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 @@ +
+
+
+

{{ trans:daugt-commerce::checkout.title }}

+

{{ trans:daugt-commerce::checkout.subtitle }}

+
+ + + ← {{ trans:daugt-commerce::checkout.continue_shopping }} + +
+ + {{ 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] }} + {{ title }} + {{ 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 @@ + +
+

{{ trans:daugt-commerce::cart.title }}

+
+
+ +
+
+
+ + + + {{ trans:daugt-commerce::cart.go-to-checkout }} + +
+ + 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 }}

+
+ +
+ + + {{ if get:category }} + + {{ /if }} + + + +
+ +
+ + {{ trans:daugt-commerce::shop.all }} + + + {{ daugt_commerce:shop_categories }} + + {{ title }} + + {{ /daugt_commerce:shop_categories }} +
+ +
+ {{ daugt_commerce:shop_products search="{get:search}" category="{get:category}" }} + {{ if no_results }} +
+ {{ trans:daugt-commerce::shop.no_products }} +
+ {{ else }} + + {{ if image }} + {{ title }} + {{ else }} +
+ {{ /if }} + +
+

{{ title }}

+

{{ daugt_commerce:money :value="price" }}

+ {{ if description_preview }} +

{{ description_preview }}

+ {{ /if }} +
+
+ {{ /if }} + {{ /daugt_commerce:shop_products }} +
+
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; + } +}