Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
ba8b231
feat(admin): constrain slider position to a SliderPositionEnum dropdown
bahman026 Jul 24, 2026
a6950d6
feat(shop): slider position lookup + full server-side Persian lang sy…
bahman026 Jul 24, 2026
abc0926
feat(admin): tags, home sections, and enum-constrained banner positions
bahman026 Aug 5, 2026
a97d064
feat(shop): tag landing pages, home tag strip, banner position lookup
bahman026 Aug 5, 2026
bafcb8a
refactor(shop): extract LoginUser so every login path merges the gues…
bahman026 Aug 5, 2026
17e1e94
refactor(shop): route AuthController's login through the LoginUser ac…
bahman026 Aug 5, 2026
9194c5f
feat(shop): Persian password-reset mail notification
bahman026 Aug 5, 2026
fea6966
feat(shop): RTL Persian reset-password email view (inline styles only)
bahman026 Aug 5, 2026
9e649ec
feat(shop): send our own reset notification instead of the framework's
bahman026 Aug 5, 2026
1996896
feat(shop): password reset over mobile OTP and emailed broker token
bahman026 Aug 5, 2026
a089784
feat(shop): forgot-password page with SMS and email channels
bahman026 Aug 5, 2026
17eaf44
feat(shop): reset-password page the mailed link opens
bahman026 Aug 5, 2026
1db5f88
feat(shop): link the password step to forgot-password
bahman026 Aug 5, 2026
c451c0c
test(shop): cover both password-reset channels and their guards
bahman026 Aug 5, 2026
b7ac485
feat(shop): mirror the admin CouponStatusEnum
bahman026 Aug 5, 2026
cbc7d25
feat(shop): mirror the admin CouponForEnum
bahman026 Aug 5, 2026
c62803f
feat(shop): Coupon read model with its scope pivots
bahman026 Aug 5, 2026
5d6741e
feat(shop): CouponDTO for the cart's coupon preview
bahman026 Aug 5, 2026
e8490d8
feat(shop): value a coupon over the cart lines it actually covers
bahman026 Aug 5, 2026
34c1f63
feat(shop): validate a coupon code against the cart without committin…
bahman026 Aug 5, 2026
61e46f7
feat(shop): carry productId/categoryId on cart lines for coupon scoping
bahman026 Aug 5, 2026
0ee17fd
feat(shop): fill the new product/category ids on each cart line
bahman026 Aug 5, 2026
a7ccbb8
feat(shop): report a coupon saving as its own summary line
bahman026 Aug 5, 2026
da680bb
feat(shop): take the coupon saving as an optional argument, so checko…
bahman026 Aug 5, 2026
14f2a72
feat(shop): apply/remove a coupon preview and re-check it on every re…
bahman026 Aug 5, 2026
e6bc917
feat(shop): coupon box on the cart page
bahman026 Aug 5, 2026
17fcd96
feat(shop): show the coupon saving in the order summary
bahman026 Aug 5, 2026
44de446
feat(shop): wire the coupon box into the cart page
bahman026 Aug 5, 2026
ae11495
test(shop): cover coupon maths, every rejection reason and the untouc…
bahman026 Aug 5, 2026
3aeb887
fix(shop): share the password-reset flash keys so the form can advance
bahman026 Aug 5, 2026
d2e3829
feat(shop): routes for password reset and the cart coupon preview
bahman026 Aug 5, 2026
278a340
feat(shop): Persian strings for password reset and coupon errors
bahman026 Aug 5, 2026
e5f3afb
feat(shop): Persian confirmed rule and password/mobile/code attributes
bahman026 Aug 5, 2026
85aee9f
feat(shop): Persian labels for the coupon enums
bahman026 Aug 5, 2026
629ee22
docs(shop): record the login/reset and coupon-preview rules
bahman026 Aug 5, 2026
483a895
docs(shop): mark password reset and coupon preview done, detail what …
bahman026 Aug 5, 2026
f58e61a
docs(shop): describe how the storefront reads coupons and which reset…
bahman026 Aug 5, 2026
389af99
docs(admin): mirror the coupons and password-reset table notes
bahman026 Aug 5, 2026
14766b2
docs(shop): explain why mobile_password_resets stays optional
bahman026 Aug 5, 2026
e9e80b8
docs(admin): explain why mobile_password_resets stays optional
bahman026 Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions admin/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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')`).
Expand Down
25 changes: 25 additions & 0 deletions admin/app/Enums/BannerPositionEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Enums;

