From 838fbe1144f34720145891e80b86a2ad1f17294c Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 25 Jun 2026 00:10:55 +0330 Subject: [PATCH 1/8] feat(shop): home page + variety images, fix placeholder images and seeds - shop: storefront home page (hero slider, categories, banners, product carousels, brands) with header nav, SEO/JSON-LD, and canonical URLs - admin/shop: add polymorphic image to varieties (model, factory, seeder, Filament form/table, product repeater, shop read model) - admin: replace dead via.placeholder.com URLs with placehold.co in factories - admin: fix TestSeeder unique constraint via Postgres sequence sync and reuse of existing province/city records; seed product/variety images Co-authored-by: Cursor --- .../Filament/Resources/ProductResource.php | 23 ++ .../Filament/Resources/VarietyResource.php | 26 +++ admin/app/Models/Variety.php | 9 + admin/database/factories/AddressFactory.php | 2 +- admin/database/factories/BannerFactory.php | 2 +- admin/database/factories/BrandFactory.php | 2 +- admin/database/factories/CategoryFactory.php | 2 +- admin/database/factories/CityFactory.php | 2 +- admin/database/factories/GatewayFactory.php | 2 +- admin/database/factories/ImageFactory.php | 15 +- admin/database/factories/PageFactory.php | 2 +- admin/database/factories/ProductFactory.php | 2 +- admin/database/factories/ReceiptFactory.php | 2 +- admin/database/factories/SlideFactory.php | 2 +- admin/database/factories/VarietyFactory.php | 12 + admin/database/seeders/CitySeeder.php | 7 + admin/database/seeders/ProductSeeder.php | 2 +- admin/database/seeders/VarietySeeder.php | 2 +- admin/lang/en/product.php | 1 + admin/lang/en/variety.php | 4 + admin/lang/fa/product.php | 1 + admin/lang/fa/variety.php | 4 + .../Filament/Resource/VarietyResourceTest.php | 16 ++ shop/app/Http/Controllers/HomeController.php | 207 ++++++++++++++++++ .../Http/Middleware/HandleInertiaRequests.php | 36 +++ shop/app/Models/Image.php | 26 +++ shop/app/Models/Variety.php | 7 + shop/config/inertia.php | 44 ++++ shop/docs/STOREFRONT_IMPLEMENTATION.md | 4 +- shop/resources/js/Components/AppHead.vue | 13 ++ .../js/Components/Header/AppHeader.vue | 85 +++++++ .../js/Components/Header/CategoryMenu.vue | 50 +++++ .../js/Components/Header/HeaderActions.vue | 26 +++ .../js/Components/Header/HeaderSearch.vue | 36 +++ .../js/Components/Home/BannerGrid.vue | 35 +++ .../js/Components/Home/BrandStrip.vue | 37 ++++ .../js/Components/Home/CategoryStrip.vue | 43 ++++ .../js/Components/Home/HeroSlider.vue | 109 +++++++++ .../js/Components/Home/ProductCarousel.vue | 71 ++++++ shop/resources/js/Components/PriceTag.vue | 47 ++++ shop/resources/js/Components/ProductCard.vue | 49 +++++ .../js/Components/SectionHeading.vue | 37 ++++ shop/resources/js/Layouts/AppLayout.vue | 11 +- shop/resources/js/Pages/Home.vue | 73 +++++- shop/resources/js/fontawesome.js | 28 ++- shop/routes/web.php | 6 +- shop/tests/Feature/HomePageTest.php | 140 ++++++++++++ 47 files changed, 1325 insertions(+), 37 deletions(-) create mode 100644 shop/app/Http/Controllers/HomeController.php create mode 100644 shop/config/inertia.php create mode 100644 shop/resources/js/Components/Header/AppHeader.vue create mode 100644 shop/resources/js/Components/Header/CategoryMenu.vue create mode 100644 shop/resources/js/Components/Header/HeaderActions.vue create mode 100644 shop/resources/js/Components/Header/HeaderSearch.vue create mode 100644 shop/resources/js/Components/Home/BannerGrid.vue create mode 100644 shop/resources/js/Components/Home/BrandStrip.vue create mode 100644 shop/resources/js/Components/Home/CategoryStrip.vue create mode 100644 shop/resources/js/Components/Home/HeroSlider.vue create mode 100644 shop/resources/js/Components/Home/ProductCarousel.vue create mode 100644 shop/resources/js/Components/PriceTag.vue create mode 100644 shop/resources/js/Components/ProductCard.vue create mode 100644 shop/resources/js/Components/SectionHeading.vue create mode 100644 shop/tests/Feature/HomePageTest.php diff --git a/admin/app/Filament/Resources/ProductResource.php b/admin/app/Filament/Resources/ProductResource.php index 9168ebac..ff7192d6 100644 --- a/admin/app/Filament/Resources/ProductResource.php +++ b/admin/app/Filament/Resources/ProductResource.php @@ -15,6 +15,7 @@ use App\Models\AttributeGroup; use App\Models\AttributeGroupCategory; use App\Models\Product; +use App\Models\Variety; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -27,6 +28,7 @@ use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Resources\Resource; +use Filament\Schemas\Components\Fieldset; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; @@ -356,6 +358,27 @@ public static function form(Schema $schema): Schema ->required() ->options(VarietyStatusEnum::options()) ->default(VarietyStatusEnum::PUBLISHED->value), + Fieldset::make(trans('product.variety_image')) + ->relationship('image') + ->schema([ + FileUpload::make('path') + ->label(trans('product.path')) + ->image() + ->nullable() + ->columnSpanFull(), + TextInput::make('alt_text') + ->label(trans('product.alt_text')) + ->nullable() + ->maxLength(255), + ]) + ->mutateRelationshipDataBeforeSaveUsing(function (array $data, Variety $record): array { + if (empty($data['path'])) { + $record->image?->delete(); + } + + return $data; + }) + ->columnSpanFull(), ]) ->columnSpanFull(), ]); diff --git a/admin/app/Filament/Resources/VarietyResource.php b/admin/app/Filament/Resources/VarietyResource.php index e4575c81..1754a76f 100644 --- a/admin/app/Filament/Resources/VarietyResource.php +++ b/admin/app/Filament/Resources/VarietyResource.php @@ -15,14 +15,17 @@ use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Forms\Components\ColorPicker; +use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Resources\Resource; +use Filament\Schemas\Components\Fieldset; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Schema; use Filament\Tables\Columns\ColorColumn; use Filament\Tables\Columns\IconColumn; +use Filament\Tables\Columns\ImageColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; @@ -130,6 +133,27 @@ public static function form(Schema $schema): Schema ->columnSpanFull() ->hintIcon('heroicon-o-information-circle') ->hintIconTooltip(trans('variety.additional_attributes_hint')), + Fieldset::make(trans('variety.image')) + ->relationship('image') + ->schema([ + FileUpload::make('path') + ->label(trans('variety.path')) + ->image() + ->nullable() + ->columnSpanFull(), + TextInput::make('alt_text') + ->label(trans('variety.alt_text')) + ->nullable() + ->maxLength(255), + ]) + ->mutateRelationshipDataBeforeSaveUsing(function (array $data, Variety $record): array { + if (empty($data['path'])) { + $record->image?->delete(); + } + + return $data; + }) + ->columnSpanFull(), ]); } @@ -137,6 +161,8 @@ public static function table(Table $table): Table { return $table ->columns([ + ImageColumn::make('image.path') + ->label(trans('variety.image')), TextColumn::make('product.heading') ->label(trans('variety.product')) ->limit(30) diff --git a/admin/app/Models/Variety.php b/admin/app/Models/Variety.php index 6d610747..ecd9a395 100644 --- a/admin/app/Models/Variety.php +++ b/admin/app/Models/Variety.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphOne; /** * @property positive-int $id @@ -29,6 +30,7 @@ * @property Collection $attributes * @property Collection $discounts * @property Collection $reviews + * @property Image|null $image */ class Variety extends Model { @@ -68,6 +70,8 @@ protected static function booted(): void static::saved(fn (Variety $variety) => $variety->syncProductVarietyCount()); static::deleted(fn (Variety $variety) => $variety->syncProductVarietyCount()); + + static::deleting(fn (Variety $variety) => $variety->image?->delete()); } public function syncProductVarietyCount(): void @@ -94,6 +98,11 @@ public function attribute(): BelongsTo return $this->belongsTo(Attribute::class); } + public function image(): MorphOne + { + return $this->morphOne(Image::class, 'imageable'); + } + public function attributes(): BelongsToMany { return $this->belongsToMany(Attribute::class)->withTimestamps(); diff --git a/admin/database/factories/AddressFactory.php b/admin/database/factories/AddressFactory.php index 5c58373d..433479ce 100644 --- a/admin/database/factories/AddressFactory.php +++ b/admin/database/factories/AddressFactory.php @@ -28,7 +28,7 @@ public function definition(): array 'postal_code' => $this->faker->postcode(), 'address' => $this->faker->address(), 'description' => $this->faker->text(), - 'city_id' => City::factory(), + 'city_id' => City::query()->inRandomOrder()->value('id') ?? City::factory(), 'prime' => false, ]; } diff --git a/admin/database/factories/BannerFactory.php b/admin/database/factories/BannerFactory.php index 64c0f32f..9643c679 100644 --- a/admin/database/factories/BannerFactory.php +++ b/admin/database/factories/BannerFactory.php @@ -34,7 +34,7 @@ public function withImages(int $count = 3): static return $this->afterCreating(function (Banner $banner) use ($count) { for ($i = 0; $i < $count; $i++) { $banner->images()->create([ - 'path' => fake()->imageUrl(), + 'path' => ImageFactory::placeholderUrl(), 'is_featured' => $i === 0, 'order' => $i, 'alt_text' => fake()->words(2, true), diff --git a/admin/database/factories/BrandFactory.php b/admin/database/factories/BrandFactory.php index d6ea7590..4f83f046 100644 --- a/admin/database/factories/BrandFactory.php +++ b/admin/database/factories/BrandFactory.php @@ -36,7 +36,7 @@ public function withImage(): BrandFactory | Factory { return $this->afterCreating(function (Brand $brand) { $brand->image()->create([ - 'path' => fake()->imageUrl(), + 'path' => ImageFactory::placeholderUrl(), 'imageable_type' => Brand::class, 'imageable_id' => $brand->id, ]); diff --git a/admin/database/factories/CategoryFactory.php b/admin/database/factories/CategoryFactory.php index 00ef284a..a35a4a39 100644 --- a/admin/database/factories/CategoryFactory.php +++ b/admin/database/factories/CategoryFactory.php @@ -40,7 +40,7 @@ public function withImage(): BrandFactory | Factory { return $this->afterCreating(function (Category $category) { $category->image()->create([ - 'path' => fake()->imageUrl(), + 'path' => ImageFactory::placeholderUrl(), 'imageable_type' => Category::class, 'imageable_id' => $category->id, ]); diff --git a/admin/database/factories/CityFactory.php b/admin/database/factories/CityFactory.php index d78c68a8..c0adebe1 100644 --- a/admin/database/factories/CityFactory.php +++ b/admin/database/factories/CityFactory.php @@ -22,7 +22,7 @@ public function definition(): array { return [ 'name' => $this->faker->city, - 'province_id' => Province::factory()->create()->id, + 'province_id' => Province::query()->inRandomOrder()->value('id') ?? Province::factory(), ]; } } diff --git a/admin/database/factories/GatewayFactory.php b/admin/database/factories/GatewayFactory.php index 8386015f..a2beaf7c 100644 --- a/admin/database/factories/GatewayFactory.php +++ b/admin/database/factories/GatewayFactory.php @@ -35,7 +35,7 @@ public function withImage(): static { return $this->afterCreating(function (Gateway $gateway): void { $gateway->image()->create([ - 'path' => fake()->imageUrl(), + 'path' => ImageFactory::placeholderUrl(), 'alt_text' => fake()->words(2, true), ]); }); diff --git a/admin/database/factories/ImageFactory.php b/admin/database/factories/ImageFactory.php index 0996f642..53a6a3f1 100644 --- a/admin/database/factories/ImageFactory.php +++ b/admin/database/factories/ImageFactory.php @@ -28,9 +28,22 @@ public function definition(): array $imageableType = $this->faker->randomElement($imageableTypes); return [ - 'path' => fake()->imageUrl, + 'path' => self::placeholderUrl(), 'imageable_id' => $imageableType::factory(), 'imageable_type' => $imageableType, ]; } + + /** + * A live placeholder image URL. + * + * Faker's default `imageUrl()` points at via.placeholder.com, which has + * been shut down; placehold.co is a working replacement. + */ + public static function placeholderUrl(int $width = 640, int $height = 480): string + { + $color = ltrim(fake()->hexColor(), '#'); + + return "https://placehold.co/{$width}x{$height}/{$color}/ffffff?text=" . urlencode(fake()->word()); + } } diff --git a/admin/database/factories/PageFactory.php b/admin/database/factories/PageFactory.php index f096afd0..3b4955fb 100644 --- a/admin/database/factories/PageFactory.php +++ b/admin/database/factories/PageFactory.php @@ -38,7 +38,7 @@ public function withImage(): static { return $this->afterCreating(function (Page $page): void { $page->image()->create([ - 'path' => fake()->imageUrl(), + 'path' => ImageFactory::placeholderUrl(), 'alt_text' => fake()->words(2, true), ]); }); diff --git a/admin/database/factories/ProductFactory.php b/admin/database/factories/ProductFactory.php index aa63faf6..2f0a3cd8 100644 --- a/admin/database/factories/ProductFactory.php +++ b/admin/database/factories/ProductFactory.php @@ -56,7 +56,7 @@ public function withImages(int $count = 3): static return $this->afterCreating(function (Product $product) use ($count) { for ($i = 0; $i < $count; $i++) { $product->images()->create([ - 'path' => fake()->imageUrl(), + 'path' => ImageFactory::placeholderUrl(), 'is_featured' => $i === 0, // First image featured 'order' => $i, 'alt_text' => fake()->words(2, true), diff --git a/admin/database/factories/ReceiptFactory.php b/admin/database/factories/ReceiptFactory.php index 182d5e2f..589d62a8 100644 --- a/admin/database/factories/ReceiptFactory.php +++ b/admin/database/factories/ReceiptFactory.php @@ -39,7 +39,7 @@ public function withImage(): static { return $this->afterCreating(function (Receipt $receipt): void { $receipt->image()->create([ - 'path' => fake()->imageUrl(), + 'path' => ImageFactory::placeholderUrl(), 'alt_text' => fake()->words(2, true), ]); }); diff --git a/admin/database/factories/SlideFactory.php b/admin/database/factories/SlideFactory.php index 0712a452..64ab093c 100644 --- a/admin/database/factories/SlideFactory.php +++ b/admin/database/factories/SlideFactory.php @@ -33,7 +33,7 @@ public function withImage(): static { return $this->afterCreating(function (Slide $slide): void { $slide->image()->create([ - 'path' => fake()->imageUrl(), + 'path' => ImageFactory::placeholderUrl(), 'is_featured' => true, 'order' => 0, 'alt_text' => $slide->heading, diff --git a/admin/database/factories/VarietyFactory.php b/admin/database/factories/VarietyFactory.php index 06f549dc..a464adc6 100644 --- a/admin/database/factories/VarietyFactory.php +++ b/admin/database/factories/VarietyFactory.php @@ -41,4 +41,16 @@ public function withAttribute(Attribute $attribute): static 'attribute_id' => $attribute->id, ]); } + + public function withImage(): static + { + return $this->afterCreating(function (Variety $variety): void { + $variety->image()->create([ + 'path' => ImageFactory::placeholderUrl(), + 'is_featured' => true, + 'order' => 0, + 'alt_text' => $variety->attribute_value ?? $variety->color, + ]); + }); + } } diff --git a/admin/database/seeders/CitySeeder.php b/admin/database/seeders/CitySeeder.php index d392b925..95983de3 100644 --- a/admin/database/seeders/CitySeeder.php +++ b/admin/database/seeders/CitySeeder.php @@ -7,6 +7,7 @@ use App\Models\City; use App\Models\Province; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\DB; class CitySeeder extends Seeder { @@ -5747,5 +5748,11 @@ public function run(): void City::query()->insert($cities); + // Rows are inserted with explicit ids, so advance the auto-increment + // sequences; otherwise the next factory-created province/city collides. + if (DB::getDriverName() === 'pgsql') { + DB::statement("SELECT setval(pg_get_serial_sequence('provinces', 'id'), (SELECT MAX(id) FROM provinces))"); + DB::statement("SELECT setval(pg_get_serial_sequence('cities', 'id'), (SELECT MAX(id) FROM cities))"); + } } } diff --git a/admin/database/seeders/ProductSeeder.php b/admin/database/seeders/ProductSeeder.php index d8241d94..016e25a0 100644 --- a/admin/database/seeders/ProductSeeder.php +++ b/admin/database/seeders/ProductSeeder.php @@ -12,6 +12,6 @@ class ProductSeeder extends Seeder public function run(): void { Product::query()->truncate(); - Product::factory()->count(20)->create(); + Product::factory()->count(20)->withImages()->create(); } } diff --git a/admin/database/seeders/VarietySeeder.php b/admin/database/seeders/VarietySeeder.php index 0cc09049..567e9233 100644 --- a/admin/database/seeders/VarietySeeder.php +++ b/admin/database/seeders/VarietySeeder.php @@ -12,6 +12,6 @@ class VarietySeeder extends Seeder public function run(): void { Variety::query()->truncate(); - Variety::factory()->count(20)->create(); + Variety::factory()->count(20)->withImage()->create(); } } diff --git a/admin/lang/en/product.php b/admin/lang/en/product.php index 52b67c6f..76941289 100644 --- a/admin/lang/en/product.php +++ b/admin/lang/en/product.php @@ -81,6 +81,7 @@ 'variety_inventory' => 'Inventory', 'variety_has_stock' => 'Has Stock', 'variety_status' => 'Status', + 'variety_image' => 'Variety Image', // Table columns 'featured' => 'Featured', diff --git a/admin/lang/en/variety.php b/admin/lang/en/variety.php index 860e3499..822e5d91 100644 --- a/admin/lang/en/variety.php +++ b/admin/lang/en/variety.php @@ -27,6 +27,10 @@ 'additional_attributes' => 'Additional Attributes', 'additional_attributes_hint' => 'Secondary attributes for this variety from other groups, e.g. Color when the primary group is Size.', + 'image' => 'Image', + 'path' => 'Image File', + 'alt_text' => 'Alt Text', + 'product' => 'Product', 'attribute_value' => 'Value', 'created_at' => 'Created At', diff --git a/admin/lang/fa/product.php b/admin/lang/fa/product.php index af51db7d..e6b53df3 100644 --- a/admin/lang/fa/product.php +++ b/admin/lang/fa/product.php @@ -81,6 +81,7 @@ 'variety_inventory' => 'موجودی', 'variety_has_stock' => 'موجود در انبار', 'variety_status' => 'وضعیت', + 'variety_image' => 'تصویر تنوع', // Table columns 'featured' => 'تصویر شاخص', diff --git a/admin/lang/fa/variety.php b/admin/lang/fa/variety.php index f769e6d6..800e52ba 100644 --- a/admin/lang/fa/variety.php +++ b/admin/lang/fa/variety.php @@ -27,6 +27,10 @@ 'additional_attributes' => 'ویژگی‌های اضافی', 'additional_attributes_hint' => 'ویژگی‌های ثانویه این تنوع از سایر گروه‌ها، مثلاً رنگ وقتی گروه اصلی سایز است.', + 'image' => 'تصویر', + 'path' => 'فایل تصویر', + 'alt_text' => 'متن جایگزین', + 'product' => 'محصول', 'attribute_value' => 'مقدار', 'created_at' => 'تاریخ ایجاد', diff --git a/admin/tests/Feature/Filament/Resource/VarietyResourceTest.php b/admin/tests/Feature/Filament/Resource/VarietyResourceTest.php index 852cda9f..3f1cd0e3 100644 --- a/admin/tests/Feature/Filament/Resource/VarietyResourceTest.php +++ b/admin/tests/Feature/Filament/Resource/VarietyResourceTest.php @@ -147,6 +147,22 @@ ->first()->id->toBe($attribute->id); }); +it('creates a featured image for a variety via the withImage factory state.', function () { + $variety = Variety::factory()->withImage()->create(); + + expect($variety->refresh()->image)->not->toBeNull() + ->and($variety->image->is_featured)->toBeTrue(); +}); + +it('deletes the variety image when the variety is deleted.', function () { + $variety = Variety::factory()->withImage()->create(); + $imageId = $variety->image->id; + + $variety->delete(); + + $this->assertDatabaseMissing('images', ['id' => $imageId]); +}); + it('cascade-deletes variety_attribute pivot rows when variety is deleted.', function () { $variety = Variety::factory()->create(); $attribute = Attribute::factory()->create(); diff --git a/shop/app/Http/Controllers/HomeController.php b/shop/app/Http/Controllers/HomeController.php new file mode 100644 index 00000000..d0db9eb5 --- /dev/null +++ b/shop/app/Http/Controllers/HomeController.php @@ -0,0 +1,207 @@ + $this->slides(), + 'categories' => $this->categories(), + 'banners' => $this->banners(), + 'productRows' => $this->productRows(), + 'brands' => $this->brands(), + ]); + } + + /** + * @return array> + */ + private function slides(): array + { + $slider = Slider::query() + ->published() + ->where('position', self::SLIDER_POSITION) + ->with(['slides' => fn ($query) => $query->orderBy('order'), 'slides.image']) + ->first(); + + if ($slider === null) { + return []; + } + + return $slider->slides + ->map(fn ($slide): array => [ + 'id' => $slide->id, + 'heading' => $slide->heading, + 'label' => $slide->label, + 'url' => $slide->url, + 'image' => $this->image($slide->image), + ]) + ->all(); + } + + /** + * Top-level active categories for the quick-links strip. + * + * @return array> + */ + private function categories(): array + { + return Category::query() + ->active() + ->whereNull('parent_id') + ->with('image') + ->orderBy('heading') + ->get() + ->map(fn (Category $category): array => [ + 'id' => $category->id, + 'heading' => $category->heading, + 'url' => '/categories/'.$category->slug, + 'image' => $this->image($category->image), + ]) + ->all(); + } + + /** + * @return array> + */ + private function banners(): array + { + return Banner::query() + ->published() + ->where('position', self::BANNER_POSITION) + ->with('featuredImage') + ->orderBy('sort') + ->get() + ->map(fn (Banner $banner): array => [ + 'id' => $banner->id, + 'heading' => $banner->heading, + 'url' => $banner->url, + 'image' => $this->image($banner->featuredImage), + ]) + ->all(); + } + + /** + * Two product carousels: newest and most viewed. + * + * @return array> + */ + private function productRows(): array + { + $rows = [ + ['title' => 'جدیدترین محصولات', 'viewAllUrl' => '/products?sort=newest', 'query' => fn ($q) => $q->latest('id')], + ['title' => 'پربازدیدترین محصولات', 'viewAllUrl' => '/products?sort=popular', 'query' => fn ($q) => $q->orderByDesc('seen')], + ]; + + return array_values(array_filter(array_map(function (array $row): array { + $products = Product::query() + ->published() + ->with(['featuredImage', 'varieties' => fn ($q) => $q->published()]) + ->tap($row['query']) + ->limit(self::ROW_LIMIT) + ->get() + ->map(fn (Product $product): array => $this->productCard($product)) + ->all(); + + return [ + 'title' => $row['title'], + 'viewAllUrl' => $row['viewAllUrl'], + 'products' => $products, + ]; + }, $rows), fn (array $row): bool => $row['products'] !== [])); + } + + /** + * @return array> + */ + private function brands(): array + { + return Brand::query() + ->active() + ->with('image') + ->orderBy('heading') + ->limit(self::ROW_LIMIT) + ->get() + ->map(fn (Brand $brand): array => [ + 'id' => $brand->id, + 'heading' => $brand->heading, + 'url' => '/brands/'.$brand->slug, + 'image' => $this->image($brand->image), + ]) + ->all(); + } + + /** + * Build a lightweight product card payload, deriving the lowest available + * price (and sale price) from the product's published varieties. + * + * @return array + */ + private function productCard(Product $product): array + { + /** @var Collection $varieties */ + $varieties = $product->varieties; + + // Prices are stored as decimals but represent whole Tomans; cast to int. + $price = (int) ($varieties->min('price') ?? $product->price); + + $rawSalePrice = $varieties + ->filter(fn ($variety): bool => $variety->sale_price !== null) + ->min('sale_price'); + $salePrice = $rawSalePrice === null ? null : (int) $rawSalePrice; + + $hasDiscount = $salePrice !== null && $salePrice < $price; + + return [ + 'id' => $product->id, + 'heading' => $product->heading, + 'url' => '/products/'.$product->slug, + 'image' => $this->image($product->featuredImage), + 'price' => $price, + 'salePrice' => $hasDiscount ? $salePrice : null, + 'discountPercent' => $hasDiscount ? (int) round((($price - $salePrice) / $price) * 100) : null, + ]; + } + + /** + * @return array{url: string, alt: string}|null + */ + private function image(?Image $image): ?array + { + if ($image === null) { + return null; + } + + return [ + 'url' => $image->url, + 'alt' => (string) ($image->alt_text ?? ''), + ]; + } +} diff --git a/shop/app/Http/Middleware/HandleInertiaRequests.php b/shop/app/Http/Middleware/HandleInertiaRequests.php index 68f8e410..d53c8dcf 100644 --- a/shop/app/Http/Middleware/HandleInertiaRequests.php +++ b/shop/app/Http/Middleware/HandleInertiaRequests.php @@ -4,6 +4,7 @@ namespace App\Http\Middleware; +use App\Models\Category; use App\Models\Setting; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -48,6 +49,9 @@ public function share(Request $request): array 'url' => $this->canonicalUrl($request), 'locale' => 'fa_IR', ], + 'nav' => [ + 'categories' => $this->navCategories(), + ], 'footer' => [ 'about' => $this->value($settings, 'footer_about'), 'columns' => [ @@ -67,6 +71,38 @@ public function share(Request $request): array ]; } + /** + * Top-level active categories (with their active children) for the header + * navigation. Shared globally so every page's header has the menu. + * + * Guarded with rescue() so the storefront still renders if the shared + * categories table is unavailable. + * + * @return array> + */ + private function navCategories(): array + { + return rescue(function (): array { + return Category::query() + ->active() + ->whereNull('parent_id') + ->with(['children' => fn ($query) => $query->active()->orderBy('heading')]) + ->orderBy('heading') + ->get() + ->map(fn (Category $category): array => [ + 'heading' => $category->heading, + 'url' => '/categories/'.$category->slug, + 'children' => $category->children + ->map(fn (Category $child): array => [ + 'heading' => $child->heading, + 'url' => '/categories/'.$child->slug, + ]) + ->all(), + ]) + ->all(); + }, [], false); + } + /** * Build the canonical/og URL from the configured public origin (APP_URL) * rather than the raw request, so it stays correct behind TLS-terminating diff --git a/shop/app/Models/Image.php b/shop/app/Models/Image.php index c25e70c9..a12d0049 100644 --- a/shop/app/Models/Image.php +++ b/shop/app/Models/Image.php @@ -5,8 +5,10 @@ namespace App\Models; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Support\Str; /** * @property positive-int $id @@ -18,6 +20,7 @@ * @property positive-int|null $order * @property Carbon|null $created_at * @property Carbon|null $updated_at + * @property-read string $url */ class Image extends Model { @@ -39,4 +42,27 @@ public function imageable(): MorphTo { return $this->morphTo(); } + + /** + * Resolve the stored relative `path` into a full URL. + * + * `images.path` is relative to the CDN/asset host (see the db doc); we + * prefix it with `app.asset_url` when set, leaving already-absolute URLs + * untouched. + */ + protected function url(): Attribute + { + return Attribute::get(function (): string { + $path = (string) $this->path; + + if ($path === '' || Str::startsWith($path, ['http://', 'https://', '//'])) { + return $path; + } + + $base = rtrim((string) config('app.asset_url'), '/'); + $path = ltrim($path, '/'); + + return $base === '' ? '/'.$path : $base.'/'.$path; + }); + } } diff --git a/shop/app/Models/Variety.php b/shop/app/Models/Variety.php index 3b235e1e..4905aaba 100644 --- a/shop/app/Models/Variety.php +++ b/shop/app/Models/Variety.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphOne; /** * @property positive-int $id @@ -27,6 +28,7 @@ * @property Attribute|null $attribute * @property Collection $attributes * @property Collection $reviews + * @property Image|null $image */ class Variety extends Model { @@ -62,6 +64,11 @@ public function attribute(): BelongsTo return $this->belongsTo(Attribute::class); } + public function image(): MorphOne + { + return $this->morphOne(Image::class, 'imageable'); + } + public function attributes(): BelongsToMany { return $this->belongsToMany(Attribute::class)->withTimestamps(); diff --git a/shop/config/inertia.php b/shop/config/inertia.php new file mode 100644 index 00000000..9bc81bd5 --- /dev/null +++ b/shop/config/inertia.php @@ -0,0 +1,44 @@ + [ + + 'ensure_pages_exist' => false, + + 'paths' => [ + resource_path('js/Pages'), + ], + + 'extensions' => [ + 'js', + 'jsx', + 'svelte', + 'ts', + 'tsx', + 'vue', + ], + + ], + + 'testing' => [ + + 'ensure_pages_exist' => true, + + ], + +]; diff --git a/shop/docs/STOREFRONT_IMPLEMENTATION.md b/shop/docs/STOREFRONT_IMPLEMENTATION.md index 3e6f3f61..8e8bd5a2 100644 --- a/shop/docs/STOREFRONT_IMPLEMENTATION.md +++ b/shop/docs/STOREFRONT_IMPLEMENTATION.md @@ -23,7 +23,7 @@ A storefront feature is "done" when it has: read/write Eloquent models for the s - [x] `A Iranian Sans` font + brand color `#ff8615` - [x] Base `AppLayout` + sample `Home` page - [x] Eloquent models mapping shared tables (read-focused): `Category`, `Brand`, `Product`, `Variety`, `Attribute`, `Image`, `Banner`, `Slider`/`Slide`, `Menu`/`MenuItem`, `Page`, `Faq`, `Review` (+ status enums and `HasOptions` trait). Relations to not-yet-created models (`AttributeGroup`, `Coupon`, `Discount`) are deferred to their phases. -- [~] Shared UI kit components: `BaseButton` + `AppLink` + `Icon` done; `PriceTag`, `ProductCard`, `QuantityInput`, `Breadcrumbs`, `Pagination`, `RatingStars`, `EmptyState` pending +- [~] Shared UI kit components: `BaseButton` + `AppLink` + `Icon` + `PriceTag` + `ProductCard` + `SectionHeading` done; `QuantityInput`, `Breadcrumbs`, `Pagination`, `RatingStars`, `EmptyState` pending - [x] Helpers/composables: Persian digits, Jalali date, money formatting (`useFormat`) - [x] SEO scaffolding: per-page `` via `AppHead` component, shared meta defaults (Inertia `seo` shared prop), canonical URL, Open Graph + Twitter, `robots.txt` - [x] Error pages (404 / 500) in Persian, RTL (self-contained Blade, brand color, IranSans, no Vite/SSR dependency) @@ -35,7 +35,7 @@ A storefront feature is "done" when it has: read/write Eloquent models for the s Read-only catalog. This is where SEO and SSR matter most. -- [ ] Home page: published banners, slider, featured categories, product rows, menu (uses `CACHE.md` keys 1, 2, 8, 3) +- [x] Home page: `HomeController` + `Home.vue` with hero slider, category strip, promo banner grid, product carousels (newest + most viewed), selected brands; JSON-LD `Organization`/`WebSite`; graceful empty states; feature tests. Caching (`CACHE.md` keys 1, 2, 8, 3) deferred to Phase 6 - [ ] Category listing page: products by category with filters (brand, attributes, price range), sorting, pagination - [ ] Product detail page: image gallery, variety selection (price + discount + stock from `varieties`), attributes, related products, breadcrumbs - [ ] Product reviews (read) on the product page; ratings summary diff --git a/shop/resources/js/Components/AppHead.vue b/shop/resources/js/Components/AppHead.vue index 3396fb18..50c0139b 100644 --- a/shop/resources/js/Components/AppHead.vue +++ b/shop/resources/js/Components/AppHead.vue @@ -27,6 +27,10 @@ const props = defineProps({ type: Boolean, default: false, }, + jsonLd: { + type: [Object, Array], + default: null, + }, }); const page = usePage(); @@ -38,6 +42,7 @@ const canonicalUrl = computed(() => props.canonical || seo.value.url || ''); const fullTitle = computed(() => props.title ? `${props.title} - ${siteName.value}` : siteName.value, ); +const jsonLdString = computed(() => (props.jsonLd ? JSON.stringify(props.jsonLd) : '')); diff --git a/shop/resources/js/Components/Header/AppHeader.vue b/shop/resources/js/Components/Header/AppHeader.vue new file mode 100644 index 00000000..596b774b --- /dev/null +++ b/shop/resources/js/Components/Header/AppHeader.vue @@ -0,0 +1,85 @@ + + + diff --git a/shop/resources/js/Components/Header/CategoryMenu.vue b/shop/resources/js/Components/Header/CategoryMenu.vue new file mode 100644 index 00000000..538f0b09 --- /dev/null +++ b/shop/resources/js/Components/Header/CategoryMenu.vue @@ -0,0 +1,50 @@ + + + diff --git a/shop/resources/js/Components/Header/HeaderActions.vue b/shop/resources/js/Components/Header/HeaderActions.vue new file mode 100644 index 00000000..21ba5331 --- /dev/null +++ b/shop/resources/js/Components/Header/HeaderActions.vue @@ -0,0 +1,26 @@ + + + diff --git a/shop/resources/js/Components/Header/HeaderSearch.vue b/shop/resources/js/Components/Header/HeaderSearch.vue new file mode 100644 index 00000000..8bc73b06 --- /dev/null +++ b/shop/resources/js/Components/Header/HeaderSearch.vue @@ -0,0 +1,36 @@ + + + diff --git a/shop/resources/js/Components/Home/BannerGrid.vue b/shop/resources/js/Components/Home/BannerGrid.vue new file mode 100644 index 00000000..ab781a0a --- /dev/null +++ b/shop/resources/js/Components/Home/BannerGrid.vue @@ -0,0 +1,35 @@ + + + diff --git a/shop/resources/js/Components/Home/BrandStrip.vue b/shop/resources/js/Components/Home/BrandStrip.vue new file mode 100644 index 00000000..1376d4d7 --- /dev/null +++ b/shop/resources/js/Components/Home/BrandStrip.vue @@ -0,0 +1,37 @@ + + + diff --git a/shop/resources/js/Components/Home/CategoryStrip.vue b/shop/resources/js/Components/Home/CategoryStrip.vue new file mode 100644 index 00000000..97daa56a --- /dev/null +++ b/shop/resources/js/Components/Home/CategoryStrip.vue @@ -0,0 +1,43 @@ + + + diff --git a/shop/resources/js/Components/Home/HeroSlider.vue b/shop/resources/js/Components/Home/HeroSlider.vue new file mode 100644 index 00000000..1cab0693 --- /dev/null +++ b/shop/resources/js/Components/Home/HeroSlider.vue @@ -0,0 +1,109 @@ + + + diff --git a/shop/resources/js/Components/Home/ProductCarousel.vue b/shop/resources/js/Components/Home/ProductCarousel.vue new file mode 100644 index 00000000..9683513f --- /dev/null +++ b/shop/resources/js/Components/Home/ProductCarousel.vue @@ -0,0 +1,71 @@ + + + diff --git a/shop/resources/js/Components/PriceTag.vue b/shop/resources/js/Components/PriceTag.vue new file mode 100644 index 00000000..510f7090 --- /dev/null +++ b/shop/resources/js/Components/PriceTag.vue @@ -0,0 +1,47 @@ + + + diff --git a/shop/resources/js/Components/ProductCard.vue b/shop/resources/js/Components/ProductCard.vue new file mode 100644 index 00000000..c46a5313 --- /dev/null +++ b/shop/resources/js/Components/ProductCard.vue @@ -0,0 +1,49 @@ + + + diff --git a/shop/resources/js/Components/SectionHeading.vue b/shop/resources/js/Components/SectionHeading.vue new file mode 100644 index 00000000..937855c8 --- /dev/null +++ b/shop/resources/js/Components/SectionHeading.vue @@ -0,0 +1,37 @@ + + + diff --git a/shop/resources/js/Layouts/AppLayout.vue b/shop/resources/js/Layouts/AppLayout.vue index 14b4e9cd..3342cdbb 100644 --- a/shop/resources/js/Layouts/AppLayout.vue +++ b/shop/resources/js/Layouts/AppLayout.vue @@ -1,23 +1,18 @@