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/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/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/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/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/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/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..1dc79c12 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 @@ -128,7 +129,7 @@ The main goal; depends on most of phases 1-3. - [ ] `user_statuses` (per-user status restriction) — niche, not needed now - [ ] Category Partner, Partner Requests, Organizational Requests, Contact Us - [ ] Points, Newsletters, Notifications, System Notifications -- [ ] `email_histories`, `mobile_histories`, `mobile_password_resets` +- [ ] `email_histories`, `mobile_histories`, `mobile_password_resets` (the storefront's mobile password reset uses cache-backed OTP, so `mobile_password_resets` is only needed if codes must become auditable — see the db doc) - [ ] Working Hours - [ ] `user_category_percent`, `user_price_conditions` - [ ] eMalls Products, Short URLs, Bank SMS diff --git a/admin/docs/ShoFlow db doc.md b/admin/docs/ShoFlow db doc.md index 1a67fcfc..81cb81c4 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. @@ -202,6 +202,13 @@ Stores discount coupons. Unlike discounts, a coupon is applied manually: the cus * `started_at`: When the coupon becomes usable. * `expired_at`: When the coupon can no longer be used. +**Storefront usage (preview only, so far).** The cart previews a coupon — `App\Actions\Coupon\PreviewCoupon` + `CalculateCouponDiscount`, code held in the session — and **writes nothing**: no order, and `total_used` is NOT incremented. Committing a coupon to an order (filling `orders.coupon_id` / `orders.coupon_discount`, bumping `total_used`, applying `shipping`) is checkout work, not built yet — see shop `STOREFRONT_IMPLEMENTATION.md` Phase 4. Reading rules the storefront applies: + +* `status` and `is_for` are int-backed enums (`CouponStatusEnum`: 10 canceled / 20 used / 30 under review / 40 active; `CouponForEnum`: 10 everyone / 20 users / 30 partners), mirrored in both apps. Only `ACTIVE` is usable; `PARTNERS` never is (single-vendor storefront) and `USERS` requires a logged-in customer. +* Money columns are `decimal` in the schema but Toman integers everywhere in the app, so the shop model casts `amount` / `min_price` / `max_discount` to int. +* **Scoping:** no `coupon_product` / `coupon_variety` / `category_coupon` rows at all means the whole cart is eligible; otherwise only lines matching a variety, a product, or a **category or any of its descendants** (the same descendant rule catalog pages use). A percentage applies to the eligible lines' total after variety sale prices, is then capped by `max_discount`, and can never exceed what those lines are worth. +* `min_price` is checked against the whole cart's payable total, not just the eligible lines. + # coupon\_product * Scopes a coupon so it can only be applied to certain products. @@ -387,6 +394,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. @@ -440,6 +460,8 @@ Implementation notes: # mobile\_password\_resets +> **Not created, and no longer needed.** The storefront's mobile password reset (`/forgot-password`, Phase 2) reuses the login OTP, which lives in the **cache** (`SendOtpCode`/`VerifyOtpCode`) — verified codes are consumed there and the verified mobile is held in the session, so no table backs it. Only build this if one-time codes ever have to be auditable or survive a cache flush. + * Resetting the password via mobile. The mechanism works similarly to password recovery via email, but instead of sending an email, an SMS containing the password recovery code is sent to the user's mobile number. The user can set their new password by entering this code on the current page. * `mobile`: Stores the user's mobile number. * `token`: Stores the user's token to verify the received token code. @@ -575,12 +597,15 @@ Implementation notes: # password\_resets +> **As built the table is `password_reset_tokens`** (Laravel's own, created in `create_users_table` alongside `users` and `sessions`), not `password_resets`. It differs from the description below in one way that matters: `email` is the **primary key**, so a customer has at most ONE outstanding request — asking again replaces the previous token instead of adding a row. Tokens expire after `config('auth.passwords.users.expire')` (60 minutes) and a fresh link can only be requested every `throttle` (60) seconds. + * The password recovery process is such that the user is not logged in and goes to the password recovery page. After clicking the password recovery button, an email containing a token and the password reset page link is sent to the user. The user then sets their new password on this page. Since the user's password is hashed, it cannot be recovered, and password recovery means setting a new password. -* According to company policies, these requests might need to be deleted every X hours/days. Each request creates a new record, regardless of whether the user has previously sent a request, and a new token is sent. The lifespan of this token for password recovery is limited. * `email`: Stores the email address for which the password reset request is sent. * `token`: Stores the token to ensure that the user has access to this email and received the token in their email. * `created_at`: Stores the password reset request date. +**Storefront usage.** `/forgot-password` (`PasswordResetController`) offers two channels. The **email** channel is the one that uses this table, through Laravel's password broker. The **mobile** channel does not touch it at all — it reuses the cache-backed login OTP (`SendOtpCode`/`VerifyOtpCode`) and keeps the verified mobile in the session for 15 minutes. Nothing here is admin-managed; see shop `STOREFRONT_IMPLEMENTATION.md` (Phase 2) and `AGENTS.md` for the non-enumeration and placeholder-email rules. + # permissions * `name`: Specifies the permission name. @@ -731,7 +756,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). @@ -1000,15 +1025,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/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/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/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' => 'تاریخ ایجاد', 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); +}); 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..d7b97a2d 100644 --- a/shop/AGENTS.md +++ b/shop/AGENTS.md @@ -187,13 +187,15 @@ This is `shop/`, the customer-facing storefront (Laravel 13 + Inertia). The Fila - **The database schema is owned by the admin app.** Do not recreate tables that already exist in `admin/`; add Eloquent models here that map to the shared tables. Coordinate any schema change in the admin app's migrations, then update `docs/ShoFlow db doc.md`. - This is single-vendor commerce; the storefront reads catalog/pricing data and writes carts, orders, addresses, receipts/transactions per the documented rules. - **Search runs behind the `App\Contracts\ProductSearch` contract** (bound to `DatabaseProductSearch` in `AppServiceProvider`), which does case-insensitive `ILIKE` matching now. Depend on the contract, never the implementation, so an Elasticsearch backend can be swapped in later without touching controllers/actions. It powers both the results page (`/search`) and the header autocomplete (`/search/suggest`). -- **Auth is mobile-first** (`AuthController`, routes under `/login`). OTP is the primary path (and registers on first login); password login is an alternative. Codes live in cache via `SendOtpCode`/`VerifyOtpCode` and are "sent" by a logged stub - replace with a real SMS provider later. The shared `users` table has NOT NULL `email`/`password` and a non-unique `mobile`, so OTP sign-ups seed placeholders (`User::placeholderEmail`) and a random password. `auth.user` + auth `flash` are shared in `HandleInertiaRequests`. +- **Auth is mobile-first** (`AuthController`, routes under `/login`). OTP is the primary path (and registers on first login); password login is an alternative. Codes live in cache via `SendOtpCode`/`VerifyOtpCode` and are "sent" by a logged stub - replace with a real SMS provider later. The shared `users` table has NOT NULL `email`/`password` and a non-unique `mobile`, so OTP sign-ups seed placeholders (`User::placeholderEmail`) and a random password. `auth.user` + auth `flash` are shared in `HandleInertiaRequests`. Every entry point that authenticates someone goes through `App\Actions\Auth\LoginUser` (captures the pre-regeneration guest session id → `Auth::login` → regenerate → `MergeGuestCart`) — never call `Auth::login` directly, or a guest's cart is orphaned. +- **Password reset** (`PasswordResetController`, `/forgot-password`) runs over two channels. **Mobile** reuses the login OTP actions (same cache key, same resend window) and, once verified, keeps the mobile in the session for 15 minutes — that session entry, not the request payload, is what authorises the final "choose a password" POST. Unlike login, reset **never creates an account**: unknown or `BLOCK`ed mobiles are refused. **Email** uses Laravel's password broker (`password_reset_tokens`, admin-owned) and must stay non-enumerating — the same response goes back whether the address exists or not, and the synthetic `@mobile.shopflow.local` placeholder of an OTP sign-up is never mailed. The mail is our own Persian RTL Blade view via `App\Notifications\ResetPasswordLink` (the framework's markdown template would emit English chrome). Both channels log the customer straight in. Because OTP accounts get a random unseen password, this is also the "set a password" flow. - **Account area** lives under `/account` (auth middleware, `AccountController`). `AccountLayout.vue` is the shared shell (sidebar nav + user card + logout) wrapping `AppLayout`; account pages are `noindex`. Built: dashboard (`Account/Dashboard.vue`) and profile edit (`Account/Profile.vue`, edits name/email; mobile read-only; placeholder email hidden via `User::hasPlaceholderEmail`). Not-yet-built sidebar links render `Account/ComingSoon.vue`. Profile saves flash a generic `status` message (shared in `HandleInertiaRequests`); `UserDTO` shapes the user payload. - **Order history + single order view**: `/account/orders` (`AccountController@orders` + `GetUserOrders`, paginated newest-first — `->latest()->orderByDesc('id')`, since `created_at` alone ties when two orders land in the same second) lists lightweight order cards (`Account/Orders/Index.vue`); `/account/orders/{order}` (`@showOrder`, 403 if `order.user_id` isn't the current user) reuses `BuildOrderDTO`/`OrderDTO` — the same action built for `Checkout/Confirmation.vue` — so no new DTO was needed for the detail view. The line-items/address/payment-summary body was extracted from `Confirmation.vue` into `Components/Order/OrderDetail.vue` and is shared by both pages; only the success banner/CTA stay page-specific. The customer-facing "order number" shown on `Confirmation.vue`, `Account/Orders/Index.vue`, and `Account/Orders/Show.vue` is `order.trackingCode` (`orders.tracking_code` — a random unique 10-digit string, auto-generated in `Order`'s `creating` model event), never the raw `id`; `id` still drives routing (`/account/orders/{order}`) and is never shown to the customer. Status badge colors are computed client-side (`composables/useOrderStatus.js`), not via an enum `color()` method (that stays admin/Filament-only per convention). **Retry payment** (`Order::isRetryable()`): only true for a `CANCELED` order whose latest transaction has `paid_at === null` — i.e. the customer canceled at Zarinpal or verification failed, never the oversold case (`CompleteCheckoutPayment::failPaidButOversold()` keeps `paid_at` set precisely so retry stays blocked and a manual refund isn't bypassed). `POST /account/orders/{order}/retry` (`@retryOrder` + `RetryOrderPayment`) pays directly, without touching the cart: it re-checks `has_stock`/`inventory` against every original line (all-or-nothing — no partial retry), and if sufficient, resets the **same** order back to `PENDING` (not a clone — its `order_varieties`/address/shipping/totals are untouched) and opens a Zarinpal session for it via `OpenZarinpalSession` — the same action `StartCheckoutPayment` uses, extracted so both flows share the transaction-creation + Zarinpal-request logic. Each attempt adds a new `Transaction` row rather than reusing/deleting a prior one, so a customer who cancels and retries repeatedly accumulates one order with several transactions (a full attempt history), not a new order per attempt — this was a deliberate redesign after cloning-per-retry cluttered the account order list with canceled duplicates. If stock is insufficient anywhere, nothing changes and the customer is redirected back to the order's own page. Because each attempt gets its own Zarinpal authority, `checkout.callback`/`CompleteCheckoutPayment` need no special-casing for retries — the callback resolves by authority to the (same) order either way. `Order::isRetryable()` and `BuildOrderDTO` both pick the *latest* transaction by `id`, not insertion order or a plain `created_at` sort, since a retried order can have several transactions sharing the same second. **Returns list** (`/account/returns`, `@returns`): reuses `Account/Orders/Index.vue` and `GetUserOrders` as-is — `GetUserOrders(User $user, ?OrderStatusEnum $status = null)` takes an optional status filter, and the Vue page takes `title`/`emptyTitle`/`emptyDescription`/`baseUrl` props (all defaulting to the `/account/orders` copy) so `/account/returns` just passes `OrderStatusEnum::RETURNED` and its own copy — no new page or action. Setting an order to `RETURNED` (admin-only, Filament) doesn't restock inventory or touch `receipts`/`transactions` — see `ORDER.md` → "Returned orders" for the full list of what's still manual. - **Wishlist**: `App\Models\Wishlist` mirrors admin's read-only-from-panel model — schema has a unique `(user_id, product_id)` constraint and **no `session_id`/nullable `user_id`** (unlike `Cart`), so it's strictly auth-only; a guest hitting the toggle route just gets redirected to login by the `auth` middleware. `POST /products/{product}/wishlist` (`WishlistController@toggle` + `App\Actions\Wishlist\ToggleWishlist`) checks for an existing row and deletes it, or creates one — no locking, matching `AddToCart`'s own check-then-act rigor. The heart toggle only lives in `Components/Product/BuyBox.vue` on the product detail page (`Product/Show.vue`) — a deliberate scope decision, not added to `ProductCard.vue`/category/brand/home listings, since that would need a bulk wishlist-membership lookup threaded through every card-producing action (`GetCategoryProducts`, `GetBrandProducts`, `GetProductRows`, etc.). `ProductController@show` computes `isWishlisted` as a sibling Inertia prop next to `product`, exactly like the existing `cartItems` prop — not baked into `ProductDTO`/`BuildProductDetail`, since those have no request/user context and no need to gain one for a single boolean. `/account/wishlist` (`AccountController@wishlist` + `App\Actions\Account\GetUserWishlist`) reuses `BuildProductCard`'s lightweight card array (the same one carousels/category/brand pages use) inside the same `{data, meta}` pagination shape as `GetUserOrders`, rendered by `Account/Wishlist/Index.vue` with its own remove button per row (posts to the same toggle route as the product page). - **Reviews**: read-side was always there (approved-only, on the product page); submission + star ratings + verified-buyer badge were added later. A `rating` (1–5, **nullable** — replies/admin-entered reviews have none) column was added to the shared `reviews` migration (admin owns it, so the admin model/factory/`ReviewResource`/lang + both `ShoFlow db doc.md` copies were updated in lockstep). Any logged-in user submits via `POST /products/{product}/reviews` (`ReviewController@store` + `App\Actions\Review\CreateReview`), which **always** creates the row `PENDING` — the storefront only ever renders `Review::approved()` rows (filtered in `ProductController@show`'s eager-load), so nothing shows until an admin flips `status` in Filament. `canReview` (is-logged-in) is a sibling Inertia prop like `isWishlisted`; the `ProductReviews.vue` form is swapped for a login prompt when false. **Verified-buyer ("خریدار") badge** is computed at read time, never stored: `App\Actions\Review\FindProductBuyers` takes the reviewers' user ids + the product id and returns which of them have an order in PAID/PROCESSING/SHIPPED/DELIVERED containing that product — explicit `whereIn` on statuses, NOT `>= PAID`, because CANCELED(60)/RETURNED(70) sort above DELIVERED(50). `BuildProductDetail` also computes `averageRating` (round to 1 dp over approved reviews' non-null ratings; null when none) and — bug fix along the way — sets a review's `author` via `User::displayName()`, not the non-existent `User->name` which had left every author blank. - **Addresses** (`AddressController`, `/account/addresses`) are immutable history: editing creates a NEW row (`UpdateUserAddress`) that inherits `prime` and soft-deletes the old one (kept for order history, hidden from the active list). First address auto-primary; one primary per user via the model `saved` hook; any address can be promoted from the list (`setPrimary`, `PUT /account/addresses/{address}/primary`); delete is soft-only (`destroy`, `DELETE /account/addresses/{address}`) to preserve order history, and deleting the default promotes the newest remaining address. The shared table has no plate/unit columns, so those round-trip through `description` as JSON (`App\Support\AddressDescription`); recipient name uses the account name (no per-address column). Province/city are cascading (`/account/addresses-cities`). **Neshan maps**: the location (lat/long) is a section separate from the province/city selects. Two key types. Picking a point on either map sets lat/long and auto-fills the address via reverse geocoding (`ReverseGeocode`, `/account/addresses-reverse`, service key). With a `web.` map key (`services.neshan.map_key`, `NESHAN_MAP_KEY`, shared per-page) the form shows the interactive `NeshanMap.vue` (draggable marker, client-side tiles, fast). Without a `web.` key the form falls back to `MapPicker.vue`: a draggable Neshan static map (proxied `StaticMap` -> `/account/addresses-static`, service key) with a fixed center pin (the selected point is always the map center), drag-to-pan (pixel delta -> lat/long via Web Mercator) and zoom buttons; the proxy caches images (30 days) and fails gracefully on timeout (the static plan is slow, so the web key is preferred). All Neshan calls use the server-side `service.` key (`NESHAN_SERVICE_KEY`); only the `web.` key ever reaches the browser. Note: `service.` keys are IP-scoped in the Neshan panel, the server's (public) egress IP must be allowed. The nullable `latitude`/`longitude` columns live on the admin-owned `addresses` table (in the `create_addresses_table` migration). - **Cart** (`carts`, admin-owned: one row per variety line) works for guests and users. `ResolveCartOwner` keys the cart by `user_id` when logged in, else the guest `session_id`; on login `MergeGuestCart` (called from `AuthController@login` with the pre-regeneration session id) folds the guest lines onto the account, combining and clamping to inventory. `CartController` (`/cart`) + `Cart/` actions (`AddToCart`, `GetCartLines`, `BuildCartSummary`) drive add/update/remove; quantity is always clamped to the variety `inventory` server-side. The cart is inventory-neutral (never touches `varieties.inventory`; see `ORDER.md`). Pricing per line is the variety `sale_price ?? price` via `CalculatePricing`; `CartSummaryDTO` totals items, savings and payable. `Cart/Index.vue` renders the checkout stepper (`CheckoutSteps.vue`), `CartLine.vue` rows and `CartSummary.vue`. Add-to-cart is wired in the product `BuyBox` (requires a selected variety). The header badge reads the shared `cart.count` prop (`HandleInertiaRequests`, guarded by `rescue`). +- **Coupons are PREVIEWED on the cart, never applied there.** `POST/DELETE /cart/coupon` store a code in the session (`cart_coupon_code`); `App\Actions\Coupon\PreviewCoupon` re-validates it **on every cart render** (the cart changes underneath it) and `CalculateCouponDiscount` computes the saving. Nothing is written — no order, no `coupons.total_used` increment — and `BuildCartSummary` takes the saving as an **optional** second argument, so the checkout/payment callers that don't pass it are unaffected and the customer is still charged the full amount (committing the coupon is Phase 4 work; see `STOREFRONT_IMPLEMENTATION.md`). Scoping: a coupon with no `coupon_product`/`coupon_variety`/`category_coupon` rows covers the whole cart, otherwise only lines matching a variety, a product, or a **category or any of its descendants** — the same descendant rule catalog pages use. Percentages apply to the eligible-lines total after variety sale prices, then `max_discount` caps it, and the result can never exceed those lines' worth. `is_for = PARTNERS` is never usable (single-vendor storefront) and `USERS` requires a login. - **Checkout** (`/checkout`, auth). Step 2 is shipping (`CheckoutController@shipping` + `Checkout/Shipping.vue`): pick a saved address (or add one inline when none exist, reusing `AddressFormModal`) and a shipping method; an empty cart redirects back to `/cart`. Shipping methods are resolved per destination by `GetShippingMethods` over the admin-owned `shipping_lines → shipping_methods → shipping_cities` hierarchy (most specific scope wins: exact city > province > nationwide null/null); cost `null` + `pay_on_delivery` means postpaid ("پس‌کرایه"), `0` means free. Changing the address refreshes the list via `/checkout/methods` (JSON, like the cities endpoint). The selected `address_id` + `shipping_method_id` are validated (method must be available for the address) and stored in the session; the cost is added to the summary payable (`CartSummary` `showShipping`/`shipping` props). Seed data is in admin `ShippingSeeder` (پیک ویژه تهران Tehran-only، پست پیشتاز nationwide، تحویل حضوری از فروشگاه pay-on-delivery). Coupons are not built yet. Shop has read-only models `ShippingLine`/`ShippingMethod`/`ShippingCity` for these shared tables. - **Order creation + Zarinpal payment** (Phase 4). `Checkout/Payment.vue` posts to `PaymentController@initiate`, which re-resolves the session's address/method exactly like the shipping step's payment render does, then **`ValidateCartStock` re-checks every line's live inventory before anything else** (`inStock && count <= inventory`) — rejects back to `/cart` with a flash message if stock changed since the item was added, so a customer is never charged via Zarinpal for something already unavailable. Only then does it call `StartCheckoutPayment`: `CreatePendingOrder` snapshots the cart into one `Order` (`PENDING`, `address_id`, `shipping_method_id`, `shipping_cost`, totals) + one `OrderVariety` per line (price/discount/final_price straight from `CartLineDTO`) inside a `DB::transaction`, then a `PENDING` `Transaction` (`port = ZARINPAL`) is created and `RequestZarinpalPayment` opens a Zarinpal sandbox payment session. The controller redirects via `Inertia::location()` (framework-handled external redirect — no custom client JS needed) to Zarinpal's StartPay page. `PaymentController@callback` (`GET /checkout/callback`) receives Zarinpal's `Authority`/`Status` redirect, looks the `Transaction` up **by `authority`** (never session, since that's the only value guaranteed to survive the round-trip), and is idempotent: an already-`PAID` order short-circuits without re-verifying, and Zarinpal's verify codes 100 (first verify) and 101 (already verified) both count as success. On success, `DecrementInventoryAndMarkPaid` does the Strategy-A row-locked decrement (`docs/ORDER.md`): `lockForUpdate()` each ordered variety sorted by id (consistent lock ordering avoids deadlocks), checks `inventory >= quantity`, decrements, then marks the order `PAID` and transaction `SUCCESS` — any shortfall rolls back untouched and cancels the order instead of overselling. On failure/cancel (`Status=NOK`, verify rejection) the order/transaction are marked `CANCELED`/`FAILED` and kept as an audit trail. The oversold case (the one race `ValidateCartStock` can't prevent — two customers reaching payment for the last unit at once) is handled separately by `CompleteCheckoutPayment::failPaidButOversold()`: Zarinpal's verify already succeeded there (money genuinely captured), so it keeps `ref_id`/`paid_at` and writes a refund-needed `result_message`, instead of a plain `FAILED` that would hide that a manual refund is owed. `PaymentController@confirmation` + `BuildOrderDTO` render `Checkout/Confirmation.vue` with the paid order's snapshot. **Amounts are stored in Toman everywhere** (consistent with the rest of the schema); the ×10 Toman→Rial conversion for Zarinpal happens only in `App\Support\Currency`, at the HTTP boundary. **Gateway credentials live in `config('services.zarinpal.*')`/`.env`** (`ZARINPAL_MERCHANT_ID`, `ZARINPAL_BASE_URL` — defaults to the sandbox), not the admin `gateways` table (nothing is seeded there); revisit if/when Mellat/Parsian are added and real gateway *selection* is needed. Manual receipt payment and coupon application at checkout are not built yet. - **PWA / install** is wired via `public/manifest.webmanifest` + `public/icons/*` + Apple meta tags in `app.blade.php`. `InstallPrompt.vue` (mounted in `AppLayout`) is an iOS-Safari-only guided "Add to Home Screen" bottom-sheet (iOS has no native prompt); it skips standalone mode and snoozes 7 days after dismissal (`localStorage`). Android/desktop Chrome rely on the native manifest install prompt. @@ -225,6 +227,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 +278,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 @@ -331,6 +334,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/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/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); + } +} 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/Cart/BuildCartSummary.php b/shop/app/Actions/Cart/BuildCartSummary.php index fccc09c7..3b2e4011 100644 --- a/shop/app/Actions/Cart/BuildCartSummary.php +++ b/shop/app/Actions/Cart/BuildCartSummary.php @@ -11,21 +11,25 @@ class BuildCartSummary { /** - * Totals for a set of cart lines. Discount is the saving from variety sale - * prices; coupons are not applied here. + * Totals for a set of cart lines. `discount` is the saving from variety + * sale prices; a coupon saving is passed in separately (the cart page + * previews one, checkout does not yet apply one) and shown as its own line + * so the two never blur together. * * @param Collection $lines */ - public function __invoke(Collection $lines): CartSummaryDTO + public function __invoke(Collection $lines, int $couponDiscount = 0): CartSummaryDTO { $itemsTotal = (int) $lines->sum(fn (CartLineDTO $line): int => $line->lineOriginalTotal()); $payable = (int) $lines->sum(fn (CartLineDTO $line): int => $line->lineTotal()); + $couponDiscount = max(0, min($couponDiscount, $payable)); return new CartSummaryDTO( count: (int) $lines->sum(fn (CartLineDTO $line): int => $line->count), itemsTotal: $itemsTotal, discount: $itemsTotal - $payable, - payable: $payable, + payable: $payable - $couponDiscount, + couponDiscount: $couponDiscount, ); } } diff --git a/shop/app/Actions/Cart/GetCartLines.php b/shop/app/Actions/Cart/GetCartLines.php index dc28a043..d3e2f614 100644 --- a/shop/app/Actions/Cart/GetCartLines.php +++ b/shop/app/Actions/Cart/GetCartLines.php @@ -56,6 +56,9 @@ private function line(Cart $line): CartLineDTO return new CartLineDTO( id: $line->id, varietyId: $variety->id, + productId: $product->id, + // Carried for coupon scoping (which lines a coupon may discount). + categoryId: $product->category_id, heading: $product->heading, url: '/products/'.$product->slug, image: $image, 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/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/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/Coupon/CalculateCouponDiscount.php b/shop/app/Actions/Coupon/CalculateCouponDiscount.php new file mode 100644 index 00000000..b8b053f5 --- /dev/null +++ b/shop/app/Actions/Coupon/CalculateCouponDiscount.php @@ -0,0 +1,109 @@ + $lines + */ + public function __invoke(Coupon $coupon, Collection $lines): int + { + $eligible = $this->eligibleTotal($coupon, $lines); + + if ($eligible <= 0) { + return 0; + } + + $discount = $coupon->is_percent + ? (int) round($eligible * $coupon->amount / 100) + : $coupon->amount; + + if ($coupon->max_discount !== null && $coupon->max_discount > 0) { + $discount = min($discount, $coupon->max_discount); + } + + return max(0, min($discount, $eligible)); + } + + /** + * Worth of the cart lines a coupon is allowed to discount. + * + * @param Collection $lines + */ + public function eligibleTotal(Coupon $coupon, Collection $lines): int + { + return (int) $lines + ->filter(fn (CartLineDTO $line): bool => $this->covers($coupon, $line)) + ->sum(fn (CartLineDTO $line): int => $line->lineTotal()); + } + + private function covers(Coupon $coupon, CartLineDTO $line): bool + { + if (! $coupon->isScoped()) { + return true; + } + + if ($coupon->varieties->contains('id', $line->varietyId)) { + return true; + } + + if ($coupon->products->contains('id', $line->productId)) { + return true; + } + + return $line->categoryId !== null + && in_array($line->categoryId, $this->categoryIds($coupon), true); + } + + /** + * The coupon's categories plus their descendants, so a coupon on a parent + * category also covers products filed under its sub-categories (the same + * rule catalog pages use). + * + * @return array + */ + private function categoryIds(Coupon $coupon): array + { + $ids = $coupon->categories->pluck('id')->map(fn (int $id): int => $id)->all(); + + if ($ids === []) { + return []; + } + + $frontier = $ids; + + while (true) { + /** @var array $children */ + $children = Category::query() + ->whereIn('parent_id', $frontier) + ->pluck('id') + ->map(fn (int $id): int => $id) + ->all(); + + $children = array_values(array_diff($children, $ids)); + + if ($children === []) { + return $ids; + } + + $ids = array_merge($ids, $children); + $frontier = $children; + } + } +} diff --git a/shop/app/Actions/Coupon/PreviewCoupon.php b/shop/app/Actions/Coupon/PreviewCoupon.php new file mode 100644 index 00000000..d7ed8927 --- /dev/null +++ b/shop/app/Actions/Coupon/PreviewCoupon.php @@ -0,0 +1,113 @@ + $lines + * @return array{coupon: CouponDTO|null, error: string|null} + */ + public function __invoke(string $code, ?User $user, Collection $lines): array + { + $code = trim($code); + + $coupon = Coupon::query() + ->with(['products', 'varieties', 'categories']) + ->whereRaw('LOWER(code) = ?', [mb_strtolower($code)]) + ->first(); + + if ($coupon === null) { + return $this->fail('invalid'); + } + + if ($coupon->status !== CouponStatusEnum::ACTIVE) { + return $this->fail('inactive'); + } + + if ($coupon->started_at !== null && $coupon->started_at->isFuture()) { + return $this->fail('not_started'); + } + + if ($coupon->expired_at !== null && $coupon->expired_at->isPast()) { + return $this->fail('expired'); + } + + if ($coupon->total_uses !== null && $coupon->total_used >= $coupon->total_uses) { + return $this->fail('exhausted'); + } + + if ($coupon->is_for === CouponForEnum::USERS && $user === null) { + return $this->fail('login_required'); + } + + // Single-vendor storefront: a partners-only coupon has no audience here. + if ($coupon->is_for === CouponForEnum::PARTNERS) { + return $this->fail('not_eligible'); + } + + // A coupon issued to one specific customer. + if ($coupon->user_id !== null && $coupon->user_id !== $user?->id) { + return $this->fail('not_eligible'); + } + + if ($lines->isEmpty()) { + return $this->fail('cart_empty'); + } + + $payable = (int) $lines->sum(fn (CartLineDTO $line): int => $line->lineTotal()); + + if ($coupon->min_price !== null && $payable < $coupon->min_price) { + return $this->fail('min_price'); + } + + $discount = ($this->calculate)($coupon, $lines); + + // A scoped coupon that matches nothing in the cart, or one whose value + // rounds to nothing, is not "applied" — say so instead of showing a + // zero saving. Free-shipping coupons are still worth keeping. + if ($discount === 0 && ! $coupon->shipping) { + return $this->fail('not_applicable'); + } + + return [ + 'coupon' => new CouponDTO( + code: $coupon->code, + name: $coupon->name, + discount: $discount, + freeShipping: $coupon->shipping, + ), + 'error' => null, + ]; + } + + /** + * @return array{coupon: null, error: string} + */ + private function fail(string $reason): array + { + return [ + 'coupon' => null, + 'error' => trans('messages.cart.coupon.'.$reason), + ]; + } +} 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/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/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/CartLineDTO.php b/shop/app/DTOs/CartLineDTO.php index 7978c6a6..5a27f7bd 100644 --- a/shop/app/DTOs/CartLineDTO.php +++ b/shop/app/DTOs/CartLineDTO.php @@ -12,6 +12,8 @@ public function __construct( public int $id, public int $varietyId, + public int $productId, + public ?int $categoryId, public string $heading, public string $url, public ?ImageDTO $image, @@ -43,6 +45,7 @@ public function toArray(): array return [ 'id' => $this->id, 'varietyId' => $this->varietyId, + 'productId' => $this->productId, 'heading' => $this->heading, 'url' => $this->url, 'image' => $this->image?->toArray(), diff --git a/shop/app/DTOs/CartSummaryDTO.php b/shop/app/DTOs/CartSummaryDTO.php index 63b831a8..b458ffe0 100644 --- a/shop/app/DTOs/CartSummaryDTO.php +++ b/shop/app/DTOs/CartSummaryDTO.php @@ -11,6 +11,7 @@ public function __construct( public int $itemsTotal, public int $discount, public int $payable, + public int $couponDiscount = 0, ) {} /** @@ -22,6 +23,7 @@ public function toArray(): array 'count' => $this->count, 'itemsTotal' => $this->itemsTotal, 'discount' => $this->discount, + 'couponDiscount' => $this->couponDiscount, 'payable' => $this->payable, ]; } diff --git a/shop/app/DTOs/CouponDTO.php b/shop/app/DTOs/CouponDTO.php new file mode 100644 index 00000000..9ce1d59b --- /dev/null +++ b/shop/app/DTOs/CouponDTO.php @@ -0,0 +1,28 @@ + + */ + public function toArray(): array + { + return [ + 'code' => $this->code, + 'name' => $this->name, + 'discount' => $this->discount, + 'freeShipping' => $this->freeShipping, + ]; + } +} 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/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/CouponForEnum.php b/shop/app/Enums/CouponForEnum.php new file mode 100644 index 00000000..14247d69 --- /dev/null +++ b/shop/app/Enums/CouponForEnum.php @@ -0,0 +1,29 @@ + trans('enums.coupon_for.everyone'), + self::USERS => trans('enums.coupon_for.users'), + self::PARTNERS => trans('enums.coupon_for.partners'), + }; + } +} diff --git a/shop/app/Enums/CouponStatusEnum.php b/shop/app/Enums/CouponStatusEnum.php new file mode 100644 index 00000000..daf2ec38 --- /dev/null +++ b/shop/app/Enums/CouponStatusEnum.php @@ -0,0 +1,27 @@ + trans('enums.coupon_status.canceled'), + self::USED => trans('enums.coupon_status.used'), + self::UNDER_REVIEW => trans('enums.coupon_status.under_review'), + self::ACTIVE => trans('enums.coupon_status.active'), + }; + } +} 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..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; @@ -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,21 +176,14 @@ 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); } 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('/'); } diff --git a/shop/app/Http/Controllers/CartController.php b/shop/app/Http/Controllers/CartController.php index ccccb213..6031424e 100644 --- a/shop/app/Http/Controllers/CartController.php +++ b/shop/app/Http/Controllers/CartController.php @@ -8,6 +8,7 @@ use App\Actions\Cart\BuildCartSummary; use App\Actions\Cart\GetCartLines; use App\Actions\Cart\ResolveCartOwner; +use App\Actions\Coupon\PreviewCoupon; use App\DTOs\CartLineDTO; use App\Models\Cart; use App\Models\Variety; @@ -20,18 +21,80 @@ class CartController extends Controller { + /** + * Session key holding the discount code being previewed on the cart. + */ + private const COUPON_KEY = 'cart_coupon_code'; + public function __construct(private ResolveCartOwner $owner) {} - public function index(Request $request, GetCartLines $getLines, BuildCartSummary $buildSummary): Response + public function index(Request $request, GetCartLines $getLines, BuildCartSummary $buildSummary, PreviewCoupon $previewCoupon): Response { $lines = $getLines(($this->owner)($request)); + // The cart changes under a coupon (lines added, removed, re-counted), + // so the stored code is re-checked on every render rather than trusted. + $code = $this->couponCode($request); + $preview = $code === null + ? ['coupon' => null, 'error' => null] + : $previewCoupon($code, $request->user(), $lines); + + $coupon = $preview['coupon']; + + if ($code !== null && $coupon === null) { + $request->session()->forget(self::COUPON_KEY); + } + return Inertia::render('Cart/Index', [ 'lines' => $lines->map(fn (CartLineDTO $line): array => $line->toArray())->all(), - 'summary' => $buildSummary($lines)->toArray(), + 'summary' => $buildSummary($lines, $coupon === null ? 0 : $coupon->discount)->toArray(), + 'coupon' => $coupon?->toArray(), + 'couponError' => $preview['error'], ]); } + /** + * Preview a discount code against the current cart. Nothing is committed: + * the code is only remembered in the session so the cart can show what it + * would save (checkout applying it is Phase 4 work). + */ + public function applyCoupon(Request $request, GetCartLines $getLines, PreviewCoupon $previewCoupon): RedirectResponse + { + $validated = $request->validate([ + 'code' => ['required', 'string', 'max:255'], + ]); + + $lines = $getLines(($this->owner)($request)); + $preview = $previewCoupon($validated['code'], $request->user(), $lines); + + if ($preview['coupon'] === null) { + $request->session()->forget(self::COUPON_KEY); + + throw ValidationException::withMessages(['code' => $preview['error']]); + } + + $request->session()->put(self::COUPON_KEY, $preview['coupon']->code); + + return back()->with('status', trans('messages.cart.coupon.applied')); + } + + public function removeCoupon(Request $request): RedirectResponse + { + $request->session()->forget(self::COUPON_KEY); + + return back()->with('status', trans('messages.cart.coupon.removed')); + } + + /** + * The discount code the customer is currently previewing, if any. + */ + private function couponCode(Request $request): ?string + { + $code = $request->session()->get(self::COUPON_KEY); + + return is_string($code) && $code !== '' ? $code : null; + } + public function store(Request $request, AddToCart $add): RedirectResponse { $validated = $request->validate([ @@ -42,12 +105,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 +133,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..9132dac2 100644 --- a/shop/app/Http/Controllers/HomeController.php +++ b/shop/app/Http/Controllers/HomeController.php @@ -4,27 +4,32 @@ namespace App\Http\Controllers; +use App\Actions\Home\GetBannersByPosition; use App\Actions\Home\GetFeaturedBrands; -use App\Actions\Home\GetHeroSlides; 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; class HomeController extends Controller { public function __invoke( - GetHeroSlides $getHeroSlides, + GetSliderByPosition $getSliderByPosition, GetHomeCategories $getHomeCategories, - GetPromoBanners $getPromoBanners, + GetHomeTags $getHomeTags, + GetBannersByPosition $getBannersByPosition, GetProductRows $getProductRows, GetFeaturedBrands $getFeaturedBrands, ): Response { return Inertia::render('Home', [ - 'slides' => $getHeroSlides(), + '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/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/PasswordResetController.php b/shop/app/Http/Controllers/PasswordResetController.php new file mode 100644 index 00000000..436c3ea8 --- /dev/null +++ b/shop/app/Http/Controllers/PasswordResetController.php @@ -0,0 +1,314 @@ +mobile($request, $normalize); + $this->userByMobile($mobile); + + return $this->sentOtp($mobile, $sendOtp); + } + + /** + * Mobile channel: resend the code. Blocked while the current one is still + * valid, so repeated taps cannot reset the expiry. + */ + public function resendOtp(Request $request, NormalizeMobile $normalize, SendOtpCode $sendOtp): RedirectResponse + { + $mobile = $this->mobile($request, $normalize); + $this->userByMobile($mobile); + + $remaining = $sendOtp->secondsRemaining($mobile); + + if ($remaining > 0) { + return back() + ->withErrors(['code' => trans('messages.auth.code_still_valid', ['seconds' => $remaining])]) + ->with('resetStep', 'otp') + ->with('resetMobile', $mobile) + ->with('resetResendIn', $remaining); + } + + return $this->sentOtp($mobile, $sendOtp); + } + + /** + * Mobile channel, step 2: verify the code. Success unlocks the + * choose-a-password step for a limited window. + */ + public function verifyOtp(Request $request, NormalizeMobile $normalize, VerifyOtpCode $verify): RedirectResponse + { + $mobile = $this->mobile($request, $normalize); + $this->userByMobile($mobile); + + if (! $verify($mobile, $this->code($request))) { + return back() + ->withErrors(['code' => trans('messages.auth.code_invalid')]) + ->with('resetStep', 'otp') + ->with('resetMobile', $mobile); + } + + $request->session()->put(self::SESSION_KEY, [ + 'mobile' => $mobile, + 'verified_at' => now()->getTimestamp(), + ]); + + return back() + ->with('resetStep', 'password') + ->with('resetMobile', $mobile); + } + + /** + * Mobile channel, step 3: store the new password and log the customer in — + * they have just proven they hold the number, which is exactly what OTP + * login proves. + */ + public function updateWithMobile(Request $request, LoginUser $login): RedirectResponse + { + $mobile = $this->verifiedMobile($request); + + if ($mobile === null) { + return redirect()->route('password.request') + ->withErrors(['mobile' => trans('messages.auth.reset.session_expired')]); + } + + $validated = $request->validate([ + 'password' => ['required', 'confirmed', PasswordRule::min(8)], + ]); + + $user = $this->userByMobile($mobile); + $user->password = $validated['password']; + $user->mobile_verified_at ??= now(); + $user->setRememberToken(Str::random(60)); + $user->save(); + + $request->session()->forget(self::SESSION_KEY); + + $login($request, $user); + + return redirect()->route('account.dashboard') + ->with('status', trans('messages.auth.reset.done')); + } + + /** + * Email channel: mail a reset link. The response never reveals whether the + * address belongs to an account. + */ + public function sendEmailLink(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'email' => ['required', 'email', 'max:255'], + ]); + + $email = $validated['email']; + $user = User::query()->where('email', $email)->first(); + + // OTP sign-ups carry a synthetic placeholder address that cannot + // receive mail, and blocked accounts must not be recoverable. + $mailable = $user !== null + && ! $user->hasPlaceholderEmail() + && $user->status !== UserStatusEnum::BLOCK; + + if ($mailable) { + $status = Password::sendResetLink(['email' => $email]); + + if ($status === Password::RESET_THROTTLED) { + return back() + ->withErrors(['email' => trans('messages.auth.reset.email_throttled')]) + ->with('resetChannel', 'email'); + } + } + + return back() + ->with('resetChannel', 'email') + ->with('resetEmailSent', true) + ->with('status', trans('messages.auth.reset.email_sent')); + } + + /** + * Email channel: the page the mailed link opens. + */ + public function edit(Request $request, string $token): Response + { + return Inertia::render('Auth/ResetPassword', [ + 'token' => $token, + 'email' => (string) $request->query('email', ''), + ]); + } + + /** + * Email channel: consume the token, store the new password, log in. + */ + public function update(Request $request, LoginUser $login): RedirectResponse + { + $validated = $request->validate([ + 'token' => ['required', 'string'], + 'email' => ['required', 'email'], + 'password' => ['required', 'confirmed', PasswordRule::min(8)], + ]); + + $reset = null; + + $status = Password::reset($validated, function (User $user, string $password) use (&$reset): void { + $user->password = $password; + $user->setRememberToken(Str::random(60)); + $user->save(); + + $reset = $user; + }); + + if ($status !== Password::PASSWORD_RESET || ! $reset instanceof User) { + return back()->withErrors(['email' => trans('messages.auth.reset.token_invalid')]); + } + + if ($reset->status === UserStatusEnum::BLOCK) { + return redirect()->route('login')->withErrors(['mobile' => trans('messages.auth.blocked')]); + } + + $login($request, $reset); + + return redirect()->route('account.dashboard') + ->with('status', trans('messages.auth.reset.done')); + } + + /** + * The mobile whose code was verified, or null when that never happened or + * the window has closed. + */ + private function verifiedMobile(Request $request): ?string + { + /** @var array{mobile: string, verified_at: int}|null $verified */ + $verified = $request->session()->get(self::SESSION_KEY); + + if ($verified === null) { + return null; + } + + if (now()->getTimestamp() - $verified['verified_at'] > self::VERIFIED_TTL) { + $request->session()->forget(self::SESSION_KEY); + + return null; + } + + return $verified['mobile']; + } + + /** + * Validate and normalise the submitted mobile, aborting back with an error + * when it is not a valid Iranian number. + */ + private function mobile(Request $request, NormalizeMobile $normalize): string + { + $mobile = $normalize((string) $request->input('mobile', '')); + + if ($mobile === null) { + throw new HttpResponseException( + back() + ->withErrors(['mobile' => trans('messages.auth.mobile_invalid')]) + ->with('resetStep', 'mobile') + ); + } + + return $mobile; + } + + /** + * The account behind a mobile number. Unlike login, password reset never + * creates an account — there is nothing to reset for an unknown number. + */ + private function userByMobile(string $mobile): User + { + $user = User::query()->where('mobile', $mobile)->first(); + + if ($user === null) { + throw new HttpResponseException( + back() + ->withErrors(['mobile' => trans('messages.auth.reset.no_account')]) + ->with('resetStep', 'mobile') + ->with('resetMobile', $mobile) + ); + } + + if ($user->status === UserStatusEnum::BLOCK) { + throw new HttpResponseException( + back() + ->withErrors(['mobile' => trans('messages.auth.blocked')]) + ->with('resetStep', 'mobile') + ); + } + + return $user; + } + + private function code(Request $request): string + { + $code = strtr((string) $request->input('code', ''), [ + '۰' => '0', '۱' => '1', '۲' => '2', '۳' => '3', '۴' => '4', + '۵' => '5', '۶' => '6', '۷' => '7', '۸' => '8', '۹' => '9', + ]); + + return preg_replace('/\D+/', '', $code) ?? ''; + } + + private function sentOtp(string $mobile, SendOtpCode $sendOtp): RedirectResponse + { + $code = $sendOtp($mobile); + + $redirect = back() + ->with('resetStep', 'otp') + ->with('resetMobile', $mobile) + ->with('resetResendIn', $sendOtp->secondsRemaining($mobile)); + + if (config('app.debug')) { + $redirect->with('authOtpDev', $code); + } + + return $redirect; + } +} 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/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/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..ab30b186 100644 --- a/shop/app/Http/Middleware/HandleInertiaRequests.php +++ b/shop/app/Http/Middleware/HandleInertiaRequests.php @@ -58,6 +58,11 @@ public function share(Request $request): array 'authMobile' => $request->session()->get('authMobile'), 'authResendIn' => $request->session()->get('authResendIn'), 'authOtpDev' => $request->session()->get('authOtpDev'), + 'resetStep' => $request->session()->get('resetStep'), + 'resetMobile' => $request->session()->get('resetMobile'), + 'resetResendIn' => $request->session()->get('resetResendIn'), + 'resetChannel' => $request->session()->get('resetChannel'), + 'resetEmailSent' => $request->session()->get('resetEmailSent'), ], 'seo' => [ 'siteName' => (string) config('app.name'), @@ -74,11 +79,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/Coupon.php b/shop/app/Models/Coupon.php new file mode 100644 index 00000000..8135eee1 --- /dev/null +++ b/shop/app/Models/Coupon.php @@ -0,0 +1,83 @@ + $products + * @property Collection $varieties + * @property Collection $categories + */ +class Coupon extends Model +{ + protected $casts = [ + 'amount' => 'integer', + 'min_price' => 'integer', + 'max_discount' => 'integer', + 'total_used' => 'integer', + 'total_uses' => 'integer', + 'is_percent' => 'boolean', + 'shipping' => 'boolean', + 'started_at' => 'datetime', + 'expired_at' => 'datetime', + 'status' => CouponStatusEnum::class, + 'is_for' => CouponForEnum::class, + ]; + + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'coupon_product'); + } + + public function varieties(): BelongsToMany + { + return $this->belongsToMany(Variety::class, 'coupon_variety'); + } + + public function categories(): BelongsToMany + { + return $this->belongsToMany(Category::class, 'category_coupon'); + } + + /** + * Whether the coupon is limited to certain products, varieties or + * categories rather than the whole cart. + */ + public function isScoped(): bool + { + return $this->products->isNotEmpty() + || $this->varieties->isNotEmpty() + || $this->categories->isNotEmpty(); + } +} 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/app/Models/User.php b/shop/app/Models/User.php index 4991f44f..e287e84e 100644 --- a/shop/app/Models/User.php +++ b/shop/app/Models/User.php @@ -5,10 +5,12 @@ namespace App\Models; use App\Enums\UserStatusEnum; +use App\Notifications\ResetPasswordLink; use Carbon\Carbon; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; +use Illuminate\Notifications\Notifiable; /** * @property positive-int $id @@ -27,6 +29,8 @@ class User extends Authenticatable /** @use HasFactory */ use HasFactory; + use Notifiable; + /** * Domain used for the synthetic email given to OTP-only sign-ups (the * shared schema requires a unique, non-null email). @@ -74,7 +78,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 @@ -89,4 +93,13 @@ public function hasPlaceholderEmail(): bool { return $this->email !== null && str_ends_with($this->email, self::PLACEHOLDER_EMAIL_DOMAIN); } + + /** + * Send the storefront's own Persian reset mail instead of the framework's + * English notification. + */ + public function sendPasswordResetNotification(mixed $token): void + { + $this->notify(new ResetPasswordLink((string) $token)); + } } 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), + ]); + } +} 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/IMPLEMENTATION.md b/shop/docs/IMPLEMENTATION.md index 7dd0a808..afd3f2e0 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 @@ -128,7 +129,7 @@ The main goal; depends on most of phases 1-3. - [ ] `user_statuses` (per-user status restriction) — niche, not needed now - [ ] Category Partner, Partner Requests, Organizational Requests, Contact Us - [ ] Points, Newsletters, Notifications, System Notifications -- [ ] `email_histories`, `mobile_histories`, `mobile_password_resets` +- [ ] `email_histories`, `mobile_histories`, `mobile_password_resets` (the storefront's mobile password reset uses cache-backed OTP, so `mobile_password_resets` is only needed if codes must become auditable — see the db doc) - [ ] Working Hours - [ ] `user_category_percent`, `user_price_conditions` - [ ] eMalls Products, Short URLs, Bank SMS diff --git a/shop/docs/STOREFRONT_IMPLEMENTATION.md b/shop/docs/STOREFRONT_IMPLEMENTATION.md index eeb52e17..dd03e774 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` @@ -58,7 +65,7 @@ Read-only catalog. This is where SEO and SSR matter most. ## Phase 2 - User auth & account -Auth uses the shared `users` table. Password reset via mobile (`mobile_password_resets`) and email (`password_resets`). +Auth uses the shared `users` table. Password reset runs over mobile **and** email — as built, the mobile channel is cache-backed OTP (the source schema's `mobile_password_resets` table is not used and was never created) and the email channel uses Laravel's `password_reset_tokens` (not the source schema's `password_resets`). - [x] Register / login with mobile number. One flow at `/login`: enter mobile, verify with a one-time code (OTP) or a password. - OTP is the primary path and registers the user on first login. Password login is offered as an alternative on the OTP screen. @@ -66,7 +73,7 @@ Auth uses the shared `users` table. Password reset via mobile (`mobile_password_ - Resend is blocked until the current code expires: `SendOtpCode` reuses the active code (no new code, no reset of its lifetime) and `/login/otp` rejects an early resend with the remaining seconds, which also drives the UI countdown. - The shared `users` table requires `email`/`password`/name, so OTP sign-ups seed placeholders (`{mobile}@mobile.shopflow.local`, a random password, empty names) the user can complete later. - Blocked users (`status = BLOCK`) cannot log in. `auth.user` and auth `flash` are shared via `HandleInertiaRequests`; the header shows the name + logout when signed in. -- [ ] Password reset via mobile and via email +- [x] Password reset via mobile and via email: `/forgot-password` (`PasswordResetController` + `Auth/ForgotPassword.vue`, two channels in one page). **Mobile**: reuses the login OTP infrastructure (`SendOtpCode`/`VerifyOtpCode`, same cache key and resend window) — send code → verify → choose a password; verifying puts the mobile in the session for 15 minutes and only that unlocks the final step. Unlike login, reset never creates an account: an unknown mobile is refused, as is a `BLOCK`ed one. **Email**: Laravel's password broker over the shared `password_reset_tokens` table; `/reset-password/{token}` → `Auth/ResetPassword.vue`. The response is the same whether or not the address exists (no account enumeration), and OTP sign-ups' synthetic `@mobile.shopflow.local` placeholder addresses are never mailed. The mail is a custom Persian RTL Blade view (`emails/password-reset.blade.php`) sent by `App\Notifications\ResetPasswordLink` via `User::sendPasswordResetNotification`, not the framework's English notification. Both channels log the customer in afterwards through the new shared `App\Actions\Auth\LoginUser` (extracted from `AuthController`, so a guest cart is still merged on the way in). Since OTP accounts are created with a random password nobody has seen, this flow doubles as "set a password for the first time". `PasswordResetTest` covers both channels, the enumeration guards and the blocked/expired cases - [x] Account dashboard layout: `/account` area (auth) with `AccountLayout` (sidebar nav + user card + logout) wrapping `AppLayout`. `AccountController@dashboard` + `Account/Dashboard.vue` shows a greeting and shortcut cards. Sidebar links not yet built (reviews) render `Account/ComingSoon.vue`. All account pages are `noindex`. Feature tests in `AccountTest` - [x] Profile view/edit: `Account/Profile.vue` edits first/last name + email (mobile is read-only). `AccountController@profile`/`updateProfile` validate (email unique, ignoring self) and flash a `status` message shared via `HandleInertiaRequests`. The synthetic OTP placeholder email (`User::hasPlaceholderEmail`) is hidden so the field shows empty. `UserDTO` shapes the shared user payload - [x] Addresses: list, create, edit at `/account/addresses` (`AddressController` + `Account/Addresses/Index.vue` with a modal `AddressFormModal`). Editing is immutable (`UpdateUserAddress`): it creates a NEW address inheriting `prime` and soft-deletes the old row, which leaves the active list but stays available for order history. The first address auto-becomes primary; one primary per user via the model `saved` hook. Any address can be set as default from the list (`PUT /account/addresses/{address}/primary`), which demotes the previous one. Delete is a soft delete (`DELETE /account/addresses/{address}`) so order history survives; deleting the default promotes the newest remaining address. Province/city are cascading selects (`/account/addresses-cities`). Phone and 10-digit postal code are normalized server-side (Persian digits). Plate/unit round-trip through the `description` column as JSON (`AddressDescription`) since the shared table has no columns for them. The location (lat/long) is its own section, separate from the province/city selects. With a `web.` map key the interactive `NeshanMap.vue` is used; otherwise `MapPicker.vue` renders a draggable Neshan static map (proxied `/account/addresses-static`, service key) with a fixed center pin, drag-to-pan and zoom buttons. Reverse geocoding (`/account/addresses-reverse`, service key) fills the address from the chosen point. All API calls use the server-side `NESHAN_SERVICE_KEY`; the optional `NESHAN_MAP_KEY` (web) enables the faster interactive map. The nullable lat/long columns are defined in the `create_addresses_table` migration. `AddressDTO` + feature tests @@ -83,14 +90,14 @@ Inventory-neutral. A cart never changes `varieties.inventory` (see `ORDER.md`). - [x] Add to cart / update quantity / remove line (`CartController` + `Cart/` actions: `AddToCart`, `GetCartLines`, `BuildCartSummary`). Quantity is capped at the variety's available `inventory` (clamped in `BuyBox`/`CartLine` and again server-side). Add-to-cart is wired from the product `BuyBox` (requires a selected variety, since cart lines reference a variety) - [x] Cart page at `/cart` (`Cart/Index.vue`): checkout stepper (`CheckoutSteps.vue`: cart / shipping / payment), line items (`CartLine.vue`) and an order summary (`CartSummary.vue`: items total, savings, payable). Unit price is the variety `sale_price ?? price`. The header shows a live item-count badge via the shared `cart.count` prop (`HandleInertiaRequests`) - [x] Merge guest cart into the user cart on login (`MergeGuestCart`, called in `AuthController@login` with the pre-regeneration session id; quantities combine and clamp to inventory) -- [ ] Coupon preview at cart (validated, not yet committed) +- [x] Coupon preview at cart (validated, not yet committed). `POST /cart/coupon` / `DELETE /cart/coupon` (`CartController@applyCoupon`/`removeCoupon`) + `Components/Cart/CouponBox.vue`. `App\Actions\Coupon\PreviewCoupon` resolves the code (case-insensitive) and checks status, start/expiry window, remaining `total_uses`, audience (`is_for`: partners never apply on a single-vendor storefront; users-only needs a login), a coupon issued to one specific `user_id`, and `min_price` against the cart — each failure returns its own Persian message. `CalculateCouponDiscount` then works out the saving over the **eligible lines only**: an unscoped coupon covers the whole cart, otherwise a line matches by variety, product, or its category (**including sub-categories** of the coupon's categories, the same descendant rule catalog pages use). Percentages apply to the eligible total after variety sale prices, are capped by `max_discount`, and can never exceed what those lines are worth. **Nothing is committed**: no order, no `coupons.total_used` increment — the code lives in the session and the cart re-validates it on every render, silently dropping it (with `couponError` explaining why) when the cart changes underneath it. `BuildCartSummary` takes the coupon saving as an optional argument and reports it as its own `couponDiscount` line, so **checkout and payment totals are unaffected** until "Coupon application" lands in Phase 4. Free-shipping coupons (`shipping`) are kept even when they discount nothing. Read model `App\Models\Coupon` + mirrored `CouponStatusEnum`/`CouponForEnum`; `CartCouponTest` covers the discount maths, every rejection reason and the untouched checkout total ## Phase 4 - Checkout & payment (commerce core) - [~] Checkout: choose address, choose shipping method (`shipping_lines` / `shipping_methods` / `shipping_cities` per-city cost), apply coupon - [x] Shipping step (`/checkout`, auth, `CheckoutController@shipping` + `Checkout/Shipping.vue`): pick a saved address (radio) or add one inline when none exist (reuses `AddressFormModal`); empty cart redirects back to `/cart`. The chosen address id is kept in the session - [x] Shipping method selection: methods are resolved per destination (`GetShippingMethods` over `shipping_cities`: exact city > province > nationwide) and listed on the shipping step; changing the address refreshes them via `/checkout/methods` (JSON). The cost flows into the order summary (pay-on-delivery shows "پس‌کرایه", zero shows "رایگان"). Selection is validated against the address and kept in the session. Seed data lives in admin `ShippingSeeder` (پیک ویژه تهران، پست پیشتاز، تحویل حضوری از فروشگاه) - - [ ] Coupon application + - [ ] Coupon application. The cart already previews a coupon (Phase 3) and leaves the code in the session (`cart_coupon_code`); what is missing is committing it — re-validate at payment time, write `orders.coupon_id`/`coupon_discount`, charge the discounted amount, increment `coupons.total_used`, and honour a `shipping` (free-shipping) coupon against the chosen shipping cost. Until then the cart shows a saving that checkout does not charge - [x] Order creation with `pending` status; line snapshots in `order_varieties`. `CreatePendingOrder` snapshots the cart (unit price, line discount, final price per `CartLineDTO`) plus the chosen `address_id` (new FK, see below) and `shipping_method_id`/`shipping_cost` into one `Order` + its `OrderVariety` rows, inside a DB transaction. `order_shippings` (fulfillment/tracking) is a staff-side concern, not created at checkout — deferred to Phase 5 - [x] Inventory decrement on successful payment only, inside a DB transaction with `SELECT ... FOR UPDATE` row lock on the variety (Strategy A, `ORDER.md`). `DecrementInventoryAndMarkPaid` locks each ordered variety (sorted by id to avoid deadlocks), verifies `inventory >= quantity`, decrements, and only then marks the order `PAID`/transaction `SUCCESS`; any shortfall rolls back untouched and cancels the order instead of overselling - [ ] Manual payment via `receipts` (card-to-card / Paya: tracking code or uploaded receipt image; staff confirm) diff --git a/shop/docs/ShoFlow db doc.md b/shop/docs/ShoFlow db doc.md index 1a67fcfc..d4f7b2a4 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. @@ -202,6 +202,13 @@ Stores discount coupons. Unlike discounts, a coupon is applied manually: the cus * `started_at`: When the coupon becomes usable. * `expired_at`: When the coupon can no longer be used. +**Storefront usage (preview only, so far).** The cart previews a coupon — `App\Actions\Coupon\PreviewCoupon` + `CalculateCouponDiscount`, code held in the session — and **writes nothing**: no order, and `total_used` is NOT incremented. Committing a coupon to an order (filling `orders.coupon_id` / `orders.coupon_discount`, bumping `total_used`, applying `shipping`) is checkout work, not built yet — see shop `STOREFRONT_IMPLEMENTATION.md` Phase 4. Reading rules the storefront applies: + +* `status` and `is_for` are int-backed enums (`CouponStatusEnum`: 10 canceled / 20 used / 30 under review / 40 active; `CouponForEnum`: 10 everyone / 20 users / 30 partners), mirrored in both apps. Only `ACTIVE` is usable; `PARTNERS` never is (single-vendor storefront) and `USERS` requires a logged-in customer. +* Money columns are `decimal` in the schema but Toman integers everywhere in the app, so the shop model casts `amount` / `min_price` / `max_discount` to int. +* **Scoping:** no `coupon_product` / `coupon_variety` / `category_coupon` rows at all means the whole cart is eligible; otherwise only lines matching a variety, a product, or a **category or any of its descendants** (the same descendant rule catalog pages use). A percentage applies to the eligible lines' total after variety sale prices, is then capped by `max_discount`, and can never exceed what those lines are worth. +* `min_price` is checked against the whole cart's payable total, not just the eligible lines. + # coupon\_product * Scopes a coupon so it can only be applied to certain products. @@ -387,6 +394,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. @@ -440,6 +460,8 @@ Implementation notes: # mobile\_password\_resets +> **Not created, and no longer needed.** The storefront's mobile password reset (`/forgot-password`, Phase 2) reuses the login OTP, which lives in the **cache** (`SendOtpCode`/`VerifyOtpCode`) — verified codes are consumed there and the verified mobile is held in the session, so no table backs it. Only build this if one-time codes ever have to be auditable or survive a cache flush. + * Resetting the password via mobile. The mechanism works similarly to password recovery via email, but instead of sending an email, an SMS containing the password recovery code is sent to the user's mobile number. The user can set their new password by entering this code on the current page. * `mobile`: Stores the user's mobile number. * `token`: Stores the user's token to verify the received token code. @@ -575,12 +597,15 @@ Implementation notes: # password\_resets +> **As built the table is `password_reset_tokens`** (Laravel's own, created in `create_users_table` alongside `users` and `sessions`), not `password_resets`. It differs from the description below in one way that matters: `email` is the **primary key**, so a customer has at most ONE outstanding request — asking again replaces the previous token instead of adding a row. Tokens expire after `config('auth.passwords.users.expire')` (60 minutes) and a fresh link can only be requested every `throttle` (60) seconds. + * The password recovery process is such that the user is not logged in and goes to the password recovery page. After clicking the password recovery button, an email containing a token and the password reset page link is sent to the user. The user then sets their new password on this page. Since the user's password is hashed, it cannot be recovered, and password recovery means setting a new password. -* According to company policies, these requests might need to be deleted every X hours/days. Each request creates a new record, regardless of whether the user has previously sent a request, and a new token is sent. The lifespan of this token for password recovery is limited. * `email`: Stores the email address for which the password reset request is sent. * `token`: Stores the token to ensure that the user has access to this email and received the token in their email. * `created_at`: Stores the password reset request date. +**Storefront usage.** `/forgot-password` (`PasswordResetController`) offers two channels. The **email** channel is the one that uses this table, through Laravel's password broker. The **mobile** channel does not touch it at all — it reuses the cache-backed login OTP (`SendOtpCode`/`VerifyOtpCode`) and keeps the verified mobile in the session for 15 minutes. Nothing here is admin-managed; see shop `STOREFRONT_IMPLEMENTATION.md` (Phase 2) and `AGENTS.md` for the non-enumeration and placeholder-email rules. + # permissions * `name`: Specifies the permission name. @@ -731,7 +756,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). @@ -1000,15 +1025,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 new file mode 100644 index 00000000..f7925dac --- /dev/null +++ b/shop/lang/fa/enums.php @@ -0,0 +1,98 @@ + [ + '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' => 'رد شده', + ], + 'coupon_status' => [ + 'canceled' => 'لغو شده', + 'used' => 'استفاده شده', + 'under_review' => 'در انتظار بررسی', + 'active' => 'فعال', + ], + 'coupon_for' => [ + 'everyone' => 'همه', + 'users' => 'کاربران', + 'partners' => 'همکاران', + ], + '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' => 'صفحه محصول — کنار', + ], + 'banner_position' => [ + 'home_top' => 'صفحه خانه — بالا', + 'home_middle' => 'صفحه خانه — شبکه میانی', + 'category_side' => 'صفحه دسته‌بندی — کنار', + ], +]; diff --git a/shop/lang/fa/messages.php b/shop/lang/fa/messages.php new file mode 100644 index 00000000..901d6858 --- /dev/null +++ b/shop/lang/fa/messages.php @@ -0,0 +1,118 @@ + 'اطلاعات حساب با موفقیت ذخیره شد.', + + 'cart' => [ + 'empty' => 'سبد خرید شما خالی است.', + 'added' => 'کالا به سبد خرید اضافه شد.', + 'removed' => 'کالا از سبد خرید حذف شد.', + 'unavailable' => 'این کالا موجود نیست.', + + 'coupon' => [ + 'applied' => 'کد تخفیف اعمال شد.', + 'removed' => 'کد تخفیف حذف شد.', + 'invalid' => 'کد تخفیف وارد شده معتبر نیست.', + 'inactive' => 'این کد تخفیف فعال نیست.', + 'not_started' => 'زمان استفاده از این کد تخفیف هنوز فرا نرسیده است.', + 'expired' => 'مهلت استفاده از این کد تخفیف به پایان رسیده است.', + 'exhausted' => 'ظرفیت استفاده از این کد تخفیف تکمیل شده است.', + 'login_required' => 'برای استفاده از این کد تخفیف باید وارد حساب کاربری خود شوید.', + 'not_eligible' => 'این کد تخفیف برای حساب شما قابل استفاده نیست.', + 'cart_empty' => 'سبد خرید شما خالی است.', + 'min_price' => 'مبلغ سبد خرید شما به حداقل لازم برای این کد تخفیف نمی‌رسد.', + 'not_applicable' => 'این کد تخفیف به کالاهای سبد خرید شما تعلق نمی‌گیرد.', + ], + ], + + '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' => 'حساب کاربری شما مسدود شده است.', + + 'reset' => [ + 'no_account' => 'حسابی با این شماره موبایل پیدا نشد.', + 'session_expired' => 'مهلت تعیین رمز عبور به پایان رسید؛ لطفاً دوباره تلاش کنید.', + 'token_invalid' => 'این لینک بازیابی نامعتبر یا منقضی شده است.', + 'done' => 'رمز عبور جدید شما ثبت شد.', + 'email_sent' => 'اگر حسابی با این ایمیل وجود داشته باشد، لینک بازیابی رمز عبور برای آن ارسال شد.', + 'email_throttled' => 'به‌تازگی یک لینک بازیابی ارسال شده است؛ لطفاً کمی صبر کنید.', + 'email_subject' => 'بازیابی رمز عبور', + 'email_intro' => 'این ایمیل را دریافت کرده‌اید چون درخواست بازیابی رمز عبور برای حساب شما در :app ثبت شده است.', + 'email_action' => 'تعیین رمز عبور جدید', + 'email_expires' => 'این لینک تا :minutes دقیقه دیگر معتبر است.', + 'email_ignore' => 'اگر این درخواست از طرف شما نبوده، نیازی به انجام کاری نیست.', + 'email_fallback' => 'اگر دکمه بالا کار نکرد، این نشانی را در مرورگر خود باز کنید:', + ], + ], + + '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..47cfa105 --- /dev/null +++ b/shop/lang/fa/validation.php @@ -0,0 +1,63 @@ + 'وارد کردن :attribute الزامی است.', + 'string' => ':attribute باید متن باشد.', + 'integer' => ':attribute نامعتبر است.', + 'numeric' => ':attribute نامعتبر است.', + 'boolean' => ':attribute نامعتبر است.', + 'email' => ':attribute معتبر نیست.', + 'exists' => ':attribute انتخاب‌شده معتبر نیست.', + 'unique' => ':attribute قبلاً استفاده شده است.', + 'confirmed' => ':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' => 'تعداد', + 'password' => 'رمز عبور', + 'mobile' => 'شماره موبایل', + 'code' => 'کد تایید', + 'token' => 'کد بازیابی', + ], +]; 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/resources/js/Components/Cart/CartSummary.vue b/shop/resources/js/Components/Cart/CartSummary.vue index 1b2ec933..c5564847 100644 --- a/shop/resources/js/Components/Cart/CartSummary.vue +++ b/shop/resources/js/Components/Cart/CartSummary.vue @@ -55,6 +55,14 @@ const total = computed(() => props.summary.payable + shippingCost.value);
{{ formatPrice(summary.discount) }}
+
+
تخفیف کد تخفیف
+
{{ formatPrice(summary.couponDiscount) }}
+
+
هزینه ارسال
روش حمل و نقل انتخاب نشده
diff --git a/shop/resources/js/Components/Cart/CouponBox.vue b/shop/resources/js/Components/Cart/CouponBox.vue new file mode 100644 index 00000000..e0c550aa --- /dev/null +++ b/shop/resources/js/Components/Cart/CouponBox.vue @@ -0,0 +1,88 @@ + + + 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/Auth/ForgotPassword.vue b/shop/resources/js/Pages/Auth/ForgotPassword.vue new file mode 100644 index 00000000..ef09fe7b --- /dev/null +++ b/shop/resources/js/Pages/Auth/ForgotPassword.vue @@ -0,0 +1,323 @@ + + + diff --git a/shop/resources/js/Pages/Auth/Login.vue b/shop/resources/js/Pages/Auth/Login.vue index 82c1ae3d..2fefaa69 100644 --- a/shop/resources/js/Pages/Auth/Login.vue +++ b/shop/resources/js/Pages/Auth/Login.vue @@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { router, useForm, usePage } from '@inertiajs/vue3'; import AppHead from '@/Components/AppHead.vue'; import AppLayout from '@/Layouts/AppLayout.vue'; +import AppLink from '@/Components/AppLink.vue'; import Icon from '@/Components/Icon.vue'; import { uiIcons } from '@/fontawesome'; @@ -225,9 +226,15 @@ onBeforeUnmount(() => { ورود - +
+ + + + رمز عبور را فراموش کرده‌اید؟ + +

+import { useForm } from '@inertiajs/vue3'; +import AppHead from '@/Components/AppHead.vue'; +import AppLayout from '@/Layouts/AppLayout.vue'; +import AppLink from '@/Components/AppLink.vue'; + +const props = defineProps({ + token: { type: String, required: true }, + email: { type: String, default: '' }, +}); + +const form = useForm({ + token: props.token, + email: props.email, + password: '', + password_confirmation: '', +}); + +function submit() { + form.post('/reset-password', { preserveScroll: true }); +} + + + diff --git a/shop/resources/js/Pages/Cart/Index.vue b/shop/resources/js/Pages/Cart/Index.vue index 81a3fc4d..c551dcb5 100644 --- a/shop/resources/js/Pages/Cart/Index.vue +++ b/shop/resources/js/Pages/Cart/Index.vue @@ -8,14 +8,17 @@ import EmptyState from '@/Components/EmptyState.vue'; import CheckoutSteps from '@/Components/Checkout/CheckoutSteps.vue'; import CartLine from '@/Components/Cart/CartLine.vue'; import CartSummary from '@/Components/Cart/CartSummary.vue'; +import CouponBox from '@/Components/Cart/CouponBox.vue'; import { uiIcons } from '@/fontawesome'; const props = defineProps({ lines: { type: Array, default: () => [] }, summary: { type: Object, - default: () => ({ count: 0, itemsTotal: 0, discount: 0, payable: 0 }), + default: () => ({ count: 0, itemsTotal: 0, discount: 0, couponDiscount: 0, payable: 0 }), }, + coupon: { type: Object, default: null }, + couponError: { type: String, default: null }, }); const page = usePage(); @@ -73,7 +76,8 @@ function checkout() {

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