use App\Traits\HasOptions;

enum BannerPositionEnum: string
{
use HasOptions;

case HOME_TOP = 'home-top';
case HOME_MIDDLE = 'home-middle';
case CATEGORY_SIDE = 'category-side';

public function label(): string
{
return match ($this) {
self::HOME_TOP => trans('banner.position_home_top'),
self::HOME_MIDDLE => trans('banner.position_home_middle'),
self::CATEGORY_SIDE => trans('banner.position_category_side'),
};
}
}
36 changes: 36 additions & 0 deletions admin/app/Enums/HomeSectionTypeEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace App\Enums;

use App\Traits\HasOptions;

/**
* The kinds of section the storefront home page can render. Each type maps to
* one storefront component + data action; the admin arranges an ordered list
* of these (home_sections table) to compose the home page.
*/
enum HomeSectionTypeEnum: string
{
use HasOptions;

case SLIDER = 'slider';
case TAGS = 'tags';
case CATEGORIES = 'categories';
case BANNERS = 'banners';
case PRODUCTS = 'products';
case BRANDS = 'brands';

public function label(): string
{
return match ($this) {
self::SLIDER => 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'),
};
}
}
27 changes: 27 additions & 0 deletions admin/app/Enums/SliderPositionEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace App\Enums;

use App\Traits\HasOptions;

enum SliderPositionEnum: string
{
use HasOptions;

case HOME_MAIN = 'home-main';
case HOME_SECONDARY = 'home-secondary';
case CATEGORY_TOP = 'category-top';
case PRODUCT_SIDE = 'product-side';

public function label(): string
{
return match ($this) {
self::HOME_MAIN => 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'),
};
}
}
24 changes: 20 additions & 4 deletions admin/app/Filament/Resources/BannerResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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'))
Expand Down
141 changes: 141 additions & 0 deletions admin/app/Filament/Resources/HomeSectionResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<?php

declare(strict_types=1);

namespace App\Filament\Resources;

use App\Enums\BannerPositionEnum;
use App\Enums\HomeSectionTypeEnum;
use App\Enums\SliderPositionEnum;
use App\Filament\Resources\HomeSectionResource\Pages\CreateHomeSection;
use App\Filament\Resources\HomeSectionResource\Pages\EditHomeSection;
use App\Filament\Resources\HomeSectionResource\Pages\ListHomeSections;
use App\Models\HomeSection;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;

class HomeSectionResource extends Resource
{
protected static ?string $model = HomeSection::class;

protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-squares-2x2';

public static function getNavigationGroup(): ?string
{
return trans('home_section.navigation_group');
}

public static function getModelLabel(): string
{
return trans('home_section.label');
}

public static function getPluralModelLabel(): string
{
return trans('home_section.plural_label');
}

public static function form(Schema $schema): Schema
{
return $schema
->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'),
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace App\Filament\Resources\HomeSectionResource\Pages;

use App\Filament\Resources\HomeSectionResource;
use Filament\Resources\Pages\CreateRecord;

class CreateHomeSection extends CreateRecord
{
protected static string $resource = HomeSectionResource::class;

protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace App\Filament\Resources\HomeSectionResource\Pages;

use App\Filament\Resources\HomeSectionResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;

class EditHomeSection extends EditRecord
{
protected static string $resource = HomeSectionResource::class;

protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}

protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace App\Filament\Resources\HomeSectionResource\Pages;

use App\Filament\Resources\HomeSectionResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;

class ListHomeSections extends ListRecords
{
protected static string $resource = HomeSectionResource::class;

protected ?string $subheading = null;

public function mount(): void
{
parent::mount();
$this->subheading = trans('home_section.subheading');
}

protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
Loading
Loading