diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..8569f6c4 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "timeout": 5, + "command": "f=\"$CLAUDE_PROJECT_DIR/ai-context/claude/personal.md\"; [ -f \"$f\" ] && cat \"$f\"; true" + } + ] + } + ] + } +} diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 00000000..577055b0 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,39 @@ +# Compose-level values for compose.prod.yaml. Copy to .env.production on the +# VPS and fill in. These are read by Compose itself (via --env-file), not by +# the Laravel apps — those have their own admin/.env.production and +# shop/.env.production. + +# --- Postgres -------------------------------------------------------------- +# Only used the first time the pgdata volume is created. Changing them later +# does not change the existing role or database. +POSTGRES_DB=shop_flow +POSTGRES_USER=shop_flow +POSTGRES_PASSWORD= + +# --- Redis ----------------------------------------------------------------- +REDIS_PASSWORD= + +# --- Public hostnames ------------------------------------------------------ +# Both must resolve to this server. Caddy does not request a certificate for +# them itself — see infrastructure/production/certs/README.md — so DNS only +# needs to be correct by the time clients connect, not before Caddy starts. +SHOP_DOMAIN=shop.example.com +ADMIN_DOMAIN=admin.example.com + +# Optional, space-separated. Old/alternate hostnames for the storefront that +# should 301-redirect to SHOP_DOMAIN instead of serving anything themselves — +# e.g. a bare apex when SHOP_DOMAIN is a subdomain, or vice versa after moving +# it. Must be included as SANs on the certificate in +# infrastructure/production/certs/ (see that directory's README) or the +# redirect itself fails as a certificate error. Leave unset for none. +SHOP_LEGACY_DOMAINS= + +# Space-separated CIDRs whose X-Forwarded-* headers Caddy should trust, or the +# token `private_ranges`. Only matters if a CDN or LB sits in front of Caddy; +# `private_ranges` is correct when clients connect to Caddy directly. +TRUSTED_PROXIES=private_ranges + +# --- Images ---------------------------------------------------------------- +# Tag applied to the built images. Set it to the deployed commit +# (`git rev-parse --short HEAD`) so a rollback has something to point at. +IMAGE_TAG=latest diff --git a/.gitignore b/.gitignore index 757fee31..563cdf77 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,21 @@ -/.idea \ No newline at end of file +/.idea +/.env.production + +# macOS finder metadata, at any depth +.DS_Store + +# TLS certificate + key for the production proxy, obtained per-deployment +# via infrastructure/production/certs/README.md. +/infrastructure/production/certs/*.pem + +# Per-person AI context, never committed — each colleague keeps their own. +# Injected by the UserPromptSubmit hook in .claude/settings.json. +/ai-context/claude/personal.md + +# Demo/staging catalog dataset. The authored content (demo/data/*.json) and +# the fetch scripts (demo/scripts/) ARE tracked, so the demo is reproducible +# on any machine — only the regenerable fetch cache and the Pexels API key +# stay out of git. See demo/README.md. +/demo/raw/ +/demo/.env +/demo/scripts/node_modules/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..f2d0d938 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@ai-context/claude/CLAUDE.md diff --git a/README.md b/README.md index d015e532..00cd67a7 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ The admin panel covers the full schema today. The storefront is built feature by ``` ShopFlow/ +├── compose.yaml # Root entry point: brings up all six containers ├── admin/ # Filament admin panel (owns the DB schema) ├── shop/ # Inertia + Vue storefront (SSR) ├── infrastructure/ @@ -54,16 +55,49 @@ Run migrations and seeders from `admin/` only. The storefront must not migrate t ## Getting started -### 1. Shared services (Postgres + Redis) +### 1. Docker environment files -In `infrastructure/docker`, create a `.env` from `.env.example`, then start the containers: +Each compose file reads its own `.env`. Create all three from their examples: ```bash -cd infrastructure/docker -sudo docker compose up -d --build +cp infrastructure/docker/.env.example infrastructure/docker/.env +cp admin/docker/.env.example admin/docker/.env +cp shop/docker/.env.example shop/docker/.env ``` -### 2. Configure each app +Fill in the blanks in `infrastructure/docker/.env` (database name, user, password, +Redis password) and set `USER_ID`/`GROUP_ID` to your own (`id -u`, `id -g`). + +### 2. Start every container + +The root `compose.yaml` merges the three compose files into one project, so a +single command from the repository root brings up the shared services and both +applications: + +```bash +docker compose up -d --build +``` + +That starts six containers on a shared `shop_flow_net` network: + +| Container | Role | Host port | +| --- | --- | --- | +| `shop_flow_db` | PostgreSQL 16 | `127.0.0.1:5432` | +| `shop_flow_redis` | Redis | `127.0.0.1:6379` | +| `shop_flow_admin_app` | admin PHP-FPM | — | +| `shop_flow_admin_nginx` | admin web server | `127.0.0.1:4040` | +| `shop_flow_shop_app` | storefront PHP-FPM | — | +| `shop_flow_shop_nginx` | storefront web server | `127.0.0.1:8080` | + +Both apps wait for Postgres and Redis to report healthy before they start. Host +ports come from the `*_EXPOSE_PORT` variables in the three `.env` files. + +Each app can still be started on its own — `docker compose up -d` inside +`infrastructure/docker`, `admin/docker`, or `shop/docker`. In that mode the +`infrastructure` project must come up first, because it creates the +`shop_flow_net` network that the other two join as an external network. + +### 3. Configure each app In both `admin/.env` and `shop/.env`, point the database at the shared Postgres (matching the values from `infrastructure/docker/.env`): @@ -74,7 +108,7 @@ DB_PORT=5432 # DB_DATABASE / DB_USERNAME / DB_PASSWORD must match infrastructure/docker/.env ``` -### 3. Admin (schema owner — set up first) +### 4. Admin (schema owner — set up first) ```bash cd admin @@ -84,7 +118,7 @@ php artisan migrate --seed npm install && npm run build ``` -### 4. Storefront +### 5. Storefront ```bash cd shop @@ -95,6 +129,18 @@ npm install && npm run build For app-specific details (Docker containers, SSR, conventions), see each app's own `README.md`, `AGENTS.md`, and `docs/`. +## Production + +`compose.yaml` is for development only — it bind-mounts the source and installs +dependencies on every container start. Production uses a separate stack, +`compose.prod.yaml`, which bakes the application into images, serves both apps +through Caddy with automatic TLS, and runs the Inertia renderer as its own +container. + +Setup for an Ubuntu VPS is documented in +[`infrastructure/production/README.md`](infrastructure/production/README.md); +deploys run through `./infrastructure/production/deploy.sh`. + ## Testing & quality The storefront bundles all checks into one command (run inside its container): diff --git a/admin/.dockerignore b/admin/.dockerignore new file mode 100644 index 00000000..9168d04a --- /dev/null +++ b/admin/.dockerignore @@ -0,0 +1,28 @@ +# Build context for docker/Dockerfile.prod. Anything listed here is rebuilt +# inside the image, so shipping the host's copy would only invalidate layers. +.git +.gitignore +.dockerignore +docker/volumes +node_modules +vendor +public/build +public/hot +public/storage +bootstrap/cache/*.php +storage/framework/cache/data/* +storage/framework/sessions/* +storage/framework/views/* +storage/logs/* +.env +.env.* +!.env.example +tests +.phpunit.cache +.phpunit.result.cache +.idea +.vscode +.fleet +.junie +.ai +.claude diff --git a/admin/.env.production.example b/admin/.env.production.example new file mode 100644 index 00000000..1328505c --- /dev/null +++ b/admin/.env.production.example @@ -0,0 +1,100 @@ +# Runtime environment for the admin containers. Copy to admin/.env.production +# on the VPS and fill in. Never committed — it holds real credentials. +# +# This file is passed to the container as environment variables; there is no +# .env inside the image. The entrypoint runs `config:cache` at start, so every +# change here needs a container restart to take effect. + +APP_NAME=ShopFlow +APP_ENV=production +# php artisan key:generate --show +APP_KEY= +APP_DEBUG=false +APP_URL=https://admin.example.com + +APP_LOCALE=fa +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US +APP_MAINTENANCE_DRIVER=file +BCRYPT_ROUNDS=12 + +# Docker captures stderr, so there is no log file on disk to rotate. +# Both, deliberately: stderr keeps `docker logs` and Docker's own rotation +# working exactly as before, while `shared` writes the rotating file the admin +# panel's log viewer reads. LOG_SHARED_PATH is on a volume both app containers +# mount, one directory per app, because the panel cannot see into another +# container. +LOG_CHANNEL=stack +LOG_STACK=stderr,shared +LOG_SHARED_PATH=/var/log/shopflow/admin/laravel.log +LOG_DAILY_DAYS=14 +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=warning + +# Host names are the compose service names on the internal network. +DB_CONNECTION=pgsql +DB_HOST=db +DB_PORT=5432 +DB_DATABASE=shop_flow +DB_USERNAME=shop_flow +DB_PASSWORD= + +REDIS_CLIENT=phpredis +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD= + +# Redis rather than the database: sessions and cache are the hottest small +# reads in the panel, and Postgres should not be paying for them. +SESSION_DRIVER=redis +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null +# Cookies are only ever sent over the Caddy TLS listener. +SESSION_SECURE_COOKIE=true +SESSION_SAME_SITE=lax + +CACHE_STORE=redis +CACHE_PREFIX= + +# sync until the workers profile is started; then set this to redis. +QUEUE_CONNECTION=sync + +BROADCAST_CONNECTION=log + +# Uploads land in storage/app/public, which is the admin_storage volume and the +# path admin_web serves at /storage. The storefront reads them from there. +FILESYSTEM_DISK=public +FILAMENT_FILESYSTEM_DISK=public + +# --- First admin account --------------------------------------------------- +# Read by AdminSeeder, which creates the account and assigns it the super-admin +# role. Set these before running `db:seed --class=Database\Seeders\AdminSeeder` +# and the defaults in config/admin.php (admin@shopFlow.dev / password) never +# reach production. Use AdminSeeder rather than `make:filament-user`: the panel +# gate is `canAccessPanel()`, which requires a role, and make:filament-user +# assigns none — the user it creates cannot log in. +ADMIN_FIRST_NAME= +ADMIN_LAST_NAME= +ADMIN_EMAIL= +ADMIN_PASSWORD= + +MAIL_MAILER=smtp +MAIL_HOST= +MAIL_PORT=587 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_SCHEME=tls +MAIL_FROM_ADDRESS="noreply@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +# --- Operational dashboards ------------------------------------------------ +# Both are gated to super-admin in AppServiceProvider; they expose slow +# queries, exception messages and full stack traces. +# /pulse — app + server health +# /log-viewer — both apps' logs, one folder each +# The storefront's logs arrive on the shared volume, not in this container. +LOG_VIEWER_SHOP_PATH=/var/log/shopflow/shop +PULSE_ENABLED=true +PULSE_SERVER_NAME=shopflow-prod diff --git a/admin/AGENTS.md b/admin/AGENTS.md index 4a1e2bc4..6d2c0d00 100644 --- a/admin/AGENTS.md +++ b/admin/AGENTS.md @@ -401,146 +401,7 @@ livewire(ListUsers::class) # ShopFlow Admin Conventions -Project-specific patterns. Match these when adding or editing code. All PHP files use `declare(strict_types=1);` and are formatted by Pint (`vendor/bin/pint`). - -## Running commands and tests - -- The app runs in Docker. Execute commands inside the container: `docker exec -it -u www-data shop_flow_admin_app bash`. -- Before committing, run `composer test-dev` (Pest, Pint, type coverage, PHPStan) inside the container and make sure it passes. -- Commit with this author: `Bahman026 ` (use `git commit --author="Bahman026 "`). -- Always ask before committing. NEVER commit without explicit user approval. - -## Implementation order - -When adding a new entity, build the files in this order, matching the existing files: - -1. Migration -2. Model, factory, seeder -3. Filament resource -4. Pest test file - -## Filament (v5) - -- Resources live in `app/Filament/Resources/{Name}Resource.php`. Page classes live in `app/Filament/Resources/{Name}Resource/Pages/`. -- Static properties use the v5 union types: - - `protected static ?string $model = Product::class;` - - `protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-shopping-bag';` -- **Do NOT use the `$navigationGroup` static property.** Override `getNavigationGroup()`, `getModelLabel()`, and `getPluralModelLabel()` as methods that call `trans()` so labels switch with the active locale (see `BrandResource`, `CategoryResource`). -- Forms use the schema signature: `public static function form(Schema $schema): Schema` returning `$schema->components([...])`. Import `Filament\Schemas\Schema`. -- Tables use `public static function table(Table $table): Table` with `->columns([])`, `->filters([])`, `->recordActions([...])`, `->toolbarActions([...])`. -- Actions come from the `Filament\Actions\` namespace (`EditAction`, `CreateAction`, `DeleteAction`, `BulkActionGroup`, `DeleteBulkAction`). -- Import individual components (`Filament\Forms\Components\TextInput`, `Filament\Tables\Columns\TextColumn`), not the parent `Forms`/`Tables` namespaces. -- For reactive `->options()` or `->live()` closures that receive `Get $get`, import `Filament\Schemas\Components\Utilities\Get` (NOT `Filament\Forms\Get` - that will throw a type error at runtime). -- Page classes set `protected static string $resource = {Name}Resource::class;`. List pages expose `CreateAction::make()` in `getHeaderActions()`. Create and Edit pages redirect with `getRedirectUrl(): string` returning `$this->getResource()::getUrl('index')`. -- Rich text uses `AmidEsfahani\FilamentTinyEditor\TinyEditor`. -- Select fields backed by an enum use `->options(SomeEnum::options())` and `->default(SomeEnum::CASE->value)`. -- Table text columns that can be long (headings, relation labels) use `->limit(30)->wrap()`. -- Enum-backed table columns render via `->getStateUsing(fn (ModelName $record): string => $record->field->label())` and `->color(fn (ModelName $record): string => $record->field->color())`. Always type the `$record` parameter and return type to satisfy 100% type coverage. -- Manage many-to-many pivots with a relationship multi-select: `Select::make('products')->relationship('products', 'heading')->multiple()->searchable()->preload()` (see `CouponResource`). No separate resource for pure scoping pivots. -- Manage a `hasMany` of line items inline on the parent's edit page with a Relation Manager in `app/Filament/Resources/{Parent}Resource/RelationManagers/{Children}RelationManager.php` (set `protected static string $relationship = 'childrenMethod';`, define `form()`/`table()` with `headerActions([CreateAction::make()])`), and register it in the parent's `getRelations()`. See `OrderResource` + `OrderVarietiesRelationManager` (an order has many `order_varieties`). The child can still have its own standalone resource for a global list. -- To filter relationship select options by another form field (reactive options): switch from `->relationship()` to `->options(fn (Get $get, ?Model $record): array => [...])`. Always include the current record's value in the options to prevent validation failures on edit: `if ($record?->field_id) { $ids = $ids->push($record->field_id)->unique(); }`. -- To reset a dependent field when its parent changes: add `->afterStateUpdated(fn (Set $set) => $set('dependent_field', null))` to the parent select alongside `->live()`. -- To show options immediately without typing, add `->preload()` to any `->multiple()` relationship select. -- `modifyQueryUsing` for relationship selects is the **3rd parameter** of `->relationship()`, not a chainable method: `->relationship('name', 'title', fn (Builder $q): Builder => $q->with('relation'))`. Calling `->modifyQueryUsing()` as a separate method throws `BadMethodCallException`. -- `->getOptionLabelFromRecordUsing(fn (Model $record): string => ...)` customises the label shown for each option in a relationship select. Pair with eager-loading in the `modifyQueryUsing` closure to avoid N+1. -- Control navigation order within a group with `protected static ?int $navigationSort = 1;` (lower = higher in the list). -- **A model's own `order` column must be paired with `->defaultSort('order')` on its table.** A sortable `order` column alone (e.g. `AncestorResource`, `AttributeGroupResource`) does nothing by default — the list still renders in insertion/id order every time it's opened, silently defeating the whole point of the field. See `FaqResource` for the reference pattern. -- **Never `withPivot()` a column that isn't actually migrated on the pivot table.** `AttributeGroup::categories()`/`Category::attributeGroups()` both declared a `order` pivot column that was never added to `attribute_group_category`, which threw `SQLSTATE[42703]: undefined column` the instant the relation was queried — verify pivot columns against the actual migration, not just intent. -- Add an explanatory subheading to a list page by overriding `mount()` on the `ListRecords` class: set `protected ?string $subheading = null;` and assign `$this->subheading = trans('resource.subheading');` inside `mount()`. Never use a hard-coded string — dynamic assignment is required for locale switching. -- Add a tooltip to a form field with `->hintIcon('heroicon-o-information-circle')->hintIconTooltip('Explanation...')`. Use this instead of always-visible `->hint()` when the text is long. -- Always add `->image()` to `FileUpload` fields that accept images. This restricts the file picker to image types only. -- `mutateRelationshipDataBeforeSaveUsing` (and `BeforeCreateUsing`) MUST return `array`, never `null`. Returning `null` throws a `TypeError` at runtime. To skip saving, delete the related record inside the callback and still return the `$data` array. -- Self-referential FK (e.g. `parent_id`): use `$table->foreignId('parent_id')->nullable()->constrained('table_name')->nullOnDelete()`. In the factory, default `parent_id` to `null` and provide a named state (e.g. `withParent(Model $parent)`) to set it. In the `parent_id` select options closure, exclude the current record to prevent circular references: `->when($record?->id, fn (Builder $q) => $q->where('id', '!=', $record->id))`. -- If a model name clashes with a Filament concept (e.g. `Page`), alias the import in the resource file: `use App\Models\Page as PageModel`. This prevents naming ambiguity without renaming the model. -- Conditionally required fields: pair `->hidden()` and `->required()` with the same closure so the field is only required when visible. Example: `->required(fn (Get $get): bool => $get('status') === SomeEnum::CASE->value)->hidden(fn (Get $get): bool => $get('status') !== SomeEnum::CASE->value)`. -- Auto-generated slug fields use `->disabled()->dehydrated()` with `->unique(Model::class, 'slug', ignoreRecord: true)` to prevent duplicates while keeping the field read-only in the form. -- Secret fields (e.g. gateway `password`): cast `'password' => 'encrypted'` and add it to the model's `$hidden`. In the form use `->password()->revealable()->dehydrated(fn (?string $state): bool => filled($state))` so an empty input on edit keeps the stored value instead of wiping it. See `GatewayResource`. - -## Localisation (fa / en) - -- The admin panel supports Persian (`fa`) and English (`en`). The active locale is stored in the session and applied by `App\Http\Middleware\SetLocale` on every request. -- Admins switch locale via user-menu items ("English" / "فارسی") that hit `GET /locale/{locale}`. -- **Translation files**: one PHP file per resource at `lang/en/{resource}.php` and `lang/fa/{resource}.php`. Keys are flat strings (`label`, `plural_label`, `navigation_group`, `subheading`, field names, hint keys, enum values, …). -- Every resource **must** override `getNavigationGroup()`, `getModelLabel()`, and `getPluralModelLabel()` as methods returning `trans('{resource}.key')`. Do NOT use static properties for these. -- Resources with no navigation group (e.g. `UserResource`) still override `getModelLabel()` and `getPluralModelLabel()`. -- Every form field and table column **must** call `->label(trans('{resource}.key'))`. Never hard-code English labels. -- Use `->hintIconTooltip(trans('...'))` and `->helperText(trans('...'))` for hint and helper strings. -- Enum `label()` methods **must** call `trans()` (e.g. `trans('brand.status_active')`) so dropdown options and table badges switch language automatically. -- Subheadings on list pages use `mount()` (see Filament section above) — not a static string. -- Filament's own UI strings are covered by published vendor translations in `lang/vendor/filament*`. -- **Persian font**: `A Iranian Sans` is loaded via `public/fonts/AIranianSans.ttf` and `public/css/persian-font.css`. The `AdminPanelProvider` registers it with `->assets([Css::make('persian-font', asset('css/persian-font.css'))])` and applies it via a `renderHook` that injects an inline ` diff --git a/admin/resources/views/vendor/pulse/dashboard.blade.php b/admin/resources/views/vendor/pulse/dashboard.blade.php new file mode 100644 index 00000000..6a95bb19 --- /dev/null +++ b/admin/resources/views/vendor/pulse/dashboard.blade.php @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/admin/tests/Feature/CategoryTreeTest.php b/admin/tests/Feature/CategoryTreeTest.php new file mode 100644 index 00000000..c9782d67 --- /dev/null +++ b/admin/tests/Feature/CategoryTreeTest.php @@ -0,0 +1,64 @@ +create(); + Category::factory()->create(['parent_id' => $parent->id]); + + expect(fn () => $parent->delete())->toThrow(QueryException::class); + + expect(Category::query()->whereKey($parent->id)->exists())->toBeTrue(); +}); + +it('allows deleting a leaf category', function () { + $leaf = Category::factory()->create(); + + $leaf->delete(); + + expect(Category::query()->whereKey($leaf->id)->exists())->toBeFalse(); +}); + +it('cannot orphan a child by pointing it at a category that does not exist', function () { + $category = Category::factory()->create(); + + expect(fn () => $category->update(['parent_id' => 999999])) + ->toThrow(QueryException::class); +}); + +it('hides the delete action for a category that still has children or products', function () { + Role::findOrCreate(RolesEnum::SUPER_ADMIN->value); + /** @var User $superAdmin */ + $superAdmin = User::factory()->create(); + $superAdmin->assignRole(RolesEnum::SUPER_ADMIN->value); + + $leaf = Category::factory()->create(); + $withChild = Category::factory()->create(); + Category::factory()->create(['parent_id' => $withChild->id]); + $withProduct = Category::factory()->create(); + Product::factory()->create(['category_id' => $withProduct->id]); + + expect($superAdmin->can('delete', $leaf))->toBeTrue() + ->and($superAdmin->can('delete', $withChild))->toBeFalse() + ->and($superAdmin->can('delete', $withProduct))->toBeFalse(); +}); + +it('still keeps deletion to super-admins', function () { + Role::findOrCreate(RolesEnum::ADMIN->value); + /** @var User $admin */ + $admin = User::factory()->create(); + $admin->assignRole(RolesEnum::ADMIN->value); + + expect($admin->can('delete', Category::factory()->create()))->toBeFalse(); +}); diff --git a/admin/tests/Feature/Filament/ImageAspectTest.php b/admin/tests/Feature/Filament/ImageAspectTest.php new file mode 100644 index 00000000..0608e745 --- /dev/null +++ b/admin/tests/Feature/Filament/ImageAspectTest.php @@ -0,0 +1,143 @@ +aspectRatio())->toMatch('/^\d+:\d+$/') + ->and($position->recommendedSize())->toMatch('/^\d+ × \d+$/'); + } +}); + +it('gives every slider position a usable crop ratio and size', function () { + foreach (SliderPositionEnum::cases() as $position) { + expect($position->aspectRatio())->toMatch('/^\d+:\d+$/') + ->and($position->recommendedSize())->toMatch('/^\d+ × \d+$/'); + } +}); + +it('matches the ratios the storefront components render at', function () { + // Keep these in step with SliderSlot.vue / BannerSlot.vue. + expect(SliderPositionEnum::HOME_MAIN->aspectRatio())->toBe('3:1') // hero + ->and(SliderPositionEnum::HOME_SECONDARY->aspectRatio())->toBe('4:1') // wide + ->and(SliderPositionEnum::CATEGORY_TOP->aspectRatio())->toBe('4:1') // wide + ->and(SliderPositionEnum::PRODUCT_SIDE->aspectRatio())->toBe('4:5') // portrait + ->and(BannerPositionEnum::HOME_TOP->aspectRatio())->toBe('5:1') // wide strip + ->and(BannerPositionEnum::HOME_MIDDLE->aspectRatio())->toBe('16:9') // grid + ->and(BannerPositionEnum::CATEGORY_SIDE->aspectRatio())->toBe('4:5'); // sidebar stack +}); + +it('tells the admin the ratio and size once a banner position is chosen', function () { + $hint = BannerResource::imageHint(BannerPositionEnum::HOME_MIDDLE); + + expect($hint)->toContain('16:9')->toContain('800 × 450') + // A missing translation key would echo the key back. + ->not->toContain('banner.path_hint'); +}); + +it('asks for a position before it can name a banner ratio', function () { + expect(BannerResource::imageHint(null)) + ->not->toBe('') + ->not->toContain('banner.path_hint_no_position'); +}); + +it('reads a slide ratio from the slider it belongs to', function () { + $slider = Slider::factory()->create(['position' => SliderPositionEnum::PRODUCT_SIDE->value]); + + expect(SlideResource::positionOf($slider->id))->toBe(SliderPositionEnum::PRODUCT_SIDE) + ->and(SlideResource::positionOf(null))->toBeNull() + ->and(SlideResource::positionOf(999999))->toBeNull(); + + expect(SlideResource::imageHint(SlideResource::positionOf($slider->id))) + ->toContain('4:5')->toContain('600 × 750'); +}); + +it('renders the banner and slide forms with the image editor enabled', function () { + get(BannerResource::getUrl('create'))->assertOk(); + get(SlideResource::getUrl('create'))->assertOk(); +}); + +// The fixed-shape slots (ImageAspectEnum). Banners and sliders are excluded on +// purpose: their shape comes from the position on the record, tested above. + +it('gives every fixed image slot a usable size and upload ceiling', function () { + foreach (ImageAspectEnum::cases() as $slot) { + expect($slot->recommendedSize())->toMatch('/^\d+ × \d+$/') + ->and($slot->maxSizeKb())->toBeGreaterThan(0); + + // A ratio is either absent by design or well-formed — never a typo. + if ($slot->aspectRatio() !== null) { + expect($slot->aspectRatio())->toMatch('/^\d+:\d+$/'); + } + } +}); + +it('crops the slots the storefront draws in a fixed frame', function () { + // Keep in step with ProductCard.vue / ProductGallery.vue (aspect-square), + // CategoryStrip.vue (a rounded-full object-cover circle) and + // Page/Show.vue (full width with no height frame of its own). + expect(ImageAspectEnum::PRODUCT->aspectRatio())->toBe('1:1') + ->and(ImageAspectEnum::VARIETY->aspectRatio())->toBe('1:1') + ->and(ImageAspectEnum::CATEGORY->aspectRatio())->toBe('1:1') + ->and(ImageAspectEnum::PAGE->aspectRatio())->toBe('16:9') + ->and(ImageAspectEnum::TAG->aspectRatio())->toBe('16:9'); +}); + +it('leaves logos and the payment receipt uncropped', function () { + // Logos are drawn with object-contain, so a wide wordmark is already safe + // and a forced square would cut it. A receipt is evidence: cropping can + // remove the reference number, amount or date. + expect(ImageAspectEnum::BRAND->aspectRatio())->toBeNull() + ->and(ImageAspectEnum::GATEWAY->aspectRatio())->toBeNull() + ->and(ImageAspectEnum::MENU_ITEM->aspectRatio())->toBeNull() + ->and(ImageAspectEnum::RECEIPT->aspectRatio())->toBeNull(); +}); + +it('keeps the product and variety forms on the same square', function () { + // Two upload sites for the same photo — the repeater inside the product + // form and the standalone variety form. They drifted apart before. + expect(ImageAspectEnum::PRODUCT->aspectRatio()) + ->toBe(ImageAspectEnum::VARIETY->aspectRatio()); +}); + +it('tells the admin the ratio and size for a cropped slot', function () { + expect(ImageAspectEnum::CATEGORY->hint()) + ->toContain('1:1')->toContain('600 × 600') + // A missing translation key would echo the key back. + ->not->toContain('system.image_hint'); +}); + +it('tells the admin an uncropped slot is kept whole', function () { + expect(ImageAspectEnum::BRAND->hint()) + ->toContain('400 × 200') + ->not->toContain('system.image_hint_free') + // No ratio is named, because none is enforced. + ->not->toContain(':'); +}); + +it('renders every fixed-slot form with the new upload rules', function () { + get(CategoryResource::getUrl('create'))->assertOk(); + get(BrandResource::getUrl('create'))->assertOk(); + get(PageResource::getUrl('create'))->assertOk(); + get(ReceiptResource::getUrl('create'))->assertOk(); +}); diff --git a/admin/tests/Feature/Filament/PositionGuideTest.php b/admin/tests/Feature/Filament/PositionGuideTest.php new file mode 100644 index 00000000..cf20c1f7 --- /dev/null +++ b/admin/tests/Feature/Filament/PositionGuideTest.php @@ -0,0 +1,154 @@ +label())->not->toBe('') + ->and($position->description())->not->toBe('') + // A missing key makes trans() echo the key back. + ->and($position->description())->not->toContain('banner.position_') + ->and($position->page())->toBeIn(['home', 'category', 'product']); + } +}); + +it('gives every slider position a label, a description and a page', function () { + foreach (SliderPositionEnum::cases() as $position) { + expect($position->label())->not->toBe('') + ->and($position->description())->not->toBe('') + ->and($position->description())->not->toContain('slider.position_') + ->and($position->page())->toBeIn(['home', 'category', 'product']); + } +}); + +it('offers a description for every option the banner form lists', function () { + expect(array_keys(BannerPositionEnum::descriptions())) + ->toBe(array_keys(BannerPositionEnum::options())); +}); + +it('offers a description for every option the slider form lists', function () { + expect(array_keys(SliderPositionEnum::descriptions())) + ->toBe(array_keys(SliderPositionEnum::options())); +}); + +it('renders the layout wireframe on the banner create form', function () { + get(BannerResource::getUrl('create')) + ->assertOk() + ->assertSee('pg__slot', escape: false) + ->assertSee(trans('position_guide.page_home')) + ->assertSee(trans('position_guide.page_category')) + ->assertSee(BannerPositionEnum::HOME_TOP->description()); +}); + +it('renders the layout wireframe on the slider create form', function () { + get(SliderResource::getUrl('create')) + ->assertOk() + ->assertSee('pg__slot', escape: false) + ->assertSee(trans('position_guide.page_product')) + ->assertSee(SliderPositionEnum::PRODUCT_SIDE->description()); +}); + +it('highlights the saved position when editing a banner', function () { + $banner = Banner::factory()->create(['position' => BannerPositionEnum::CATEGORY_SIDE->value]); + + get(BannerResource::getUrl('edit', ['record' => $banner])) + ->assertOk() + // The wrapper attribute drives the highlight ... + ->assertSee('data-selected="category-side"', escape: false) + // ... and the slot it points at has to exist. + ->assertSee('data-slot="category-side"', escape: false); +}); + +it('highlights the saved position when editing a slider', function () { + $slider = Slider::factory()->create(['position' => SliderPositionEnum::PRODUCT_SIDE->value]); + + get(SliderResource::getUrl('edit', ['record' => $slider])) + ->assertOk() + ->assertSee('data-selected="product-side"', escape: false) + ->assertSee('data-slot="product-side"', escape: false); +}); + +it('emits a highlight rule for every position of both kinds', function () { + $html = get(BannerResource::getUrl('create'))->getContent(); + + foreach ([...BannerPositionEnum::cases(), ...SliderPositionEnum::cases()] as $position) { + expect($html) + ->toContain('data-slot="' . $position->value . '"') + ->toContain('.pg[data-selected="' . $position->value . '"]'); + } +}); + +// Filament wraps every schema component in a wire:partial. The guide's own +// state never changes, so without an explicit partial re-render the browser +// keeps showing the stale wireframe even though the server renders the right +// one. Filament throws if the named component cannot be resolved, so simply +// changing the position is enough to catch a rename or a typo here. +it('re-renders the wireframe when the banner position changes', function () { + livewire(CreateBanner::class) + ->set('data.position', BannerPositionEnum::HOME_TOP->value) + ->assertSee('data-selected="home-top"', escape: false); +}); + +it('re-renders the wireframe when the slider position changes', function () { + livewire(CreateSlider::class) + ->set('data.position', SliderPositionEnum::PRODUCT_SIDE->value) + ->assertSee('data-selected="product-side"', escape: false); +}); + +it('keeps the guide in step with the radio without a server round-trip', function () { + // Alpine mirrors the radio into data-selected, so a stale wire:partial can + // never leave the wireframe showing the wrong slot. + get(BannerResource::getUrl('create')) + ->assertOk() + ->assertSee('x-bind:data-selected', escape: false); +}); + +it('never writes the wireframe field to the model', function () { + expect(Banner::factory()->create()->getAttributes())->not->toHaveKey('position_guide') + ->and(Slider::factory()->create()->getAttributes())->not->toHaveKey('position_guide'); +}); + +// Featured tags have no position column: they always land in the same slot on +// the home page. The guide appears once the toggle is on, to say where. + +it('hides the tag layout guide until the homepage toggle is on', function () { + livewire(CreateTag::class) + ->set('data.show_on_home', false) + ->assertDontSee('data-slot="home-tags"', escape: false); +}); + +it('shows the tag layout guide when the homepage toggle is on', function () { + livewire(CreateTag::class) + ->set('data.show_on_home', true) + ->assertSee('data-slot="home-tags"', escape: false) + ->assertSee('data-selected="home-tags"', escape: false); +}); + +it('dims the pages a featured tag never appears on', function () { + $html = livewire(CreateTag::class) + ->set('data.show_on_home', true) + ->html(); + + // Home holds the slot; category and product do not. + expect(substr_count($html, 'pg__card--muted'))->toBeGreaterThan(1); +}); diff --git a/admin/tests/Feature/Filament/Resource/HomeSectionResourceTest.php b/admin/tests/Feature/Filament/Resource/HomeSectionResourceTest.php deleted file mode 100644 index 022de52d..00000000 --- a/admin/tests/Feature/Filament/Resource/HomeSectionResourceTest.php +++ /dev/null @@ -1,78 +0,0 @@ -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/OperationalDashboardsTest.php b/admin/tests/Feature/OperationalDashboardsTest.php new file mode 100644 index 00000000..852bc06e --- /dev/null +++ b/admin/tests/Feature/OperationalDashboardsTest.php @@ -0,0 +1,138 @@ +assertOk(); +}); + +it('lets a super-admin open the log viewer', function (): void { + login(); + + get('/log-viewer')->assertOk(); +}); + +it('keeps pulse away from a plain admin', function (): void { + loginAsAdmin(); + + // Day-to-day staff run the catalogue and orders; server internals and + // exception traces are not part of that job. + get('/pulse')->assertForbidden(); +}); + +it('keeps the log viewer away from a plain admin', function (): void { + loginAsAdmin(); + + get('/log-viewer')->assertForbidden(); +}); + +it('keeps both dashboards away from a storefront customer', function (): void { + Role::findOrCreate(RolesEnum::USER->value); + /** @var User $customer */ + $customer = User::factory()->create(); + $customer->assignRole(RolesEnum::USER->value); + actingAs($customer); + + // The two apps share the `users` table, so a shop customer is a real user + // here — they must reach neither dashboard. + get('/pulse')->assertForbidden(); + get('/log-viewer')->assertForbidden(); +}); + +it('keeps both dashboards away from a guest', function (): void { + get('/pulse')->assertForbidden(); + get('/log-viewer')->assertForbidden(); +}); + +// The storefront runs in its own container. The viewer is configured with one +// folder per app so the two are never mixed, and the storefront path is an env +// value because in production it is a shared volume, not a sibling directory. + +it('shows the admin and storefront logs as two separate folders', function (): void { + $groups = collect(config('log-viewer.include_files'))->values(); + + expect($groups)->toContain('Admin panel') + ->and($groups)->toContain('Storefront'); +}); + +it('points the storefront folder at a directory that exists', function (): void { + $paths = collect(config('log-viewer.include_files'))->keys(); + + $shopGlob = $paths->first(fn (string $path): bool => str_contains($path, 'shop')); + + expect($shopGlob)->not->toBeNull() + ->and(File::isDirectory(dirname((string) $shopGlob)))->toBeTrue( + 'the storefront log directory is not reachable from the admin app' + ); +}); + +it('writes the shared channel where the viewer reads', function (): void { + // LOG_STACK=stderr,shared in production: stderr keeps `docker logs` intact, + // `shared` adds the file the viewer needs. If the channel is missing the + // viewer silently shows nothing. + expect(config('logging.channels.shared'))->not->toBeNull() + ->and(config('logging.channels.shared.driver'))->toBe('daily'); +}); + +// The dashboards are navigation links that open in a new tab, not embedded +// pages: both ship their own full-page layout, which an iframe squeezed into an +// unusable frame. The link must still respect the gate, or the sidebar becomes +// a way around it. + +/** + * @return array + */ +function sidebarLinks(): array +{ + Filament\Facades\Filament::setCurrentPanel(Filament\Facades\Filament::getPanel('admin')); + + return collect(Filament\Facades\Filament::getNavigation()) + ->flatMap(fn ($group) => $group->getItems()) + ->mapWithKeys(fn ($item) => [$item->getLabel() => $item]) + ->all(); +} + +it('shows both dashboard links to a super-admin, opening in a new tab', function (): void { + login(); + + $links = sidebarLinks(); + + foreach ([trans('system.health_label') => 'pulse', trans('system.logs_label') => 'log-viewer'] as $label => $path) { + expect($links)->toHaveKey($label) + ->and($links[$label]->getUrl())->toBe(url($path)) + // Without this the panel is replaced by the dashboard and the only + // way back is the browser's back button. + ->and($links[$label]->shouldOpenUrlInNewTab())->toBeTrue(); + } +}); + +it('hides both dashboard links from a plain admin', function (): void { + loginAsAdmin(); + + $labels = array_keys(sidebarLinks()); + + expect($labels)->not->toContain(trans('system.health_label')) + ->and($labels)->not->toContain(trans('system.logs_label')); +}); + +it('translates both link labels', function (): void { + // A missing key makes trans() echo the key back. + expect(trans('system.health_label'))->not->toContain('system.') + ->and(trans('system.logs_label'))->not->toContain('system.'); +}); diff --git a/admin/tests/Feature/OrderInventoryTest.php b/admin/tests/Feature/OrderInventoryTest.php new file mode 100644 index 00000000..4f2bbc31 --- /dev/null +++ b/admin/tests/Feature/OrderInventoryTest.php @@ -0,0 +1,117 @@ +create(['inventory' => $inventory]); + $order = Order::factory()->create(['status' => $status]); + + OrderVariety::create([ + 'order_id' => $order->id, + 'product_id' => $variety->product_id, + 'variety_id' => $variety->id, + 'quantity' => $quantity, + 'price' => 1000, + 'final_price' => 1000, + ]); + + return [$order->refresh(), $variety]; +} + +it('takes stock when an order becomes paid', function () { + [$order, $variety] = stockOrder(inventory: 10, quantity: 3); + + $order->update(['status' => OrderStatusEnum::PAID]); + + expect($variety->fresh()->inventory)->toBe(7); +}); + +it('gives stock back when a paid order is canceled', function () { + [$order, $variety] = stockOrder(inventory: 10, quantity: 3); + + $order->update(['status' => OrderStatusEnum::PAID]); + $order->update(['status' => OrderStatusEnum::CANCELED]); + + expect($variety->fresh()->inventory)->toBe(10); +}); + +it('gives stock back when a delivered order is returned', function () { + [$order, $variety] = stockOrder(inventory: 10, quantity: 3); + + $order->update(['status' => OrderStatusEnum::PAID]); + $order->update(['status' => OrderStatusEnum::DELIVERED]); + $order->update(['status' => OrderStatusEnum::RETURNED]); + + expect($variety->fresh()->inventory)->toBe(10); +}); + +it('leaves stock alone while an order moves between fulfilment statuses', function () { + [$order, $variety] = stockOrder(inventory: 10, quantity: 3); + + $order->update(['status' => OrderStatusEnum::PAID]); + + // Paid -> processing -> shipped -> delivered all hold the same stock. + foreach ([OrderStatusEnum::PROCESSING, OrderStatusEnum::SHIPPED, OrderStatusEnum::DELIVERED] as $status) { + $order->update(['status' => $status]); + expect($variety->fresh()->inventory)->toBe(7); + } +}); + +it('leaves stock alone when the status does not change', function () { + [$order, $variety] = stockOrder(inventory: 10, quantity: 3); + + $order->update(['status' => OrderStatusEnum::PAID]); + $order->update(['content' => 'a note, not a status change']); + + expect($variety->fresh()->inventory)->toBe(7); +}); + +it('never takes stock twice for the same order', function () { + [$order, $variety] = stockOrder(inventory: 10, quantity: 3); + + $order->update(['status' => OrderStatusEnum::PAID]); + $order->update(['status' => OrderStatusEnum::PAID]); + + expect($variety->fresh()->inventory)->toBe(7); +}); + +it('refuses the transition rather than pushing stock negative', function () { + [$order, $variety] = stockOrder(inventory: 2, quantity: 3); + + expect(fn () => $order->update(['status' => OrderStatusEnum::PAID])) + ->toThrow(InsufficientInventoryException::class); + + expect($variety->fresh()->inventory)->toBe(2); +}); + +it('keeps the order pending when the panel cannot cover the stock', function () { + [$order, $variety] = stockOrder(inventory: 2, quantity: 3); + + livewire(EditOrder::class, ['record' => $order->getRouteKey()]) + ->fillForm(['status' => OrderStatusEnum::PAID->value]) + ->call('save') + ->assertNotified(); + + // Both the status and the stock are untouched — the save rolled back. + expect($order->fresh()->status)->toBe(OrderStatusEnum::PENDING) + ->and($variety->fresh()->inventory)->toBe(2); +}); diff --git a/admin/tests/Feature/ResourcePermissionsTest.php b/admin/tests/Feature/ResourcePermissionsTest.php new file mode 100644 index 00000000..1862ec54 --- /dev/null +++ b/admin/tests/Feature/ResourcePermissionsTest.php @@ -0,0 +1,128 @@ + + */ +function allResourceClasses(): array +{ + return collect(glob(app_path('Filament/Resources/*Resource.php')) ?: []) + ->map(fn (string $path): string => 'App\\Filament\\Resources\\' . basename($path, '.php')) + ->all(); +} + +it('gates every resource behind a permission group', function () { + $ungated = collect(allResourceClasses()) + ->reject(fn (string $class): bool => method_exists($class, 'permissionGroup')) + ->all(); + + expect($ungated)->toBe([], 'Resources with no permission group: ' . implode(', ', $ungated)); +}); + +it('lets a super-admin reach every resource', function () { + login(); + + foreach (allResourceClasses() as $resource) { + expect($resource::canViewAny())->toBeTrue("super-admin cannot view {$resource}"); + } +}); + +it('keeps settings and payment gateways away from a plain admin', function () { + loginAsAdmin(); + + // Day-to-day staff run the catalogue and orders... + expect(ProductResource::canViewAny())->toBeTrue() + ->and(OrderResource::canViewAny())->toBeTrue(); + + // ...but settings and gateway credentials are super-admin territory. + expect(SettingResource::canViewAny())->toBeFalse() + ->and(GatewayResource::canViewAny())->toBeFalse() + ->and(SettingResource::canCreate())->toBeFalse() + ->and(GatewayResource::canCreate())->toBeFalse(); +}); + +it('lets an admin process orders without letting them delete the history', function () { + loginAsAdmin(); + + $order = Order::factory()->create(); + + expect(OrderResource::canEdit($order))->toBeTrue() + ->and(OrderResource::canDelete($order))->toBeFalse() + ->and(OrderResource::canDeleteAny())->toBeFalse(); +}); + +it('denies everything to a panel user holding no permissions', function () { + app(RolePermissionSeeder::class)->run(); + + $user = User::factory()->create(); + $user->assignRole(RolesEnum::USER->value); + actingAs($user); + + foreach (allResourceClasses() as $resource) { + expect($resource::canViewAny())->toBeFalse("{$resource} is reachable with no permissions"); + } +}); + +it('denies everything when nobody is logged in', function () { + Auth::logout(); + + expect(ProductResource::canViewAny())->toBeFalse() + ->and(OrderResource::canViewAny())->toBeFalse(); +}); + +it('still lets a policy tighten what the permission allows', function () { + login(); + + $leaf = Category::factory()->create(); + $withProduct = Category::factory()->create(); + Product::factory()->create(['category_id' => $withProduct->id]); + + // The super-admin holds delete_catalog for both, but CategoryPolicy + // refuses the one that still has products pointing at it. + expect(CategoryResource::canDelete($leaf))->toBeTrue() + ->and(CategoryResource::canDelete($withProduct))->toBeFalse(); +}); + +it('keeps creating panel users to super-admins even with the permission', function () { + loginAsAdmin(); + + // The admin role holds create_customers, but staff accounts stay + // super-admin only. + expect(UserResource::canViewAny())->toBeTrue() + ->and(UserResource::canCreate())->toBeFalse(); + + login(); + expect(UserResource::canCreate())->toBeTrue(); +}); + +it('names every permission the seeder grants', function () { + app(RolePermissionSeeder::class)->run(); + + $expected = PermissionActionEnum::all(); + + expect($expected)->toHaveCount(count(PermissionGroupEnum::cases()) * count(PermissionActionEnum::cases())) + ->and($expected)->toContain('view_orders', 'delete_settings', 'update_catalog'); +}); diff --git a/admin/tests/Pest.php b/admin/tests/Pest.php index 580f5ec9..d89455d9 100644 --- a/admin/tests/Pest.php +++ b/admin/tests/Pest.php @@ -15,6 +15,7 @@ use App\Enums\RolesEnum; use App\Models\User; +use Database\Seeders\RolePermissionSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Spatie\Permission\Models\Role; use Tests\TestCase; @@ -52,10 +53,36 @@ | */ +/** + * A logged-in super-admin. + * + * Roles and permissions come from RolePermissionSeeder rather than being + * hand-rolled, so resource authorization behaves in tests exactly as it does in + * the panel — a resource that a real super-admin could not reach must not be + * reachable here either. + */ function login(?User $user = null): void { $user ??= User::factory()->create(); - Role::create(['name' => RolesEnum::SUPER_ADMIN->value]); + + app(RolePermissionSeeder::class)->run(); + $user->assignRole(RolesEnum::SUPER_ADMIN->value); actingAs($user); } + +/** + * A logged-in admin — the day-to-day staff role, which deliberately cannot + * reach settings, gateways or staff accounts. + */ +function loginAsAdmin(?User $user = null): User +{ + $user ??= User::factory()->create(); + + app(RolePermissionSeeder::class)->run(); + + $user->assignRole(RolesEnum::ADMIN->value); + actingAs($user); + + return $user; +} diff --git a/ai-context/README.md b/ai-context/README.md new file mode 100644 index 00000000..62665255 --- /dev/null +++ b/ai-context/README.md @@ -0,0 +1,107 @@ +# ai-context + +Shared context and configuration for AI coding assistants working on ShopFlow. Everything the +**team** needs lives here; nothing but thin pointers stays in the apps. + +Unlike the HBOX setup this mirrors, this is **not a submodule** — ShopFlow is a single monorepo +with one version line, so there is no second repo to share context with and no per-major branch to +keep separate. A plain directory does the same job with none of the submodule friction. + +## Layout + +| Path | Content | +|---|---| +| `claude/CLAUDE.md` | project instructions, loaded at the start of every session | +| `claude/admin.md` | admin panel conventions — loaded when a file under `admin/` is read | +| `claude/shop.md` | storefront conventions — loaded when a file under `shop/` is read | +| `claude/personal.md` | your own instructions — **gitignored**, see [Personal context](#personal-context) | +| `claude/mcp.json` | MCP servers the team shares (reference copy, not read by the tool) | + +## How the repo points here + +Three tracked one-line files, each importing the file it needs: + +```bash +CLAUDE.md # @ai-context/claude/CLAUDE.md +admin/CLAUDE.md # @../ai-context/claude/admin.md + @AGENTS.md +shop/CLAUDE.md # @../ai-context/claude/shop.md + @AGENTS.md +``` + +Two rules make that work, both worth knowing before editing any of it: + +- **Claude Code reads `CLAUDE.md`, never `AGENTS.md`.** An `AGENTS.md` on its own is dead weight as + far as this tool is concerned — which is why the app conventions used to never reach a session + despite being written down. Other assistants (Codex, Cursor) do read `AGENTS.md`, so the files + stay, and the `CLAUDE.md` next to each imports them. +- **`@` paths resolve relative to the importing file's own directory.** From `admin/CLAUDE.md` the + path is `@../ai-context/...`; `@ai-context/...` there would look for `admin/ai-context/` and + silently find nothing. Max import depth is 4 hops. + +### Why the app conventions are not in the root file + +The root `CLAUDE.md` loads at launch, every session. Files in subdirectories load **on demand** — +only once Claude reads a file in that subtree. Keeping admin and storefront conventions in +`admin/`/`shop/` therefore means a storefront task never pays for 160 lines of Filament conventions, +and an admin task never pays for 187 lines of Inertia/SSR conventions. Folding both into the root +file would load ~350 lines of mostly-irrelevant context into every session, including sessions that +touch neither app. + +Aim for under 200 lines per file: past that, adherence drops. + +## `boost:install` will overwrite `AGENTS.md` + +Both `AGENTS.md` files are generated by `php artisan boost:install` (Laravel Boost). Anything +hand-written in them is lost the next time it runs — which is why the ShopFlow conventions were +moved *out* of them and into `claude/admin.md` / `claude/shop.md`, where Boost cannot reach. + +Keep it that way. If Boost regenerates a file, the only thing to restore is the pointer line at the +top; the conventions themselves are safe here. + +## Personal context + +`claude/personal.md` is **your own file**, one per developer. Write how *you* want the assistant to +work — language, tone, how much explanation you want, your own habits. Only what every colleague +needs belongs in `claude/CLAUDE.md`; preferences do not. + +It is gitignored, so writing yours cannot affect anybody else and a fresh clone has none. Nothing +breaks without it. + +Unlike the other files here it is not a path the tool knows about. It is read by a +`UserPromptSubmit` hook in **`.claude/settings.json`**, which *is* committed — so the wiring ships +with the repo and nobody has to configure anything. Write the file and it is picked up from your +next prompt on. + +Two details keep that hook safe to ship: `$CLAUDE_PROJECT_DIR` instead of an absolute path, so it +resolves in every clone, and the trailing `; true`, so a colleague with no `personal.md` gets +silence rather than a failing hook on every prompt. + +Keep `settings.json` itself impersonal — it is committed. Personal *settings* go in +`.claude/settings.local.json`, which stays out of git. + +| Tier | Path | In git? | +|---|---|---| +| Team, whole repo | `claude/CLAUDE.md` | committed | +| Team, one app | `claude/admin.md`, `claude/shop.md` | committed | +| Personal, ShopFlow | `claude/personal.md` | ignored | +| Personal, every repo | `~/.claude/CLAUDE.md` | outside any repo | +| Personal, this project | `~/.claude/projects//memory/` | outside any repo | + +## MCP servers + +`claude/mcp.json` is a reference copy of the servers the team agrees on — Claude Code does **not** +read it. Register them once per person at user scope, which lands in `~/.claude.json`, survives +fresh clones, and keeps no token in the tree: + +```bash +claude mcp add -s user --transport http cloudflare https://mcp.cloudflare.com/mcp +``` + +The repo's own `.mcp.json` holds only `laravel-boost`, which is project-scoped by nature (it shells +into the dev container). + +## Keeping it current + +When a task turns up something that would have saved the session time — a trap, a decision and the +reason behind it — write it down before finishing: repo-wide knowledge into `claude/CLAUDE.md`, +app-specific into `claude/admin.md` or `claude/shop.md`. Leave out anything the code, `git log` or +`docs/` already answers, and keep it short: every line here is loaded into a session. diff --git a/ai-context/claude/CLAUDE.md b/ai-context/claude/CLAUDE.md new file mode 100644 index 00000000..43c81380 --- /dev/null +++ b/ai-context/claude/CLAUDE.md @@ -0,0 +1,176 @@ +# ShopFlow — project context for Claude + +## What this is + +ShopFlow is an open-source (MIT), **single-vendor** e-commerce platform for a Persian, RTL online +store — one business selling its own products. There is no marketplace, no sellers, no +seller-scoped anything: never add `seller_id` or a seller relation to a model, migration or +resource. + +A monorepo of two Laravel 13 / PHP 8.5 apps sharing **one** PostgreSQL database: + +| Path | App | Role | +|---|---|---| +| `admin/` | Laravel + Filament 5 | management panel. **Owns the database schema** — every migration lives here | +| `shop/` | Laravel + Inertia + Vue 3 (SSR) | customer-facing storefront. Mostly *reads* the catalog | + +This file lives in `ai-context/claude/`, not at the repo root; `CLAUDE.md` at the root is a one-line +import of it. Per-app conventions are in `ai-context/claude/admin.md` and `ai-context/claude/shop.md`, +imported by `admin/CLAUDE.md` and `shop/CLAUDE.md` so they load only when that app is being worked +on. See `ai-context/README.md` for how the wiring works and why. + +## The one rule that spans both apps + +**`admin` owns the schema; `shop` never migrates it.** The storefront adds read-focused Eloquent +models mapping to the same tables. A schema change is an `admin/` migration, then the shop model is +updated to match — never the other way round, and never a duplicate table. + +`admin/docs/ShoFlow db doc.md` (copied to `shop/docs/`) is the source of truth for columns and +relationships. Read it before assuming a column exists or is non-null; a column the primary flow +always sets can still be nullable at the DB level (`users.mobile` is, and has thrown a `TypeError` +for exactly that reason). + +**Enums are mirrored, not shared.** Every enum in `shop/app/Enums` has a twin in `admin/app/Enums` +with the same case names and backing values — the values are what the two apps agree on through the +database, so changing one side alone silently breaks the other. `shop/tests/Feature/EnumMirrorTest.php` +fails the build on any mismatch. Change both in the same commit. + +## Read the docs first + +Before starting a task, read the ones relevant to it. They are duplicated in `admin/docs/` and +`shop/docs/` where both apps care: + +| Doc | What it settles | +|---|---| +| `ShoFlow db doc.md` | schema reference, source of truth | +| `ORDER.md` | orders + inventory rules. **Stock is decremented only on successful payment** (Strategy A); carts never touch inventory | +| `CACHE.md` | cache keys identified; nothing is cached yet | +| `admin/docs/IMPLEMENTATION.md` | admin build status | +| `shop/docs/STOREFRONT_IMPLEMENTATION.md` | storefront roadmap — update it as features land | +| `shop/docs/TAGS.md`, `BANNERS_SLIDERS.md`, `VARIETY_GUIDE.md`, `SHIPPING_GUIDE.md` | the tricky domains | + +## Local development + +Both apps run in Docker. One `docker compose up -d --build` from the repo root brings up all six +containers (the root `compose.yaml` merges the three app compose files): + +| Container | Role | Host port | +|---|---|---| +| `shop_flow_db` | PostgreSQL 16 | `127.0.0.1:5432` | +| `shop_flow_redis` | Redis | `127.0.0.1:6379` | +| `shop_flow_admin_app` / `_nginx` | admin | `127.0.0.1:4040` | +| `shop_flow_shop_app` / `_nginx` | storefront | `127.0.0.1:8080` | + +Run everything inside the container as the web user: + +```bash +docker exec -it -u www-data shop_flow_admin_app bash +docker exec -u www-data shop_flow_admin_app php artisan migrate +``` + +Each compose file reads its own `.env` (`infrastructure/docker/`, `admin/docker/`, `shop/docker/`) — +copy from `.env.example` and set `USER_ID`/`GROUP_ID` to your own. + +**Trap: the container's CLI `memory_limit` is 128 MB**, which is not enough for the admin app's full +Pest run — it dies mid-suite with `Allowed memory size exhausted` inside a Filament view. That is not +a broken test. Run the full suite with an override: + +```bash +docker exec -u www-data shop_flow_admin_app php -d memory_limit=1024M vendor/bin/pest +``` + +The storefront's `--type-coverage` run hits the same ceiling (it loads PHPStan), +and PHPStan there also needs a writable cache dir as `www-data`: + +```bash +docker exec -u www-data -e TMPDIR=/tmp/phpstan-www shop_flow_shop_app \ + bash -lc 'mkdir -p /tmp/phpstan-www && php -d memory_limit=1024M vendor/bin/pest --type-coverage --min=100' +``` + +## Testing + +Use Pest directly, never `php artisan test`. + +```bash +# admin — Pest, Pint, type coverage, PHPStan +docker exec -u www-data shop_flow_admin_app composer test-dev +# storefront — the same four plus ESLint and Prettier +docker exec -u www-data shop_flow_shop_app composer test-dev +``` + +Both must pass before finishing. 100% type coverage is required in `shop/`; PHPStan runs at level 5; +Pint formats every PHP file (`vendor/bin/pint --dirty --format agent` after editing PHP). + +**The storefront tests run against a real Postgres database (`shop_flow_test`) whose schema is built +by `admin`'s migrations**, not sqlite and not the shop's own — the shop does not own those tables, and +sqlite would hide schema drift. One-time setup, from the admin container: + +```bash +DB_DATABASE=shop_flow_test php artisan migrate --force +DB_DATABASE=shop_flow_test php artisan db:seed --class="Database\Seeders\SettingSeeder" --force +``` + +After a new admin migration, re-run the first line against `shop_flow_test` too, or the storefront +suite fails against a stale schema. + +## Production + +`compose.yaml` is development only. Production is `compose.prod.yaml` — images with the app baked in, +Caddy as the only container with published ports. **Read +`infrastructure/production/README.md` before touching any of it**; the deployment does not work the +way the stock recipe implies: + +- The server cannot reach Docker Hub, `deb.debian.org`, packagist, the npm registry or Let's + Encrypt's ACME API, so images **cannot be built on it**. They are cross-built for `linux/amd64` + elsewhere and shipped with `infrastructure/production/ship-images.sh`, then deployed with + `deploy-prebuilt.sh`. A registry mirror does not fix this — the PHP base stage still needs Debian apt. +- TLS is a certificate obtained off-box via a DNS-01 challenge and mounted into Caddy; automatic + HTTPS is switched off. See `infrastructure/production/certs/README.md` for renewal. +- The first admin user is created by `AdminSeeder` (which assigns the `super-admin` role), **not** + `make:filament-user` — the panel gate `canAccessPanel()` requires a role, and that command assigns + none, so the account it creates cannot log in. + +## Two traps that cost time + +**The `deploy` user's login shell is fish, not bash.** A bash loop or `&&` chain +sent as an `ssh user@host '...'` argument is a syntax error there — it fails +*after* the earlier steps of a script have already run, which is the worst +moment. Always pipe remote scripts through bash explicitly: + +```bash +ssh -p 9011 deploy@87.107.104.19 'bash -s' <<'EOF' +... +EOF +``` + +**Filament caches the resources and pages it discovered** in +`bootstrap/cache/filament`. While that cache exists a newly added resource or +page is invisible in the sidebar no matter how correct the code is — and tests +still pass, because the test process builds its own container without it. If +something new does not appear in the panel, clear it before doubting the code: + +```bash +docker exec -u www-data shop_flow_admin_app php artisan filament:optimize-clear +``` + +The production entrypoint runs `filament:optimize`, which rebuilds this cache on +every container start — correct there, and not a problem, since a new image +starts with a fresh one. + +## Commits + +- **Never commit or push without being asked in that message.** Finishing work is not permission. +- Author: `Bahman026 `. +- **Conventional Commits** (`feat`, `fix`, `chore`, `build`, `refactor`, `docs`, `style`, `test`), + scoped `admin`/`shop`/`infra` where it helps. +- One logical change per commit; do not bundle unrelated changes. Check `git diff --cached` before + committing — `git commit` commits the whole index, not just what you last `git add`ed. +- Documentation duplicated across both apps (`ORDER.md`, `ShoFlow db doc.md`) is updated in both + copies in the same commit. + +## Keeping this current + +When a task turns up something that would have saved time — a trap, an upstream behaviour, a decision +and its reason — write it down before finishing: repo-wide here, app-specific in +`ai-context/claude/admin.md` or `shop.md`. Leave out what the code, `git log` or `docs/` already +answers; every line here is loaded into a session. diff --git a/ai-context/claude/admin.md b/ai-context/claude/admin.md new file mode 100644 index 00000000..60d2eba8 --- /dev/null +++ b/ai-context/claude/admin.md @@ -0,0 +1,184 @@ +# ShopFlow Admin Conventions + +Project-specific patterns. Match these when adding or editing code. All PHP files use `declare(strict_types=1);` and are formatted by Pint (`vendor/bin/pint`). + +## Running commands and tests + +Commit rules, the shared database and the Docker basics are in `ai-context/claude/CLAUDE.md`. + +- The app runs in Docker. Execute commands inside the container: `docker exec -it -u www-data shop_flow_admin_app bash`. +- Before committing, run `composer test-dev` (Pest, Pint, type coverage, PHPStan) inside the container and make sure it passes. + +## Authorization + +- Every Filament resource is gated by `App\Traits\AuthorizesWithPermissions` and declares a + `PermissionGroupEnum` (catalog / content / orders / customers / shipping / marketing / settings). + Permissions are `{view,create,update,delete}_{group}`, seeded by `RolePermissionSeeder`. +- **A new resource must declare `permissionGroup()`** — `ResourcePermissionsTest` fails the build otherwise, + because an ungated resource would be reachable by anyone who can open the panel. +- Where a model has a policy it still applies *on top of* the permission, but only for the abilities the + policy actually implements (Laravel denies any ability a policy omits, which would otherwise forbid + viewing categories just because `CategoryPolicy` defines only `delete`). +- `super-admin` holds everything. `admin` runs catalogue/content/promotions fully, and processes orders, + shipping and customers without delete. Settings, gateways and staff accounts are super-admin only. +- `User::canAccessPanel()` keeps storefront customers out of the panel entirely — the two apps share the + `users` table. + +## Operational dashboards (Pulse + log viewer) + +- **`/pulse`** (Laravel Pulse) and **`/log-viewer`** (opcodesio/log-viewer) live in this app, both + gated to `super-admin` by `viewPulse` / `viewLogViewer` in `AppServiceProvider`. Neither is a + Filament resource, so `AuthorizesWithPermissions` does not reach them — those two gates are the + only thing in front of them, and both dashboards show slow queries, exception messages and full + stack traces. `OperationalDashboardsTest` fails the build if either opens up. +- **Pulse records from both apps into the same `pulse_*` tables**, because the two apps share one + database. The storefront has the package too, but `config/pulse.php` there sets `'path' => null` + so no `/pulse` route is registered on a public site. Migrations live here only — admin owns the + schema, as always. +- The **`pulse` service** in `compose.prod.yaml` runs `pulse:check`, which is what fills the Servers + card (CPU/memory/disk); the other cards are recorded by the apps as they serve requests. + `pulse:trim` prunes old samples and runs from the scheduler, which sits behind the `workers` + profile — until that profile is started the `pulse_*` tables only grow. +- **Logging is `stack` = `stderr,shared` in production.** `stderr` keeps `docker logs` and Docker's + rotation working exactly as before; `shared` (a `daily` channel at `LOG_SHARED_PATH`) writes the + file the viewer reads. The two apps write into one **named volume** (`applogs` at + `/var/log/shopflow`), each in its own subdirectory, because the panel cannot see into another + container. The viewer shows them as two folders, `Admin panel` and `Storefront`. +- **Both Dockerfiles create `/var/log/shopflow` owned by `www-data`.** Docker initialises a fresh + named volume from the image, ownership included; left to Docker the volume is `root:root` and + php-fpm silently writes no logs at all. + +## Implementation order + +When adding a new entity, build the files in this order, matching the existing files: + +1. Migration +2. Model, factory, seeder +3. Filament resource +4. Pest test file + +## Filament (v5) + +- Resources live in `app/Filament/Resources/{Name}Resource.php`. Page classes live in `app/Filament/Resources/{Name}Resource/Pages/`. +- Static properties use the v5 union types: + - `protected static ?string $model = Product::class;` + - `protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-shopping-bag';` +- **Do NOT use the `$navigationGroup` static property.** Override `getNavigationGroup()`, `getModelLabel()`, and `getPluralModelLabel()` as methods that call `trans()` so labels switch with the active locale (see `BrandResource`, `CategoryResource`). +- Forms use the schema signature: `public static function form(Schema $schema): Schema` returning `$schema->components([...])`. Import `Filament\Schemas\Schema`. +- Tables use `public static function table(Table $table): Table` with `->columns([])`, `->filters([])`, `->recordActions([...])`, `->toolbarActions([...])`. +- Actions come from the `Filament\Actions\` namespace (`EditAction`, `CreateAction`, `DeleteAction`, `BulkActionGroup`, `DeleteBulkAction`). +- Import individual components (`Filament\Forms\Components\TextInput`, `Filament\Tables\Columns\TextColumn`), not the parent `Forms`/`Tables` namespaces. +- For reactive `->options()` or `->live()` closures that receive `Get $get`, import `Filament\Schemas\Components\Utilities\Get` (NOT `Filament\Forms\Get` - that will throw a type error at runtime). +- Page classes set `protected static string $resource = {Name}Resource::class;`. List pages expose `CreateAction::make()` in `getHeaderActions()`. Create and Edit pages redirect with `getRedirectUrl(): string` returning `$this->getResource()::getUrl('index')`. +- Rich text uses `AmidEsfahani\FilamentTinyEditor\TinyEditor`. +- Select fields backed by an enum use `->options(SomeEnum::options())` and `->default(SomeEnum::CASE->value)`. +- Table text columns that can be long (headings, relation labels) use `->limit(30)->wrap()`. +- Enum-backed table columns render via `->getStateUsing(fn (ModelName $record): string => $record->field->label())` and `->color(fn (ModelName $record): string => $record->field->color())`. Always type the `$record` parameter and return type to satisfy 100% type coverage. +- Manage many-to-many pivots with a relationship multi-select: `Select::make('products')->relationship('products', 'heading')->multiple()->searchable()->preload()` (see `CouponResource`). No separate resource for pure scoping pivots. +- Manage a `hasMany` of line items inline on the parent's edit page with a Relation Manager in `app/Filament/Resources/{Parent}Resource/RelationManagers/{Children}RelationManager.php` (set `protected static string $relationship = 'childrenMethod';`, define `form()`/`table()` with `headerActions([CreateAction::make()])`), and register it in the parent's `getRelations()`. See `OrderResource` + `OrderVarietiesRelationManager` (an order has many `order_varieties`). The child can still have its own standalone resource for a global list. +- To filter relationship select options by another form field (reactive options): switch from `->relationship()` to `->options(fn (Get $get, ?Model $record): array => [...])`. Always include the current record's value in the options to prevent validation failures on edit: `if ($record?->field_id) { $ids = $ids->push($record->field_id)->unique(); }`. +- To reset a dependent field when its parent changes: add `->afterStateUpdated(fn (Set $set) => $set('dependent_field', null))` to the parent select alongside `->live()`. +- To show options immediately without typing, add `->preload()` to any `->multiple()` relationship select. +- `modifyQueryUsing` for relationship selects is the **3rd parameter** of `->relationship()`, not a chainable method: `->relationship('name', 'title', fn (Builder $q): Builder => $q->with('relation'))`. Calling `->modifyQueryUsing()` as a separate method throws `BadMethodCallException`. +- `->getOptionLabelFromRecordUsing(fn (Model $record): string => ...)` customises the label shown for each option in a relationship select. Pair with eager-loading in the `modifyQueryUsing` closure to avoid N+1. +- Control navigation order within a group with `protected static ?int $navigationSort = 1;` (lower = higher in the list). +- **A model's own `order` column must be paired with `->defaultSort('order')` on its table.** A sortable `order` column alone (e.g. `AncestorResource`, `AttributeGroupResource`) does nothing by default — the list still renders in insertion/id order every time it's opened, silently defeating the whole point of the field. See `FaqResource` for the reference pattern. +- **Never `withPivot()` a column that isn't actually migrated on the pivot table.** `AttributeGroup::categories()`/`Category::attributeGroups()` both declared a `order` pivot column that was never added to `attribute_group_category`, which threw `SQLSTATE[42703]: undefined column` the instant the relation was queried — verify pivot columns against the actual migration, not just intent. +- Add an explanatory subheading to a list page by overriding `mount()` on the `ListRecords` class: set `protected ?string $subheading = null;` and assign `$this->subheading = trans('resource.subheading');` inside `mount()`. Never use a hard-coded string — dynamic assignment is required for locale switching. +- Add a tooltip to a form field with `->hintIcon('heroicon-o-information-circle')->hintIconTooltip('Explanation...')`. Use this instead of always-visible `->hint()` when the text is long. +- Always add `->image()` to `FileUpload` fields that accept images. This restricts the file picker to image types only. +- `mutateRelationshipDataBeforeSaveUsing` (and `BeforeCreateUsing`) MUST return `array`, never `null`. Returning `null` throws a `TypeError` at runtime. To skip saving, delete the related record inside the callback and still return the `$data` array. +- Self-referential FK (e.g. `parent_id`): use `$table->foreignId('parent_id')->nullable()->constrained('table_name')->nullOnDelete()`. In the factory, default `parent_id` to `null` and provide a named state (e.g. `withParent(Model $parent)`) to set it. In the `parent_id` select options closure, exclude the current record to prevent circular references: `->when($record?->id, fn (Builder $q) => $q->where('id', '!=', $record->id))`. +- If a model name clashes with a Filament concept (e.g. `Page`), alias the import in the resource file: `use App\Models\Page as PageModel`. This prevents naming ambiguity without renaming the model. +- Conditionally required fields: pair `->hidden()` and `->required()` with the same closure so the field is only required when visible. Example: `->required(fn (Get $get): bool => $get('status') === SomeEnum::CASE->value)->hidden(fn (Get $get): bool => $get('status') !== SomeEnum::CASE->value)`. +- Auto-generated slug fields use `->disabled()->dehydrated()` with `->unique(Model::class, 'slug', ignoreRecord: true)` to prevent duplicates while keeping the field read-only in the form. +- Secret fields (e.g. gateway `password`): cast `'password' => 'encrypted'` and add it to the model's `$hidden`. In the form use `->password()->revealable()->dehydrated(fn (?string $state): bool => filled($state))` so an empty input on edit keeps the stored value instead of wiping it. See `GatewayResource`. + +## Localisation (fa / en) + +- The admin panel supports Persian (`fa`) and English (`en`). The active locale is stored in the session and applied by `App\Http\Middleware\SetLocale` on every request. +- Admins switch locale via user-menu items ("English" / "فارسی") that hit `GET /locale/{locale}`. +- **Translation files**: one PHP file per resource at `lang/en/{resource}.php` and `lang/fa/{resource}.php`. Keys are flat strings (`label`, `plural_label`, `navigation_group`, `subheading`, field names, hint keys, enum values, …). +- Every resource **must** override `getNavigationGroup()`, `getModelLabel()`, and `getPluralModelLabel()` as methods returning `trans('{resource}.key')`. Do NOT use static properties for these. +- Resources with no navigation group (e.g. `UserResource`) still override `getModelLabel()` and `getPluralModelLabel()`. +- Every form field and table column **must** call `->label(trans('{resource}.key'))`. Never hard-code English labels. +- Use `->hintIconTooltip(trans('...'))` and `->helperText(trans('...'))` for hint and helper strings. +- Enum `label()` methods **must** call `trans()` (e.g. `trans('brand.status_active')`) so dropdown options and table badges switch language automatically. +- Subheadings on list pages use `mount()` (see Filament section above) — not a static string. +- Filament's own UI strings are covered by published vendor translations in `lang/vendor/filament*`. +- **Persian font**: `A Iranian Sans` is loaded via `public/fonts/AIranianSans.ttf` and `public/css/persian-font.css`. The `AdminPanelProvider` registers it with `->assets([Css::make('persian-font', asset('css/persian-font.css'))])` and applies it via a `renderHook` that injects an inline `