diff --git a/README.md b/README.md
index 9b37b55..c61d073 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,51 @@
# Commerce
-> Commerce is a Statamic addon starter.
+Statamic commerce addon with product sync, cart/checkout tags, Stripe webhooks, and optional shop scaffolding.
-## Features
+## Install
-Planned features:
+```bash
+composer require daugtcom/statamic-commerce
+```
-- TBD
+## Commands
-## How to Install
+### Install collections/blueprints/taxonomies
-You can install this addon via Composer:
+```bash
+php please daugt-commerce:install
+```
-``` bash
-composer require daugtcom/statamic-commerce
+### Make optional shop (listing/detail/search/filter/cart)
+
+```bash
+php please daugt-commerce:make-shop
```
-## How to Use
+This creates:
+- `resources/views/shop/index.antlers.html`
+- `resources/views/shop/_cart.antlers.html`
+- `resources/views/products/product.antlers.html`
+- `resources/views/checkout.antlers.html`
+- optional `pages/shop` entry using template `shop/index` (unless `--without-shop-page`)
+- optional `pages/checkout` entry using template `checkout` (unless `--without-checkout-page`)
+
+Add `{{ partial:shop/cart }}` once in your main layout to mount the cart drawer globally.
+Shop templates are copied from addon source views in `resources/views/...` (shop index, cart partial, product detail).
+
+Use `--force` to overwrite existing shop files or to re-ensure the generated shop page entries.
+
+Note: product detail routing stays collection-driven via the products collection route (`/shop/product/{slug}`), not by writing to `routes/web.php`.
+
+## Shop Tags
+
+- `{{ daugt_commerce:shop_products }}`:
+ filtered product loop (supports `search`, `category`, `sort`, `limit` via params or query string)
+- `{{ daugt_commerce:shop_categories }}`:
+ category chips with counts and active state
-Add usage docs once the first features land.
+Backward compatibility:
+- `statamic:daugt-commerce:make-store` remains available as an alias.
+- `statamic:daugt-commerce:make-storefront` remains available as an alias.
+- `store_products` and `store_categories` tags remain available as aliases.
+- `storefront_products` and `storefront_categories` tags remain available as aliases.
diff --git a/lang/de.json b/lang/de.json
index 574bb80..4a1f01e 100644
--- a/lang/de.json
+++ b/lang/de.json
@@ -161,6 +161,26 @@
"daugt-commerce::cart.remove": "Entfernen",
"daugt-commerce::cart.quantity": "Anzahl: :count",
"daugt-commerce::cart.go-to-checkout": "Zahlungspflichtig bestellen",
+ "daugt-commerce::cart.close_panel": "Panel schließen",
+ "daugt-commerce::shop.title": "Shop",
+ "daugt-commerce::shop.subtitle": "Stöbere Produkte und lege sie in den Warenkorb.",
+ "daugt-commerce::shop.search": "Suchen",
+ "daugt-commerce::shop.search_placeholder": "Produkte suchen",
+ "daugt-commerce::shop.reset": "Zurücksetzen",
+ "daugt-commerce::shop.all_categories": "Alle Kategorien",
+ "daugt-commerce::shop.all": "Alle",
+ "daugt-commerce::shop.no_products": "Keine Produkte für die aktuellen Filter gefunden.",
+ "daugt-commerce::shop.view_product": "Produkt ansehen",
+ "daugt-commerce::shop.back_to_shop": "Zurück zum Shop",
+ "daugt-commerce::shop.buy_external": "Extern kaufen",
+ "daugt-commerce::shop.add_to_cart": "In den Warenkorb",
+ "daugt-commerce::shop.shipping_product": "Versandprodukt",
+ "daugt-commerce::shop.recurring_billing": "Wiederkehrende Abrechnung",
+ "daugt-commerce::shop.description": "Beschreibung",
+ "daugt-commerce::checkout.title": "Checkout",
+ "daugt-commerce::checkout.subtitle": "Schließe deine Bestellung sicher mit Stripe ab.",
+ "daugt-commerce::checkout.continue_shopping": "Weiter einkaufen",
+ "daugt-commerce::checkout.empty": "Dein Warenkorb ist leer.",
"daugt-commerce::address.title": "Adresse",
"daugt-commerce::address.fields.name": "Name",
"daugt-commerce::address.fields.phone": "Telefon",
diff --git a/lang/en.json b/lang/en.json
index af65b47..54ab9ed 100644
--- a/lang/en.json
+++ b/lang/en.json
@@ -161,6 +161,26 @@
"daugt-commerce::cart.remove": "Remove",
"daugt-commerce::cart.quantity": "Qty :count",
"daugt-commerce::cart.go-to-checkout": "Go to checkout",
+ "daugt-commerce::cart.close_panel": "Close panel",
+ "daugt-commerce::shop.title": "Shop",
+ "daugt-commerce::shop.subtitle": "Browse products and add what you need to the cart.",
+ "daugt-commerce::shop.search": "Search",
+ "daugt-commerce::shop.search_placeholder": "Search products",
+ "daugt-commerce::shop.reset": "Reset",
+ "daugt-commerce::shop.all_categories": "All categories",
+ "daugt-commerce::shop.all": "All",
+ "daugt-commerce::shop.no_products": "No products found for your current filters.",
+ "daugt-commerce::shop.view_product": "View product",
+ "daugt-commerce::shop.back_to_shop": "Back to shop",
+ "daugt-commerce::shop.buy_external": "Buy external",
+ "daugt-commerce::shop.add_to_cart": "Add to cart",
+ "daugt-commerce::shop.shipping_product": "Shipping product",
+ "daugt-commerce::shop.recurring_billing": "Recurring billing",
+ "daugt-commerce::shop.description": "Description",
+ "daugt-commerce::checkout.title": "Checkout",
+ "daugt-commerce::checkout.subtitle": "Complete your order securely with Stripe.",
+ "daugt-commerce::checkout.continue_shopping": "Continue shopping",
+ "daugt-commerce::checkout.empty": "Your cart is empty.",
"daugt-commerce::address.title": "Address",
"daugt-commerce::address.fields.name": "Name",
"daugt-commerce::address.fields.phone": "Phone",
diff --git a/resources/css/addon.css b/resources/css/addon.css
index 47429f9..1249799 100644
--- a/resources/css/addon.css
+++ b/resources/css/addon.css
@@ -1,6 +1 @@
-/*
-TODO: Revert back to statamic tailwind config for release (less CSS shipped)
@import "@statamic/cms/tailwind.css";
- */
-
-@import "tailwindcss";
diff --git a/resources/views/checkout.antlers.html b/resources/views/checkout.antlers.html
new file mode 100644
index 0000000..62d2ea2
--- /dev/null
+++ b/resources/views/checkout.antlers.html
@@ -0,0 +1,22 @@
+
+
+
+ {{ if daugt_commerce:cart_count == "0" }}
+
+ {{ trans:daugt-commerce::checkout.empty }}
+
+ {{ else }}
+
+ {{ daugt_commerce:checkout return_url="{ current_url }" }}
+
+ {{ /if }}
+
diff --git a/resources/views/products/product.antlers.html b/resources/views/products/product.antlers.html
new file mode 100644
index 0000000..a3e73ac
--- /dev/null
+++ b/resources/views/products/product.antlers.html
@@ -0,0 +1,56 @@
+
+
+ ← {{ trans:daugt-commerce::shop.back_to_shop }}
+
+
+
+
+
+ {{ if media[0] }}
+

+ {{ else }}
+
+ {{ /if }}
+
+
+
+
+
{{ title }}
+
+
+
{{ daugt_commerce:money :value="price" }}
+
+
+
+ {{ if external_product && external_product_url }}
+
+ {{ trans:daugt-commerce::shop.buy_external }}
+
+ {{ else }}
+ {{ daugt_commerce:add_to_cart redirect="{ current_url }" }}
+
+ {{ /daugt_commerce:add_to_cart }}
+ {{ /if }}
+
+
+
+ {{ if shipping }}
+ - {{ trans:daugt-commerce::shop.shipping_product }}
+ {{ /if }}
+
+ {{ if billing_type == "recurring" }}
+ - {{ trans:daugt-commerce::shop.recurring_billing }}
+ {{ /if }}
+
+
+
+ {{ if description }}
+
+
{{ trans:daugt-commerce::shop.description }}
+
{{ description }}
+
+ {{ /if }}
+
+
diff --git a/resources/views/shop/_cart.antlers.html b/resources/views/shop/_cart.antlers.html
new file mode 100644
index 0000000..cd4e9a4
--- /dev/null
+++ b/resources/views/shop/_cart.antlers.html
@@ -0,0 +1,104 @@
+
+
+
diff --git a/resources/views/shop/index.antlers.html b/resources/views/shop/index.antlers.html
new file mode 100644
index 0000000..d01fada
--- /dev/null
+++ b/resources/views/shop/index.antlers.html
@@ -0,0 +1,69 @@
+
+
+
{{ trans:daugt-commerce::shop.title }}
+
{{ trans:daugt-commerce::shop.subtitle }}
+
+
+
+
+
+
+
+
diff --git a/src/Console/Commands/MakeShopCommand.php b/src/Console/Commands/MakeShopCommand.php
new file mode 100644
index 0000000..99d3134
--- /dev/null
+++ b/src/Console/Commands/MakeShopCommand.php
@@ -0,0 +1,164 @@
+output->write((new AsciiArt())());
+
+ $force = (bool) $this->option('force');
+
+ foreach ($this->shopTemplateMap() as $source => $destination) {
+ $this->publishTemplate($source, $destination, $force);
+ }
+
+ $this->ensureShopPages($force);
+
+ $this->newLine();
+ $this->info('Shop templates published successfully.');
+ $this->line('- Product detail route is managed by the products collection route.');
+ $this->line('- Listing route: /shop');
+ $this->line('- Checkout route: /checkout');
+ $this->line('- Cart drawer partial: resources/views/shop/_cart.antlers.html');
+ $this->line('- Include {{ partial:shop/cart }} once in your layout (for example in layout.antlers.html).');
+
+ return self::SUCCESS;
+ }
+
+ private function publishTemplate(string $sourcePath, string $destinationPath, bool $force): void
+ {
+ if (! File::exists($sourcePath)) {
+ throw new \RuntimeException(sprintf(
+ 'Missing shop template source at [%s].',
+ $sourcePath
+ ));
+ }
+
+ if (File::exists($destinationPath) && ! $force) {
+ $this->line("Skipped existing file: {$this->relativePath($destinationPath)}");
+ return;
+ }
+
+ File::ensureDirectoryExists(dirname($destinationPath));
+ File::copy($sourcePath, $destinationPath);
+
+ $this->info("Published: {$this->relativePath($destinationPath)}");
+ }
+
+ private function ensureShopPages(bool $force): void
+ {
+ $collectionHandle = (string) $this->option('page-collection');
+
+ if (! (bool) $this->option('without-shop-page')) {
+ $this->ensurePageEntry(
+ $collectionHandle,
+ (string) $this->option('page-slug'),
+ 'shop/index',
+ $force
+ );
+ }
+
+ if (! (bool) $this->option('without-checkout-page')) {
+ $this->ensurePageEntry(
+ $collectionHandle,
+ (string) $this->option('checkout-slug'),
+ 'checkout',
+ $force
+ );
+ }
+ }
+
+ private function ensurePageEntry(
+ string $collectionHandle,
+ string $slugOption,
+ string $template,
+ bool $force
+ ): void
+ {
+ $slug = trim(Str::slug($slugOption), '/');
+
+ if ($slug === '') {
+ $this->warn("Page slug is empty after normalization for template [{$template}], skipping page creation.");
+ return;
+ }
+
+ $collection = Collection::find($collectionHandle);
+ if (! $collection) {
+ $this->warn("Collection [{$collectionHandle}] not found. Create a page manually that uses template [{$template}].");
+ return;
+ }
+
+ $existing = Entry::query()
+ ->where('collection', $collectionHandle)
+ ->where('slug', $slug)
+ ->first();
+
+ if ($existing && ! $force) {
+ $this->line("Skipped existing page entry: {$collectionHandle}/{$slug}");
+ return;
+ }
+
+ $entry = $existing ?: Entry::make()
+ ->collection($collectionHandle)
+ ->slug($slug);
+
+ $title = Str::title(str_replace(['-', '_'], ' ', $slug));
+
+ $entry->published(true);
+ $entry->data(array_merge($entry->data()->all(), [
+ 'title' => $title,
+ 'template' => $template,
+ ]));
+ $entry->save();
+
+ $this->info("Ensured page entry: {$collectionHandle}/{$slug} ({$template})");
+ }
+
+ private function shopTemplateMap(): array
+ {
+ return [
+ $this->templateSourcePath('shop/index.antlers.html') => resource_path('views/shop/index.antlers.html'),
+ $this->templateSourcePath('shop/_cart.antlers.html') => resource_path('views/shop/_cart.antlers.html'),
+ $this->templateSourcePath('products/product.antlers.html') => resource_path('views/products/product.antlers.html'),
+ $this->templateSourcePath('checkout.antlers.html') => resource_path('views/checkout.antlers.html'),
+ ];
+ }
+
+ private function templateSourcePath(string $relativePath): string
+ {
+ return __DIR__ . '/../../../resources/views/' . ltrim($relativePath, '/');
+ }
+
+ private function relativePath(string $absolutePath): string
+ {
+ return ltrim(str_replace(base_path(), '', $absolutePath), '/');
+ }
+}
diff --git a/src/Tags/DaugtCommerceTags.php b/src/Tags/DaugtCommerceTags.php
index e5114fe..e48d29c 100644
--- a/src/Tags/DaugtCommerceTags.php
+++ b/src/Tags/DaugtCommerceTags.php
@@ -3,9 +3,17 @@
namespace Daugt\Commerce\Tags;
use Daugt\Commerce\Carts\CartManager;
+use Daugt\Commerce\Entries\ProductEntry;
use Daugt\Commerce\Payments\Contracts\PaymentProviderExtension;
use Daugt\Commerce\Payments\PaymentProviderResolver;
use Daugt\Commerce\Support\AddonSettings;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Str;
+use Statamic\Contracts\Entries\Entry as EntryContract;
+use Statamic\Contracts\Taxonomies\Term as TermContract;
+use Statamic\Facades\Entry;
+use Statamic\Facades\Term;
use Statamic\Tags\Tags;
class DaugtCommerceTags extends Tags
@@ -166,6 +174,84 @@ public function checkout(): string
return view($definition['view'], $data)->render();
}
+ public function shopProducts(): string|array
+ {
+ $products = $this->queryShopProducts(
+ $this->stringParam('search'),
+ $this->stringParam('category'),
+ $this->stringParam('sort') ?: 'updated_desc',
+ $this->intParam('limit')
+ );
+
+ $items = $products->values()->all();
+
+ if (! $this->isPair) {
+ return $this->aliasedResult($items);
+ }
+
+ if ($items === []) {
+ return $this->parseNoResults();
+ }
+
+ return (string) $this->parseLoop(
+ array_map(
+ fn (EntryContract $entry) => $this->shopProductRow($entry),
+ $items
+ )
+ );
+ }
+
+ public function storefrontProducts(): string|array
+ {
+ return $this->shopProducts();
+ }
+
+ public function storeProducts(): string|array
+ {
+ return $this->shopProducts();
+ }
+
+ public function shopCategories(): string|array
+ {
+ $taxonomy = $this->stringParam('taxonomy') ?: ProductEntry::CATEGORIES;
+ $activeCategory = $this->stringParam('category');
+ $products = $this->queryShopProducts();
+ $categoryCounts = $this->categoryCounts($products, $taxonomy);
+ $terms = $this->termsForTaxonomy($taxonomy);
+
+ $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,
+ 'url' => $this->urlWithQuery(['category' => $slug]),
+ ];
+ })->values()->all();
+
+ if (! $this->isPair) {
+ return $this->aliasedResult($items);
+ }
+
+ if ($items === []) {
+ return $this->parseNoResults();
+ }
+
+ return $this->parseLoop($items);
+ }
+
+ public function storefrontCategories(): string|array
+ {
+ return $this->shopCategories();
+ }
+
+ public function storeCategories(): string|array
+ {
+ return $this->shopCategories();
+ }
+
private function productId(): ?string
{
$param = $this->params->get('product_id')
@@ -180,6 +266,280 @@ private function productId(): ?string
return is_string($contextId) && $contextId !== '' ? $contextId : null;
}
+ private function queryShopProducts(
+ ?string $search = null,
+ ?string $category = null,
+ string $sort = 'updated_desc',
+ ?int $limit = null
+ ): Collection {
+ $products = Entry::query()
+ ->where('collection', ProductEntry::COLLECTION)
+ ->whereStatus('published')
+ ->get()
+ ->filter(fn ($entry) => $entry instanceof EntryContract);
+
+ if ($search !== null && $search !== '') {
+ $needle = Str::lower($search);
+ $products = $products->filter(function (EntryContract $entry) use ($needle) {
+ $title = Str::lower((string) ($entry->get(ProductEntry::TITLE) ?: ''));
+ $description = Str::lower($this->searchableDescription($entry->get(ProductEntry::DESCRIPTION)));
+
+ return Str::contains($title, $needle) || Str::contains($description, $needle);
+ });
+ }
+
+ if ($category !== null && $category !== '') {
+ $products = $products->filter(
+ fn (EntryContract $entry) => $this->entryHasCategory($entry, $category)
+ );
+ }
+
+ $products = $this->sortProducts($products, $sort);
+
+ if ($limit !== null && $limit > 0) {
+ $products = $products->take($limit);
+ }
+
+ return $products->values();
+ }
+
+ private function sortProducts(Collection $products, string $sort): Collection
+ {
+ return match ($sort) {
+ 'price_asc' => $products->sortBy(fn (EntryContract $entry) => $this->entryPrice($entry) ?? INF),
+ 'price_desc' => $products->sortByDesc(fn (EntryContract $entry) => $this->entryPrice($entry) ?? -INF),
+ 'title_asc' => $products->sortBy(fn (EntryContract $entry) => Str::lower((string) ($entry->get(ProductEntry::TITLE) ?: ''))),
+ 'title_desc' => $products->sortByDesc(fn (EntryContract $entry) => Str::lower((string) ($entry->get(ProductEntry::TITLE) ?: ''))),
+ 'updated_asc' => $products->sortBy(fn (EntryContract $entry) => $this->entryTimestamp($entry)),
+ default => $products->sortByDesc(fn (EntryContract $entry) => $this->entryTimestamp($entry)),
+ };
+ }
+
+ private function entryTimestamp(EntryContract $entry): int
+ {
+ $candidates = [
+ $entry->get('updated_at'),
+ $entry->get('created_at'),
+ $entry->get('date'),
+ ];
+
+ foreach ($candidates as $candidate) {
+ if ($candidate instanceof \DateTimeInterface) {
+ return $candidate->getTimestamp();
+ }
+
+ if (is_numeric($candidate)) {
+ return (int) $candidate;
+ }
+
+ if (is_string($candidate) && $candidate !== '') {
+ $timestamp = strtotime($candidate);
+ if ($timestamp !== false) {
+ return $timestamp;
+ }
+ }
+ }
+
+ return 0;
+ }
+
+ private function searchableDescription(mixed $description): string
+ {
+ if (is_string($description)) {
+ return $description;
+ }
+
+ if (is_array($description)) {
+ return (string) json_encode($description);
+ }
+
+ return '';
+ }
+
+ private function shopProductRow(EntryContract $entry): array
+ {
+ $media = $this->shopProductMedia($entry);
+ $descriptionPreview = trim(strip_tags($this->searchableDescription($entry->get(ProductEntry::DESCRIPTION))));
+
+ return [
+ 'id' => (string) $entry->id(),
+ 'slug' => (string) $entry->slug(),
+ 'url' => (string) $entry->url(),
+ 'title' => (string) ($entry->get(ProductEntry::TITLE) ?? ''),
+ 'description' => $entry->get(ProductEntry::DESCRIPTION),
+ 'description_preview' => Str::limit($descriptionPreview, 120),
+ 'price' => $this->entryPrice($entry),
+ 'media' => $media,
+ 'image' => $media[0] ?? null,
+ 'external_product' => $this->entryExternalProduct($entry),
+ 'external_product_url' => $this->entryExternalProductUrl($entry),
+ ];
+ }
+
+ private function shopProductMedia(EntryContract $entry): array
+ {
+ try {
+ $augmented = $entry->augmentedValue(ProductEntry::MEDIA);
+ if ($augmented !== null) {
+ $value = $augmented->value();
+
+ if ($value instanceof \Statamic\Assets\OrderedQueryBuilder) {
+ $value = $value->get();
+ }
+
+ if ($value instanceof Collection) {
+ return $value->values()->all();
+ }
+
+ return Arr::wrap($value);
+ }
+ } catch (\Throwable) {
+ // Some test environments do not provide an assets container.
+ }
+
+ return Arr::wrap($entry->get(ProductEntry::MEDIA));
+ }
+
+ private function entryHasCategory(EntryContract $entry, string $category): bool
+ {
+ $normalizedCategory = Str::lower($category);
+ $terms = $this->normalizeCategoryValues($entry->get(ProductEntry::CATEGORIES), ProductEntry::CATEGORIES);
+
+ return in_array($normalizedCategory, $terms, true);
+ }
+
+ private function categoryCounts(Collection $products, string $taxonomy): array
+ {
+ $counts = [];
+
+ foreach ($products as $entry) {
+ if (! $entry instanceof EntryContract) {
+ continue;
+ }
+
+ foreach ($this->normalizeCategoryValues($entry->get(ProductEntry::CATEGORIES), $taxonomy) as $slug) {
+ $counts[$slug] = ($counts[$slug] ?? 0) + 1;
+ }
+ }
+
+ return $counts;
+ }
+
+ private function normalizeCategoryValues(mixed $value, string $taxonomy): array
+ {
+ $slugs = [];
+
+ foreach (Arr::wrap($value) as $item) {
+ if ($item instanceof TermContract) {
+ $slug = (string) $item->slug();
+ if ($slug !== '') {
+ $slugs[] = Str::lower($slug);
+ }
+ continue;
+ }
+
+ if (! is_string($item) || $item === '') {
+ continue;
+ }
+
+ $parts = explode('::', $item, 2);
+ $slug = count($parts) === 2 ? $parts[1] : $parts[0];
+ if ($slug === '') {
+ continue;
+ }
+
+ if (count($parts) === 2 && $parts[0] !== $taxonomy) {
+ continue;
+ }
+
+ $slugs[] = Str::lower($slug);
+ }
+
+ return array_values(array_unique($slugs));
+ }
+
+ private function entryPrice(EntryContract $entry): ?float
+ {
+ if (method_exists($entry, 'price')) {
+ $value = $entry->price();
+
+ return $value !== null ? (float) $value : null;
+ }
+
+ $value = $entry->get(ProductEntry::PRICE);
+
+ return $value !== null ? (float) $value : null;
+ }
+
+ private function entryExternalProduct(EntryContract $entry): bool
+ {
+ if (method_exists($entry, 'externalProduct')) {
+ return (bool) $entry->externalProduct();
+ }
+
+ return (bool) $entry->get(ProductEntry::EXTERNAL_PRODUCT);
+ }
+
+ private function entryExternalProductUrl(EntryContract $entry): ?string
+ {
+ if (method_exists($entry, 'externalProductUrl')) {
+ $value = $entry->externalProductUrl();
+
+ return $value !== null ? (string) $value : null;
+ }
+
+ $value = $entry->get(ProductEntry::EXTERNAL_PRODUCT_URL);
+
+ return $value !== null ? (string) $value : null;
+ }
+
+ private function termsForTaxonomy(string $taxonomy): Collection
+ {
+ $terms = Term::query()
+ ->where('taxonomy', $taxonomy)
+ ->get()
+ ->filter(fn ($term) => $term instanceof TermContract)
+ ->sortBy(fn (TermContract $term) => Str::lower((string) ($term->title() ?: $term->slug())))
+ ->values();
+
+ return $terms;
+ }
+
+ private function urlWithQuery(array $query): string
+ {
+ if (! request()) {
+ $category = $query['category'] ?? null;
+ return is_string($category) && $category !== '' ? '?category=' . $category : '#';
+ }
+
+ return request()->fullUrlWithQuery($query);
+ }
+
+ private function stringParam(string $key): ?string
+ {
+ $value = $this->params->get($key);
+ if (! is_string($value) || trim($value) === '') {
+ $queryValue = request()->query($key);
+ if (is_string($queryValue) && trim($queryValue) !== '') {
+ return trim($queryValue);
+ }
+
+ return null;
+ }
+
+ return trim($value);
+ }
+
+ private function intParam(string $key): ?int
+ {
+ $value = $this->params->get($key);
+ if ($value === null || $value === '') {
+ $value = request()->query($key);
+ }
+
+ return is_numeric($value) ? (int) $value : null;
+ }
+
private function activeExtension(): ?PaymentProviderExtension
{
$resolver = app(PaymentProviderResolver::class);
diff --git a/tests/MakeStorefrontCommandTest.php b/tests/MakeStorefrontCommandTest.php
new file mode 100644
index 0000000..e0b85ce
--- /dev/null
+++ b/tests/MakeStorefrontCommandTest.php
@@ -0,0 +1,99 @@
+artisan('statamic:daugt-commerce:make-shop --without-shop-page --without-checkout-page')->assertExitCode(0);
+
+ $this->assertFileExists($shopView);
+ $this->assertFileExists($cartPartial);
+ $this->assertFileExists($productView);
+ $this->assertFileExists($checkoutView);
+ $this->assertSame(
+ rtrim(File::get($this->sourceTemplate('shop/index.antlers.html'))),
+ rtrim(File::get($shopView))
+ );
+ $this->assertSame(
+ rtrim(File::get($this->sourceTemplate('shop/_cart.antlers.html'))),
+ rtrim(File::get($cartPartial))
+ );
+ $this->assertSame(
+ rtrim(File::get($this->sourceTemplate('products/product.antlers.html'))),
+ rtrim(File::get($productView))
+ );
+ $this->assertSame(
+ rtrim(File::get($this->sourceTemplate('checkout.antlers.html'))),
+ rtrim(File::get($checkoutView))
+ );
+ }
+
+ public function test_make_shop_does_not_overwrite_existing_files_without_force(): void
+ {
+ $productView = resource_path('views/products/product.antlers.html');
+
+ File::ensureDirectoryExists(dirname($productView));
+ File::put($productView, 'custom product template');
+
+ $this->artisan('statamic:daugt-commerce:make-shop --without-shop-page --without-checkout-page')->assertExitCode(0);
+
+ $this->assertSame('custom product template', File::get($productView));
+ }
+
+ public function test_make_shop_can_ensure_shop_and_checkout_page_entries(): void
+ {
+ $pages = CollectionFacade::make('pages');
+ $pages->title('Pages');
+ $pages->save();
+
+ $this->artisan('statamic:daugt-commerce:make-shop')->assertExitCode(0);
+
+ $shopEntry = Entry::query()
+ ->where('collection', 'pages')
+ ->where('slug', 'shop')
+ ->first();
+
+ $checkoutEntry = Entry::query()
+ ->where('collection', 'pages')
+ ->where('slug', 'checkout')
+ ->first();
+
+ $this->assertNotNull($shopEntry);
+ $this->assertNotNull($checkoutEntry);
+ $this->assertSame('shop/index', $shopEntry->get('template'));
+ $this->assertSame('checkout', $checkoutEntry->get('template'));
+ }
+
+ public function test_make_storefront_command_alias_still_works(): void
+ {
+ $this->artisan('statamic:daugt-commerce:make-storefront --without-shop-page --without-checkout-page')
+ ->assertExitCode(0);
+ }
+
+ public function test_make_store_command_alias_still_works(): void
+ {
+ $this->artisan('statamic:daugt-commerce:make-store --without-shop-page --without-checkout-page')
+ ->assertExitCode(0);
+ }
+
+ private function sourceTemplate(string $relativePath): string
+ {
+ return dirname(__DIR__) . '/resources/views/' . ltrim($relativePath, '/');
+ }
+}
diff --git a/tests/StorefrontTagsTest.php b/tests/StorefrontTagsTest.php
new file mode 100644
index 0000000..870b449
--- /dev/null
+++ b/tests/StorefrontTagsTest.php
@@ -0,0 +1,173 @@
+entryClass(ProductEntry::class);
+ $collection->save();
+
+ $taxonomy = Taxonomy::make('categories');
+ $taxonomy->title('Categories');
+ $taxonomy->save();
+
+ $this->makeTerm('wellness', 'Wellness');
+ $this->makeTerm('tech', 'Tech');
+ }
+
+ public function test_shop_products_filters_by_search_and_category(): void
+ {
+ $this->makeProduct('prod-1', 'Breath Course', ['categories::wellness'], true, 'Breathwork basics');
+ $this->makeProduct('prod-2', 'Coding Mastery', ['categories::tech'], true, 'Advanced coding');
+ $this->makeProduct('prod-3', 'Draft Product', ['categories::wellness'], false, 'Not visible');
+
+ request()->query->replace([
+ 'search' => 'breath',
+ 'category' => 'wellness',
+ ]);
+
+ $result = $this->makeTags()->shopProducts();
+
+ $this->assertIsArray($result);
+ $this->assertCount(1, $result);
+ $this->assertInstanceOf(ProductEntry::class, $result[0]);
+ $this->assertSame('prod-1', (string) $result[0]->id());
+ }
+
+ public function test_shop_categories_returns_counts_and_active_state(): void
+ {
+ $this->makeProduct('prod-4', 'Morning Practice', ['categories::wellness'], true);
+ $this->makeProduct('prod-5', 'Code Lab', ['categories::tech'], true);
+ $this->makeProduct('prod-6', 'Wellness Plus', ['categories::wellness'], true);
+
+ request()->query->replace([
+ 'category' => 'wellness',
+ ]);
+
+ $rows = $this->makeTags()->shopCategories();
+
+ $this->assertIsArray($rows);
+
+ $wellness = collect($rows)->firstWhere('slug', 'wellness');
+ $tech = collect($rows)->firstWhere('slug', 'tech');
+
+ $this->assertNotNull($wellness);
+ $this->assertNotNull($tech);
+
+ $this->assertSame(2, $wellness['count']);
+ $this->assertTrue($wellness['active']);
+ $this->assertSame(1, $tech['count']);
+ $this->assertFalse($tech['active']);
+ }
+
+ public function test_shop_products_tag_pair_renders_product_context(): void
+ {
+ $this->makeProduct('prod-pair-1', 'Pair Product', ['categories::wellness'], true, 'Pair description');
+
+ $output = Antlers::parse(
+ '{{ daugt_commerce:shop_products }}{{ id }}|{{ title }}{{ /daugt_commerce:shop_products }}'
+ );
+
+ $this->assertSame('prod-pair-1|Pair Product', trim($output));
+ }
+
+ public function test_shop_products_tag_pair_renders_no_results_block(): void
+ {
+ $output = Antlers::parse(
+ '{{ daugt_commerce:shop_products search="does-not-exist" }}{{ if no_results }}No results{{ else }}{{ title }}{{ /if }}{{ /daugt_commerce:shop_products }}'
+ );
+
+ $this->assertSame('No results', trim($output));
+ }
+
+ public function test_storefront_tag_alias_still_works(): void
+ {
+ $this->makeProduct('prod-pair-2', 'Alias Product', ['categories::wellness'], true, 'Alias description');
+
+ $output = Antlers::parse(
+ '{{ daugt_commerce:storefront_products }}{{ title }}{{ /daugt_commerce:storefront_products }}'
+ );
+
+ $this->assertStringContainsString('Alias Product', $output);
+ }
+
+ public function test_store_tag_alias_still_works(): void
+ {
+ $this->makeProduct('prod-pair-3', 'Store Alias Product', ['categories::wellness'], true, 'Alias description');
+
+ $output = Antlers::parse(
+ '{{ daugt_commerce:store_products }}{{ title }}{{ /daugt_commerce:store_products }}'
+ );
+
+ $this->assertStringContainsString('Store Alias Product', $output);
+ }
+
+ private function makeTags(array $params = []): DaugtCommerceTags
+ {
+ $tags = new DaugtCommerceTags();
+ $tags->setProperties([
+ 'parser' => null,
+ 'content' => '',
+ 'context' => [],
+ 'params' => $params,
+ 'tag' => 'daugt_commerce:shop_products',
+ 'tag_method' => 'shop_products',
+ ]);
+
+ return $tags;
+ }
+
+ private function makeProduct(
+ string $id,
+ string $title,
+ array $categories = [],
+ bool $published = true,
+ string $description = ''
+ ): ProductEntry {
+ $entry = Entry::make()
+ ->collection(ProductEntry::COLLECTION)
+ ->id($id)
+ ->slug($id)
+ ->published($published);
+
+ $entry->data([
+ ProductEntry::TITLE => $title,
+ ProductEntry::BILLING_TYPE => 'one_time',
+ ProductEntry::PRICE => 10,
+ ProductEntry::CATEGORIES => $categories,
+ ProductEntry::DESCRIPTION => $description,
+ ]);
+
+ $entry->saveQuietly();
+
+ $entry = Entry::find($id);
+ $this->assertInstanceOf(ProductEntry::class, $entry);
+
+ return $entry;
+ }
+
+ private function makeTerm(string $slug, string $title): TermContract
+ {
+ $term = Term::make();
+ $term->taxonomy('categories');
+ $term->slug($slug);
+ $term->data(['title' => $title]);
+ $term->save();
+
+ return $term;
+ }
+}