Skip to content

Add make-storefront command and storefront query tags#12

Merged
felixbeer merged 8 commits into
mainfrom
codex/storefront-scaffold-issue-9
Feb 19, 2026
Merged

Add make-storefront command and storefront query tags#12
felixbeer merged 8 commits into
mainfrom
codex/storefront-scaffold-issue-9

Conversation

@felixbeer

@felixbeer felixbeer commented Feb 7, 2026

Copy link
Copy Markdown
Member

Summary

  • replace scaffold command with daugt-commerce:make-storefront to align with the existing make-pattern
  • remove stub-file publishing and source storefront templates directly from addon view files in resources/views/...
  • scaffold these templates:
    • resources/views/shop/index.antlers.html
    • resources/views/shop/_cart.antlers.html
    • resources/views/products/product.antlers.html
    • resources/views/checkout.antlers.html
  • include checkout page scaffolding (pages/checkout => checkout) in addition to shop page scaffolding (pages/shop => shop/index)
  • keep product detail routing collection-driven (/shop/product/{slug}), no routes/web.php mutation
  • refresh storefront UI to match the existing daugt-saas visual direction (clean neutral card/product layouts)
  • keep storefront query tags and add regression tests for pair + no-results rendering

Verification

  • ./vendor/bin/phpunit
  • php please daugt-commerce:make-storefront --force in playground
  • Browser (http://127.0.0.1:8991):
    • /shop renders new card-based listing and product cards are fully clickable
    • /shop/product/cooles-produkt renders updated product layout with cart + checkout CTA
    • /checkout renders order summary + embedded Stripe checkout
    • search no-results works (/shop?search=zzzz)
    • add-to-cart increments cart quantity/count in UI

Closes #9

Copilot AI review requested due to automatic review settings February 7, 2026 20:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an optional “storefront scaffolding” workflow to the Statamic Commerce addon, including a CLI scaffolding command, Antlers tag APIs for listing/category rendering, starter templates/routes stubs, and accompanying docs/tests to help teams quickly spin up a browsing storefront.

Changes:

  • Add statamic:daugt-commerce:scaffold-storefront command to publish starter storefront views and optionally wire /shop into routes/web.php.
  • Add storefront_products + storefront_categories tags to power listing/search/category chips (including pair + no-results behavior).
  • Add storefront stub templates/routes + README documentation + PHPUnit coverage.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
src/Tags/DaugtCommerceTags.php Adds storefront listing + category tags, plus filtering/sorting helpers.
src/Console/Commands/ScaffoldStorefrontCommand.php New command to publish storefront stubs and ensure routes are present.
resources/stubs/storefront/views/shop/index.antlers.html.stub Starter shop listing template using the new tags.
resources/stubs/storefront/views/products/product.antlers.html.stub Starter product detail template reusing existing cart tag.
resources/stubs/storefront/routes/web.php.stub Stub route entry for /shop.
tests/StorefrontTagsTest.php Tests storefront tag filtering, pair rendering, and no-results branch.
tests/StorefrontScaffoldCommandTest.php Tests scaffold publishing + non-overwrite behavior.
README.md Documents install + scaffolding + new storefront tags.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread README.md Outdated
Comment on lines +26 to +30
- `resources/views/shop/index.antlers.html`
- `resources/views/products/product.antlers.html` (if missing)
- `routes/web.php` entry for `/shop`

Use `--force` to overwrite scaffolded templates.

Copilot AI Feb 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

README says only resources/views/products/product.antlers.html is created "(if missing)", but the command also skips resources/views/shop/index.antlers.html when it already exists (unless --force is used). Update the wording so it accurately reflects the non-overwrite behavior for both scaffolded templates.

Suggested change
- `resources/views/shop/index.antlers.html`
- `resources/views/products/product.antlers.html` (if missing)
- `routes/web.php` entry for `/shop`
Use `--force` to overwrite scaffolded templates.
- `resources/views/shop/index.antlers.html` (created if missing)
- `resources/views/products/product.antlers.html` (created if missing)
- `routes/web.php` entry for `/shop`
Use `--force` to overwrite existing scaffolded templates.

Copilot uses AI. Check for mistakes.
<div class="md:flex">
<a href="{{ url }}" class="block md:w-72">
{{ if media }}
<img src="{{ glide :src="media[0]" width="900" height="600" fit="crop" }}" alt="{{ title }}" class="h-52 w-full object-cover md:h-full" />

Copilot AI Feb 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The glide tag parameters are using double quotes inside an HTML attribute that is also double-quoted (src="{{ glide :src="media[0]" ... }}"), which will break the rendered HTML. Use single quotes for the Antlers parameter value (or otherwise avoid nested double quotes) so the src attribute remains valid.

Suggested change
<img src="{{ glide :src="media[0]" width="900" height="600" fit="crop" }}" alt="{{ title }}" class="h-52 w-full object-cover md:h-full" />
<img src="{{ glide :src='media[0]' width="900" height="600" fit="crop" }}" alt="{{ title }}" class="h-52 w-full object-cover md:h-full" />

Copilot uses AI. Check for mistakes.
<article class="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm md:flex">
<div class="md:w-1/2">
{{ if media }}
<img src="{{ glide :src="media[0]" width="1200" height="800" fit="crop" }}" alt="{{ title }}" class="h-full w-full object-cover" />

Copilot AI Feb 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The glide tag parameters are using double quotes inside a double-quoted HTML src attribute (src="{{ glide :src="media[0]" ... }}"), which will break the markup. Switch the Antlers parameter quotes to single quotes (or otherwise avoid nested double quotes).

Suggested change
<img src="{{ glide :src="media[0]" width="1200" height="800" fit="crop" }}" alt="{{ title }}" class="h-full w-full object-cover" />
<img src="{{ glide :src='media[0]' width='1200' height='800' fit='crop' }}" alt="{{ title }}" class="h-full w-full object-cover" />

Copilot uses AI. Check for mistakes.
continue;
}

