From 3b1a1e92e8382e06ab024237a590bb79271004ca Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Sat, 27 Jun 2026 12:50:43 +0330 Subject: [PATCH 01/32] feat(shop): category listing page with faceted filters Add /categories/{slug} listing (products incl. descendants) with brand, attribute, price and availability filters, sorting, pagination and BreadcrumbList JSON-LD. Digikala-style filter UI: accordion sections, price slider, brand search, per-option counts, instant apply. Attribute filtering matches via the product_attribute pivot. Thin controller + Category actions + CategoryDTO + Pest feature tests. Co-authored-by: Cursor --- .../Category/BuildCategoryBreadcrumbs.php | 36 ++ .../Actions/Category/BuildCategoryDetail.php | 29 ++ .../Actions/Category/CollectCategoryIds.php | 40 ++ .../Actions/Category/GetCategoryFilters.php | 138 +++++++ .../Actions/Category/GetCategoryProducts.php | 118 ++++++ shop/app/DTOs/CategoryDTO.php | 38 ++ .../Http/Controllers/CategoryController.php | 77 ++++ .../Components/Category/CategoryFilters.vue | 342 ++++++++++++++++++ .../Components/Category/CategoryToolbar.vue | 53 +++ shop/resources/js/Components/EmptyState.vue | 30 ++ shop/resources/js/Components/Pagination.vue | 91 +++++ shop/resources/js/Pages/Category/Show.vue | 192 ++++++++++ shop/routes/web.php | 3 + shop/tests/Feature/CategoryPageTest.php | 235 ++++++++++++ 14 files changed, 1422 insertions(+) create mode 100644 shop/app/Actions/Category/BuildCategoryBreadcrumbs.php create mode 100644 shop/app/Actions/Category/BuildCategoryDetail.php create mode 100644 shop/app/Actions/Category/CollectCategoryIds.php create mode 100644 shop/app/Actions/Category/GetCategoryFilters.php create mode 100644 shop/app/Actions/Category/GetCategoryProducts.php create mode 100644 shop/app/DTOs/CategoryDTO.php create mode 100644 shop/app/Http/Controllers/CategoryController.php create mode 100644 shop/resources/js/Components/Category/CategoryFilters.vue create mode 100644 shop/resources/js/Components/Category/CategoryToolbar.vue create mode 100644 shop/resources/js/Components/EmptyState.vue create mode 100644 shop/resources/js/Components/Pagination.vue create mode 100644 shop/resources/js/Pages/Category/Show.vue create mode 100644 shop/tests/Feature/CategoryPageTest.php diff --git a/shop/app/Actions/Category/BuildCategoryBreadcrumbs.php b/shop/app/Actions/Category/BuildCategoryBreadcrumbs.php new file mode 100644 index 00000000..83f800ca --- /dev/null +++ b/shop/app/Actions/Category/BuildCategoryBreadcrumbs.php @@ -0,0 +1,36 @@ + + */ + public function __invoke(Category $category): array + { + $chain = []; + $parent = $category->parent; + + while ($parent instanceof Category) { + array_unshift($chain, [ + 'heading' => $parent->heading, + 'url' => '/categories/'.$parent->slug, + ]); + $parent = $parent->parent; + } + + return [ + ['heading' => 'خانه', 'url' => '/'], + ...$chain, + ['heading' => $category->heading, 'url' => null], + ]; + } +} diff --git a/shop/app/Actions/Category/BuildCategoryDetail.php b/shop/app/Actions/Category/BuildCategoryDetail.php new file mode 100644 index 00000000..4f435afc --- /dev/null +++ b/shop/app/Actions/Category/BuildCategoryDetail.php @@ -0,0 +1,29 @@ +id, + heading: $category->heading, + url: '/categories/'.$category->slug, + title: $category->title, + description: $category->description, + content: $category->content, + noIndex: (bool) $category->no_index, + canonical: $category->canonical, + image: ($this->transformImage)($category->image), + ); + } +} diff --git a/shop/app/Actions/Category/CollectCategoryIds.php b/shop/app/Actions/Category/CollectCategoryIds.php new file mode 100644 index 00000000..3f5ebece --- /dev/null +++ b/shop/app/Actions/Category/CollectCategoryIds.php @@ -0,0 +1,40 @@ + + */ + public function __invoke(Category $category): array + { + $ids = [$category->id]; + $frontier = [$category->id]; + + while (true) { + $children = Category::query() + ->whereIn('parent_id', $frontier) + ->pluck('id') + ->all(); + + $children = array_values(array_diff(array_map('intval', $children), $ids)); + + if ($children === []) { + break; + } + + $ids = array_merge($ids, $children); + $frontier = $children; + } + + return $ids; + } +} diff --git a/shop/app/Actions/Category/GetCategoryFilters.php b/shop/app/Actions/Category/GetCategoryFilters.php new file mode 100644 index 00000000..de6ec5c0 --- /dev/null +++ b/shop/app/Actions/Category/GetCategoryFilters.php @@ -0,0 +1,138 @@ + $categoryIds + * @param array{brands: array, attributes: array, minPrice: int|null, maxPrice: int|null, inStock: bool, sort: string} $filters + * @return array{brands: array>, attributeGroups: array>, price: array{min: int, max: int}} + */ + public function __invoke(array $categoryIds, array $filters): array + { + return [ + 'brands' => $this->brands($categoryIds, $filters['brands']), + 'attributeGroups' => $this->attributeGroups($categoryIds, $filters['attributes']), + 'price' => $this->priceBounds($categoryIds), + ]; + } + + /** + * @param array $categoryIds + * @param array $selected + * @return array> + */ + private function brands(array $categoryIds, array $selected): array + { + $counts = Product::query() + ->published() + ->whereIn('category_id', $categoryIds) + ->whereNotNull('brand_id') + ->selectRaw('brand_id, count(*) as aggregate') + ->groupBy('brand_id') + ->pluck('aggregate', 'brand_id'); + + return Brand::query() + ->active() + ->whereIn('id', $counts->keys()->all()) + ->orderBy('heading') + ->get() + ->map(fn (Brand $brand): array => [ + 'id' => $brand->id, + 'heading' => $brand->heading, + 'slug' => $brand->slug, + 'count' => (int) ($counts[$brand->id] ?? 0), + 'selected' => in_array($brand->slug, $selected, true), + ]) + ->all(); + } + + /** + * Attribute groups flagged as filters for any of the categories, limited to + * the attribute values actually attached to products in those categories. + * + * @param array $categoryIds + * @param array $selected + * @return array> + */ + private function attributeGroups(array $categoryIds, array $selected): array + { + $groupIds = array_map('intval', DB::table('attribute_group_category') + ->whereIn('category_id', $categoryIds) + ->where('as_filter', true) + ->pluck('attribute_group_id') + ->unique() + ->all()); + + if ($groupIds === []) { + return []; + } + + $counts = DB::table('product_attribute') + ->join('products', 'products.id', '=', 'product_attribute.product_id') + ->where('products.status', ProductStatusEnum::PUBLISHED->value) + ->whereIn('products.category_id', $categoryIds) + ->groupBy('product_attribute.attribute_id') + ->selectRaw('product_attribute.attribute_id as attribute_id, count(distinct products.id) as aggregate') + ->pluck('aggregate', 'attribute_id'); + + $usedAttributeIds = array_map('intval', $counts->keys()->all()); + + if ($usedAttributeIds === []) { + return []; + } + + return AttributeGroup::query() + ->whereIn('id', $groupIds) + ->with(['attributes' => fn (Relation $relation) => $relation->whereIn('id', $usedAttributeIds)->orderBy('value')]) + ->orderBy('name') + ->get() + ->reject(fn (AttributeGroup $group): bool => $group->attributes->isEmpty()) + ->map(fn (AttributeGroup $group): array => [ + 'id' => $group->id, + 'name' => $group->name, + 'attributes' => $group->attributes + ->map(fn (Attribute $attribute): array => [ + 'id' => $attribute->id, + 'value' => $attribute->value, + 'color' => $attribute->color, + 'count' => (int) ($counts[$attribute->id] ?? 0), + 'selected' => in_array($attribute->id, $selected, true), + ]) + ->all(), + ]) + ->values() + ->all(); + } + + /** + * @param array $categoryIds + * @return array{min: int, max: int} + */ + private function priceBounds(array $categoryIds): array + { + $base = Product::query() + ->published() + ->whereIn('category_id', $categoryIds); + + return [ + 'min' => (int) ((clone $base)->min('price') ?? 0), + 'max' => (int) ((clone $base)->max('price') ?? 0), + ]; + } +} diff --git a/shop/app/Actions/Category/GetCategoryProducts.php b/shop/app/Actions/Category/GetCategoryProducts.php new file mode 100644 index 00000000..aad2e43b --- /dev/null +++ b/shop/app/Actions/Category/GetCategoryProducts.php @@ -0,0 +1,118 @@ + $categoryIds + * @param array{brands: array, attributes: array, minPrice: int|null, maxPrice: int|null, inStock: bool, sort: string} $filters + * @return array{data: array>, meta: array{currentPage: int, lastPage: int, perPage: int, total: int, from: int|null, to: int|null}} + */ + public function __invoke(array $categoryIds, array $filters): array + { + $query = Product::query() + ->published() + ->whereIn('category_id', $categoryIds) + ->with([ + 'featuredImage', + 'varieties' => fn (Relation $relation) => $relation->where('status', VarietyStatusEnum::PUBLISHED->value)->with('image'), + ]); + + if ($filters['brands'] !== []) { + $query->whereHas('brand', fn (Builder $brand) => $brand->whereIn('slug', $filters['brands'])); + } + + if ($filters['minPrice'] !== null) { + $query->where('price', '>=', $filters['minPrice']); + } + + if ($filters['maxPrice'] !== null) { + $query->where('price', '<=', $filters['maxPrice']); + } + + if ($filters['inStock']) { + $query->where('has_stock', true); + } + + // Faceted attribute filter through the product↔attribute pivot + // (product_attribute is the documented "filters to products" link). + // OR within a group, AND across groups. + foreach ($this->groupedAttributes($filters['attributes']) as $attributeIds) { + $query->whereHas('attributes', fn (Builder $attribute) => $attribute->whereIn('attributes.id', $attributeIds)); + } + + $this->applySort($query, $filters['sort']); + + $paginator = $query->paginate(self::PER_PAGE)->withQueryString(); + + /** @var array $items */ + $items = $paginator->items(); + + return [ + 'data' => array_map(fn (Product $product): array => ($this->buildProductCard)($product), $items), + 'meta' => [ + 'currentPage' => $paginator->currentPage(), + 'lastPage' => $paginator->lastPage(), + 'perPage' => $paginator->perPage(), + 'total' => $paginator->total(), + 'from' => $paginator->firstItem(), + 'to' => $paginator->lastItem(), + ], + ]; + } + + /** + * Group selected attribute ids by their attribute group, so each group + * becomes one AND-ed constraint while values inside it stay OR-ed. + * + * @param array $attributeIds + * @return array> + */ + private function groupedAttributes(array $attributeIds): array + { + if ($attributeIds === []) { + return []; + } + + return Attribute::query() + ->whereIn('id', $attributeIds) + ->get() + ->groupBy('attribute_group_id') + ->map(fn (Collection $group): array => $group->pluck('id')->all()) + ->values() + ->all(); + } + + /** + * @param Builder $query + */ + private function applySort(Builder $query, string $sort): void + { + match ($sort) { + 'cheapest' => $query->orderBy('price'), + 'expensive' => $query->orderByDesc('price'), + 'popular' => $query->orderByDesc('seen'), + default => $query->orderByDesc('id'), + }; + } +} diff --git a/shop/app/DTOs/CategoryDTO.php b/shop/app/DTOs/CategoryDTO.php new file mode 100644 index 00000000..faa0e5dc --- /dev/null +++ b/shop/app/DTOs/CategoryDTO.php @@ -0,0 +1,38 @@ + + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'heading' => $this->heading, + 'url' => $this->url, + 'title' => $this->title, + 'description' => $this->description, + 'content' => $this->content, + 'noIndex' => $this->noIndex, + 'canonical' => $this->canonical, + 'image' => $this->image?->toArray(), + ]; + } +} diff --git a/shop/app/Http/Controllers/CategoryController.php b/shop/app/Http/Controllers/CategoryController.php new file mode 100644 index 00000000..74f957d3 --- /dev/null +++ b/shop/app/Http/Controllers/CategoryController.php @@ -0,0 +1,77 @@ +active() + ->where('slug', $slug) + ->with('image') + ->firstOrFail(); + + $categoryIds = $collectCategoryIds($category); + $filters = $this->filters($request); + + return Inertia::render('Category/Show', [ + 'category' => $buildCategoryDetail($category)->toArray(), + 'breadcrumbs' => $buildBreadcrumbs($category), + 'products' => $getCategoryProducts($categoryIds, $filters), + 'filters' => $getCategoryFilters($categoryIds, $filters), + 'applied' => $filters, + ]); + } + + /** + * Normalise the filter/sort query parameters into a typed shape. + * + * @return array{brands: array, attributes: array, minPrice: int|null, maxPrice: int|null, inStock: bool, sort: string} + */ + private function filters(Request $request): array + { + $sort = (string) $request->query('sort', 'newest'); + + if (! in_array($sort, ['newest', 'cheapest', 'expensive', 'popular'], true)) { + $sort = 'newest'; + } + + return [ + 'brands' => array_values(array_filter(array_map('strval', (array) $request->query('brands', [])))), + 'attributes' => array_values(array_filter(array_map('intval', (array) $request->query('attributes', [])))), + 'minPrice' => $this->intOrNull($request->query('min_price')), + 'maxPrice' => $this->intOrNull($request->query('max_price')), + 'inStock' => $request->boolean('in_stock'), + 'sort' => $sort, + ]; + } + + private function intOrNull(mixed $value): ?int + { + if ($value === null || $value === '' || ! is_numeric($value)) { + return null; + } + + return (int) $value; + } +} diff --git a/shop/resources/js/Components/Category/CategoryFilters.vue b/shop/resources/js/Components/Category/CategoryFilters.vue new file mode 100644 index 00000000..47984fdd --- /dev/null +++ b/shop/resources/js/Components/Category/CategoryFilters.vue @@ -0,0 +1,342 @@ + + + + + diff --git a/shop/resources/js/Components/Category/CategoryToolbar.vue b/shop/resources/js/Components/Category/CategoryToolbar.vue new file mode 100644 index 00000000..22600e51 --- /dev/null +++ b/shop/resources/js/Components/Category/CategoryToolbar.vue @@ -0,0 +1,53 @@ + + + diff --git a/shop/resources/js/Components/EmptyState.vue b/shop/resources/js/Components/EmptyState.vue new file mode 100644 index 00000000..be1b1f1d --- /dev/null +++ b/shop/resources/js/Components/EmptyState.vue @@ -0,0 +1,30 @@ + + + diff --git a/shop/resources/js/Components/Pagination.vue b/shop/resources/js/Components/Pagination.vue new file mode 100644 index 00000000..17b70a5b --- /dev/null +++ b/shop/resources/js/Components/Pagination.vue @@ -0,0 +1,91 @@ + + + diff --git a/shop/resources/js/Pages/Category/Show.vue b/shop/resources/js/Pages/Category/Show.vue new file mode 100644 index 00000000..39f8236e --- /dev/null +++ b/shop/resources/js/Pages/Category/Show.vue @@ -0,0 +1,192 @@ + + + diff --git a/shop/routes/web.php b/shop/routes/web.php index 1f4ab360..af025b68 100644 --- a/shop/routes/web.php +++ b/shop/routes/web.php @@ -2,10 +2,13 @@ declare(strict_types=1); +use App\Http\Controllers\CategoryController; use App\Http\Controllers\HomeController; use App\Http\Controllers\ProductController; use Illuminate\Support\Facades\Route; Route::get('/', HomeController::class)->name('home'); +Route::get('/categories/{slug}', [CategoryController::class, 'show'])->name('categories.show'); + Route::get('/products/{slug}', [ProductController::class, 'show'])->name('products.show'); diff --git a/shop/tests/Feature/CategoryPageTest.php b/shop/tests/Feature/CategoryPageTest.php new file mode 100644 index 00000000..82798eca --- /dev/null +++ b/shop/tests/Feature/CategoryPageTest.php @@ -0,0 +1,235 @@ + 'media/sample.jpg', + 'imageable_type' => $type, + 'imageable_id' => $id, + 'alt_text' => 'تصویر نمونه', + 'is_featured' => true, + ]); +} + +function catCategory(string $slug = 'shoes', ?int $parentId = null): Category +{ + return Category::create([ + 'heading' => 'دسته '.$slug, + 'slug' => $slug, + 'status' => CategoryStatusEnum::ACTIVE, + 'parent_id' => $parentId, + ]); +} + +function catProduct(Category $category, int $price = 100000, ?int $brandId = null, ?int $attributeId = null, bool $hasStock = true): Product +{ + $product = Product::create([ + 'heading' => 'محصول '.uniqid(), + 'slug' => 'product-'.uniqid(), + 'price' => $price, + 'category_id' => $category->id, + 'brand_id' => $brandId, + 'has_stock' => $hasStock, + 'status' => ProductStatusEnum::PUBLISHED, + 'seen' => 0, + ]); + catImage(Product::class, $product->id); + + Variety::create([ + 'product_id' => $product->id, + 'price' => $price, + 'inventory' => 5, + 'has_stock' => true, + 'status' => VarietyStatusEnum::PUBLISHED, + ]); + + // product_attribute is the documented filters-to-products link. + if ($attributeId !== null) { + $product->attributes()->attach($attributeId, ['is_highlight' => false]); + } + + return $product; +} + +it('renders the category page with products and breadcrumbs', function (): void { + $category = catCategory('apparel'); + catProduct($category); + + $this->get('/categories/'.$category->slug) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->component('Category/Show') + ->where('category.heading', $category->heading) + ->has('products.data', 1) + ->where('products.meta.total', 1) + ->has('breadcrumbs', 2) + ->where('breadcrumbs.0.heading', 'خانه') + ); +}); + +it('includes products from descendant categories', function (): void { + $parent = catCategory('electronics'); + $child = catCategory('phones', $parent->id); + catProduct($child); + + $this->get('/categories/'.$parent->slug) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('products.meta.total', 1) + ); +}); + +it('filters products by brand', function (): void { + $category = catCategory('bags'); + $nike = Brand::create(['heading' => 'نایک', 'slug' => 'nike', 'status' => BrandStatusEnum::ACTIVE]); + $puma = Brand::create(['heading' => 'پوما', 'slug' => 'puma', 'status' => BrandStatusEnum::ACTIVE]); + catProduct($category, brandId: $nike->id); + catProduct($category, brandId: $puma->id); + + $this->get('/categories/'.$category->slug.'?brands[]=nike') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('products.meta.total', 1) + ->has('filters.brands', 2) + ); +}); + +it('filters products by price range', function (): void { + $category = catCategory('watches'); + catProduct($category, price: 100000); + catProduct($category, price: 500000); + + $this->get('/categories/'.$category->slug.'?min_price=200000') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('products.meta.total', 1) + ->where('filters.price.min', 100000) + ->where('filters.price.max', 500000) + ); +}); + +it('filters products by a product attribute', function (): void { + $category = catCategory('tshirts'); + + $ancestorId = DB::table('ancestors')->insertGetId([ + 'name' => 'رنگ', 'created_at' => now(), 'updated_at' => now(), + ]); + $groupId = DB::table('attribute_groups')->insertGetId([ + 'ancestor_id' => $ancestorId, 'name' => 'رنگ', 'created_at' => now(), 'updated_at' => now(), + ]); + DB::table('attribute_group_category')->insert([ + 'attribute_group_id' => $groupId, + 'category_id' => $category->id, + 'as_filter' => true, + 'required' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $red = Attribute::create(['attribute_group_id' => $groupId, 'value' => 'قرمز', 'color' => '#ff0000']); + $blue = Attribute::create(['attribute_group_id' => $groupId, 'value' => 'آبی', 'color' => '#0000ff']); + + catProduct($category, attributeId: $red->id); + catProduct($category, attributeId: $blue->id); + + $this->get('/categories/'.$category->slug.'?attributes[]='.$red->id) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('products.meta.total', 1) + ->has('filters.attributeGroups', 1, fn (AssertableInertia $group): AssertableInertia => $group + ->where('name', 'رنگ') + ->has('attributes', 2) + ->etc() + ) + ); +}); + +it('filters products to in-stock only', function (): void { + $category = catCategory('stock'); + catProduct($category, hasStock: true); + catProduct($category, hasStock: false); + + $this->get('/categories/'.$category->slug.'?in_stock=1') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('products.meta.total', 1) + ); +}); + +it('exposes per-brand product counts in facets', function (): void { + $category = catCategory('counts'); + $brand = Brand::create(['heading' => 'سونی', 'slug' => 'sony', 'status' => BrandStatusEnum::ACTIVE]); + catProduct($category, brandId: $brand->id); + catProduct($category, brandId: $brand->id); + + $this->get('/categories/'.$category->slug) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('filters.brands.0.count', 2) + ->etc() + ); +}); + +it('sorts products by cheapest first', function (): void { + $category = catCategory('sorting'); + catProduct($category, price: 900000); + catProduct($category, price: 100000); + + $this->get('/categories/'.$category->slug.'?sort=cheapest') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('products.data.0.price', 100000) + ); +}); + +it('paginates products', function (): void { + $category = catCategory('many'); + + foreach (range(1, 25) as $ignored) { + catProduct($category); + } + + $this->get('/categories/'.$category->slug) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('products.meta.total', 25) + ->where('products.meta.lastPage', 2) + ->has('products.data', 24) + ); + + $this->get('/categories/'.$category->slug.'?page=2') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('products.meta.currentPage', 2) + ->has('products.data', 1) + ); +}); + +it('returns 404 for an inactive category', function (): void { + $category = Category::create([ + 'heading' => 'غیرفعال', + 'slug' => 'inactive-cat', + 'status' => CategoryStatusEnum::INACTIVE, + ]); + + $this->get('/categories/'.$category->slug)->assertNotFound(); +}); + +it('returns 404 for a missing category', function (): void { + $this->get('/categories/does-not-exist')->assertNotFound(); +}); From a17b680870554376c5c05622dc5717f25dc9fddc Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Sat, 27 Jun 2026 12:50:56 +0330 Subject: [PATCH 02/32] feat(shop): product card variety-image fallback and open in new tab Cards now fall back to the first variety image when a product has no featured image (eager-load varieties.image to avoid N+1). AppLink gains a new-tab option and ProductCard opens products in a new tab. Co-authored-by: Cursor --- shop/app/Actions/Catalog/BuildProductCard.php | 15 ++++++++++++++- shop/app/Actions/Home/GetProductRows.php | 2 +- shop/app/Actions/Product/GetRelatedProducts.php | 2 +- shop/resources/js/Components/AppLink.vue | 11 +++++++++-- shop/resources/js/Components/ProductCard.vue | 1 + 5 files changed, 26 insertions(+), 5 deletions(-) diff --git a/shop/app/Actions/Catalog/BuildProductCard.php b/shop/app/Actions/Catalog/BuildProductCard.php index deab9be4..6e4bdd7d 100644 --- a/shop/app/Actions/Catalog/BuildProductCard.php +++ b/shop/app/Actions/Catalog/BuildProductCard.php @@ -4,6 +4,7 @@ namespace App\Actions\Catalog; +use App\Models\Image; use App\Models\Product; use App\Models\Variety; use Illuminate\Database\Eloquent\Collection; @@ -32,10 +33,22 @@ public function __invoke(Product $product): array 'id' => $product->id, 'heading' => $product->heading, 'url' => '/products/'.$product->slug, - 'image' => ($this->transformImage)($product->featuredImage)?->toArray(), + 'image' => ($this->transformImage)($this->cardImage($product))?->toArray(), 'price' => $pricing['price'], 'salePrice' => $pricing['salePrice'], 'discountPercent' => $pricing['discountPercent'], ]; } + + /** + * The product's featured image, falling back to the first variety image so + * cards still show a photo when the product has no product-level image + * (mirrors the detail gallery, which also surfaces variety images). + */ + private function cardImage(Product $product): ?Image + { + return $product->featuredImage + ?? $product->varieties + ->first(fn (Variety $variety): bool => $variety->image !== null)?->image; + } } diff --git a/shop/app/Actions/Home/GetProductRows.php b/shop/app/Actions/Home/GetProductRows.php index 0f9559b7..851c7b7e 100644 --- a/shop/app/Actions/Home/GetProductRows.php +++ b/shop/app/Actions/Home/GetProductRows.php @@ -34,7 +34,7 @@ public function __invoke(): array return array_values(array_filter(array_map(function (array $row): array { $products = Product::query() ->published() - ->with(['featuredImage', 'varieties' => fn (Relation $q) => $q->where('status', VarietyStatusEnum::PUBLISHED->value)]) + ->with(['featuredImage', 'varieties' => fn (Relation $q) => $q->where('status', VarietyStatusEnum::PUBLISHED->value)->with('image')]) ->tap($row['query']) ->limit(self::ROW_LIMIT) ->get() diff --git a/shop/app/Actions/Product/GetRelatedProducts.php b/shop/app/Actions/Product/GetRelatedProducts.php index 7ff727d3..0ff194d1 100644 --- a/shop/app/Actions/Product/GetRelatedProducts.php +++ b/shop/app/Actions/Product/GetRelatedProducts.php @@ -33,7 +33,7 @@ public function __invoke(Product $product): array ->published() ->where('category_id', $product->category_id) ->whereKeyNot($product->id) - ->with(['featuredImage', 'varieties' => fn (Relation $query) => $query->where('status', VarietyStatusEnum::PUBLISHED->value)]) + ->with(['featuredImage', 'varieties' => fn (Relation $query) => $query->where('status', VarietyStatusEnum::PUBLISHED->value)->with('image')]) ->latest('id') ->limit(self::LIMIT) ->get() diff --git a/shop/resources/js/Components/AppLink.vue b/shop/resources/js/Components/AppLink.vue index f06c31b7..4c76a252 100644 --- a/shop/resources/js/Components/AppLink.vue +++ b/shop/resources/js/Components/AppLink.vue @@ -7,6 +7,10 @@ const props = defineProps({ type: String, required: true, }, + newTab: { + type: Boolean, + default: false, + }, }); const isExternal = computed( @@ -15,13 +19,16 @@ const isExternal = computed( props.href.startsWith('mailto:') || props.href.startsWith('tel:'), ); + +const target = computed(() => (props.newTab ? '_blank' : undefined)); +const rel = computed(() => (props.newTab || isExternal.value ? 'noopener noreferrer' : undefined)); diff --git a/shop/resources/js/Components/ProductCard.vue b/shop/resources/js/Components/ProductCard.vue index 0341f1b9..51557246 100644 --- a/shop/resources/js/Components/ProductCard.vue +++ b/shop/resources/js/Components/ProductCard.vue @@ -15,6 +15,7 @@ defineProps({