Add make-storefront command and storefront query tags#12
Conversation
There was a problem hiding this comment.
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-storefrontcommand to publish starter storefront views and optionally wire/shopintoroutes/web.php. - Add
storefront_products+storefront_categoriestags 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.
| - `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. |
There was a problem hiding this comment.
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.
| - `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. |
| <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" /> |
There was a problem hiding this comment.
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.
| <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" /> |
| <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" /> |
There was a problem hiding this comment.
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).
| <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" /> |
| continue; | ||
| } | ||
|
|
||
| foreach ($this->normalizeCategoryValues($entry->get(ProductEntry::CATEGORIES), $taxonomy) as $slug) { |
There was a problem hiding this comment.
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).
| foreach ($this->normalizeCategoryValues($entry->get(ProductEntry::CATEGORIES), $taxonomy) as $slug) { | |
| foreach ($this->normalizeCategoryValues($entry->get($taxonomy), $taxonomy) as $slug) { |
|
|
||
| $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, |
There was a problem hiding this comment.
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).
| $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, |
| private function urlWithQuery(array $query): string | ||
| { | ||
| if (! request()) { | ||
| $category = $query['category'] ?? null; | ||
| return is_string($category) && $category !== '' ? '?category=' . $category : '#'; | ||
| } | ||
|
|
||
| return request()->fullUrlWithQuery($query); | ||
| } |
There was a problem hiding this comment.
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.
| $products = Entry::query() | ||
| ->where('collection', ProductEntry::COLLECTION) | ||
| ->whereStatus('published') |
There was a problem hiding this comment.
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.
| $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 |
|
|
||
| $existing = File::get($routesPath); | ||
|
|
||
| if (str_contains($existing, "Route::statamic('shop', 'shop/index')")) { |
There was a problem hiding this comment.
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.
| if (str_contains($existing, "Route::statamic('shop', 'shop/index')")) { | |
| if (preg_match('/Route::statamic\s*\(\s*[\'"]shop[\'"]\s*,/m', $existing)) { |
There was a problem hiding this comment.
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.
| #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; | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| #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; |
| $taxonomy = $this->stringParam('taxonomy') ?: ProductEntry::CATEGORIES; | ||
| $activeCategory = $this->stringParam('category'); | ||
| $products = $this->queryStorefrontProducts(); | ||
| $categoryCounts = $this->categoryCounts($products, $taxonomy); | ||
| $terms = $this->termsForTaxonomy($taxonomy); |
There was a problem hiding this comment.
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.
| $products = Entry::query() | ||
| ->where('collection', ProductEntry::COLLECTION) | ||
| ->whereStatus('published') | ||
| ->get() | ||
| ->filter(fn ($entry) => $entry instanceof ProductEntry); | ||
|
|
There was a problem hiding this comment.
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.
Summary
daugt-commerce:make-storefrontto align with the existing make-patternresources/views/...resources/views/shop/index.antlers.htmlresources/views/shop/_cart.antlers.htmlresources/views/products/product.antlers.htmlresources/views/checkout.antlers.htmlpages/checkout=>checkout) in addition to shop page scaffolding (pages/shop=>shop/index)/shop/product/{slug}), noroutes/web.phpmutationdaugt-saasvisual direction (clean neutral card/product layouts)Verification
./vendor/bin/phpunitphp please daugt-commerce:make-storefront --forcein playground/shoprenders new card-based listing and product cards are fully clickable/shop/product/cooles-produktrenders updated product layout with cart + checkout CTA/checkoutrenders order summary + embedded Stripe checkout/shop?search=zzzz)Closes #9