foreach ($this->normalizeCategoryValues($entry->get(ProductEntry::CATEGORIES), $taxonomy) as $slug) {

Copilot AI Feb 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

storefront_categories accepts a taxonomy param, but categoryCounts() always reads $entry->get(ProductEntry::CATEGORIES). If a caller passes a different taxonomy handle, counts will be calculated from the wrong field. Use the $taxonomy argument when reading the entry value (or otherwise document that only the categories field is supported).

Suggested change
foreach ($this->normalizeCategoryValues($entry->get(ProductEntry::CATEGORIES), $taxonomy) as $slug) {
foreach ($this->normalizeCategoryValues($entry->get($taxonomy), $taxonomy) as $slug) {

Copilot uses AI. Check for mistakes.
Comment on lines +211 to +219

$items = $terms->map(function (TermContract $term) use ($categoryCounts, $activeCategory) {
$slug = (string) $term->slug();

return [
'slug' => $slug,
'title' => (string) ($term->title() ?: $slug),
'count' => (int) ($categoryCounts[$slug] ?? 0),
'active' => $activeCategory !== '' && $activeCategory === $slug,

Copilot AI Feb 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Active category comparison is case-sensitive ($activeCategory === $slug) while filtering lowercases the category value. A query like ?category=Wellness will filter correctly but will not mark the chip as active. Normalize both $activeCategory and $slug to the same casing (and consider using the normalized slug when indexing into $categoryCounts since those keys are lowercased).

Suggested change
$items = $terms->map(function (TermContract $term) use ($categoryCounts, $activeCategory) {
$slug = (string) $term->slug();
return [
'slug' => $slug,
'title' => (string) ($term->title() ?: $slug),
'count' => (int) ($categoryCounts[$slug] ?? 0),
'active' => $activeCategory !== '' && $activeCategory === $slug,
$normalizedActiveCategory = $activeCategory === null ? '' : Str::lower($activeCategory);
$items = $terms->map(function (TermContract $term) use ($categoryCounts, $normalizedActiveCategory) {
$slug = (string) $term->slug();
$normalizedSlug = Str::lower($slug);
return [
'slug' => $slug,
'title' => (string) ($term->title() ?: $slug),
'count' => (int) ($categoryCounts[$normalizedSlug] ?? 0),
'active' => $normalizedActiveCategory !== '' && $normalizedActiveCategory === $normalizedSlug,

Copilot uses AI. Check for mistakes.
Comment on lines +424 to +432
private function urlWithQuery(array $query): string
{
if (! request()) {
$category = $query['category'] ?? null;
return is_string($category) && $category !== '' ? '?category=' . $category : '#';
}

return request()->fullUrlWithQuery($query);
}

Copilot AI Feb 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

urlWithQuery() uses if (! request()), but the request() helper returns a Request instance, so this branch is effectively dead and the fallback URL generation will never run. If you need a non-HTTP fallback (e.g. in console/test contexts), switch to a real availability check (like app()->bound('request') / app()->runningInConsole()) and ensure the query values are URL-encoded.

Copilot uses AI. Check for mistakes.
Comment on lines +255 to +257
$products = Entry::query()
->where('collection', ProductEntry::COLLECTION)
->whereStatus('published')

Copilot AI Feb 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

queryStorefrontProducts() loads all published products into memory (->get()), then filters/sorts/limits in PHP. This will become a bottleneck as the catalog grows. Prefer pushing sort + limit (and any supported filters) down into the Entry::query() builder so the stache/database does less work per request.

Suggested change
$products = Entry::query()
->where('collection', ProductEntry::COLLECTION)
->whereStatus('published')
$query = Entry::query()
->where('collection', ProductEntry::COLLECTION)
->whereStatus('published');
// Fast path: when there is no search or category filter and the default sort is used,
// push sort + limit into the query builder to avoid loading all products into memory.
if (
($search === null || $search === '')
&& ($category === null || $category === '')
&& $sort === 'updated_desc'
) {
$query = $query->orderBy('updated_at', 'desc');
if ($limit !== null && $limit > 0) {
$query = $query->limit($limit);
}
$products = $query
->get()
->filter(fn ($entry) => $entry instanceof ProductEntry);
return $products->values();
}
// Fallback: preserve existing behavior (filter/sort/limit in PHP) for
// cases with search/category filters or non-default sort modes.
$products = $query

Copilot uses AI. Check for mistakes.

$existing = File::get($routesPath);

if (str_contains($existing, "Route::statamic('shop', 'shop/index')")) {

Copilot AI Feb 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Route presence detection is a simple str_contains on one exact string. If a project already has the shop route with different quoting/spacing or additional chaining (eg ->name(...)), this check will miss it and the command will append a duplicate route. Consider using a more flexible match (regex) or parsing the file to detect an existing Route::statamic for the shop URI.

Suggested change
if (str_contains($existing, "Route::statamic('shop', 'shop/index')")) {
if (preg_match('/Route::statamic\s*\(\s*[\'"]shop[\'"]\s*,/m', $existing)) {

Copilot uses AI. Check for mistakes.
@felixbeer felixbeer changed the title Add storefront scaffolding command and reusable listing tags Add make-storefront command and storefront query tags Feb 7, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread resources/views/shop/_cart.antlers.html Outdated
Comment on lines +2 to +12
#daugt-commerce-cart, #daugt-commerce-cart::backdrop {
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete, opacity 0.5s;
opacity: 0;

&[open], &[open]::backdrop {
opacity: 1;

@starting-style {
opacity: 0;
}
}

Copilot AI Feb 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The <style> block uses CSS nesting (&[open], &[open]::backdrop) and @starting-style inside a raw <style> tag. Unless you explicitly target browsers with CSS nesting support, these rules may be ignored and the dialog animation/backdrop styling won’t work. Consider rewriting the selectors without nesting (explicit #daugt-commerce-cart[open] / #daugt-commerce-cart[open]::backdrop) and avoiding @starting-style if you need broader compatibility.

Suggested change
#daugt-commerce-cart, #daugt-commerce-cart::backdrop {
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete, opacity 0.5s;
opacity: 0;
&[open], &[open]::backdrop {
opacity: 1;
@starting-style {
opacity: 0;
}
}
#daugt-commerce-cart,
#daugt-commerce-cart::backdrop {
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete, opacity 0.5s;
opacity: 0;
}
#daugt-commerce-cart[open],
#daugt-commerce-cart[open]::backdrop {
opacity: 1;

Copilot uses AI. Check for mistakes.
Comment on lines +206 to +210
$taxonomy = $this->stringParam('taxonomy') ?: ProductEntry::CATEGORIES;
$activeCategory = $this->stringParam('category');
$products = $this->queryStorefrontProducts();
$categoryCounts = $this->categoryCounts($products, $taxonomy);
$terms = $this->termsForTaxonomy($taxonomy);

Copilot AI Feb 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

storefront_categories exposes a taxonomy param, but the implementation only reads category values from ProductEntry::CATEGORIES and storefrontProducts()/entryHasCategory() also hardcode ProductEntry::CATEGORIES. If a consumer sets taxonomy to anything else, term counts and filtering will silently break (counts end up 0 / filters never match). Either remove the taxonomy param or thread it through consistently by reading $entry->get($taxonomy) and using that taxonomy when normalizing and matching category slugs.

Copilot uses AI. Check for mistakes.
Comment on lines +255 to +260
$products = Entry::query()
->where('collection', ProductEntry::COLLECTION)
->whereStatus('published')
->get()
->filter(fn ($entry) => $entry instanceof ProductEntry);

Copilot AI Feb 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

queryStorefrontProducts() calls Entry::query()->...->get() and then does all search/category filtering + sorting in PHP. On larger catalogs this forces every published product into memory on each request. If Statamic's entry query supports it, push filters/sort/limit into the query layer (e.g. constrain by taxonomy and apply sort/limit before get()), and only fall back to in-memory filtering when necessary.

Copilot uses AI. Check for mistakes.
@felixbeer felixbeer merged commit 0cd0909 into main Feb 19, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Provide optional storefront scaffolding (listing/detail/search/filter)

2 participants