From ba8b231d28e87a3cdf9210c22b2858c4fabacb6d Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Fri, 24 Jul 2026 19:13:37 +0330 Subject: [PATCH 01/40] feat(admin): constrain slider position to a SliderPositionEnum dropdown Replaces the free-text `position` field with a Select backed by a new SliderPositionEnum (home-main, home-secondary, category-top, product-side). The factory now derives its position from the enum, and the table column shows the readable label. Prevents typos that would silently break the storefront's slider lookup. Lang + db doc updated. --- admin/app/Enums/SliderPositionEnum.php | 27 +++++++++++++++++++ .../app/Filament/Resources/SliderResource.php | 7 +++-- admin/database/factories/SliderFactory.php | 3 ++- admin/docs/ShoFlow db doc.md | 2 +- admin/lang/en/slider.php | 6 ++++- admin/lang/fa/slider.php | 6 ++++- 6 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 admin/app/Enums/SliderPositionEnum.php diff --git a/admin/app/Enums/SliderPositionEnum.php b/admin/app/Enums/SliderPositionEnum.php new file mode 100644 index 00000000..879b9c3e --- /dev/null +++ b/admin/app/Enums/SliderPositionEnum.php @@ -0,0 +1,27 @@ + trans('slider.position_home_main'), + self::HOME_SECONDARY => trans('slider.position_home_secondary'), + self::CATEGORY_TOP => trans('slider.position_category_top'), + self::PRODUCT_SIDE => trans('slider.position_product_side'), + }; + } +} diff --git a/admin/app/Filament/Resources/SliderResource.php b/admin/app/Filament/Resources/SliderResource.php index 2b6014f3..f1556da5 100644 --- a/admin/app/Filament/Resources/SliderResource.php +++ b/admin/app/Filament/Resources/SliderResource.php @@ -4,6 +4,7 @@ namespace App\Filament\Resources; +use App\Enums\SliderPositionEnum; use App\Enums\SliderStatusEnum; use App\Filament\Resources\SliderResource\Pages\CreateSlider; use App\Filament\Resources\SliderResource\Pages\EditSlider; @@ -52,10 +53,11 @@ public static function form(Schema $schema): Schema ->maxLength(255) ->hintIcon('heroicon-o-information-circle') ->hintIconTooltip(trans('slider.name_hint')), - TextInput::make('position') + Select::make('position') ->label(trans('slider.position')) ->required() - ->maxLength(255) + ->options(SliderPositionEnum::options()) + ->native(false) ->hintIcon('heroicon-o-information-circle') ->hintIconTooltip(trans('slider.position_hint')), Select::make('status') @@ -77,6 +79,7 @@ public static function table(Table $table): Table ->searchable(), TextColumn::make('position') ->label(trans('slider.position')) + ->formatStateUsing(fn (string $state): string => SliderPositionEnum::tryFrom($state)?->label() ?? $state) ->searchable(), TextColumn::make('slides_count') ->label(trans('slider.slides_count')) diff --git a/admin/database/factories/SliderFactory.php b/admin/database/factories/SliderFactory.php index 45811220..fc22783c 100644 --- a/admin/database/factories/SliderFactory.php +++ b/admin/database/factories/SliderFactory.php @@ -4,6 +4,7 @@ namespace Database\Factories; +use App\Enums\SliderPositionEnum; use App\Enums\SliderStatusEnum; use App\Models\Slider; use Illuminate\Database\Eloquent\Factories\Factory; @@ -22,7 +23,7 @@ public function definition(): array { return [ 'name' => fake()->words(3, true), - 'position' => fake()->randomElement(['home-main', 'home-secondary', 'category-top', 'product-side']), + 'position' => fake()->randomElement(SliderPositionEnum::cases())->value, 'status' => fake()->randomElement(SliderStatusEnum::cases()), ]; } diff --git a/admin/docs/ShoFlow db doc.md b/admin/docs/ShoFlow db doc.md index 1a67fcfc..b59b0816 100644 --- a/admin/docs/ShoFlow db doc.md +++ b/admin/docs/ShoFlow db doc.md @@ -731,7 +731,7 @@ A specific service tier offered by a shipping carrier. References `shipping_line * For creating various sliders. * `name`: Human-readable label for the slider, e.g. "Home Page Main Slider." Not shown on the frontend. -* `position`: The key used by the frontend to fetch this slider, e.g. "home-main". Must be unique per placement. +* `position`: Where the frontend shows this slider (e.g. `home-main`). Constrained to `App\Enums\SliderPositionEnum` (mirrored in both apps): `home-main`, `home-secondary`, `category-top`, `product-side`. The admin picks it from a dropdown; the storefront looks it up by the same enum value. Keep one published slider per position (the column is not DB-unique; the frontend takes the first published match). Only `home-main` is rendered so far (home hero). * `status`: Publication status — 10 for deleted, 20 for published (default), 30 for draft. * Deleting a slider cascades to its slides (and their images). diff --git a/admin/lang/en/slider.php b/admin/lang/en/slider.php index bfc41299..97d9a4f5 100644 --- a/admin/lang/en/slider.php +++ b/admin/lang/en/slider.php @@ -11,7 +11,11 @@ 'name' => 'Name', 'name_hint' => 'A human-readable label for this slider, e.g. "Home Page Main Slider". Not shown on the frontend.', 'position' => 'Position', - 'position_hint' => 'The position key used by the frontend to fetch this slider, e.g. "home-main". Must be unique per placement.', + 'position_hint' => 'Where this slider appears on the storefront. Pick from the fixed list of placements the frontend knows about; keep one published slider per position.', + 'position_home_main' => 'Home — main banner', + 'position_home_secondary' => 'Home — secondary banner', + 'position_category_top' => 'Category page — top', + 'position_product_side' => 'Product page — sidebar', 'status' => 'Status', 'slides_count' => 'Slides', 'created_at' => 'Created At', diff --git a/admin/lang/fa/slider.php b/admin/lang/fa/slider.php index ddfd52cf..a34a1652 100644 --- a/admin/lang/fa/slider.php +++ b/admin/lang/fa/slider.php @@ -11,7 +11,11 @@ 'name' => 'نام', 'name_hint' => 'برچسب قابل خواندن برای این اسلایدر، مثلاً "اسلایدر اصلی صفحه اصلی". در فرانت‌اند نمایش داده نمی‌شود.', 'position' => 'موقعیت', - 'position_hint' => 'کلید موقعیتی که فرانت‌اند برای دریافت این اسلایدر استفاده می‌کند، مثلاً "home-main". باید یکتا باشد.', + 'position_hint' => 'محل نمایش این اسلایدر در فروشگاه. از فهرست ثابت موقعیت‌هایی که فرانت‌اند می‌شناسد انتخاب کنید؛ برای هر موقعیت یک اسلایدر منتشرشده نگه دارید.', + 'position_home_main' => 'صفحه خانه — بنر اصلی', + 'position_home_secondary' => 'صفحه خانه — بنر دوم', + 'position_category_top' => 'صفحه دسته‌بندی — بالا', + 'position_product_side' => 'صفحه محصول — کنار', 'status' => 'وضعیت', 'slides_count' => 'اسلایدها', 'created_at' => 'تاریخ ایجاد', From a6950d653a4370ad00f12587c74d52c10e54d507 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Fri, 24 Jul 2026 19:14:11 +0330 Subject: [PATCH 02/40] feat(shop): slider position lookup + full server-side Persian lang system Slider: mirror the admin SliderPositionEnum in shop; rename GetHeroSlides -> GetSliderByPosition(SliderPositionEnum) and have HomeController pass HOME_MAIN, so the admin's assigned position and the storefront lookup share one enum value instead of a hardcoded string. Localization: move ALL server-side user-facing Persian out of PHP into Laravel's lang system, default locale fa (APP_LOCALE=fa in .env, .env.example and phpunit.xml; fallback_locale=en so framework strings still resolve). Three lang/fa files: - enums.php: every enum label() (keyed enums..) - messages.php: flash/withErrors/abort strings, breadcrumbs, footer titles, home row titles, payment/result messages (payment.order_number takes an :id), deleted_product/guest_user fallbacks - validation.php: standard Laravel rules + attributes, so AddressController drops its inline messages/attributes and every form gets Persian errors Digit-normalization maps and code comments are intentionally left as-is. Docs/AGENTS/tests updated; a HomePageTest case asserts a non-home-main slider doesn't leak onto the hero. --- shop/.env.example | 5 +- shop/AGENTS.md | 7 +- shop/app/Actions/Account/GetUserOrders.php | 2 +- .../Actions/Brand/BuildBrandBreadcrumbs.php | 2 +- .../Category/BuildCategoryBreadcrumbs.php | 2 +- shop/app/Actions/Checkout/BuildOrderDTO.php | 2 +- .../Checkout/CompleteCheckoutPayment.php | 6 +- .../Actions/Checkout/OpenZarinpalSession.php | 2 +- shop/app/Actions/Home/GetProductRows.php | 4 +- ...HeroSlides.php => GetSliderByPosition.php} | 17 ++-- .../Product/BuildProductBreadcrumbs.php | 2 +- shop/app/Enums/BannerStatusEnum.php | 6 +- shop/app/Enums/BrandStatusEnum.php | 4 +- shop/app/Enums/CategoryStatusEnum.php | 4 +- shop/app/Enums/OrderSrcEnum.php | 8 +- shop/app/Enums/OrderStatusEnum.php | 14 +-- shop/app/Enums/PageStatusEnum.php | 8 +- shop/app/Enums/ProductStatusEnum.php | 6 +- shop/app/Enums/ReviewStatusEnum.php | 8 +- shop/app/Enums/SliderPositionEnum.php | 34 +++++++ shop/app/Enums/SliderStatusEnum.php | 6 +- shop/app/Enums/TransactionPortEnum.php | 6 +- shop/app/Enums/TransactionStatusEnum.php | 8 +- shop/app/Enums/UserStatusEnum.php | 4 +- shop/app/Enums/VarietyStatusEnum.php | 6 +- .../Http/Controllers/AccountController.php | 12 +-- .../Http/Controllers/AddressController.php | 32 ++----- shop/app/Http/Controllers/AuthController.php | 10 +-- shop/app/Http/Controllers/CartController.php | 6 +- .../Http/Controllers/CheckoutController.php | 10 +-- shop/app/Http/Controllers/FaqController.php | 4 +- shop/app/Http/Controllers/HomeController.php | 7 +- shop/app/Http/Controllers/PageController.php | 2 +- .../Http/Controllers/PaymentController.php | 12 +-- .../app/Http/Controllers/ReviewController.php | 2 +- .../Http/Controllers/WishlistController.php | 2 +- .../Http/Middleware/HandleInertiaRequests.php | 6 +- shop/app/Models/User.php | 2 +- shop/config/app.php | 2 +- shop/docs/ShoFlow db doc.md | 2 +- shop/lang/fa/enums.php | 82 +++++++++++++++++ shop/lang/fa/messages.php | 88 +++++++++++++++++++ shop/lang/fa/validation.php | 58 ++++++++++++ shop/phpunit.xml | 4 + shop/tests/Feature/HomePageTest.php | 16 ++++ 45 files changed, 400 insertions(+), 132 deletions(-) rename shop/app/Actions/Home/{GetHeroSlides.php => GetSliderByPosition.php} (66%) create mode 100644 shop/app/Enums/SliderPositionEnum.php create mode 100644 shop/lang/fa/enums.php create mode 100644 shop/lang/fa/messages.php create mode 100644 shop/lang/fa/validation.php diff --git a/shop/.env.example b/shop/.env.example index 2165eb58..0e802777 100644 --- a/shop/.env.example +++ b/shop/.env.example @@ -10,7 +10,10 @@ APP_URL=http://localhost # keep loading from this app. Leave empty if all images use absolute URLs. IMAGE_URL=http://127.0.0.1:4040/storage -APP_LOCALE=en +# Storefront is Persian-only, so the default locale is fa. Fallback stays en +# so framework strings (validation, pagination) still resolve when a fa +# translation is missing. +APP_LOCALE=fa APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US diff --git a/shop/AGENTS.md b/shop/AGENTS.md index a69110a1..8fc94ce2 100644 --- a/shop/AGENTS.md +++ b/shop/AGENTS.md @@ -225,6 +225,7 @@ The shop UI uses **Inertia + Vue 3** (SSR enabled). Clean, readable code is a ha ## Language, RTL & fonts - **The storefront is Persian only.** There is no language switcher and no English UI. Set `` in the root template, and write all user-facing text in Persian. +- **Server-side user-facing strings go through Laravel's lang system, not hardcoded Persian literals in PHP.** The default locale is `fa` (`config('app.locale')` / `APP_LOCALE=fa` in both `.env` and `phpunit.xml`); `fallback_locale` stays `en` so framework strings still resolve when a `fa` key is missing. Reference translations with `trans('file.key')`. The `lang/fa/` files are: **`enums.php`** (every enum `label()` — order/transaction/product/category/brand/page/banner/variety/user/review status, order src, transaction port, slider status + position — keyed `enums..`), **`messages.php`** (controller/action flash + `withErrors` + `abort` strings, breadcrumb labels, footer titles, home row titles, payment/result messages incl. `payment.order_number` with an `:id` placeholder, and UI fallbacks like `deleted_product`/`guest_user`), and **`validation.php`** (the standard Laravel file — rule messages + an `attributes` map; because it exists, controllers just call `$request->validate([...rules...])` with NO inline `$messages`/`$attributes`, and every form gets Persian errors for free). **Do NOT move data or comments**: Persian/Arabic *digit-normalization maps* (`NormalizeMobile`, `AuthController`, `AddressController::toEnglishDigits`) are data, and Persian in code comments stays. Vue-side text is still written inline in Persian in the components (not in lang files). - Build RTL-first: use Tailwind logical utilities (`ms-`, `me-`, `ps-`, `pe-`, `start-`, `end-`) instead of left/right so layout flows right to left. - **Font: `A Iranian Sans` (IranSans).** Reuse the same font as the admin app. The file lives in admin at `public/fonts/AIranianSans.ttf`; copy it into this app's `public/fonts/AIranianSans.ttf` and load it with an `@font-face` (family name `A Iranian Sans`), then set it as the default `font-family` on `body`. - Show Persian digits and Jalali (Shamsi) dates. Format on the server so SSR output is already correct. @@ -275,9 +276,9 @@ Keep controllers thin and push logic into single-purpose actions that return typ ## Enums -- Backed enums (usually `int`) in `app/Enums/`, e.g. `ProductStatusEnum: int` with explicit case values (`DELETED = 10`). -- Provide `label(): string` and, where shown to users, `color(): string`, both using `match ($this)`. -- Mirror the admin app's enum values so both apps agree on the shared schema. +- Backed enums (usually `int`; string-backed when the column stores a slug, e.g. `SliderPositionEnum: string` mapping `HOME_MAIN => 'home-main'`) in `app/Enums/`, with explicit case values (`DELETED = 10`). +- Provide `label(): string` via `match ($this)`. New enums return the label through `trans('domain.key')` (see the Language section) — not a hardcoded Persian literal. `color()` is NOT defined on shop enums (badge colors are client-side, e.g. `useOrderStatus.js`); that stays admin/Filament-only. +- Mirror the admin app's enum values so both apps agree on the shared schema. **Constrain shared "position/location" string columns with an enum** (e.g. `sliders.position` → `SliderPositionEnum`, used both by the admin Filament `Select` and the storefront lookup) rather than free text, so the value an admin picks and the value the frontend queries can never drift. ## Migrations diff --git a/shop/app/Actions/Account/GetUserOrders.php b/shop/app/Actions/Account/GetUserOrders.php index 341800fa..11d1a542 100644 --- a/shop/app/Actions/Account/GetUserOrders.php +++ b/shop/app/Actions/Account/GetUserOrders.php @@ -83,7 +83,7 @@ private function card(Order $order): array 'totalPrice' => $order->total_price, 'itemCount' => (int) $order->orderVarieties->sum('quantity'), // @phpstan-ignore nullsafe.neverNull (nullable snapshot FK; see BuildOrderDTO::line()) - 'firstItemHeading' => $product?->heading ?? 'محصول حذف‌شده', + 'firstItemHeading' => $product?->heading ?? trans('messages.deleted_product'), 'image' => $image?->toArray(), 'url' => '/account/orders/'.$order->id, ]; diff --git a/shop/app/Actions/Brand/BuildBrandBreadcrumbs.php b/shop/app/Actions/Brand/BuildBrandBreadcrumbs.php index 1fedf270..4699982b 100644 --- a/shop/app/Actions/Brand/BuildBrandBreadcrumbs.php +++ b/shop/app/Actions/Brand/BuildBrandBreadcrumbs.php @@ -16,7 +16,7 @@ class BuildBrandBreadcrumbs public function __invoke(Brand $brand): array { return [ - ['heading' => 'خانه', 'url' => '/'], + ['heading' => trans('messages.breadcrumb.home'), 'url' => '/'], ['heading' => $brand->heading, 'url' => null], ]; } diff --git a/shop/app/Actions/Category/BuildCategoryBreadcrumbs.php b/shop/app/Actions/Category/BuildCategoryBreadcrumbs.php index 83f800ca..8a8a32be 100644 --- a/shop/app/Actions/Category/BuildCategoryBreadcrumbs.php +++ b/shop/app/Actions/Category/BuildCategoryBreadcrumbs.php @@ -28,7 +28,7 @@ public function __invoke(Category $category): array } return [ - ['heading' => 'خانه', 'url' => '/'], + ['heading' => trans('messages.breadcrumb.home'), 'url' => '/'], ...$chain, ['heading' => $category->heading, 'url' => null], ]; diff --git a/shop/app/Actions/Checkout/BuildOrderDTO.php b/shop/app/Actions/Checkout/BuildOrderDTO.php index f3a48afc..9c047ca5 100644 --- a/shop/app/Actions/Checkout/BuildOrderDTO.php +++ b/shop/app/Actions/Checkout/BuildOrderDTO.php @@ -75,7 +75,7 @@ private function line(OrderVariety $line): OrderLineDTO return new OrderLineDTO( // @phpstan-ignore nullsafe.neverNull (see comment above) - heading: $product?->heading ?? 'محصول حذف‌شده', + heading: $product?->heading ?? trans('messages.deleted_product'), url: $product === null ? null : '/products/'.$product->slug, image: $image, color: $variety?->color, diff --git a/shop/app/Actions/Checkout/CompleteCheckoutPayment.php b/shop/app/Actions/Checkout/CompleteCheckoutPayment.php index e2e04fff..7ea412c0 100644 --- a/shop/app/Actions/Checkout/CompleteCheckoutPayment.php +++ b/shop/app/Actions/Checkout/CompleteCheckoutPayment.php @@ -44,7 +44,7 @@ public function __invoke(string $authority, string $status): ?Order } if ($status !== 'OK') { - $this->fail($order, $transaction, TransactionStatusEnum::CANCELED, null, 'پرداخت توسط کاربر لغو شد.'); + $this->fail($order, $transaction, TransactionStatusEnum::CANCELED, null, trans('messages.payment.canceled_by_user')); return null; } @@ -52,7 +52,7 @@ public function __invoke(string $authority, string $status): ?Order $verified = ($this->verifyPayment)($authority, $order->total_price); if ($verified === null || ! in_array($verified['code'], [100, 101], true)) { - $this->fail($order, $transaction, TransactionStatusEnum::FAILED, $verified['code'] ?? null, 'تأیید پرداخت ناموفق بود.'); + $this->fail($order, $transaction, TransactionStatusEnum::FAILED, $verified['code'] ?? null, trans('messages.payment.verify_failed')); return null; } @@ -94,7 +94,7 @@ private function failPaidButOversold(Order $order, Transaction $transaction, ?st 'ref_id' => $refId, 'paid_at' => now(), 'result_code' => '100', - 'result_message' => 'پرداخت با موفقیت انجام شد ولی موجودی کالا کافی نبود — نیاز به بازگشت وجه به مشتری.', + 'result_message' => trans('messages.payment.paid_but_oversold'), ]); } } diff --git a/shop/app/Actions/Checkout/OpenZarinpalSession.php b/shop/app/Actions/Checkout/OpenZarinpalSession.php index 2c07a06f..8947a035 100644 --- a/shop/app/Actions/Checkout/OpenZarinpalSession.php +++ b/shop/app/Actions/Checkout/OpenZarinpalSession.php @@ -34,7 +34,7 @@ public function __invoke(Order $order, User $user, string $callbackUrl, string $ $result = ($this->requestPayment)( $order->total_price, - 'سفارش شماره '.$order->id, + trans('messages.payment.order_number', ['id' => $order->id]), $callbackUrl, $user->hasPlaceholderEmail() ? null : $user->email, $user->mobile, diff --git a/shop/app/Actions/Home/GetProductRows.php b/shop/app/Actions/Home/GetProductRows.php index 851c7b7e..acb50d08 100644 --- a/shop/app/Actions/Home/GetProductRows.php +++ b/shop/app/Actions/Home/GetProductRows.php @@ -27,8 +27,8 @@ public function __construct(private BuildProductCard $buildProductCard) {} public function __invoke(): array { $rows = [ - ['title' => 'جدیدترین محصولات', 'viewAllUrl' => '/products?sort=newest', 'query' => fn (Builder $q) => $q->latest('id')], - ['title' => 'پربازدیدترین محصولات', 'viewAllUrl' => '/products?sort=popular', 'query' => fn (Builder $q) => $q->orderByDesc('seen')], + ['title' => trans('messages.home.row_newest'), 'viewAllUrl' => '/products?sort=newest', 'query' => fn (Builder $q) => $q->latest('id')], + ['title' => trans('messages.home.row_popular'), 'viewAllUrl' => '/products?sort=popular', 'query' => fn (Builder $q) => $q->orderByDesc('seen')], ]; return array_values(array_filter(array_map(function (array $row): array { diff --git a/shop/app/Actions/Home/GetHeroSlides.php b/shop/app/Actions/Home/GetSliderByPosition.php similarity index 66% rename from shop/app/Actions/Home/GetHeroSlides.php rename to shop/app/Actions/Home/GetSliderByPosition.php index 55a798dd..182069c7 100644 --- a/shop/app/Actions/Home/GetHeroSlides.php +++ b/shop/app/Actions/Home/GetSliderByPosition.php @@ -5,27 +5,28 @@ namespace App\Actions\Home; use App\Actions\Catalog\TransformImage; +use App\Enums\SliderPositionEnum; use App\Models\Slide; use App\Models\Slider; use Illuminate\Database\Eloquent\Relations\Relation; -class GetHeroSlides +class GetSliderByPosition { - /** - * Admin-defined slider position the storefront home reads. - */ - private const POSITION = 'home-main'; - public function __construct(private TransformImage $transformImage) {} /** + * The published slider assigned to a given position, as an ordered list of + * its slides. Empty when no published slider is assigned there. The + * position is a SliderPositionEnum (shared with the admin) rather than a + * loose string, so the admin's choice and this lookup can't drift apart. + * * @return array> */ - public function __invoke(): array + public function __invoke(SliderPositionEnum $position): array { $slider = Slider::query() ->published() - ->where('position', self::POSITION) + ->where('position', $position->value) ->with(['slides' => fn (Relation $query) => $query->orderBy('order'), 'slides.image']) ->first(); diff --git a/shop/app/Actions/Product/BuildProductBreadcrumbs.php b/shop/app/Actions/Product/BuildProductBreadcrumbs.php index 7a6c5f92..508e2e58 100644 --- a/shop/app/Actions/Product/BuildProductBreadcrumbs.php +++ b/shop/app/Actions/Product/BuildProductBreadcrumbs.php @@ -28,7 +28,7 @@ public function __invoke(Product $product): array } return [ - ['heading' => 'خانه', 'url' => '/'], + ['heading' => trans('messages.breadcrumb.home'), 'url' => '/'], ...$chain, ['heading' => $product->heading, 'url' => null], ]; diff --git a/shop/app/Enums/BannerStatusEnum.php b/shop/app/Enums/BannerStatusEnum.php index 9c45c981..9e5eb62b 100644 --- a/shop/app/Enums/BannerStatusEnum.php +++ b/shop/app/Enums/BannerStatusEnum.php @@ -17,9 +17,9 @@ enum BannerStatusEnum: int public function label(): string { return match ($this) { - self::DELETED => 'حذف شده', - self::PUBLISHED => 'منتشر شده', - self::DRAFT => 'پیش‌نویس', + self::DELETED => trans('enums.banner_status.deleted'), + self::PUBLISHED => trans('enums.banner_status.published'), + self::DRAFT => trans('enums.banner_status.draft'), }; } } diff --git a/shop/app/Enums/BrandStatusEnum.php b/shop/app/Enums/BrandStatusEnum.php index 9b5383ec..15a37395 100644 --- a/shop/app/Enums/BrandStatusEnum.php +++ b/shop/app/Enums/BrandStatusEnum.php @@ -16,8 +16,8 @@ enum BrandStatusEnum: int public function label(): string { return match ($this) { - self::ACTIVE => 'فعال', - self::INACTIVE => 'غیرفعال', + self::ACTIVE => trans('enums.brand_status.active'), + self::INACTIVE => trans('enums.brand_status.inactive'), }; } } diff --git a/shop/app/Enums/CategoryStatusEnum.php b/shop/app/Enums/CategoryStatusEnum.php index 4364e901..9a326f43 100644 --- a/shop/app/Enums/CategoryStatusEnum.php +++ b/shop/app/Enums/CategoryStatusEnum.php @@ -16,8 +16,8 @@ enum CategoryStatusEnum: int public function label(): string { return match ($this) { - self::ACTIVE => 'فعال', - self::INACTIVE => 'غیرفعال', + self::ACTIVE => trans('enums.category_status.active'), + self::INACTIVE => trans('enums.category_status.inactive'), }; } } diff --git a/shop/app/Enums/OrderSrcEnum.php b/shop/app/Enums/OrderSrcEnum.php index e31fc932..f47c255a 100644 --- a/shop/app/Enums/OrderSrcEnum.php +++ b/shop/app/Enums/OrderSrcEnum.php @@ -18,10 +18,10 @@ enum OrderSrcEnum: int public function label(): string { return match ($this) { - self::PWA => 'اپلیکیشن وب', - self::WEB => 'وبسایت', - self::APP => 'اپلیکیشن موبایل', - self::OLD => 'سامانه قدیم', + self::PWA => trans('enums.order_src.pwa'), + self::WEB => trans('enums.order_src.web'), + self::APP => trans('enums.order_src.app'), + self::OLD => trans('enums.order_src.old'), }; } } diff --git a/shop/app/Enums/OrderStatusEnum.php b/shop/app/Enums/OrderStatusEnum.php index 6bf144f5..d5dfdf2d 100644 --- a/shop/app/Enums/OrderStatusEnum.php +++ b/shop/app/Enums/OrderStatusEnum.php @@ -21,13 +21,13 @@ enum OrderStatusEnum: int public function label(): string { return match ($this) { - self::PENDING => 'در انتظار پرداخت', - self::PAID => 'پرداخت‌شده', - self::PROCESSING => 'در حال آماده‌سازی', - self::SHIPPED => 'ارسال‌شده', - self::DELIVERED => 'تحویل داده‌شده', - self::CANCELED => 'لغوشده', - self::RETURNED => 'مرجوع‌شده', + self::PENDING => trans('enums.order_status.pending'), + self::PAID => trans('enums.order_status.paid'), + self::PROCESSING => trans('enums.order_status.processing'), + self::SHIPPED => trans('enums.order_status.shipped'), + self::DELIVERED => trans('enums.order_status.delivered'), + self::CANCELED => trans('enums.order_status.canceled'), + self::RETURNED => trans('enums.order_status.returned'), }; } } diff --git a/shop/app/Enums/PageStatusEnum.php b/shop/app/Enums/PageStatusEnum.php index 2425912a..df7f6666 100644 --- a/shop/app/Enums/PageStatusEnum.php +++ b/shop/app/Enums/PageStatusEnum.php @@ -18,10 +18,10 @@ enum PageStatusEnum: int public function label(): string { return match ($this) { - self::DELETED => 'حذف شده', - self::PUBLISHED => 'منتشر شده', - self::DRAFT => 'پیش‌نویس', - self::SCHEDULED => 'زمان‌بندی شده', + self::DELETED => trans('enums.page_status.deleted'), + self::PUBLISHED => trans('enums.page_status.published'), + self::DRAFT => trans('enums.page_status.draft'), + self::SCHEDULED => trans('enums.page_status.scheduled'), }; } } diff --git a/shop/app/Enums/ProductStatusEnum.php b/shop/app/Enums/ProductStatusEnum.php index 731e2399..5117c1e8 100644 --- a/shop/app/Enums/ProductStatusEnum.php +++ b/shop/app/Enums/ProductStatusEnum.php @@ -17,9 +17,9 @@ enum ProductStatusEnum: int public function label(): string { return match ($this) { - self::DELETED => 'حذف شده', - self::PUBLISHED => 'منتشر شده', - self::DRAFT => 'پیش‌نویس', + self::DELETED => trans('enums.product_status.deleted'), + self::PUBLISHED => trans('enums.product_status.published'), + self::DRAFT => trans('enums.product_status.draft'), }; } } diff --git a/shop/app/Enums/ReviewStatusEnum.php b/shop/app/Enums/ReviewStatusEnum.php index a50bc7ce..42ef7cd8 100644 --- a/shop/app/Enums/ReviewStatusEnum.php +++ b/shop/app/Enums/ReviewStatusEnum.php @@ -18,10 +18,10 @@ enum ReviewStatusEnum: int public function label(): string { return match ($this) { - self::DELETED => 'حذف شده', - self::PENDING => 'در انتظار بررسی', - self::APPROVED => 'تایید شده', - self::REJECTED => 'رد شده', + self::DELETED => trans('enums.review_status.deleted'), + self::PENDING => trans('enums.review_status.pending'), + self::APPROVED => trans('enums.review_status.approved'), + self::REJECTED => trans('enums.review_status.rejected'), }; } } diff --git a/shop/app/Enums/SliderPositionEnum.php b/shop/app/Enums/SliderPositionEnum.php new file mode 100644 index 00000000..4aff2630 --- /dev/null +++ b/shop/app/Enums/SliderPositionEnum.php @@ -0,0 +1,34 @@ + trans('enums.slider_position.home_main'), + self::HOME_SECONDARY => trans('enums.slider_position.home_secondary'), + self::CATEGORY_TOP => trans('enums.slider_position.category_top'), + self::PRODUCT_SIDE => trans('enums.slider_position.product_side'), + }; + } +} diff --git a/shop/app/Enums/SliderStatusEnum.php b/shop/app/Enums/SliderStatusEnum.php index 3ba29197..3689966f 100644 --- a/shop/app/Enums/SliderStatusEnum.php +++ b/shop/app/Enums/SliderStatusEnum.php @@ -17,9 +17,9 @@ enum SliderStatusEnum: int public function label(): string { return match ($this) { - self::DELETED => 'حذف شده', - self::PUBLISHED => 'منتشر شده', - self::DRAFT => 'پیش‌نویس', + self::DELETED => trans('enums.slider_status.deleted'), + self::PUBLISHED => trans('enums.slider_status.published'), + self::DRAFT => trans('enums.slider_status.draft'), }; } } diff --git a/shop/app/Enums/TransactionPortEnum.php b/shop/app/Enums/TransactionPortEnum.php index 50e2ed5f..7622ecb9 100644 --- a/shop/app/Enums/TransactionPortEnum.php +++ b/shop/app/Enums/TransactionPortEnum.php @@ -17,9 +17,9 @@ enum TransactionPortEnum: int public function label(): string { return match ($this) { - self::MELLAT => 'ملت', - self::PARSIAN => 'پارسیان', - self::ZARINPAL => 'زرین‌پال', + self::MELLAT => trans('enums.transaction_port.mellat'), + self::PARSIAN => trans('enums.transaction_port.parsian'), + self::ZARINPAL => trans('enums.transaction_port.zarinpal'), }; } } diff --git a/shop/app/Enums/TransactionStatusEnum.php b/shop/app/Enums/TransactionStatusEnum.php index dfb07bd0..cc1bb067 100644 --- a/shop/app/Enums/TransactionStatusEnum.php +++ b/shop/app/Enums/TransactionStatusEnum.php @@ -18,10 +18,10 @@ enum TransactionStatusEnum: int public function label(): string { return match ($this) { - self::PENDING => 'در انتظار', - self::SUCCESS => 'موفق', - self::FAILED => 'ناموفق', - self::CANCELED => 'لغوشده', + self::PENDING => trans('enums.transaction_status.pending'), + self::SUCCESS => trans('enums.transaction_status.success'), + self::FAILED => trans('enums.transaction_status.failed'), + self::CANCELED => trans('enums.transaction_status.canceled'), }; } } diff --git a/shop/app/Enums/UserStatusEnum.php b/shop/app/Enums/UserStatusEnum.php index af0551d5..4270d6e2 100644 --- a/shop/app/Enums/UserStatusEnum.php +++ b/shop/app/Enums/UserStatusEnum.php @@ -16,8 +16,8 @@ enum UserStatusEnum: int public function label(): string { return match ($this) { - self::ACTIVE => 'فعال', - self::BLOCK => 'مسدود', + self::ACTIVE => trans('enums.user_status.active'), + self::BLOCK => trans('enums.user_status.block'), }; } } diff --git a/shop/app/Enums/VarietyStatusEnum.php b/shop/app/Enums/VarietyStatusEnum.php index e5fc6c06..0973f653 100644 --- a/shop/app/Enums/VarietyStatusEnum.php +++ b/shop/app/Enums/VarietyStatusEnum.php @@ -17,9 +17,9 @@ enum VarietyStatusEnum: int public function label(): string { return match ($this) { - self::DELETED => 'حذف شده', - self::PUBLISHED => 'منتشر شده', - self::DRAFT => 'پیش‌نویس', + self::DELETED => trans('enums.variety_status.deleted'), + self::PUBLISHED => trans('enums.variety_status.published'), + self::DRAFT => trans('enums.variety_status.draft'), }; } } diff --git a/shop/app/Http/Controllers/AccountController.php b/shop/app/Http/Controllers/AccountController.php index be5fdf10..aea1ac81 100644 --- a/shop/app/Http/Controllers/AccountController.php +++ b/shop/app/Http/Controllers/AccountController.php @@ -63,7 +63,7 @@ public function updateProfile(Request $request): RedirectResponse $user->save(); - return back()->with('status', 'اطلاعات حساب با موفقیت ذخیره شد.'); + return back()->with('status', trans('messages.profile_saved')); } public function orders(Request $request, GetUserOrders $getUserOrders): Response @@ -111,7 +111,7 @@ public function retryOrder(Request $request, Order $order, RetryOrderPayment $re if ($url === null) { return redirect()->route('account.orders.show', $order) - ->with('status', 'متأسفانه موجودی برخی از کالاهای این سفارش دیگر کافی نیست.'); + ->with('status', trans('messages.orders.retry_insufficient_stock')); } return Inertia::location($url); @@ -121,9 +121,9 @@ public function returns(Request $request, GetUserOrders $getUserOrders): Respons { return Inertia::render('Account/Orders/Index', [ 'orders' => $getUserOrders($this->user($request), OrderStatusEnum::RETURNED), - 'title' => 'مرجوعی‌های من', - 'emptyTitle' => 'هنوز مرجوعی‌ای ثبت نشده است', - 'emptyDescription' => 'سفارش‌های مرجوع‌شده شما اینجا نمایش داده می‌شوند.', + 'title' => trans('messages.orders.returns_title'), + 'emptyTitle' => trans('messages.orders.returns_empty_title'), + 'emptyDescription' => trans('messages.orders.returns_empty_description'), 'baseUrl' => '/account/returns', ]); } @@ -137,7 +137,7 @@ public function wishlist(Request $request, GetUserWishlist $getUserWishlist): Re public function reviews(): Response { - return $this->comingSoon('نظرات ثبت‌شده'); + return $this->comingSoon(trans('messages.account.reviews_coming_soon')); } private function comingSoon(string $title): Response diff --git a/shop/app/Http/Controllers/AddressController.php b/shop/app/Http/Controllers/AddressController.php index da114648..de647bdc 100644 --- a/shop/app/Http/Controllers/AddressController.php +++ b/shop/app/Http/Controllers/AddressController.php @@ -54,7 +54,7 @@ public function store(Request $request, NormalizeMobile $normalize, StoreUserAdd $store($user, $data); - return back()->with('status', 'نشانی با موفقیت ثبت شد.'); + return back()->with('status', trans('messages.address.created')); } public function update(Request $request, Address $address, NormalizeMobile $normalize, UpdateUserAddress $update): RedirectResponse @@ -63,7 +63,7 @@ public function update(Request $request, Address $address, NormalizeMobile $norm $update($address, $this->validated($request, $normalize)); - return back()->with('status', 'نشانی با موفقیت ویرایش شد.'); + return back()->with('status', trans('messages.address.updated')); } public function setPrimary(Request $request, Address $address): RedirectResponse @@ -73,7 +73,7 @@ public function setPrimary(Request $request, Address $address): RedirectResponse // Saving with prime=true demotes the user's other addresses (model hook). $address->update(['prime' => true]); - return back()->with('status', 'نشانی پیش‌فرض تغییر کرد.'); + return back()->with('status', trans('messages.address.primary_changed')); } public function destroy(Request $request, Address $address): RedirectResponse @@ -94,7 +94,7 @@ public function destroy(Request $request, Address $address): RedirectResponse ?->update(['prime' => true]); } - return back()->with('status', 'نشانی حذف شد.'); + return back()->with('status', trans('messages.address.deleted')); } public function cities(Request $request): JsonResponse @@ -163,33 +163,13 @@ private function validated(Request $request, NormalizeMobile $normalize): array 'latitude' => ['nullable', 'numeric', 'between:-90,90'], 'longitude' => ['nullable', 'numeric', 'between:-180,180'], 'prime' => ['boolean'], - ], [ - 'required' => 'وارد کردن :attribute الزامی است.', - 'string' => ':attribute باید متن باشد.', - 'integer' => ':attribute نامعتبر است.', - 'numeric' => ':attribute نامعتبر است.', - 'exists' => ':attribute انتخاب‌شده معتبر نیست.', - 'max' => ':attribute نباید بیشتر از :max نویسه باشد.', - 'between' => ':attribute خارج از محدوده مجاز است.', - 'boolean' => ':attribute نامعتبر است.', - ], [ - 'name' => 'عنوان نشانی', - 'city_id' => 'شهر', - 'address' => 'نشانی', - 'plate' => 'پلاک', - 'unit' => 'واحد', - 'postal_code' => 'کد پستی', - 'phone' => 'شماره موبایل', - 'note' => 'توضیحات', - 'latitude' => 'موقعیت مکانی', - 'longitude' => 'موقعیت مکانی', ]); $phone = $normalize($validated['phone']); if ($phone === null) { throw ValidationException::withMessages([ - 'phone' => 'شماره موبایل معتبر نیست.', + 'phone' => trans('messages.address.invalid_phone'), ]); } @@ -197,7 +177,7 @@ private function validated(Request $request, NormalizeMobile $normalize): array if (preg_match('/^\d{10}$/', $postal) !== 1) { throw ValidationException::withMessages([ - 'postal_code' => 'کد پستی باید ۱۰ رقم باشد.', + 'postal_code' => trans('messages.address.invalid_postal_code'), ]); } diff --git a/shop/app/Http/Controllers/AuthController.php b/shop/app/Http/Controllers/AuthController.php index f5b14b64..ab8db23d 100644 --- a/shop/app/Http/Controllers/AuthController.php +++ b/shop/app/Http/Controllers/AuthController.php @@ -47,7 +47,7 @@ public function requestOtp(Request $request, NormalizeMobile $normalize, SendOtp if ($remaining > 0) { return back() - ->withErrors(['code' => "کد قبلی هنوز معتبر است؛ لطفاً {$remaining} ثانیه دیگر برای دریافت کد جدید صبر کنید."]) + ->withErrors(['code' => trans('messages.auth.code_still_valid', ['seconds' => $remaining])]) ->with('authStep', 'otp') ->with('authMobile', $mobile) ->with('authResendIn', $remaining); @@ -67,7 +67,7 @@ public function verifyOtp(Request $request, NormalizeMobile $normalize, VerifyOt if (! $verify($mobile, $code)) { return back() - ->withErrors(['code' => 'کد وارد شده نادرست یا منقضی شده است.']) + ->withErrors(['code' => trans('messages.auth.code_invalid')]) ->with('authStep', 'otp') ->with('authMobile', $mobile); } @@ -107,7 +107,7 @@ public function password(Request $request, NormalizeMobile $normalize): Redirect if ($user === null || ! Hash::check($password, $user->password ?? '')) { return back() - ->withErrors(['password' => 'رمز عبور نادرست است.']) + ->withErrors(['password' => trans('messages.auth.password_invalid')]) ->with('authStep', 'password') ->with('authMobile', $mobile); } @@ -139,7 +139,7 @@ private function mobile(Request $request, NormalizeMobile $normalize): string if ($mobile === null) { throw new HttpResponseException( back() - ->withErrors(['mobile' => 'شماره موبایل معتبر نیست.']) + ->withErrors(['mobile' => trans('messages.auth.mobile_invalid')]) ->with('authStep', 'mobile') ); } @@ -176,7 +176,7 @@ private function sentOtp(string $mobile, SendOtpCode $sendOtp): RedirectResponse private function blocked(string $mobile): RedirectResponse { return back() - ->withErrors(['mobile' => 'حساب کاربری شما مسدود شده است.']) + ->withErrors(['mobile' => trans('messages.auth.blocked')]) ->with('authStep', 'mobile') ->with('authMobile', $mobile); } diff --git a/shop/app/Http/Controllers/CartController.php b/shop/app/Http/Controllers/CartController.php index ccccb213..cb95efe2 100644 --- a/shop/app/Http/Controllers/CartController.php +++ b/shop/app/Http/Controllers/CartController.php @@ -42,12 +42,12 @@ public function store(Request $request, AddToCart $add): RedirectResponse $variety = Variety::query()->findOrFail($validated['variety_id']); if (! $variety->has_stock || $variety->inventory < 1) { - throw ValidationException::withMessages(['variety_id' => 'این کالا موجود نیست.']); + throw ValidationException::withMessages(['variety_id' => trans('messages.cart.unavailable')]); } $add(($this->owner)($request), $variety, (int) ($validated['count'] ?? 1)); - return back()->with('status', 'کالا به سبد خرید اضافه شد.'); + return back()->with('status', trans('messages.cart.added')); } public function update(Request $request, Cart $cart): RedirectResponse @@ -70,7 +70,7 @@ public function destroy(Request $request, Cart $cart): RedirectResponse $cart->delete(); - return back()->with('status', 'کالا از سبد خرید حذف شد.'); + return back()->with('status', trans('messages.cart.removed')); } private function ensureOwner(Request $request, Cart $cart): void diff --git a/shop/app/Http/Controllers/CheckoutController.php b/shop/app/Http/Controllers/CheckoutController.php index 6ce3e2e7..86a8e283 100644 --- a/shop/app/Http/Controllers/CheckoutController.php +++ b/shop/app/Http/Controllers/CheckoutController.php @@ -37,7 +37,7 @@ public function shipping(Request $request, GetCartLines $getLines, BuildCartSumm $lines = $getLines(($this->owner)($request)); if ($lines->isEmpty()) { - return redirect()->route('cart')->with('status', 'سبد خرید شما خالی است.'); + return redirect()->route('cart')->with('status', trans('messages.cart.empty')); } $addresses = Address::query() @@ -103,7 +103,7 @@ public function storeShipping(Request $request): RedirectResponse $available = collect($this->methodsFor($address)); if (! $available->contains(fn (array $method): bool => $method['id'] === (int) $validated['shipping_method_id'])) { - return back()->withErrors(['shipping_method_id' => 'روش ارسال انتخاب‌شده معتبر نیست.']); + return back()->withErrors(['shipping_method_id' => trans('messages.checkout.invalid_method')]); } $request->session()->put('checkout.address_id', (int) $validated['address_id']); @@ -121,7 +121,7 @@ public function payment(Request $request, GetCartLines $getLines, BuildCartSumma $lines = $getLines(($this->owner)($request)); if ($lines->isEmpty()) { - return redirect()->route('cart')->with('status', 'سبد خرید شما خالی است.'); + return redirect()->route('cart')->with('status', trans('messages.cart.empty')); } $addressId = $request->session()->get('checkout.address_id'); @@ -130,7 +130,7 @@ public function payment(Request $request, GetCartLines $getLines, BuildCartSumma : Address::query()->forUser($user->id)->with('city.province')->find($addressId); if ($address === null) { - return redirect()->route('checkout.shipping')->with('status', 'لطفاً نشانی ارسال را انتخاب کنید.'); + return redirect()->route('checkout.shipping')->with('status', trans('messages.checkout.choose_address')); } $methodId = $request->session()->get('checkout.shipping_method_id'); @@ -138,7 +138,7 @@ public function payment(Request $request, GetCartLines $getLines, BuildCartSumma ->firstWhere('id', $methodId === null ? 0 : (int) $methodId); if ($method === null) { - return redirect()->route('checkout.shipping')->with('status', 'لطفاً روش ارسال را انتخاب کنید.'); + return redirect()->route('checkout.shipping')->with('status', trans('messages.checkout.choose_method')); } return Inertia::render('Checkout/Payment', [ diff --git a/shop/app/Http/Controllers/FaqController.php b/shop/app/Http/Controllers/FaqController.php index 29b51f62..08a918c6 100644 --- a/shop/app/Http/Controllers/FaqController.php +++ b/shop/app/Http/Controllers/FaqController.php @@ -16,8 +16,8 @@ public function show(GetFaqs $getFaqs, ?string $position = null): Response 'position' => $position, 'faqs' => $getFaqs($position), 'breadcrumbs' => [ - ['heading' => 'خانه', 'url' => '/'], - ['heading' => 'سوالات متداول', 'url' => null], + ['heading' => trans('messages.breadcrumb.home'), 'url' => '/'], + ['heading' => trans('messages.breadcrumb.faq'), 'url' => null], ], ]); } diff --git a/shop/app/Http/Controllers/HomeController.php b/shop/app/Http/Controllers/HomeController.php index 7ab249e5..cd6390bc 100644 --- a/shop/app/Http/Controllers/HomeController.php +++ b/shop/app/Http/Controllers/HomeController.php @@ -5,24 +5,25 @@ namespace App\Http\Controllers; use App\Actions\Home\GetFeaturedBrands; -use App\Actions\Home\GetHeroSlides; use App\Actions\Home\GetHomeCategories; use App\Actions\Home\GetProductRows; use App\Actions\Home\GetPromoBanners; +use App\Actions\Home\GetSliderByPosition; +use App\Enums\SliderPositionEnum; use Inertia\Inertia; use Inertia\Response; class HomeController extends Controller { public function __invoke( - GetHeroSlides $getHeroSlides, + GetSliderByPosition $getSliderByPosition, GetHomeCategories $getHomeCategories, GetPromoBanners $getPromoBanners, GetProductRows $getProductRows, GetFeaturedBrands $getFeaturedBrands, ): Response { return Inertia::render('Home', [ - 'slides' => $getHeroSlides(), + 'slides' => $getSliderByPosition(SliderPositionEnum::HOME_MAIN), 'categories' => $getHomeCategories(), 'banners' => $getPromoBanners(), 'productRows' => $getProductRows(), diff --git a/shop/app/Http/Controllers/PageController.php b/shop/app/Http/Controllers/PageController.php index 9b273b56..9cac697d 100644 --- a/shop/app/Http/Controllers/PageController.php +++ b/shop/app/Http/Controllers/PageController.php @@ -22,7 +22,7 @@ public function show(string $slug, BuildPageDetail $buildPageDetail): Response return Inertia::render('Page/Show', [ 'page' => $buildPageDetail($page)->toArray(), 'breadcrumbs' => [ - ['heading' => 'خانه', 'url' => '/'], + ['heading' => trans('messages.breadcrumb.home'), 'url' => '/'], ['heading' => $page->heading, 'url' => null], ], ]); diff --git a/shop/app/Http/Controllers/PaymentController.php b/shop/app/Http/Controllers/PaymentController.php index 3d2fcc46..ab7f4970 100644 --- a/shop/app/Http/Controllers/PaymentController.php +++ b/shop/app/Http/Controllers/PaymentController.php @@ -43,25 +43,25 @@ public function initiate(Request $request, GetCartLines $getLines, BuildCartSumm $lines = $getLines(($this->owner)($request)); if ($lines->isEmpty()) { - return redirect()->route('cart')->with('status', 'سبد خرید شما خالی است.'); + return redirect()->route('cart')->with('status', trans('messages.cart.empty')); } // Stock can change between adding to cart and reaching payment; never // open a Zarinpal payment session for something no longer available. if (! $validateStock($lines)) { - return redirect()->route('cart')->with('status', 'موجودی برخی از کالاهای سبد خرید شما تغییر کرده است. لطفاً سبد خرید را بررسی کنید.'); + return redirect()->route('cart')->with('status', trans('messages.checkout.stock_changed')); } $address = $this->resolveAddress($request, $user); if ($address === null) { - return redirect()->route('checkout.shipping')->with('status', 'لطفاً نشانی ارسال را انتخاب کنید.'); + return redirect()->route('checkout.shipping')->with('status', trans('messages.checkout.choose_address')); } $method = $this->resolveMethod($request, $address); if ($method === null) { - return redirect()->route('checkout.shipping')->with('status', 'لطفاً روش ارسال را انتخاب کنید.'); + return redirect()->route('checkout.shipping')->with('status', trans('messages.checkout.choose_method')); } $summary = $buildSummary($lines); @@ -77,7 +77,7 @@ public function initiate(Request $request, GetCartLines $getLines, BuildCartSumm ); if ($url === null) { - return back()->with('status', 'در اتصال به درگاه پرداخت خطایی رخ داد. لطفاً دوباره تلاش کنید.'); + return back()->with('status', trans('messages.payment.gateway_error')); } return Inertia::location($url); @@ -97,7 +97,7 @@ public function callback(Request $request): RedirectResponse $order = ($this->completePayment)($validated['Authority'], $validated['Status']); if ($order === null) { - return redirect()->route('checkout.payment')->with('status', 'پرداخت ناموفق بود.'); + return redirect()->route('checkout.payment')->with('status', trans('messages.payment.failed')); } $request->session()->forget(['checkout.address_id', 'checkout.shipping_method_id']); diff --git a/shop/app/Http/Controllers/ReviewController.php b/shop/app/Http/Controllers/ReviewController.php index 71f281ad..d216eb5c 100644 --- a/shop/app/Http/Controllers/ReviewController.php +++ b/shop/app/Http/Controllers/ReviewController.php @@ -32,6 +32,6 @@ public function store(Request $request, Product $product, CreateReview $createRe 'rating' => (int) $validated['rating'], ]); - return back()->with('status', 'دیدگاه شما ثبت شد و پس از تأیید نمایش داده می‌شود.'); + return back()->with('status', trans('messages.review.submitted')); } } diff --git a/shop/app/Http/Controllers/WishlistController.php b/shop/app/Http/Controllers/WishlistController.php index 47355daf..92b936ad 100644 --- a/shop/app/Http/Controllers/WishlistController.php +++ b/shop/app/Http/Controllers/WishlistController.php @@ -22,6 +22,6 @@ public function toggle(Request $request, Product $product, ToggleWishlist $toggl $wishlisted = $toggle($user, $product); - return back()->with('status', $wishlisted ? 'به علاقه‌مندی‌ها اضافه شد.' : 'از علاقه‌مندی‌ها حذف شد.'); + return back()->with('status', trans($wishlisted ? 'messages.wishlist.added' : 'messages.wishlist.removed')); } } diff --git a/shop/app/Http/Middleware/HandleInertiaRequests.php b/shop/app/Http/Middleware/HandleInertiaRequests.php index 6311803d..2d649627 100644 --- a/shop/app/Http/Middleware/HandleInertiaRequests.php +++ b/shop/app/Http/Middleware/HandleInertiaRequests.php @@ -74,11 +74,11 @@ public function share(Request $request): array 'footer' => [ 'about' => $this->value($settings, 'footer_about'), 'columns' => [ - ['title' => 'فروشگاه', 'links' => $this->json($settings, 'footer_links_shop')], - ['title' => 'پشتیبانی', 'links' => $this->json($settings, 'footer_links_support')], + ['title' => trans('messages.footer.shop'), 'links' => $this->json($settings, 'footer_links_shop')], + ['title' => trans('messages.footer.support'), 'links' => $this->json($settings, 'footer_links_support')], ], 'contact' => [ - 'title' => 'ارتباط با ما', + 'title' => trans('messages.footer.contact'), 'phone' => $this->value($settings, 'site_phone'), 'email' => $this->value($settings, 'site_email'), 'hours' => $this->json($settings, 'site_working_hours'), diff --git a/shop/app/Models/User.php b/shop/app/Models/User.php index 4991f44f..803ca42a 100644 --- a/shop/app/Models/User.php +++ b/shop/app/Models/User.php @@ -74,7 +74,7 @@ public function displayName(): string { $name = trim(($this->first_name ?? '').' '.($this->last_name ?? '')); - return $name !== '' ? $name : ($this->mobile ?? $this->email ?? 'کاربر'); + return $name !== '' ? $name : ($this->mobile ?? $this->email ?? trans('messages.guest_user')); } public static function placeholderEmail(string $mobile): string diff --git a/shop/config/app.php b/shop/config/app.php index ff910160..4c7d7cb9 100644 --- a/shop/config/app.php +++ b/shop/config/app.php @@ -95,7 +95,7 @@ | */ - 'locale' => env('APP_LOCALE', 'en'), + 'locale' => env('APP_LOCALE', 'fa'), 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), diff --git a/shop/docs/ShoFlow db doc.md b/shop/docs/ShoFlow db doc.md index 1a67fcfc..9d368555 100644 --- a/shop/docs/ShoFlow db doc.md +++ b/shop/docs/ShoFlow db doc.md @@ -731,7 +731,7 @@ A specific service tier offered by a shipping carrier. References `shipping_line * For creating various sliders. * `name`: Human-readable label for the slider, e.g. "Home Page Main Slider." Not shown on the frontend. -* `position`: The key used by the frontend to fetch this slider, e.g. "home-main". Must be unique per placement. +* `position`: Where the frontend shows this slider (e.g. `home-main`). Constrained to `App\Enums\SliderPositionEnum` (mirrored in both apps): `home-main`, `home-secondary`, `category-top`, `product-side`. The admin picks it from a dropdown; the storefront looks it up by the same enum value (`GetSliderByPosition`). Keep one published slider per position (the column is not DB-unique; the frontend takes the first published match). Only `home-main` is rendered so far (home hero). * `status`: Publication status — 10 for deleted, 20 for published (default), 30 for draft. * Deleting a slider cascades to its slides (and their images). diff --git a/shop/lang/fa/enums.php b/shop/lang/fa/enums.php new file mode 100644 index 00000000..d6005735 --- /dev/null +++ b/shop/lang/fa/enums.php @@ -0,0 +1,82 @@ + [ + 'active' => 'فعال', + 'inactive' => 'غیرفعال', + ], + 'category_status' => [ + 'active' => 'فعال', + 'inactive' => 'غیرفعال', + ], + 'banner_status' => [ + 'deleted' => 'حذف شده', + 'published' => 'منتشر شده', + 'draft' => 'پیش‌نویس', + ], + 'page_status' => [ + 'deleted' => 'حذف شده', + 'published' => 'منتشر شده', + 'draft' => 'پیش‌نویس', + 'scheduled' => 'زمان‌بندی شده', + ], + 'product_status' => [ + 'deleted' => 'حذف شده', + 'published' => 'منتشر شده', + 'draft' => 'پیش‌نویس', + ], + 'variety_status' => [ + 'deleted' => 'حذف شده', + 'published' => 'منتشر شده', + 'draft' => 'پیش‌نویس', + ], + 'user_status' => [ + 'active' => 'فعال', + 'block' => 'مسدود', + ], + 'order_status' => [ + 'pending' => 'در انتظار پرداخت', + 'paid' => 'پرداخت‌شده', + 'processing' => 'در حال آماده‌سازی', + 'shipped' => 'ارسال‌شده', + 'delivered' => 'تحویل داده‌شده', + 'canceled' => 'لغوشده', + 'returned' => 'مرجوع‌شده', + ], + 'order_src' => [ + 'pwa' => 'اپلیکیشن وب', + 'web' => 'وبسایت', + 'app' => 'اپلیکیشن موبایل', + 'old' => 'سامانه قدیم', + ], + 'review_status' => [ + 'deleted' => 'حذف شده', + 'pending' => 'در انتظار بررسی', + 'approved' => 'تایید شده', + 'rejected' => 'رد شده', + ], + 'transaction_status' => [ + 'pending' => 'در انتظار', + 'success' => 'موفق', + 'failed' => 'ناموفق', + 'canceled' => 'لغوشده', + ], + 'transaction_port' => [ + 'mellat' => 'ملت', + 'parsian' => 'پارسیان', + 'zarinpal' => 'زرین‌پال', + ], + 'slider_status' => [ + 'deleted' => 'حذف شده', + 'published' => 'منتشر شده', + 'draft' => 'پیش‌نویس', + ], + 'slider_position' => [ + 'home_main' => 'صفحه خانه — بنر اصلی', + 'home_secondary' => 'صفحه خانه — بنر دوم', + 'category_top' => 'صفحه دسته‌بندی — بالا', + 'product_side' => 'صفحه محصول — کنار', + ], +]; diff --git a/shop/lang/fa/messages.php b/shop/lang/fa/messages.php new file mode 100644 index 00000000..c1d50dad --- /dev/null +++ b/shop/lang/fa/messages.php @@ -0,0 +1,88 @@ + 'اطلاعات حساب با موفقیت ذخیره شد.', + + 'cart' => [ + 'empty' => 'سبد خرید شما خالی است.', + 'added' => 'کالا به سبد خرید اضافه شد.', + 'removed' => 'کالا از سبد خرید حذف شد.', + 'unavailable' => 'این کالا موجود نیست.', + ], + + 'checkout' => [ + 'stock_changed' => 'موجودی برخی از کالاهای سبد خرید شما تغییر کرده است. لطفاً سبد خرید را بررسی کنید.', + 'choose_address' => 'لطفاً نشانی ارسال را انتخاب کنید.', + 'choose_method' => 'لطفاً روش ارسال را انتخاب کنید.', + 'invalid_method' => 'روش ارسال انتخاب‌شده معتبر نیست.', + ], + + 'payment' => [ + 'gateway_error' => 'در اتصال به درگاه پرداخت خطایی رخ داد. لطفاً دوباره تلاش کنید.', + 'failed' => 'پرداخت ناموفق بود.', + 'canceled_by_user' => 'پرداخت توسط کاربر لغو شد.', + 'verify_failed' => 'تأیید پرداخت ناموفق بود.', + 'paid_but_oversold' => 'پرداخت با موفقیت انجام شد ولی موجودی کالا کافی نبود — نیاز به بازگشت وجه به مشتری.', + 'order_number' => 'سفارش شماره :id', + ], + + 'address' => [ + 'created' => 'نشانی با موفقیت ثبت شد.', + 'updated' => 'نشانی با موفقیت ویرایش شد.', + 'primary_changed' => 'نشانی پیش‌فرض تغییر کرد.', + 'deleted' => 'نشانی حذف شد.', + 'invalid_phone' => 'شماره موبایل معتبر نیست.', + 'invalid_postal_code' => 'کد پستی باید ۱۰ رقم باشد.', + ], + + 'auth' => [ + 'code_still_valid' => 'کد قبلی هنوز معتبر است؛ لطفاً :seconds ثانیه دیگر برای دریافت کد جدید صبر کنید.', + 'code_invalid' => 'کد وارد شده نادرست یا منقضی شده است.', + 'password_invalid' => 'رمز عبور نادرست است.', + 'mobile_invalid' => 'شماره موبایل معتبر نیست.', + 'blocked' => 'حساب کاربری شما مسدود شده است.', + ], + + 'wishlist' => [ + 'added' => 'به علاقه‌مندی‌ها اضافه شد.', + 'removed' => 'از علاقه‌مندی‌ها حذف شد.', + ], + + 'review' => [ + 'submitted' => 'دیدگاه شما ثبت شد و پس از تأیید نمایش داده می‌شود.', + ], + + 'orders' => [ + 'retry_insufficient_stock' => 'متأسفانه موجودی برخی از کالاهای این سفارش دیگر کافی نیست.', + 'returns_title' => 'مرجوعی‌های من', + 'returns_empty_title' => 'هنوز مرجوعی‌ای ثبت نشده است', + 'returns_empty_description' => 'سفارش‌های مرجوع‌شده شما اینجا نمایش داده می‌شوند.', + ], + + 'account' => [ + 'reviews_coming_soon' => 'نظرات ثبت‌شده', + ], + + 'home' => [ + 'row_newest' => 'جدیدترین محصولات', + 'row_popular' => 'پربازدیدترین محصولات', + ], + + 'breadcrumb' => [ + 'home' => 'خانه', + 'faq' => 'سوالات متداول', + ], + + 'footer' => [ + 'shop' => 'فروشگاه', + 'support' => 'پشتیبانی', + 'contact' => 'ارتباط با ما', + ], + + // Fallbacks shown when referenced data is missing. + 'deleted_product' => 'محصول حذف‌شده', + 'guest_user' => 'کاربر', +]; diff --git a/shop/lang/fa/validation.php b/shop/lang/fa/validation.php new file mode 100644 index 00000000..4dcf86e5 --- /dev/null +++ b/shop/lang/fa/validation.php @@ -0,0 +1,58 @@ + 'وارد کردن :attribute الزامی است.', + 'string' => ':attribute باید متن باشد.', + 'integer' => ':attribute نامعتبر است.', + 'numeric' => ':attribute نامعتبر است.', + 'boolean' => ':attribute نامعتبر است.', + 'email' => ':attribute معتبر نیست.', + 'exists' => ':attribute انتخاب‌شده معتبر نیست.', + 'unique' => ':attribute قبلاً استفاده شده است.', + + 'max' => [ + 'numeric' => ':attribute نباید بیشتر از :max باشد.', + 'string' => ':attribute نباید بیشتر از :max نویسه باشد.', + 'array' => ':attribute نباید بیشتر از :max مورد باشد.', + 'file' => ':attribute نباید بیشتر از :max کیلوبایت باشد.', + ], + 'min' => [ + 'numeric' => ':attribute نباید کمتر از :min باشد.', + 'string' => ':attribute نباید کمتر از :min نویسه باشد.', + 'array' => ':attribute نباید کمتر از :min مورد باشد.', + 'file' => ':attribute نباید کمتر از :min کیلوبایت باشد.', + ], + 'between' => [ + 'numeric' => ':attribute خارج از محدوده مجاز است.', + 'string' => ':attribute خارج از محدوده مجاز است.', + 'array' => ':attribute خارج از محدوده مجاز است.', + 'file' => ':attribute خارج از محدوده مجاز است.', + ], + + 'attributes' => [ + 'name' => 'عنوان نشانی', + 'city_id' => 'شهر', + 'address' => 'نشانی', + 'plate' => 'پلاک', + 'unit' => 'واحد', + 'postal_code' => 'کد پستی', + 'phone' => 'شماره موبایل', + 'note' => 'توضیحات', + 'latitude' => 'موقعیت مکانی', + 'longitude' => 'موقعیت مکانی', + 'first_name' => 'نام', + 'last_name' => 'نام خانوادگی', + 'email' => 'ایمیل', + 'rating' => 'امتیاز', + 'heading' => 'عنوان', + 'content' => 'متن دیدگاه', + 'count' => 'تعداد', + ], +]; diff --git a/shop/phpunit.xml b/shop/phpunit.xml index b424eb89..6930e37f 100644 --- a/shop/phpunit.xml +++ b/shop/phpunit.xml @@ -19,6 +19,10 @@ + + + diff --git a/shop/tests/Feature/HomePageTest.php b/shop/tests/Feature/HomePageTest.php index e1268075..f1de731c 100644 --- a/shop/tests/Feature/HomePageTest.php +++ b/shop/tests/Feature/HomePageTest.php @@ -6,6 +6,7 @@ use App\Enums\BrandStatusEnum; use App\Enums\CategoryStatusEnum; use App\Enums\ProductStatusEnum; +use App\Enums\SliderPositionEnum; use App\Enums\SliderStatusEnum; use App\Enums\VarietyStatusEnum; use App\Models\Banner; @@ -138,3 +139,18 @@ function featureImage(string $type, int $id, bool $featured = true): void ->where('brands', []) ); }); + +it('only shows the slider assigned to the home-main position', function (): void { + // A published slider at a different position must not leak onto the home + // hero — the home page asks GetSliderByPosition for HOME_MAIN only. + $other = Slider::create([ + 'name' => 'sidebar slider', + 'position' => SliderPositionEnum::PRODUCT_SIDE->value, + 'status' => SliderStatusEnum::PUBLISHED, + ]); + Slide::create(['slider_id' => $other->id, 'heading' => 'کنار', 'order' => 1]); + + $this->get('/') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page->where('slides', [])); +}); From abc0926ff30d9953087e24e090ddae663f8f3ae4 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Wed, 5 Aug 2026 11:55:10 +0330 Subject: [PATCH 03/40] feat(admin): tags, home sections, and enum-constrained banner positions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tags: new `tags` table + `attribute_tag` pivot, `Tag` model/factory/seeder and `TagResource`. A tag is an SEO landing page for a category and/or attribute filter, not a free-form product label (no `product_tag` pivot). Deviations from the source schema: `category_id` is nullable and attributes are many-to-many (so a tag can be category-only, attribute-only, or both — at least one required, enforced in the form via `requiredWithout`); SEO columns `title`/`description`/`no_index`/ `canonical` added to match categories/products; the single `attribute_id` column and the user/seller `type` column are dropped (single-vendor). Home sections: new `home_sections` table (not in the source schema) so staff compose the storefront home page instead of it being hardcoded. `HomeSectionTypeEnum` (slider/tags/categories/banners/products/brands), a `config` JSON bag per type (`{"position": …}` for slider/banners, `{"sort": …}` for product rows), `order` + `status`, and a `HomeSectionResource` with a drag-to-reorder table whose form only shows the fields the chosen type needs. `HomeSectionSeeder` seeds the current hardcoded order. The storefront does not read this table yet. Banner positions: `position` moves from a free-text input to a `BannerPositionEnum` dropdown (home-top / home-middle / category-side), mirrored in the shop so the assigned position and the storefront lookup can't disagree; the table column and factory derive from the enum too. Banner/slide `url` no longer requires an absolute URL — a leading `/` internal path is accepted so they can link to tag pages. SliderSeeder: create exactly one published slider per position (the storefront takes the first published slider per position, so random duplicates made the visible one nondeterministic) and delete via `Slider::query()->delete()`, since Postgres refuses to TRUNCATE a table that `slides` FK-references. --- admin/AGENTS.md | 2 + admin/app/Enums/BannerPositionEnum.php | 25 +++ admin/app/Enums/HomeSectionTypeEnum.php | 36 ++++ .../app/Filament/Resources/BannerResource.php | 24 ++- .../Resources/HomeSectionResource.php | 141 +++++++++++++ .../Pages/CreateHomeSection.php | 18 ++ .../Pages/EditHomeSection.php | 26 +++ .../Pages/ListHomeSections.php | 29 +++ .../app/Filament/Resources/SlideResource.php | 10 +- admin/app/Filament/Resources/TagResource.php | 194 ++++++++++++++++++ .../Resources/TagResource/Pages/CreateTag.php | 18 ++ .../Resources/TagResource/Pages/EditTag.php | 26 +++ .../Resources/TagResource/Pages/ListTags.php | 29 +++ admin/app/Models/HomeSection.php | 45 ++++ admin/app/Models/Tag.php | 85 ++++++++ admin/database/factories/BannerFactory.php | 3 +- .../database/factories/HomeSectionFactory.php | 31 +++ admin/database/factories/TagFactory.php | 76 +++++++ .../2026_06_20_000023_create_tags_table.php | 47 +++++ ...6_20_000024_create_attribute_tag_table.php | 28 +++ ...6_20_000025_create_home_sections_table.php | 37 ++++ admin/database/seeders/DatabaseSeeder.php | 1 + admin/database/seeders/HomeSectionSeeder.php | 37 ++++ admin/database/seeders/SliderSeeder.php | 21 +- admin/database/seeders/TagSeeder.php | 50 +++++ admin/database/seeders/TestSeeder.php | 1 + admin/docs/IMPLEMENTATION.md | 3 +- admin/docs/ShoFlow db doc.md | 40 +++- admin/lang/en/banner.php | 6 + admin/lang/en/home_section.php | 28 +++ admin/lang/en/slide.php | 3 +- admin/lang/en/tag.php | 33 +++ admin/lang/fa/banner.php | 6 + admin/lang/fa/home_section.php | 28 +++ admin/lang/fa/slide.php | 3 +- admin/lang/fa/tag.php | 33 +++ .../Filament/Resource/BannerResourceTest.php | 30 +++ .../Resource/HomeSectionResourceTest.php | 78 +++++++ .../Filament/Resource/TagResourceTest.php | 108 ++++++++++ 39 files changed, 1417 insertions(+), 22 deletions(-) create mode 100644 admin/app/Enums/BannerPositionEnum.php create mode 100644 admin/app/Enums/HomeSectionTypeEnum.php create mode 100644 admin/app/Filament/Resources/HomeSectionResource.php create mode 100644 admin/app/Filament/Resources/HomeSectionResource/Pages/CreateHomeSection.php create mode 100644 admin/app/Filament/Resources/HomeSectionResource/Pages/EditHomeSection.php create mode 100644 admin/app/Filament/Resources/HomeSectionResource/Pages/ListHomeSections.php create mode 100644 admin/app/Filament/Resources/TagResource.php create mode 100644 admin/app/Filament/Resources/TagResource/Pages/CreateTag.php create mode 100644 admin/app/Filament/Resources/TagResource/Pages/EditTag.php create mode 100644 admin/app/Filament/Resources/TagResource/Pages/ListTags.php create mode 100644 admin/app/Models/HomeSection.php create mode 100644 admin/app/Models/Tag.php create mode 100644 admin/database/factories/HomeSectionFactory.php create mode 100644 admin/database/factories/TagFactory.php create mode 100644 admin/database/migrations/2026_06_20_000023_create_tags_table.php create mode 100644 admin/database/migrations/2026_06_20_000024_create_attribute_tag_table.php create mode 100644 admin/database/migrations/2026_06_20_000025_create_home_sections_table.php create mode 100644 admin/database/seeders/HomeSectionSeeder.php create mode 100644 admin/database/seeders/TagSeeder.php create mode 100644 admin/lang/en/home_section.php create mode 100644 admin/lang/en/tag.php create mode 100644 admin/lang/fa/home_section.php create mode 100644 admin/lang/fa/tag.php create mode 100644 admin/tests/Feature/Filament/Resource/HomeSectionResourceTest.php create mode 100644 admin/tests/Feature/Filament/Resource/TagResourceTest.php diff --git a/admin/AGENTS.md b/admin/AGENTS.md index 6fbc10ff..4a1e2bc4 100644 --- a/admin/AGENTS.md +++ b/admin/AGENTS.md @@ -513,6 +513,8 @@ When adding a new entity, build the files in this order, matching the existing f - `TestSeeder` holds factory-generated sample data (`Model::factory()->count(20)->create()`) for manual admin-panel testing. Run it separately with `php artisan db:seed --class=TestSeeder`. Add new sample-data seeders here, not in `DatabaseSeeder`. - Reference seeders use idempotent `updateOrCreate()` / `firstOrCreate()` so re-seeding is safe. - When truncating and re-seeding a table whose model has a `deleting` event (e.g. to cascade-delete related images), delete records one by one via `Model::all()->each->delete()` BEFORE truncating the parent. Use `->each->delete()` on a **Collection**, not a query builder — `Model::query()->each` does not exist and will throw an exception. +- **Don't `truncate()` a table that is referenced by a foreign key.** Postgres refuses to `TRUNCATE` a table another table FK-references (e.g. `slides.slider_id → sliders`) even after the referencing rows are deleted, unless you `CASCADE`. Use `Model::query()->delete()` instead (after clearing the children). `SliderSeeder` hit this — it deletes slides via `->each->delete()` (fires their image cleanup) then `Slider::query()->delete()`. +- Seed data should honor code-level enum constraints and how the storefront reads them: `SliderSeeder` creates exactly one PUBLISHED slider per `SliderPositionEnum` case (not N random-position rows), because the storefront shows the *first published slider per position* — random duplicates would make which one appears nondeterministic. - **A "delete-then-recreate" seeder must clear every table that FK-references it first, not just its own model's `deleting` event.** `ShippingLineSeeder` deleting all `shipping_lines` threw `SQLSTATE[23503]` because `shipping_methods`/`shipping_cities` (no cascade) still referenced them; `CitySeeder` deleting `cities` hit the same thing via `addresses.city_id` (worse: `Address` uses `SoftDeletes`, so even a soft-deleted row still blocks the FK — use `withTrashed()->forceDelete()`, not a plain `delete()`, to actually clear it). Check every migration for FKs into the table you're about to wipe, not just the ones you already know about. - **`TestSeeder` must never seed a table that a "real" seeder (`DatabaseSeeder`'s chain) already populates with load-bearing data.** `TestSeeder` used to include `ShippingLineSeeder`/`ShippingMethodSeeder`/`ShippingCitySeeder` (20 random rows each); since `DatabaseSeeder` → `ShippingSeeder` already seeds the 3 real, checkout-critical shipping methods, running `TestSeeder` afterward silently replaced them with random fake ones tied to random specific cities (no nationwide fallback) — breaking the storefront checkout's shipping-method selection with no visible error until a customer tried to check out. Removed from `TestSeeder` entirely; re-run `ShippingSeeder` if this ever regresses. - Read configurable values from config, not literals (see `AdminSeeder` reading `config('admin.account')`). diff --git a/admin/app/Enums/BannerPositionEnum.php b/admin/app/Enums/BannerPositionEnum.php new file mode 100644 index 00000000..2db7b01f --- /dev/null +++ b/admin/app/Enums/BannerPositionEnum.php @@ -0,0 +1,25 @@ + trans('banner.position_home_top'), + self::HOME_MIDDLE => trans('banner.position_home_middle'), + self::CATEGORY_SIDE => trans('banner.position_category_side'), + }; + } +} diff --git a/admin/app/Enums/HomeSectionTypeEnum.php b/admin/app/Enums/HomeSectionTypeEnum.php new file mode 100644 index 00000000..5516ce2b --- /dev/null +++ b/admin/app/Enums/HomeSectionTypeEnum.php @@ -0,0 +1,36 @@ + trans('home_section.type_slider'), + self::TAGS => trans('home_section.type_tags'), + self::CATEGORIES => trans('home_section.type_categories'), + self::BANNERS => trans('home_section.type_banners'), + self::PRODUCTS => trans('home_section.type_products'), + self::BRANDS => trans('home_section.type_brands'), + }; + } +} diff --git a/admin/app/Filament/Resources/BannerResource.php b/admin/app/Filament/Resources/BannerResource.php index 9be390f0..44e1dc5b 100644 --- a/admin/app/Filament/Resources/BannerResource.php +++ b/admin/app/Filament/Resources/BannerResource.php @@ -4,11 +4,13 @@ namespace App\Filament\Resources; +use App\Enums\BannerPositionEnum; use App\Enums\BannerStatusEnum; use App\Filament\Resources\BannerResource\Pages\CreateBanner; use App\Filament\Resources\BannerResource\Pages\EditBanner; use App\Filament\Resources\BannerResource\Pages\ListBanners; use App\Models\Banner; +use Closure; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -50,18 +52,31 @@ public static function form(Schema $schema): Schema { return $schema ->components([ - TextInput::make('position') + Select::make('position') ->label(trans('banner.position')) ->required() - ->maxLength(255), + ->options(BannerPositionEnum::options()) + ->native(false) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip(trans('banner.position_hint')), TextInput::make('heading') ->label(trans('banner.heading')) ->required() ->maxLength(255), TextInput::make('url') ->label(trans('banner.url')) - ->url() - ->maxLength(255), + ->maxLength(255) + // Allow an absolute URL (https://…) or an internal path + // (/tags/…, /categories/…) so banners can link to tag + // pages. Wrapped in an outer closure so Filament returns + // the rule to Laravel instead of evaluating it itself. + ->rule(static fn (): Closure => static function (string $attribute, mixed $value, Closure $fail): void { + if ($value !== null && $value !== '' && (! is_string($value) || preg_match('#^(https?://|/)#', $value) !== 1)) { + $fail(trans('banner.url_invalid')); + } + }) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip(trans('banner.url_hint')), TextInput::make('sort') ->label(trans('banner.sort')) ->numeric() @@ -97,6 +112,7 @@ public static function table(Table $table): Table ->columns([ TextColumn::make('position') ->label(trans('banner.position')) + ->formatStateUsing(fn (string $state): string => BannerPositionEnum::tryFrom($state)?->label() ?? $state) ->searchable(), TextColumn::make('heading') ->label(trans('banner.heading')) diff --git a/admin/app/Filament/Resources/HomeSectionResource.php b/admin/app/Filament/Resources/HomeSectionResource.php new file mode 100644 index 00000000..3e0b6463 --- /dev/null +++ b/admin/app/Filament/Resources/HomeSectionResource.php @@ -0,0 +1,141 @@ +components([ + Select::make('type') + ->label(trans('home_section.type')) + ->required() + ->live() + ->options(HomeSectionTypeEnum::options()) + ->default(HomeSectionTypeEnum::PRODUCTS->value) + ->native(false) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip(trans('home_section.type_hint')), + + // Slider/banner sections point at a position (which slider or + // banner group to show); the options depend on the type. + Select::make('config.position') + ->label(trans('home_section.position')) + ->options(fn (Get $get): array => match ($get('type')) { + HomeSectionTypeEnum::SLIDER->value => SliderPositionEnum::options(), + HomeSectionTypeEnum::BANNERS->value => BannerPositionEnum::options(), + default => [], + }) + ->visible(fn (Get $get): bool => in_array($get('type'), [ + HomeSectionTypeEnum::SLIDER->value, + HomeSectionTypeEnum::BANNERS->value, + ], true)) + ->required(fn (Get $get): bool => in_array($get('type'), [ + HomeSectionTypeEnum::SLIDER->value, + HomeSectionTypeEnum::BANNERS->value, + ], true)) + ->native(false), + + // Product rows carry a sort and a heading. + Select::make('config.sort') + ->label(trans('home_section.sort_by')) + ->options([ + 'newest' => trans('home_section.sort_newest'), + 'popular' => trans('home_section.sort_popular'), + ]) + ->visible(fn (Get $get): bool => $get('type') === HomeSectionTypeEnum::PRODUCTS->value) + ->required(fn (Get $get): bool => $get('type') === HomeSectionTypeEnum::PRODUCTS->value) + ->native(false), + TextInput::make('title') + ->label(trans('home_section.title')) + ->maxLength(255) + ->visible(fn (Get $get): bool => $get('type') === HomeSectionTypeEnum::PRODUCTS->value) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip(trans('home_section.title_hint')), + + Toggle::make('status') + ->label(trans('home_section.status')) + ->default(true), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->reorderable('order') + ->defaultSort('order') + ->columns([ + TextColumn::make('order') + ->label(trans('home_section.order')) + ->sortable(), + TextColumn::make('type') + ->label(trans('home_section.type')) + ->getStateUsing(fn (HomeSection $record): string => $record->type->label()), + TextColumn::make('title') + ->label(trans('home_section.title')) + ->placeholder('—'), + IconColumn::make('status') + ->label(trans('home_section.status')) + ->boolean(), + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => ListHomeSections::route('/'), + 'create' => CreateHomeSection::route('/create'), + 'edit' => EditHomeSection::route('/{record}/edit'), + ]; + } +} diff --git a/admin/app/Filament/Resources/HomeSectionResource/Pages/CreateHomeSection.php b/admin/app/Filament/Resources/HomeSectionResource/Pages/CreateHomeSection.php new file mode 100644 index 00000000..815d1783 --- /dev/null +++ b/admin/app/Filament/Resources/HomeSectionResource/Pages/CreateHomeSection.php @@ -0,0 +1,18 @@ +getResource()::getUrl('index'); + } +} diff --git a/admin/app/Filament/Resources/HomeSectionResource/Pages/EditHomeSection.php b/admin/app/Filament/Resources/HomeSectionResource/Pages/EditHomeSection.php new file mode 100644 index 00000000..255885b0 --- /dev/null +++ b/admin/app/Filament/Resources/HomeSectionResource/Pages/EditHomeSection.php @@ -0,0 +1,26 @@ +getResource()::getUrl('index'); + } + + protected function getHeaderActions(): array + { + return [ + DeleteAction::make(), + ]; + } +} diff --git a/admin/app/Filament/Resources/HomeSectionResource/Pages/ListHomeSections.php b/admin/app/Filament/Resources/HomeSectionResource/Pages/ListHomeSections.php new file mode 100644 index 00000000..a04a404e --- /dev/null +++ b/admin/app/Filament/Resources/HomeSectionResource/Pages/ListHomeSections.php @@ -0,0 +1,29 @@ +subheading = trans('home_section.subheading'); + } + + protected function getHeaderActions(): array + { + return [ + CreateAction::make(), + ]; + } +} diff --git a/admin/app/Filament/Resources/SlideResource.php b/admin/app/Filament/Resources/SlideResource.php index 32d822b1..cf6a4cd5 100644 --- a/admin/app/Filament/Resources/SlideResource.php +++ b/admin/app/Filament/Resources/SlideResource.php @@ -8,6 +8,7 @@ use App\Filament\Resources\SlideResource\Pages\EditSlide; use App\Filament\Resources\SlideResource\Pages\ListSlides; use App\Models\Slide; +use Closure; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -70,9 +71,16 @@ public static function form(Schema $schema): Schema ->hintIconTooltip(trans('slide.label_field_hint')), TextInput::make('url') ->label(trans('slide.url')) - ->url() ->nullable() ->maxLength(255) + // Absolute URL (https://…) or internal path (/tags/…) so a + // slide can link to a tag/category page. Wrapped so Filament + // returns the rule to Laravel instead of evaluating it. + ->rule(static fn (): Closure => static function (string $attribute, mixed $value, Closure $fail): void { + if ($value !== null && $value !== '' && (! is_string($value) || preg_match('#^(https?://|/)#', $value) !== 1)) { + $fail(trans('slide.url_invalid')); + } + }) ->hintIcon('heroicon-o-information-circle') ->hintIconTooltip(trans('slide.url_hint')), TextInput::make('order') diff --git a/admin/app/Filament/Resources/TagResource.php b/admin/app/Filament/Resources/TagResource.php new file mode 100644 index 00000000..e2013667 --- /dev/null +++ b/admin/app/Filament/Resources/TagResource.php @@ -0,0 +1,194 @@ +components([ + Fieldset::make(trans('tag.section_main')) + ->schema([ + TextInput::make('name') + ->label(trans('tag.name')) + ->required() + ->live(onBlur: true) + ->maxLength(255) + ->afterStateUpdated(function (string $operation, ?string $state, Set $set): void { + if ($operation === 'create') { + $set('slug', Str::slug((string) $state)); + } + }), + TextInput::make('slug') + ->label(trans('tag.slug')) + ->required() + ->maxLength(255) + ->unique(Tag::class, 'slug', ignoreRecord: true) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip(trans('tag.slug_hint')), + Select::make('category_id') + ->label(trans('tag.category_id')) + ->relationship('category', 'heading') + ->requiredWithout('attributes') + ->searchable() + ->preload() + ->native(false) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip(trans('tag.category_id_hint')), + Select::make('attributes') + ->label(trans('tag.attributes')) + ->relationship('attributes', 'value') + ->multiple() + ->requiredWithout('category_id') + ->searchable() + ->preload() + ->native(false) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip(trans('tag.attributes_hint')), + ]), + Fieldset::make(trans('tag.section_home')) + ->schema([ + Toggle::make('show_on_home') + ->label(trans('tag.show_on_home')) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip(trans('tag.show_on_home_hint')), + TextInput::make('home_order') + ->label(trans('tag.home_order')) + ->numeric() + ->default(0) + ->required(), + Fieldset::make('image') + ->label(trans('tag.image')) + ->relationship('image') + ->schema([ + FileUpload::make('path') + ->label(trans('tag.path')) + ->image() + ->nullable() + ->columnSpanFull(), + ]) + ->mutateRelationshipDataBeforeSaveUsing(function (array $data, Tag $record): array { + if ($data['path'] === null) { + $record->image?->delete(); + } + + return $data; + }) + ->columnSpanFull(), + ]), + Fieldset::make(trans('tag.section_seo')) + ->schema([ + TextInput::make('title') + ->label(trans('tag.title')) + ->maxLength(255), + Textarea::make('description') + ->label(trans('tag.description')) + ->maxLength(255) + ->columnSpanFull(), + TextInput::make('canonical') + ->label(trans('tag.canonical')) + ->maxLength(255), + Toggle::make('no_index') + ->label(trans('tag.no_index')), + ]), + TinyEditor::make('content') + ->label(trans('tag.content')) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->defaultSort('created_at', 'desc') + ->columns([ + TextColumn::make('name') + ->label(trans('tag.name')) + ->searchable(), + TextColumn::make('slug') + ->label(trans('tag.slug')) + ->searchable(), + TextColumn::make('category.heading') + ->label(trans('tag.category_id')) + ->placeholder('—') + ->searchable(), + TextColumn::make('attributes.value') + ->label(trans('tag.attributes')) + ->badge() + ->placeholder('—'), + IconColumn::make('show_on_home') + ->label(trans('tag.show_on_home')) + ->boolean(), + IconColumn::make('no_index') + ->label(trans('tag.no_index')) + ->boolean() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('created_at') + ->label(trans('tag.created_at')) + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => ListTags::route('/'), + 'create' => CreateTag::route('/create'), + 'edit' => EditTag::route('/{record}/edit'), + ]; + } +} diff --git a/admin/app/Filament/Resources/TagResource/Pages/CreateTag.php b/admin/app/Filament/Resources/TagResource/Pages/CreateTag.php new file mode 100644 index 00000000..15b40e70 --- /dev/null +++ b/admin/app/Filament/Resources/TagResource/Pages/CreateTag.php @@ -0,0 +1,18 @@ +getResource()::getUrl('index'); + } +} diff --git a/admin/app/Filament/Resources/TagResource/Pages/EditTag.php b/admin/app/Filament/Resources/TagResource/Pages/EditTag.php new file mode 100644 index 00000000..f618a9aa --- /dev/null +++ b/admin/app/Filament/Resources/TagResource/Pages/EditTag.php @@ -0,0 +1,26 @@ +getResource()::getUrl('index'); + } + + protected function getHeaderActions(): array + { + return [ + DeleteAction::make(), + ]; + } +} diff --git a/admin/app/Filament/Resources/TagResource/Pages/ListTags.php b/admin/app/Filament/Resources/TagResource/Pages/ListTags.php new file mode 100644 index 00000000..a3fb9be1 --- /dev/null +++ b/admin/app/Filament/Resources/TagResource/Pages/ListTags.php @@ -0,0 +1,29 @@ +subheading = trans('tag.subheading'); + } + + protected function getHeaderActions(): array + { + return [ + CreateAction::make(), + ]; + } +} diff --git a/admin/app/Models/HomeSection.php b/admin/app/Models/HomeSection.php new file mode 100644 index 00000000..98e3f713 --- /dev/null +++ b/admin/app/Models/HomeSection.php @@ -0,0 +1,45 @@ +|null $config + * @property int $order + * @property bool $status + * @property Carbon|null $created_at + * @property Carbon|null $updated_at + */ +class HomeSection extends Model +{ + /** @use HasFactory */ + use HasFactory; + + protected $fillable = [ + 'type', + 'title', + 'config', + 'order', + 'status', + ]; + + protected $casts = [ + 'type' => HomeSectionTypeEnum::class, + 'config' => 'array', + 'order' => 'integer', + 'status' => 'boolean', + ]; +} diff --git a/admin/app/Models/Tag.php b/admin/app/Models/Tag.php new file mode 100644 index 00000000..48714d9d --- /dev/null +++ b/admin/app/Models/Tag.php @@ -0,0 +1,85 @@ + $attributes + * @property Image|null $image + */ +class Tag extends Model +{ + /** @use HasFactory */ + use HasFactory; + + protected $fillable = [ + 'name', + 'slug', + 'category_id', + 'content', + 'title', + 'description', + 'no_index', + 'canonical', + 'show_on_home', + 'home_order', + ]; + + protected $casts = [ + 'no_index' => 'boolean', + 'show_on_home' => 'boolean', + 'home_order' => 'integer', + ]; + + protected static function booted(): void + { + static::deleting(function (Tag $tag): void { + $tag->image?->delete(); + }); + } + + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + + public function attributes(): BelongsToMany + { + return $this->belongsToMany(Attribute::class)->withTimestamps(); + } + + public function image(): MorphOne + { + return $this->morphOne(Image::class, 'imageable'); + } +} diff --git a/admin/database/factories/BannerFactory.php b/admin/database/factories/BannerFactory.php index 9643c679..f544db3d 100644 --- a/admin/database/factories/BannerFactory.php +++ b/admin/database/factories/BannerFactory.php @@ -4,6 +4,7 @@ namespace Database\Factories; +use App\Enums\BannerPositionEnum; use App\Enums\BannerStatusEnum; use App\Models\Banner; use Illuminate\Database\Eloquent\Factories\Factory; @@ -21,7 +22,7 @@ class BannerFactory extends Factory public function definition(): array { return [ - 'position' => fake()->randomElement(['home-top', 'home-middle', 'category-side']), + 'position' => fake()->randomElement(BannerPositionEnum::cases())->value, 'heading' => fake()->words(3, true), 'url' => fake()->url(), 'sort' => fake()->numberBetween(0, 20), diff --git a/admin/database/factories/HomeSectionFactory.php b/admin/database/factories/HomeSectionFactory.php new file mode 100644 index 00000000..44de3675 --- /dev/null +++ b/admin/database/factories/HomeSectionFactory.php @@ -0,0 +1,31 @@ + + */ +class HomeSectionFactory extends Factory +{ + protected $model = HomeSection::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'type' => HomeSectionTypeEnum::CATEGORIES, + 'title' => null, + 'config' => null, + 'order' => fake()->numberBetween(0, 20), + 'status' => true, + ]; + } +} diff --git a/admin/database/factories/TagFactory.php b/admin/database/factories/TagFactory.php new file mode 100644 index 00000000..0f5b2608 --- /dev/null +++ b/admin/database/factories/TagFactory.php @@ -0,0 +1,76 @@ + + */ +class TagFactory extends Factory +{ + protected $model = Tag::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->words(2, true), + // Persian names slugify to empty, so use a uuid for a unique slug + // (see AGENTS.md → Factories). + 'slug' => (string) Str::uuid(), + // A category by default so the tag is valid ("at least one of + // category/attributes"); override to null for attribute-only tags. + 'category_id' => Category::factory(), + 'content' => fake()->optional()->paragraph(), + 'title' => fake()->optional()->sentence(), + 'description' => fake()->optional()->sentence(), + 'no_index' => false, + 'canonical' => null, + ]; + } + + /** + * Attach attributes to the tag (a new one is created if none given). + * + * @param array|null $attributeIds + */ + public function withAttributes(?array $attributeIds = null, int $count = 1): static + { + return $this->afterCreating(function (Tag $tag) use ($attributeIds, $count): void { + $ids = $attributeIds ?? Attribute::factory()->count($count)->create()->pluck('id')->all(); + $tag->attributes()->syncWithoutDetaching($ids); + }); + } + + /** + * Show the tag in the storefront home-page featured-tags strip. + */ + public function featured(int $order = 0): static + { + return $this->state([ + 'show_on_home' => true, + 'home_order' => $order, + ]); + } + + public function withImage(): static + { + return $this->afterCreating(function (Tag $tag): void { + $tag->image()->create([ + 'path' => ImageFactory::placeholderUrl(), + 'is_featured' => true, + 'order' => 0, + 'alt_text' => is_string($tag->name) ? $tag->name : null, + ]); + }); + } +} diff --git a/admin/database/migrations/2026_06_20_000023_create_tags_table.php b/admin/database/migrations/2026_06_20_000023_create_tags_table.php new file mode 100644 index 00000000..aa575690 --- /dev/null +++ b/admin/database/migrations/2026_06_20_000023_create_tags_table.php @@ -0,0 +1,47 @@ +id(); + $table->text('name'); + $table->text('slug')->unique(); + // A tag is an SEO landing page for a category and/or attribute + // filter (see docs/TAGS.md). Both are optional (at least one is + // required, enforced in the app) so a tag can scope by category + // only, by attribute(s) only, or both. Attributes are a + // many-to-many via the attribute_tag pivot. + $table->foreignIdFor(Category::class)->nullable()->constrained()->cascadeOnDelete(); + $table->text('content')->nullable(); + // SEO fields, mirroring categories/products (decision: extend the + // schema rather than reuse name/content, since tags exist for SEO). + $table->text('title')->nullable(); + $table->string('description')->nullable(); + $table->boolean('no_index')->default(false); + $table->text('canonical')->nullable(); + // Home-page placement: whether the tag shows in the storefront's + // featured-tags strip, and in what order. This is how a customer + // discovers a tag from the home page (its image + name link to + // /tags/{slug}). The image itself is a polymorphic `images` row. + $table->boolean('show_on_home')->default(false); + $table->unsignedInteger('home_order')->default(0); + $table->timestamps(); + // Note: the original source schema had a `type` (user/seller) column; + // dropped here — ShopFlow is single-vendor, so it has no meaning. + }); + } + + public function down(): void + { + Schema::dropIfExists('tags'); + } +}; diff --git a/admin/database/migrations/2026_06_20_000024_create_attribute_tag_table.php b/admin/database/migrations/2026_06_20_000024_create_attribute_tag_table.php new file mode 100644 index 00000000..8725713b --- /dev/null +++ b/admin/database/migrations/2026_06_20_000024_create_attribute_tag_table.php @@ -0,0 +1,28 @@ +foreignIdFor(Attribute::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(Tag::class)->constrained()->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['attribute_id', 'tag_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('attribute_tag'); + } +}; diff --git a/admin/database/migrations/2026_06_20_000025_create_home_sections_table.php b/admin/database/migrations/2026_06_20_000025_create_home_sections_table.php new file mode 100644 index 00000000..7b5e0e10 --- /dev/null +++ b/admin/database/migrations/2026_06_20_000025_create_home_sections_table.php @@ -0,0 +1,37 @@ +id(); + $table->string('type')->default(HomeSectionTypeEnum::PRODUCTS->value); + $table->string('title')->nullable(); + // Type-specific settings (e.g. {"position":"home-main"} for a + // slider, {"sort":"newest"} for a product row). Null for types + // that need none (tags/categories/brands). + $table->json('config')->nullable(); + $table->unsignedInteger('order')->default(0); + $table->boolean('status')->default(true); + $table->timestamps(); + + $table->index(['status', 'order']); + }); + } + + public function down(): void + { + Schema::dropIfExists('home_sections'); + } +}; diff --git a/admin/database/seeders/DatabaseSeeder.php b/admin/database/seeders/DatabaseSeeder.php index 91070415..059464c7 100644 --- a/admin/database/seeders/DatabaseSeeder.php +++ b/admin/database/seeders/DatabaseSeeder.php @@ -23,6 +23,7 @@ public function run(): void AttributeGroupCategorySeeder::class, ShippingSeeder::class, SettingSeeder::class, + HomeSectionSeeder::class, ]); } } diff --git a/admin/database/seeders/HomeSectionSeeder.php b/admin/database/seeders/HomeSectionSeeder.php new file mode 100644 index 00000000..b0937d26 --- /dev/null +++ b/admin/database/seeders/HomeSectionSeeder.php @@ -0,0 +1,37 @@ + HomeSectionTypeEnum::SLIDER, 'title' => null, 'config' => ['position' => 'home-main'], 'order' => 1], + ['type' => HomeSectionTypeEnum::TAGS, 'title' => null, 'config' => null, 'order' => 2], + ['type' => HomeSectionTypeEnum::CATEGORIES, 'title' => null, 'config' => null, 'order' => 3], + ['type' => HomeSectionTypeEnum::BANNERS, 'title' => null, 'config' => ['position' => 'home-middle'], 'order' => 4], + ['type' => HomeSectionTypeEnum::PRODUCTS, 'title' => 'جدیدترین محصولات', 'config' => ['sort' => 'newest'], 'order' => 5], + ['type' => HomeSectionTypeEnum::PRODUCTS, 'title' => 'پربازدیدترین محصولات', 'config' => ['sort' => 'popular'], 'order' => 6], + ['type' => HomeSectionTypeEnum::BRANDS, 'title' => null, 'config' => null, 'order' => 7], + ]; + + foreach ($sections as $section) { + HomeSection::updateOrCreate( + ['order' => $section['order']], + [...$section, 'status' => true], + ); + } + } +} diff --git a/admin/database/seeders/SliderSeeder.php b/admin/database/seeders/SliderSeeder.php index d5491d97..1127f5a4 100644 --- a/admin/database/seeders/SliderSeeder.php +++ b/admin/database/seeders/SliderSeeder.php @@ -4,6 +4,8 @@ namespace Database\Seeders; +use App\Enums\SliderPositionEnum; +use App\Enums\SliderStatusEnum; use App\Models\Slide; use App\Models\Slider; use Illuminate\Database\Seeder; @@ -12,11 +14,24 @@ class SliderSeeder extends Seeder { public function run(): void { + // Delete slides one by one so each Slide's `deleting` event fires and + // cleans up its image. Then delete sliders with a plain delete(), not + // truncate(): Postgres refuses to TRUNCATE a table referenced by a FK + // (slides.slider_id) even once the slides are gone. Slide::all()->each->delete(); - Slider::query()->truncate(); + Slider::query()->delete(); + + // One published slider per position, so every placement the storefront + // knows about (SliderPositionEnum) has coherent demo data and the + // storefront's "first published slider per position" lookup is + // deterministic (no random duplicate positions fighting for the spot). + foreach (SliderPositionEnum::cases() as $position) { + $slider = Slider::factory()->create([ + 'position' => $position->value, + 'status' => SliderStatusEnum::PUBLISHED, + ]); - Slider::factory()->count(10)->create()->each(function (Slider $slider): void { Slide::factory()->count(5)->withImage()->create(['slider_id' => $slider->id]); - }); + } } } diff --git a/admin/database/seeders/TagSeeder.php b/admin/database/seeders/TagSeeder.php new file mode 100644 index 00000000..b22e5b1b --- /dev/null +++ b/admin/database/seeders/TagSeeder.php @@ -0,0 +1,50 @@ +delete(); + + $categories = Category::query()->inRandomOrder()->limit(self::COUNT)->get(); + $attributes = Attribute::query()->inRandomOrder()->limit(self::COUNT)->get(); + + // With no reference data (e.g. run in isolation), let the factory make + // its own throwaway category + attribute per tag so there is still + // data to test against. + if ($categories->isEmpty() || $attributes->isEmpty()) { + Tag::factory()->count(self::COUNT)->withAttributes()->create(); + + return; + } + + // Normal path: fake tags (random name/slug/content) pointed at real + // categories + a real attribute, so their /tags/{slug} pages can + // resolve products instead of being empty. The first few are marked + // "show on home" with an image so the storefront featured-tags strip + // has data to render. + for ($i = 0; $i < self::COUNT; $i++) { + $factory = Tag::factory()->withAttributes([$attributes->random()->id]); + + if ($i < 4) { + $factory = $factory->featured($i)->withImage(); + } + + $factory->create(['category_id' => $categories->random()->id]); + } + } +} diff --git a/admin/database/seeders/TestSeeder.php b/admin/database/seeders/TestSeeder.php index 1578fb2e..f2193c70 100644 --- a/admin/database/seeders/TestSeeder.php +++ b/admin/database/seeders/TestSeeder.php @@ -24,6 +24,7 @@ public function run(): void FaqSeeder::class, ReviewSeeder::class, WishlistSeeder::class, + TagSeeder::class, CartSeeder::class, OrderSeeder::class, OrderVarietySeeder::class, diff --git a/admin/docs/IMPLEMENTATION.md b/admin/docs/IMPLEMENTATION.md index 4e7729a1..adbab1ca 100644 --- a/admin/docs/IMPLEMENTATION.md +++ b/admin/docs/IMPLEMENTATION.md @@ -80,7 +80,8 @@ Depend mostly on Images only. - [x] FAQs - [x] Reviews - [x] Wishlists -- [ ] Tags +- [x] Tags (SEO landing page for a category and/or attribute filter; `tags` + `attribute_tag` pivot migrations, `Tag` model + factory + seeder, `TagResource` + pages + lang, tests. `category_id` nullable + attributes many-to-many, at-least-one-required; SEO columns added — `title`/`description`/`no_index`/`canonical`; source single `attribute_id` replaced by the pivot and `type` column dropped, single-vendor. See shop `TAGS.md`) +- [x] Home Sections (admin side only). New ShopFlow table `home_sections` (not in the source schema) so staff compose the storefront home page instead of it being hardcoded: `HomeSection` model + factory + `HomeSectionSeeder` (seeds the current hardcoded order), `HomeSectionTypeEnum` (`slider`/`tags`/`categories`/`banners`/`products`/`brands`, mirrored in the shop), `HomeSectionResource` (drag-to-reorder table; the form shows only the fields the chosen `type` needs — slider/banner `config.position` from the matching position enum, product `config.sort` + `title`) + lang + tests. **The storefront does not read this table yet** — see shop `STOREFRONT_IMPLEMENTATION.md` - [ ] Brand-Category pages - [ ] Redirects - [ ] Helps diff --git a/admin/docs/ShoFlow db doc.md b/admin/docs/ShoFlow db doc.md index b59b0816..f034d869 100644 --- a/admin/docs/ShoFlow db doc.md +++ b/admin/docs/ShoFlow db doc.md @@ -97,9 +97,9 @@ The **`attribute_group_category`** table (singular) manages the relationship bet Used to store banners. -* `position` specifies the advertisement location, which is an arbitrary name to retrieve the corresponding record from the database. +* `position` specifies where the banner appears. Constrained to `App\Enums\BannerPositionEnum` (mirrored in both apps): `home-top`, `home-middle`, `category-side`. Admin picks it from a dropdown; the storefront looks it up by the same enum value (`GetBannersByPosition`). A position can be rendered as a grid (all published banners) or a single banner (`->first()`) — the enum doesn't dictate that. Only `home-middle` is rendered today (the home grid). * `heading` specifies the banner item title or the alt text of the image. -* `url` specifies the item link, which redirects when the image or title is clicked. +* `url` specifies the item link (image/title click target). Accepts an absolute URL (`https://…`) or an internal path (`/tags/…`, `/categories/…`) — the admin field validates for either, so banners can link to tag pages. `sliders`/`slides` `url` accepts the same. * `sort` specifies the item order. * `status` stores the publication status of the banner, with values 10 for deleted, 20 for published, and 30 for draft. @@ -387,6 +387,19 @@ Implementation notes: * `content`: Displays the help content. * `position`: Specifies which section the help is for (admin, sellers, etc.). +# home_sections + +**Not in the source schema — added by ShopFlow.** The ordered list of blocks the storefront home page is composed from, so staff can add/reorder/disable home rows instead of the layout being hardcoded in `Home.vue`. Admin manages them via `HomeSectionResource` (drag-to-reorder table). + +* `type`: Which block to render. `App\Enums\HomeSectionTypeEnum` (string-backed, mirrored in both apps): `slider`, `tags`, `categories`, `banners`, `products`, `brands`. Each type maps to one storefront component + data action. Defaults to `products`. +* `title`: Optional heading shown above the block. Only meaningful for `products` rows (the other types carry their own heading); nullable. +* `config`: JSON bag of type-specific settings, nullable. `slider` → `{"position": ""}`, `banners` → `{"position": ""}`, `products` → `{"sort": "newest"|"popular"}`. `tags`/`categories`/`brands` need none. The admin form shows only the fields the chosen `type` uses and requires them. +* `order`: Display order, ascending (set by drag-to-reorder in the admin table). +* `status`: Boolean; `false` hides the block without deleting it. Indexed together with `order`. +* `created_at` / `updated_at`. + +> **Storefront wiring is not built yet** — `HomeController`/`Home.vue` still render a hardcoded section order and ignore this table. See shop `STOREFRONT_IMPLEMENTATION.md`. + # holidays * Stores holidays when no product delivery is made. @@ -1000,15 +1013,22 @@ This table is used to store "Contact Us" information. # tags -This table is for storing tags. +**Built.** A tag is an SEO landing page for a category **and/or** attribute filter (see shop `TAGS.md`) — its own URL (`/tags/{slug}`) listing the products in `category_id` (and descendants), or across all categories when no category, that carry the tag's attribute(s). Not a free-form product label; there is no `product_tag` pivot. Admin manages tags via `TagResource`. -* `slug`: Stores the tag's slug. -* `name`: Stores the tag's name. -* `category_id`: Stores the category ID. -* `attribute_id`: Stores the attribute ID. -* `content`: Stores the content related to the tag. -* `type`: Specifies the type of the question, for example, for users or sellers. -* `created_at`: Stores the creation date. +* `name`: Tag display name. +* `slug`: URL slug, unique, stable. +* `category_id`: FK → `categories`, **nullable**, `cascadeOnDelete`. +* `content`: Editor HTML shown on the tag page (nullable). +* `title`, `description`, `no_index` (bool, default false), `canonical`: SEO fields, mirroring `categories`/`products`. Added when tags were built (the original source schema had none of these). +* `show_on_home` (bool, default false) + `home_order` (unsigned int, default 0): whether the tag appears in the storefront home-page featured-tags strip, and its order there. Its image is a polymorphic `images` row (like categories/slides). +* `created_at` / `updated_at`. +* Attributes are **many-to-many** via the `attribute_tag` pivot (`attribute_id` + `tag_id`, unique pair, cascade). A tag has zero or more attributes. +* **Rule:** category and attributes are each optional, but **at least one must be set** (enforced in the admin form, not the DB). +* **Not** carried over from the source schema: the old single `attribute_id` column (now the pivot) and the `type` (user/seller) column — ShopFlow is single-vendor, so `type` was dropped. + +# attribute_tag + +Pivot linking `tags` to their `attributes` (many-to-many). `attribute_id` + `tag_id` (unique pair), both `cascadeOnDelete`, plus timestamps. # points diff --git a/admin/lang/en/banner.php b/admin/lang/en/banner.php index 41cf1db9..a2ac9e5b 100644 --- a/admin/lang/en/banner.php +++ b/admin/lang/en/banner.php @@ -8,8 +8,14 @@ 'navigation_group' => 'Content', 'position' => 'Position', + 'position_hint' => 'Where this banner appears on the storefront. Only the fixed placements the frontend knows about are offered.', + 'position_home_top' => 'Home — top', + 'position_home_middle' => 'Home — middle grid', + 'position_category_side' => 'Category page — side', 'heading' => 'Heading', 'url' => 'URL', + 'url_hint' => 'Where the banner links to. Use an absolute URL (https://…) or an internal path such as /tags/gaming-gear or /categories/mobile.', + 'url_invalid' => 'Enter an absolute URL (https://…) or an internal path starting with /.', 'sort' => 'Sort', 'status' => 'Status', 'images' => 'Images', diff --git a/admin/lang/en/home_section.php b/admin/lang/en/home_section.php new file mode 100644 index 00000000..6f554a3a --- /dev/null +++ b/admin/lang/en/home_section.php @@ -0,0 +1,28 @@ + 'Home Section', + 'plural_label' => 'Home Sections', + 'navigation_group' => 'Content', + 'subheading' => 'Compose the storefront home page: add, reorder (drag rows) and toggle sections. Each section is rendered by its type.', + + 'type' => 'Type', + 'type_hint' => 'Which kind of section to render. Some types need extra settings below.', + 'position' => 'Position', + 'sort_by' => 'Sort products by', + 'sort_newest' => 'Newest', + 'sort_popular' => 'Most viewed', + 'title' => 'Title', + 'title_hint' => 'Heading shown above a product row.', + 'order' => 'Order', + 'status' => 'Active', + + 'type_slider' => 'Slider', + 'type_tags' => 'Tags', + 'type_categories' => 'Categories', + 'type_banners' => 'Banners', + 'type_products' => 'Product row', + 'type_brands' => 'Brands', +]; diff --git a/admin/lang/en/slide.php b/admin/lang/en/slide.php index 3b40e7ac..501251b8 100644 --- a/admin/lang/en/slide.php +++ b/admin/lang/en/slide.php @@ -15,7 +15,8 @@ 'label_field' => 'Label', 'label_field_hint' => 'Optional secondary text shown on the slide, e.g. a subtitle or call-to-action label.', 'url' => 'URL', - 'url_hint' => 'The link the slide points to when clicked.', + 'url_hint' => 'The link the slide points to when clicked. Use an absolute URL (https://…) or an internal path such as /tags/gaming-gear.', + 'url_invalid' => 'Enter an absolute URL (https://…) or an internal path starting with /.', 'order' => 'Order', 'order_hint' => 'Display order within the slider. Lower numbers appear first.', 'image' => 'Image', diff --git a/admin/lang/en/tag.php b/admin/lang/en/tag.php new file mode 100644 index 00000000..5e377f4d --- /dev/null +++ b/admin/lang/en/tag.php @@ -0,0 +1,33 @@ + 'Tag', + 'plural_label' => 'Tags', + 'navigation_group' => 'Content', + 'subheading' => 'SEO landing pages for a category + attribute filter (e.g. "Gaming gear", "Red men\'s shoes"). Each tag has its own URL and lists the matching products; it is not a free-form product label.', + + 'section_main' => 'Tag', + 'section_home' => 'Home Page', + 'section_seo' => 'SEO', + + 'name' => 'Name', + 'slug' => 'Slug', + 'slug_hint' => 'The stable, human-readable URL segment: /tags/{slug}. Do not change it once published.', + 'category_id' => 'Category', + 'category_id_hint' => 'Optional. The category the tag scopes to (its sub-categories are included). Leave empty for an attribute-only tag. At least one of category or attributes is required.', + 'attributes' => 'Attributes', + 'attributes_hint' => 'Optional. One or more attributes the tag filters by (OR within a group, AND across groups). Leave empty for a category-only tag. At least one of category or attributes is required.', + 'content' => 'Content', + 'title' => 'SEO Title', + 'description' => 'SEO Description', + 'canonical' => 'Canonical URL', + 'no_index' => 'No Index', + 'show_on_home' => 'Show on Home Page', + 'show_on_home_hint' => 'When on, this tag appears in the storefront home page featured-tags strip (its image + name link to the tag page).', + 'home_order' => 'Home Order', + 'image' => 'Image', + 'path' => 'Image File', + 'created_at' => 'Created At', +]; diff --git a/admin/lang/fa/banner.php b/admin/lang/fa/banner.php index 1b7d5dad..38097e0b 100644 --- a/admin/lang/fa/banner.php +++ b/admin/lang/fa/banner.php @@ -8,8 +8,14 @@ 'navigation_group' => 'محتوا', 'position' => 'موقعیت', + 'position_hint' => 'محل نمایش این بنر در فروشگاه. فقط جایگاه‌های ثابتی که فرانت‌اند می‌شناسد در دسترس است.', + 'position_home_top' => 'صفحه خانه — بالا', + 'position_home_middle' => 'صفحه خانه — شبکه میانی', + 'position_category_side' => 'صفحه دسته‌بندی — کنار', 'heading' => 'عنوان', 'url' => 'لینک', + 'url_hint' => 'مقصد لینک بنر. یک نشانی کامل (‏https://…‏) یا یک مسیر داخلی مانند ‏/tags/gaming-gear‏ یا ‏/categories/mobile‏ وارد کنید.', + 'url_invalid' => 'یک نشانی کامل (‏https://…‏) یا یک مسیر داخلی که با / شروع می‌شود وارد کنید.', 'sort' => 'ترتیب', 'status' => 'وضعیت', 'images' => 'تصاویر', diff --git a/admin/lang/fa/home_section.php b/admin/lang/fa/home_section.php new file mode 100644 index 00000000..dcb165b5 --- /dev/null +++ b/admin/lang/fa/home_section.php @@ -0,0 +1,28 @@ + 'بخش صفحه خانه', + 'plural_label' => 'بخش‌های صفحه خانه', + 'navigation_group' => 'محتوا', + 'subheading' => 'چیدمان صفحه خانه فروشگاه: بخش‌ها را اضافه، جابجا (با کشیدن ردیف‌ها) و فعال/غیرفعال کنید. هر بخش بر اساس نوعش نمایش داده می‌شود.', + + 'type' => 'نوع', + 'type_hint' => 'نوع بخشی که نمایش داده می‌شود. برخی نوع‌ها به تنظیمات بیشتری در پایین نیاز دارند.', + 'position' => 'موقعیت', + 'sort_by' => 'مرتب‌سازی کالاها بر اساس', + 'sort_newest' => 'جدیدترین', + 'sort_popular' => 'پربازدیدترین', + 'title' => 'عنوان', + 'title_hint' => 'عنوانی که بالای ردیف کالاها نمایش داده می‌شود.', + 'order' => 'ترتیب', + 'status' => 'فعال', + + 'type_slider' => 'اسلایدر', + 'type_tags' => 'تگ‌ها', + 'type_categories' => 'دسته‌بندی‌ها', + 'type_banners' => 'بنرها', + 'type_products' => 'ردیف کالا', + 'type_brands' => 'برندها', +]; diff --git a/admin/lang/fa/slide.php b/admin/lang/fa/slide.php index 74d4193f..dab57d5c 100644 --- a/admin/lang/fa/slide.php +++ b/admin/lang/fa/slide.php @@ -15,7 +15,8 @@ 'label_field' => 'برچسب', 'label_field_hint' => 'متن ثانویه اختیاری روی اسلاید، مثلاً زیرعنوان یا متن دعوت به اقدام.', 'url' => 'لینک', - 'url_hint' => 'لینکی که با کلیک روی اسلاید باز می‌شود.', + 'url_hint' => 'لینکی که با کلیک روی اسلاید باز می‌شود. یک نشانی کامل (‏https://…‏) یا یک مسیر داخلی مانند ‏/tags/gaming-gear‏ وارد کنید.', + 'url_invalid' => 'یک نشانی کامل (‏https://…‏) یا یک مسیر داخلی که با / شروع می‌شود وارد کنید.', 'order' => 'ترتیب', 'order_hint' => 'ترتیب نمایش در اسلایدر. اعداد کمتر اول نشان داده می‌شوند.', 'image' => 'تصویر', diff --git a/admin/lang/fa/tag.php b/admin/lang/fa/tag.php new file mode 100644 index 00000000..1defcd49 --- /dev/null +++ b/admin/lang/fa/tag.php @@ -0,0 +1,33 @@ + 'تگ', + 'plural_label' => 'تگ‌ها', + 'navigation_group' => 'محتوا', + 'subheading' => 'صفحات فرود سئو برای ترکیب دسته‌بندی + ویژگی (مثلاً «تجهیزات گیمینگ»، «کفش مردانه قرمز»). هر تگ نشانی اختصاصی خود را دارد و کالاهای منطبق را نمایش می‌دهد؛ یک برچسب آزاد روی کالا نیست.', + + 'section_main' => 'تگ', + 'section_home' => 'صفحه خانه', + 'section_seo' => 'سئو', + + 'name' => 'نام', + 'slug' => 'اسلاگ', + 'slug_hint' => 'بخش پایدار و خوانای نشانی: ‏/tags/{slug}. پس از انتشار تغییرش ندهید.', + 'category_id' => 'دسته‌بندی', + 'category_id_hint' => 'اختیاری. دسته‌بندی‌ای که تگ به آن محدود می‌شود (زیر‌دسته‌ها هم شامل می‌شوند). برای تگ فقط-ویژگی خالی بگذارید. حداقل یکی از دسته‌بندی یا ویژگی‌ها الزامی است.', + 'attributes' => 'ویژگی‌ها', + 'attributes_hint' => 'اختیاری. یک یا چند ویژگی که تگ بر اساس آن‌ها فیلتر می‌کند (در یک گروه «یا»، بین گروه‌ها «و»). برای تگ فقط-دسته‌بندی خالی بگذارید. حداقل یکی از دسته‌بندی یا ویژگی‌ها الزامی است.', + 'content' => 'محتوا', + 'title' => 'عنوان سئو', + 'description' => 'توضیحات سئو', + 'canonical' => 'نشانی کاننیکال', + 'no_index' => 'نمایه نشود', + 'show_on_home' => 'نمایش در صفحه خانه', + 'show_on_home_hint' => 'در صورت فعال بودن، این تگ در نوار تگ‌های منتخب صفحه خانه فروشگاه نمایش داده می‌شود (تصویر و نام آن به صفحه تگ لینک می‌شود).', + 'home_order' => 'ترتیب در خانه', + 'image' => 'تصویر', + 'path' => 'فایل تصویر', + 'created_at' => 'تاریخ ایجاد', +]; diff --git a/admin/tests/Feature/Filament/Resource/BannerResourceTest.php b/admin/tests/Feature/Filament/Resource/BannerResourceTest.php index c906b44f..fd60700d 100644 --- a/admin/tests/Feature/Filament/Resource/BannerResourceTest.php +++ b/admin/tests/Feature/Filament/Resource/BannerResourceTest.php @@ -80,6 +80,36 @@ ]); }); +it('accepts an internal path url (e.g. a tag link).', function () { + $banner = Banner::factory()->make(); + + livewire(BannerResource\Pages\CreateBanner::class) + ->fillForm([ + 'position' => $banner->position, + 'heading' => $banner->heading, + 'url' => '/tags/gaming-gear', + 'status' => $banner->status->value, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $this->assertDatabaseHas(Banner::class, ['url' => '/tags/gaming-gear']); +}); + +it('rejects a url that is neither absolute nor an internal path.', function () { + $banner = Banner::factory()->make(); + + livewire(BannerResource\Pages\CreateBanner::class) + ->fillForm([ + 'position' => $banner->position, + 'heading' => $banner->heading, + 'url' => 'not a url', + 'status' => $banner->status->value, + ]) + ->call('create') + ->assertHasFormErrors(['url']); +}); + it('can delete banner model.', function () { $banner = Banner::factory()->create(); diff --git a/admin/tests/Feature/Filament/Resource/HomeSectionResourceTest.php b/admin/tests/Feature/Filament/Resource/HomeSectionResourceTest.php new file mode 100644 index 00000000..022de52d --- /dev/null +++ b/admin/tests/Feature/Filament/Resource/HomeSectionResourceTest.php @@ -0,0 +1,78 @@ +assertOk(); +}); + +it('can list home sections in the table.', function () { + $sections = HomeSection::factory()->count(3)->create(); + + livewire(ListHomeSections::class) + ->assertCanSeeTableRecords($sections); +}); + +it('can create a product-row section with a sort and title.', function () { + livewire(CreateHomeSection::class) + ->fillForm([ + 'type' => HomeSectionTypeEnum::PRODUCTS->value, + 'title' => 'جدیدترین محصولات', + 'config' => ['sort' => 'newest'], + 'status' => true, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $section = HomeSection::query()->latest('id')->firstOrFail(); + expect($section->type)->toBe(HomeSectionTypeEnum::PRODUCTS) + ->and($section->config)->toBe(['sort' => 'newest']); +}); + +it('can create a slider section with a position.', function () { + livewire(CreateHomeSection::class) + ->fillForm([ + 'type' => HomeSectionTypeEnum::SLIDER->value, + 'config' => ['position' => 'home-main'], + 'status' => true, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + expect(HomeSection::query()->latest('id')->firstOrFail()->config)->toBe(['position' => 'home-main']); +}); + +it('requires a position for a slider section.', function () { + livewire(CreateHomeSection::class) + ->fillForm([ + 'type' => HomeSectionTypeEnum::SLIDER->value, + 'config' => ['position' => null], + 'status' => true, + ]) + ->call('create') + ->assertHasFormErrors(['config.position']); +}); + +it('can delete a home section.', function () { + $section = HomeSection::factory()->create(); + + livewire(EditHomeSection::class, ['record' => $section->getRouteKey()]) + ->callAction(DeleteAction::class); + + $this->assertModelMissing($section); +}); diff --git a/admin/tests/Feature/Filament/Resource/TagResourceTest.php b/admin/tests/Feature/Filament/Resource/TagResourceTest.php new file mode 100644 index 00000000..ad72fd12 --- /dev/null +++ b/admin/tests/Feature/Filament/Resource/TagResourceTest.php @@ -0,0 +1,108 @@ +assertOk(); +}); + +it('can list tags in the table.', function () { + $tags = Tag::factory()->count(5)->create(); + + livewire(ListTags::class) + ->assertCanSeeTableRecords($tags); +}); + +it('can render edit tag page.', function () { + $tag = Tag::factory()->create(); + + get(TagResource::getUrl('edit', ['record' => $tag]))->assertOk(); +}); + +it('can create a tag with a category and multiple attributes.', function () { + $category = Category::factory()->create(); + $attributes = Attribute::factory()->count(2)->create(); + + livewire(CreateTag::class) + ->fillForm([ + 'name' => 'تجهیزات گیمینگ', + 'slug' => 'gaming-gear', + 'category_id' => $category->id, + 'attributes' => $attributes->pluck('id')->all(), + 'no_index' => false, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $tag = Tag::query()->where('slug', 'gaming-gear')->firstOrFail(); + expect($tag->category_id)->toBe($category->id) + ->and($tag->attributes)->toHaveCount(2); +}); + +it('can create an attribute-only tag (no category).', function () { + $attribute = Attribute::factory()->create(); + + livewire(CreateTag::class) + ->fillForm([ + 'name' => 'محصولات قرمز', + 'slug' => 'red-products', + 'attributes' => [$attribute->id], + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $tag = Tag::query()->where('slug', 'red-products')->firstOrFail(); + expect($tag->category_id)->toBeNull() + ->and($tag->attributes)->toHaveCount(1); +}); + +it('requires at least one of category or attributes.', function () { + livewire(CreateTag::class) + ->fillForm([ + 'name' => 'بدون هیچ‌کدام', + 'slug' => 'neither', + 'category_id' => null, + 'attributes' => [], + ]) + ->call('create') + ->assertHasFormErrors(['category_id', 'attributes']); +}); + +it('requires a unique slug.', function () { + Tag::factory()->create(['slug' => 'gaming-gear']); + $category = Category::factory()->create(); + + livewire(CreateTag::class) + ->fillForm([ + 'name' => 'دوباره', + 'slug' => 'gaming-gear', + 'category_id' => $category->id, + ]) + ->call('create') + ->assertHasFormErrors(['slug']); +}); + +it('can delete a tag.', function () { + $tag = Tag::factory()->create(); + + livewire(EditTag::class, ['record' => $tag->getRouteKey()]) + ->callAction(DeleteAction::class); + + $this->assertModelMissing($tag); +}); From a97d064054ea549e3bf8df3392dfaa2faa475c2e Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Wed, 5 Aug 2026 11:55:21 +0330 Subject: [PATCH 04/40] feat(shop): tag landing pages, home tag strip, banner position lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tag pages: `GET /tags/{slug}` -> `TagController@show` + `Tags/Show.vue`. A tag is an SEO landing page for a category and/or attribute filter, so the page reuses the category machinery — `CollectCategoryIds` + `GetCategoryProducts` + `GetCategoryFilters` — merging the tag's attribute ids into the applied filters (OR within an attribute group, AND across groups, same as the category page). For attribute-only tags there is no category, so the controller passes an empty category-id list and both shared actions now guard their category `whereIn` behind `when($ids !== [])` ("no category constraint"); category pages always pass ids, so they are unaffected. `app/Actions/Tag/BuildTagDetail` (-> `TagDTO`, nullable category name/url) + `BuildTagBreadcrumbs` (Home -> [Category …] -> Tag). SEO title/description/canonical/no_index come from the tag; JSON-LD `BreadcrumbList`. Tests cover attribute filtering, category-only, attribute-only (cross-category) and multi-attribute AND. Home: tags flagged `show_on_home` render as an image strip after the category strip (`GetHomeTags` -> `tags` prop -> `Home/TagStrip.vue`), each linking to its tag page. Banners: `GetPromoBanners` becomes `GetBannersByPosition`, taking a `BannerPositionEnum` mirrored from the admin enum, so the home grid asks for `home-middle` instead of relying on an implicit convention — the same pattern already used for slider positions. Docs: TAGS.md rewritten as built; `home_sections` documented in the db doc and flagged in both roadmaps as admin-only (the storefront still renders a hardcoded home layout and does not read the table yet). --- shop/AGENTS.md | 1 + .../Actions/Category/GetCategoryFilters.php | 5 +- .../Actions/Category/GetCategoryProducts.php | 4 +- ...moBanners.php => GetBannersByPosition.php} | 18 +- shop/app/Actions/Home/GetHomeTags.php | 37 ++++ shop/app/Actions/Tag/BuildTagBreadcrumbs.php | 38 ++++ shop/app/Actions/Tag/BuildTagDetail.php | 27 +++ shop/app/DTOs/TagDTO.php | 40 ++++ shop/app/Enums/BannerPositionEnum.php | 33 ++++ shop/app/Http/Controllers/HomeController.php | 10 +- shop/app/Http/Controllers/TagController.php | 86 +++++++++ shop/app/Models/Tag.php | 78 ++++++++ shop/docs/IMPLEMENTATION.md | 3 +- shop/docs/STOREFRONT_IMPLEMENTATION.md | 7 + shop/docs/ShoFlow db doc.md | 40 +++- shop/docs/TAGS.md | 68 +++---- shop/lang/fa/enums.php | 5 + .../resources/js/Components/Home/TagStrip.vue | 35 ++++ shop/resources/js/Pages/Home.vue | 7 + shop/resources/js/Pages/Tags/Show.vue | 177 ++++++++++++++++++ shop/routes/web.php | 4 + shop/tests/Feature/HomePageTest.php | 22 +++ shop/tests/Feature/TagTest.php | 156 +++++++++++++++ 23 files changed, 836 insertions(+), 65 deletions(-) rename shop/app/Actions/Home/{GetPromoBanners.php => GetBannersByPosition.php} (58%) create mode 100644 shop/app/Actions/Home/GetHomeTags.php create mode 100644 shop/app/Actions/Tag/BuildTagBreadcrumbs.php create mode 100644 shop/app/Actions/Tag/BuildTagDetail.php create mode 100644 shop/app/DTOs/TagDTO.php create mode 100644 shop/app/Enums/BannerPositionEnum.php create mode 100644 shop/app/Http/Controllers/TagController.php create mode 100644 shop/app/Models/Tag.php create mode 100644 shop/resources/js/Components/Home/TagStrip.vue create mode 100644 shop/resources/js/Pages/Tags/Show.vue create mode 100644 shop/tests/Feature/TagTest.php diff --git a/shop/AGENTS.md b/shop/AGENTS.md index 8fc94ce2..9c61f4af 100644 --- a/shop/AGENTS.md +++ b/shop/AGENTS.md @@ -332,6 +332,7 @@ DB_DATABASE=shop_flow_test php artisan db:seed --class="Database\Seeders\Setting - **Catalog filtering**: attribute filters on the category page match products through the `product_attribute` pivot (`Product::attributes()`) — the schema's documented "filters to products" link — never through `varieties`/`attribute_variety` (those drive the product page's variety selector). `attribute_group_category.as_filter` decides which groups appear as filters (resolved across the category and its descendants). Facet within a group is OR, across groups is AND. - Facets only list values actually attached to products in the category, and each option carries a product `count` (category-level baseline, not recomputed against the other active selections). - Facet groups render in `attribute_groups.order` (admin-configured), not alphabetically by name — `GetCategoryFilters::attributeGroups()` orders by `order` then `name` as a tiebreak. Displayed text uses `attribute_groups.name`; `label` is documented as admin-panel-only (per `ShoFlow db doc.md`) and is never shown to customers. +- **Tags** (`/tags/{slug}`, `TagController@show`): a tag is an **SEO landing page for a category and/or attribute filter**, NOT a free-form product label — there is no `product_tag` pivot. The `tags` table is admin-owned (`TagResource`); **`category_id` is nullable** and **attributes are many-to-many** via the `attribute_tag` pivot (`Tag::attributes()`), plus SEO columns (`title`/`description`/`no_index`/`canonical`) and `content`. Category and attributes are each optional but **at least one is required** (admin enforces via `requiredWithout`). Three shapes: category+attrs, category-only, attribute(s)-only. The controller **reuses the category machinery** — `CollectCategoryIds` + `GetCategoryProducts` + `GetCategoryFilters` — merging the tag's attribute ids into the applied `attributes` filter (grouped OR-within/AND-across, same as the category page). **Key detail:** for attribute-only tags there's no category, so the controller passes an empty category-id list, and `GetCategoryProducts`/`GetCategoryFilters` treat an empty list as "no category constraint" (each guards its category `whereIn` behind a `when($ids !== [])` — category pages always pass ids, so they're unaffected). `app/Actions/Tag/*` = `BuildTagDetail` (→ `TagDTO`, nullable `categoryName`/`categoryUrl`) + `BuildTagBreadcrumbs` (Home → [Category …] → Tag; category crumbs omitted when attribute-only). `Tags/Show.vue` mirrors `Category/Show.vue`. **Home-page discovery:** tags carry `show_on_home` + `home_order` + a polymorphic image; `GetHomeTags` feeds the `tags` prop that `HomeController` passes to `Home.vue`, rendered as image cards by `Components/Home/TagStrip.vue` (after the category strip), each linking to `/tags/{slug}`. See `docs/TAGS.md`. - An availability filter (`in_stock` → `products.has_stock`) and a price range (on `products.price`, the denormalized cheapest-variety base price) are also supported. - Filter UI is Digikala-style (`CategoryFilters.vue`): availability toggle, price range slider, brand list with a search box, collapsible accordion sections, per-option counts, and instant apply on change. - **Product gallery**: show all images together — the product images plus every variety image, combined and deduped by URL. Never hide images based on the selection; selecting a variety only switches the main image to that variety's photo (when it exists in the list). diff --git a/shop/app/Actions/Category/GetCategoryFilters.php b/shop/app/Actions/Category/GetCategoryFilters.php index c5e2a44e..2a50e440 100644 --- a/shop/app/Actions/Category/GetCategoryFilters.php +++ b/shop/app/Actions/Category/GetCategoryFilters.php @@ -9,6 +9,7 @@ use App\Models\AttributeGroup; use App\Models\Brand; use App\Models\Product; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\DB; @@ -41,7 +42,7 @@ private function brands(array $categoryIds, array $selected): array { $counts = Product::query() ->published() - ->whereIn('category_id', $categoryIds) + ->when($categoryIds !== [], fn (Builder $q): Builder => $q->whereIn('category_id', $categoryIds)) ->whereNotNull('brand_id') ->selectRaw('brand_id, count(*) as aggregate') ->groupBy('brand_id') @@ -129,7 +130,7 @@ private function priceBounds(array $categoryIds): array { $base = Product::query() ->published() - ->whereIn('category_id', $categoryIds); + ->when($categoryIds !== [], fn (Builder $q): Builder => $q->whereIn('category_id', $categoryIds)); return [ 'min' => (int) ((clone $base)->min('price') ?? 0), diff --git a/shop/app/Actions/Category/GetCategoryProducts.php b/shop/app/Actions/Category/GetCategoryProducts.php index aad2e43b..612f7009 100644 --- a/shop/app/Actions/Category/GetCategoryProducts.php +++ b/shop/app/Actions/Category/GetCategoryProducts.php @@ -32,7 +32,9 @@ public function __invoke(array $categoryIds, array $filters): array { $query = Product::query() ->published() - ->whereIn('category_id', $categoryIds) + // Empty category list = no category constraint (attribute-only tag + // pages span all categories); category pages always pass ids. + ->when($categoryIds !== [], fn (Builder $q): Builder => $q->whereIn('category_id', $categoryIds)) ->with([ 'featuredImage', 'varieties' => fn (Relation $relation) => $relation->where('status', VarietyStatusEnum::PUBLISHED->value)->with('image'), diff --git a/shop/app/Actions/Home/GetPromoBanners.php b/shop/app/Actions/Home/GetBannersByPosition.php similarity index 58% rename from shop/app/Actions/Home/GetPromoBanners.php rename to shop/app/Actions/Home/GetBannersByPosition.php index e2a9fd1a..e30e9ecf 100644 --- a/shop/app/Actions/Home/GetPromoBanners.php +++ b/shop/app/Actions/Home/GetBannersByPosition.php @@ -5,25 +5,27 @@ namespace App\Actions\Home; use App\Actions\Catalog\TransformImage; +use App\Enums\BannerPositionEnum; use App\Models\Banner; -class GetPromoBanners +class GetBannersByPosition { - /** - * Admin-defined banner position the storefront home reads. - */ - private const POSITION = 'home-middle'; - public function __construct(private TransformImage $transformImage) {} /** + * Published banners assigned to a given position, ordered by `sort`. The + * caller decides whether to render them as a grid (all) or a single + * banner (the first). The position is a BannerPositionEnum (shared with + * the admin) rather than a loose string, so the admin's choice and this + * lookup can't drift apart. + * * @return array> */ - public function __invoke(): array + public function __invoke(BannerPositionEnum $position): array { return Banner::query() ->published() - ->where('position', self::POSITION) + ->where('position', $position->value) ->with('featuredImage') ->orderBy('sort') ->get() diff --git a/shop/app/Actions/Home/GetHomeTags.php b/shop/app/Actions/Home/GetHomeTags.php new file mode 100644 index 00000000..9ee27acf --- /dev/null +++ b/shop/app/Actions/Home/GetHomeTags.php @@ -0,0 +1,37 @@ +> + */ + public function __invoke(): array + { + return Tag::query() + ->onHome() + ->with('image') + ->orderBy('home_order') + ->orderBy('id') + ->get() + ->map(fn (Tag $tag): array => [ + 'id' => $tag->id, + 'name' => $tag->name, + 'url' => '/tags/'.$tag->slug, + 'image' => ($this->transformImage)($tag->image)?->toArray(), + ]) + ->all(); + } +} diff --git a/shop/app/Actions/Tag/BuildTagBreadcrumbs.php b/shop/app/Actions/Tag/BuildTagBreadcrumbs.php new file mode 100644 index 00000000..ef816ee8 --- /dev/null +++ b/shop/app/Actions/Tag/BuildTagBreadcrumbs.php @@ -0,0 +1,38 @@ + + */ + public function __invoke(Tag $tag): array + { + $chain = []; + $category = $tag->category; + + while ($category instanceof Category) { + array_unshift($chain, [ + 'heading' => $category->heading, + 'url' => '/categories/'.$category->slug, + ]); + $category = $category->parent; + } + + return [ + ['heading' => trans('messages.breadcrumb.home'), 'url' => '/'], + ...$chain, + ['heading' => $tag->name, 'url' => null], + ]; + } +} diff --git a/shop/app/Actions/Tag/BuildTagDetail.php b/shop/app/Actions/Tag/BuildTagDetail.php new file mode 100644 index 00000000..a8ac5414 --- /dev/null +++ b/shop/app/Actions/Tag/BuildTagDetail.php @@ -0,0 +1,27 @@ +id, + name: $tag->name, + url: '/tags/'.$tag->slug, + title: $tag->title, + description: $tag->description, + content: $tag->content, + noIndex: (bool) $tag->no_index, + canonical: $tag->canonical, + categoryName: $tag->category?->heading, + categoryUrl: $tag->category === null ? null : '/categories/'.$tag->category->slug, + ); + } +} diff --git a/shop/app/DTOs/TagDTO.php b/shop/app/DTOs/TagDTO.php new file mode 100644 index 00000000..263fa10e --- /dev/null +++ b/shop/app/DTOs/TagDTO.php @@ -0,0 +1,40 @@ + + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'url' => $this->url, + 'title' => $this->title, + 'description' => $this->description, + 'content' => $this->content, + 'noIndex' => $this->noIndex, + 'canonical' => $this->canonical, + 'categoryName' => $this->categoryName, + 'categoryUrl' => $this->categoryUrl, + ]; + } +} diff --git a/shop/app/Enums/BannerPositionEnum.php b/shop/app/Enums/BannerPositionEnum.php new file mode 100644 index 00000000..88da8efc --- /dev/null +++ b/shop/app/Enums/BannerPositionEnum.php @@ -0,0 +1,33 @@ + trans('enums.banner_position.home_top'), + self::HOME_MIDDLE => trans('enums.banner_position.home_middle'), + self::CATEGORY_SIDE => trans('enums.banner_position.category_side'), + }; + } +} diff --git a/shop/app/Http/Controllers/HomeController.php b/shop/app/Http/Controllers/HomeController.php index cd6390bc..9132dac2 100644 --- a/shop/app/Http/Controllers/HomeController.php +++ b/shop/app/Http/Controllers/HomeController.php @@ -4,11 +4,13 @@ namespace App\Http\Controllers; +use App\Actions\Home\GetBannersByPosition; use App\Actions\Home\GetFeaturedBrands; use App\Actions\Home\GetHomeCategories; +use App\Actions\Home\GetHomeTags; use App\Actions\Home\GetProductRows; -use App\Actions\Home\GetPromoBanners; use App\Actions\Home\GetSliderByPosition; +use App\Enums\BannerPositionEnum; use App\Enums\SliderPositionEnum; use Inertia\Inertia; use Inertia\Response; @@ -18,14 +20,16 @@ class HomeController extends Controller public function __invoke( GetSliderByPosition $getSliderByPosition, GetHomeCategories $getHomeCategories, - GetPromoBanners $getPromoBanners, + GetHomeTags $getHomeTags, + GetBannersByPosition $getBannersByPosition, GetProductRows $getProductRows, GetFeaturedBrands $getFeaturedBrands, ): Response { return Inertia::render('Home', [ 'slides' => $getSliderByPosition(SliderPositionEnum::HOME_MAIN), 'categories' => $getHomeCategories(), - 'banners' => $getPromoBanners(), + 'tags' => $getHomeTags(), + 'banners' => $getBannersByPosition(BannerPositionEnum::HOME_MIDDLE), 'productRows' => $getProductRows(), 'brands' => $getFeaturedBrands(), ]); diff --git a/shop/app/Http/Controllers/TagController.php b/shop/app/Http/Controllers/TagController.php new file mode 100644 index 00000000..db23b5e6 --- /dev/null +++ b/shop/app/Http/Controllers/TagController.php @@ -0,0 +1,86 @@ +where('slug', $slug) + ->with(['category', 'attributes']) + ->firstOrFail(); + + // Category is optional: with one, scope to it (+ descendants); without, + // no category constraint (an attribute-only tag spans all categories). + $categoryIds = $tag->category === null ? [] : $collectCategoryIds($tag->category); + + $filters = $this->filters($request); + + // Force the tag's attribute(s) into the attribute filter so they're + // always applied on top of any query-string filters. Grouping (OR + // within a group, AND across groups) is handled by GetCategoryProducts. + $tagAttributeIds = $tag->attributes->pluck('id')->all(); + $filters['attributes'] = array_values(array_unique([...$filters['attributes'], ...$tagAttributeIds])); + + return Inertia::render('Tags/Show', [ + 'tag' => $buildTagDetail($tag)->toArray(), + 'breadcrumbs' => $buildBreadcrumbs($tag), + 'products' => $getCategoryProducts($categoryIds, $filters), + 'filters' => $getCategoryFilters($categoryIds, $filters), + 'applied' => $filters, + ]); + } + + /** + * Normalise the filter/sort query parameters into a typed shape (same + * shape the category page uses; the tag's own attribute is forced on top). + * + * @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/app/Models/Tag.php b/shop/app/Models/Tag.php new file mode 100644 index 00000000..44bed5a9 --- /dev/null +++ b/shop/app/Models/Tag.php @@ -0,0 +1,78 @@ + $attributes + * @property Image|null $image + */ +class Tag extends Model +{ + protected $fillable = [ + 'name', + 'slug', + 'category_id', + 'content', + 'title', + 'description', + 'no_index', + 'canonical', + 'show_on_home', + 'home_order', + ]; + + protected $casts = [ + 'no_index' => 'boolean', + 'show_on_home' => 'boolean', + 'home_order' => 'integer', + ]; + + public function scopeOnHome(Builder $query): Builder + { + return $query->where('show_on_home', true); + } + + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + + public function attributes(): BelongsToMany + { + return $this->belongsToMany(Attribute::class)->withTimestamps(); + } + + public function image(): MorphOne + { + return $this->morphOne(Image::class, 'imageable'); + } +} diff --git a/shop/docs/IMPLEMENTATION.md b/shop/docs/IMPLEMENTATION.md index 7dd0a808..a4b918ee 100644 --- a/shop/docs/IMPLEMENTATION.md +++ b/shop/docs/IMPLEMENTATION.md @@ -80,7 +80,8 @@ Depend mostly on Images only. - [x] FAQs - [x] Reviews - [x] Wishlists -- [ ] Tags +- [x] Tags (admin owns the schema; storefront renders `/tags/{slug}` — see shop `TAGS.md` and `STOREFRONT_IMPLEMENTATION.md`) +- [~] Home Sections (`home_sections`: admin can compose/reorder the home page; the storefront still renders a hardcoded order and does not read the table yet — see `STOREFRONT_IMPLEMENTATION.md`) - [ ] Brand-Category pages - [ ] Redirects - [ ] Helps diff --git a/shop/docs/STOREFRONT_IMPLEMENTATION.md b/shop/docs/STOREFRONT_IMPLEMENTATION.md index eeb52e17..4d78ecc5 100644 --- a/shop/docs/STOREFRONT_IMPLEMENTATION.md +++ b/shop/docs/STOREFRONT_IMPLEMENTATION.md @@ -42,6 +42,12 @@ Read-only catalog. This is where SEO and SSR matter most. - [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 - Header (`AppHeader` + `Header/*`): logo, search, account/cart actions, desktop category menu with dropdowns, mobile drawer; categories shared via Inertia `nav.categories` - `Variety` read model exposes a polymorphic `image` relation (per-color photo) for the upcoming product detail page + - Slider and banner blocks are looked up by **position enum**, never by a magic string: `GetSliderByPosition(SliderPositionEnum::HOME_MAIN)` and `GetBannersByPosition(BannerPositionEnum::HOME_MIDDLE)` (the old `GetHeroSlides`/`GetPromoBanners` were renamed). `BannerPositionEnum` (`home-top`, `home-middle`, `category-side`) mirrors the admin enum so the position staff pick and the position the storefront reads can't disagree; `home-top` and `category-side` have no render site yet +- [~] **Admin-composed home page** — admin side built, storefront side NOT built. The `home_sections` table (admin-owned, `HomeSectionResource`, drag-to-reorder) holds the ordered list of home blocks: `type` (`HomeSectionTypeEnum`: `slider`/`tags`/`categories`/`banners`/`products`/`brands`), optional `title`, a `config` JSON bag (`{"position": …}` for slider/banners, `{"sort": "newest"|"popular"}` for products), `order`, `status`. Today `HomeController` + `Home.vue` still render a **hardcoded** order (hero → tags → categories → banners → product carousels → brands) and ignore the table, so reordering or disabling a section in the admin panel has no effect on the site. Remaining work: + - `HomeSection` read model in the shop + a mirrored `HomeSectionTypeEnum` + - `GetHomeSections` action returning the enabled sections ordered by `order`, each resolved to its data (dispatching to the existing `GetSliderByPosition` / `GetHomeTags` / `GetHomeCategories` / `GetBannersByPosition` / `GetProductRows` / `GetFeaturedBrands` actions using the section's `config`) + - `Home.vue` renders `` over that list instead of a fixed template; keep the graceful empty state per block, and fall back to the current hardcoded order when the table is empty + - Feature tests (order respected, disabled sections hidden, config honored, empty-table fallback); caching per `CACHE.md` when Phase 6 lands - [x] Category listing page: `CategoryController@show` (`/categories/{slug}`) + `Category/Show.vue`. Lists the category's products plus its descendants', with facet filters (brand, attribute groups marked `as_filter`, price range), sorting (newest/cheapest/expensive/popular) and pagination; sidebar filters + toolbar + `Pagination`/`EmptyState` components; breadcrumbs; JSON-LD `BreadcrumbList`; feature tests. Attribute filtering matches products through the `product_attribute` pivot (the documented "filters to products" link, NOT varieties); facets list only attribute values actually attached to products in the category; OR within a group, AND across groups. Price filters on `products.price` (denormalized cheapest-variety base price). Filter UI is Digikala-style: availability toggle (`in_stock` → `products.has_stock`), price range slider, brand list with search box, collapsible accordion sections, per-option product counts, and instant apply on change - [x] Product detail page: `ProductController@show` (`/products/{slug}`) + `Product/Show.vue`. Gallery shows all images combined (product images + every variety image, deduped by URL); selecting a variety switches the main image to that variety's photo without hiding the others. Variety selector (primary attribute group drives selection, additional attributes constrained by it, never the other way), buy box (price hidden until a variety is fully selected; price/discount/stock, trust badges, quantity), specs, description, breadcrumbs, related carousel; JSON-LD `Product`/`Offer` + `BreadcrumbList`; view counter; feature tests. Add-to-cart wiring deferred to Phase 3 - Descriptive specs/highlights (`product_attribute`) are paired with their attribute group's `name` (`BuildProductDetail::spec()`, eager-loads `attributes.attributeGroup`) — never rendered as a bare value with no label. `ProductSpecs.vue` renders them as a `group: value` list. @@ -49,6 +55,7 @@ Read-only catalog. This is where SEO and SSR matter most. - Quantity rule: the quantity stepper must never exceed the selected variety's `inventory` (clamp the max). Enforced with cart wiring in Phase 3 - [x] Product reviews (read) on the product page (approved only). Star ratings now built (see Phase 5 "Submit product review"): a `rating` column was added to the shared `reviews` table, the product header + reviews section show the real average (`product.averageRating`), and each review shows its own stars - [x] Brand page: `BrandController@show` (`/brands/{slug}`) + `Brand/Show.vue`. Lists a brand's products with facet filters (category, price range, availability), sorting, pagination; sidebar `BrandFilters` (accordion, price slider, category search + counts) + shared toolbar/`Pagination`/`EmptyState`; breadcrumbs; JSON-LD `BreadcrumbList`; feature tests. Thin controller + `app/Actions/Brand/*` + `BrandDTO`. Shared catalog test helpers moved to `tests/Helpers.php` +- [x] Tag landing page: `TagController@show` (`/tags/{slug}`) + `Tags/Show.vue`. A tag is an SEO page for a **category and/or attribute** filter (see `TAGS.md`) — category is optional and attributes are many (`attribute_tag` pivot), at least one required. It reuses the category machinery (`CollectCategoryIds` + `GetCategoryProducts` + `GetCategoryFilters`), merges the tag's attribute ids into the applied filters (grouped OR/AND), and for attribute-only tags passes an empty category list which the shared actions treat as "no category constraint". Thin controller + `app/Actions/Tag/*` (`BuildTagDetail` → `TagDTO`, `BuildTagBreadcrumbs`); breadcrumbs Home → [Category …] → Tag; SEO title/description/canonical/no_index from the tag; JSON-LD `BreadcrumbList`; feature tests cover attribute filtering, category-only, attribute-only (cross-category), and multi-attribute AND. Tags are surfaced on the home page via `show_on_home`/`home_order` + a tag image: `GetHomeTags` → `Home.vue` `tags` prop → `Components/Home/TagStrip.vue` (image cards after the category strip, each linking to `/tags/{slug}`). The `tags` schema is admin-owned (`TagResource`) — see `TAGS.md` - [x] Search: keyword search over products (`/search?q=`). `SearchController` -> `ProductSearch` contract (bound to `DatabaseProductSearch` in `AppServiceProvider`) does a case-insensitive `ILIKE` match on product heading/title/description and brand name (words AND-ed, fields OR-ed); sort + pagination; `Search/Results.vue` (noindex). Autocomplete: `GET /search/suggest?q=` (`SearchController@suggest` -> `GetSearchSuggestions`) returns matching categories + products (with category context) as JSON; `HeaderSearch.vue` shows a debounced dropdown (categories labeled `دسته‌بندی`, products as `در {category}`). Contract lets Elasticsearch swap in later without touching callers. Feature tests cover name/brand match, unpublished exclusion, blank query, and the suggest JSON - [x] CMS pages (`pages.show`): `PageController@show` + `Page/Show.vue`. Served at clean top-level slugs (e.g. `/about-us`) via a catch-all `/{slug}` route kept LAST in `routes/web.php`; published pages only; heading, HTML content, optional image; breadcrumbs; JSON-LD `BreadcrumbList`; canonical/noindex; feature tests. `PageDTO` + `BuildPageDetail` - [x] FAQ page (`faqs.show`): `FaqController@show` + `Faq/Show.vue` (accordion). `/faq` shows null-position questions, `/faq/{position}` scopes to a section; ordered by `order`; JSON-LD `FAQPage` + `BreadcrumbList`; feature tests. `FaqDTO` + `GetFaqs` diff --git a/shop/docs/ShoFlow db doc.md b/shop/docs/ShoFlow db doc.md index 9d368555..bf2789c1 100644 --- a/shop/docs/ShoFlow db doc.md +++ b/shop/docs/ShoFlow db doc.md @@ -97,9 +97,9 @@ The **`attribute_group_category`** table (singular) manages the relationship bet Used to store banners. -* `position` specifies the advertisement location, which is an arbitrary name to retrieve the corresponding record from the database. +* `position` specifies where the banner appears. Constrained to `App\Enums\BannerPositionEnum` (mirrored in both apps): `home-top`, `home-middle`, `category-side`. Admin picks it from a dropdown; the storefront looks it up by the same enum value (`GetBannersByPosition`). A position can be rendered as a grid (all published banners) or a single banner (`->first()`) — the enum doesn't dictate that. Only `home-middle` is rendered today (the home grid). * `heading` specifies the banner item title or the alt text of the image. -* `url` specifies the item link, which redirects when the image or title is clicked. +* `url` specifies the item link (image/title click target). Accepts an absolute URL (`https://…`) or an internal path (`/tags/…`, `/categories/…`) — the admin field validates for either, so banners can link to tag pages. `sliders`/`slides` `url` accepts the same. * `sort` specifies the item order. * `status` stores the publication status of the banner, with values 10 for deleted, 20 for published, and 30 for draft. @@ -387,6 +387,19 @@ Implementation notes: * `content`: Displays the help content. * `position`: Specifies which section the help is for (admin, sellers, etc.). +# home_sections + +**Not in the source schema — added by ShopFlow.** The ordered list of blocks the storefront home page is composed from, so staff can add/reorder/disable home rows instead of the layout being hardcoded in `Home.vue`. Admin manages them via `HomeSectionResource` (drag-to-reorder table). + +* `type`: Which block to render. `App\Enums\HomeSectionTypeEnum` (string-backed, mirrored in both apps): `slider`, `tags`, `categories`, `banners`, `products`, `brands`. Each type maps to one storefront component + data action. Defaults to `products`. +* `title`: Optional heading shown above the block. Only meaningful for `products` rows (the other types carry their own heading); nullable. +* `config`: JSON bag of type-specific settings, nullable. `slider` → `{"position": ""}`, `banners` → `{"position": ""}`, `products` → `{"sort": "newest"|"popular"}`. `tags`/`categories`/`brands` need none. The admin form shows only the fields the chosen `type` uses and requires them. +* `order`: Display order, ascending (set by drag-to-reorder in the admin table). +* `status`: Boolean; `false` hides the block without deleting it. Indexed together with `order`. +* `created_at` / `updated_at`. + +> **Storefront wiring is not built yet** — `HomeController`/`Home.vue` still render a hardcoded section order and ignore this table. See shop `STOREFRONT_IMPLEMENTATION.md`. + # holidays * Stores holidays when no product delivery is made. @@ -1000,15 +1013,22 @@ This table is used to store "Contact Us" information. # tags -This table is for storing tags. +**Built.** A tag is an SEO landing page for a category **and/or** attribute filter (see `TAGS.md`) — its own URL (`/tags/{slug}`) listing the products in `category_id` (and descendants), or across all categories when no category, that carry the tag's attribute(s). Not a free-form product label; there is no `product_tag` pivot. -* `slug`: Stores the tag's slug. -* `name`: Stores the tag's name. -* `category_id`: Stores the category ID. -* `attribute_id`: Stores the attribute ID. -* `content`: Stores the content related to the tag. -* `type`: Specifies the type of the question, for example, for users or sellers. -* `created_at`: Stores the creation date. +* `name`: Tag display name. +* `slug`: URL slug, unique, stable. +* `category_id`: FK → `categories`, **nullable**, `cascadeOnDelete`. +* `content`: Editor HTML shown on the tag page (nullable). +* `title`, `description`, `no_index` (bool, default false), `canonical`: SEO fields, mirroring `categories`/`products`. Added when tags were built (the original source schema had none of these). +* `show_on_home` (bool, default false) + `home_order` (unsigned int, default 0): whether the tag appears in the storefront home-page featured-tags strip, and its order there. Its image is a polymorphic `images` row (like categories/slides). +* `created_at` / `updated_at`. +* Attributes are **many-to-many** via the `attribute_tag` pivot (`attribute_id` + `tag_id`, unique pair, cascade). A tag has zero or more attributes. +* **Rule:** category and attributes are each optional, but **at least one must be set** (enforced in the admin form, not the DB). +* **Not** carried over from the source schema: the old single `attribute_id` column (now the pivot) and the `type` (user/seller) column — ShopFlow is single-vendor, so `type` was dropped. + +# attribute_tag + +Pivot linking `tags` to their `attributes` (many-to-many). `attribute_id` + `tag_id` (unique pair), both `cascadeOnDelete`, plus timestamps. # points diff --git a/shop/docs/TAGS.md b/shop/docs/TAGS.md index 20953046..16edfa1f 100644 --- a/shop/docs/TAGS.md +++ b/shop/docs/TAGS.md @@ -1,65 +1,53 @@ # Tags -Status: **planned, not built.** The `tags` table is described in `ShoFlow db doc.md` but has no migration yet in `admin/`. There is no `product_tag` pivot. +Status: **built (2026-07-24).** Admin owns the `tags` table + `attribute_tag` pivot + `TagResource`; the storefront renders `/tags/{slug}`. There is no `product_tag` pivot (products are never attached directly). ## What a tag is -A tag is a **SEO landing page for a `category + attribute` combination** — a saved filter turned into its own page with its own slug and content. Example: "تجهیزات گیمینگ", "لوازم تابستانی", "کفش مردانه قرمز". +A tag is a **SEO landing page for a category and/or attribute filter** — a saved filter turned into its own page with its own slug and content. Category and attributes are **each optional, but at least one must be set**. Three shapes: -A tag is **not**: -- a free-form product label (products are not attached to tags; there is no `product_tag` pivot), -- a dynamic row like best-sellers (that is a sort by `seen`), -- a banner or menu (those have their own tables). +- **category + attribute(s)** — e.g. "کفش مردانه قرمز" (shoes + red). +- **category only** — a themed page for a whole category with its own slug/SEO (a near-duplicate of the category page, useful as a campaign landing page). +- **attribute(s) only** — a cross-category page, e.g. "محصولات گیمینگ" (all products carrying the گیمینگ attribute, any category). -## Schema (as documented) +A tag is **not**: a free-form product label (no `product_tag` pivot), a dynamic row like best-sellers (that's a sort by `seen`), or a banner/menu (own tables). -`tags` columns from `ShoFlow db doc.md`: +## Schema (as built) | Column | Meaning | | --- | --- | -| `slug` | Tag URL slug (stable, human-readable Persian) | | `name` | Tag display name | -| `category_id` | The category the tag scopes to | -| `attribute_id` | The attribute the tag filters by | -| `content` | Editor content shown on the tag page | -| `type` | Tag type (e.g. user/seller in the source schema) | -| `created_at` | Creation date | +| `slug` | Tag URL slug (stable, human-readable Persian), unique | +| `category_id` | FK → `categories`, **nullable**, `cascadeOnDelete` | +| `content` | Editor HTML shown on the tag page (nullable) | +| `title`, `description`, `no_index`, `canonical` | SEO fields (added on build) | +| `created_at` / `updated_at` | timestamps | -A tag resolves to products as: **products in `category_id` (and its descendants) that have `attribute_id`** — the same matching rule the category page uses (`product_attribute` pivot). See `AGENTS.md` → Catalog filtering. +Attributes are a **many-to-many** through the `attribute_tag` pivot (`attribute_id` + `tag_id`, unique pair, `cascadeOnDelete`). -> The documented schema has only `content`, no `title` / `description` / `no_index` / `canonical`. SEO meta would either reuse `name`/`content` or the schema must be extended in admin. Decide before building (open question below). +**Product resolution:** products in `category_id` (and descendants) — or all categories if no category — that carry the tag's attribute(s). Matching reuses the category page's grouped-attribute logic (`product_attribute` pivot; **OR within an attribute group, AND across groups**). See `AGENTS.md` → Catalog filtering. -## What tags are good for +## Resolved decisions -- Themed/filtered **landing pages** that need their own URL and SEO content. -- A **link target** for promo banners and menu items (banner "تجهیزات گیمینگ" → `/tags/gaming-gear`). +- **SEO fields**: schema **extended** with `title`/`description`/`no_index`/`canonical` (matches `categories`/`products`). +- **`type` column**: **dropped** — single-vendor, no sellers. +- **Attributes**: **many** per tag (via `attribute_tag`), not one. +- **Category/attribute optionality**: each optional, **at least one required** (enforced in the admin form via `requiredWithout`, not a DB constraint). -## What to use instead (not tags) - -- **Best-sellers / newest rows** → dynamic queries (already `GetProductRows`). -- **Banners / sliders / header menu** → `banners`, `sliders`/`slides`, `menus`/`menu_items` (already built). -- **Hand-picked cross-category collections** → would require a new `product_tag` pivot (admin schema change). Only add if editorial collections are actually needed. - -## Responsibilities +## Implementation **Admin (`admin/`) — owns the schema:** -- Migration for `tags` (and any extra SEO columns if chosen). -- `Tag` model + Filament resource: manage slug, name, category, attribute, content, type. +- Migrations `create_tags_table` + `create_attribute_tag_table`; `Tag` model (`attributes()` belongsToMany) + factory (`withAttributes()` state) + `TagSeeder`; `TagResource` (category optional, attributes multi-select, `requiredWithout` guard) + lang; `TagResourceTest`. **Shop (`shop/`) — read/render only:** -- `Tag` read model mapping the shared table. -- Route `GET /tags/{slug}` → thin `TagController` → action that loads products (category + descendants, filtered by the tag's attribute) reusing the category listing pieces. -- `Tags/Show.vue` reusing `ProductCard` / `Pagination` / filters where it makes sense. -- SEO: unique title/description, canonical, JSON-LD `BreadcrumbList`; breadcrumbs Home → Category → Tag. +- `Tag` read model, `TagDTO`, `app/Actions/Tag/{BuildTagDetail,BuildTagBreadcrumbs}`, thin `TagController@show`, route `GET /tags/{slug}`, `Tags/Show.vue`. +- Reuses `CollectCategoryIds` + `GetCategoryProducts` + `GetCategoryFilters`; the tag's attribute ids are merged into the applied `attributes` filter, and an **empty category list means "no category constraint"** (those two shared actions guard the category `whereIn` behind a non-empty check, so category pages are unaffected). +- SEO: title/description/canonical/no_index from the tag; breadcrumbs Home → [Category …] → Tag (category crumbs omitted for attribute-only tags); JSON-LD `BreadcrumbList`. -## Build order (when approved) +## Surfacing tags on the home page -1. Admin: migration → `Tag` model + factory + seeder → Filament resource. -2. Shop: `Tag` model → action + thin controller + Inertia page → feature tests. -3. Wire banners/menu items to `/tags/{slug}`. +This is how a customer discovers a tag. A tag has `show_on_home` (bool) + `home_order` (int) + a polymorphic image. Admins toggle **Show on Home Page** in `TagResource` and upload an image. The storefront home renders a **featured-tags strip** (`Components/Home/TagStrip.vue`, fed by `GetHomeTags` → `HomeController` `tags` prop, placed after the category strip): image cards, ordered by `home_order`, each linking to `/tags/{slug}`. Only `show_on_home = true` tags appear. -## Open questions +## Integrating with slides / banners / menus -- **SEO fields**: reuse `name`/`content`, or extend the schema with `title`/`description`/`no_index`/`canonical` (matches how `categories`/`products` do SEO)? -- **`type`**: single-vendor store has no sellers — is this column needed in shop, or admin-only? -- **One attribute per tag** (as the schema implies) vs. multiple — confirm before building. +You can also point a slide's / banner's / menu item's `url` at `/tags/{slug}` to link to a tag. Note: the admin slide/banner URL field currently enforces absolute-URL validation, so relative `/tags/...` paths need that validation relaxed first (not yet done). diff --git a/shop/lang/fa/enums.php b/shop/lang/fa/enums.php index d6005735..5c3f2824 100644 --- a/shop/lang/fa/enums.php +++ b/shop/lang/fa/enums.php @@ -79,4 +79,9 @@ 'category_top' => 'صفحه دسته‌بندی — بالا', 'product_side' => 'صفحه محصول — کنار', ], + 'banner_position' => [ + 'home_top' => 'صفحه خانه — بالا', + 'home_middle' => 'صفحه خانه — شبکه میانی', + 'category_side' => 'صفحه دسته‌بندی — کنار', + ], ]; diff --git a/shop/resources/js/Components/Home/TagStrip.vue b/shop/resources/js/Components/Home/TagStrip.vue new file mode 100644 index 00000000..45e88432 --- /dev/null +++ b/shop/resources/js/Components/Home/TagStrip.vue @@ -0,0 +1,35 @@ + + + diff --git a/shop/resources/js/Pages/Home.vue b/shop/resources/js/Pages/Home.vue index 0f815956..8207ef3b 100644 --- a/shop/resources/js/Pages/Home.vue +++ b/shop/resources/js/Pages/Home.vue @@ -5,6 +5,7 @@ import AppHead from '@/Components/AppHead.vue'; import AppLayout from '@/Layouts/AppLayout.vue'; import HeroSlider from '@/Components/Home/HeroSlider.vue'; import CategoryStrip from '@/Components/Home/CategoryStrip.vue'; +import TagStrip from '@/Components/Home/TagStrip.vue'; import BannerGrid from '@/Components/Home/BannerGrid.vue'; import ProductCarousel from '@/Components/Home/ProductCarousel.vue'; import BrandStrip from '@/Components/Home/BrandStrip.vue'; @@ -18,6 +19,10 @@ defineProps({ type: Array, default: () => [], }, + tags: { + type: Array, + default: () => [], + }, banners: { type: Array, default: () => [], @@ -62,6 +67,8 @@ const jsonLd = computed(() => [
+ + diff --git a/shop/resources/js/Pages/Tags/Show.vue b/shop/resources/js/Pages/Tags/Show.vue new file mode 100644 index 00000000..6435ac90 --- /dev/null +++ b/shop/resources/js/Pages/Tags/Show.vue @@ -0,0 +1,177 @@ + + + diff --git a/shop/routes/web.php b/shop/routes/web.php index d30e6abc..2a11be1e 100644 --- a/shop/routes/web.php +++ b/shop/routes/web.php @@ -17,6 +17,7 @@ use App\Http\Controllers\ReviewController; use App\Http\Controllers\SearchController; use App\Http\Controllers\SitemapController; +use App\Http\Controllers\TagController; use App\Http\Controllers\WishlistController; use Illuminate\Support\Facades\Route; @@ -92,6 +93,9 @@ Route::get('/brands/{slug}', [BrandController::class, 'show'])->name('brands.show'); +// Tag = SEO landing page for a category+attribute filter (see docs/TAGS.md). +Route::get('/tags/{slug}', [TagController::class, 'show'])->name('tags.show'); + Route::get('/faq/{position?}', [FaqController::class, 'show'])->name('faqs.show'); Route::get('/products/{slug}', [ProductController::class, 'show'])->name('products.show'); diff --git a/shop/tests/Feature/HomePageTest.php b/shop/tests/Feature/HomePageTest.php index f1de731c..5fd84742 100644 --- a/shop/tests/Feature/HomePageTest.php +++ b/shop/tests/Feature/HomePageTest.php @@ -16,6 +16,7 @@ use App\Models\Product; use App\Models\Slide; use App\Models\Slider; +use App\Models\Tag; use App\Models\Variety; use Inertia\Testing\AssertableInertia; @@ -140,6 +141,27 @@ function featureImage(string $type, int $id, bool $featured = true): void ); }); +it('shows only featured tags on the home page, in order', function (): void { + $category = Category::create([ + 'heading' => 'دسته تگ', + 'slug' => 'tag-cat', + 'status' => CategoryStatusEnum::ACTIVE, + ]); + + Tag::create(['name' => 'تگ دوم', 'slug' => 'tag-b', 'category_id' => $category->id, 'show_on_home' => true, 'home_order' => 2]); + Tag::create(['name' => 'تگ اول', 'slug' => 'tag-a', 'category_id' => $category->id, 'show_on_home' => true, 'home_order' => 1]); + Tag::create(['name' => 'تگ مخفی', 'slug' => 'tag-hidden', 'category_id' => $category->id, 'show_on_home' => false]); + + $this->get('/') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->has('tags', 2) // only the two featured, not the hidden one + ->where('tags.0.name', 'تگ اول') // home_order 1 first + ->where('tags.0.url', '/tags/tag-a') + ->where('tags.1.name', 'تگ دوم') + ); +}); + it('only shows the slider assigned to the home-main position', function (): void { // A published slider at a different position must not leak onto the home // hero — the home page asks GetSliderByPosition for HOME_MAIN only. diff --git a/shop/tests/Feature/TagTest.php b/shop/tests/Feature/TagTest.php new file mode 100644 index 00000000..1e352b95 --- /dev/null +++ b/shop/tests/Feature/TagTest.php @@ -0,0 +1,156 @@ +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(), + ]); + + return [ + Attribute::create(['attribute_group_id' => $groupId, 'value' => 'گیمینگ', 'color' => '#ff0000']), + Attribute::create(['attribute_group_id' => $groupId, 'value' => 'اداری', 'color' => '#0000ff']), + ]; +} + +it('lists only products that carry the tag\'s attribute', function (): void { + $category = catCategory('gaming'); + [$gaming, $office] = tagAttributes($category); + + catProduct($category, attributeId: $gaming->id); + catProduct($category, attributeId: $office->id); + + $tag = Tag::create([ + 'name' => 'تجهیزات گیمینگ', + 'slug' => 'gaming-gear', + 'category_id' => $category->id, + 'no_index' => false, + ]); + $tag->attributes()->attach($gaming->id); + + $this->get('/tags/'.$tag->slug) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->component('Tags/Show') + ->where('tag.name', 'تجهیزات گیمینگ') + ->where('products.meta.total', 1) // only the gaming product, not the office one + ->has('breadcrumbs', 3) + ->where('breadcrumbs.0.heading', 'خانه') + ->where('breadcrumbs.2.heading', 'تجهیزات گیمینگ') + ); +}); + +it('lists a category-only tag\'s whole category (no attribute filter)', function (): void { + $category = catCategory('all-shoes'); + catProduct($category); + catProduct($category); + + $tag = Tag::create([ + 'name' => 'همه کفش‌ها', + 'slug' => 'all-shoes', + 'category_id' => $category->id, + ]); + + $this->get('/tags/'.$tag->slug) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('products.meta.total', 2) + ); +}); + +it('lists an attribute-only tag across categories (no category)', function (): void { + $shoes = catCategory('cross-shoes'); + $bags = catCategory('cross-bags'); + [$gaming] = tagAttributes($shoes); + + catProduct($shoes, attributeId: $gaming->id); + catProduct($bags, attributeId: $gaming->id); + catProduct($bags); // no attribute → excluded + + $tag = Tag::create([ + 'name' => 'محصولات گیمینگ', + 'slug' => 'gaming-all', + 'category_id' => null, + ]); + $tag->attributes()->attach($gaming->id); + + $this->get('/tags/'.$tag->slug) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('products.meta.total', 2) // both categories' gaming products + ->has('breadcrumbs', 2) // Home → Tag (no category crumb) + ); +}); + +it('applies multiple attributes across groups as AND', function (): void { + $category = catCategory('multi'); + [$gaming] = tagAttributes($category); + + // A second group + attribute so the two are AND-ed across groups. + $groupId = DB::table('attribute_groups')->insertGetId([ + 'ancestor_id' => DB::table('ancestors')->insertGetId(['name' => 'برند', 'created_at' => now(), 'updated_at' => now()]), + '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(), + ]); + $premium = Attribute::create(['attribute_group_id' => $groupId, 'value' => 'پریمیوم']); + + $both = catProduct($category, attributeId: $gaming->id); + $both->attributes()->attach($premium->id); + catProduct($category, attributeId: $gaming->id); // gaming only, not premium + + $tag = Tag::create(['name' => 'گیمینگ پریمیوم', 'slug' => 'gaming-premium', 'category_id' => $category->id]); + $tag->attributes()->attach([$gaming->id, $premium->id]); + + $this->get('/tags/'.$tag->slug) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('products.meta.total', 1) // only the product with BOTH attributes + ); +}); + +it('returns 404 for an unknown tag slug', function (): void { + $this->get('/tags/does-not-exist')->assertNotFound(); +}); + +it('honours the tag no_index flag for SEO', function (): void { + $category = catCategory('summer'); + $tag = Tag::create([ + 'name' => 'لوازم تابستانی', + 'slug' => 'summer-gear', + 'category_id' => $category->id, + 'no_index' => true, + ]); + + $this->get('/tags/'.$tag->slug) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('tag.noIndex', true) + ); +}); From bafcb8a670ad9e6598d300160124fb9b155364f2 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Wed, 5 Aug 2026 13:09:15 +0330 Subject: [PATCH 05/40] refactor(shop): extract LoginUser so every login path merges the guest cart --- shop/app/Actions/Auth/LoginUser.php | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 shop/app/Actions/Auth/LoginUser.php diff --git a/shop/app/Actions/Auth/LoginUser.php b/shop/app/Actions/Auth/LoginUser.php new file mode 100644 index 00000000..a1dd0106 --- /dev/null +++ b/shop/app/Actions/Auth/LoginUser.php @@ -0,0 +1,33 @@ +session()->getId(); + + Auth::login($user, remember: true); + $request->session()->regenerate(); + + ($this->mergeGuestCart)($user, $guestSession); + } +} From 17e1e9441eeb0616858fd58bc6d735740a5d2830 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Wed, 5 Aug 2026 13:09:15 +0330 Subject: [PATCH 06/40] refactor(shop): route AuthController's login through the LoginUser action --- shop/app/Http/Controllers/AuthController.php | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/shop/app/Http/Controllers/AuthController.php b/shop/app/Http/Controllers/AuthController.php index ab8db23d..298a3ae4 100644 --- a/shop/app/Http/Controllers/AuthController.php +++ b/shop/app/Http/Controllers/AuthController.php @@ -4,10 +4,10 @@ namespace App\Http\Controllers; +use App\Actions\Auth\LoginUser; use App\Actions\Auth\NormalizeMobile; use App\Actions\Auth\SendOtpCode; use App\Actions\Auth\VerifyOtpCode; -use App\Actions\Cart\MergeGuestCart; use App\Enums\UserStatusEnum; use App\Models\User; use Illuminate\Http\Exceptions\HttpResponseException; @@ -183,14 +183,7 @@ private function blocked(string $mobile): RedirectResponse private function login(Request $request, User $user): RedirectResponse { - // Capture the guest session id before regeneration so any cart built - // while logged out is carried onto the account. - $guestSession = $request->session()->getId(); - - Auth::login($user, remember: true); - $request->session()->regenerate(); - - app(MergeGuestCart::class)($user, $guestSession); + app(LoginUser::class)($request, $user); return redirect()->intended('/'); } From 9194c5f44f02aa7e6b8024b6827005fffe8f8885 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Wed, 5 Aug 2026 13:09:15 +0330 Subject: [PATCH 07/40] feat(shop): Persian password-reset mail notification --- shop/app/Notifications/ResetPasswordLink.php | 42 ++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 shop/app/Notifications/ResetPasswordLink.php diff --git a/shop/app/Notifications/ResetPasswordLink.php b/shop/app/Notifications/ResetPasswordLink.php new file mode 100644 index 00000000..469dbdf4 --- /dev/null +++ b/shop/app/Notifications/ResetPasswordLink.php @@ -0,0 +1,42 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + /** @var CanResetPassword $notifiable */ + $email = $notifiable->getEmailForPasswordReset(); + + $url = route('password.reset', ['token' => $this->token, 'email' => $email]); + + return (new MailMessage) + ->subject(trans('messages.auth.reset.email_subject')) + ->view('emails.password-reset', [ + 'url' => $url, + 'minutes' => (int) config('auth.passwords.users.expire', 60), + ]); + } +} From fea696641fa1c810f39928a3f6ed19ca191fcf0e Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Wed, 5 Aug 2026 13:09:15 +0330 Subject: [PATCH 08/40] feat(shop): RTL Persian reset-password email view (inline styles only) --- .../views/emails/password-reset.blade.php | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 shop/resources/views/emails/password-reset.blade.php diff --git a/shop/resources/views/emails/password-reset.blade.php b/shop/resources/views/emails/password-reset.blade.php new file mode 100644 index 00000000..45c7e733 --- /dev/null +++ b/shop/resources/views/emails/password-reset.blade.php @@ -0,0 +1,41 @@ + + + + + + {{ trans('messages.auth.reset.email_subject') }} + +{{-- Inline styles only: email clients strip