diff --git a/.github/workflows/deploy-application.yml b/.github/workflows/deploy-application.yml index cf4c3283..986bc6b7 100644 --- a/.github/workflows/deploy-application.yml +++ b/.github/workflows/deploy-application.yml @@ -53,6 +53,33 @@ jobs: create-deployment-artifact-shop: name: Create Deployment Artifact (Shop) runs-on: ubuntu-latest + + # The shop reads the admin-owned database, so tests need that schema. + # We stand up Postgres, build the schema with admin's migrations, then run + # the shop test suite against it. + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: shop_flow_test + POSTGRES_USER: shop_flow + POSTGRES_PASSWORD: password + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U shop_flow" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + DB_CONNECTION: pgsql + DB_HOST: 127.0.0.1 + DB_PORT: 5432 + DB_DATABASE: shop_flow_test + DB_USERNAME: shop_flow + DB_PASSWORD: password + steps: - uses: actions/checkout@v2 @@ -61,6 +88,16 @@ jobs: with: php-version: 8.5 tools: composer + extensions: pdo_pgsql, pgsql + + - name: Build shared schema (admin migrations) + working-directory: ./admin + run: | + composer install --no-interaction --prefer-dist --optimize-autoloader + cp .env.example .env + php artisan key:generate + php artisan migrate --force + php artisan db:seed --class="Database\\Seeders\\SettingSeeder" --force - name: Install PHP Dependencies working-directory: ./shop @@ -73,6 +110,12 @@ jobs: npm ci npm run build + - name: Run Frontend Lint & Format Check + working-directory: ./shop + run: | + npm run lint + npm run format:check + - name: Setup Environment working-directory: ./shop run: | diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..d4254b47 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,19 @@ +{ + "mcpServers": { + "laravel-boost": { + "type": "stdio", + "command": "docker", + "args": [ + "exec", + "-i", + "-u", + "www-data", + "shop_flow_admin_app", + "php", + "artisan", + "boost:mcp" + ], + "env": {} + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index 7e00496c..d015e532 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,117 @@ -### Documant of the database: -https://docs.google.com/document/d/e/2PACX-1vTqah2hdQeeiu3Le07zfOfp5vK-ojLwJtQmzbgdoq_wmJu-0dBdTcFsS0uSiUtYpSglEwMD5xSFIiG5/pub -### Setting Up and Running the Project in Docker: +# ShopFlow +ShopFlow is an **open-source, single-vendor e-commerce platform** (MIT licensed) for building a Persian, right-to-left online store. It is designed for one business selling its own products (no marketplace/seller system), with a strong focus on SEO and a server-rendered storefront so pages are fast and indexable. +It is built as a monorepo of two Laravel apps that share one PostgreSQL database: -**In the `infrastructure/docker` directory, create a `.env` file and fill in the values according to `.env.example`. Then run the following command to create the Postgres and Redis containers:** - ```bash - sudo docker compose up -d --build +- **`admin/`** — the management panel (Laravel 13 + Filament 5, PHP 8.5). It **owns the database schema** (all migrations live here) and is where the team manages catalog, orders, content, and settings. +- **`shop/`** — the customer-facing storefront (Laravel 13 + Inertia + Vue 3 with SSR, PHP 8.5). It mostly **reads** catalog/pricing data and writes carts, orders, addresses, and payments. The UI is Persian, RTL-first. +The two apps never duplicate tables: `admin` migrates the shared schema, and `shop` adds read-focused Eloquent models that map to the same tables. -**note:** Make sure to configure the database settings in the .env files for both Admin and API projects as follows, if you haven't changed the host and port in the Docker Compose files: - ```bash - DB_CONNECTION=pgsql - DB_HOST=db - DB_PORT=5432 +## What ShopFlow provides + +The platform models a full online-store domain: + +- **Catalog** — hierarchical categories, products with purchasable varieties (e.g. size/color), brands, attributes and attribute groups, and images. +- **Discovery** — faceted category filtering (brand, attribute, price, availability), sorting, pagination, and attribute-based SEO landing pages (tags). +- **Pricing & promotions** — per-variety pricing, sale prices, discounts and coupons. +- **Cart & checkout** — carts, orders with line snapshots, per-city shipping methods, and inventory that is only decremented on a successful payment. +- **Payments** — manual receipts (card-to-card / Paya) and online gateways (Mellat / Parsian / Zarinpal) via transactions. +- **Customers** — accounts, addresses kept as immutable history, wishlists, product reviews, points, and newsletters. +- **SEO** — SSR HTML, unique titles/meta, canonical URLs, Open Graph, JSON-LD structured data, sitemap, and redirects. + +The admin panel covers the full schema today. The storefront is built feature by feature against `shop/docs/STOREFRONT_IMPLEMENTATION.md`; see that roadmap for current status. + +## Repository structure + +``` +ShopFlow/ +├── admin/ # Filament admin panel (owns the DB schema) +├── shop/ # Inertia + Vue storefront (SSR) +├── infrastructure/ +│ └── docker/ # Shared Postgres + Redis (docker compose) +├── .github/workflows/ # CI (deploy-application.yml) +└── README.md +``` + +## Tech stack + +- PHP 8.5, Laravel 13 +- Admin: Filament 5 +- Storefront: Inertia.js 3 + Vue 3 (SSR), Tailwind CSS v4, FontAwesome +- PostgreSQL (shared), Redis +- Quality: Pest, Pint, PHPStan (level 5), 100% type coverage, ESLint + Prettier + +## Database + +`admin` is the single source of truth for the schema. The full table reference lives in: + +- In-repo: `admin/docs/ShoFlow db doc.md` (and a copy under `shop/docs/`) +- Online: https://docs.google.com/document/d/e/2PACX-1vTqah2hdQeeiu3Le07zfOfp5vK-ojLwJtQmzbgdoq_wmJu-0dBdTcFsS0uSiUtYpSglEwMD5xSFIiG5/pub + +Run migrations and seeders from `admin/` only. The storefront must not migrate these tables. + +## Getting started + +### 1. Shared services (Postgres + Redis) + +In `infrastructure/docker`, create a `.env` from `.env.example`, then start the containers: + +```bash +cd infrastructure/docker +sudo docker compose up -d --build +``` + +### 2. Configure each app + +In both `admin/.env` and `shop/.env`, point the database at the shared Postgres (matching the values from `infrastructure/docker/.env`): + +```bash +DB_CONNECTION=pgsql +DB_HOST=db +DB_PORT=5432 +# DB_DATABASE / DB_USERNAME / DB_PASSWORD must match infrastructure/docker/.env +``` + +### 3. Admin (schema owner — set up first) + +```bash +cd admin +composer install +php artisan key:generate +php artisan migrate --seed +npm install && npm run build +``` + +### 4. Storefront + +```bash +cd shop +composer install +php artisan key:generate +npm install && npm run build ``` -Fill in the values for DB_DATABASE, DB_USERNAME, and DB_PASSWORD according to the .env file in the infrastructure directory. +For app-specific details (Docker containers, SSR, conventions), see each app's own `README.md`, `AGENTS.md`, and `docs/`. + +## Testing & quality + +The storefront bundles all checks into one command (run inside its container): + +```bash +cd shop +composer test-dev # Pest, Pint, Pest type-coverage (--min=100), PHPStan, ESLint, Prettier +``` + +CI runs the same checks via `.github/workflows/deploy-application.yml`. + +## Documentation + +- `shop/docs/STOREFRONT_IMPLEMENTATION.md` — storefront roadmap and status +- `admin/docs/` and `shop/docs/` — schema, variety guide, orders/inventory, cache keys, tags +- `shop/AGENTS.md` / `admin/AGENTS.md` — conventions for contributors and AI agents + +## License + +Open-sourced under the [MIT license](LICENSE). diff --git a/admin/AGENTS.md b/admin/AGENTS.md index 80075b94..6fbc10ff 100644 --- a/admin/AGENTS.md +++ b/admin/AGENTS.md @@ -113,7 +113,7 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac # Test Enforcement - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. -- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. +- Run the minimum number of tests needed to ensure code quality and speed. Use Pest directly (not `php artisan test`): `vendor/bin/pest` with a specific filename or `--filter`. === laravel/core rules === @@ -158,7 +158,7 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac - This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`. - The `{name}` argument should not include the test suite directory. Use `php artisan make:test --pest SomeFeatureTest` instead of `php artisan make:test --pest Feature/SomeFeatureTest`. -- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`. +- Run tests with Pest directly (not `php artisan test`): `vendor/bin/pest` or filter: `vendor/bin/pest --filter=testName`. - Do NOT delete tests without approval. === filament/filament rules === @@ -444,6 +444,8 @@ When adding a new entity, build the files in this order, matching the existing f - `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. @@ -511,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. +- **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')`). ## Pest tests diff --git a/admin/app/Filament/Resources/AncestorResource.php b/admin/app/Filament/Resources/AncestorResource.php index ded749c3..600db0dd 100644 --- a/admin/app/Filament/Resources/AncestorResource.php +++ b/admin/app/Filament/Resources/AncestorResource.php @@ -57,6 +57,7 @@ public static function form(Schema $schema): Schema public static function table(Table $table): Table { return $table + ->defaultSort('order') ->columns([ TextColumn::make('name') ->label(trans('ancestor.name')) diff --git a/admin/app/Filament/Resources/AttributeGroupResource.php b/admin/app/Filament/Resources/AttributeGroupResource.php index 9e10fb9a..9caf3504 100644 --- a/admin/app/Filament/Resources/AttributeGroupResource.php +++ b/admin/app/Filament/Resources/AttributeGroupResource.php @@ -69,6 +69,7 @@ public static function form(Schema $schema): Schema public static function table(Table $table): Table { return $table + ->defaultSort('order') ->columns([ TextColumn::make('ancestor.name') ->label(trans('attribute_group.ancestor')) diff --git a/admin/app/Filament/Resources/OrderResource.php b/admin/app/Filament/Resources/OrderResource.php index ff4e432c..b0362050 100644 --- a/admin/app/Filament/Resources/OrderResource.php +++ b/admin/app/Filament/Resources/OrderResource.php @@ -118,6 +118,12 @@ public static function form(Schema $schema): Schema ]), Fieldset::make(trans('order.section_shipping')) ->schema([ + Select::make('address_id') + ->label(trans('order.address_id')) + ->relationship('address', 'address') + ->searchable() + ->preload() + ->native(false), Select::make('shipping_line_id') ->label(trans('order.shipping_line_id')) ->relationship('shippingLine', 'name') @@ -208,6 +214,10 @@ public static function table(Table $table): Table TextColumn::make('id') ->label('#') ->sortable(), + TextColumn::make('tracking_code') + ->label(trans('order.tracking_code')) + ->searchable() + ->copyable(), TextColumn::make('user.email') ->label(trans('order.user_id')) ->searchable() diff --git a/admin/app/Filament/Resources/ProductResource.php b/admin/app/Filament/Resources/ProductResource.php index 9168ebac..ff7192d6 100644 --- a/admin/app/Filament/Resources/ProductResource.php +++ b/admin/app/Filament/Resources/ProductResource.php @@ -15,6 +15,7 @@ use App\Models\AttributeGroup; use App\Models\AttributeGroupCategory; use App\Models\Product; +use App\Models\Variety; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -27,6 +28,7 @@ use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Resources\Resource; +use Filament\Schemas\Components\Fieldset; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; @@ -356,6 +358,27 @@ public static function form(Schema $schema): Schema ->required() ->options(VarietyStatusEnum::options()) ->default(VarietyStatusEnum::PUBLISHED->value), + Fieldset::make(trans('product.variety_image')) + ->relationship('image') + ->schema([ + FileUpload::make('path') + ->label(trans('product.path')) + ->image() + ->nullable() + ->columnSpanFull(), + TextInput::make('alt_text') + ->label(trans('product.alt_text')) + ->nullable() + ->maxLength(255), + ]) + ->mutateRelationshipDataBeforeSaveUsing(function (array $data, Variety $record): array { + if (empty($data['path'])) { + $record->image?->delete(); + } + + return $data; + }) + ->columnSpanFull(), ]) ->columnSpanFull(), ]); diff --git a/admin/app/Filament/Resources/ReviewResource.php b/admin/app/Filament/Resources/ReviewResource.php index 6f144125..4931b5ea 100644 --- a/admin/app/Filament/Resources/ReviewResource.php +++ b/admin/app/Filament/Resources/ReviewResource.php @@ -64,6 +64,13 @@ public static function form(Schema $schema): Schema ->columnSpanFull() ->hintIcon('heroicon-o-information-circle') ->hintIconTooltip(trans('review.content_hint')), + Select::make('rating') + ->label(trans('review.rating')) + ->options([1 => '۱', 2 => '۲', 3 => '۳', 4 => '۴', 5 => '۵']) + ->nullable() + ->native(false) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip(trans('review.rating_hint')), Select::make('product_id') ->label(trans('review.product_id')) ->relationship('product', 'heading') @@ -139,6 +146,10 @@ public static function table(Table $table): Table ->label(trans('review.heading')) ->limit(40) ->searchable(), + TextColumn::make('rating') + ->label(trans('review.rating')) + ->placeholder('—') + ->sortable(), TextColumn::make('user.email') ->label(trans('review.user')) ->placeholder(trans('review.anonymous')) diff --git a/admin/app/Filament/Resources/VarietyResource.php b/admin/app/Filament/Resources/VarietyResource.php index e4575c81..1754a76f 100644 --- a/admin/app/Filament/Resources/VarietyResource.php +++ b/admin/app/Filament/Resources/VarietyResource.php @@ -15,14 +15,17 @@ use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Forms\Components\ColorPicker; +use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Resources\Resource; +use Filament\Schemas\Components\Fieldset; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Schema; use Filament\Tables\Columns\ColorColumn; use Filament\Tables\Columns\IconColumn; +use Filament\Tables\Columns\ImageColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; @@ -130,6 +133,27 @@ public static function form(Schema $schema): Schema ->columnSpanFull() ->hintIcon('heroicon-o-information-circle') ->hintIconTooltip(trans('variety.additional_attributes_hint')), + Fieldset::make(trans('variety.image')) + ->relationship('image') + ->schema([ + FileUpload::make('path') + ->label(trans('variety.path')) + ->image() + ->nullable() + ->columnSpanFull(), + TextInput::make('alt_text') + ->label(trans('variety.alt_text')) + ->nullable() + ->maxLength(255), + ]) + ->mutateRelationshipDataBeforeSaveUsing(function (array $data, Variety $record): array { + if (empty($data['path'])) { + $record->image?->delete(); + } + + return $data; + }) + ->columnSpanFull(), ]); } @@ -137,6 +161,8 @@ public static function table(Table $table): Table { return $table ->columns([ + ImageColumn::make('image.path') + ->label(trans('variety.image')), TextColumn::make('product.heading') ->label(trans('variety.product')) ->limit(30) diff --git a/admin/app/Models/Address.php b/admin/app/Models/Address.php index 1b8cced0..5c1ef540 100644 --- a/admin/app/Models/Address.php +++ b/admin/app/Models/Address.php @@ -18,6 +18,8 @@ * @property string $phone * @property string $postal_code * @property string $address + * @property string|null $latitude + * @property string|null $longitude * @property string|null $description * @property positive-int $city_id * @property positive-int $user_id @@ -38,6 +40,8 @@ class Address extends Model 'phone', 'postal_code', 'address', + 'latitude', + 'longitude', 'description', 'city_id', 'user_id', diff --git a/admin/app/Models/AttributeGroup.php b/admin/app/Models/AttributeGroup.php index 3e9dd7dd..aeab0ab6 100644 --- a/admin/app/Models/AttributeGroup.php +++ b/admin/app/Models/AttributeGroup.php @@ -48,7 +48,7 @@ public function attributes(): HasMany public function categories(): BelongsToMany { return $this->belongsToMany(Category::class, 'attribute_group_category') - ->withPivot(['as_filter', 'required', 'order']) + ->withPivot(['as_filter', 'required']) ->withTimestamps(); } diff --git a/admin/app/Models/Category.php b/admin/app/Models/Category.php index ffc73f32..417db341 100644 --- a/admin/app/Models/Category.php +++ b/admin/app/Models/Category.php @@ -85,7 +85,7 @@ public function attributeGroupCategories(): HasMany public function attributeGroups(): BelongsToMany { return $this->belongsToMany(AttributeGroup::class, 'attribute_group_category') - ->withPivot(['as_filter', 'required', 'order']) + ->withPivot(['as_filter', 'required']) ->withTimestamps(); } diff --git a/admin/app/Models/Order.php b/admin/app/Models/Order.php index bc36b5d5..7ac82ac1 100644 --- a/admin/app/Models/Order.php +++ b/admin/app/Models/Order.php @@ -16,6 +16,7 @@ /** * @property positive-int $id + * @property string $tracking_code * @property positive-int|null $user_id * @property positive-int|null $coupon_id * @property OrderStatusEnum $status @@ -41,6 +42,7 @@ * @property string|null $collector_description * @property positive-int|null $notifier_id * @property Carbon|null $notified_at + * @property positive-int|null $address_id * @property positive-int|null $shipping_line_id * @property positive-int|null $shipping_method_id * @property string|null $send_description @@ -53,6 +55,7 @@ * @property User|null $confirmer * @property User|null $collector * @property User|null $notifier + * @property Address|null $address * @property ShippingLine|null $shippingLine * @property ShippingMethod|null $shippingMethod * @property Collection $orderVarieties @@ -92,6 +95,7 @@ class Order extends Model 'collector_description', 'notifier_id', 'notified_at', + 'address_id', 'shipping_line_id', 'shipping_method_id', 'send_description', @@ -109,6 +113,26 @@ class Order extends Model 'notified_at' => 'datetime', ]; + protected static function booted(): void + { + static::creating(function (Order $order): void { + $order->tracking_code ??= self::generateTrackingCode(); + }); + } + + /** + * A random 10-digit number, not the sequential `id`, so a customer's + * tracking code never reveals order volume/growth over time. + */ + private static function generateTrackingCode(): string + { + do { + $code = (string) random_int(1_000_000_000, 9_999_999_999); + } while (self::query()->where('tracking_code', $code)->exists()); + + return $code; + } + public function user(): BelongsTo { return $this->belongsTo(User::class); @@ -134,6 +158,11 @@ public function notifier(): BelongsTo return $this->belongsTo(User::class, 'notifier_id'); } + public function address(): BelongsTo + { + return $this->belongsTo(Address::class); + } + public function shippingLine(): BelongsTo { return $this->belongsTo(ShippingLine::class); diff --git a/admin/app/Models/Review.php b/admin/app/Models/Review.php index bab295d1..5cc510eb 100644 --- a/admin/app/Models/Review.php +++ b/admin/app/Models/Review.php @@ -15,6 +15,7 @@ * @property positive-int $id * @property string $heading * @property string $content + * @property int<1, 5>|null $rating * @property positive-int|null $user_id * @property positive-int $product_id * @property positive-int|null $variety_id @@ -33,6 +34,7 @@ class Review extends Model protected $fillable = [ 'heading', 'content', + 'rating', 'user_id', 'product_id', 'variety_id', @@ -41,6 +43,7 @@ class Review extends Model ]; protected $casts = [ + 'rating' => 'integer', 'status' => ReviewStatusEnum::class, ]; diff --git a/admin/app/Models/Variety.php b/admin/app/Models/Variety.php index 6d610747..ecd9a395 100644 --- a/admin/app/Models/Variety.php +++ b/admin/app/Models/Variety.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphOne; /** * @property positive-int $id @@ -29,6 +30,7 @@ * @property Collection $attributes * @property Collection $discounts * @property Collection $reviews + * @property Image|null $image */ class Variety extends Model { @@ -68,6 +70,8 @@ protected static function booted(): void static::saved(fn (Variety $variety) => $variety->syncProductVarietyCount()); static::deleted(fn (Variety $variety) => $variety->syncProductVarietyCount()); + + static::deleting(fn (Variety $variety) => $variety->image?->delete()); } public function syncProductVarietyCount(): void @@ -94,6 +98,11 @@ public function attribute(): BelongsTo return $this->belongsTo(Attribute::class); } + public function image(): MorphOne + { + return $this->morphOne(Image::class, 'imageable'); + } + public function attributes(): BelongsToMany { return $this->belongsToMany(Attribute::class)->withTimestamps(); diff --git a/admin/database/factories/AddressFactory.php b/admin/database/factories/AddressFactory.php index 5c58373d..433479ce 100644 --- a/admin/database/factories/AddressFactory.php +++ b/admin/database/factories/AddressFactory.php @@ -28,7 +28,7 @@ public function definition(): array 'postal_code' => $this->faker->postcode(), 'address' => $this->faker->address(), 'description' => $this->faker->text(), - 'city_id' => City::factory(), + 'city_id' => City::query()->inRandomOrder()->value('id') ?? City::factory(), 'prime' => false, ]; } diff --git a/admin/database/factories/BannerFactory.php b/admin/database/factories/BannerFactory.php index 64c0f32f..9643c679 100644 --- a/admin/database/factories/BannerFactory.php +++ b/admin/database/factories/BannerFactory.php @@ -34,7 +34,7 @@ public function withImages(int $count = 3): static return $this->afterCreating(function (Banner $banner) use ($count) { for ($i = 0; $i < $count; $i++) { $banner->images()->create([ - 'path' => fake()->imageUrl(), + 'path' => ImageFactory::placeholderUrl(), 'is_featured' => $i === 0, 'order' => $i, 'alt_text' => fake()->words(2, true), diff --git a/admin/database/factories/BrandFactory.php b/admin/database/factories/BrandFactory.php index d6ea7590..4f83f046 100644 --- a/admin/database/factories/BrandFactory.php +++ b/admin/database/factories/BrandFactory.php @@ -36,7 +36,7 @@ public function withImage(): BrandFactory | Factory { return $this->afterCreating(function (Brand $brand) { $brand->image()->create([ - 'path' => fake()->imageUrl(), + 'path' => ImageFactory::placeholderUrl(), 'imageable_type' => Brand::class, 'imageable_id' => $brand->id, ]); diff --git a/admin/database/factories/CategoryFactory.php b/admin/database/factories/CategoryFactory.php index 00ef284a..a35a4a39 100644 --- a/admin/database/factories/CategoryFactory.php +++ b/admin/database/factories/CategoryFactory.php @@ -40,7 +40,7 @@ public function withImage(): BrandFactory | Factory { return $this->afterCreating(function (Category $category) { $category->image()->create([ - 'path' => fake()->imageUrl(), + 'path' => ImageFactory::placeholderUrl(), 'imageable_type' => Category::class, 'imageable_id' => $category->id, ]); diff --git a/admin/database/factories/CityFactory.php b/admin/database/factories/CityFactory.php index d78c68a8..c0adebe1 100644 --- a/admin/database/factories/CityFactory.php +++ b/admin/database/factories/CityFactory.php @@ -22,7 +22,7 @@ public function definition(): array { return [ 'name' => $this->faker->city, - 'province_id' => Province::factory()->create()->id, + 'province_id' => Province::query()->inRandomOrder()->value('id') ?? Province::factory(), ]; } } diff --git a/admin/database/factories/GatewayFactory.php b/admin/database/factories/GatewayFactory.php index 8386015f..a2beaf7c 100644 --- a/admin/database/factories/GatewayFactory.php +++ b/admin/database/factories/GatewayFactory.php @@ -35,7 +35,7 @@ public function withImage(): static { return $this->afterCreating(function (Gateway $gateway): void { $gateway->image()->create([ - 'path' => fake()->imageUrl(), + 'path' => ImageFactory::placeholderUrl(), 'alt_text' => fake()->words(2, true), ]); }); diff --git a/admin/database/factories/ImageFactory.php b/admin/database/factories/ImageFactory.php index 0996f642..53a6a3f1 100644 --- a/admin/database/factories/ImageFactory.php +++ b/admin/database/factories/ImageFactory.php @@ -28,9 +28,22 @@ public function definition(): array $imageableType = $this->faker->randomElement($imageableTypes); return [ - 'path' => fake()->imageUrl, + 'path' => self::placeholderUrl(), 'imageable_id' => $imageableType::factory(), 'imageable_type' => $imageableType, ]; } + + /** + * A live placeholder image URL. + * + * Faker's default `imageUrl()` points at via.placeholder.com, which has + * been shut down; placehold.co is a working replacement. + */ + public static function placeholderUrl(int $width = 640, int $height = 480): string + { + $color = ltrim(fake()->hexColor(), '#'); + + return "https://placehold.co/{$width}x{$height}/{$color}/ffffff?text=" . urlencode(fake()->word()); + } } diff --git a/admin/database/factories/OrderFactory.php b/admin/database/factories/OrderFactory.php index 22c71b5d..bdc8d836 100644 --- a/admin/database/factories/OrderFactory.php +++ b/admin/database/factories/OrderFactory.php @@ -6,6 +6,7 @@ use App\Enums\OrderSrcEnum; use App\Enums\OrderStatusEnum; +use App\Models\Address; use App\Models\Order; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -27,6 +28,7 @@ public function definition(): array return [ 'user_id' => User::factory(), + 'address_id' => Address::factory(), 'coupon_id' => null, 'status' => fake()->randomElement(OrderStatusEnum::cases()), 'coupon_discount' => 0, diff --git a/admin/database/factories/PageFactory.php b/admin/database/factories/PageFactory.php index f096afd0..3b4955fb 100644 --- a/admin/database/factories/PageFactory.php +++ b/admin/database/factories/PageFactory.php @@ -38,7 +38,7 @@ public function withImage(): static { return $this->afterCreating(function (Page $page): void { $page->image()->create([ - 'path' => fake()->imageUrl(), + 'path' => ImageFactory::placeholderUrl(), 'alt_text' => fake()->words(2, true), ]); }); diff --git a/admin/database/factories/ProductFactory.php b/admin/database/factories/ProductFactory.php index aa63faf6..2f0a3cd8 100644 --- a/admin/database/factories/ProductFactory.php +++ b/admin/database/factories/ProductFactory.php @@ -56,7 +56,7 @@ public function withImages(int $count = 3): static return $this->afterCreating(function (Product $product) use ($count) { for ($i = 0; $i < $count; $i++) { $product->images()->create([ - 'path' => fake()->imageUrl(), + 'path' => ImageFactory::placeholderUrl(), 'is_featured' => $i === 0, // First image featured 'order' => $i, 'alt_text' => fake()->words(2, true), diff --git a/admin/database/factories/ReceiptFactory.php b/admin/database/factories/ReceiptFactory.php index 182d5e2f..589d62a8 100644 --- a/admin/database/factories/ReceiptFactory.php +++ b/admin/database/factories/ReceiptFactory.php @@ -39,7 +39,7 @@ public function withImage(): static { return $this->afterCreating(function (Receipt $receipt): void { $receipt->image()->create([ - 'path' => fake()->imageUrl(), + 'path' => ImageFactory::placeholderUrl(), 'alt_text' => fake()->words(2, true), ]); }); diff --git a/admin/database/factories/ReviewFactory.php b/admin/database/factories/ReviewFactory.php index 156d07e1..451ac686 100644 --- a/admin/database/factories/ReviewFactory.php +++ b/admin/database/factories/ReviewFactory.php @@ -22,6 +22,7 @@ public function definition(): array return [ 'heading' => fake()->sentence(5), 'content' => fake()->paragraph(), + 'rating' => fake()->numberBetween(1, 5), 'user_id' => null, 'product_id' => Product::factory(), 'variety_id' => null, @@ -35,6 +36,8 @@ public function withParent(Review $parent): static return $this->state([ 'parent_id' => $parent->id, 'product_id' => $parent->product_id, + // A reply is not itself a rated review. + 'rating' => null, ]); } } diff --git a/admin/database/factories/SlideFactory.php b/admin/database/factories/SlideFactory.php index 0712a452..64ab093c 100644 --- a/admin/database/factories/SlideFactory.php +++ b/admin/database/factories/SlideFactory.php @@ -33,7 +33,7 @@ public function withImage(): static { return $this->afterCreating(function (Slide $slide): void { $slide->image()->create([ - 'path' => fake()->imageUrl(), + 'path' => ImageFactory::placeholderUrl(), 'is_featured' => true, 'order' => 0, 'alt_text' => $slide->heading, diff --git a/admin/database/factories/VarietyFactory.php b/admin/database/factories/VarietyFactory.php index 06f549dc..9fc63967 100644 --- a/admin/database/factories/VarietyFactory.php +++ b/admin/database/factories/VarietyFactory.php @@ -41,4 +41,31 @@ public function withAttribute(Attribute $attribute): static 'attribute_id' => $attribute->id, ]); } + + public function published(): static + { + return $this->state([ + 'status' => VarietyStatusEnum::PUBLISHED, + ]); + } + + public function inStock(): static + { + return $this->state([ + 'has_stock' => true, + 'inventory' => fake()->numberBetween(5, 100), + ]); + } + + public function withImage(): static + { + return $this->afterCreating(function (Variety $variety): void { + $variety->image()->create([ + 'path' => ImageFactory::placeholderUrl(), + 'is_featured' => true, + 'order' => 0, + 'alt_text' => $variety->attribute_value ?? $variety->color, + ]); + }); + } } diff --git a/admin/database/migrations/2024_08_07_033435_create_addresses_table.php b/admin/database/migrations/2024_08_07_033435_create_addresses_table.php index a4331aaa..a9c8b3b5 100644 --- a/admin/database/migrations/2024_08_07_033435_create_addresses_table.php +++ b/admin/database/migrations/2024_08_07_033435_create_addresses_table.php @@ -21,6 +21,8 @@ public function up(): void $table->text('phone'); $table->text('postal_code')->nullable(); $table->text('address'); + $table->decimal('latitude', 10, 7)->nullable(); + $table->decimal('longitude', 10, 7)->nullable(); $table->string('description')->nullable(); $table->boolean('prime')->default(false); $table->softDeletes(); diff --git a/admin/database/migrations/2026_06_20_000008_create_reviews_table.php b/admin/database/migrations/2026_06_20_000008_create_reviews_table.php index 3d4c16a4..5c9f26f5 100644 --- a/admin/database/migrations/2026_06_20_000008_create_reviews_table.php +++ b/admin/database/migrations/2026_06_20_000008_create_reviews_table.php @@ -18,6 +18,9 @@ public function up(): void $table->id(); $table->string('heading'); $table->text('content'); + // 1–5 star rating. Nullable: replies (parent_id set) and legacy/ + // admin-entered reviews may carry no rating. + $table->unsignedTinyInteger('rating')->nullable(); $table->foreignIdFor(User::class)->nullable()->constrained()->nullOnDelete(); $table->foreignIdFor(Product::class)->constrained()->cascadeOnDelete(); $table->foreignIdFor(Variety::class)->nullable()->constrained()->nullOnDelete(); diff --git a/admin/database/migrations/2026_06_20_000014_create_orders_table.php b/admin/database/migrations/2026_06_20_000014_create_orders_table.php index 0b2a7c31..bd2829aa 100644 --- a/admin/database/migrations/2026_06_20_000014_create_orders_table.php +++ b/admin/database/migrations/2026_06_20_000014_create_orders_table.php @@ -4,6 +4,7 @@ use App\Enums\OrderSrcEnum; use App\Enums\OrderStatusEnum; +use App\Models\Address; use App\Models\Coupon; use App\Models\ShippingLine; use App\Models\ShippingMethod; @@ -18,6 +19,10 @@ public function up(): void { Schema::create('orders', function (Blueprint $table): void { $table->id(); + // Customer-facing order identifier (e.g. "1168407691"): an opaque + // random 10-digit number, not the sequential `id`, so a customer + // can't infer order volume/growth from their own tracking code. + $table->string('tracking_code', 10)->unique(); $table->foreignIdFor(User::class)->nullable()->constrained()->nullOnDelete(); $table->foreignIdFor(Coupon::class)->nullable()->constrained()->nullOnDelete(); $table->unsignedTinyInteger('status')->default(OrderStatusEnum::PENDING->value); @@ -55,6 +60,11 @@ public function up(): void $table->foreignId('notifier_id')->nullable()->constrained('users')->nullOnDelete(); $table->dateTime('notified_at')->nullable(); + // The address the order ships to. Addresses are immutable history + // (edits create a new row), so this always points at the exact + // address snapshot the customer chose at checkout. + $table->foreignIdFor(Address::class)->nullable()->constrained()->nullOnDelete(); + $table->foreignIdFor(ShippingLine::class)->nullable()->constrained()->nullOnDelete(); $table->foreignIdFor(ShippingMethod::class)->nullable()->constrained()->nullOnDelete(); $table->text('send_description')->nullable(); diff --git a/admin/database/seeders/CategorySeeder.php b/admin/database/seeders/CategorySeeder.php index 598a66dd..17cc1a25 100644 --- a/admin/database/seeders/CategorySeeder.php +++ b/admin/database/seeders/CategorySeeder.php @@ -8,6 +8,7 @@ use App\Models\Category; use Illuminate\Database\Seeder; use Illuminate\Support\Arr; // Import the Arr facade +use Illuminate\Support\Facades\DB; class CategorySeeder extends Seeder { @@ -108,6 +109,13 @@ public function run(): void $this->createChildren($category['children'], $category['id']); } } + + // Explicit IDs above leave Postgres' auto-increment sequence behind, so + // advance it past the seeded rows to avoid duplicate-key errors on + // categories created later via the admin panel. + if (DB::getDriverName() === 'pgsql') { + DB::statement("SELECT setval(pg_get_serial_sequence('categories', 'id'), (SELECT MAX(id) FROM categories))"); + } } private function createChildren(array $children, int $parentId): void diff --git a/admin/database/seeders/CitySeeder.php b/admin/database/seeders/CitySeeder.php index d392b925..d2d3b021 100644 --- a/admin/database/seeders/CitySeeder.php +++ b/admin/database/seeders/CitySeeder.php @@ -4,9 +4,11 @@ namespace Database\Seeders; +use App\Models\Address; use App\Models\City; use App\Models\Province; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\DB; class CitySeeder extends Seeder { @@ -15,6 +17,12 @@ class CitySeeder extends Seeder */ public function run(): void { + // addresses.city_id has no cascade action, so a lingering address + // (soft-deleted or not — the row still exists either way) blocks + // deleting cities. Force-delete every address first, including + // already-soft-deleted ones, so re-seeding doesn't hit a FK violation. + Address::withTrashed()->forceDelete(); + City::query()->delete(); Province::query()->delete(); @@ -5747,5 +5755,11 @@ public function run(): void City::query()->insert($cities); + // Rows are inserted with explicit ids, so advance the auto-increment + // sequences; otherwise the next factory-created province/city collides. + if (DB::getDriverName() === 'pgsql') { + DB::statement("SELECT setval(pg_get_serial_sequence('provinces', 'id'), (SELECT MAX(id) FROM provinces))"); + DB::statement("SELECT setval(pg_get_serial_sequence('cities', 'id'), (SELECT MAX(id) FROM cities))"); + } } } diff --git a/admin/database/seeders/DatabaseSeeder.php b/admin/database/seeders/DatabaseSeeder.php index e43f6d22..91070415 100644 --- a/admin/database/seeders/DatabaseSeeder.php +++ b/admin/database/seeders/DatabaseSeeder.php @@ -21,6 +21,7 @@ public function run(): void AncestorSeeder::class, AttributeSeeder::class, AttributeGroupCategorySeeder::class, + ShippingSeeder::class, SettingSeeder::class, ]); } diff --git a/admin/database/seeders/PageSeeder.php b/admin/database/seeders/PageSeeder.php index 0c31f8e7..a13aa3ba 100644 --- a/admin/database/seeders/PageSeeder.php +++ b/admin/database/seeders/PageSeeder.php @@ -4,6 +4,7 @@ namespace Database\Seeders; +use App\Enums\PageStatusEnum; use App\Models\Page; use Illuminate\Database\Seeder; @@ -13,6 +14,75 @@ public function run(): void { Page::all()->each->delete(); - Page::factory()->count(10)->withImage()->create(); + foreach ($this->pages() as $page) { + Page::create([ + ...$page, + 'status' => PageStatusEnum::PUBLISHED, + ]); + } + + Page::factory()->count(6)->withImage()->create(); + } + + /** + * Fixed CMS pages linked from the storefront footer. Slugs must match the + * footer link URLs seeded in SettingSeeder (e.g. /about-us). + * + * @return array> + */ + private function pages(): array + { + return [ + [ + 'heading' => 'درباره ما', + 'slug' => 'about-us', + 'title' => 'درباره فروشگاه اینترنتی شاپ‌فلو', + 'description' => 'با شاپ‌فلو، فروشگاه اینترنتی محصولات متنوع، بیشتر آشنا شوید.', + 'content' => '

فروشگاه اینترنتی شاپ‌فلو با هدف ارائه تجربه‌ای آسان، سریع و مطمئن از خرید آنلاین فعالیت می‌کند. ما طیف گسترده‌ای از محصولات شامل پوشاک، لوازم آرایشی و بهداشتی، کالای خواب، لوازم خانه و آشپزخانه و الکترونیک را با تضمین اصالت و بهترین قیمت در اختیار شما قرار می‌دهیم.

' + . '

اصول ما ساده است: روش‌های پرداخت متنوع، هفت روز ضمانت بازگشت کالا و تضمین اصالت محصولات.

' + . '

راه‌های ارتباط با ما

' + . '
    ' + . '
  • تلفن پشتیبانی: ۰۲۱-۹۱۰۰۰۰۰۰
  • ' + . '
  • ایمیل: support@shopflow.ir
  • ' + . '
  • نشانی: تهران، خیابان ولیعصر، بالاتر از میدان ونک، کوچه نگار، پلاک ۲۴، طبقه سوم
  • ' + . '
  • شبکه‌های اجتماعی: اینستاگرام، تلگرام، فیسبوک و واتساپ شاپ‌فلو
  • ' + . '
', + ], + [ + 'heading' => 'شرایط بازگشت کالا', + 'slug' => 'return-conditions', + 'title' => 'شرایط و قوانین بازگشت کالا', + 'description' => 'هر آنچه درباره ضمانت هفت روزه بازگشت کالا باید بدانید.', + 'content' => '

در شاپ‌فلو شما تا هفت روز پس از دریافت سفارش فرصت دارید در صورت عدم رضایت، کالا را بازگردانید.

' + . '

شرایط لازم برای بازگشت

' + . '
    ' + . '
  • کالا باید سالم و بدون استفاده باشد.
  • ' + . '
  • برچسب‌ها و بسته‌بندی اصلی کالا حفظ شده باشد.
  • ' + . '
  • فاکتور یا کد سفارش همراه کالا ارائه شود.
  • ' + . '
' + . '

برای ثبت درخواست بازگشت، با پشتیبانی فروشگاه تماس بگیرید.

', + ], + [ + 'heading' => 'حریم خصوصی', + 'slug' => 'privacy', + 'title' => 'سیاست حفظ حریم خصوصی', + 'description' => 'نحوه جمع‌آوری و حفاظت از اطلاعات کاربران در شاپ‌فلو.', + 'content' => '

حفظ حریم خصوصی کاربران برای ما اهمیت زیادی دارد. اطلاعات شخصی شما تنها برای پردازش سفارش‌ها و بهبود خدمات استفاده می‌شود.

' + . '

اطلاعاتی که جمع‌آوری می‌کنیم

' + . '
    ' + . '
  • اطلاعات تماس و نشانی برای ارسال سفارش.
  • ' + . '
  • سوابق خرید برای ارائه خدمات بهتر.
  • ' + . '
' + . '

ما هرگز اطلاعات شما را بدون اجازه در اختیار اشخاص ثالث قرار نمی‌دهیم.

', + ], + [ + 'heading' => 'همکاری با ما', + 'slug' => 'cooperation', + 'title' => 'فرصت‌های همکاری با شاپ‌فلو', + 'description' => 'برای همکاری و فروش محصولات خود با ما در ارتباط باشید.', + 'content' => '

اگر تأمین‌کننده، تولیدکننده یا فروشنده هستید و مایل به همکاری با شاپ‌فلو هستید، خوشحال می‌شویم با شما در ارتباط باشیم.

' + . '

برای شروع همکاری، اطلاعات خود را از طریق ایمیل support@shopflow.ir برای ما ارسال کنید.

', + ], + ]; } } diff --git a/admin/database/seeders/ProductSeeder.php b/admin/database/seeders/ProductSeeder.php index d8241d94..9c8e5b6c 100644 --- a/admin/database/seeders/ProductSeeder.php +++ b/admin/database/seeders/ProductSeeder.php @@ -5,6 +5,7 @@ namespace Database\Seeders; use App\Models\Product; +use App\Models\Variety; use Illuminate\Database\Seeder; class ProductSeeder extends Seeder @@ -12,6 +13,17 @@ class ProductSeeder extends Seeder public function run(): void { Product::query()->truncate(); - Product::factory()->count(20)->create(); + + Product::factory() + ->count(20) + ->withImages() + ->has( + Variety::factory() + ->count(3) + ->published() + ->inStock() + ->withImage() + ) + ->create(); } } diff --git a/admin/database/seeders/SettingSeeder.php b/admin/database/seeders/SettingSeeder.php index 5e021106..c42f2cd3 100644 --- a/admin/database/seeders/SettingSeeder.php +++ b/admin/database/seeders/SettingSeeder.php @@ -96,6 +96,8 @@ private function settings(): array 'content' => $this->json([ ['name' => 'اینستاگرام', 'url' => 'https://instagram.com/shopflow'], ['name' => 'تلگرام', 'url' => 'https://t.me/shopflow'], + ['name' => 'فیسبوک', 'url' => 'https://facebook.com/shopflow'], + ['name' => 'واتساپ', 'url' => 'https://wa.me/989000000000'], ['name' => 'لینکدین', 'url' => 'https://linkedin.com/company/shopflow'], ]), ], diff --git a/admin/database/seeders/ShippingLineSeeder.php b/admin/database/seeders/ShippingLineSeeder.php index 161bc518..59f0b280 100644 --- a/admin/database/seeders/ShippingLineSeeder.php +++ b/admin/database/seeders/ShippingLineSeeder.php @@ -4,14 +4,21 @@ namespace Database\Seeders; +use App\Models\ShippingCity; use App\Models\ShippingLine; +use App\Models\ShippingMethod; use Illuminate\Database\Seeder; class ShippingLineSeeder extends Seeder { public function run(): void { - ShippingLine::all()->each->delete(); + // shipping_methods (and, through it, shipping_cities) reference + // shipping_lines, so re-seeding must clear those dependents first or + // deleting an existing line throws a foreign key violation. + ShippingCity::query()->delete(); + ShippingMethod::query()->delete(); + ShippingLine::query()->delete(); ShippingLine::factory()->count(20)->create(); } diff --git a/admin/database/seeders/ShippingSeeder.php b/admin/database/seeders/ShippingSeeder.php new file mode 100644 index 00000000..50256f6f --- /dev/null +++ b/admin/database/seeders/ShippingSeeder.php @@ -0,0 +1,85 @@ +delete(); + ShippingMethod::query()->delete(); + ShippingLine::query()->delete(); + + $tehran = Province::query()->where('name', 'تهران')->value('id'); + + // پیک ویژه تهران: same-day style courier, Tehran only. + $courier = ShippingLine::query()->create([ + 'name' => 'پیک ویژه تهران', + 'cost' => 50000, + ]); + $courierMethod = ShippingMethod::query()->create([ + 'shipping_line_id' => $courier->id, + 'name' => 'پیک ویژه تهران', + 'type' => 'پیک', + 'status' => true, + ]); + ShippingCity::query()->create([ + 'shipping_method_id' => $courierMethod->id, + 'province_id' => $tehran, + 'amount' => 50000, + 'sending_days' => '۲۴ ساعت کاری', + 'description' => 'تحویل ۲۴ ساعت کاری پس از ثبت سفارش (روزهای تعطیل جزو زمان آماده‌سازی و ارسال محاسبه نمی‌شوند). سفارش‌هایی که در روزهای تعطیل رسمی ثبت شوند، در اولین روز کاری بعد پردازش و روز کاری پس از آن ارسال خواهند شد.', + 'status' => true, + ]); + + // پست پیشتاز: nationwide. + $post = ShippingLine::query()->create([ + 'name' => 'پست', + 'cost' => 45000, + ]); + $postMethod = ShippingMethod::query()->create([ + 'shipping_line_id' => $post->id, + 'name' => 'پست پیشتاز', + 'type' => 'پست', + 'status' => true, + ]); + ShippingCity::query()->create([ + 'shipping_method_id' => $postMethod->id, + 'amount' => 45000, + 'sending_days' => '۲ تا ۴ روز کاری', + 'description' => 'ارسال از طریق پست پیشتاز (۲ تا ۴ روز کاری).', + 'status' => true, + ]); + + // تحویل حضوری از فروشگاه: nationwide, postpaid (pay on delivery). + $pickup = ShippingLine::query()->create([ + 'name' => 'تحویل حضوری از فروشگاه', + 'cost' => 0, + ]); + $pickupMethod = ShippingMethod::query()->create([ + 'shipping_line_id' => $pickup->id, + 'name' => 'تحویل حضوری از فروشگاه', + 'type' => 'حضوری', + 'status' => true, + ]); + ShippingCity::query()->create([ + 'shipping_method_id' => $pickupMethod->id, + 'pay_on_delivery' => true, + 'amount' => null, + 'description' => 'تحویل حضوری (شنبه تا چهارشنبه، به‌جز روزهای تعطیل) — بعد از آماده‌سازی، زمان تحویل با شما از طرف فروشگاه هماهنگ می‌شود. هزینه ارسال به صورت پس‌کرایه می‌باشد.', + 'status' => true, + ]); + } +} diff --git a/admin/database/seeders/TestSeeder.php b/admin/database/seeders/TestSeeder.php index 71bf47fa..1578fb2e 100644 --- a/admin/database/seeders/TestSeeder.php +++ b/admin/database/seeders/TestSeeder.php @@ -34,9 +34,13 @@ public function run(): void GatewaySeeder::class, UserConfigSeeder::class, AddressSeeder::class, - ShippingLineSeeder::class, - ShippingMethodSeeder::class, - ShippingCitySeeder::class, + // ShippingLineSeeder/ShippingMethodSeeder/ShippingCitySeeder are + // deliberately NOT called here: each does `Model::all()->each->delete()` + // then creates 20 random rows, which wipes out ShippingSeeder's real, + // checkout-critical shipping methods (called by DatabaseSeeder) and + // replaces them with random fake ones (no nationwide fallback), so + // the storefront checkout can no longer find a shipping method for + // most addresses. Re-run ShippingSeeder if real shipping data is lost. ]); } } diff --git a/admin/database/seeders/VarietySeeder.php b/admin/database/seeders/VarietySeeder.php index 0cc09049..f515a38f 100644 --- a/admin/database/seeders/VarietySeeder.php +++ b/admin/database/seeders/VarietySeeder.php @@ -4,6 +4,7 @@ namespace Database\Seeders; +use App\Models\Product; use App\Models\Variety; use Illuminate\Database\Seeder; @@ -12,6 +13,21 @@ class VarietySeeder extends Seeder public function run(): void { Variety::query()->truncate(); - Variety::factory()->count(20)->create(); + + $products = Product::query()->get(); + + if ($products->isEmpty()) { + $products = Product::factory()->count(5)->withImages()->create(); + } + + $products->each(function (Product $product): void { + Variety::factory() + ->count(fake()->numberBetween(1, 3)) + ->for($product) + ->published() + ->inStock() + ->withImage() + ->create(); + }); } } diff --git a/admin/docker/docker-compose.yml b/admin/docker/docker-compose.yml index 9bb97dda..3a493a70 100755 --- a/admin/docker/docker-compose.yml +++ b/admin/docker/docker-compose.yml @@ -1,4 +1,3 @@ -version: "3.8" services: app: build: diff --git a/admin/docs/CACHE.md b/admin/docs/CACHE.md index e6d18529..7bec45cb 100644 --- a/admin/docs/CACHE.md +++ b/admin/docs/CACHE.md @@ -23,6 +23,7 @@ Legend: `[ ]` not started, `[x]` implemented. | 7 | `varieties.product.{product_id}` | All varieties for a product (price, inventory, status) | 15 min | Variety saved / deleted | | 9 | `pages.{slug}` | Single published page record | 1 hour | Page saved / deleted | | 10 | `faqs.{position}` | FAQs for a given position (null = main FAQ page) | 1 hour | FAQ saved / deleted | +| 11 | `settings.autoload` | Autoloaded site settings (key => content), used for footer/contact | 1 hour | Setting saved / deleted (in admin) | --- diff --git a/admin/docs/IMPLEMENTATION.md b/admin/docs/IMPLEMENTATION.md index 70610cff..4e7729a1 100644 --- a/admin/docs/IMPLEMENTATION.md +++ b/admin/docs/IMPLEMENTATION.md @@ -20,7 +20,7 @@ Catalog layer and platform basics. - [x] Attributes - [x] Attribute-Group-Categories - [x] Products (+ `product_attribute` pivot, product images) -- [x] Varieties (+ `variety_counts` auto-sync on Product) +- [x] Varieties (+ `variety_counts` auto-sync on Product; optional polymorphic image via `images` table — `withImage()` factory state, upload in VarietyResource and the ProductResource variety repeater, deleted with the variety) - [x] Discounts (auto-applied price rules per variety) - [x] Coupons (+ `coupon_product`, `coupon_variety`, `category_coupon` scoping pivots) - [x] Images (polymorphic, used via uploads - no standalone resource by design) @@ -29,7 +29,7 @@ Catalog layer and platform basics. - [x] Menus + Menu Items (nested via `parent_id`; optional polymorphic image per item) - [x] Pages (CMS static pages; polymorphic image; SCHEDULED status with `published_at`) - [x] FAQs (question + answer; `order` and nullable `position` for placement context) -- [x] Reviews (user reviews for products; `parent_id` for replies; `status` moderation) +- [x] Reviews (user reviews for products; `rating` 1–5 nullable star rating; `parent_id` for replies; `status` moderation). Reviews are submitted from the storefront (created `PENDING`); staff approve/reject via the Edit form's `status` dropdown - [x] Wishlists (`user_id` + `product_id` pivot; cascades on user/product delete; list + delete resource) - [x] Addresses (immutable history: model, factory, seeder, Filament resource (+ pages), tests. Editing creates a NEW address instead of mutating; the new address inherits the edited one's primary status; records are never deleted, so orders keep an accurate address history. One primary per user enforced via a model `saved` hook) @@ -41,6 +41,8 @@ Cross-cutting improvements landed: - `ColorPicker` added to Variety form; `ColorColumn` in the table. Auto-fill from attribute only triggers when `attribute_id` changes, preserving manual overrides. - Documentation reorganized into `docs/` directory (`ShoFlow db doc.md`, `VARIETY_GUIDE.md`, `IMPLEMENTATION.md`, `CACHE.md`, `ORDER.md`). - **Inventory rule** (`ORDER.md`): stock is decremented only on successful payment (Strategy A); carts never change `varieties.inventory`. +- **Placeholder images**: factories generate images via `ImageFactory::placeholderUrl()` (`placehold.co`); the dead `via.placeholder.com` service was removed. `ProductSeeder`/`VarietySeeder` attach images by default (`withImages()` / `withImage()`). +- **Seeder robustness**: `CitySeeder` advances Postgres sequences with `setval` after explicit-ID inserts; `CityFactory`/`AddressFactory` reuse existing province/city rows before creating new ones, fixing `TestSeeder` unique-constraint failures. - **Localisation (fa / en)**: `SetLocale` middleware + `/locale/{locale}` route + user-menu switcher. All resources (Brand, Category, Product, Variety, City, Province, Coupon, Discount, ShippingLine, ShippingMethod, ShippingCity, Ancestor, AttributeGroup, AttributeGroupCategory, Attribute, Slider, Slide, Banner, Menu, MenuItem, Page, FAQ, Review, Wishlist, User) have `lang/en` + `lang/fa` files, `trans()`-based labels on all form fields and table columns, and translated enum `label()` methods. List-page subheadings are set via `mount()`. Filament vendor translations published for built-in UI strings. - **Persian font**: `A Iranian Sans` loaded from `public/fonts/AIranianSans.ttf`; applied globally when locale is `fa` via `public/css/persian-font.css` and a `renderHook` in `AdminPanelProvider`. - **Locale switching** (`en` / `fa`): `SetLocale` middleware reads the locale from session and calls `App::setLocale()`. A `/locale/{locale}` route stores the choice. Two user-menu items (English / فارسی) in `AdminPanelProvider` let admins switch. All Filament sub-package translations (`filament`, `filament-forms`, `filament-tables`, `filament-actions`, `filament-notifications`) are published to `lang/vendor/`. @@ -99,7 +101,7 @@ Depend mostly on Images only. The main goal; depends on most of phases 1-3. - [~] Carts (migration, model, factory, seeder, tests; no Filament resource by design. Each row is one line item: variety + count, per user or guest session. Inventory rule in `ORDER.md`) -- [x] Orders (`orders` table only: model, migration, factory, seeder, Filament resource (+ pages), tests. `OrderStatusEnum` + `OrderSrcEnum`; staff/finance refs to not-yet-built tables kept nullable without FK. Inventory rule in `ORDER.md`) +- [x] Orders (`orders` table only: model, migration, factory, seeder, Filament resource (+ pages), tests. `OrderStatusEnum` + `OrderSrcEnum`; staff/finance refs to not-yet-built tables kept nullable without FK. Inventory rule in `ORDER.md`. `address_id` (nullable FK to `addresses`, `nullOnDelete`) added when the storefront's checkout/order-creation flow was implemented — the original doc had no way to record which address an order ships to. `tracking_code` (unique random 10-digit string, auto-generated via a `creating` model event in both apps' `Order` model) added as the customer-facing order identifier, shown instead of the sequential `id`; also a searchable/copyable column in the Filament table) - [x] `order_varieties` (line items: model, migration, factory, seeder, Filament resource (+ pages), tests. Stores a per-line price/discount snapshot; `sub_order_id` kept nullable without FK since `sub_orders` is not implemented. An order's many varieties are also editable inline on the Order edit page via `OrderVarietiesRelationManager`) - [~] Sub Orders + `sub_order_logs` — NOT IMPLEMENTED (single-vendor). These are seller-centric; with no sellers an order maps 1:1 to fulfillment, so they add no value. Fulfillment lives on the order itself / `order_shippings`. - [x] `order_shippings` (fulfillment/shipment records: model, migration, factory, seeder, `OrderShippingPaymentTypeEnum`, Filament resource (+ pages), inline relation manager on Order, tests) diff --git a/admin/docs/ORDER.md b/admin/docs/ORDER.md index b8a2cce6..88ccb9ff 100644 --- a/admin/docs/ORDER.md +++ b/admin/docs/ORDER.md @@ -1,6 +1,6 @@ # Orders & Inventory -Notes for when the orders and payments flow is built. Not implemented yet. +Strategy A (below) is implemented on the storefront side: order creation is `App\Actions\Checkout\CreatePendingOrder` (shop), the row-locked decrement is `App\Actions\Checkout\DecrementInventoryAndMarkPaid`, wired together by `App\Actions\Checkout\CompleteCheckoutPayment` (Zarinpal callback handler, `PaymentController@callback`). Strategy B is still just notes. ## When does `varieties.inventory` decrease? @@ -34,6 +34,39 @@ The lock stops two simultaneous payments from both selling the last unit, so no The last unit is not held during payment, so two people can both reach the payment page and the second fails at the final confirm. Rare except on hot or flash-sale items. +Storefront-side, `PaymentController::initiate()` calls `ValidateCartStock` to re-check live inventory right before opening a Zarinpal payment session — this closes the common, fully-preventable case (item already out of stock when the customer clicks پرداخت) so nobody is charged for something unavailable. It cannot close the race above (two people mid-payment for the same last unit); that residual case still reaches `DecrementInventoryAndMarkPaid`'s rejection, and since Zarinpal's verify already succeeded there (money genuinely captured), `CompleteCheckoutPayment::failPaidButOversold()` keeps `ref_id`/`paid_at` and writes a "needs manual refund" message into `result_message` instead of a plain `FAILED` with no trace — check the Transactions table for `result_message` mentioning بازگشت وجه. + +### Known quirk: `AddToCart`/`MergeGuestCart` floor quantity at 1 even when inventory is 0 + +Both `App\Actions\Cart\AddToCart` and `App\Actions\Cart\MergeGuestCart` (storefront) clamp with `max(1, min($desired, $variety->inventory))` — if `$variety->inventory` is 0, this still forces the cart line's `count` to 1. It's harmless at the two call sites that already exist (`CartController::store()` rejects a zero-inventory variety before ever calling `AddToCart`; `MergeGuestCart` only hits zero inventory in the rare case an item went out of stock while sitting in a guest cart, and `GetCartLines`' own `inStock` check still correctly hides it as unavailable everywhere it's displayed). If a new storefront call site skips that guard, it needs to check `has_stock`/`inventory` itself first rather than relying on the floor. + +## Retrying payment on a canceled order ("پرداخت مجدد") + +`Order::isRetryable()` (storefront) allows retry only for a `CANCELED` order whose latest transaction never actually captured money — never for the oversold case above, where Zarinpal already captured payment and a retry would risk double-charging before a manual refund. + +`App\Actions\Checkout\RetryOrderPayment` (storefront, used by `AccountController::retryOrder()`) pays directly — it does not touch the cart or send the customer back through checkout. It re-checks live stock for every original line (all-or-nothing; no partial retry), then resets the **same** order back to `PENDING` (not a clone — its line items/address/shipping/totals are untouched) and opens a fresh Zarinpal session for it via `App\Actions\Checkout\OpenZarinpalSession` (the same action the normal checkout flow uses), which adds a new `Transaction` row. A customer who cancels and retries repeatedly ends up with one order and several transactions (a full attempt history), not a new order per attempt. Because each attempt gets its own Zarinpal authority, `CompleteCheckoutPayment`'s callback handling needs no special-casing for retries — it resolves by authority either way. + +## Returned orders (`RETURNED`): nothing happens automatically + +Setting an order's `status` to `RETURNED` in the Filament admin panel (`OrderResource`'s `status` field is a plain `Select` — no path to this exists on the storefront) is a plain data write. Nothing else is triggered: + +- **Inventory is not restocked.** No code anywhere increments `varieties.inventory` back; Strategy A only ever decrements on payment and never reverses it for a return. +- **No `Receipt` or `Transaction` row is created or updated.** There's no observer/event tied to `orders.status` — changing it doesn't touch either table. +- **No refund is tracked.** Neither `receipts` nor `transactions` has a refund-related column (`refunded_at`, `refund_amount`, etc.) — the free-text "بازگشت وجه" note in `Transaction.result_message` is written only for the unrelated oversold-payment race (`failPaidButOversold()` above), not for a manual return. +- **No status-transition validation.** Any status can be set to `RETURNED` from any prior status. + +Until this is built, staff must handle a return manually: restock the variety's inventory if the item is resellable, and record/process the refund outside the system (there's currently nowhere in the schema to note it other than a free-text `order_notes` entry). + +## Planned: expiring abandoned PENDING orders after 15 minutes + +**Not yet implemented** — documenting the decision now so the eventual build matches it. + +A customer who opens a Zarinpal payment session and then abandons it (closes the tab, never returns) leaves the order stuck as `PENDING` forever — `checkout.callback` is the only thing that ever changes its status, and it's never called if the customer doesn't come back. Since Strategy A never touches inventory for a `PENDING` order, this doesn't oversell anything, but it clutters the order list (admin panel and the customer's own account order history) with stale, never-resolved rows. + +Decision: a scheduled job should mark any `PENDING` order **`CANCELED`** once it's more than 15 minutes old with no successful payment — same as any other canceled order (a normal status flip, row kept as-is, `order_varieties` untouched). Not a hard delete — deleting would break the audit-trail invariant every other cancel path in this codebase relies on (oversold tracking, retry history, admin visibility). An expired order becomes retryable through the existing storefront `Order::isRetryable()` / `RetryOrderPayment` flow like any other canceled order, no special-casing needed there. + +Open question for whoever builds this: what "15 minutes old" should measure — `orders.created_at`, or the latest `Transaction.created_at` (so a retry restarts the clock rather than the job racing to expire an order the customer just retried a few seconds before minute 15). The latter matches the reused-order retry design above and is the more correct choice. + ## If this is not enough later: Strategy B (reserve at checkout) Only consider this if real lost sales, oversell complaints, or flash sales appear. It is an additive change, so deferring it costs nothing now. @@ -49,9 +82,11 @@ Preferred shape if B is needed: a `reservations` table (`variety_id`, `quantity` ## Payments: receipts vs transactions/gateways -Two payment paths, kept separate: +Two payment paths, kept separate — **a paid order only ever has a row in one of them, never both**: + +- Manual / offline payments use `receipts` (admin table built; not yet wired into the storefront checkout flow): card-to-card, Paya transfers, prepayments. The customer provides a tracking code or uploads a receipt image, and staff confirm it. Fields: `destination_bank`, `end_of_card_number`, `tracking_code`, `is_paya`, plus a polymorphic receipt image. +- Online gateway payments use `transactions` (built, storefront-side): **Zarinpal only so far, sandbox mode** (`port = ZARINPAL`). Mellat and Parsian are not built. The shop reads Zarinpal's `merchant_id`/base URL from its own `config('services.zarinpal.*')`/`.env`, not this `gateways` table (nothing is seeded there yet) — revisit once a second gateway needs real *selection* logic (`gateways.active`/`priority`). -- Manual / offline payments use `receipts` (built): card-to-card, Paya transfers, prepayments. The customer provides a tracking code or uploads a receipt image, and staff confirm it. Fields: `destination_bank`, `end_of_card_number`, `tracking_code`, `is_paya`, plus a polymorphic receipt image. -- Online gateway payments will use `transactions` + `gateways` (not built yet): Mellat, Parsian, Zarinpal. These record the gateway result automatically. +**A Zarinpal-paid order will never show a `receipts` row** — nothing in the codebase creates one for an online gateway payment (no observer/event links `Transaction` to `Receipt`); staff seeing an empty Receipts tab on a Zarinpal order in the panel is expected, not a bug. `receipts` only gets rows from the (not-yet-built) manual bank-transfer flow. Keep `receipts` if there is any chance of manual bank transfers (typical for Iranian shops). If the shop ever becomes gateway-only, `transactions`/`gateways` would cover everything and `receipts` could be retired. diff --git a/admin/docs/ShoFlow db doc.md b/admin/docs/ShoFlow db doc.md index 47c4256f..1a67fcfc 100644 --- a/admin/docs/ShoFlow db doc.md +++ b/admin/docs/ShoFlow db doc.md @@ -28,7 +28,7 @@ Implementation notes: * Addresses are immutable history. Editing in the admin panel never updates a row: it creates a NEW address. The new address inherits the edited one's `prime` status (if the edited address was primary the new one becomes primary and the old is demoted; otherwise the new one is created non-primary). The old record is kept so orders that reference an address keep an accurate history. * No delete action is exposed (table, edit page); records are never removed. `deleted_at` (soft delete) stays on the table for future use but is not used by the panel. * One primary per user is enforced by a model `saved` hook that demotes the user's other `prime` addresses. -* `latitude` / `longitude` from the doc are not implemented as columns yet (no current need). +* `latitude` / `longitude` are nullable columns (defined in `create_addresses_table`); the storefront fills them from a Neshan map when the customer adds an address. # Ancestors @@ -449,6 +449,7 @@ Implementation notes: # orders * Used to store orders. +* `tracking_code`: Customer-facing order identifier (a random unique 10-digit number, e.g. `1168407691`). Not in the original doc — added so customers have an opaque tracking code instead of the sequential `id`, which would otherwise leak order volume/growth. Auto-generated on create (see Implementation notes). * `user_id`: Indicates which user the order belongs to. * `coupon_id`: Stores the coupon ID if the order used a coupon; otherwise, it is null. * `coupon_discount`: Specifies the discount amount applied through the coupon. @@ -473,6 +474,7 @@ Implementation notes: * `collector_description`: Collection-related description. * `notifier_id`: Customer notification. * `notified_at`: Customer notification date. +* `address_id`: Specifies the address the order ships to. Not in the original doc — added when online payment/order creation was implemented, since the doc otherwise had no way to record a shipping destination. * `shipping_line_id`: Specifies which shipping line. * `shipping_method_id`: Specifies which shipping method. * `send_description`: Provides the shipping description. @@ -485,6 +487,8 @@ Implementation notes: * `user_id`: Nullable FK to `users`, `nullOnDelete` so orders survive user deletion. * `confirmed_id`, `collector_id`, `notifier_id`: Nullable FKs to `users` (`nullOnDelete`). * `accounting_id`, `bijack_image_id`: Plain nullable columns with no FK constraint, because the accounting table is not built yet and images are stored polymorphically elsewhere. +* `address_id`: Nullable FK to `addresses`, `nullOnDelete`. Addresses are immutable history (edits create a new row), so this always points at the exact address snapshot chosen at checkout. +* `tracking_code`: `string(10)`, unique, not nullable. Generated by a `creating` model event (`random_int(1_000_000_000, 9_999_999_999)`, retried on collision) in both apps' `Order` model — each app writes to the same `orders` table but is a separate Eloquent class, so the generation logic is duplicated rather than shared. Never mass-assignable (not in `$fillable`). * Money columns (`coupon_discount`, `discount`, `shipping_cost`, `total_products_price`, `tax`, `total_price`): `decimal(12,2)`, default `0`. * No `seller_id` (single-vendor). The seller-centric `sub_orders` / `sub_order_logs` tables are intentionally not implemented. * Only the `orders` table is implemented so far; `order_varieties` and the other order_* tables are not built yet. @@ -663,6 +667,7 @@ Implementation notes: * Contains user reviews for each product. * `heading`: The review title written by the user (e.g. "Great product!"). * `content`: The full review text. +* `rating`: 1–5 star rating. Not in the original doc — added when storefront review submission was built. Nullable: replies (`parent_id` set) and admin-entered reviews may carry no rating. The storefront requires it on submit and shows the per-product average from approved reviews. * `user_id`: The user who submitted the review. Nullable; set to null if the user is deleted. * `product_id`: The product being reviewed. Cascade-deletes the review when the product is deleted. * `variety_id`: The specific variety (e.g. size/color) the user purchased. Nullable; set to null if the variety is deleted. diff --git a/admin/lang/en/order.php b/admin/lang/en/order.php index be2aa130..05527cce 100644 --- a/admin/lang/en/order.php +++ b/admin/lang/en/order.php @@ -16,6 +16,7 @@ 'section_collection' => 'Collection', 'section_notification' => 'Customer Notification', + 'tracking_code' => 'Tracking Code', 'user_id' => 'Customer', 'coupon_id' => 'Coupon', 'status' => 'Status', @@ -47,6 +48,7 @@ 'notifier_id' => 'Notified By', 'notified_at' => 'Notified At', + 'address_id' => 'Shipping Address', 'shipping_line_id' => 'Shipping Line', 'shipping_method_id' => 'Shipping Method', 'send_description' => 'Shipping Description', diff --git a/admin/lang/en/product.php b/admin/lang/en/product.php index 52b67c6f..76941289 100644 --- a/admin/lang/en/product.php +++ b/admin/lang/en/product.php @@ -81,6 +81,7 @@ 'variety_inventory' => 'Inventory', 'variety_has_stock' => 'Has Stock', 'variety_status' => 'Status', + 'variety_image' => 'Variety Image', // Table columns 'featured' => 'Featured', diff --git a/admin/lang/en/receipt.php b/admin/lang/en/receipt.php index 02cea1b9..b3f240b5 100644 --- a/admin/lang/en/receipt.php +++ b/admin/lang/en/receipt.php @@ -6,7 +6,7 @@ 'label' => 'Receipt', 'plural_label' => 'Receipts', 'navigation_group' => 'Commerce', - 'subheading' => 'Payment receipts recorded against users and (optionally) orders, including bank transfer details.', + 'subheading' => 'Manual/offline payments only (card-to-card, Paya transfer, prepayment) recorded against users and (optionally) orders. Online gateway payments (Zarinpal, Mellat, Parsian) never appear here — check the Transactions table for those instead.', 'user_id' => 'User', 'card_id' => 'Card ID', diff --git a/admin/lang/en/review.php b/admin/lang/en/review.php index c5554411..aefd0b03 100644 --- a/admin/lang/en/review.php +++ b/admin/lang/en/review.php @@ -12,6 +12,8 @@ 'heading_hint' => 'The review title written by the user (e.g. "Great product!").', 'content' => 'Review Text', 'content_hint' => 'The full review text.', + 'rating' => 'Rating', + 'rating_hint' => 'The 1–5 star rating the user gave. Empty for replies or unrated reviews.', 'product_id' => 'Product', 'product_id_hint' => 'The product this review is about.', 'variety_id' => 'Variety (optional)', diff --git a/admin/lang/en/transaction.php b/admin/lang/en/transaction.php index feaac8e7..0d2a2f0d 100644 --- a/admin/lang/en/transaction.php +++ b/admin/lang/en/transaction.php @@ -6,7 +6,7 @@ 'label' => 'Transaction', 'plural_label' => 'Transactions', 'navigation_group' => 'Commerce', - 'subheading' => 'Online gateway payment transactions (Mellat, Parsian, Zarinpal) recorded against users and orders.', + 'subheading' => 'Online gateway payment transactions (Mellat, Parsian, Zarinpal) recorded against users and orders. Manual/offline payments (card-to-card, Paya, prepayment) never appear here — check the Receipts table for those instead.', 'user_id' => 'User', 'order_id' => 'Order', diff --git a/admin/lang/en/variety.php b/admin/lang/en/variety.php index 860e3499..822e5d91 100644 --- a/admin/lang/en/variety.php +++ b/admin/lang/en/variety.php @@ -27,6 +27,10 @@ 'additional_attributes' => 'Additional Attributes', 'additional_attributes_hint' => 'Secondary attributes for this variety from other groups, e.g. Color when the primary group is Size.', + 'image' => 'Image', + 'path' => 'Image File', + 'alt_text' => 'Alt Text', + 'product' => 'Product', 'attribute_value' => 'Value', 'created_at' => 'Created At', diff --git a/admin/lang/fa/order.php b/admin/lang/fa/order.php index 951ef166..fb186306 100644 --- a/admin/lang/fa/order.php +++ b/admin/lang/fa/order.php @@ -16,6 +16,7 @@ 'section_collection' => 'وصول', 'section_notification' => 'اطلاع‌رسانی به مشتری', + 'tracking_code' => 'کد رهگیری', 'user_id' => 'مشتری', 'coupon_id' => 'کوپن', 'status' => 'وضعیت', @@ -47,6 +48,7 @@ 'notifier_id' => 'اطلاع‌رسان', 'notified_at' => 'تاریخ اطلاع‌رسانی', + 'address_id' => 'نشانی ارسال', 'shipping_line_id' => 'خط ارسال', 'shipping_method_id' => 'روش ارسال', 'send_description' => 'توضیحات ارسال', diff --git a/admin/lang/fa/product.php b/admin/lang/fa/product.php index af51db7d..e6b53df3 100644 --- a/admin/lang/fa/product.php +++ b/admin/lang/fa/product.php @@ -81,6 +81,7 @@ 'variety_inventory' => 'موجودی', 'variety_has_stock' => 'موجود در انبار', 'variety_status' => 'وضعیت', + 'variety_image' => 'تصویر تنوع', // Table columns 'featured' => 'تصویر شاخص', diff --git a/admin/lang/fa/receipt.php b/admin/lang/fa/receipt.php index c9a0440b..c4b151b3 100644 --- a/admin/lang/fa/receipt.php +++ b/admin/lang/fa/receipt.php @@ -6,7 +6,7 @@ 'label' => 'رسید', 'plural_label' => 'رسیدها', 'navigation_group' => 'فروش', - 'subheading' => 'رسیدهای پرداخت ثبت‌شده برای کاربران و (در صورت وجود) سفارش‌ها، همراه با اطلاعات انتقال بانکی.', + 'subheading' => 'فقط پرداخت‌های دستی/آفلاین (کارت‌به‌کارت، انتقال پایا، پیش‌پرداخت) که برای کاربران و (در صورت وجود) سفارش‌ها ثبت شده‌اند. پرداخت‌های درگاهی آنلاین (زرین‌پال، ملت، پارسیان) هرگز اینجا نمایش داده نمی‌شوند — برای آن‌ها به جدول تراکنش‌ها مراجعه کنید.', 'user_id' => 'کاربر', 'card_id' => 'شناسه کارت', diff --git a/admin/lang/fa/review.php b/admin/lang/fa/review.php index 83278f15..469cb279 100644 --- a/admin/lang/fa/review.php +++ b/admin/lang/fa/review.php @@ -12,6 +12,8 @@ 'heading_hint' => 'عنوان نقد نوشته‌شده توسط کاربر (مثلاً "محصول عالی!").', 'content' => 'متن نقد', 'content_hint' => 'متن کامل نقد و بررسی.', + 'rating' => 'امتیاز', + 'rating_hint' => 'امتیاز ۱ تا ۵ ستاره‌ای که کاربر داده. برای پاسخ‌ها یا نقدهای بدون امتیاز خالی است.', 'product_id' => 'محصول', 'product_id_hint' => 'محصولی که این نقد درباره آن است.', 'variety_id' => 'نوع محصول (اختیاری)', diff --git a/admin/lang/fa/transaction.php b/admin/lang/fa/transaction.php index 36b7a69a..d8e4f9d0 100644 --- a/admin/lang/fa/transaction.php +++ b/admin/lang/fa/transaction.php @@ -6,7 +6,7 @@ 'label' => 'تراکنش', 'plural_label' => 'تراکنش‌ها', 'navigation_group' => 'فروش', - 'subheading' => 'تراکنش‌های پرداخت آنلاین درگاه (ملت، پارسیان، زرین‌پال) ثبت‌شده برای کاربران و سفارش‌ها.', + 'subheading' => 'تراکنش‌های پرداخت آنلاین درگاه (ملت، پارسیان، زرین‌پال) ثبت‌شده برای کاربران و سفارش‌ها. پرداخت‌های دستی/آفلاین (کارت‌به‌کارت، پایا، پیش‌پرداخت) هرگز اینجا نمایش داده نمی‌شوند — برای آن‌ها به جدول رسیدها مراجعه کنید.', 'user_id' => 'کاربر', 'order_id' => 'سفارش', diff --git a/admin/lang/fa/variety.php b/admin/lang/fa/variety.php index f769e6d6..800e52ba 100644 --- a/admin/lang/fa/variety.php +++ b/admin/lang/fa/variety.php @@ -27,6 +27,10 @@ 'additional_attributes' => 'ویژگی‌های اضافی', 'additional_attributes_hint' => 'ویژگی‌های ثانویه این تنوع از سایر گروه‌ها، مثلاً رنگ وقتی گروه اصلی سایز است.', + 'image' => 'تصویر', + 'path' => 'فایل تصویر', + 'alt_text' => 'متن جایگزین', + 'product' => 'محصول', 'attribute_value' => 'مقدار', 'created_at' => 'تاریخ ایجاد', diff --git a/admin/tests/Feature/Filament/Resource/AttributeGroupCategoryResourceTest.php b/admin/tests/Feature/Filament/Resource/AttributeGroupCategoryResourceTest.php index 53898882..8bade586 100644 --- a/admin/tests/Feature/Filament/Resource/AttributeGroupCategoryResourceTest.php +++ b/admin/tests/Feature/Filament/Resource/AttributeGroupCategoryResourceTest.php @@ -96,3 +96,14 @@ ]) ->assertActionDoesNotExist(DeleteAction::class); }); + +it('reads the attributeGroups/categories pivot without querying a non-existent order column', function () { + // Regression test: AttributeGroup::categories() and Category::attributeGroups() + // used to declare ->withPivot(['as_filter', 'required', 'order']) even though + // attribute_group_category has no `order` column, which threw a SQLSTATE + // "undefined column" error the instant either relation was queried. + $attributeGroupCategory = AttributeGroupCategory::factory()->create(); + + expect($attributeGroupCategory->attributeGroup->categories()->get())->toHaveCount(1); + expect($attributeGroupCategory->category->attributeGroups()->get())->toHaveCount(1); +}); diff --git a/admin/tests/Feature/Filament/Resource/VarietyResourceTest.php b/admin/tests/Feature/Filament/Resource/VarietyResourceTest.php index 852cda9f..3f1cd0e3 100644 --- a/admin/tests/Feature/Filament/Resource/VarietyResourceTest.php +++ b/admin/tests/Feature/Filament/Resource/VarietyResourceTest.php @@ -147,6 +147,22 @@ ->first()->id->toBe($attribute->id); }); +it('creates a featured image for a variety via the withImage factory state.', function () { + $variety = Variety::factory()->withImage()->create(); + + expect($variety->refresh()->image)->not->toBeNull() + ->and($variety->image->is_featured)->toBeTrue(); +}); + +it('deletes the variety image when the variety is deleted.', function () { + $variety = Variety::factory()->withImage()->create(); + $imageId = $variety->image->id; + + $variety->delete(); + + $this->assertDatabaseMissing('images', ['id' => $imageId]); +}); + it('cascade-deletes variety_attribute pivot rows when variety is deleted.', function () { $variety = Variety::factory()->create(); $attribute = Attribute::factory()->create(); diff --git a/admin/tests/Feature/OrderTest.php b/admin/tests/Feature/OrderTest.php index a9605814..1291a772 100644 --- a/admin/tests/Feature/OrderTest.php +++ b/admin/tests/Feature/OrderTest.php @@ -48,3 +48,15 @@ expect($order->refresh()->user_id)->toBeNull(); }); + +it('auto-generates a unique 10-digit tracking code.', function (): void { + $order = Order::factory()->create(); + + expect($order->tracking_code)->toMatch('/^[1-9]\d{9}$/'); +}); + +it('keeps an explicitly assigned tracking code instead of generating a new one.', function (): void { + $order = Order::factory()->create(['tracking_code' => '1234567890']); + + expect($order->tracking_code)->toBe('1234567890'); +}); diff --git a/infrastructure/docker/docker-compose.yml b/infrastructure/docker/docker-compose.yml index 84c973e0..c7f991f3 100755 --- a/infrastructure/docker/docker-compose.yml +++ b/infrastructure/docker/docker-compose.yml @@ -1,7 +1,7 @@ services: db: - image: postgres:alpine + image: postgres:16-alpine container_name: ${COMPOSE_PROJECT_NAME}_db restart: unless-stopped environment: @@ -30,3 +30,6 @@ services: networks: net: driver: bridge + +volumes: + pgdata: diff --git a/shop/.env.example b/shop/.env.example index c0660ea1..2165eb58 100644 --- a/shop/.env.example +++ b/shop/.env.example @@ -1,9 +1,15 @@ -APP_NAME=Laravel +APP_NAME=ShopFlow APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://localhost +# Base URL for catalog images uploaded via the admin (Filament) app. +# The admin serves its uploads at `/storage`, so point this there. +# Keep this separate from ASSET_URL so the storefront's own Vite build assets +# keep loading from this app. Leave empty if all images use absolute URLs. +IMAGE_URL=http://127.0.0.1:4040/storage + APP_LOCALE=en APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US @@ -20,14 +26,18 @@ LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug -DB_CONNECTION=sqlite -# DB_HOST=127.0.0.1 -# DB_PORT=3306 -# DB_DATABASE=laravel -# DB_USERNAME=root -# DB_PASSWORD= +# Shop reads the shared database owned by the admin app. +# Point these at the same Postgres instance/credentials as admin. +DB_CONNECTION=pgsql +DB_HOST=db +DB_PORT=5432 +DB_DATABASE= +DB_USERNAME= +DB_PASSWORD= -SESSION_DRIVER=database +# Shop does not own the session/cache/jobs tables in the shared DB, +# so it uses file/sync drivers instead of the database driver. +SESSION_DRIVER=file SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ @@ -35,9 +45,9 @@ SESSION_DOMAIN=null BROADCAST_CONNECTION=log FILESYSTEM_DISK=local -QUEUE_CONNECTION=database +QUEUE_CONNECTION=sync -CACHE_STORE=database +CACHE_STORE=file # CACHE_PREFIX= MEMCACHED_HOST=127.0.0.1 @@ -63,3 +73,15 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +# Neshan maps (https://platform.neshan.org). +# SERVICE_KEY (service.*) is required: used server-side for geocoding, reverse +# geocoding and the static map image. +# MAP_KEY (web.*) is optional: only enables the interactive browser map. +NESHAN_MAP_KEY= +NESHAN_SERVICE_KEY= + +# Zarinpal payment gateway (https://zarinpal.com). Defaults to the sandbox +# base URL; any 36-character string works as the merchant_id in sandbox mode. +ZARINPAL_MERCHANT_ID= +ZARINPAL_BASE_URL=https://sandbox.zarinpal.com diff --git a/shop/.prettierignore b/shop/.prettierignore new file mode 100644 index 00000000..d0e4d385 --- /dev/null +++ b/shop/.prettierignore @@ -0,0 +1,5 @@ +public/build +bootstrap/ssr +node_modules +vendor +storage diff --git a/shop/.prettierrc.json b/shop/.prettierrc.json new file mode 100644 index 00000000..9ce0ff35 --- /dev/null +++ b/shop/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 4, + "printWidth": 100, + "trailingComma": "all", + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/shop/AGENTS.md b/shop/AGENTS.md index dc6ce94f..a69110a1 100644 --- a/shop/AGENTS.md +++ b/shop/AGENTS.md @@ -156,9 +156,10 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac ## Running Tests - Run the minimal number of tests, using an appropriate filter, before finalizing. -- To run all tests: `php artisan test --compact`. -- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`. -- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file). +- Use Pest directly, not `php artisan test`. +- To run all tests: `vendor/bin/pest`. +- To run all tests in a file: `vendor/bin/pest tests/Feature/ExampleTest.php`. +- To filter on a particular test name: `vendor/bin/pest --filter=testName` (recommended after making a change to a related file). @@ -178,12 +179,24 @@ This is `shop/`, the customer-facing storefront (Laravel 13 + Inertia). The Fila - 100% type coverage is required: add return types, parameter types, and property/PHPDoc types to every new PHP file. - Commit with this author: `Bahman026 ` (use `git commit --author="Bahman026 "`). - Always ask before committing. NEVER commit without explicit user approval. +- Keep commits separate and atomic: one logical change per commit with its own message. Do not bundle unrelated changes into a single commit. ## Architecture - Laravel + Inertia (server-driven SPA). Controllers return `Inertia::render(...)`; page components live under `resources/js/Pages`. - **The database schema is owned by the admin app.** Do not recreate tables that already exist in `admin/`; add Eloquent models here that map to the shared tables. Coordinate any schema change in the admin app's migrations, then update `docs/ShoFlow db doc.md`. - This is single-vendor commerce; the storefront reads catalog/pricing data and writes carts, orders, addresses, receipts/transactions per the documented rules. +- **Search runs behind the `App\Contracts\ProductSearch` contract** (bound to `DatabaseProductSearch` in `AppServiceProvider`), which does case-insensitive `ILIKE` matching now. Depend on the contract, never the implementation, so an Elasticsearch backend can be swapped in later without touching controllers/actions. It powers both the results page (`/search`) and the header autocomplete (`/search/suggest`). +- **Auth is mobile-first** (`AuthController`, routes under `/login`). OTP is the primary path (and registers on first login); password login is an alternative. Codes live in cache via `SendOtpCode`/`VerifyOtpCode` and are "sent" by a logged stub - replace with a real SMS provider later. The shared `users` table has NOT NULL `email`/`password` and a non-unique `mobile`, so OTP sign-ups seed placeholders (`User::placeholderEmail`) and a random password. `auth.user` + auth `flash` are shared in `HandleInertiaRequests`. +- **Account area** lives under `/account` (auth middleware, `AccountController`). `AccountLayout.vue` is the shared shell (sidebar nav + user card + logout) wrapping `AppLayout`; account pages are `noindex`. Built: dashboard (`Account/Dashboard.vue`) and profile edit (`Account/Profile.vue`, edits name/email; mobile read-only; placeholder email hidden via `User::hasPlaceholderEmail`). Not-yet-built sidebar links render `Account/ComingSoon.vue`. Profile saves flash a generic `status` message (shared in `HandleInertiaRequests`); `UserDTO` shapes the user payload. +- **Order history + single order view**: `/account/orders` (`AccountController@orders` + `GetUserOrders`, paginated newest-first — `->latest()->orderByDesc('id')`, since `created_at` alone ties when two orders land in the same second) lists lightweight order cards (`Account/Orders/Index.vue`); `/account/orders/{order}` (`@showOrder`, 403 if `order.user_id` isn't the current user) reuses `BuildOrderDTO`/`OrderDTO` — the same action built for `Checkout/Confirmation.vue` — so no new DTO was needed for the detail view. The line-items/address/payment-summary body was extracted from `Confirmation.vue` into `Components/Order/OrderDetail.vue` and is shared by both pages; only the success banner/CTA stay page-specific. The customer-facing "order number" shown on `Confirmation.vue`, `Account/Orders/Index.vue`, and `Account/Orders/Show.vue` is `order.trackingCode` (`orders.tracking_code` — a random unique 10-digit string, auto-generated in `Order`'s `creating` model event), never the raw `id`; `id` still drives routing (`/account/orders/{order}`) and is never shown to the customer. Status badge colors are computed client-side (`composables/useOrderStatus.js`), not via an enum `color()` method (that stays admin/Filament-only per convention). **Retry payment** (`Order::isRetryable()`): only true for a `CANCELED` order whose latest transaction has `paid_at === null` — i.e. the customer canceled at Zarinpal or verification failed, never the oversold case (`CompleteCheckoutPayment::failPaidButOversold()` keeps `paid_at` set precisely so retry stays blocked and a manual refund isn't bypassed). `POST /account/orders/{order}/retry` (`@retryOrder` + `RetryOrderPayment`) pays directly, without touching the cart: it re-checks `has_stock`/`inventory` against every original line (all-or-nothing — no partial retry), and if sufficient, resets the **same** order back to `PENDING` (not a clone — its `order_varieties`/address/shipping/totals are untouched) and opens a Zarinpal session for it via `OpenZarinpalSession` — the same action `StartCheckoutPayment` uses, extracted so both flows share the transaction-creation + Zarinpal-request logic. Each attempt adds a new `Transaction` row rather than reusing/deleting a prior one, so a customer who cancels and retries repeatedly accumulates one order with several transactions (a full attempt history), not a new order per attempt — this was a deliberate redesign after cloning-per-retry cluttered the account order list with canceled duplicates. If stock is insufficient anywhere, nothing changes and the customer is redirected back to the order's own page. Because each attempt gets its own Zarinpal authority, `checkout.callback`/`CompleteCheckoutPayment` need no special-casing for retries — the callback resolves by authority to the (same) order either way. `Order::isRetryable()` and `BuildOrderDTO` both pick the *latest* transaction by `id`, not insertion order or a plain `created_at` sort, since a retried order can have several transactions sharing the same second. **Returns list** (`/account/returns`, `@returns`): reuses `Account/Orders/Index.vue` and `GetUserOrders` as-is — `GetUserOrders(User $user, ?OrderStatusEnum $status = null)` takes an optional status filter, and the Vue page takes `title`/`emptyTitle`/`emptyDescription`/`baseUrl` props (all defaulting to the `/account/orders` copy) so `/account/returns` just passes `OrderStatusEnum::RETURNED` and its own copy — no new page or action. Setting an order to `RETURNED` (admin-only, Filament) doesn't restock inventory or touch `receipts`/`transactions` — see `ORDER.md` → "Returned orders" for the full list of what's still manual. +- **Wishlist**: `App\Models\Wishlist` mirrors admin's read-only-from-panel model — schema has a unique `(user_id, product_id)` constraint and **no `session_id`/nullable `user_id`** (unlike `Cart`), so it's strictly auth-only; a guest hitting the toggle route just gets redirected to login by the `auth` middleware. `POST /products/{product}/wishlist` (`WishlistController@toggle` + `App\Actions\Wishlist\ToggleWishlist`) checks for an existing row and deletes it, or creates one — no locking, matching `AddToCart`'s own check-then-act rigor. The heart toggle only lives in `Components/Product/BuyBox.vue` on the product detail page (`Product/Show.vue`) — a deliberate scope decision, not added to `ProductCard.vue`/category/brand/home listings, since that would need a bulk wishlist-membership lookup threaded through every card-producing action (`GetCategoryProducts`, `GetBrandProducts`, `GetProductRows`, etc.). `ProductController@show` computes `isWishlisted` as a sibling Inertia prop next to `product`, exactly like the existing `cartItems` prop — not baked into `ProductDTO`/`BuildProductDetail`, since those have no request/user context and no need to gain one for a single boolean. `/account/wishlist` (`AccountController@wishlist` + `App\Actions\Account\GetUserWishlist`) reuses `BuildProductCard`'s lightweight card array (the same one carousels/category/brand pages use) inside the same `{data, meta}` pagination shape as `GetUserOrders`, rendered by `Account/Wishlist/Index.vue` with its own remove button per row (posts to the same toggle route as the product page). +- **Reviews**: read-side was always there (approved-only, on the product page); submission + star ratings + verified-buyer badge were added later. A `rating` (1–5, **nullable** — replies/admin-entered reviews have none) column was added to the shared `reviews` migration (admin owns it, so the admin model/factory/`ReviewResource`/lang + both `ShoFlow db doc.md` copies were updated in lockstep). Any logged-in user submits via `POST /products/{product}/reviews` (`ReviewController@store` + `App\Actions\Review\CreateReview`), which **always** creates the row `PENDING` — the storefront only ever renders `Review::approved()` rows (filtered in `ProductController@show`'s eager-load), so nothing shows until an admin flips `status` in Filament. `canReview` (is-logged-in) is a sibling Inertia prop like `isWishlisted`; the `ProductReviews.vue` form is swapped for a login prompt when false. **Verified-buyer ("خریدار") badge** is computed at read time, never stored: `App\Actions\Review\FindProductBuyers` takes the reviewers' user ids + the product id and returns which of them have an order in PAID/PROCESSING/SHIPPED/DELIVERED containing that product — explicit `whereIn` on statuses, NOT `>= PAID`, because CANCELED(60)/RETURNED(70) sort above DELIVERED(50). `BuildProductDetail` also computes `averageRating` (round to 1 dp over approved reviews' non-null ratings; null when none) and — bug fix along the way — sets a review's `author` via `User::displayName()`, not the non-existent `User->name` which had left every author blank. +- **Addresses** (`AddressController`, `/account/addresses`) are immutable history: editing creates a NEW row (`UpdateUserAddress`) that inherits `prime` and soft-deletes the old one (kept for order history, hidden from the active list). First address auto-primary; one primary per user via the model `saved` hook; any address can be promoted from the list (`setPrimary`, `PUT /account/addresses/{address}/primary`); delete is soft-only (`destroy`, `DELETE /account/addresses/{address}`) to preserve order history, and deleting the default promotes the newest remaining address. The shared table has no plate/unit columns, so those round-trip through `description` as JSON (`App\Support\AddressDescription`); recipient name uses the account name (no per-address column). Province/city are cascading (`/account/addresses-cities`). **Neshan maps**: the location (lat/long) is a section separate from the province/city selects. Two key types. Picking a point on either map sets lat/long and auto-fills the address via reverse geocoding (`ReverseGeocode`, `/account/addresses-reverse`, service key). With a `web.` map key (`services.neshan.map_key`, `NESHAN_MAP_KEY`, shared per-page) the form shows the interactive `NeshanMap.vue` (draggable marker, client-side tiles, fast). Without a `web.` key the form falls back to `MapPicker.vue`: a draggable Neshan static map (proxied `StaticMap` -> `/account/addresses-static`, service key) with a fixed center pin (the selected point is always the map center), drag-to-pan (pixel delta -> lat/long via Web Mercator) and zoom buttons; the proxy caches images (30 days) and fails gracefully on timeout (the static plan is slow, so the web key is preferred). All Neshan calls use the server-side `service.` key (`NESHAN_SERVICE_KEY`); only the `web.` key ever reaches the browser. Note: `service.` keys are IP-scoped in the Neshan panel, the server's (public) egress IP must be allowed. The nullable `latitude`/`longitude` columns live on the admin-owned `addresses` table (in the `create_addresses_table` migration). +- **Cart** (`carts`, admin-owned: one row per variety line) works for guests and users. `ResolveCartOwner` keys the cart by `user_id` when logged in, else the guest `session_id`; on login `MergeGuestCart` (called from `AuthController@login` with the pre-regeneration session id) folds the guest lines onto the account, combining and clamping to inventory. `CartController` (`/cart`) + `Cart/` actions (`AddToCart`, `GetCartLines`, `BuildCartSummary`) drive add/update/remove; quantity is always clamped to the variety `inventory` server-side. The cart is inventory-neutral (never touches `varieties.inventory`; see `ORDER.md`). Pricing per line is the variety `sale_price ?? price` via `CalculatePricing`; `CartSummaryDTO` totals items, savings and payable. `Cart/Index.vue` renders the checkout stepper (`CheckoutSteps.vue`), `CartLine.vue` rows and `CartSummary.vue`. Add-to-cart is wired in the product `BuyBox` (requires a selected variety). The header badge reads the shared `cart.count` prop (`HandleInertiaRequests`, guarded by `rescue`). +- **Checkout** (`/checkout`, auth). Step 2 is shipping (`CheckoutController@shipping` + `Checkout/Shipping.vue`): pick a saved address (or add one inline when none exist, reusing `AddressFormModal`) and a shipping method; an empty cart redirects back to `/cart`. Shipping methods are resolved per destination by `GetShippingMethods` over the admin-owned `shipping_lines → shipping_methods → shipping_cities` hierarchy (most specific scope wins: exact city > province > nationwide null/null); cost `null` + `pay_on_delivery` means postpaid ("پس‌کرایه"), `0` means free. Changing the address refreshes the list via `/checkout/methods` (JSON, like the cities endpoint). The selected `address_id` + `shipping_method_id` are validated (method must be available for the address) and stored in the session; the cost is added to the summary payable (`CartSummary` `showShipping`/`shipping` props). Seed data is in admin `ShippingSeeder` (پیک ویژه تهران Tehran-only، پست پیشتاز nationwide، تحویل حضوری از فروشگاه pay-on-delivery). Coupons are not built yet. Shop has read-only models `ShippingLine`/`ShippingMethod`/`ShippingCity` for these shared tables. +- **Order creation + Zarinpal payment** (Phase 4). `Checkout/Payment.vue` posts to `PaymentController@initiate`, which re-resolves the session's address/method exactly like the shipping step's payment render does, then **`ValidateCartStock` re-checks every line's live inventory before anything else** (`inStock && count <= inventory`) — rejects back to `/cart` with a flash message if stock changed since the item was added, so a customer is never charged via Zarinpal for something already unavailable. Only then does it call `StartCheckoutPayment`: `CreatePendingOrder` snapshots the cart into one `Order` (`PENDING`, `address_id`, `shipping_method_id`, `shipping_cost`, totals) + one `OrderVariety` per line (price/discount/final_price straight from `CartLineDTO`) inside a `DB::transaction`, then a `PENDING` `Transaction` (`port = ZARINPAL`) is created and `RequestZarinpalPayment` opens a Zarinpal sandbox payment session. The controller redirects via `Inertia::location()` (framework-handled external redirect — no custom client JS needed) to Zarinpal's StartPay page. `PaymentController@callback` (`GET /checkout/callback`) receives Zarinpal's `Authority`/`Status` redirect, looks the `Transaction` up **by `authority`** (never session, since that's the only value guaranteed to survive the round-trip), and is idempotent: an already-`PAID` order short-circuits without re-verifying, and Zarinpal's verify codes 100 (first verify) and 101 (already verified) both count as success. On success, `DecrementInventoryAndMarkPaid` does the Strategy-A row-locked decrement (`docs/ORDER.md`): `lockForUpdate()` each ordered variety sorted by id (consistent lock ordering avoids deadlocks), checks `inventory >= quantity`, decrements, then marks the order `PAID` and transaction `SUCCESS` — any shortfall rolls back untouched and cancels the order instead of overselling. On failure/cancel (`Status=NOK`, verify rejection) the order/transaction are marked `CANCELED`/`FAILED` and kept as an audit trail. The oversold case (the one race `ValidateCartStock` can't prevent — two customers reaching payment for the last unit at once) is handled separately by `CompleteCheckoutPayment::failPaidButOversold()`: Zarinpal's verify already succeeded there (money genuinely captured), so it keeps `ref_id`/`paid_at` and writes a refund-needed `result_message`, instead of a plain `FAILED` that would hide that a manual refund is owed. `PaymentController@confirmation` + `BuildOrderDTO` render `Checkout/Confirmation.vue` with the paid order's snapshot. **Amounts are stored in Toman everywhere** (consistent with the rest of the schema); the ×10 Toman→Rial conversion for Zarinpal happens only in `App\Support\Currency`, at the HTTP boundary. **Gateway credentials live in `config('services.zarinpal.*')`/`.env`** (`ZARINPAL_MERCHANT_ID`, `ZARINPAL_BASE_URL` — defaults to the sandbox), not the admin `gateways` table (nothing is seeded there); revisit if/when Mellat/Parsian are added and real gateway *selection* is needed. Manual receipt payment and coupon application at checkout are not built yet. +- **PWA / install** is wired via `public/manifest.webmanifest` + `public/icons/*` + Apple meta tags in `app.blade.php`. `InstallPrompt.vue` (mounted in `AppLayout`) is an iOS-Safari-only guided "Add to Home Screen" bottom-sheet (iOS has no native prompt); it skips standalone mode and snoozes 7 days after dismissal (`localStorage`). Android/desktop Chrome rely on the native manifest install prompt. ## Frontend (Inertia + Vue) @@ -199,7 +212,15 @@ The shop UI uses **Inertia + Vue 3** (SSR enabled). Clean, readable code is a ha - Extract repeated logic into composables under `resources/js/composables/` (e.g. `useCart.ts`). - Style with Tailwind utility classes, RTL-first (see fonts/RTL section). No inline styles, no copy-pasted markup; reuse components. - **Brand color is `#ff8615`.** It is registered in `resources/css/app.css` as `--color-brand`, so use the Tailwind `brand` utilities (`bg-brand`, `text-brand`, `border-brand`, ...) for primary actions and accents. Do not hardcode the hex in components. +- **Icons: FontAwesome only, for a uniform icon set.** Always render icons through the shared `` component (`resources/js/Components/Icon.vue`). Never use raw SVGs, emoji, or another icon library, and do not place `` directly in components. + - Register every icon as an object in `resources/js/fontawesome.js` (solid from `@fortawesome/free-solid-svg-icons`, brands from `@fortawesome/free-brands-svg-icons`) and pass the imported icon object: ``. + - Do NOT use string names with `library.add` (e.g. `['fab','instagram']`). Inertia turns FontAwesome's missing-icon `console.error` into an SSR exception, so string lookups break SSR. Passing icon objects is the SSR-safe, tree-shakeable pattern. - Pages must use Inertia's `` for SEO tags (see SEO section) and render meaningful content server-side. +- **SSR runtime**: SSR is served by a Node process (`php artisan inertia:start-ssr`, port 13714). It has no auto-restart and Inertia's SSR server crashes the whole process on a bad request body (it `JSON.parse`s with no error handling), after which every page silently falls back to client-side (empty `
`, no `data-server-rendered`). If pages stop rendering server-side, restart it and never POST malformed payloads to the render endpoint. After `npm run build`, restart SSR so it serves the new bundle. +- **Lint & format the frontend** (the JS/Vue equivalent of Pint/PHPStan): + - **Prettier** formats `resources/js` (config in `.prettierrc.json`: 4-space indent, single quotes, semicolons, `printWidth` 100, Tailwind class sorting via `prettier-plugin-tailwindcss`). + - **ESLint** (flat config in `eslint.config.js`: `eslint-plugin-vue` recommended + `@vue/eslint-config-prettier`) analyses and auto-fixes Vue/JS issues. + - Scripts: `npm run format` (write) / `npm run format:check`, `npm run lint` / `npm run lint:fix`. Run them before finishing frontend work; `composer test-dev` also runs `lint` + `format:check`. ## Language, RTL & fonts @@ -227,9 +248,21 @@ When adding a new feature, build files in this order, matching existing files: 1. Migration (only when a genuinely new table is needed — most already exist via admin) 2. Model, factory, seeder -3. Controller + Inertia page (or Eloquent API Resource for JSON endpoints) +3. DTOs + Actions for the page/endpoint data, then a thin Controller + Inertia page (or Eloquent API Resource for JSON endpoints) 4. Test +## Server-side structure (controllers, actions, DTOs) + +Keep controllers thin and push logic into single-purpose actions that return typed DTOs. See `ProductController` + `app/Actions/Product/*` + `app/DTOs/*` as the reference. + +- **Controllers** only resolve the request: load the model(s), call actions, and hand the result to `Inertia::render(...)`. No payload shaping or business logic in the controller. Inject actions via method (or constructor) parameters; the container resolves them. +- **Actions** live in `app/Actions//` (e.g. `Actions/Catalog`, `Actions/Product`), one responsibility per class, invoked via `__invoke(...)`. Reusable, cross-page actions (image/price shaping) go under `Actions/Catalog`; page-specific ones under their feature folder. Actions depend on other actions through constructor injection. +- **DTOs** live in `app/DTOs/`, **one per model** (`ProductDTO`, `VarietyDTO`, `ImageDTO`, `ReviewDTO`, ...). They are `readonly` classes using constructor property promotion with `camelCase` properties that match the Inertia/JSON keys the frontend expects. + - Do not create DTOs for small value shapes (prices, links, breadcrumbs, variant axes/options). Type those as PHPDoc array shapes instead, e.g. `array{price: int, salePrice: int|null, discountPercent: int|null}` or `array{heading: string, url: string}`. + - Provide a `toArray(): array` for the Inertia boundary. Flat DTOs may use `get_object_vars($this)`; DTOs holding nested DTOs convert them explicitly in `toArray()`. Add a `fromArray(array $data): self` only when something actually hydrates the DTO from an array (e.g. cache payloads, queue jobs) — don't add it speculatively. + - Actions return DTOs (or plain typed arrays for value shapes / lightweight cards); the controller calls `->toArray()` on DTOs at the Inertia boundary so the frontend receives plain nested arrays. Do not pass DTO objects straight into `Inertia::render` (Inertia testing reads array keys, not object properties). +- **Type every query closure** (100% type coverage requires it). Eager-load constraints inside `with([...])` receive a `Illuminate\Database\Eloquent\Relations\Relation` — type the param as `Relation` and use base query methods. Larastan can't resolve model scopes (`published()`, `active()`) on a bare `Relation`, so inline the filter instead, e.g. `fn (Relation $query) => $query->where('status', VarietyStatusEnum::PUBLISHED->value)`. `tap()` closures get a `Builder`; `map()`/`filter()`/`each()` over an Eloquent collection get the model type (`fn (Variety $variety) => ...`). + ## Models - Declare `protected $fillable` (array) and `protected $casts` (array property; `User` uses the `casts()` method). @@ -238,6 +271,7 @@ When adding a new feature, build files in this order, matching existing files: - Type all relationship methods with their return type (`HasMany`, `BelongsTo`, `MorphOne`, `MorphMany`, `BelongsToMany`). - Query scopes are typed: `public function scopePublished(Builder $query): Builder`. - Model-event logic goes in `booted()`. +- **Don't assume a shared-schema column is non-null just because the primary storefront flow always sets it.** `users.mobile` is nullable at the DB level (admin/staff accounts created via Filament have none) even though every OTP-registered customer has one; `UserDTO::$mobile` and `User::displayName()`'s fallback both used to assume otherwise and threw a `TypeError` the moment an admin account ended up authenticated on the storefront guard. Check the actual migration's nullability, not just what the primary use case guarantees. ## Enums @@ -274,15 +308,33 @@ When adding a new feature, build files in this order, matching existing files: ## Tests -- This project uses **Pest** (not PHPUnit — this overrides the auto-generated boost note above). Write tests as Pest functions (`it(...)`, `test(...)`, `expect(...)`) with `declare(strict_types=1);`. Create them with `php artisan make:test --pest {name}`. -- Global setup lives in `tests/Pest.php`: `Feature` tests use `TestCase` + `RefreshDatabase`. -- Most tests should be feature tests. Use factories (and their custom states) to build data. Assert with `assertDatabaseHas(...)`, `assertModelMissing(...)`, or `expect($model->refresh())`. +- This project uses **Pest** (not PHPUnit — this overrides the auto-generated boost note above). Write tests as Pest functions (`it(...)`, `test(...)`, `expect(...)`) with `declare(strict_types=1);`. Create them with `php artisan make:test --pest {name}`. Run them with `vendor/bin/pest`, not `php artisan test`. +- Global setup lives in `tests/Pest.php`: `Feature` tests use `TestCase` + `DatabaseTransactions` (each test runs in a transaction that is rolled back). +- **Shared database, not sqlite.** The shop is a read-only consumer of the admin-owned schema, so tests run against a real Postgres test database (`shop_flow_test`) whose schema is built by **admin's** migrations — never the shop's. The shop must not own or migrate those tables. Do not switch tests back to sqlite/`RefreshDatabase`; that hides schema drift from production. +- **One-time local setup** (run from the admin container, pointing at the test DB): + +```bash +createdb -U shop_flow shop_flow_test # or CREATE DATABASE shop_flow_test; +cd admin +DB_DATABASE=shop_flow_test php artisan migrate --force +DB_DATABASE=shop_flow_test php artisan db:seed --class="Database\Seeders\SettingSeeder" --force +``` + +- Most tests should be feature tests. Build test rows with factories inside the test (they roll back via the transaction). Assert with `assertDatabaseHas(...)`, `assertModelMissing(...)`, or `expect($model->refresh())`. - Run the minimum tests needed with a filter before finalizing, then `composer test-dev` for the full suite. ## Business constraints - **No seller / marketplace system.** ShopFlow is a single-vendor store. Never add `seller_id`, `seller_creator_id`, or any seller relation to models, migrations, or controllers. - **Inventory**: stock is decremented only on successful payment (Strategy A); carts never change inventory. See `docs/ORDER.md`. +- **Quantity cap**: the customer can never choose a quantity above the selected variety's available stock. The quantity input on the product/cart pages must clamp to the variety `inventory`; the add-to-cart action must reject amounts beyond it. +- **Catalog filtering**: attribute filters on the category page match products through the `product_attribute` pivot (`Product::attributes()`) — the schema's documented "filters to products" link — never through `varieties`/`attribute_variety` (those drive the product page's variety selector). `attribute_group_category.as_filter` decides which groups appear as filters (resolved across the category and its descendants). Facet within a group is OR, across groups is AND. + - Facets only list values actually attached to products in the category, and each option carries a product `count` (category-level baseline, not recomputed against the other active selections). + - Facet groups render in `attribute_groups.order` (admin-configured), not alphabetically by name — `GetCategoryFilters::attributeGroups()` orders by `order` then `name` as a tiebreak. Displayed text uses `attribute_groups.name`; `label` is documented as admin-panel-only (per `ShoFlow db doc.md`) and is never shown to customers. + - An availability filter (`in_stock` → `products.has_stock`) and a price range (on `products.price`, the denormalized cheapest-variety base price) are also supported. + - Filter UI is Digikala-style (`CategoryFilters.vue`): availability toggle, price range slider, brand list with a search box, collapsible accordion sections, per-option counts, and instant apply on change. +- **Product gallery**: show all images together — the product images plus every variety image, combined and deduped by URL. Never hide images based on the selection; selecting a variety only switches the main image to that variety's photo (when it exists in the list). +- **Product cards**: the card image is the product's `featuredImage`, falling back to the first variety image so cards still show a photo when the product has no product-level image (eager-load `varieties.image` wherever cards are built to avoid N+1). Cards link via `AppLink` with `new-tab` so products open in a new tab. - **Addresses are immutable history.** Editing an address creates a new record (inheriting the edited one's primary status); addresses are never deleted, so orders keep an accurate history. ## Roadmap & docs diff --git a/shop/app/Actions/Account/BuildAddressDTO.php b/shop/app/Actions/Account/BuildAddressDTO.php new file mode 100644 index 00000000..77187220 --- /dev/null +++ b/shop/app/Actions/Account/BuildAddressDTO.php @@ -0,0 +1,37 @@ +description); + $city = $address->city; + $province = $city->province; + + return new AddressDTO( + id: $address->id, + name: $address->name, + phone: $address->phone, + postalCode: $address->postal_code, + address: $address->address, + plate: $parts['plate'], + unit: $parts['unit'], + note: $parts['note'], + latitude: $address->latitude, + longitude: $address->longitude, + cityId: $address->city_id, + cityName: $city->name, + provinceId: $province->id, + provinceName: $province->name, + prime: $address->prime, + ); + } +} diff --git a/shop/app/Actions/Account/GetUserOrders.php b/shop/app/Actions/Account/GetUserOrders.php new file mode 100644 index 00000000..341800fa --- /dev/null +++ b/shop/app/Actions/Account/GetUserOrders.php @@ -0,0 +1,91 @@ +>, meta: array{currentPage: int, lastPage: int, perPage: int, total: int, from: int|null, to: int|null}} + */ + public function __invoke(User $user, ?OrderStatusEnum $status = null): array + { + $query = Order::query()->where('user_id', $user->id); + + if ($status !== null) { + $query->where('status', $status); + } + + $paginator = $query + ->with(['orderVarieties.product.featuredImage', 'orderVarieties.variety.image']) + // created_at alone isn't a reliable sort: orders created within the + // same second would tie with no deterministic order, so id breaks it. + ->latest() + ->orderByDesc('id') + ->paginate(self::PER_PAGE) + ->withQueryString(); + + /** @var array $items */ + $items = $paginator->items(); + + return [ + 'data' => array_map(fn (Order $order): array => $this->card($order), $items), + 'meta' => [ + 'currentPage' => $paginator->currentPage(), + 'lastPage' => $paginator->lastPage(), + 'perPage' => $paginator->perPage(), + 'total' => $paginator->total(), + 'from' => $paginator->firstItem(), + 'to' => $paginator->lastItem(), + ], + ]; + } + + /** + * @return array + */ + private function card(Order $order): array + { + /** @var OrderVariety|null $firstLine */ + $firstLine = $order->orderVarieties->first(); + $product = $firstLine?->product; + $variety = $firstLine?->variety; + + $image = $this->transformImage->__invoke( + // @phpstan-ignore nullsafe.neverNull (nullable snapshot FKs; see BuildOrderDTO::line()) + $variety?->image ?? $product?->featuredImage, + ); + + return [ + 'id' => $order->id, + 'trackingCode' => $order->tracking_code, + 'status' => $order->status->name, + 'statusLabel' => $order->status->label(), + 'createdAt' => (string) $order->created_at?->toIso8601String(), + 'totalPrice' => $order->total_price, + 'itemCount' => (int) $order->orderVarieties->sum('quantity'), + // @phpstan-ignore nullsafe.neverNull (nullable snapshot FK; see BuildOrderDTO::line()) + 'firstItemHeading' => $product?->heading ?? 'محصول حذف‌شده', + 'image' => $image?->toArray(), + 'url' => '/account/orders/'.$order->id, + ]; + } +} diff --git a/shop/app/Actions/Account/GetUserWishlist.php b/shop/app/Actions/Account/GetUserWishlist.php new file mode 100644 index 00000000..8128bfe0 --- /dev/null +++ b/shop/app/Actions/Account/GetUserWishlist.php @@ -0,0 +1,56 @@ +>, meta: array{currentPage: int, lastPage: int, perPage: int, total: int, from: int|null, to: int|null}} + */ + public function __invoke(User $user): array + { + $paginator = Wishlist::query() + ->where('user_id', $user->id) + ->with(['product.featuredImage', 'product.varieties.image']) + // created_at alone isn't a reliable sort: rows saved within the + // same second would tie with no deterministic order, so id + // breaks it (same class of bug fixed in GetUserOrders). + ->latest() + ->orderByDesc('id') + ->paginate(self::PER_PAGE) + ->withQueryString(); + + /** @var array $items */ + $items = $paginator->items(); + + return [ + 'data' => array_map(fn (Wishlist $wishlist): array => ($this->buildProductCard)($wishlist->product), $items), + 'meta' => [ + 'currentPage' => $paginator->currentPage(), + 'lastPage' => $paginator->lastPage(), + 'perPage' => $paginator->perPage(), + 'total' => $paginator->total(), + 'from' => $paginator->firstItem(), + 'to' => $paginator->lastItem(), + ], + ]; + } +} diff --git a/shop/app/Actions/Account/ReverseGeocode.php b/shop/app/Actions/Account/ReverseGeocode.php new file mode 100644 index 00000000..f775fdda --- /dev/null +++ b/shop/app/Actions/Account/ReverseGeocode.php @@ -0,0 +1,43 @@ + $key]) + ->timeout(8) + ->get('https://api.neshan.org/v5/reverse', [ + 'lat' => $latitude, + 'lng' => $longitude, + ]); + } catch (Throwable) { + return null; + } + + if (! $response->successful()) { + return null; + } + + $address = $response->json('formatted_address'); + + return is_string($address) && $address !== '' ? $address : null; + } +} diff --git a/shop/app/Actions/Account/StaticMap.php b/shop/app/Actions/Account/StaticMap.php new file mode 100644 index 00000000..f9bfba26 --- /dev/null +++ b/shop/app/Actions/Account/StaticMap.php @@ -0,0 +1,67 @@ +retry(2, 200)->get('https://api.neshan.org/v5/static', [ + 'key' => $key, + 'type' => 'standard-day', + 'zoom' => $zoom, + 'latitude' => $latitude, + 'longitude' => $longitude, + 'width' => 600, + 'height' => 350, + ]); + } catch (Throwable) { + return null; + } + + if (! $response->successful()) { + return null; + } + + $contentType = $response->header('Content-Type'); + + $image = [ + 'body' => $response->body(), + 'contentType' => $contentType !== '' ? $contentType : 'image/png', + ]; + + Cache::put($cacheKey, $image, now()->addDays(30)); + + return $image; + } +} diff --git a/shop/app/Actions/Account/StoreUserAddress.php b/shop/app/Actions/Account/StoreUserAddress.php new file mode 100644 index 00000000..6fb008fe --- /dev/null +++ b/shop/app/Actions/Account/StoreUserAddress.php @@ -0,0 +1,31 @@ + $data + */ + public function __invoke(User $user, array $data): Address + { + return Address::create([ + 'user_id' => $user->id, + 'name' => $data['name'], + 'phone' => $data['phone'], + 'postal_code' => $data['postal_code'], + 'address' => $data['address'], + 'latitude' => $data['latitude'], + 'longitude' => $data['longitude'], + 'description' => AddressDescription::encode($data['plate'], $data['unit'], $data['note']), + 'city_id' => $data['city_id'], + 'prime' => $data['prime'], + ]); + } +} diff --git a/shop/app/Actions/Account/UpdateUserAddress.php b/shop/app/Actions/Account/UpdateUserAddress.php new file mode 100644 index 00000000..45d12ffa --- /dev/null +++ b/shop/app/Actions/Account/UpdateUserAddress.php @@ -0,0 +1,42 @@ + $data + */ + public function __invoke(Address $old, array $data): Address + { + return DB::transaction(function () use ($old, $data): Address { + $new = Address::create([ + 'user_id' => $old->user_id, + 'name' => $data['name'], + 'phone' => $data['phone'], + 'postal_code' => $data['postal_code'], + 'address' => $data['address'], + 'latitude' => $data['latitude'], + 'longitude' => $data['longitude'], + 'description' => AddressDescription::encode($data['plate'], $data['unit'], $data['note']), + 'city_id' => $data['city_id'], + 'prime' => $data['prime'] || $old->prime, + ]); + + $old->delete(); + + return $new; + }); + } +} diff --git a/shop/app/Actions/Auth/NormalizeMobile.php b/shop/app/Actions/Auth/NormalizeMobile.php new file mode 100644 index 00000000..6ef15010 --- /dev/null +++ b/shop/app/Actions/Auth/NormalizeMobile.php @@ -0,0 +1,36 @@ + '0', '۱' => '1', '۲' => '2', '۳' => '3', '۴' => '4', + '۵' => '5', '۶' => '6', '۷' => '7', '۸' => '8', '۹' => '9', + '٠' => '0', '١' => '1', '٢' => '2', '٣' => '3', '٤' => '4', + '٥' => '5', '٦' => '6', '٧' => '7', '٨' => '8', '٩' => '9', + ]); + + $digits = preg_replace('/\D+/', '', $digits) ?? ''; + + // Accept 98xxxxxxxxxx and 0098xxxxxxxxxx variants too. + if (str_starts_with($digits, '0098')) { + $digits = '0'.substr($digits, 4); + } elseif (str_starts_with($digits, '98') && strlen($digits) === 12) { + $digits = '0'.substr($digits, 2); + } elseif (str_starts_with($digits, '9') && strlen($digits) === 10) { + $digits = '0'.$digits; + } + + return preg_match('/^09\d{9}$/', $digits) === 1 ? $digits : null; + } +} diff --git a/shop/app/Actions/Auth/SendOtpCode.php b/shop/app/Actions/Auth/SendOtpCode.php new file mode 100644 index 00000000..18804be1 --- /dev/null +++ b/shop/app/Actions/Auth/SendOtpCode.php @@ -0,0 +1,83 @@ +current($mobile); + + if ($existing !== null && $existing['expires_at'] > now()->getTimestamp()) { + return $existing['code']; + } + + $code = str_pad((string) random_int(0, 10 ** self::LENGTH - 1), self::LENGTH, '0', STR_PAD_LEFT); + + Cache::put($this->key($mobile), [ + 'code' => $code, + 'attempts' => 0, + 'expires_at' => now()->addSeconds(self::TTL)->getTimestamp(), + ], now()->addSeconds(self::TTL)); + + Log::info('OTP sent', ['mobile' => $mobile, 'code' => $code]); + + return $code; + } + + /** + * Seconds remaining before a new code can be requested (0 when none is + * active). + */ + public function secondsRemaining(string $mobile): int + { + $stored = $this->current($mobile); + + if ($stored === null) { + return 0; + } + + return max(0, $stored['expires_at'] - now()->getTimestamp()); + } + + /** + * @return array{code: string, attempts: int, expires_at: int}|null + */ + private function current(string $mobile): ?array + { + /** @var array{code: string, attempts: int, expires_at: int}|null $stored */ + $stored = Cache::get($this->key($mobile)); + + return $stored; + } + + public static function key(string $mobile): string + { + return 'otp:'.$mobile; + } +} diff --git a/shop/app/Actions/Auth/VerifyOtpCode.php b/shop/app/Actions/Auth/VerifyOtpCode.php new file mode 100644 index 00000000..8acc856b --- /dev/null +++ b/shop/app/Actions/Auth/VerifyOtpCode.php @@ -0,0 +1,59 @@ +getTimestamp()) { + Cache::forget($key); + + return false; + } + + if (hash_equals($stored['code'], $code)) { + Cache::forget($key); + + return true; + } + + $attempts = $stored['attempts'] + 1; + $remaining = $stored['expires_at'] - now()->getTimestamp(); + + if ($attempts >= self::MAX_ATTEMPTS || $remaining <= 0) { + Cache::forget($key); + } else { + // Keep the original expiry; wrong tries must not extend the window. + Cache::put($key, [ + 'code' => $stored['code'], + 'attempts' => $attempts, + 'expires_at' => $stored['expires_at'], + ], now()->addSeconds($remaining)); + } + + return false; + } +} diff --git a/shop/app/Actions/Brand/BuildBrandBreadcrumbs.php b/shop/app/Actions/Brand/BuildBrandBreadcrumbs.php new file mode 100644 index 00000000..1fedf270 --- /dev/null +++ b/shop/app/Actions/Brand/BuildBrandBreadcrumbs.php @@ -0,0 +1,23 @@ + + */ + public function __invoke(Brand $brand): array + { + return [ + ['heading' => 'خانه', 'url' => '/'], + ['heading' => $brand->heading, 'url' => null], + ]; + } +} diff --git a/shop/app/Actions/Brand/BuildBrandDetail.php b/shop/app/Actions/Brand/BuildBrandDetail.php new file mode 100644 index 00000000..325c6062 --- /dev/null +++ b/shop/app/Actions/Brand/BuildBrandDetail.php @@ -0,0 +1,29 @@ +id, + heading: $brand->heading, + url: '/brands/'.$brand->slug, + title: $brand->title, + description: $brand->description, + content: $brand->content, + noIndex: (bool) $brand->no_index, + canonical: $brand->canonical, + image: ($this->transformImage)($brand->image), + ); + } +} diff --git a/shop/app/Actions/Brand/GetBrandFilters.php b/shop/app/Actions/Brand/GetBrandFilters.php new file mode 100644 index 00000000..fb81e3e8 --- /dev/null +++ b/shop/app/Actions/Brand/GetBrandFilters.php @@ -0,0 +1,70 @@ +, minPrice: int|null, maxPrice: int|null, inStock: bool, sort: string} $filters + * @return array{categories: array>, price: array{min: int, max: int}} + */ + public function __invoke(int $brandId, array $filters): array + { + return [ + 'categories' => $this->categories($brandId, $filters['categories']), + 'price' => $this->priceBounds($brandId), + ]; + } + + /** + * @param array $selected + * @return array> + */ + private function categories(int $brandId, array $selected): array + { + $counts = Product::query() + ->published() + ->where('brand_id', $brandId) + ->whereNotNull('category_id') + ->selectRaw('category_id, count(*) as aggregate') + ->groupBy('category_id') + ->pluck('aggregate', 'category_id'); + + return Category::query() + ->active() + ->whereIn('id', $counts->keys()->all()) + ->orderBy('heading') + ->get() + ->map(fn (Category $category): array => [ + 'id' => $category->id, + 'heading' => $category->heading, + 'slug' => $category->slug, + 'count' => (int) ($counts[$category->id] ?? 0), + 'selected' => in_array($category->slug, $selected, true), + ]) + ->all(); + } + + /** + * @return array{min: int, max: int} + */ + private function priceBounds(int $brandId): array + { + $base = Product::query() + ->published() + ->where('brand_id', $brandId); + + return [ + 'min' => (int) ((clone $base)->min('price') ?? 0), + 'max' => (int) ((clone $base)->max('price') ?? 0), + ]; + } +} diff --git a/shop/app/Actions/Brand/GetBrandProducts.php b/shop/app/Actions/Brand/GetBrandProducts.php new file mode 100644 index 00000000..b3e96996 --- /dev/null +++ b/shop/app/Actions/Brand/GetBrandProducts.php @@ -0,0 +1,86 @@ +, minPrice: int|null, maxPrice: int|null, inStock: bool, sort: string} $filters + * @return array{data: array>, meta: array{currentPage: int, lastPage: int, perPage: int, total: int, from: int|null, to: int|null}} + */ + public function __invoke(int $brandId, array $filters): array + { + $query = Product::query() + ->published() + ->where('brand_id', $brandId) + ->with([ + 'featuredImage', + 'varieties' => fn (Relation $relation) => $relation->where('status', VarietyStatusEnum::PUBLISHED->value)->with('image'), + ]); + + if ($filters['categories'] !== []) { + $query->whereHas('category', fn (Builder $category) => $category->whereIn('slug', $filters['categories'])); + } + + if ($filters['minPrice'] !== null) { + $query->where('price', '>=', $filters['minPrice']); + } + + if ($filters['maxPrice'] !== null) { + $query->where('price', '<=', $filters['maxPrice']); + } + + if ($filters['inStock']) { + $query->where('has_stock', true); + } + + $this->applySort($query, $filters['sort']); + + $paginator = $query->paginate(self::PER_PAGE)->withQueryString(); + + /** @var array $items */ + $items = $paginator->items(); + + return [ + 'data' => array_map(fn (Product $product): array => ($this->buildProductCard)($product), $items), + 'meta' => [ + 'currentPage' => $paginator->currentPage(), + 'lastPage' => $paginator->lastPage(), + 'perPage' => $paginator->perPage(), + 'total' => $paginator->total(), + 'from' => $paginator->firstItem(), + 'to' => $paginator->lastItem(), + ], + ]; + } + + /** + * @param Builder $query + */ + private function applySort(Builder $query, string $sort): void + { + match ($sort) { + 'cheapest' => $query->orderBy('price'), + 'expensive' => $query->orderByDesc('price'), + 'popular' => $query->orderByDesc('seen'), + default => $query->orderByDesc('id'), + }; + } +} diff --git a/shop/app/Actions/Cart/AddToCart.php b/shop/app/Actions/Cart/AddToCart.php new file mode 100644 index 00000000..576409d4 --- /dev/null +++ b/shop/app/Actions/Cart/AddToCart.php @@ -0,0 +1,41 @@ +where($owner) + ->where('variety_id', $variety->id) + ->first(); + + $current = $line === null ? 0 : $line->count; + $desired = $current + max(1, $count); + $clamped = max(1, min($desired, $variety->inventory)); + + if ($line === null) { + return Cart::query()->create([ + ...$owner, + 'variety_id' => $variety->id, + 'count' => $clamped, + ]); + } + + $line->update(['count' => $clamped]); + + return $line; + } +} diff --git a/shop/app/Actions/Cart/BuildCartSummary.php b/shop/app/Actions/Cart/BuildCartSummary.php new file mode 100644 index 00000000..fccc09c7 --- /dev/null +++ b/shop/app/Actions/Cart/BuildCartSummary.php @@ -0,0 +1,31 @@ + $lines + */ + public function __invoke(Collection $lines): CartSummaryDTO + { + $itemsTotal = (int) $lines->sum(fn (CartLineDTO $line): int => $line->lineOriginalTotal()); + $payable = (int) $lines->sum(fn (CartLineDTO $line): int => $line->lineTotal()); + + return new CartSummaryDTO( + count: (int) $lines->sum(fn (CartLineDTO $line): int => $line->count), + itemsTotal: $itemsTotal, + discount: $itemsTotal - $payable, + payable: $payable, + ); + } +} diff --git a/shop/app/Actions/Cart/GetCartLines.php b/shop/app/Actions/Cart/GetCartLines.php new file mode 100644 index 00000000..dc28a043 --- /dev/null +++ b/shop/app/Actions/Cart/GetCartLines.php @@ -0,0 +1,85 @@ + + */ + public function __invoke(array $owner): Collection + { + return Cart::query() + ->where($owner) + ->with([ + 'variety.product.featuredImage', + 'variety.image', + 'variety.attribute.attributeGroup', + 'variety.attributes.attributeGroup', + ]) + ->latest() + ->get() + ->map(fn (Cart $line): CartLineDTO => $this->line($line)) + ->values(); + } + + private function line(Cart $line): CartLineDTO + { + $variety = $line->variety; + $product = $variety->product; + $pricing = $this->pricing->forVariety($variety); + + $image = $this->transformImage->__invoke( + $variety->image ?? $product->featuredImage, + ); + + return new CartLineDTO( + id: $line->id, + varietyId: $variety->id, + heading: $product->heading, + url: '/products/'.$product->slug, + image: $image, + color: $variety->color, + attributes: $this->attributes($variety), + unitPrice: $pricing['salePrice'] ?? $pricing['price'], + originalPrice: $pricing['price'], + discountPercent: $pricing['discountPercent'], + count: $line->count, + inventory: $variety->inventory, + inStock: $variety->has_stock && $variety->inventory > 0, + ); + } + + /** + * @return array + */ + private function attributes(Variety $variety): array + { + return collect($this->varietyAttributes->__invoke($variety)) + ->map(fn (Attribute $attribute): array => [ + 'group' => $attribute->attributeGroup?->name, + 'value' => $attribute->value, + ]) + ->all(); + } +} diff --git a/shop/app/Actions/Cart/MergeGuestCart.php b/shop/app/Actions/Cart/MergeGuestCart.php new file mode 100644 index 00000000..ddfd020f --- /dev/null +++ b/shop/app/Actions/Cart/MergeGuestCart.php @@ -0,0 +1,51 @@ +where('session_id', $sessionId) + ->whereNull('user_id') + ->with('variety') + ->get(); + + foreach ($guestLines as $guestLine) { + $existing = Cart::query() + ->where('user_id', $user->id) + ->where('variety_id', $guestLine->variety_id) + ->first(); + + if ($existing === null) { + $guestLine->update([ + 'user_id' => $user->id, + 'session_id' => null, + ]); + + continue; + } + + $cap = $guestLine->variety->inventory; + $existing->update([ + 'count' => max(1, min($existing->count + $guestLine->count, $cap)), + ]); + $guestLine->delete(); + } + } +} diff --git a/shop/app/Actions/Cart/ResolveCartOwner.php b/shop/app/Actions/Cart/ResolveCartOwner.php new file mode 100644 index 00000000..37b9c69a --- /dev/null +++ b/shop/app/Actions/Cart/ResolveCartOwner.php @@ -0,0 +1,28 @@ +user(); + + if ($user instanceof User) { + return ['user_id' => $user->id]; + } + + return ['session_id' => $request->session()->getId()]; + } +} diff --git a/shop/app/Actions/Catalog/BuildProductCard.php b/shop/app/Actions/Catalog/BuildProductCard.php new file mode 100644 index 00000000..6e4bdd7d --- /dev/null +++ b/shop/app/Actions/Catalog/BuildProductCard.php @@ -0,0 +1,54 @@ + + */ + public function __invoke(Product $product): array + { + /** @var Collection $varieties */ + $varieties = $product->varieties; + + $pricing = $this->pricing->forVarieties($varieties, (int) $product->price); + + return [ + 'id' => $product->id, + 'heading' => $product->heading, + 'url' => '/products/'.$product->slug, + 'image' => ($this->transformImage)($this->cardImage($product))?->toArray(), + 'price' => $pricing['price'], + 'salePrice' => $pricing['salePrice'], + 'discountPercent' => $pricing['discountPercent'], + ]; + } + + /** + * The product's featured image, falling back to the first variety image so + * cards still show a photo when the product has no product-level image + * (mirrors the detail gallery, which also surfaces variety images). + */ + private function cardImage(Product $product): ?Image + { + return $product->featuredImage + ?? $product->varieties + ->first(fn (Variety $variety): bool => $variety->image !== null)?->image; + } +} diff --git a/shop/app/Actions/Catalog/CalculatePricing.php b/shop/app/Actions/Catalog/CalculatePricing.php new file mode 100644 index 00000000..dc7a5aaa --- /dev/null +++ b/shop/app/Actions/Catalog/CalculatePricing.php @@ -0,0 +1,56 @@ + $varieties + * @return array{price: int, salePrice: int|null, discountPercent: int|null} + */ + public function forVarieties(Collection $varieties, int $fallbackPrice): array + { + $price = (int) ($varieties->min('price') ?? $fallbackPrice); + + $rawSalePrice = $varieties + ->filter(fn (Variety $variety): bool => $variety->sale_price !== null) + ->min('sale_price'); + + return $this->shape($price, $rawSalePrice === null ? null : (int) $rawSalePrice); + } + + /** + * Pricing for a single variety. + * + * @return array{price: int, salePrice: int|null, discountPercent: int|null} + */ + public function forVariety(Variety $variety): array + { + return $this->shape( + (int) $variety->price, + $variety->sale_price === null ? null : (int) $variety->sale_price, + ); + } + + /** + * @return array{price: int, salePrice: int|null, discountPercent: int|null} + */ + private function shape(int $price, ?int $salePrice): array + { + $hasDiscount = $salePrice !== null && $salePrice < $price; + + return [ + 'price' => $price, + 'salePrice' => $hasDiscount ? $salePrice : null, + 'discountPercent' => $hasDiscount ? (int) round((($price - $salePrice) / $price) * 100) : null, + ]; + } +} diff --git a/shop/app/Actions/Catalog/TransformImage.php b/shop/app/Actions/Catalog/TransformImage.php new file mode 100644 index 00000000..74fd8dd9 --- /dev/null +++ b/shop/app/Actions/Catalog/TransformImage.php @@ -0,0 +1,26 @@ +url, + alt: (string) ($image->alt_text ?? ''), + ); + } +} diff --git a/shop/app/Actions/Category/BuildCategoryBreadcrumbs.php b/shop/app/Actions/Category/BuildCategoryBreadcrumbs.php new file mode 100644 index 00000000..83f800ca --- /dev/null +++ b/shop/app/Actions/Category/BuildCategoryBreadcrumbs.php @@ -0,0 +1,36 @@ + + */ + public function __invoke(Category $category): array + { + $chain = []; + $parent = $category->parent; + + while ($parent instanceof Category) { + array_unshift($chain, [ + 'heading' => $parent->heading, + 'url' => '/categories/'.$parent->slug, + ]); + $parent = $parent->parent; + } + + return [ + ['heading' => 'خانه', 'url' => '/'], + ...$chain, + ['heading' => $category->heading, 'url' => null], + ]; + } +} diff --git a/shop/app/Actions/Category/BuildCategoryDetail.php b/shop/app/Actions/Category/BuildCategoryDetail.php new file mode 100644 index 00000000..4f435afc --- /dev/null +++ b/shop/app/Actions/Category/BuildCategoryDetail.php @@ -0,0 +1,29 @@ +id, + heading: $category->heading, + url: '/categories/'.$category->slug, + title: $category->title, + description: $category->description, + content: $category->content, + noIndex: (bool) $category->no_index, + canonical: $category->canonical, + image: ($this->transformImage)($category->image), + ); + } +} diff --git a/shop/app/Actions/Category/CollectCategoryIds.php b/shop/app/Actions/Category/CollectCategoryIds.php new file mode 100644 index 00000000..3f5ebece --- /dev/null +++ b/shop/app/Actions/Category/CollectCategoryIds.php @@ -0,0 +1,40 @@ + + */ + public function __invoke(Category $category): array + { + $ids = [$category->id]; + $frontier = [$category->id]; + + while (true) { + $children = Category::query() + ->whereIn('parent_id', $frontier) + ->pluck('id') + ->all(); + + $children = array_values(array_diff(array_map('intval', $children), $ids)); + + if ($children === []) { + break; + } + + $ids = array_merge($ids, $children); + $frontier = $children; + } + + return $ids; + } +} diff --git a/shop/app/Actions/Category/GetCategoryFilters.php b/shop/app/Actions/Category/GetCategoryFilters.php new file mode 100644 index 00000000..c5e2a44e --- /dev/null +++ b/shop/app/Actions/Category/GetCategoryFilters.php @@ -0,0 +1,139 @@ + $categoryIds + * @param array{brands: array, attributes: array, minPrice: int|null, maxPrice: int|null, inStock: bool, sort: string} $filters + * @return array{brands: array>, attributeGroups: array>, price: array{min: int, max: int}} + */ + public function __invoke(array $categoryIds, array $filters): array + { + return [ + 'brands' => $this->brands($categoryIds, $filters['brands']), + 'attributeGroups' => $this->attributeGroups($categoryIds, $filters['attributes']), + 'price' => $this->priceBounds($categoryIds), + ]; + } + + /** + * @param array $categoryIds + * @param array $selected + * @return array> + */ + private function brands(array $categoryIds, array $selected): array + { + $counts = Product::query() + ->published() + ->whereIn('category_id', $categoryIds) + ->whereNotNull('brand_id') + ->selectRaw('brand_id, count(*) as aggregate') + ->groupBy('brand_id') + ->pluck('aggregate', 'brand_id'); + + return Brand::query() + ->active() + ->whereIn('id', $counts->keys()->all()) + ->orderBy('heading') + ->get() + ->map(fn (Brand $brand): array => [ + 'id' => $brand->id, + 'heading' => $brand->heading, + 'slug' => $brand->slug, + 'count' => (int) ($counts[$brand->id] ?? 0), + 'selected' => in_array($brand->slug, $selected, true), + ]) + ->all(); + } + + /** + * Attribute groups flagged as filters for any of the categories, limited to + * the attribute values actually attached to products in those categories. + * + * @param array $categoryIds + * @param array $selected + * @return array> + */ + private function attributeGroups(array $categoryIds, array $selected): array + { + $groupIds = array_map('intval', DB::table('attribute_group_category') + ->whereIn('category_id', $categoryIds) + ->where('as_filter', true) + ->pluck('attribute_group_id') + ->unique() + ->all()); + + if ($groupIds === []) { + return []; + } + + $counts = DB::table('product_attribute') + ->join('products', 'products.id', '=', 'product_attribute.product_id') + ->where('products.status', ProductStatusEnum::PUBLISHED->value) + ->whereIn('products.category_id', $categoryIds) + ->groupBy('product_attribute.attribute_id') + ->selectRaw('product_attribute.attribute_id as attribute_id, count(distinct products.id) as aggregate') + ->pluck('aggregate', 'attribute_id'); + + $usedAttributeIds = array_map('intval', $counts->keys()->all()); + + if ($usedAttributeIds === []) { + return []; + } + + return AttributeGroup::query() + ->whereIn('id', $groupIds) + ->with(['attributes' => fn (Relation $relation) => $relation->whereIn('id', $usedAttributeIds)->orderBy('value')]) + ->orderBy('order') + ->orderBy('name') + ->get() + ->reject(fn (AttributeGroup $group): bool => $group->attributes->isEmpty()) + ->map(fn (AttributeGroup $group): array => [ + 'id' => $group->id, + 'name' => $group->name, + 'attributes' => $group->attributes + ->map(fn (Attribute $attribute): array => [ + 'id' => $attribute->id, + 'value' => $attribute->value, + 'color' => $attribute->color, + 'count' => (int) ($counts[$attribute->id] ?? 0), + 'selected' => in_array($attribute->id, $selected, true), + ]) + ->all(), + ]) + ->values() + ->all(); + } + + /** + * @param array $categoryIds + * @return array{min: int, max: int} + */ + private function priceBounds(array $categoryIds): array + { + $base = Product::query() + ->published() + ->whereIn('category_id', $categoryIds); + + return [ + 'min' => (int) ((clone $base)->min('price') ?? 0), + 'max' => (int) ((clone $base)->max('price') ?? 0), + ]; + } +} diff --git a/shop/app/Actions/Category/GetCategoryProducts.php b/shop/app/Actions/Category/GetCategoryProducts.php new file mode 100644 index 00000000..aad2e43b --- /dev/null +++ b/shop/app/Actions/Category/GetCategoryProducts.php @@ -0,0 +1,118 @@ + $categoryIds + * @param array{brands: array, attributes: array, minPrice: int|null, maxPrice: int|null, inStock: bool, sort: string} $filters + * @return array{data: array>, meta: array{currentPage: int, lastPage: int, perPage: int, total: int, from: int|null, to: int|null}} + */ + public function __invoke(array $categoryIds, array $filters): array + { + $query = Product::query() + ->published() + ->whereIn('category_id', $categoryIds) + ->with([ + 'featuredImage', + 'varieties' => fn (Relation $relation) => $relation->where('status', VarietyStatusEnum::PUBLISHED->value)->with('image'), + ]); + + if ($filters['brands'] !== []) { + $query->whereHas('brand', fn (Builder $brand) => $brand->whereIn('slug', $filters['brands'])); + } + + if ($filters['minPrice'] !== null) { + $query->where('price', '>=', $filters['minPrice']); + } + + if ($filters['maxPrice'] !== null) { + $query->where('price', '<=', $filters['maxPrice']); + } + + if ($filters['inStock']) { + $query->where('has_stock', true); + } + + // Faceted attribute filter through the product↔attribute pivot + // (product_attribute is the documented "filters to products" link). + // OR within a group, AND across groups. + foreach ($this->groupedAttributes($filters['attributes']) as $attributeIds) { + $query->whereHas('attributes', fn (Builder $attribute) => $attribute->whereIn('attributes.id', $attributeIds)); + } + + $this->applySort($query, $filters['sort']); + + $paginator = $query->paginate(self::PER_PAGE)->withQueryString(); + + /** @var array $items */ + $items = $paginator->items(); + + return [ + 'data' => array_map(fn (Product $product): array => ($this->buildProductCard)($product), $items), + 'meta' => [ + 'currentPage' => $paginator->currentPage(), + 'lastPage' => $paginator->lastPage(), + 'perPage' => $paginator->perPage(), + 'total' => $paginator->total(), + 'from' => $paginator->firstItem(), + 'to' => $paginator->lastItem(), + ], + ]; + } + + /** + * Group selected attribute ids by their attribute group, so each group + * becomes one AND-ed constraint while values inside it stay OR-ed. + * + * @param array $attributeIds + * @return array> + */ + private function groupedAttributes(array $attributeIds): array + { + if ($attributeIds === []) { + return []; + } + + return Attribute::query() + ->whereIn('id', $attributeIds) + ->get() + ->groupBy('attribute_group_id') + ->map(fn (Collection $group): array => $group->pluck('id')->all()) + ->values() + ->all(); + } + + /** + * @param Builder $query + */ + private function applySort(Builder $query, string $sort): void + { + match ($sort) { + 'cheapest' => $query->orderBy('price'), + 'expensive' => $query->orderByDesc('price'), + 'popular' => $query->orderByDesc('seen'), + default => $query->orderByDesc('id'), + }; + } +} diff --git a/shop/app/Actions/Checkout/BuildOrderDTO.php b/shop/app/Actions/Checkout/BuildOrderDTO.php new file mode 100644 index 00000000..f3a48afc --- /dev/null +++ b/shop/app/Actions/Checkout/BuildOrderDTO.php @@ -0,0 +1,87 @@ +loadMissing([ + 'orderVarieties.product.featuredImage', + 'orderVarieties.variety.image', + 'address.city.province', + 'shippingMethod.shippingLine', + 'transactions', + ]); + + // A retried order can accumulate several transactions (one per + // attempt); the loaded collection isn't guaranteed DB-insertion + // order, so sort by id rather than trusting Collection::last(). + /** @var Transaction|null $transaction */ + $transaction = $order->transactions->firstWhere('status', TransactionStatusEnum::SUCCESS) + ?? $order->transactions->sortBy('id')->last(); + + return new OrderDTO( + id: $order->id, + trackingCode: $order->tracking_code, + status: $order->status->name, + statusLabel: $order->status->label(), + createdAt: (string) $order->created_at?->toIso8601String(), + totalProductsPrice: $order->total_products_price, + discount: $order->discount, + shippingCost: $order->shipping_cost, + taxPrice: $order->tax, + totalPrice: $order->total_price, + lines: $order->orderVarieties->map(fn (OrderVariety $line): OrderLineDTO => $this->line($line))->all(), + address: $order->address === null ? null : ($this->buildAddress)($order->address), + shippingMethodName: $order->shippingMethod?->name, + shippingLineName: $order->shippingMethod?->shippingLine?->name, + refId: $transaction?->ref_id, + paidAt: $transaction?->paid_at?->toIso8601String(), + canRetryPayment: $order->isRetryable(), + ); + } + + private function line(OrderVariety $line): OrderLineDTO + { + // product_id/variety_id are nullable snapshot FKs (nullOnDelete): the + // line survives deletion of the product/variety it once referenced. + // Larastan resolves these BelongsTo magic properties as non-nullable + // regardless of the column's actual nullability, so it flags the + // nullsafe operator below as redundant — it is not; keep it. + $product = $line->product; + $variety = $line->variety; + + $image = $this->transformImage->__invoke( + // @phpstan-ignore nullsafe.neverNull (see comment above) + $variety?->image ?? $product?->featuredImage, + ); + + return new OrderLineDTO( + // @phpstan-ignore nullsafe.neverNull (see comment above) + heading: $product?->heading ?? 'محصول حذف‌شده', + url: $product === null ? null : '/products/'.$product->slug, + image: $image, + color: $variety?->color, + quantity: $line->quantity, + unitPrice: $line->price, + finalPrice: $line->final_price, + ); + } +} diff --git a/shop/app/Actions/Checkout/CompleteCheckoutPayment.php b/shop/app/Actions/Checkout/CompleteCheckoutPayment.php new file mode 100644 index 00000000..e2e04fff --- /dev/null +++ b/shop/app/Actions/Checkout/CompleteCheckoutPayment.php @@ -0,0 +1,100 @@ +where('transaction_id', $authority) + ->with('order') + ->first(); + + if ($transaction === null || $transaction->order === null) { + return null; + } + + $order = $transaction->order; + + // Idempotency: a refreshed/duplicated callback for an already-paid + // order must not re-verify or re-decrement inventory. + if ($order->status === OrderStatusEnum::PAID) { + return $order; + } + + if ($status !== 'OK') { + $this->fail($order, $transaction, TransactionStatusEnum::CANCELED, null, 'پرداخت توسط کاربر لغو شد.'); + + return null; + } + + $verified = ($this->verifyPayment)($authority, $order->total_price); + + if ($verified === null || ! in_array($verified['code'], [100, 101], true)) { + $this->fail($order, $transaction, TransactionStatusEnum::FAILED, $verified['code'] ?? null, 'تأیید پرداخت ناموفق بود.'); + + return null; + } + + if (! ($this->decrementAndMarkPaid)($order, $transaction, $verified['refId'])) { + // Unlike the two paths above, Zarinpal already verified this + // payment as successful (money genuinely captured) — this is the + // rare race ORDER.md accepts (two customers reaching payment for + // the last unit at once). Record ref_id/paid_at so staff can find + // and manually refund it; a plain FAILED status with no ref_id + // would hide that money needs to go back to the customer. + $this->failPaidButOversold($order, $transaction, $verified['refId']); + + return null; + } + + Cart::query()->where('user_id', $order->user_id)->delete(); + + return $order->refresh(); + } + + private function fail(Order $order, Transaction $transaction, TransactionStatusEnum $status, ?int $resultCode, string $message): void + { + $order->update(['status' => OrderStatusEnum::CANCELED]); + + $transaction->update([ + 'status' => $status, + 'result_code' => $resultCode === null ? null : (string) $resultCode, + 'result_message' => $message, + ]); + } + + private function failPaidButOversold(Order $order, Transaction $transaction, ?string $refId): void + { + $order->update(['status' => OrderStatusEnum::CANCELED]); + + $transaction->update([ + 'status' => TransactionStatusEnum::FAILED, + 'ref_id' => $refId, + 'paid_at' => now(), + 'result_code' => '100', + 'result_message' => 'پرداخت با موفقیت انجام شد ولی موجودی کالا کافی نبود — نیاز به بازگشت وجه به مشتری.', + ]); + } +} diff --git a/shop/app/Actions/Checkout/CreatePendingOrder.php b/shop/app/Actions/Checkout/CreatePendingOrder.php new file mode 100644 index 00000000..4b50dd24 --- /dev/null +++ b/shop/app/Actions/Checkout/CreatePendingOrder.php @@ -0,0 +1,69 @@ + $lines + */ + public function __invoke(User $user, Collection $lines, Address $address, ShippingMethodDTO $method, CartSummaryDTO $summary): Order + { + return DB::transaction(function () use ($user, $lines, $address, $method, $summary): Order { + $varieties = Variety::query() + ->whereIn('id', $lines->pluck('varietyId')) + ->get() + ->keyBy('id'); + + $shippingCost = $method->payOnDelivery ? 0 : ($method->cost ?? 0); + + $order = Order::create([ + 'user_id' => $user->id, + 'address_id' => $address->id, + 'status' => OrderStatusEnum::PENDING, + 'coupon_discount' => 0, + 'discount' => $summary->discount, + 'shipping_cost' => $shippingCost, + 'total_products_price' => $summary->itemsTotal, + 'tax' => 0, + 'total_price' => $summary->payable + $shippingCost, + 'src' => OrderSrcEnum::WEB, + 'shipping_method_id' => $method->id, + ]); + + foreach ($lines as $line) { + /** @var CartLineDTO $line */ + $variety = $varieties->get($line->varietyId); + + $order->orderVarieties()->create([ + 'product_id' => $variety?->product_id, + 'variety_id' => $line->varietyId, + 'quantity' => $line->count, + 'price' => $line->originalPrice, + 'discount' => $line->lineOriginalTotal() - $line->lineTotal(), + 'coupon_discount' => 0, + 'final_price' => $line->lineTotal(), + ]); + } + + return $order; + }); + } +} diff --git a/shop/app/Actions/Checkout/DecrementInventoryAndMarkPaid.php b/shop/app/Actions/Checkout/DecrementInventoryAndMarkPaid.php new file mode 100644 index 00000000..2cd6a808 --- /dev/null +++ b/shop/app/Actions/Checkout/DecrementInventoryAndMarkPaid.php @@ -0,0 +1,54 @@ +orderVarieties() + ->whereNotNull('variety_id') + ->orderBy('variety_id') + ->get(); + + foreach ($lines as $line) { + /** @var OrderVariety $line */ + $variety = Variety::query()->whereKey($line->variety_id)->lockForUpdate()->first(); + + if ($variety === null || $variety->inventory < $line->quantity) { + return false; + } + + $variety->decrement('inventory', $line->quantity); + } + + $order->update(['status' => OrderStatusEnum::PAID]); + + $transaction->update([ + 'status' => TransactionStatusEnum::SUCCESS, + 'ref_id' => $refId, + 'paid_at' => now(), + 'result_code' => '100', + ]); + + return true; + }); + } +} diff --git a/shop/app/Actions/Checkout/GetShippingMethods.php b/shop/app/Actions/Checkout/GetShippingMethods.php new file mode 100644 index 00000000..b89da183 --- /dev/null +++ b/shop/app/Actions/Checkout/GetShippingMethods.php @@ -0,0 +1,75 @@ + province > nationwide). One entry + * per method, with the most specific cost/description winning. + * + * @return Collection + */ + public function __invoke(int $cityId, int $provinceId): Collection + { + $rows = ShippingCity::query() + ->where('status', true) + ->where(function (Builder $query) use ($cityId, $provinceId): void { + $query->where('city_id', $cityId) + ->orWhere(fn (Builder $q): Builder => $q->whereNull('city_id')->where('province_id', $provinceId)) + ->orWhere(fn (Builder $q): Builder => $q->whereNull('city_id')->whereNull('province_id')); + }) + ->whereHas('shippingMethod', fn (Builder $query): Builder => $query->where('status', true)) + ->with('shippingMethod.shippingLine') + ->get(); + + return $rows + ->groupBy('shipping_method_id') + ->map(fn (Collection $group): ShippingMethodDTO => $this->best($group, $cityId, $provinceId)) + ->values(); + } + + /** + * @param Collection $group + */ + private function best(Collection $group, int $cityId, int $provinceId): ShippingMethodDTO + { + /** @var ShippingCity $row */ + $row = $group + ->sortByDesc(fn (ShippingCity $city): int => $this->specificity($city, $cityId, $provinceId)) + ->first(); + + $method = $row->shippingMethod; + + return new ShippingMethodDTO( + id: $method->id, + name: $method->name, + lineName: $method->shippingLine->name, + description: $row->description, + sendingDays: $row->sending_days, + cost: $row->pay_on_delivery ? null : (int) ($row->amount ?? 0), + payOnDelivery: $row->pay_on_delivery, + ); + } + + private function specificity(ShippingCity $city, int $cityId, int $provinceId): int + { + if ($city->city_id === $cityId) { + return 3; + } + + if ($city->province_id === $provinceId) { + return 2; + } + + return 1; + } +} diff --git a/shop/app/Actions/Checkout/OpenZarinpalSession.php b/shop/app/Actions/Checkout/OpenZarinpalSession.php new file mode 100644 index 00000000..2c07a06f --- /dev/null +++ b/shop/app/Actions/Checkout/OpenZarinpalSession.php @@ -0,0 +1,54 @@ +transactions()->create([ + 'user_id' => $user->id, + 'status' => TransactionStatusEnum::PENDING, + 'port' => TransactionPortEnum::ZARINPAL, + 'amount' => $order->total_price, + 'ip' => $ip, + ]); + + $result = ($this->requestPayment)( + $order->total_price, + 'سفارش شماره '.$order->id, + $callbackUrl, + $user->hasPlaceholderEmail() ? null : $user->email, + $user->mobile, + ); + + if ($result === null) { + $transaction->update(['status' => TransactionStatusEnum::FAILED]); + $order->update(['status' => OrderStatusEnum::CANCELED]); + + return null; + } + + $transaction->update(['transaction_id' => $result['authority']]); + + return $this->requestPayment->startPayUrl($result['authority']); + } +} diff --git a/shop/app/Actions/Checkout/RequestZarinpalPayment.php b/shop/app/Actions/Checkout/RequestZarinpalPayment.php new file mode 100644 index 00000000..509878e9 --- /dev/null +++ b/shop/app/Actions/Checkout/RequestZarinpalPayment.php @@ -0,0 +1,65 @@ +post($this->baseUrl().'/pg/v4/payment/request.json', [ + 'merchant_id' => $merchantId, + 'amount' => Currency::tomanToRial($amountToman), + 'callback_url' => $callbackUrl, + 'description' => $description, + 'metadata' => array_filter(['mobile' => $mobile, 'email' => $email]), + ]); + } catch (Throwable) { + return null; + } + + if (! $response->successful()) { + return null; + } + + $code = $response->json('data.code'); + $authority = $response->json('data.authority'); + + if ($code !== 100 || ! is_string($authority) || $authority === '') { + return null; + } + + return ['authority' => $authority]; + } + + public function startPayUrl(string $authority): string + { + return $this->baseUrl().'/pg/StartPay/'.$authority; + } + + private function baseUrl(): string + { + $baseUrl = config('services.zarinpal.base_url'); + + return is_string($baseUrl) && $baseUrl !== '' ? rtrim($baseUrl, '/') : 'https://sandbox.zarinpal.com'; + } +} diff --git a/shop/app/Actions/Checkout/RetryOrderPayment.php b/shop/app/Actions/Checkout/RetryOrderPayment.php new file mode 100644 index 00000000..d579a6c5 --- /dev/null +++ b/shop/app/Actions/Checkout/RetryOrderPayment.php @@ -0,0 +1,50 @@ +hasSufficientStock($order)) { + return null; + } + + $order->update(['status' => OrderStatusEnum::PENDING]); + + return ($this->openSession)($order, $user, $callbackUrl, $ip); + } + + private function hasSufficientStock(Order $order): bool + { + $varietyIds = $order->orderVarieties->pluck('variety_id')->filter()->all(); + + $varieties = Variety::query()->whereIn('id', $varietyIds)->get()->keyBy('id'); + + return $order->orderVarieties->every(function (OrderVariety $line) use ($varieties): bool { + $variety = $line->variety_id === null ? null : $varieties->get($line->variety_id); + + return $variety !== null && $variety->has_stock && $variety->inventory >= $line->quantity; + }); + } +} diff --git a/shop/app/Actions/Checkout/StartCheckoutPayment.php b/shop/app/Actions/Checkout/StartCheckoutPayment.php new file mode 100644 index 00000000..e7ec94e9 --- /dev/null +++ b/shop/app/Actions/Checkout/StartCheckoutPayment.php @@ -0,0 +1,35 @@ + $lines + */ + public function __invoke(User $user, Collection $lines, Address $address, ShippingMethodDTO $method, CartSummaryDTO $summary, string $callbackUrl, string $ip): ?string + { + $order = ($this->createPendingOrder)($user, $lines, $address, $method, $summary); + + return ($this->openSession)($order, $user, $callbackUrl, $ip); + } +} diff --git a/shop/app/Actions/Checkout/ValidateCartStock.php b/shop/app/Actions/Checkout/ValidateCartStock.php new file mode 100644 index 00000000..226ea5f6 --- /dev/null +++ b/shop/app/Actions/Checkout/ValidateCartStock.php @@ -0,0 +1,25 @@ + $lines + */ + public function __invoke(Collection $lines): bool + { + return $lines->every(fn (CartLineDTO $line): bool => $line->inStock && $line->count <= $line->inventory); + } +} diff --git a/shop/app/Actions/Checkout/VerifyZarinpalPayment.php b/shop/app/Actions/Checkout/VerifyZarinpalPayment.php new file mode 100644 index 00000000..424f6f3a --- /dev/null +++ b/shop/app/Actions/Checkout/VerifyZarinpalPayment.php @@ -0,0 +1,65 @@ +post($this->baseUrl().'/pg/v4/payment/verify.json', [ + 'merchant_id' => $merchantId, + 'amount' => Currency::tomanToRial($amountToman), + 'authority' => $authority, + ]); + } catch (Throwable) { + return null; + } + + if (! $response->successful()) { + return null; + } + + $code = $response->json('data.code'); + + if (! is_int($code)) { + return null; + } + + $refId = $response->json('data.ref_id'); + $cardPan = $response->json('data.card_pan'); + + return [ + 'code' => $code, + 'refId' => is_string($refId) || is_int($refId) ? (string) $refId : null, + 'cardPan' => is_string($cardPan) ? $cardPan : null, + ]; + } + + private function baseUrl(): string + { + $baseUrl = config('services.zarinpal.base_url'); + + return is_string($baseUrl) && $baseUrl !== '' ? rtrim($baseUrl, '/') : 'https://sandbox.zarinpal.com'; + } +} diff --git a/shop/app/Actions/Faq/GetFaqs.php b/shop/app/Actions/Faq/GetFaqs.php new file mode 100644 index 00000000..e66d7763 --- /dev/null +++ b/shop/app/Actions/Faq/GetFaqs.php @@ -0,0 +1,37 @@ +> + */ + public function __invoke(?string $position): array + { + return Faq::query() + ->when( + $position === null, + fn (Builder $query) => $query->whereNull('position'), + fn (Builder $query) => $query->where('position', $position), + ) + ->orderBy('order') + ->orderBy('id') + ->get() + ->map(fn (Faq $faq): array => (new FaqDTO( + id: $faq->id, + heading: $faq->heading, + content: $faq->content, + ))->toArray()) + ->all(); + } +} diff --git a/shop/app/Actions/Home/GetFeaturedBrands.php b/shop/app/Actions/Home/GetFeaturedBrands.php new file mode 100644 index 00000000..dc01581c --- /dev/null +++ b/shop/app/Actions/Home/GetFeaturedBrands.php @@ -0,0 +1,38 @@ +> + */ + public function __invoke(): array + { + return Brand::query() + ->active() + ->with('image') + ->orderBy('heading') + ->limit(self::LIMIT) + ->get() + ->map(fn (Brand $brand): array => [ + 'id' => $brand->id, + 'heading' => $brand->heading, + 'url' => '/brands/'.$brand->slug, + 'image' => ($this->transformImage)($brand->image)?->toArray(), + ]) + ->all(); + } +} diff --git a/shop/app/Actions/Home/GetHeroSlides.php b/shop/app/Actions/Home/GetHeroSlides.php new file mode 100644 index 00000000..55a798dd --- /dev/null +++ b/shop/app/Actions/Home/GetHeroSlides.php @@ -0,0 +1,46 @@ +> + */ + public function __invoke(): array + { + $slider = Slider::query() + ->published() + ->where('position', self::POSITION) + ->with(['slides' => fn (Relation $query) => $query->orderBy('order'), 'slides.image']) + ->first(); + + if ($slider === null) { + return []; + } + + return $slider->slides + ->map(fn (Slide $slide): array => [ + 'id' => $slide->id, + 'heading' => $slide->heading, + 'label' => $slide->label, + 'url' => $slide->url, + 'image' => ($this->transformImage)($slide->image)?->toArray(), + ]) + ->all(); + } +} diff --git a/shop/app/Actions/Home/GetHomeCategories.php b/shop/app/Actions/Home/GetHomeCategories.php new file mode 100644 index 00000000..cecb37a1 --- /dev/null +++ b/shop/app/Actions/Home/GetHomeCategories.php @@ -0,0 +1,35 @@ +> + */ + public function __invoke(): array + { + return Category::query() + ->active() + ->whereNull('parent_id') + ->with('image') + ->orderBy('heading') + ->get() + ->map(fn (Category $category): array => [ + 'id' => $category->id, + 'heading' => $category->heading, + 'url' => '/categories/'.$category->slug, + 'image' => ($this->transformImage)($category->image)?->toArray(), + ]) + ->all(); + } +} diff --git a/shop/app/Actions/Home/GetProductRows.php b/shop/app/Actions/Home/GetProductRows.php new file mode 100644 index 00000000..851c7b7e --- /dev/null +++ b/shop/app/Actions/Home/GetProductRows.php @@ -0,0 +1,51 @@ +> + */ + public function __invoke(): array + { + $rows = [ + ['title' => 'جدیدترین محصولات', 'viewAllUrl' => '/products?sort=newest', 'query' => fn (Builder $q) => $q->latest('id')], + ['title' => 'پربازدیدترین محصولات', 'viewAllUrl' => '/products?sort=popular', 'query' => fn (Builder $q) => $q->orderByDesc('seen')], + ]; + + return array_values(array_filter(array_map(function (array $row): array { + $products = Product::query() + ->published() + ->with(['featuredImage', 'varieties' => fn (Relation $q) => $q->where('status', VarietyStatusEnum::PUBLISHED->value)->with('image')]) + ->tap($row['query']) + ->limit(self::ROW_LIMIT) + ->get() + ->map(fn (Product $product): array => ($this->buildProductCard)($product)) + ->all(); + + return [ + 'title' => $row['title'], + 'viewAllUrl' => $row['viewAllUrl'], + 'products' => $products, + ]; + }, $rows), fn (array $row): bool => $row['products'] !== [])); + } +} diff --git a/shop/app/Actions/Home/GetPromoBanners.php b/shop/app/Actions/Home/GetPromoBanners.php new file mode 100644 index 00000000..e2a9fd1a --- /dev/null +++ b/shop/app/Actions/Home/GetPromoBanners.php @@ -0,0 +1,38 @@ +> + */ + public function __invoke(): array + { + return Banner::query() + ->published() + ->where('position', self::POSITION) + ->with('featuredImage') + ->orderBy('sort') + ->get() + ->map(fn (Banner $banner): array => [ + 'id' => $banner->id, + 'heading' => $banner->heading, + 'url' => $banner->url, + 'image' => ($this->transformImage)($banner->featuredImage)?->toArray(), + ]) + ->all(); + } +} diff --git a/shop/app/Actions/Page/BuildPageDetail.php b/shop/app/Actions/Page/BuildPageDetail.php new file mode 100644 index 00000000..ea740c07 --- /dev/null +++ b/shop/app/Actions/Page/BuildPageDetail.php @@ -0,0 +1,29 @@ +id, + heading: $page->heading, + url: '/'.$page->slug, + title: $page->title, + description: $page->description, + content: $page->content, + noIndex: (bool) $page->no_index, + canonical: $page->canonical, + image: ($this->transformImage)($page->image), + ); + } +} diff --git a/shop/app/Actions/Product/BuildProductBreadcrumbs.php b/shop/app/Actions/Product/BuildProductBreadcrumbs.php new file mode 100644 index 00000000..7a6c5f92 --- /dev/null +++ b/shop/app/Actions/Product/BuildProductBreadcrumbs.php @@ -0,0 +1,36 @@ + + */ + public function __invoke(Product $product): array + { + $chain = []; + $category = $product->category; + + while ($category instanceof Category) { + array_unshift($chain, [ + 'heading' => $category->heading, + 'url' => '/categories/'.$category->slug, + ]); + $category = $category->parent; + } + + return [ + ['heading' => 'خانه', 'url' => '/'], + ...$chain, + ['heading' => $product->heading, 'url' => null], + ]; + } +} diff --git a/shop/app/Actions/Product/BuildProductDetail.php b/shop/app/Actions/Product/BuildProductDetail.php new file mode 100644 index 00000000..ba1ccbb3 --- /dev/null +++ b/shop/app/Actions/Product/BuildProductDetail.php @@ -0,0 +1,179 @@ + $varieties */ + $varieties = $product->varieties; + + $pricing = $this->pricing->forVarieties($varieties, (int) $product->price); + + /** @var array $reviewerIds */ + $reviewerIds = $product->reviews + ->pluck('user_id') + ->filter() + ->unique() + ->values() + ->all(); + + $buyerIds = ($this->findProductBuyers)($product->id, $reviewerIds); + + $ratings = $product->reviews + ->pluck('rating') + ->filter(fn (?int $rating): bool => $rating !== null); + + return new ProductDTO( + id: $product->id, + heading: $product->heading, + url: '/products/'.$product->slug, + content: $product->content, + title: $product->title, + description: $product->description, + noIndex: (bool) $product->no_index, + canonical: $product->canonical, + image: ($this->transformImage)($product->featuredImage), + gallery: $this->gallery($product), + brand: $product->brand === null ? null : [ + 'heading' => $product->brand->heading, + 'url' => '/brands/'.$product->brand->slug, + ], + category: $product->category === null ? null : [ + 'heading' => $product->category->heading, + 'url' => '/categories/'.$product->category->slug, + ], + price: $pricing['price'], + salePrice: $pricing['salePrice'], + discountPercent: $pricing['discountPercent'], + inStock: $varieties->contains(fn (Variety $variety): bool => $this->inStock($variety)), + variantAxes: ($this->buildVariantAxes)($varieties), + varieties: $varieties->map(fn (Variety $variety): VarietyDTO => $this->variety($variety))->all(), + highlights: $product->attributes + ->filter(fn (Attribute $attribute): bool => (bool) ($attribute->pivot->is_highlight ?? false)) + ->map(fn (Attribute $attribute): array => $this->spec($attribute)) + ->values() + ->all(), + specs: $product->attributes + ->map(fn (Attribute $attribute): array => $this->spec($attribute)) + ->all(), + reviews: $product->reviews + ->map(fn (Review $review): ReviewDTO => new ReviewDTO( + id: $review->id, + heading: $review->heading, + content: $review->content, + rating: $review->rating, + author: $review->user?->displayName(), + date: $review->created_at?->toIso8601String(), + isBuyer: $review->user_id !== null && in_array($review->user_id, $buyerIds, true), + )) + ->all(), + reviewCount: $product->reviews->count(), + averageRating: $ratings->isEmpty() ? null : round((float) $ratings->avg(), 1), + ); + } + + private function variety(Variety $variety): VarietyDTO + { + $pricing = $this->pricing->forVariety($variety); + $inStock = $this->inStock($variety); + + return new VarietyDTO( + id: $variety->id, + label: $variety->attribute_value ?? $variety->attribute?->value, + color: $variety->color, + price: $pricing['price'], + salePrice: $pricing['salePrice'], + discountPercent: $pricing['discountPercent'], + inStock: $inStock, + inventory: $inStock ? (int) $variety->inventory : 0, + image: ($this->transformImage)($variety->image), + options: $this->options($variety), + ); + } + + /** + * A descriptive product attribute paired with its group name (e.g. + * `{group: 'متریال', value: 'پنبه'}`), so specs/highlights are never + * shown as bare, out-of-context values. + * + * @return array{group: string, value: string} + */ + private function spec(Attribute $attribute): array + { + return [ + 'group' => (string) $attribute->attributeGroup->name, + 'value' => $attribute->value, + ]; + } + + /** + * Map of attribute-group id => list of values for a variety. A variety can + * carry several values in the same group (e.g. a color offered in sizes). + * + * @return array> + */ + private function options(Variety $variety): array + { + $options = []; + + foreach (($this->varietyAttributes)($variety) as $attribute) { + $options[$attribute->attribute_group_id][] = $attribute->value; + } + + return array_map( + fn (array $values): array => array_values(array_unique($values)), + $options, + ); + } + + /** + * Featured image first, then the rest of the gallery. + * + * @return array + */ + private function gallery(Product $product): array + { + return $product->images + ->sortByDesc('is_featured') + ->map(fn (Image $image): ImageDTO => new ImageDTO( + url: $image->url, + alt: (string) ($image->alt_text ?? $product->heading), + )) + ->values() + ->all(); + } + + private function inStock(Variety $variety): bool + { + return $variety->has_stock && $variety->inventory > 0; + } +} diff --git a/shop/app/Actions/Product/BuildVariantAxes.php b/shop/app/Actions/Product/BuildVariantAxes.php new file mode 100644 index 00000000..5d71c7f7 --- /dev/null +++ b/shop/app/Actions/Product/BuildVariantAxes.php @@ -0,0 +1,127 @@ + $varieties + * @return array}> + */ + public function __invoke(Collection $varieties): array + { + $axes = []; + + // The primary axis is the group most varieties pin via their primary + // attribute (attribute_id). Counting keeps it stable even if a single + // variety has inconsistent data; ties fall back to first seen. + $primaryVotes = []; + + foreach ($varieties as $variety) { + if ($variety->attribute !== null) { + $groupId = $variety->attribute->attribute_group_id; + $primaryVotes[$groupId] = ($primaryVotes[$groupId] ?? 0) + 1; + } + + foreach (($this->varietyAttributes)($variety) as $attribute) { + $groupId = $attribute->attribute_group_id; + + $axes[$groupId] ??= [ + 'id' => $groupId, + 'name' => (string) $attribute->attributeGroup->name, + 'options' => [], + ]; + + // Keyed by attribute id (not value) so options are dedupe-safe + // and can be sorted deterministically below. + $axes[$groupId]['options'][$attribute->id] ??= [ + 'value' => $attribute->value, + 'color' => $attribute->color, + ]; + } + } + + $primaryGroupId = $this->primaryGroupId($primaryVotes); + + $result = array_values(array_map( + fn (array $axis): array => [ + 'id' => $axis['id'], + 'name' => $axis['name'], + 'primary' => $axis['id'] === $primaryGroupId, + 'options' => $this->sortedOptions($axis['options']), + ], + $axes, + )); + + return $this->primaryFirst($result, $primaryGroupId); + } + + /** + * Options in attribute-creation order (`attributes` has no explicit + * `order` column, so id order is the best deterministic proxy available) + * instead of whatever order varieties happened to be processed in. + * + * @param array $options keyed by attribute id + * @return array + */ + private function sortedOptions(array $options): array + { + ksort($options); + + return array_values($options); + } + + /** + * The primary axis always renders first, regardless of which attribute + * group was encountered first while iterating varieties — e.g. a variety + * whose primary attribute was deleted (attribute_id set to null) still + * carries secondary attributes, and must never push the primary axis + * further down the list just because it was processed first. + * + * @param array}> $axes + * @return array}> + */ + private function primaryFirst(array $axes, ?int $primaryGroupId): array + { + if ($primaryGroupId === null) { + return $axes; + } + + $primary = array_values(array_filter($axes, fn (array $axis): bool => $axis['id'] === $primaryGroupId)); + $rest = array_values(array_filter($axes, fn (array $axis): bool => $axis['id'] !== $primaryGroupId)); + + return [...$primary, ...$rest]; + } + + /** + * The most-voted attribute group, or null when no variety has a primary. + * + * @param array $primaryVotes + */ + private function primaryGroupId(array $primaryVotes): ?int + { + if ($primaryVotes === []) { + return null; + } + + $primaryGroupId = array_key_first($primaryVotes); + foreach ($primaryVotes as $groupId => $votes) { + if ($votes > $primaryVotes[$primaryGroupId]) { + $primaryGroupId = $groupId; + } + } + + return $primaryGroupId; + } +} diff --git a/shop/app/Actions/Product/GetRelatedProducts.php b/shop/app/Actions/Product/GetRelatedProducts.php new file mode 100644 index 00000000..0ff194d1 --- /dev/null +++ b/shop/app/Actions/Product/GetRelatedProducts.php @@ -0,0 +1,43 @@ +> + */ + public function __invoke(Product $product): array + { + if ($product->category_id === null) { + return []; + } + + return Product::query() + ->published() + ->where('category_id', $product->category_id) + ->whereKeyNot($product->id) + ->with(['featuredImage', 'varieties' => fn (Relation $query) => $query->where('status', VarietyStatusEnum::PUBLISHED->value)->with('image')]) + ->latest('id') + ->limit(self::LIMIT) + ->get() + ->map(fn (Product $related): array => ($this->buildProductCard)($related)) + ->all(); + } +} diff --git a/shop/app/Actions/Product/VarietyAttributes.php b/shop/app/Actions/Product/VarietyAttributes.php new file mode 100644 index 00000000..c8326b1c --- /dev/null +++ b/shop/app/Actions/Product/VarietyAttributes.php @@ -0,0 +1,32 @@ + + */ + public function __invoke(Variety $variety): array + { + $attributes = []; + + if ($variety->attribute !== null) { + $attributes[] = $variety->attribute; + } + + foreach ($variety->attributes as $attribute) { + $attributes[] = $attribute; + } + + return $attributes; + } +} diff --git a/shop/app/Actions/Review/CreateReview.php b/shop/app/Actions/Review/CreateReview.php new file mode 100644 index 00000000..82eb80b0 --- /dev/null +++ b/shop/app/Actions/Review/CreateReview.php @@ -0,0 +1,31 @@ + $user->id, + 'product_id' => $product->id, + 'heading' => $data['heading'], + 'content' => $data['content'], + 'rating' => $data['rating'], + 'status' => ReviewStatusEnum::PENDING, + ]); + } +} diff --git a/shop/app/Actions/Review/FindProductBuyers.php b/shop/app/Actions/Review/FindProductBuyers.php new file mode 100644 index 00000000..0bf9edc6 --- /dev/null +++ b/shop/app/Actions/Review/FindProductBuyers.php @@ -0,0 +1,47 @@ += PAID`, since CANCELED (60)/RETURNED (70) sort above DELIVERED (50). + */ + private const PURCHASED_STATUSES = [ + OrderStatusEnum::PAID, + OrderStatusEnum::PROCESSING, + OrderStatusEnum::SHIPPED, + OrderStatusEnum::DELIVERED, + ]; + + /** + * Of the given candidate user ids, which have a purchased order containing + * this product — used to flag "verified buyer" (خریدار) on their reviews. + * + * @param array $candidateUserIds + * @return array the subset of user ids who bought the product + */ + public function __invoke(int $productId, array $candidateUserIds): array + { + if ($candidateUserIds === []) { + return []; + } + + return Order::query() + ->whereIn('user_id', $candidateUserIds) + ->whereIn('status', self::PURCHASED_STATUSES) + ->whereHas('orderVarieties', fn (Builder $query): Builder => $query->where('product_id', $productId)) + ->pluck('user_id') + ->unique() + ->values() + ->all(); + } +} diff --git a/shop/app/Actions/Search/GetSearchSuggestions.php b/shop/app/Actions/Search/GetSearchSuggestions.php new file mode 100644 index 00000000..89eef133 --- /dev/null +++ b/shop/app/Actions/Search/GetSearchSuggestions.php @@ -0,0 +1,45 @@ +, products: array} + */ + public function __invoke(string $term): array + { + $term = trim($term); + + if ($term === '') { + return ['categories' => [], 'products' => []]; + } + + $categories = Category::query() + ->active() + ->where('heading', 'ilike', '%'.$term.'%') + ->orderBy('heading') + ->limit(5) + ->get(['heading', 'slug']) + ->map(fn (Category $category): array => [ + 'heading' => $category->heading, + 'url' => '/categories/'.$category->slug, + ]) + ->all(); + + return [ + 'categories' => $categories, + 'products' => $this->search->suggest($term, 8), + ]; + } +} diff --git a/shop/app/Actions/Sitemap/GetSitemapUrls.php b/shop/app/Actions/Sitemap/GetSitemapUrls.php new file mode 100644 index 00000000..8270a43b --- /dev/null +++ b/shop/app/Actions/Sitemap/GetSitemapUrls.php @@ -0,0 +1,72 @@ + + */ + public function __invoke(): array + { + $base = rtrim((string) config('app.url'), '/'); + + $static = [ + ['loc' => $base.'/', 'lastmod' => null], + ['loc' => $base.'/faq', 'lastmod' => null], + ]; + + $categories = Category::query() + ->active() + ->where('no_index', false) + ->get(['slug', 'updated_at']) + ->map(fn (Category $category): array => [ + 'loc' => $base.'/categories/'.$category->slug, + 'lastmod' => $category->updated_at?->toAtomString(), + ]) + ->all(); + + $brands = Brand::query() + ->active() + ->where('no_index', false) + ->get(['slug', 'updated_at']) + ->map(fn (Brand $brand): array => [ + 'loc' => $base.'/brands/'.$brand->slug, + 'lastmod' => $brand->updated_at?->toAtomString(), + ]) + ->all(); + + $products = Product::query() + ->published() + ->where('no_index', false) + ->get(['slug', 'updated_at']) + ->map(fn (Product $product): array => [ + 'loc' => $base.'/products/'.$product->slug, + 'lastmod' => $product->updated_at?->toAtomString(), + ]) + ->all(); + + $pages = Page::query() + ->published() + ->where('no_index', false) + ->get(['slug', 'updated_at']) + ->map(fn (Page $page): array => [ + 'loc' => $base.'/'.$page->slug, + 'lastmod' => $page->updated_at?->toAtomString(), + ]) + ->all(); + + return [...$static, ...$categories, ...$brands, ...$products, ...$pages]; + } +} diff --git a/shop/app/Actions/Wishlist/ToggleWishlist.php b/shop/app/Actions/Wishlist/ToggleWishlist.php new file mode 100644 index 00000000..e094344d --- /dev/null +++ b/shop/app/Actions/Wishlist/ToggleWishlist.php @@ -0,0 +1,38 @@ +where('user_id', $user->id) + ->where('product_id', $product->id) + ->first(); + + if ($existing !== null) { + $existing->delete(); + + return false; + } + + Wishlist::create([ + 'user_id' => $user->id, + 'product_id' => $product->id, + ]); + + return true; + } +} diff --git a/shop/app/Contracts/ProductSearch.php b/shop/app/Contracts/ProductSearch.php new file mode 100644 index 00000000..9fc6fc90 --- /dev/null +++ b/shop/app/Contracts/ProductSearch.php @@ -0,0 +1,26 @@ +>, meta: array{currentPage: int, lastPage: int, perPage: int, total: int, from: int|null, to: int|null}} + */ + public function search(string $term, array $options): array; + + /** + * Lightweight product matches for the header search autocomplete. + * + * @return array + */ + public function suggest(string $term, int $limit = 8): array; +} diff --git a/shop/app/DTOs/AddressDTO.php b/shop/app/DTOs/AddressDTO.php new file mode 100644 index 00000000..54105d8e --- /dev/null +++ b/shop/app/DTOs/AddressDTO.php @@ -0,0 +1,50 @@ + + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'phone' => $this->phone, + 'postalCode' => $this->postalCode, + 'address' => $this->address, + 'plate' => $this->plate, + 'unit' => $this->unit, + 'note' => $this->note, + 'latitude' => $this->latitude, + 'longitude' => $this->longitude, + 'cityId' => $this->cityId, + 'cityName' => $this->cityName, + 'provinceId' => $this->provinceId, + 'provinceName' => $this->provinceName, + 'prime' => $this->prime, + ]; + } +} diff --git a/shop/app/DTOs/BrandDTO.php b/shop/app/DTOs/BrandDTO.php new file mode 100644 index 00000000..9249e9ca --- /dev/null +++ b/shop/app/DTOs/BrandDTO.php @@ -0,0 +1,38 @@ + + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'heading' => $this->heading, + 'url' => $this->url, + 'title' => $this->title, + 'description' => $this->description, + 'content' => $this->content, + 'noIndex' => $this->noIndex, + 'canonical' => $this->canonical, + 'image' => $this->image?->toArray(), + ]; + } +} diff --git a/shop/app/DTOs/CartLineDTO.php b/shop/app/DTOs/CartLineDTO.php new file mode 100644 index 00000000..7978c6a6 --- /dev/null +++ b/shop/app/DTOs/CartLineDTO.php @@ -0,0 +1,61 @@ + $attributes + */ + public function __construct( + public int $id, + public int $varietyId, + public string $heading, + public string $url, + public ?ImageDTO $image, + public ?string $color, + public array $attributes, + public int $unitPrice, + public int $originalPrice, + public ?int $discountPercent, + public int $count, + public int $inventory, + public bool $inStock, + ) {} + + public function lineTotal(): int + { + return $this->unitPrice * $this->count; + } + + public function lineOriginalTotal(): int + { + return $this->originalPrice * $this->count; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'varietyId' => $this->varietyId, + 'heading' => $this->heading, + 'url' => $this->url, + 'image' => $this->image?->toArray(), + 'color' => $this->color, + 'attributes' => $this->attributes, + 'unitPrice' => $this->unitPrice, + 'originalPrice' => $this->originalPrice, + 'discountPercent' => $this->discountPercent, + 'count' => $this->count, + 'inventory' => $this->inventory, + 'inStock' => $this->inStock, + 'lineTotal' => $this->lineTotal(), + 'lineOriginalTotal' => $this->lineOriginalTotal(), + ]; + } +} diff --git a/shop/app/DTOs/CartSummaryDTO.php b/shop/app/DTOs/CartSummaryDTO.php new file mode 100644 index 00000000..63b831a8 --- /dev/null +++ b/shop/app/DTOs/CartSummaryDTO.php @@ -0,0 +1,28 @@ + + */ + public function toArray(): array + { + return [ + 'count' => $this->count, + 'itemsTotal' => $this->itemsTotal, + 'discount' => $this->discount, + 'payable' => $this->payable, + ]; + } +} diff --git a/shop/app/DTOs/CategoryDTO.php b/shop/app/DTOs/CategoryDTO.php new file mode 100644 index 00000000..faa0e5dc --- /dev/null +++ b/shop/app/DTOs/CategoryDTO.php @@ -0,0 +1,38 @@ + + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'heading' => $this->heading, + 'url' => $this->url, + 'title' => $this->title, + 'description' => $this->description, + 'content' => $this->content, + 'noIndex' => $this->noIndex, + 'canonical' => $this->canonical, + 'image' => $this->image?->toArray(), + ]; + } +} diff --git a/shop/app/DTOs/FaqDTO.php b/shop/app/DTOs/FaqDTO.php new file mode 100644 index 00000000..19db6060 --- /dev/null +++ b/shop/app/DTOs/FaqDTO.php @@ -0,0 +1,26 @@ + + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'heading' => $this->heading, + 'content' => $this->content, + ]; + } +} diff --git a/shop/app/DTOs/ImageDTO.php b/shop/app/DTOs/ImageDTO.php new file mode 100644 index 00000000..aa70d6a1 --- /dev/null +++ b/shop/app/DTOs/ImageDTO.php @@ -0,0 +1,21 @@ + + */ + public function toArray(): array + { + return get_object_vars($this); + } +} diff --git a/shop/app/DTOs/OrderDTO.php b/shop/app/DTOs/OrderDTO.php new file mode 100644 index 00000000..4ed3d7a8 --- /dev/null +++ b/shop/app/DTOs/OrderDTO.php @@ -0,0 +1,57 @@ + $lines + */ + public function __construct( + public int $id, + public string $trackingCode, + public string $status, + public string $statusLabel, + public string $createdAt, + public int $totalProductsPrice, + public int $discount, + public int $shippingCost, + public int $taxPrice, + public int $totalPrice, + public array $lines, + public ?AddressDTO $address, + public ?string $shippingMethodName, + public ?string $shippingLineName, + public ?string $refId, + public ?string $paidAt, + public bool $canRetryPayment, + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'trackingCode' => $this->trackingCode, + 'status' => $this->status, + 'statusLabel' => $this->statusLabel, + 'createdAt' => $this->createdAt, + 'totalProductsPrice' => $this->totalProductsPrice, + 'discount' => $this->discount, + 'shippingCost' => $this->shippingCost, + 'taxPrice' => $this->taxPrice, + 'totalPrice' => $this->totalPrice, + 'lines' => array_map(fn (OrderLineDTO $line): array => $line->toArray(), $this->lines), + 'address' => $this->address?->toArray(), + 'shippingMethodName' => $this->shippingMethodName, + 'shippingLineName' => $this->shippingLineName, + 'refId' => $this->refId, + 'paidAt' => $this->paidAt, + 'canRetryPayment' => $this->canRetryPayment, + ]; + } +} diff --git a/shop/app/DTOs/OrderLineDTO.php b/shop/app/DTOs/OrderLineDTO.php new file mode 100644 index 00000000..ffa9f0bf --- /dev/null +++ b/shop/app/DTOs/OrderLineDTO.php @@ -0,0 +1,34 @@ + + */ + public function toArray(): array + { + return [ + 'heading' => $this->heading, + 'url' => $this->url, + 'image' => $this->image?->toArray(), + 'color' => $this->color, + 'quantity' => $this->quantity, + 'unitPrice' => $this->unitPrice, + 'finalPrice' => $this->finalPrice, + ]; + } +} diff --git a/shop/app/DTOs/PageDTO.php b/shop/app/DTOs/PageDTO.php new file mode 100644 index 00000000..a019c4d7 --- /dev/null +++ b/shop/app/DTOs/PageDTO.php @@ -0,0 +1,38 @@ + + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'heading' => $this->heading, + 'url' => $this->url, + 'title' => $this->title, + 'description' => $this->description, + 'content' => $this->content, + 'noIndex' => $this->noIndex, + 'canonical' => $this->canonical, + 'image' => $this->image?->toArray(), + ]; + } +} diff --git a/shop/app/DTOs/ProductDTO.php b/shop/app/DTOs/ProductDTO.php new file mode 100644 index 00000000..db9e48ad --- /dev/null +++ b/shop/app/DTOs/ProductDTO.php @@ -0,0 +1,76 @@ + $gallery + * @param array{heading: string, url: string}|null $brand + * @param array{heading: string, url: string}|null $category + * @param array> $variantAxes + * @param array $varieties + * @param array $highlights + * @param array $specs + * @param array $reviews + */ + public function __construct( + public int $id, + public string $heading, + public string $url, + public ?string $content, + public ?string $title, + public ?string $description, + public bool $noIndex, + public ?string $canonical, + public ?ImageDTO $image, + public array $gallery, + public ?array $brand, + public ?array $category, + public int $price, + public ?int $salePrice, + public ?int $discountPercent, + public bool $inStock, + public array $variantAxes, + public array $varieties, + public array $highlights, + public array $specs, + public array $reviews, + public int $reviewCount, + public ?float $averageRating, + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'heading' => $this->heading, + 'url' => $this->url, + 'content' => $this->content, + 'title' => $this->title, + 'description' => $this->description, + 'noIndex' => $this->noIndex, + 'canonical' => $this->canonical, + 'image' => $this->image?->toArray(), + 'gallery' => array_map(fn (ImageDTO $image): array => $image->toArray(), $this->gallery), + 'brand' => $this->brand, + 'category' => $this->category, + 'price' => $this->price, + 'salePrice' => $this->salePrice, + 'discountPercent' => $this->discountPercent, + 'inStock' => $this->inStock, + 'variantAxes' => $this->variantAxes, + 'varieties' => array_map(fn (VarietyDTO $variety): array => $variety->toArray(), $this->varieties), + 'highlights' => $this->highlights, + 'specs' => $this->specs, + 'reviews' => array_map(fn (ReviewDTO $review): array => $review->toArray(), $this->reviews), + 'reviewCount' => $this->reviewCount, + 'averageRating' => $this->averageRating, + ]; + } +} diff --git a/shop/app/DTOs/ReviewDTO.php b/shop/app/DTOs/ReviewDTO.php new file mode 100644 index 00000000..89e6c257 --- /dev/null +++ b/shop/app/DTOs/ReviewDTO.php @@ -0,0 +1,26 @@ + + */ + public function toArray(): array + { + return get_object_vars($this); + } +} diff --git a/shop/app/DTOs/ShippingMethodDTO.php b/shop/app/DTOs/ShippingMethodDTO.php new file mode 100644 index 00000000..2306f753 --- /dev/null +++ b/shop/app/DTOs/ShippingMethodDTO.php @@ -0,0 +1,34 @@ + + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'lineName' => $this->lineName, + 'description' => $this->description, + 'sendingDays' => $this->sendingDays, + 'cost' => $this->cost, + 'payOnDelivery' => $this->payOnDelivery, + ]; + } +} diff --git a/shop/app/DTOs/UserDTO.php b/shop/app/DTOs/UserDTO.php new file mode 100644 index 00000000..088bc9b0 --- /dev/null +++ b/shop/app/DTOs/UserDTO.php @@ -0,0 +1,32 @@ + + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'displayName' => $this->displayName, + 'mobile' => $this->mobile, + 'firstName' => $this->firstName, + 'lastName' => $this->lastName, + 'email' => $this->email, + ]; + } +} diff --git a/shop/app/DTOs/VarietyDTO.php b/shop/app/DTOs/VarietyDTO.php new file mode 100644 index 00000000..be2a5e2f --- /dev/null +++ b/shop/app/DTOs/VarietyDTO.php @@ -0,0 +1,43 @@ +> $options attribute-group id => values + */ + public function __construct( + public int $id, + public ?string $label, + public ?string $color, + public int $price, + public ?int $salePrice, + public ?int $discountPercent, + public bool $inStock, + public int $inventory, + public ?ImageDTO $image, + public array $options, + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'label' => $this->label, + 'color' => $this->color, + 'price' => $this->price, + 'salePrice' => $this->salePrice, + 'discountPercent' => $this->discountPercent, + 'inStock' => $this->inStock, + 'inventory' => $this->inventory, + 'image' => $this->image?->toArray(), + 'options' => $this->options, + ]; + } +} diff --git a/shop/app/Enums/OrderSrcEnum.php b/shop/app/Enums/OrderSrcEnum.php new file mode 100644 index 00000000..e31fc932 --- /dev/null +++ b/shop/app/Enums/OrderSrcEnum.php @@ -0,0 +1,27 @@ + 'اپلیکیشن وب', + self::WEB => 'وبسایت', + self::APP => 'اپلیکیشن موبایل', + self::OLD => 'سامانه قدیم', + }; + } +} diff --git a/shop/app/Enums/OrderStatusEnum.php b/shop/app/Enums/OrderStatusEnum.php new file mode 100644 index 00000000..6bf144f5 --- /dev/null +++ b/shop/app/Enums/OrderStatusEnum.php @@ -0,0 +1,33 @@ + 'در انتظار پرداخت', + self::PAID => 'پرداخت‌شده', + self::PROCESSING => 'در حال آماده‌سازی', + self::SHIPPED => 'ارسال‌شده', + self::DELIVERED => 'تحویل داده‌شده', + self::CANCELED => 'لغوشده', + self::RETURNED => 'مرجوع‌شده', + }; + } +} diff --git a/shop/app/Enums/TransactionPortEnum.php b/shop/app/Enums/TransactionPortEnum.php new file mode 100644 index 00000000..50e2ed5f --- /dev/null +++ b/shop/app/Enums/TransactionPortEnum.php @@ -0,0 +1,25 @@ + 'ملت', + self::PARSIAN => 'پارسیان', + self::ZARINPAL => 'زرین‌پال', + }; + } +} diff --git a/shop/app/Enums/TransactionStatusEnum.php b/shop/app/Enums/TransactionStatusEnum.php new file mode 100644 index 00000000..dfb07bd0 --- /dev/null +++ b/shop/app/Enums/TransactionStatusEnum.php @@ -0,0 +1,27 @@ + 'در انتظار', + self::SUCCESS => 'موفق', + self::FAILED => 'ناموفق', + self::CANCELED => 'لغوشده', + }; + } +} diff --git a/shop/app/Enums/UserStatusEnum.php b/shop/app/Enums/UserStatusEnum.php new file mode 100644 index 00000000..af0551d5 --- /dev/null +++ b/shop/app/Enums/UserStatusEnum.php @@ -0,0 +1,23 @@ + 'فعال', + self::BLOCK => 'مسدود', + }; + } +} diff --git a/shop/app/Http/Controllers/AccountController.php b/shop/app/Http/Controllers/AccountController.php new file mode 100644 index 00000000..be5fdf10 --- /dev/null +++ b/shop/app/Http/Controllers/AccountController.php @@ -0,0 +1,172 @@ + $this->userDto($request)->toArray(), + ]); + } + + public function profile(Request $request): Response + { + $user = $this->user($request); + $data = $this->userDto($request)->toArray(); + + // Hide the synthetic OTP placeholder so the field shows empty. + if ($user->hasPlaceholderEmail()) { + $data['email'] = null; + } + + return Inertia::render('Account/Profile', ['user' => $data]); + } + + public function updateProfile(Request $request): RedirectResponse + { + $user = $this->user($request); + + $validated = $request->validate([ + 'first_name' => ['required', 'string', 'max:255'], + 'last_name' => ['required', 'string', 'max:255'], + 'email' => ['nullable', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)], + ]); + + $user->first_name = $validated['first_name']; + $user->last_name = $validated['last_name']; + + $email = $validated['email'] ?? null; + + if ($email !== null && $email !== '' && $email !== $user->email) { + $user->email = $email; + $user->email_verified_at = null; + } + + $user->save(); + + return back()->with('status', 'اطلاعات حساب با موفقیت ذخیره شد.'); + } + + public function orders(Request $request, GetUserOrders $getUserOrders): Response + { + return Inertia::render('Account/Orders/Index', [ + 'orders' => $getUserOrders($this->user($request)), + ]); + } + + public function showOrder(Request $request, Order $order, BuildOrderDTO $buildOrderDTO): Response + { + if ($order->user_id !== $this->user($request)->id) { + abort(403); + } + + return Inertia::render('Account/Orders/Show', [ + 'order' => $buildOrderDTO($order)->toArray(), + ]); + } + + public function receipt(Request $request, Order $order, BuildOrderDTO $buildOrderDTO): Response + { + if ($order->user_id !== $this->user($request)->id) { + abort(403); + } + + return Inertia::render('Account/Orders/Receipt', [ + 'order' => $buildOrderDTO($order)->toArray(), + ]); + } + + public function retryOrder(Request $request, Order $order, RetryOrderPayment $retryPayment): RedirectResponse|SymfonyResponse + { + $user = $this->user($request); + + if ($order->user_id !== $user->id) { + abort(403); + } + + if (! $order->isRetryable()) { + abort(403); + } + + $url = $retryPayment($order, $user, route('checkout.callback'), (string) $request->ip()); + + if ($url === null) { + return redirect()->route('account.orders.show', $order) + ->with('status', 'متأسفانه موجودی برخی از کالاهای این سفارش دیگر کافی نیست.'); + } + + return Inertia::location($url); + } + + public function returns(Request $request, GetUserOrders $getUserOrders): Response + { + return Inertia::render('Account/Orders/Index', [ + 'orders' => $getUserOrders($this->user($request), OrderStatusEnum::RETURNED), + 'title' => 'مرجوعی‌های من', + 'emptyTitle' => 'هنوز مرجوعی‌ای ثبت نشده است', + 'emptyDescription' => 'سفارش‌های مرجوع‌شده شما اینجا نمایش داده می‌شوند.', + 'baseUrl' => '/account/returns', + ]); + } + + public function wishlist(Request $request, GetUserWishlist $getUserWishlist): Response + { + return Inertia::render('Account/Wishlist/Index', [ + 'products' => $getUserWishlist($this->user($request)), + ]); + } + + public function reviews(): Response + { + return $this->comingSoon('نظرات ثبت‌شده'); + } + + private function comingSoon(string $title): Response + { + return Inertia::render('Account/ComingSoon', ['title' => $title]); + } + + private function user(Request $request): User + { + $user = $request->user(); + + if (! $user instanceof User) { + abort(403); + } + + return $user; + } + + private function userDto(Request $request): UserDTO + { + $user = $this->user($request); + + return new UserDTO( + id: $user->id, + displayName: $user->displayName(), + mobile: $user->mobile, + firstName: $user->first_name, + lastName: $user->last_name, + email: $user->email, + ); + } +} diff --git a/shop/app/Http/Controllers/AddressController.php b/shop/app/Http/Controllers/AddressController.php new file mode 100644 index 00000000..da114648 --- /dev/null +++ b/shop/app/Http/Controllers/AddressController.php @@ -0,0 +1,246 @@ +user($request); + + $addresses = Address::query() + ->forUser($user->id) + ->with('city.province') + ->latest() + ->get() + ->map(fn (Address $address): array => $build($address)->toArray()) + ->all(); + + return Inertia::render('Account/Addresses/Index', [ + 'addresses' => $addresses, + 'provinces' => Province::query()->orderBy('name')->get(['id', 'name'])->toArray(), + 'neshanMapKey' => config('services.neshan.map_key'), + ]); + } + + public function store(Request $request, NormalizeMobile $normalize, StoreUserAddress $store): RedirectResponse + { + $user = $this->user($request); + $data = $this->validated($request, $normalize); + + // The first address a user adds becomes their default automatically. + $data['prime'] = $data['prime'] || ! Address::query()->forUser($user->id)->exists(); + + $store($user, $data); + + return back()->with('status', 'نشانی با موفقیت ثبت شد.'); + } + + public function update(Request $request, Address $address, NormalizeMobile $normalize, UpdateUserAddress $update): RedirectResponse + { + $this->ensureOwner($request, $address); + + $update($address, $this->validated($request, $normalize)); + + return back()->with('status', 'نشانی با موفقیت ویرایش شد.'); + } + + public function setPrimary(Request $request, Address $address): RedirectResponse + { + $this->ensureOwner($request, $address); + + // Saving with prime=true demotes the user's other addresses (model hook). + $address->update(['prime' => true]); + + return back()->with('status', 'نشانی پیش‌فرض تغییر کرد.'); + } + + public function destroy(Request $request, Address $address): RedirectResponse + { + $this->ensureOwner($request, $address); + + // Soft delete only: orders reference addresses, so the row must survive + // for history. It just leaves the customer's active list. + $wasPrime = $address->prime; + $address->delete(); + + // Keep a default selectable: promote the newest remaining address. + if ($wasPrime) { + Address::query() + ->forUser($address->user_id) + ->latest() + ->first() + ?->update(['prime' => true]); + } + + return back()->with('status', 'نشانی حذف شد.'); + } + + public function cities(Request $request): JsonResponse + { + $validated = $request->validate([ + 'province_id' => ['required', 'integer', Rule::exists('provinces', 'id')], + ]); + + $cities = City::query() + ->where('province_id', $validated['province_id']) + ->orderBy('name') + ->get(['id', 'name']); + + return response()->json($cities); + } + + public function reverse(Request $request, ReverseGeocode $reverse): JsonResponse + { + $validated = $request->validate([ + 'lat' => ['required', 'numeric', 'between:-90,90'], + 'lng' => ['required', 'numeric', 'between:-180,180'], + ]); + + return response()->json([ + 'address' => $reverse((float) $validated['lat'], (float) $validated['lng']), + ]); + } + + public function staticMap(Request $request, StaticMap $static): HttpResponse + { + $validated = $request->validate([ + 'lat' => ['required', 'numeric', 'between:-90,90'], + 'lng' => ['required', 'numeric', 'between:-180,180'], + 'zoom' => ['nullable', 'integer', 'between:5,19'], + ]); + + $image = $static( + (float) $validated['lat'], + (float) $validated['lng'], + (int) ($validated['zoom'] ?? 15), + ); + + if ($image === null) { + abort(404); + } + + return response($image['body'], 200) + ->header('Content-Type', $image['contentType']) + ->header('Cache-Control', 'private, max-age=86400'); + } + + /** + * @return array + */ + private function validated(Request $request, NormalizeMobile $normalize): array + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'city_id' => ['required', 'integer', Rule::exists('cities', 'id')], + 'address' => ['required', 'string', 'max:1000'], + 'plate' => ['nullable', 'string', 'max:50'], + 'unit' => ['nullable', 'string', 'max:50'], + 'postal_code' => ['required', 'string'], + 'phone' => ['required', 'string'], + 'note' => ['nullable', 'string', 'max:500'], + 'latitude' => ['nullable', 'numeric', 'between:-90,90'], + 'longitude' => ['nullable', 'numeric', 'between:-180,180'], + 'prime' => ['boolean'], + ], [ + 'required' => 'وارد کردن :attribute الزامی است.', + 'string' => ':attribute باید متن باشد.', + 'integer' => ':attribute نامعتبر است.', + 'numeric' => ':attribute نامعتبر است.', + 'exists' => ':attribute انتخاب‌شده معتبر نیست.', + 'max' => ':attribute نباید بیشتر از :max نویسه باشد.', + 'between' => ':attribute خارج از محدوده مجاز است.', + 'boolean' => ':attribute نامعتبر است.', + ], [ + 'name' => 'عنوان نشانی', + 'city_id' => 'شهر', + 'address' => 'نشانی', + 'plate' => 'پلاک', + 'unit' => 'واحد', + 'postal_code' => 'کد پستی', + 'phone' => 'شماره موبایل', + 'note' => 'توضیحات', + 'latitude' => 'موقعیت مکانی', + 'longitude' => 'موقعیت مکانی', + ]); + + $phone = $normalize($validated['phone']); + + if ($phone === null) { + throw ValidationException::withMessages([ + 'phone' => 'شماره موبایل معتبر نیست.', + ]); + } + + $postal = $this->toEnglishDigits($validated['postal_code']); + + if (preg_match('/^\d{10}$/', $postal) !== 1) { + throw ValidationException::withMessages([ + 'postal_code' => 'کد پستی باید ۱۰ رقم باشد.', + ]); + } + + return [ + 'name' => $validated['name'], + 'city_id' => $validated['city_id'], + 'address' => $validated['address'], + 'plate' => $validated['plate'] ?? null, + 'unit' => $validated['unit'] ?? null, + 'postal_code' => $postal, + 'phone' => $phone, + 'note' => $validated['note'] ?? null, + 'latitude' => $validated['latitude'] ?? null, + 'longitude' => $validated['longitude'] ?? null, + 'prime' => (bool) ($validated['prime'] ?? false), + ]; + } + + private function toEnglishDigits(string $value): string + { + return strtr($value, [ + '۰' => '0', '۱' => '1', '۲' => '2', '۳' => '3', '۴' => '4', + '۵' => '5', '۶' => '6', '۷' => '7', '۸' => '8', '۹' => '9', + '٠' => '0', '١' => '1', '٢' => '2', '٣' => '3', '٤' => '4', + '٥' => '5', '٦' => '6', '٧' => '7', '٨' => '8', '٩' => '9', + ]); + } + + private function ensureOwner(Request $request, Address $address): void + { + if ($address->user_id !== $this->user($request)->id) { + abort(403); + } + } + + private function user(Request $request): User + { + $user = $request->user(); + + if (! $user instanceof User) { + abort(403); + } + + return $user; + } +} diff --git a/shop/app/Http/Controllers/AuthController.php b/shop/app/Http/Controllers/AuthController.php new file mode 100644 index 00000000..f5b14b64 --- /dev/null +++ b/shop/app/Http/Controllers/AuthController.php @@ -0,0 +1,197 @@ +sentOtp($this->mobile($request, $normalize), $sendOtp); + } + + /** + * Resend a one-time code — also used to switch from password back to OTP + * login. Resending is blocked while the current code is still valid. + */ + public function requestOtp(Request $request, NormalizeMobile $normalize, SendOtpCode $sendOtp): RedirectResponse + { + $mobile = $this->mobile($request, $normalize); + $remaining = $sendOtp->secondsRemaining($mobile); + + if ($remaining > 0) { + return back() + ->withErrors(['code' => "کد قبلی هنوز معتبر است؛ لطفاً {$remaining} ثانیه دیگر برای دریافت کد جدید صبر کنید."]) + ->with('authStep', 'otp') + ->with('authMobile', $mobile) + ->with('authResendIn', $remaining); + } + + return $this->sentOtp($mobile, $sendOtp); + } + + /** + * Verify a one-time code, then log in the user (creating the account on + * first login). + */ + public function verifyOtp(Request $request, NormalizeMobile $normalize, VerifyOtpCode $verify): RedirectResponse + { + $mobile = $this->mobile($request, $normalize); + $code = $this->code($request); + + if (! $verify($mobile, $code)) { + return back() + ->withErrors(['code' => 'کد وارد شده نادرست یا منقضی شده است.']) + ->with('authStep', 'otp') + ->with('authMobile', $mobile); + } + + $user = User::query()->firstOrNew(['mobile' => $mobile]); + + if ($user->exists && $user->status === UserStatusEnum::BLOCK) { + return $this->blocked($mobile); + } + + if (! $user->exists) { + // The shared schema requires email/password/name to be present, so + // seed safe placeholders the user can complete later in their + // profile. The random password keeps the account OTP-only. + $user->status = UserStatusEnum::ACTIVE; + $user->first_name = ''; + $user->last_name = ''; + $user->email = User::placeholderEmail($mobile); + $user->password = Hash::make(Str::random(40)); + } + + $user->mobile_verified_at ??= now(); + $user->save(); + + return $this->login($request, $user); + } + + /** + * Log in with mobile + password. + */ + public function password(Request $request, NormalizeMobile $normalize): RedirectResponse + { + $mobile = $this->mobile($request, $normalize); + $password = (string) $request->input('password', ''); + + $user = User::query()->where('mobile', $mobile)->first(); + + if ($user === null || ! Hash::check($password, $user->password ?? '')) { + return back() + ->withErrors(['password' => 'رمز عبور نادرست است.']) + ->with('authStep', 'password') + ->with('authMobile', $mobile); + } + + if ($user->status === UserStatusEnum::BLOCK) { + return $this->blocked($mobile); + } + + return $this->login($request, $user); + } + + public function logout(Request $request): RedirectResponse + { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect('/'); + } + + /** + * Validate and normalise the submitted mobile, aborting back with an error + * when it is not a valid Iranian number. + */ + private function mobile(Request $request, NormalizeMobile $normalize): string + { + $mobile = $normalize((string) $request->input('mobile', '')); + + if ($mobile === null) { + throw new HttpResponseException( + back() + ->withErrors(['mobile' => 'شماره موبایل معتبر نیست.']) + ->with('authStep', 'mobile') + ); + } + + return $mobile; + } + + private function code(Request $request): string + { + $code = strtr((string) $request->input('code', ''), [ + '۰' => '0', '۱' => '1', '۲' => '2', '۳' => '3', '۴' => '4', + '۵' => '5', '۶' => '6', '۷' => '7', '۸' => '8', '۹' => '9', + ]); + + return preg_replace('/\D+/', '', $code) ?? ''; + } + + private function sentOtp(string $mobile, SendOtpCode $sendOtp): RedirectResponse + { + $code = $sendOtp($mobile); + + $redirect = back() + ->with('authStep', 'otp') + ->with('authMobile', $mobile) + ->with('authResendIn', $sendOtp->secondsRemaining($mobile)); + + if (config('app.debug')) { + $redirect->with('authOtpDev', $code); + } + + return $redirect; + } + + private function blocked(string $mobile): RedirectResponse + { + return back() + ->withErrors(['mobile' => 'حساب کاربری شما مسدود شده است.']) + ->with('authStep', 'mobile') + ->with('authMobile', $mobile); + } + + private function login(Request $request, User $user): RedirectResponse + { + // Capture the guest session id before regeneration so any cart built + // while logged out is carried onto the account. + $guestSession = $request->session()->getId(); + + Auth::login($user, remember: true); + $request->session()->regenerate(); + + app(MergeGuestCart::class)($user, $guestSession); + + return redirect()->intended('/'); + } +} diff --git a/shop/app/Http/Controllers/BrandController.php b/shop/app/Http/Controllers/BrandController.php new file mode 100644 index 00000000..4c35ba42 --- /dev/null +++ b/shop/app/Http/Controllers/BrandController.php @@ -0,0 +1,73 @@ +active() + ->where('slug', $slug) + ->with('image') + ->firstOrFail(); + + $filters = $this->filters($request); + + return Inertia::render('Brand/Show', [ + 'brand' => $buildBrandDetail($brand)->toArray(), + 'breadcrumbs' => $buildBreadcrumbs($brand), + 'products' => $getBrandProducts($brand->id, $filters), + 'filters' => $getBrandFilters($brand->id, $filters), + 'applied' => $filters, + ]); + } + + /** + * Normalise the filter/sort query parameters into a typed shape. + * + * @return array{categories: array, minPrice: int|null, maxPrice: int|null, inStock: bool, sort: string} + */ + private function filters(Request $request): array + { + $sort = (string) $request->query('sort', 'newest'); + + if (! in_array($sort, ['newest', 'cheapest', 'expensive', 'popular'], true)) { + $sort = 'newest'; + } + + return [ + 'categories' => array_values(array_filter(array_map('strval', (array) $request->query('categories', [])))), + 'minPrice' => $this->intOrNull($request->query('min_price')), + 'maxPrice' => $this->intOrNull($request->query('max_price')), + 'inStock' => $request->boolean('in_stock'), + 'sort' => $sort, + ]; + } + + private function intOrNull(mixed $value): ?int + { + if ($value === null || $value === '' || ! is_numeric($value)) { + return null; + } + + return (int) $value; + } +} diff --git a/shop/app/Http/Controllers/CartController.php b/shop/app/Http/Controllers/CartController.php new file mode 100644 index 00000000..ccccb213 --- /dev/null +++ b/shop/app/Http/Controllers/CartController.php @@ -0,0 +1,88 @@ +owner)($request)); + + return Inertia::render('Cart/Index', [ + 'lines' => $lines->map(fn (CartLineDTO $line): array => $line->toArray())->all(), + 'summary' => $buildSummary($lines)->toArray(), + ]); + } + + public function store(Request $request, AddToCart $add): RedirectResponse + { + $validated = $request->validate([ + 'variety_id' => ['required', 'integer', Rule::exists('varieties', 'id')], + 'count' => ['nullable', 'integer', 'min:1'], + ]); + + $variety = Variety::query()->findOrFail($validated['variety_id']); + + if (! $variety->has_stock || $variety->inventory < 1) { + throw ValidationException::withMessages(['variety_id' => 'این کالا موجود نیست.']); + } + + $add(($this->owner)($request), $variety, (int) ($validated['count'] ?? 1)); + + return back()->with('status', 'کالا به سبد خرید اضافه شد.'); + } + + public function update(Request $request, Cart $cart): RedirectResponse + { + $this->ensureOwner($request, $cart); + + $validated = $request->validate([ + 'count' => ['required', 'integer', 'min:1'], + ]); + + $cap = max(1, $cart->variety->inventory); + $cart->update(['count' => min((int) $validated['count'], $cap)]); + + return back(); + } + + public function destroy(Request $request, Cart $cart): RedirectResponse + { + $this->ensureOwner($request, $cart); + + $cart->delete(); + + return back()->with('status', 'کالا از سبد خرید حذف شد.'); + } + + private function ensureOwner(Request $request, Cart $cart): void + { + $owner = ($this->owner)($request); + + $matches = isset($owner['user_id']) + ? $cart->user_id === $owner['user_id'] + : $cart->session_id === $owner['session_id']; + + if (! $matches) { + abort(403); + } + } +} diff --git a/shop/app/Http/Controllers/CategoryController.php b/shop/app/Http/Controllers/CategoryController.php new file mode 100644 index 00000000..74f957d3 --- /dev/null +++ b/shop/app/Http/Controllers/CategoryController.php @@ -0,0 +1,77 @@ +active() + ->where('slug', $slug) + ->with('image') + ->firstOrFail(); + + $categoryIds = $collectCategoryIds($category); + $filters = $this->filters($request); + + return Inertia::render('Category/Show', [ + 'category' => $buildCategoryDetail($category)->toArray(), + 'breadcrumbs' => $buildBreadcrumbs($category), + 'products' => $getCategoryProducts($categoryIds, $filters), + 'filters' => $getCategoryFilters($categoryIds, $filters), + 'applied' => $filters, + ]); + } + + /** + * Normalise the filter/sort query parameters into a typed shape. + * + * @return array{brands: array, attributes: array, minPrice: int|null, maxPrice: int|null, inStock: bool, sort: string} + */ + private function filters(Request $request): array + { + $sort = (string) $request->query('sort', 'newest'); + + if (! in_array($sort, ['newest', 'cheapest', 'expensive', 'popular'], true)) { + $sort = 'newest'; + } + + return [ + 'brands' => array_values(array_filter(array_map('strval', (array) $request->query('brands', [])))), + 'attributes' => array_values(array_filter(array_map('intval', (array) $request->query('attributes', [])))), + 'minPrice' => $this->intOrNull($request->query('min_price')), + 'maxPrice' => $this->intOrNull($request->query('max_price')), + 'inStock' => $request->boolean('in_stock'), + 'sort' => $sort, + ]; + } + + private function intOrNull(mixed $value): ?int + { + if ($value === null || $value === '' || ! is_numeric($value)) { + return null; + } + + return (int) $value; + } +} diff --git a/shop/app/Http/Controllers/CheckoutController.php b/shop/app/Http/Controllers/CheckoutController.php new file mode 100644 index 00000000..6ce3e2e7 --- /dev/null +++ b/shop/app/Http/Controllers/CheckoutController.php @@ -0,0 +1,180 @@ +user($request); + $lines = $getLines(($this->owner)($request)); + + if ($lines->isEmpty()) { + return redirect()->route('cart')->with('status', 'سبد خرید شما خالی است.'); + } + + $addresses = Address::query() + ->forUser($user->id) + ->with('city.province') + ->latest() + ->get(); + + $defaultAddress = $addresses->firstWhere('prime', true) ?? $addresses->first(); + $selectedAddressId = $request->session()->get('checkout.address_id') + ?? ($defaultAddress === null ? null : $defaultAddress->id); + + $selectedAddress = $selectedAddressId === null + ? null + : $addresses->firstWhere('id', $selectedAddressId); + + return Inertia::render('Checkout/Shipping', [ + 'summary' => $buildSummary($lines)->toArray(), + 'addresses' => $addresses->map(fn (Address $address): array => $build($address)->toArray())->all(), + 'selectedAddressId' => $selectedAddressId, + 'shippingMethods' => $this->methodsFor($selectedAddress), + 'selectedMethodId' => $request->session()->get('checkout.shipping_method_id'), + 'provinces' => Province::query()->orderBy('name')->get(['id', 'name'])->toArray(), + 'neshanMapKey' => config('services.neshan.map_key'), + ]); + } + + /** + * Shipping methods for one of the user's addresses, as JSON. Used by the + * shipping step to refresh the list when the chosen address changes. + */ + public function methods(Request $request): JsonResponse + { + $user = $this->user($request); + + $validated = $request->validate([ + 'address_id' => [ + 'required', + 'integer', + Rule::exists('addresses', 'id')->where('user_id', $user->id), + ], + ]); + + $address = Address::query()->forUser($user->id)->find($validated['address_id']); + + return response()->json(['methods' => $this->methodsFor($address)]); + } + + public function storeShipping(Request $request): RedirectResponse + { + $user = $this->user($request); + + $validated = $request->validate([ + 'address_id' => [ + 'required', + 'integer', + Rule::exists('addresses', 'id')->where('user_id', $user->id), + ], + 'shipping_method_id' => ['required', 'integer'], + ]); + + $address = Address::query()->forUser($user->id)->findOrFail($validated['address_id']); + $available = collect($this->methodsFor($address)); + + if (! $available->contains(fn (array $method): bool => $method['id'] === (int) $validated['shipping_method_id'])) { + return back()->withErrors(['shipping_method_id' => 'روش ارسال انتخاب‌شده معتبر نیست.']); + } + + $request->session()->put('checkout.address_id', (int) $validated['address_id']); + $request->session()->put('checkout.shipping_method_id', (int) $validated['shipping_method_id']); + + return redirect()->route('checkout.payment'); + } + + /** + * Step 3: payment. Placeholder until the gateway/receipt flow is built. + */ + public function payment(Request $request, GetCartLines $getLines, BuildCartSummary $buildSummary, BuildAddressDTO $build): Response|RedirectResponse + { + $user = $this->user($request); + $lines = $getLines(($this->owner)($request)); + + if ($lines->isEmpty()) { + return redirect()->route('cart')->with('status', 'سبد خرید شما خالی است.'); + } + + $addressId = $request->session()->get('checkout.address_id'); + $address = $addressId === null + ? null + : Address::query()->forUser($user->id)->with('city.province')->find($addressId); + + if ($address === null) { + return redirect()->route('checkout.shipping')->with('status', 'لطفاً نشانی ارسال را انتخاب کنید.'); + } + + $methodId = $request->session()->get('checkout.shipping_method_id'); + $method = collect($this->methodsFor($address)) + ->firstWhere('id', $methodId === null ? 0 : (int) $methodId); + + if ($method === null) { + return redirect()->route('checkout.shipping')->with('status', 'لطفاً روش ارسال را انتخاب کنید.'); + } + + return Inertia::render('Checkout/Payment', [ + 'summary' => $buildSummary($lines)->toArray(), + 'address' => $build($address)->toArray(), + 'shippingMethod' => $method, + ]); + } + + /** + * Shipping methods (as arrays) available for an address, or an empty list + * when there is no address yet. + * + * @return array> + */ + private function methodsFor(?Address $address): array + { + if ($address === null) { + return []; + } + + $city = $address->city; + + return ($this->getShippingMethods)($city->id, $city->province_id) + ->map(fn (ShippingMethodDTO $method): array => $method->toArray()) + ->all(); + } + + private function user(Request $request): User + { + $user = $request->user(); + + if (! $user instanceof User) { + abort(403); + } + + return $user; + } +} diff --git a/shop/app/Http/Controllers/FaqController.php b/shop/app/Http/Controllers/FaqController.php new file mode 100644 index 00000000..29b51f62 --- /dev/null +++ b/shop/app/Http/Controllers/FaqController.php @@ -0,0 +1,24 @@ + $position, + 'faqs' => $getFaqs($position), + 'breadcrumbs' => [ + ['heading' => 'خانه', 'url' => '/'], + ['heading' => 'سوالات متداول', 'url' => null], + ], + ]); + } +} diff --git a/shop/app/Http/Controllers/HomeController.php b/shop/app/Http/Controllers/HomeController.php new file mode 100644 index 00000000..7ab249e5 --- /dev/null +++ b/shop/app/Http/Controllers/HomeController.php @@ -0,0 +1,32 @@ + $getHeroSlides(), + 'categories' => $getHomeCategories(), + 'banners' => $getPromoBanners(), + 'productRows' => $getProductRows(), + 'brands' => $getFeaturedBrands(), + ]); + } +} diff --git a/shop/app/Http/Controllers/PageController.php b/shop/app/Http/Controllers/PageController.php new file mode 100644 index 00000000..9b273b56 --- /dev/null +++ b/shop/app/Http/Controllers/PageController.php @@ -0,0 +1,30 @@ +published() + ->where('slug', $slug) + ->with('image') + ->firstOrFail(); + + return Inertia::render('Page/Show', [ + 'page' => $buildPageDetail($page)->toArray(), + 'breadcrumbs' => [ + ['heading' => 'خانه', 'url' => '/'], + ['heading' => $page->heading, 'url' => null], + ], + ]); + } +} diff --git a/shop/app/Http/Controllers/PaymentController.php b/shop/app/Http/Controllers/PaymentController.php new file mode 100644 index 00000000..3d2fcc46 --- /dev/null +++ b/shop/app/Http/Controllers/PaymentController.php @@ -0,0 +1,154 @@ +user($request); + $lines = $getLines(($this->owner)($request)); + + if ($lines->isEmpty()) { + return redirect()->route('cart')->with('status', 'سبد خرید شما خالی است.'); + } + + // Stock can change between adding to cart and reaching payment; never + // open a Zarinpal payment session for something no longer available. + if (! $validateStock($lines)) { + return redirect()->route('cart')->with('status', 'موجودی برخی از کالاهای سبد خرید شما تغییر کرده است. لطفاً سبد خرید را بررسی کنید.'); + } + + $address = $this->resolveAddress($request, $user); + + if ($address === null) { + return redirect()->route('checkout.shipping')->with('status', 'لطفاً نشانی ارسال را انتخاب کنید.'); + } + + $method = $this->resolveMethod($request, $address); + + if ($method === null) { + return redirect()->route('checkout.shipping')->with('status', 'لطفاً روش ارسال را انتخاب کنید.'); + } + + $summary = $buildSummary($lines); + + $url = ($this->startPayment)( + $user, + $lines, + $address, + $method, + $summary, + route('checkout.callback'), + (string) $request->ip(), + ); + + if ($url === null) { + return back()->with('status', 'در اتصال به درگاه پرداخت خطایی رخ داد. لطفاً دوباره تلاش کنید.'); + } + + return Inertia::location($url); + } + + /** + * Zarinpal's return redirect. Looked up by `Authority`, not session, since + * that's the only value guaranteed to survive the round-trip. + */ + public function callback(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'Authority' => ['required', 'string'], + 'Status' => ['required', 'string'], + ]); + + $order = ($this->completePayment)($validated['Authority'], $validated['Status']); + + if ($order === null) { + return redirect()->route('checkout.payment')->with('status', 'پرداخت ناموفق بود.'); + } + + $request->session()->forget(['checkout.address_id', 'checkout.shipping_method_id']); + + return redirect()->route('checkout.confirmation', $order); + } + + public function confirmation(Request $request, Order $order): Response + { + if ($order->user_id !== $this->user($request)->id) { + abort(403); + } + + return Inertia::render('Checkout/Confirmation', [ + 'order' => ($this->buildOrderDTO)($order)->toArray(), + ]); + } + + private function resolveAddress(Request $request, User $user): ?Address + { + $addressId = $request->session()->get('checkout.address_id'); + + if ($addressId === null) { + return null; + } + + return Address::query()->forUser($user->id)->with('city.province')->find($addressId); + } + + private function resolveMethod(Request $request, Address $address): ?ShippingMethodDTO + { + $methodId = $request->session()->get('checkout.shipping_method_id'); + + if ($methodId === null) { + return null; + } + + $city = $address->city; + + return ($this->getShippingMethods)($city->id, $city->province_id) + ->firstWhere('id', (int) $methodId); + } + + private function user(Request $request): User + { + $user = $request->user(); + + if (! $user instanceof User) { + abort(403); + } + + return $user; + } +} diff --git a/shop/app/Http/Controllers/ProductController.php b/shop/app/Http/Controllers/ProductController.php new file mode 100644 index 00000000..1412bb77 --- /dev/null +++ b/shop/app/Http/Controllers/ProductController.php @@ -0,0 +1,105 @@ +published() + ->where('slug', $slug) + ->with([ + 'featuredImage', + 'images', + 'brand', + 'category', + 'varieties' => fn (Relation $query) => $query->where('status', VarietyStatusEnum::PUBLISHED->value)->with([ + 'image', + 'attribute.attributeGroup', + 'attributes.attributeGroup', + ]), + 'attributes.attributeGroup', + 'reviews' => fn (Relation $query) => $query->where('status', ReviewStatusEnum::APPROVED->value)->whereNull('parent_id')->with('user')->latest(), + ]) + ->firstOrFail(); + + $product->increment('seen'); + + return Inertia::render('Product/Show', [ + 'product' => $buildProductDetail($product)->toArray(), + 'breadcrumbs' => $buildBreadcrumbs($product), + 'related' => $getRelatedProducts($product), + 'cartItems' => $this->cartItems($request, $resolveOwner, $product), + 'isWishlisted' => $this->isWishlisted($request, $product), + // Any logged-in user may review; the form is hidden for guests. + 'canReview' => $request->user() instanceof User, + ]); + } + + /** + * Whether the current user has saved this product, so the buy box can + * show a filled/outlined heart instead of a fresh wishlist button. + * Guests (no session-based wishlist support, unlike cart) always see false. + */ + private function isWishlisted(Request $request, Product $product): bool + { + $user = $request->user(); + + if (! $user instanceof User) { + return false; + } + + return Wishlist::query() + ->where('user_id', $user->id) + ->where('product_id', $product->id) + ->exists(); + } + + /** + * The current cart quantity (and line id) per variety of this product, so + * the buy box can mirror the cart instead of a fresh add-to-cart button. + * + * @return array + */ + private function cartItems(Request $request, ResolveCartOwner $resolveOwner, Product $product): array + { + $varietyIds = $product->varieties->pluck('id')->all(); + + if ($varietyIds === []) { + return []; + } + + return Cart::query() + ->where($resolveOwner($request)) + ->whereIn('variety_id', $varietyIds) + ->get(['id', 'variety_id', 'count']) + ->mapWithKeys(fn (Cart $line): array => [ + $line->variety_id => ['id' => $line->id, 'count' => $line->count], + ]) + ->all(); + } +} diff --git a/shop/app/Http/Controllers/ReviewController.php b/shop/app/Http/Controllers/ReviewController.php new file mode 100644 index 00000000..71f281ad --- /dev/null +++ b/shop/app/Http/Controllers/ReviewController.php @@ -0,0 +1,37 @@ +user(); + + if (! $user instanceof User) { + abort(403); + } + + $validated = $request->validate([ + 'rating' => ['required', 'integer', 'between:1,5'], + 'heading' => ['required', 'string', 'max:255'], + 'content' => ['required', 'string', 'max:2000'], + ]); + + $createReview($user, $product, [ + 'heading' => $validated['heading'], + 'content' => $validated['content'], + 'rating' => (int) $validated['rating'], + ]); + + return back()->with('status', 'دیدگاه شما ثبت شد و پس از تأیید نمایش داده می‌شود.'); + } +} diff --git a/shop/app/Http/Controllers/SearchController.php b/shop/app/Http/Controllers/SearchController.php new file mode 100644 index 00000000..1386cfcf --- /dev/null +++ b/shop/app/Http/Controllers/SearchController.php @@ -0,0 +1,41 @@ +query('q', '')); + $sort = $this->sort($request); + + return Inertia::render('Search/Results', [ + 'query' => $term, + 'products' => $search->search($term, ['sort' => $sort]), + 'applied' => ['sort' => $sort], + ]); + } + + public function suggest(Request $request, GetSearchSuggestions $getSuggestions): JsonResponse + { + return response()->json($getSuggestions((string) $request->query('q', ''))); + } + + private function sort(Request $request): string + { + $sort = (string) $request->query('sort', 'newest'); + + return in_array($sort, ['newest', 'cheapest', 'expensive', 'popular'], true) + ? $sort + : 'newest'; + } +} diff --git a/shop/app/Http/Controllers/SitemapController.php b/shop/app/Http/Controllers/SitemapController.php new file mode 100644 index 00000000..29b36eba --- /dev/null +++ b/shop/app/Http/Controllers/SitemapController.php @@ -0,0 +1,46 @@ +']; + $lines[] = ''; + + foreach ($getSitemapUrls() as $url) { + $lines[] = ' '; + $lines[] = ' '.htmlspecialchars($url['loc'], ENT_XML1).''; + + if ($url['lastmod'] !== null) { + $lines[] = ' '.$url['lastmod'].''; + } + + $lines[] = ' '; + } + + $lines[] = ''; + + return response(implode("\n", $lines)."\n") + ->header('Content-Type', 'application/xml; charset=UTF-8'); + } + + public function robots(): Response + { + $base = rtrim((string) config('app.url'), '/'); + + $body = implode("\n", [ + 'User-agent: *', + 'Allow: /', + 'Sitemap: '.$base.'/sitemap.xml', + ])."\n"; + + return response($body)->header('Content-Type', 'text/plain; charset=UTF-8'); + } +} diff --git a/shop/app/Http/Controllers/WishlistController.php b/shop/app/Http/Controllers/WishlistController.php new file mode 100644 index 00000000..47355daf --- /dev/null +++ b/shop/app/Http/Controllers/WishlistController.php @@ -0,0 +1,27 @@ +user(); + + if (! $user instanceof User) { + abort(403); + } + + $wishlisted = $toggle($user, $product); + + return back()->with('status', $wishlisted ? 'به علاقه‌مندی‌ها اضافه شد.' : 'از علاقه‌مندی‌ها حذف شد.'); + } +} diff --git a/shop/app/Http/Middleware/HandleInertiaRequests.php b/shop/app/Http/Middleware/HandleInertiaRequests.php index b9701985..6311803d 100644 --- a/shop/app/Http/Middleware/HandleInertiaRequests.php +++ b/shop/app/Http/Middleware/HandleInertiaRequests.php @@ -4,7 +4,15 @@ namespace App\Http\Middleware; +use App\Actions\Cart\ResolveCartOwner; +use App\Enums\CategoryStatusEnum; +use App\Models\Cart; +use App\Models\Category; +use App\Models\Setting; +use App\Models\User; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Inertia\Middleware; class HandleInertiaRequests extends Middleware @@ -37,9 +45,176 @@ public function version(Request $request): ?string */ public function share(Request $request): array { + $settings = $this->autoloadedSettings(); + return [ ...parent::share($request), - // + 'auth' => [ + 'user' => $this->authUser($request), + ], + 'flash' => [ + 'status' => $request->session()->get('status'), + 'authStep' => $request->session()->get('authStep'), + 'authMobile' => $request->session()->get('authMobile'), + 'authResendIn' => $request->session()->get('authResendIn'), + 'authOtpDev' => $request->session()->get('authOtpDev'), + ], + 'seo' => [ + 'siteName' => (string) config('app.name'), + 'url' => $this->canonicalUrl($request), + 'origin' => rtrim((string) config('app.url'), '/'), + 'locale' => 'fa_IR', + ], + 'nav' => [ + 'categories' => $this->navCategories(), + ], + 'cart' => [ + 'count' => $this->cartCount($request), + ], + 'footer' => [ + 'about' => $this->value($settings, 'footer_about'), + 'columns' => [ + ['title' => 'فروشگاه', 'links' => $this->json($settings, 'footer_links_shop')], + ['title' => 'پشتیبانی', 'links' => $this->json($settings, 'footer_links_support')], + ], + 'contact' => [ + 'title' => 'ارتباط با ما', + 'phone' => $this->value($settings, 'site_phone'), + 'email' => $this->value($settings, 'site_email'), + 'hours' => $this->json($settings, 'site_working_hours'), + 'address' => $this->value($settings, 'site_address'), + ], + 'socials' => $this->json($settings, 'footer_socials'), + 'copyright' => $this->value($settings, 'footer_copyright'), + ], + ]; + } + + /** + * The authenticated user as a small payload for the header/account UI. + * + * @return array|null + */ + private function authUser(Request $request): ?array + { + $user = $request->user(); + + if (! $user instanceof User) { + return null; + } + + return [ + 'id' => $user->id, + 'name' => $user->displayName(), + 'mobile' => $user->mobile, ]; } + + /** + * Total item count in the current cart (user or guest session), shared so + * the header badge stays in sync on every page. Guarded so the storefront + * still renders if the shared carts table is unavailable. + */ + private function cartCount(Request $request): int + { + return rescue(function () use ($request): int { + $owner = app(ResolveCartOwner::class)($request); + + return (int) Cart::query()->where($owner)->sum('count'); + }, 0, false); + } + + /** + * Top-level active categories (with their active children) for the header + * navigation. Shared globally so every page's header has the menu. + * + * Guarded with rescue() so the storefront still renders if the shared + * categories table is unavailable. + * + * @return array> + */ + private function navCategories(): array + { + return rescue(function (): array { + return Category::query() + ->active() + ->whereNull('parent_id') + ->with(['children' => fn (Relation $query) => $query->where('status', CategoryStatusEnum::ACTIVE->value)->orderBy('heading')]) + ->orderBy('heading') + ->get() + ->map(fn (Category $category): array => [ + 'heading' => $category->heading, + 'url' => '/categories/'.$category->slug, + 'children' => $category->children + ->map(fn (Category $child): array => [ + 'heading' => $child->heading, + 'url' => '/categories/'.$child->slug, + ]) + ->all(), + ]) + ->all(); + }, [], false); + } + + /** + * Build the canonical/og URL from the configured public origin (APP_URL) + * rather than the raw request, so it stays correct behind TLS-terminating + * proxies regardless of the internal scheme/host. + */ + private function canonicalUrl(Request $request): string + { + $base = rtrim((string) config('app.url'), '/'); + $path = $request->path(); + + return $path === '/' ? $base : $base.'/'.$path; + } + + /** + * Load the globally autoloaded settings as a key => content map. + * + * Guarded with rescue() so the storefront still renders in environments + * where the admin-owned settings table is absent (e.g. the test database). + * + * @return Collection + */ + private function autoloadedSettings(): Collection + { + /** @var Collection $settings */ + $settings = rescue( + fn (): Collection => Setting::query()->autoloaded()->pluck('content', 'key'), + collect(), + false, + ); + + return $settings; + } + + /** + * @param Collection $settings + */ + private function value(Collection $settings, string $key): ?string + { + $value = $settings->get($key); + + return is_string($value) ? $value : null; + } + + /** + * Decode a JSON setting value into an array. + * + * @param Collection $settings + * @return array + */ + private function json(Collection $settings, string $key): array + { + $value = $this->value($settings, $key); + + if ($value === null || $value === '') { + return []; + } + + $decoded = json_decode($value, true); + + return is_array($decoded) ? $decoded : []; + } } diff --git a/shop/app/Models/Address.php b/shop/app/Models/Address.php new file mode 100644 index 00000000..bc7e0276 --- /dev/null +++ b/shop/app/Models/Address.php @@ -0,0 +1,81 @@ + 'boolean', + ]; + + protected static function booted(): void + { + // One primary address per user: when an address is saved as primary, + // demote the user's other addresses (mirrors the admin model). + static::saved(function (Address $address): void { + if ($address->prime) { + static::query() + ->where('user_id', $address->user_id) + ->whereKeyNot($address->getKey()) + ->where('prime', true) + ->update(['prime' => false]); + } + }); + } + + public function scopeForUser(Builder $query, int $userId): Builder + { + return $query->where('user_id', $userId); + } + + public function city(): BelongsTo + { + return $this->belongsTo(City::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/shop/app/Models/Attribute.php b/shop/app/Models/Attribute.php index e8def1b6..a86abfea 100644 --- a/shop/app/Models/Attribute.php +++ b/shop/app/Models/Attribute.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -14,6 +15,7 @@ * @property positive-int $attribute_group_id * @property string|null $color * @property string $value + * @property AttributeGroup|null $attributeGroup * @property Collection $varieties * @property Collection $directVarieties * @property Collection $products @@ -26,6 +28,11 @@ class Attribute extends Model 'color', ]; + public function attributeGroup(): BelongsTo + { + return $this->belongsTo(AttributeGroup::class); + } + /** Varieties linked via the attribute_variety pivot (additional attributes). */ public function varieties(): BelongsToMany { diff --git a/shop/app/Models/AttributeGroup.php b/shop/app/Models/AttributeGroup.php new file mode 100644 index 00000000..b55955b4 --- /dev/null +++ b/shop/app/Models/AttributeGroup.php @@ -0,0 +1,26 @@ + $attributes + */ +class AttributeGroup extends Model +{ + protected $fillable = [ + 'name', + ]; + + public function attributes(): HasMany + { + return $this->hasMany(Attribute::class); + } +} diff --git a/shop/app/Models/Cart.php b/shop/app/Models/Cart.php new file mode 100644 index 00000000..9308e8bb --- /dev/null +++ b/shop/app/Models/Cart.php @@ -0,0 +1,47 @@ + 'integer', + ]; + + public function variety(): BelongsTo + { + return $this->belongsTo(Variety::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/shop/app/Models/City.php b/shop/app/Models/City.php new file mode 100644 index 00000000..f5d18442 --- /dev/null +++ b/shop/app/Models/City.php @@ -0,0 +1,30 @@ +belongsTo(Province::class); + } +} diff --git a/shop/app/Models/Image.php b/shop/app/Models/Image.php index c25e70c9..bc0b4f5f 100644 --- a/shop/app/Models/Image.php +++ b/shop/app/Models/Image.php @@ -5,8 +5,10 @@ namespace App\Models; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Support\Str; /** * @property positive-int $id @@ -18,6 +20,7 @@ * @property positive-int|null $order * @property Carbon|null $created_at * @property Carbon|null $updated_at + * @property-read string $url */ class Image extends Model { @@ -39,4 +42,27 @@ public function imageable(): MorphTo { return $this->morphTo(); } + + /** + * Resolve the stored relative `path` into a full URL. + * + * `images.path` is relative to the image host (see the db doc); we prefix + * it with `app.image_url` (the admin app's public storage) when set, + * leaving already-absolute URLs untouched. + */ + protected function url(): Attribute + { + return Attribute::get(function (): string { + $path = (string) $this->path; + + if ($path === '' || Str::startsWith($path, ['http://', 'https://', '//'])) { + return $path; + } + + $base = rtrim((string) config('app.image_url'), '/'); + $path = ltrim($path, '/'); + + return $base === '' ? '/'.$path : $base.'/'.$path; + }); + } } diff --git a/shop/app/Models/Order.php b/shop/app/Models/Order.php new file mode 100644 index 00000000..d0d8e443 --- /dev/null +++ b/shop/app/Models/Order.php @@ -0,0 +1,133 @@ + $orderVarieties + * @property Collection $transactions + */ +class Order extends Model +{ + protected $fillable = [ + 'user_id', + 'address_id', + 'status', + 'coupon_discount', + 'discount', + 'shipping_cost', + 'total_products_price', + 'tax', + 'total_price', + 'src', + 'shipping_method_id', + ]; + + protected $casts = [ + 'status' => OrderStatusEnum::class, + 'src' => OrderSrcEnum::class, + 'coupon_discount' => 'integer', + 'discount' => 'integer', + 'shipping_cost' => 'integer', + 'total_products_price' => 'integer', + 'tax' => 'integer', + 'total_price' => 'integer', + ]; + + protected static function booted(): void + { + static::creating(function (Order $order): void { + $order->tracking_code ??= self::generateTrackingCode(); + }); + } + + /** + * A random 10-digit number, not the sequential `id`, so a customer's + * tracking code never reveals order volume/growth over time. + */ + private static function generateTrackingCode(): string + { + do { + $code = (string) random_int(1_000_000_000, 9_999_999_999); + } while (self::query()->where('tracking_code', $code)->exists()); + + return $code; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function address(): BelongsTo + { + return $this->belongsTo(Address::class); + } + + public function shippingMethod(): BelongsTo + { + return $this->belongsTo(ShippingMethod::class); + } + + public function orderVarieties(): HasMany + { + return $this->hasMany(OrderVariety::class); + } + + public function transactions(): HasMany + { + return $this->hasMany(Transaction::class); + } + + /** + * Whether the customer can safely retry payment on this order. Only true + * for a CANCELED order whose latest transaction never actually captured + * money (customer canceled at the gateway, or verification failed) — + * never true for the oversold case (CompleteCheckoutPayment:: + * failPaidButOversold), where Zarinpal already captured payment and a + * retry would risk charging the customer twice before a manual refund. + * Repeated retries reuse this same order (RetryOrderPayment), each + * adding its own Transaction row, so an order can accumulate several + * transactions sharing the same `created_at` second in quick succession + * — `id` breaks the tie deterministically (see GetUserOrders for the + * same class of bug). + */ + public function isRetryable(): bool + { + if ($this->status !== OrderStatusEnum::CANCELED) { + return false; + } + + /** @var Transaction|null $transaction */ + $transaction = $this->transactions()->latest()->orderByDesc('id')->first(); + + return $transaction === null || $transaction->paid_at === null; + } +} diff --git a/shop/app/Models/OrderVariety.php b/shop/app/Models/OrderVariety.php new file mode 100644 index 00000000..33d37d3f --- /dev/null +++ b/shop/app/Models/OrderVariety.php @@ -0,0 +1,64 @@ + 'integer', + 'price' => 'integer', + 'discount' => 'integer', + 'coupon_discount' => 'integer', + 'final_price' => 'integer', + ]; + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function variety(): BelongsTo + { + return $this->belongsTo(Variety::class); + } +} diff --git a/shop/app/Models/Page.php b/shop/app/Models/Page.php index 2db6d2f3..a7174430 100644 --- a/shop/app/Models/Page.php +++ b/shop/app/Models/Page.php @@ -21,6 +21,8 @@ * @property string|null $canonical * @property PageStatusEnum $status * @property Carbon|null $published_at + * @property Carbon|null $created_at + * @property Carbon|null $updated_at * @property Image|null $image */ class Page extends Model diff --git a/shop/app/Models/Product.php b/shop/app/Models/Product.php index 2805fa5f..4e9edc9d 100644 --- a/shop/app/Models/Product.php +++ b/shop/app/Models/Product.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Enums\ProductStatusEnum; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; @@ -40,6 +41,8 @@ * @property positive-int|null $height * @property ProductStatusEnum $status * @property positive-int $seen + * @property Carbon|null $created_at + * @property Carbon|null $updated_at * @property Image|null $featuredImage * @property Collection $images * @property Collection $varieties @@ -102,7 +105,10 @@ public function featuredImage(): HasOne public function varieties(): HasMany { - return $this->hasMany(Variety::class); + // Deterministic order (creation order): without it Postgres doesn't + // guarantee row order, which fed a real bug in the variety selector + // (axis/option order depending on accidental fetch order). + return $this->hasMany(Variety::class)->orderBy('id'); } public function attributes(): BelongsToMany diff --git a/shop/app/Models/Province.php b/shop/app/Models/Province.php new file mode 100644 index 00000000..c0189f8a --- /dev/null +++ b/shop/app/Models/Province.php @@ -0,0 +1,29 @@ + $cities + */ +class Province extends Model +{ + protected $fillable = [ + 'name', + ]; + + public function cities(): HasMany + { + return $this->hasMany(City::class); + } +} diff --git a/shop/app/Models/Review.php b/shop/app/Models/Review.php index e99f0302..c17f0320 100644 --- a/shop/app/Models/Review.php +++ b/shop/app/Models/Review.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Enums\ReviewStatusEnum; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; @@ -15,11 +16,14 @@ * @property positive-int $id * @property string $heading * @property string $content + * @property int<1, 5>|null $rating * @property positive-int|null $user_id * @property positive-int $product_id * @property positive-int|null $variety_id * @property positive-int|null $parent_id * @property ReviewStatusEnum $status + * @property Carbon|null $created_at + * @property Carbon|null $updated_at * @property User|null $user * @property Product $product * @property Variety|null $variety @@ -31,6 +35,7 @@ class Review extends Model protected $fillable = [ 'heading', 'content', + 'rating', 'user_id', 'product_id', 'variety_id', @@ -39,6 +44,7 @@ class Review extends Model ]; protected $casts = [ + 'rating' => 'integer', 'status' => ReviewStatusEnum::class, ]; diff --git a/shop/app/Models/Setting.php b/shop/app/Models/Setting.php new file mode 100644 index 00000000..bd0ebdb0 --- /dev/null +++ b/shop/app/Models/Setting.php @@ -0,0 +1,37 @@ + 'boolean', + ]; + + public function scopeAutoloaded(Builder $query): Builder + { + return $query->where('autoload', true); + } +} diff --git a/shop/app/Models/ShippingCity.php b/shop/app/Models/ShippingCity.php new file mode 100644 index 00000000..ba7d4ea8 --- /dev/null +++ b/shop/app/Models/ShippingCity.php @@ -0,0 +1,44 @@ + 'boolean', + 'status' => 'boolean', + ]; + + public function shippingMethod(): BelongsTo + { + return $this->belongsTo(ShippingMethod::class); + } +} diff --git a/shop/app/Models/ShippingLine.php b/shop/app/Models/ShippingLine.php new file mode 100644 index 00000000..5f62710c --- /dev/null +++ b/shop/app/Models/ShippingLine.php @@ -0,0 +1,28 @@ + $shippingMethods + */ +class ShippingLine extends Model +{ + protected $fillable = [ + 'name', + 'cost', + ]; + + public function shippingMethods(): HasMany + { + return $this->hasMany(ShippingMethod::class); + } +} diff --git a/shop/app/Models/ShippingMethod.php b/shop/app/Models/ShippingMethod.php new file mode 100644 index 00000000..28a0c915 --- /dev/null +++ b/shop/app/Models/ShippingMethod.php @@ -0,0 +1,49 @@ + $shippingCities + */ +class ShippingMethod extends Model +{ + protected $fillable = [ + 'shipping_line_id', + 'name', + 'type', + 'min_count', + 'min_amount', + 'for', + 'status', + ]; + + protected $casts = [ + 'status' => 'boolean', + ]; + + public function shippingLine(): BelongsTo + { + return $this->belongsTo(ShippingLine::class); + } + + public function shippingCities(): HasMany + { + return $this->hasMany(ShippingCity::class); + } +} diff --git a/shop/app/Models/Transaction.php b/shop/app/Models/Transaction.php new file mode 100644 index 00000000..d7b74324 --- /dev/null +++ b/shop/app/Models/Transaction.php @@ -0,0 +1,65 @@ + 'integer', + 'port' => TransactionPortEnum::class, + 'status' => TransactionStatusEnum::class, + 'paid_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } +} diff --git a/shop/app/Models/User.php b/shop/app/Models/User.php index f6ba1d2e..4991f44f 100644 --- a/shop/app/Models/User.php +++ b/shop/app/Models/User.php @@ -1,32 +1,92 @@ */ - use HasFactory, Notifiable; + use HasFactory; + + /** + * Domain used for the synthetic email given to OTP-only sign-ups (the + * shared schema requires a unique, non-null email). + */ + public const PLACEHOLDER_EMAIL_DOMAIN = '@mobile.shopflow.local'; + + protected $fillable = [ + 'first_name', + 'last_name', + 'email', + 'mobile', + 'mobile_verified_at', + 'password', + 'status', + ]; + + /** + * @var list + */ + protected $hidden = [ + 'password', + 'remember_token', + ]; /** - * Get the attributes that should be cast. - * * @return array */ protected function casts(): array { return [ 'email_verified_at' => 'datetime', + 'mobile_verified_at' => 'datetime', 'password' => 'hashed', + 'status' => UserStatusEnum::class, ]; } + + /** + * Human-friendly name, falling back to the mobile number. `mobile` is + * nullable at the schema level (admin/staff accounts have none), so this + * falls back further to the email, or a generic label, rather than + * assuming every user has a mobile. + */ + public function displayName(): string + { + $name = trim(($this->first_name ?? '').' '.($this->last_name ?? '')); + + return $name !== '' ? $name : ($this->mobile ?? $this->email ?? 'کاربر'); + } + + public static function placeholderEmail(string $mobile): string + { + return $mobile.self::PLACEHOLDER_EMAIL_DOMAIN; + } + + /** + * Whether the email is the synthetic placeholder from an OTP sign-up. + */ + public function hasPlaceholderEmail(): bool + { + return $this->email !== null && str_ends_with($this->email, self::PLACEHOLDER_EMAIL_DOMAIN); + } } diff --git a/shop/app/Models/Variety.php b/shop/app/Models/Variety.php index 3b235e1e..1232602c 100644 --- a/shop/app/Models/Variety.php +++ b/shop/app/Models/Variety.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphOne; /** * @property positive-int $id @@ -20,13 +21,14 @@ * @property string|null $color * @property positive-int $price * @property positive-int|null $sale_price - * @property positive-int $inventory + * @property int<0, max> $inventory * @property bool $has_stock * @property VarietyStatusEnum $status * @property Product $product * @property Attribute|null $attribute * @property Collection $attributes * @property Collection $reviews + * @property Image|null $image */ class Variety extends Model { @@ -62,6 +64,11 @@ public function attribute(): BelongsTo return $this->belongsTo(Attribute::class); } + public function image(): MorphOne + { + return $this->morphOne(Image::class, 'imageable'); + } + public function attributes(): BelongsToMany { return $this->belongsToMany(Attribute::class)->withTimestamps(); diff --git a/shop/app/Models/Wishlist.php b/shop/app/Models/Wishlist.php new file mode 100644 index 00000000..77b5a826 --- /dev/null +++ b/shop/app/Models/Wishlist.php @@ -0,0 +1,39 @@ +belongsTo(User::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/shop/app/Providers/AppServiceProvider.php b/shop/app/Providers/AppServiceProvider.php index 452e6b65..9e0c2f83 100644 --- a/shop/app/Providers/AppServiceProvider.php +++ b/shop/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Contracts\ProductSearch; +use App\Search\DatabaseProductSearch; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -11,7 +13,9 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + // Keyword search runs on the database for now. Swap this binding to an + // Elasticsearch-backed implementation later without changing callers. + $this->app->bind(ProductSearch::class, DatabaseProductSearch::class); } /** diff --git a/shop/app/Search/DatabaseProductSearch.php b/shop/app/Search/DatabaseProductSearch.php new file mode 100644 index 00000000..70505884 --- /dev/null +++ b/shop/app/Search/DatabaseProductSearch.php @@ -0,0 +1,143 @@ +>, meta: array{currentPage: int, lastPage: int, perPage: int, total: int, from: int|null, to: int|null}} + */ + public function search(string $term, array $options): array + { + $words = preg_split('/\s+/', trim($term), -1, PREG_SPLIT_NO_EMPTY) ?: []; + + if ($words === []) { + return $this->empty(); + } + + $query = Product::query() + ->published() + ->with([ + 'featuredImage', + 'varieties' => fn (Relation $relation) => $relation->where('status', VarietyStatusEnum::PUBLISHED->value)->with('image'), + ]); + + foreach ($words as $word) { + $like = '%'.$word.'%'; + + $query->where(function (Builder $group) use ($like): void { + $group->where('heading', 'ilike', $like) + ->orWhere('title', 'ilike', $like) + ->orWhere('description', 'ilike', $like) + ->orWhereHas('brand', fn (Builder $brand) => $brand->where('heading', 'ilike', $like)); + }); + } + + $this->applySort($query, $options['sort']); + + $paginator = $query->paginate(self::PER_PAGE)->withQueryString(); + + /** @var array $items */ + $items = $paginator->items(); + + return [ + 'data' => array_map(fn (Product $product): array => ($this->buildProductCard)($product), $items), + 'meta' => [ + 'currentPage' => $paginator->currentPage(), + 'lastPage' => $paginator->lastPage(), + 'perPage' => $paginator->perPage(), + 'total' => $paginator->total(), + 'from' => $paginator->firstItem(), + 'to' => $paginator->lastItem(), + ], + ]; + } + + /** + * Top products (by views) whose name or brand matches the term, used for + * the header search dropdown. + * + * @return array + */ + public function suggest(string $term, int $limit = 8): array + { + $term = trim($term); + + if ($term === '') { + return []; + } + + $like = '%'.$term.'%'; + + return Product::query() + ->published() + ->with('category:id,heading') + ->where(function (Builder $group) use ($like): void { + $group->where('heading', 'ilike', $like) + ->orWhereHas('brand', fn (Builder $brand) => $brand->where('heading', 'ilike', $like)); + }) + ->orderByDesc('seen') + ->limit($limit) + ->get(['id', 'heading', 'slug', 'category_id']) + ->map(fn (Product $product): array => [ + 'heading' => $product->heading, + 'url' => '/products/'.$product->slug, + 'categoryHeading' => $product->category?->heading, + ]) + ->all(); + } + + /** + * @param Builder $query + */ + private function applySort(Builder $query, string $sort): void + { + match ($sort) { + 'cheapest' => $query->orderBy('price'), + 'expensive' => $query->orderByDesc('price'), + 'popular' => $query->orderByDesc('seen'), + default => $query->orderByDesc('id'), + }; + } + + /** + * Empty result set for a blank query. + * + * @return array{data: array>, meta: array{currentPage: int, lastPage: int, perPage: int, total: int, from: int|null, to: int|null}} + */ + private function empty(): array + { + return [ + 'data' => [], + 'meta' => [ + 'currentPage' => 1, + 'lastPage' => 1, + 'perPage' => self::PER_PAGE, + 'total' => 0, + 'from' => null, + 'to' => null, + ], + ]; + } +} diff --git a/shop/app/Support/AddressDescription.php b/shop/app/Support/AddressDescription.php new file mode 100644 index 00000000..4aead7f4 --- /dev/null +++ b/shop/app/Support/AddressDescription.php @@ -0,0 +1,58 @@ + $plate, 'unit' => $unit, 'note' => $note], + static fn (?string $value): bool => $value !== null && $value !== '', + ); + + if ($payload === []) { + return null; + } + + $json = json_encode($payload, JSON_UNESCAPED_UNICODE); + + return $json === false ? null : $json; + } + + /** + * @return array{plate: string|null, unit: string|null, note: string|null} + */ + public static function decode(?string $description): array + { + if ($description === null || $description === '') { + return ['plate' => null, 'unit' => null, 'note' => null]; + } + + $data = json_decode($description, true); + + if (! is_array($data)) { + return ['plate' => null, 'unit' => null, 'note' => $description]; + } + + return [ + 'plate' => self::stringOrNull($data['plate'] ?? null), + 'unit' => self::stringOrNull($data['unit'] ?? null), + 'note' => self::stringOrNull($data['note'] ?? null), + ]; + } + + private static function stringOrNull(mixed $value): ?string + { + return is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/shop/app/Support/Currency.php b/shop/app/Support/Currency.php new file mode 100644 index 00000000..343fa4cf --- /dev/null +++ b/shop/app/Support/Currency.php @@ -0,0 +1,19 @@ +web(append: [ HandleInertiaRequests::class, ]); + + $middleware->redirectGuestsTo(fn (): string => route('login')); + $middleware->redirectUsersTo('/'); + + $middleware->trustProxies( + at: '*', + headers: Request::HEADER_X_FORWARDED_FOR + | Request::HEADER_X_FORWARDED_HOST + | Request::HEADER_X_FORWARDED_PORT + | Request::HEADER_X_FORWARDED_PROTO, + ); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( diff --git a/shop/composer.json b/shop/composer.json index 6e7619b4..94231152 100644 --- a/shop/composer.json +++ b/shop/composer.json @@ -57,7 +57,9 @@ "./vendor/bin/pest", "./vendor/bin/pint -v --ansi --bail", "./vendor/bin/pest --memory-limit=2048M --type-coverage --min=100", - "./vendor/bin/phpstan analyze -c ./phpstan.neon --memory-limit=512M" + "./vendor/bin/phpstan analyze -c ./phpstan.neon --memory-limit=512M", + "npm run lint", + "npm run format:check" ], "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", diff --git a/shop/config/app.php b/shop/config/app.php index 423eed59..ff910160 100644 --- a/shop/config/app.php +++ b/shop/config/app.php @@ -54,6 +54,23 @@ 'url' => env('APP_URL', 'http://localhost'), + /* + |-------------------------------------------------------------------------- + | Image Base URL + |-------------------------------------------------------------------------- + | + | Base URL for catalog images. Filament (admin) saves uploads to the admin + | app's public storage, served at `/storage`. The storefront is + | a separate app, so point this at the admin's storage to resolve relative + | `images.path` values. This is intentionally separate from `ASSET_URL` + | (which would also rewrite the storefront's own Vite build assets). + | Absolute image URLs (e.g. seeded placeholders) are left untouched by the + | Image model's `url` accessor. + | + */ + + 'image_url' => env('IMAGE_URL'), + /* |-------------------------------------------------------------------------- | Application Timezone diff --git a/shop/config/inertia.php b/shop/config/inertia.php new file mode 100644 index 00000000..9bc81bd5 --- /dev/null +++ b/shop/config/inertia.php @@ -0,0 +1,44 @@ + [ + + 'ensure_pages_exist' => false, + + 'paths' => [ + resource_path('js/Pages'), + ], + + 'extensions' => [ + 'js', + 'jsx', + 'svelte', + 'ts', + 'tsx', + 'vue', + ], + + ], + + 'testing' => [ + + 'ensure_pages_exist' => true, + + ], + +]; diff --git a/shop/config/services.php b/shop/config/services.php index 6a90eb83..7153a1eb 100644 --- a/shop/config/services.php +++ b/shop/config/services.php @@ -35,4 +35,19 @@ ], ], + // Neshan maps. The web map key is sent to the browser (domain-restricted); + // the service key stays server-side for reverse geocoding. + 'neshan' => [ + 'map_key' => env('NESHAN_MAP_KEY'), + 'service_key' => env('NESHAN_SERVICE_KEY'), + ], + + // Zarinpal payment gateway. base_url defaults to the sandbox so local/CI + // never accidentally hits production; any 36-character merchant_id works + // in sandbox mode (no real merchant account needed). + 'zarinpal' => [ + 'merchant_id' => env('ZARINPAL_MERCHANT_ID'), + 'base_url' => env('ZARINPAL_BASE_URL', 'https://sandbox.zarinpal.com'), + ], + ]; diff --git a/shop/database/factories/UserFactory.php b/shop/database/factories/UserFactory.php index c4ceb074..6406d92c 100644 --- a/shop/database/factories/UserFactory.php +++ b/shop/database/factories/UserFactory.php @@ -1,45 +1,44 @@ */ class UserFactory extends Factory { - /** - * The current password being used by the factory. - */ - protected static ?string $password; + protected $model = User::class; /** - * Define the model's default state. - * * @return array */ public function definition(): array { return [ - 'name' => fake()->name(), + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), 'email' => fake()->unique()->safeEmail(), - 'email_verified_at' => now(), - 'password' => static::$password ??= Hash::make('password'), - 'remember_token' => Str::random(10), + 'mobile' => '09'.fake()->unique()->numerify('#########'), + 'mobile_verified_at' => now(), + 'password' => Hash::make('password'), + 'status' => UserStatusEnum::ACTIVE, ]; } /** - * Indicate that the model's email address should be unverified. + * A blocked user who must not be able to authenticate. */ - public function unverified(): static + public function blocked(): static { - return $this->state(fn (array $attributes) => [ - 'email_verified_at' => null, + return $this->state(fn (array $attributes): array => [ + 'status' => UserStatusEnum::BLOCK, ]); } } diff --git a/shop/docker/docker-compose.yml b/shop/docker/docker-compose.yml index b8ba0594..5b99e320 100644 --- a/shop/docker/docker-compose.yml +++ b/shop/docker/docker-compose.yml @@ -1,4 +1,3 @@ -version: "3.8" services: app: build: diff --git a/shop/docs/CACHE.md b/shop/docs/CACHE.md index e6d18529..7bec45cb 100644 --- a/shop/docs/CACHE.md +++ b/shop/docs/CACHE.md @@ -23,6 +23,7 @@ Legend: `[ ]` not started, `[x]` implemented. | 7 | `varieties.product.{product_id}` | All varieties for a product (price, inventory, status) | 15 min | Variety saved / deleted | | 9 | `pages.{slug}` | Single published page record | 1 hour | Page saved / deleted | | 10 | `faqs.{position}` | FAQs for a given position (null = main FAQ page) | 1 hour | FAQ saved / deleted | +| 11 | `settings.autoload` | Autoloaded site settings (key => content), used for footer/contact | 1 hour | Setting saved / deleted (in admin) | --- diff --git a/shop/docs/IMPLEMENTATION.md b/shop/docs/IMPLEMENTATION.md index 107e17d0..7dd0a808 100644 --- a/shop/docs/IMPLEMENTATION.md +++ b/shop/docs/IMPLEMENTATION.md @@ -20,7 +20,7 @@ Catalog layer and platform basics. - [x] Attributes - [x] Attribute-Group-Categories - [x] Products (+ `product_attribute` pivot, product images) -- [x] Varieties (+ `variety_counts` auto-sync on Product) +- [x] Varieties (+ `variety_counts` auto-sync on Product; optional polymorphic image via `images` table — `withImage()` factory state, upload in VarietyResource and the ProductResource variety repeater, deleted with the variety) - [x] Discounts (auto-applied price rules per variety) - [x] Coupons (+ `coupon_product`, `coupon_variety`, `category_coupon` scoping pivots) - [x] Images (polymorphic, used via uploads - no standalone resource by design) @@ -41,6 +41,8 @@ Cross-cutting improvements landed: - `ColorPicker` added to Variety form; `ColorColumn` in the table. Auto-fill from attribute only triggers when `attribute_id` changes, preserving manual overrides. - Documentation reorganized into `docs/` directory (`ShoFlow db doc.md`, `VARIETY_GUIDE.md`, `IMPLEMENTATION.md`, `CACHE.md`, `ORDER.md`). - **Inventory rule** (`ORDER.md`): stock is decremented only on successful payment (Strategy A); carts never change `varieties.inventory`. +- **Placeholder images**: factories generate images via `ImageFactory::placeholderUrl()` (`placehold.co`); the dead `via.placeholder.com` service was removed. `ProductSeeder`/`VarietySeeder` attach images by default (`withImages()` / `withImage()`). +- **Seeder robustness**: `CitySeeder` advances Postgres sequences with `setval` after explicit-ID inserts; `CityFactory`/`AddressFactory` reuse existing province/city rows before creating new ones, fixing `TestSeeder` unique-constraint failures. - **Localisation (fa / en)**: `SetLocale` middleware + `/locale/{locale}` route + user-menu switcher. All resources (Brand, Category, Product, Variety, City, Province, Coupon, Discount, ShippingLine, ShippingMethod, ShippingCity, Ancestor, AttributeGroup, AttributeGroupCategory, Attribute, Slider, Slide, Banner, Menu, MenuItem, Page, FAQ, Review, Wishlist, User) have `lang/en` + `lang/fa` files, `trans()`-based labels on all form fields and table columns, and translated enum `label()` methods. List-page subheadings are set via `mount()`. Filament vendor translations published for built-in UI strings. - **Persian font**: `A Iranian Sans` loaded from `public/fonts/AIranianSans.ttf`; applied globally when locale is `fa` via `public/css/persian-font.css` and a `renderHook` in `AdminPanelProvider`. - **Locale switching** (`en` / `fa`): `SetLocale` middleware reads the locale from session and calls `App::setLocale()`. A `/locale/{locale}` route stores the choice. Two user-menu items (English / فارسی) in `AdminPanelProvider` let admins switch. All Filament sub-package translations (`filament`, `filament-forms`, `filament-tables`, `filament-actions`, `filament-notifications`) are published to `lang/vendor/`. @@ -99,7 +101,7 @@ Depend mostly on Images only. The main goal; depends on most of phases 1-3. - [~] Carts (migration, model, factory, seeder, tests; no Filament resource by design. Each row is one line item: variety + count, per user or guest session. Inventory rule in `ORDER.md`) -- [x] Orders (`orders` table only: model, migration, factory, seeder, Filament resource (+ pages), tests. `OrderStatusEnum` + `OrderSrcEnum`; staff/finance refs to not-yet-built tables kept nullable without FK. Inventory rule in `ORDER.md`) +- [x] Orders (`orders` table only: model, migration, factory, seeder, Filament resource (+ pages), tests. `OrderStatusEnum` + `OrderSrcEnum`; staff/finance refs to not-yet-built tables kept nullable without FK. Inventory rule in `ORDER.md`. `address_id` (nullable FK to `addresses`, `nullOnDelete`) added when the storefront's checkout/order-creation flow was implemented — the original doc had no way to record which address an order ships to. `tracking_code` (unique random 10-digit string, auto-generated via a `creating` model event in both apps' `Order` model) added as the customer-facing order identifier, shown instead of the sequential `id`; also a searchable/copyable column in the Filament table) - [x] `order_varieties` (line items: model, migration, factory, seeder, Filament resource (+ pages), tests. Stores a per-line price/discount snapshot; `sub_order_id` kept nullable without FK since `sub_orders` is not implemented. An order's many varieties are also editable inline on the Order edit page via `OrderVarietiesRelationManager`) - [~] Sub Orders + `sub_order_logs` — NOT IMPLEMENTED (single-vendor). These are seller-centric; with no sellers an order maps 1:1 to fulfillment, so they add no value. Fulfillment lives on the order itself / `order_shippings`. - [x] `order_shippings` (fulfillment/shipment records: model, migration, factory, seeder, `OrderShippingPaymentTypeEnum`, Filament resource (+ pages), inline relation manager on Order, tests) @@ -115,7 +117,7 @@ The main goal; depends on most of phases 1-3. - [ ] Guarantees + `guarantee_items` + `guarantee_item_images` + `guarantee_logs` + `guarantee_item_logs` - [ ] Accounting Sources - [ ] Persons -- [ ] Settings +- [x] Settings (global site key/value settings: model, migration, factory, idempotent reference seeder (footer/contact keys) in `DatabaseSeeder`, Filament resource (+ pages), fa/en lang, tests. Unique on `key`; `autoload` defaults true) - [ ] Cards / Saved Cards ## Phase 6 - Users extended & misc diff --git a/shop/docs/ORDER.md b/shop/docs/ORDER.md index b8a2cce6..321ac335 100644 --- a/shop/docs/ORDER.md +++ b/shop/docs/ORDER.md @@ -1,6 +1,6 @@ # Orders & Inventory -Notes for when the orders and payments flow is built. Not implemented yet. +Strategy A (below) is implemented: order creation is `App\Actions\Checkout\CreatePendingOrder` (shop), the row-locked decrement is `App\Actions\Checkout\DecrementInventoryAndMarkPaid`, wired together by `App\Actions\Checkout\CompleteCheckoutPayment` (Zarinpal callback handler, `PaymentController@callback`). Strategy B is still just notes. ## When does `varieties.inventory` decrease? @@ -34,6 +34,47 @@ The lock stops two simultaneous payments from both selling the last unit, so no The last unit is not held during payment, so two people can both reach the payment page and the second fails at the final confirm. Rare except on hot or flash-sale items. +`PaymentController::initiate()` calls `ValidateCartStock` to re-check live inventory right before opening a Zarinpal payment session — this closes the common, fully-preventable case (item already out of stock when the customer clicks پرداخت) so nobody is charged for something unavailable. It cannot close the race above (two people mid-payment for the same last unit); that residual case still reaches `DecrementInventoryAndMarkPaid`'s rejection, and since Zarinpal's verify already succeeded there (money genuinely captured), `CompleteCheckoutPayment::failPaidButOversold()` keeps `ref_id`/`paid_at` and writes a "needs manual refund" message into `result_message` instead of a plain `FAILED` with no trace — check the Transactions table for `result_message` mentioning بازگشت وجه. + +### Known quirk: `AddToCart`/`MergeGuestCart` floor quantity at 1 even when inventory is 0 + +Both `App\Actions\Cart\AddToCart` and `App\Actions\Cart\MergeGuestCart` clamp with `max(1, min($desired, $variety->inventory))` — if `$variety->inventory` is 0, this still forces the cart line's `count` to 1. It's harmless at the two call sites that already exist (`CartController::store()` rejects a zero-inventory variety before ever calling `AddToCart`; `MergeGuestCart` only hits zero inventory in the rare case an item went out of stock while sitting in a guest cart, and `GetCartLines`' own `inStock` check still correctly hides it as unavailable everywhere it's displayed). If you add another `AddToCart` call site, check `has_stock`/`inventory` yourself first rather than relying on the floor. + +## Retrying payment on a canceled order ("پرداخت مجدد") + +`Order::isRetryable()` allows retry only for a `CANCELED` order whose latest transaction never actually captured money (customer backed out at Zarinpal, or verification failed) — never for the oversold case above, where Zarinpal already captured payment and a retry would risk double-charging before a manual refund. + +`App\Actions\Checkout\RetryOrderPayment` (used by `AccountController::retryOrder()`) pays directly — it does **not** touch the cart or redirect the customer back through checkout: + +1. Re-check live stock for every original line (`has_stock` and `inventory >= quantity`), all-or-nothing. If any line can no longer be fulfilled in full, nothing changes and the customer is bounced back to the order's own page with a message — no partial retry. +2. If stock is sufficient, reset the **same** order back to `PENDING` (its `order_varieties`/address/shipping/totals are untouched) rather than cloning a new order row. +3. Open a Zarinpal session for that order via `App\Actions\Checkout\OpenZarinpalSession` (the same action `StartCheckoutPayment` uses for a normal checkout) and redirect to Zarinpal's StartPay URL. This adds a new `Transaction` row for the order — it never reuses an old one — so a customer who cancels and retries repeatedly ends up with one order and several transactions (a full attempt history), not a new order per attempt. + +Because each attempt gets its own Zarinpal authority (a new `Transaction` row), the existing `checkout.callback` route and `CompleteCheckoutPayment` need no special-casing — the callback looks up by authority and resolves to the (same) order either way. `Order::isRetryable()`/`BuildOrderDTO` pick the latest transaction by `id` (not insertion order or a plain `created_at` sort) since several transactions can now share the same order and even the same `created_at` second. + +**Known tradeoff**: since retries reuse the order, an old *stale* Zarinpal callback URL (e.g. the customer navigates back to a previous attempt's return link after already starting a newer one) could re-verify against the wrong, now-superseded authority and cancel an order with a different attempt genuinely in flight. This is the same class of rare, accepted race as the last-unit-oversell case below — not specifically guarded against. + +## Returned orders (`RETURNED`): nothing happens automatically + +Setting an order's `status` to `RETURNED` (in the Filament admin panel — there's no storefront-side path to it) is a plain data write. Nothing else is triggered: + +- **Inventory is not restocked.** No code anywhere increments `varieties.inventory` back; Strategy A only ever decrements on payment and never reverses it for a return. +- **No `Receipt` or `Transaction` row is created or updated.** There's no observer/event tied to `orders.status` — changing it doesn't touch either table. +- **No refund is tracked.** Neither `receipts` nor `transactions` has a refund-related column (`refunded_at`, `refund_amount`, etc.) — the free-text "بازگشت وجه" note in `Transaction.result_message` is written only for the unrelated oversold-payment race (`failPaidButOversold()` above), not for a manual return. +- **No status-transition validation.** `status` is a plain Filament `Select`; any status can be set to `RETURNED` from any prior status. + +Until this is built, staff must handle a return manually: restock the variety's inventory if the item is resellable, and record/process the refund outside the system (there's currently nowhere in the schema to note it other than a free-text `order_notes` entry). + +## Planned: expiring abandoned PENDING orders after 15 minutes + +**Not yet implemented** — documenting the decision now so the eventual build matches it. + +A customer who opens a Zarinpal payment session and then abandons it (closes the tab, never returns) leaves the order stuck as `PENDING` forever — `checkout.callback` is the only thing that ever changes its status, and it's never called if the customer doesn't come back. Since Strategy A never touches inventory for a `PENDING` order, this doesn't oversell anything, but it clutters the order list (admin panel and the customer's own `/account/orders`) with stale, never-resolved rows. + +Decision: a scheduled job should mark any `PENDING` order **`CANCELED`** once it's more than 15 minutes old with no successful payment — same as any other canceled order (a normal `fail()`-style status flip, row kept as-is, `order_varieties` untouched). Not a hard delete — deleting would break the audit-trail invariant every other cancel path in this codebase relies on (oversold tracking, retry history, admin visibility). An expired order becomes retryable through the existing `Order::isRetryable()` / `RetryOrderPayment` flow like any other canceled order, no special-casing needed there. + +Open question for whoever builds this: what "15 minutes old" should measure — `orders.created_at`, or the latest `Transaction.created_at` (so a retry restarts the clock rather than the job racing to expire an order the customer just retried a few seconds before minute 15). The latter matches the reused-order retry design in the section above and is the more correct choice. + ## If this is not enough later: Strategy B (reserve at checkout) Only consider this if real lost sales, oversell complaints, or flash sales appear. It is an additive change, so deferring it costs nothing now. @@ -49,9 +90,11 @@ Preferred shape if B is needed: a `reservations` table (`variety_id`, `quantity` ## Payments: receipts vs transactions/gateways -Two payment paths, kept separate: +Two payment paths, kept separate — **a paid order only ever has a row in one of them, never both**: + +- Manual / offline payments use `receipts` (admin table built; not yet wired into the storefront checkout flow): card-to-card, Paya transfers, prepayments. The customer provides a tracking code or uploads a receipt image, and staff confirm it. Fields: `destination_bank`, `end_of_card_number`, `tracking_code`, `is_paya`, plus a polymorphic receipt image. +- Online gateway payments use `transactions` (built, storefront-side): **Zarinpal only so far, sandbox mode** (`port = ZARINPAL`). Mellat and Parsian are not built. The shop reads Zarinpal's `merchant_id`/base URL from `config('services.zarinpal.*')`/`.env`, not the admin `gateways` table (nothing is seeded there yet) — revisit once a second gateway needs real *selection* logic (`gateways.active`/`priority`). See `AGENTS.md` → "Order creation + Zarinpal payment" for the full flow. -- Manual / offline payments use `receipts` (built): card-to-card, Paya transfers, prepayments. The customer provides a tracking code or uploads a receipt image, and staff confirm it. Fields: `destination_bank`, `end_of_card_number`, `tracking_code`, `is_paya`, plus a polymorphic receipt image. -- Online gateway payments will use `transactions` + `gateways` (not built yet): Mellat, Parsian, Zarinpal. These record the gateway result automatically. +**A Zarinpal-paid order will never show a `receipts` row** — nothing in the codebase creates one for an online gateway payment (no observer/event links `Transaction` to `Receipt`); checking the admin panel's Order → Receipts tab and finding it empty for a Zarinpal order is expected, not a bug. `receipts` only gets rows from the (not-yet-built) manual bank-transfer flow. Keep `receipts` if there is any chance of manual bank transfers (typical for Iranian shops). If the shop ever becomes gateway-only, `transactions`/`gateways` would cover everything and `receipts` could be retired. diff --git a/shop/docs/STOREFRONT_IMPLEMENTATION.md b/shop/docs/STOREFRONT_IMPLEMENTATION.md index 5915a4bb..eeb52e17 100644 --- a/shop/docs/STOREFRONT_IMPLEMENTATION.md +++ b/shop/docs/STOREFRONT_IMPLEMENTATION.md @@ -13,6 +13,8 @@ A storefront feature is "done" when it has: read/write Eloquent models for the s - Persian digits and Jalali dates, formatted server-side. - One Vue component per element; pages under `resources/js/Pages`, shared shells under `resources/js/Layouts`, reusable elements under `resources/js/Components`. - Catalog tables are read-only here; never recreate or migrate admin-owned tables. +- Keep controllers thin: push data loading/shaping into single-purpose actions (`app/Actions`) that return typed DTOs (`app/DTOs`, one per model). See `ProductController`/`HomeController` + their actions. Details in `AGENTS.md`. +- Before finishing, run `composer test-dev` (Pest, Pint, 100% type coverage, PHPStan level 5, ESLint, Prettier). CI runs the same checks. --- @@ -23,60 +25,85 @@ A storefront feature is "done" when it has: read/write Eloquent models for the s - [x] `A Iranian Sans` font + brand color `#ff8615` - [x] Base `AppLayout` + sample `Home` page - [x] Eloquent models mapping shared tables (read-focused): `Category`, `Brand`, `Product`, `Variety`, `Attribute`, `Image`, `Banner`, `Slider`/`Slide`, `Menu`/`MenuItem`, `Page`, `Faq`, `Review` (+ status enums and `HasOptions` trait). Relations to not-yet-created models (`AttributeGroup`, `Coupon`, `Discount`) are deferred to their phases. -- [ ] Shared UI kit components: `BaseButton`, `PriceTag`, `ProductCard`, `QuantityInput`, `Breadcrumbs`, `Pagination`, `RatingStars`, `EmptyState` -- [ ] Helpers/composables: Persian digits, Jalali date, money formatting (`useFormat`) -- [ ] SEO scaffolding: per-page `` pattern, shared meta defaults, canonical URL, Open Graph, `robots.txt` -- [ ] Error pages (404 / 500) in Persian, RTL +- [~] Shared UI kit components: `BaseButton` + `AppLink` (supports `new-tab` + external-link detection) + `Icon` + `PriceTag` + `ProductCard` (image falls back to first variety image when the product has none; opens in a new tab) + `SectionHeading` + `Breadcrumbs` + `RatingStars` + `Pagination` + `EmptyState` done; `QuantityInput` pending +- [x] Helpers/composables: Persian digits, Jalali date, money formatting (`useFormat`) +- [x] SEO scaffolding: per-page `` via `AppHead` component, shared meta defaults (Inertia `seo` shared prop), canonical URL, Open Graph + Twitter, `robots.txt` +- [x] Error pages (404 / 500) in Persian, RTL (self-contained Blade, brand color, IranSans, no Vite/SSR dependency) +- [x] Shared database connection: shop reads the admin-owned Postgres (`shop_flow_db`); file/sync session/cache/queue so shop owns no tables (`.env.example` stays SQLite for CI/tests) +- [x] Icon system: FontAwesome (self-hosted via npm) behind the shared `Icon` component, icon-object pattern (SSR-safe). See `AGENTS.md` +- [x] Global footer wired from `settings`: read-only `Setting` model + Inertia shared `footer` (link columns, contact, socials, about, copyright) rendered by `AppFooter`/`Footer/*` components +- [x] Server-side structure: thin controllers delegating to actions (`app/Actions/{Catalog,Product,Home}`) returning per-model DTOs (`app/DTOs`) +- [x] Dev tooling: `composer test-dev` runs Pest, Pint, Pest type-coverage (`--min=100`), PHPStan (level 5), ESLint + Prettier (`resources/js`); mirrored in CI (`deploy-application.yml`) ## Phase 1 - Catalog browsing (highest SEO value, build first) Read-only catalog. This is where SEO and SSR matter most. -- [ ] Home page: published banners, slider, featured categories, product rows, menu (uses `CACHE.md` keys 1, 2, 8, 3) -- [ ] Category listing page: products by category with filters (brand, attributes, price range), sorting, pagination -- [ ] Product detail page: image gallery, variety selection (price + discount + stock from `varieties`), attributes, related products, breadcrumbs -- [ ] Product reviews (read) on the product page; ratings summary -- [ ] Brand page: products for a brand -- [ ] Search: keyword search over products with results page -- [ ] CMS pages (`pages.{slug}`) and FAQ page (`faqs.{position}`) -- [ ] SEO per page: unique title/description, canonical, Open Graph, JSON-LD `Product` (+ `Offer`), `BreadcrumbList`, `Organization`/`WebSite` on home -- [ ] `sitemap.xml` (categories, products, brands, pages) and correct 200/404 status codes +- [x] Home page: `HomeController` + `Home.vue` with hero slider, category strip, promo banner grid, product carousels (newest + most viewed), selected brands; JSON-LD `Organization`/`WebSite`; graceful empty states; feature tests. Caching (`CACHE.md` keys 1, 2, 8, 3) deferred to Phase 6 + - Header (`AppHeader` + `Header/*`): logo, search, account/cart actions, desktop category menu with dropdowns, mobile drawer; categories shared via Inertia `nav.categories` + - `Variety` read model exposes a polymorphic `image` relation (per-color photo) for the upcoming product detail page +- [x] Category listing page: `CategoryController@show` (`/categories/{slug}`) + `Category/Show.vue`. Lists the category's products plus its descendants', with facet filters (brand, attribute groups marked `as_filter`, price range), sorting (newest/cheapest/expensive/popular) and pagination; sidebar filters + toolbar + `Pagination`/`EmptyState` components; breadcrumbs; JSON-LD `BreadcrumbList`; feature tests. Attribute filtering matches products through the `product_attribute` pivot (the documented "filters to products" link, NOT varieties); facets list only attribute values actually attached to products in the category; OR within a group, AND across groups. Price filters on `products.price` (denormalized cheapest-variety base price). Filter UI is Digikala-style: availability toggle (`in_stock` → `products.has_stock`), price range slider, brand list with search box, collapsible accordion sections, per-option product counts, and instant apply on change +- [x] Product detail page: `ProductController@show` (`/products/{slug}`) + `Product/Show.vue`. Gallery shows all images combined (product images + every variety image, deduped by URL); selecting a variety switches the main image to that variety's photo without hiding the others. Variety selector (primary attribute group drives selection, additional attributes constrained by it, never the other way), buy box (price hidden until a variety is fully selected; price/discount/stock, trust badges, quantity), specs, description, breadcrumbs, related carousel; JSON-LD `Product`/`Offer` + `BreadcrumbList`; view counter; feature tests. Add-to-cart wiring deferred to Phase 3 + - Descriptive specs/highlights (`product_attribute`) are paired with their attribute group's `name` (`BuildProductDetail::spec()`, eager-loads `attributes.attributeGroup`) — never rendered as a bare value with no label. `ProductSpecs.vue` renders them as a `group: value` list. + - Variant axis/option order is deterministic: `Product::varieties()` sorts `->orderBy('id')` (Postgres gives no row-order guarantee otherwise), `BuildVariantAxes` sorts each axis's options by attribute id (`attributes` has no `order` column, so creation order is the best available proxy) and always places the primary axis first — regardless of which attribute group a variety's row happens to expose first (e.g. a variety whose primary attribute was deleted, leaving only secondary/pivot attributes, no longer pushes the primary axis down the list). + - Quantity rule: the quantity stepper must never exceed the selected variety's `inventory` (clamp the max). Enforced with cart wiring in Phase 3 +- [x] Product reviews (read) on the product page (approved only). Star ratings now built (see Phase 5 "Submit product review"): a `rating` column was added to the shared `reviews` table, the product header + reviews section show the real average (`product.averageRating`), and each review shows its own stars +- [x] Brand page: `BrandController@show` (`/brands/{slug}`) + `Brand/Show.vue`. Lists a brand's products with facet filters (category, price range, availability), sorting, pagination; sidebar `BrandFilters` (accordion, price slider, category search + counts) + shared toolbar/`Pagination`/`EmptyState`; breadcrumbs; JSON-LD `BreadcrumbList`; feature tests. Thin controller + `app/Actions/Brand/*` + `BrandDTO`. Shared catalog test helpers moved to `tests/Helpers.php` +- [x] Search: keyword search over products (`/search?q=`). `SearchController` -> `ProductSearch` contract (bound to `DatabaseProductSearch` in `AppServiceProvider`) does a case-insensitive `ILIKE` match on product heading/title/description and brand name (words AND-ed, fields OR-ed); sort + pagination; `Search/Results.vue` (noindex). Autocomplete: `GET /search/suggest?q=` (`SearchController@suggest` -> `GetSearchSuggestions`) returns matching categories + products (with category context) as JSON; `HeaderSearch.vue` shows a debounced dropdown (categories labeled `دسته‌بندی`, products as `در {category}`). Contract lets Elasticsearch swap in later without touching callers. Feature tests cover name/brand match, unpublished exclusion, blank query, and the suggest JSON +- [x] CMS pages (`pages.show`): `PageController@show` + `Page/Show.vue`. Served at clean top-level slugs (e.g. `/about-us`) via a catch-all `/{slug}` route kept LAST in `routes/web.php`; published pages only; heading, HTML content, optional image; breadcrumbs; JSON-LD `BreadcrumbList`; canonical/noindex; feature tests. `PageDTO` + `BuildPageDetail` +- [x] FAQ page (`faqs.show`): `FaqController@show` + `Faq/Show.vue` (accordion). `/faq` shows null-position questions, `/faq/{position}` scopes to a section; ordered by `order`; JSON-LD `FAQPage` + `BreadcrumbList`; feature tests. `FaqDTO` + `GetFaqs` +- [x] SEO per page: shared `AppHead` emits unique title/description, canonical, Open Graph + Twitter cards, robots noindex, and JSON-LD. Product page has `Product` (+ `Offer`, `Brand`); home has `Organization` + `WebSite`; catalog/CMS pages emit `BreadcrumbList` with absolute `item` URLs via the shared `seo.js` helper (origin shared as `seo.origin`) +- [x] `sitemap.xml` + `robots.txt`: `SitemapController` (`index`/`robots`) + `GetSitemapUrls` action lists home, FAQ, active categories/brands and published products/pages (excludes `no_index`); `robots.txt` points at the sitemap. Controllers use `firstOrFail()` so unknown/unpublished slugs return 404; feature tests cover sitemap contents and 404s +- [x] PWA / add-to-home-screen: `public/manifest.webmanifest` + app icons (`public/icons/*`) + Apple meta tags in `app.blade.php`. `InstallPrompt.vue` (mounted in `AppLayout`) shows a light bottom-sheet with install steps on iOS Safari only (no native prompt there); hidden when already installed (standalone) and snoozed 7 days after dismissal via `localStorage`. Android/desktop Chrome use the native install prompt from the manifest ## Phase 2 - User auth & account Auth uses the shared `users` table. Password reset via mobile (`mobile_password_resets`) and email (`password_resets`). -- [ ] Register / login (credentials per `users`) +- [x] Register / login with mobile number. One flow at `/login`: enter mobile, verify with a one-time code (OTP) or a password. + - OTP is the primary path and registers the user on first login. Password login is offered as an alternative on the OTP screen. + - Codes are stored in cache (`otp:{mobile}`, 5 digits, 2 min TTL) and "sent" via a logged stub in `SendOtpCode` (swap in an SMS provider later). In debug mode the code is flashed to the page for testing. + - Resend is blocked until the current code expires: `SendOtpCode` reuses the active code (no new code, no reset of its lifetime) and `/login/otp` rejects an early resend with the remaining seconds, which also drives the UI countdown. + - The shared `users` table requires `email`/`password`/name, so OTP sign-ups seed placeholders (`{mobile}@mobile.shopflow.local`, a random password, empty names) the user can complete later. + - Blocked users (`status = BLOCK`) cannot log in. `auth.user` and auth `flash` are shared via `HandleInertiaRequests`; the header shows the name + logout when signed in. - [ ] Password reset via mobile and via email -- [ ] Account dashboard layout -- [ ] Profile view/edit -- [ ] Addresses: list, create, edit. Editing creates a NEW address inheriting primary status; addresses are never deleted (immutable history, see `AGENTS.md` / `db doc`) -- [ ] Order history + single order view -- [ ] Wishlist (`wishlists`): add/remove, list +- [x] Account dashboard layout: `/account` area (auth) with `AccountLayout` (sidebar nav + user card + logout) wrapping `AppLayout`. `AccountController@dashboard` + `Account/Dashboard.vue` shows a greeting and shortcut cards. Sidebar links not yet built (reviews) render `Account/ComingSoon.vue`. All account pages are `noindex`. Feature tests in `AccountTest` +- [x] Profile view/edit: `Account/Profile.vue` edits first/last name + email (mobile is read-only). `AccountController@profile`/`updateProfile` validate (email unique, ignoring self) and flash a `status` message shared via `HandleInertiaRequests`. The synthetic OTP placeholder email (`User::hasPlaceholderEmail`) is hidden so the field shows empty. `UserDTO` shapes the shared user payload +- [x] Addresses: list, create, edit at `/account/addresses` (`AddressController` + `Account/Addresses/Index.vue` with a modal `AddressFormModal`). Editing is immutable (`UpdateUserAddress`): it creates a NEW address inheriting `prime` and soft-deletes the old row, which leaves the active list but stays available for order history. The first address auto-becomes primary; one primary per user via the model `saved` hook. Any address can be set as default from the list (`PUT /account/addresses/{address}/primary`), which demotes the previous one. Delete is a soft delete (`DELETE /account/addresses/{address}`) so order history survives; deleting the default promotes the newest remaining address. Province/city are cascading selects (`/account/addresses-cities`). Phone and 10-digit postal code are normalized server-side (Persian digits). Plate/unit round-trip through the `description` column as JSON (`AddressDescription`) since the shared table has no columns for them. The location (lat/long) is its own section, separate from the province/city selects. With a `web.` map key the interactive `NeshanMap.vue` is used; otherwise `MapPicker.vue` renders a draggable Neshan static map (proxied `/account/addresses-static`, service key) with a fixed center pin, drag-to-pan and zoom buttons. Reverse geocoding (`/account/addresses-reverse`, service key) fills the address from the chosen point. All API calls use the server-side `NESHAN_SERVICE_KEY`; the optional `NESHAN_MAP_KEY` (web) enables the faster interactive map. The nullable lat/long columns are defined in the `create_addresses_table` migration. `AddressDTO` + feature tests +- [x] Order history + single order view: `/account/orders` (`AccountController@orders` + `GetUserOrders`, paginated newest-first, `GetCategoryProducts`-style `{data, meta}` shape) lists lightweight order cards (status badge, date, total, item count, first-line thumbnail); `/account/orders/{order}` (`@showOrder`, 403 if not the owner) reuses the existing `BuildOrderDTO`/`OrderDTO` built for checkout confirmation — any status is viewable, not just paid. The shared line-items/address/payment-summary markup was extracted from `Checkout/Confirmation.vue` into `Components/Order/OrderDetail.vue`, reused by both pages. Status badge colors are computed client-side (`composables/useOrderStatus.js`) since shop enums never define `color()` (admin/Filament-only convention). The customer-facing "order number" everywhere is `order.trackingCode` (`orders.tracking_code`, a random unique 10-digit string), never the raw `id`. + - **Retry payment** (`Order::isRetryable()`, `POST /account/orders/{order}/retry` → `@retryOrder` + `RetryOrderPayment`): only offered for a `CANCELED` order whose latest transaction never actually captured money (`paid_at === null`) — customer canceled at Zarinpal, or verification failed. **Never** offered for the oversold case (`CompleteCheckoutPayment::failPaidButOversold()`), since Zarinpal already captured that payment and retrying would risk charging the customer twice before a manual refund. Retrying pays directly — it does not touch the cart: it re-checks live stock for every original line (all-or-nothing, no partial retry), and if sufficient, resets the same order back to `PENDING` (not a clone) and opens a fresh Zarinpal session for it (`OpenZarinpalSession`, shared with the normal checkout flow), redirecting straight to Zarinpal. Each attempt adds a new `Transaction` row, so repeated cancel-and-retry cycles accumulate transactions on one order instead of a new order per attempt. If stock is no longer sufficient anywhere, nothing changes and the customer is sent back to the order's own page with a message. + - **Returns list**: `/account/returns` (`@returns`) reuses the same `Account/Orders/Index.vue` page and `GetUserOrders`, just scoped to `OrderStatusEnum::RETURNED` and with its own title/empty-state copy/pagination base URL passed as props — no separate Vue page or action needed. See `ORDER.md` for what changing an order to `RETURNED` does and does not do automatically (nothing — no inventory restock, no refund tracking; both are manual today). +- [x] Wishlist (`wishlists`): add/remove, list. `App\Models\Wishlist` (shop-side, mirrors admin's read-only-from-panel model) has a DB-level unique `(user_id, product_id)` constraint — no `session_id`/guest support (unlike `Cart`), so it's strictly auth-only. `POST /products/{product}/wishlist` (`WishlistController@toggle` + `ToggleWishlist` action) checks-then-deletes/creates and redirects back with a flash message; the heart toggle lives only in `BuyBox.vue` on the product detail page (scope decision — not on product cards across category/brand/home listings, to avoid threading a bulk wishlist-membership lookup through every card-producing action). `ProductController@show` computes `isWishlisted` as a sibling Inertia prop (same pattern as `cartItems`), not baked into `ProductDTO`/`BuildProductDetail`, since those have no request/user context. `/account/wishlist` (`AccountController@wishlist` + `GetUserWishlist`) reuses `BuildProductCard`'s lightweight card shape (same `{data, meta}` pagination pattern as `GetUserOrders`) for `Account/Wishlist/Index.vue`, with its own remove button per row (posts to the same toggle route). ## Phase 3 - Cart Inventory-neutral. A cart never changes `varieties.inventory` (see `ORDER.md`). -- [ ] Cart model mapping `carts` (one row per variety line; user or guest session) -- [ ] Add to cart / update quantity / remove line -- [ ] Cart page + mini-cart component with live totals -- [ ] Merge guest cart into user cart on login +- [x] Cart model mapping `carts` (one row per variety line; user via `user_id`, guest via `session_id`). Owner is resolved per request by `ResolveCartOwner` +- [x] Add to cart / update quantity / remove line (`CartController` + `Cart/` actions: `AddToCart`, `GetCartLines`, `BuildCartSummary`). Quantity is capped at the variety's available `inventory` (clamped in `BuyBox`/`CartLine` and again server-side). Add-to-cart is wired from the product `BuyBox` (requires a selected variety, since cart lines reference a variety) +- [x] Cart page at `/cart` (`Cart/Index.vue`): checkout stepper (`CheckoutSteps.vue`: cart / shipping / payment), line items (`CartLine.vue`) and an order summary (`CartSummary.vue`: items total, savings, payable). Unit price is the variety `sale_price ?? price`. The header shows a live item-count badge via the shared `cart.count` prop (`HandleInertiaRequests`) +- [x] Merge guest cart into the user cart on login (`MergeGuestCart`, called in `AuthController@login` with the pre-regeneration session id; quantities combine and clamp to inventory) - [ ] Coupon preview at cart (validated, not yet committed) ## Phase 4 - Checkout & payment (commerce core) -- [ ] Checkout: choose address, choose shipping method (`shipping_lines` / `shipping_methods` / `shipping_cities` per-city cost), apply coupon -- [ ] Order creation with `pending` status; line snapshots in `order_varieties`; `order_shippings` for fulfillment -- [ ] Inventory decrement on successful payment only, inside a DB transaction with `SELECT ... FOR UPDATE` row lock on the variety (Strategy A, `ORDER.md`) +- [~] Checkout: choose address, choose shipping method (`shipping_lines` / `shipping_methods` / `shipping_cities` per-city cost), apply coupon + - [x] Shipping step (`/checkout`, auth, `CheckoutController@shipping` + `Checkout/Shipping.vue`): pick a saved address (radio) or add one inline when none exist (reuses `AddressFormModal`); empty cart redirects back to `/cart`. The chosen address id is kept in the session + - [x] Shipping method selection: methods are resolved per destination (`GetShippingMethods` over `shipping_cities`: exact city > province > nationwide) and listed on the shipping step; changing the address refreshes them via `/checkout/methods` (JSON). The cost flows into the order summary (pay-on-delivery shows "پس‌کرایه", zero shows "رایگان"). Selection is validated against the address and kept in the session. Seed data lives in admin `ShippingSeeder` (پیک ویژه تهران، پست پیشتاز، تحویل حضوری از فروشگاه) + - [ ] Coupon application +- [x] Order creation with `pending` status; line snapshots in `order_varieties`. `CreatePendingOrder` snapshots the cart (unit price, line discount, final price per `CartLineDTO`) plus the chosen `address_id` (new FK, see below) and `shipping_method_id`/`shipping_cost` into one `Order` + its `OrderVariety` rows, inside a DB transaction. `order_shippings` (fulfillment/tracking) is a staff-side concern, not created at checkout — deferred to Phase 5 +- [x] Inventory decrement on successful payment only, inside a DB transaction with `SELECT ... FOR UPDATE` row lock on the variety (Strategy A, `ORDER.md`). `DecrementInventoryAndMarkPaid` locks each ordered variety (sorted by id to avoid deadlocks), verifies `inventory >= quantity`, decrements, and only then marks the order `PAID`/transaction `SUCCESS`; any shortfall rolls back untouched and cancels the order instead of overselling - [ ] Manual payment via `receipts` (card-to-card / Paya: tracking code or uploaded receipt image; staff confirm) -- [ ] Online payment via `gateways` + `transactions` (Mellat / Parsian / Zarinpal): redirect + callback that marks the order paid and decrements stock -- [ ] Order confirmation / result page +- [ ] Scheduled job to expire abandoned `PENDING` orders (mark `CANCELED`) after 15 minutes with no successful payment — decision documented in `ORDER.md`, not yet built +- [x] Online payment via `gateways` + `transactions`: **Zarinpal only, sandbox mode** (`RequestZarinpalPayment`/`VerifyZarinpalPayment` hit `services.zarinpal.base_url` — defaults to `https://sandbox.zarinpal.com` — with `ZARINPAL_MERCHANT_ID` from `.env`, not the admin `gateways` table; revisit if/when Mellat/Parsian are added). `PaymentController@initiate` first calls `ValidateCartStock` to re-check every line's live inventory — rejects back to `/cart` if stock changed since it was added, so nobody is charged for something already unavailable — then creates the pending order + `PENDING` transaction and redirects to Zarinpal's StartPay page via `Inertia::location`; `@callback` (`GET /checkout/callback`, looked up by Zarinpal's `authority`, never by session) verifies the payment (idempotent: verify codes 100 and 101 both count as success, and an already-`PAID` order short-circuits without re-verifying), runs the inventory decrement, clears the cart and checkout session keys. The residual race `ValidateCartStock` can't prevent (two customers reaching payment for the last unit at once) is handled by `CompleteCheckoutPayment::failPaidButOversold()`, which keeps `ref_id`/`paid_at` for a manual refund instead of a plain `FAILED` (see `ORDER.md`). Amounts are stored in Toman everywhere and converted ×10 to Rial only at the Zarinpal HTTP boundary (`App\Support\Currency`) +- [x] Order confirmation / result page: `Checkout/Confirmation.vue` (`PaymentController@confirmation`, `BuildOrderDTO`) shows the paid order's line items, address, shipping method and total once payment succeeds + +**Schema note**: `orders.address_id` (nullable FK to `addresses`, `nullOnDelete`) was added to the admin migration when this was built — the documented schema had no way to record which address an order ships to. See `ShoFlow db doc.md` and admin's `IMPLEMENTATION.md`. ## Phase 5 - Post-purchase & engagement - [ ] Order status / tracking view -- [ ] Submit product review (writes `reviews`, moderation status pending) +- [x] Submit product review (writes `reviews`, moderation status pending). Any logged-in user can submit via the "ثبت دیدگاه" form in `ProductReviews.vue` (star picker + heading + text); `POST /products/{product}/reviews` (`ReviewController@store` + `App\Actions\Review\CreateReview`) always creates the row as `PENDING`, so it stays hidden until an admin approves it in Filament. A `rating` (1–5, nullable) column was added to the shared `reviews` migration (admin-owned; admin model/factory/resource/lang + db doc updated too). Reviews written by a user who actually purchased the product show a "خریدار" (verified buyer) badge — computed at read time by `App\Actions\Review\FindProductBuyers` (any order in PAID/PROCESSING/SHIPPED/DELIVERED containing the product; CANCELED/RETURNED don't count), never stored. `ProductController@show` passes `canReview` (is-logged-in) as a sibling prop like `isWishlisted`; the form is replaced with a login prompt for guests. Also fixed a latent bug where review authors never showed (used the non-existent `User->name`; now `displayName()`) - [ ] Notifications to the customer (order placed / paid / shipped) - scope TBD ## Phase 6 - Performance & SEO hardening diff --git a/shop/docs/ShoFlow db doc.md b/shop/docs/ShoFlow db doc.md index 47c4256f..1a67fcfc 100644 --- a/shop/docs/ShoFlow db doc.md +++ b/shop/docs/ShoFlow db doc.md @@ -28,7 +28,7 @@ Implementation notes: * Addresses are immutable history. Editing in the admin panel never updates a row: it creates a NEW address. The new address inherits the edited one's `prime` status (if the edited address was primary the new one becomes primary and the old is demoted; otherwise the new one is created non-primary). The old record is kept so orders that reference an address keep an accurate history. * No delete action is exposed (table, edit page); records are never removed. `deleted_at` (soft delete) stays on the table for future use but is not used by the panel. * One primary per user is enforced by a model `saved` hook that demotes the user's other `prime` addresses. -* `latitude` / `longitude` from the doc are not implemented as columns yet (no current need). +* `latitude` / `longitude` are nullable columns (defined in `create_addresses_table`); the storefront fills them from a Neshan map when the customer adds an address. # Ancestors @@ -449,6 +449,7 @@ Implementation notes: # orders * Used to store orders. +* `tracking_code`: Customer-facing order identifier (a random unique 10-digit number, e.g. `1168407691`). Not in the original doc — added so customers have an opaque tracking code instead of the sequential `id`, which would otherwise leak order volume/growth. Auto-generated on create (see Implementation notes). * `user_id`: Indicates which user the order belongs to. * `coupon_id`: Stores the coupon ID if the order used a coupon; otherwise, it is null. * `coupon_discount`: Specifies the discount amount applied through the coupon. @@ -473,6 +474,7 @@ Implementation notes: * `collector_description`: Collection-related description. * `notifier_id`: Customer notification. * `notified_at`: Customer notification date. +* `address_id`: Specifies the address the order ships to. Not in the original doc — added when online payment/order creation was implemented, since the doc otherwise had no way to record a shipping destination. * `shipping_line_id`: Specifies which shipping line. * `shipping_method_id`: Specifies which shipping method. * `send_description`: Provides the shipping description. @@ -485,6 +487,8 @@ Implementation notes: * `user_id`: Nullable FK to `users`, `nullOnDelete` so orders survive user deletion. * `confirmed_id`, `collector_id`, `notifier_id`: Nullable FKs to `users` (`nullOnDelete`). * `accounting_id`, `bijack_image_id`: Plain nullable columns with no FK constraint, because the accounting table is not built yet and images are stored polymorphically elsewhere. +* `address_id`: Nullable FK to `addresses`, `nullOnDelete`. Addresses are immutable history (edits create a new row), so this always points at the exact address snapshot chosen at checkout. +* `tracking_code`: `string(10)`, unique, not nullable. Generated by a `creating` model event (`random_int(1_000_000_000, 9_999_999_999)`, retried on collision) in both apps' `Order` model — each app writes to the same `orders` table but is a separate Eloquent class, so the generation logic is duplicated rather than shared. Never mass-assignable (not in `$fillable`). * Money columns (`coupon_discount`, `discount`, `shipping_cost`, `total_products_price`, `tax`, `total_price`): `decimal(12,2)`, default `0`. * No `seller_id` (single-vendor). The seller-centric `sub_orders` / `sub_order_logs` tables are intentionally not implemented. * Only the `orders` table is implemented so far; `order_varieties` and the other order_* tables are not built yet. @@ -663,6 +667,7 @@ Implementation notes: * Contains user reviews for each product. * `heading`: The review title written by the user (e.g. "Great product!"). * `content`: The full review text. +* `rating`: 1–5 star rating. Not in the original doc — added when storefront review submission was built. Nullable: replies (`parent_id` set) and admin-entered reviews may carry no rating. The storefront requires it on submit and shows the per-product average from approved reviews. * `user_id`: The user who submitted the review. Nullable; set to null if the user is deleted. * `product_id`: The product being reviewed. Cascade-deletes the review when the product is deleted. * `variety_id`: The specific variety (e.g. size/color) the user purchased. Nullable; set to null if the variety is deleted. diff --git a/shop/docs/TAGS.md b/shop/docs/TAGS.md new file mode 100644 index 00000000..20953046 --- /dev/null +++ b/shop/docs/TAGS.md @@ -0,0 +1,65 @@ +# Tags + +Status: **planned, not built.** The `tags` table is described in `ShoFlow db doc.md` but has no migration yet in `admin/`. There is no `product_tag` pivot. + +## What a tag is + +A tag is a **SEO landing page for a `category + attribute` combination** — a saved filter turned into its own page with its own slug and content. Example: "تجهیزات گیمینگ", "لوازم تابستانی", "کفش مردانه قرمز". + +A tag is **not**: +- a free-form product label (products are not attached to tags; there is no `product_tag` pivot), +- a dynamic row like best-sellers (that is a sort by `seen`), +- a banner or menu (those have their own tables). + +## Schema (as documented) + +`tags` columns from `ShoFlow db doc.md`: + +| Column | Meaning | +| --- | --- | +| `slug` | Tag URL slug (stable, human-readable Persian) | +| `name` | Tag display name | +| `category_id` | The category the tag scopes to | +| `attribute_id` | The attribute the tag filters by | +| `content` | Editor content shown on the tag page | +| `type` | Tag type (e.g. user/seller in the source schema) | +| `created_at` | Creation date | + +A tag resolves to products as: **products in `category_id` (and its descendants) that have `attribute_id`** — the same matching rule the category page uses (`product_attribute` pivot). See `AGENTS.md` → Catalog filtering. + +> The documented schema has only `content`, no `title` / `description` / `no_index` / `canonical`. SEO meta would either reuse `name`/`content` or the schema must be extended in admin. Decide before building (open question below). + +## What tags are good for + +- Themed/filtered **landing pages** that need their own URL and SEO content. +- A **link target** for promo banners and menu items (banner "تجهیزات گیمینگ" → `/tags/gaming-gear`). + +## What to use instead (not tags) + +- **Best-sellers / newest rows** → dynamic queries (already `GetProductRows`). +- **Banners / sliders / header menu** → `banners`, `sliders`/`slides`, `menus`/`menu_items` (already built). +- **Hand-picked cross-category collections** → would require a new `product_tag` pivot (admin schema change). Only add if editorial collections are actually needed. + +## Responsibilities + +**Admin (`admin/`) — owns the schema:** +- Migration for `tags` (and any extra SEO columns if chosen). +- `Tag` model + Filament resource: manage slug, name, category, attribute, content, type. + +**Shop (`shop/`) — read/render only:** +- `Tag` read model mapping the shared table. +- Route `GET /tags/{slug}` → thin `TagController` → action that loads products (category + descendants, filtered by the tag's attribute) reusing the category listing pieces. +- `Tags/Show.vue` reusing `ProductCard` / `Pagination` / filters where it makes sense. +- SEO: unique title/description, canonical, JSON-LD `BreadcrumbList`; breadcrumbs Home → Category → Tag. + +## Build order (when approved) + +1. Admin: migration → `Tag` model + factory + seeder → Filament resource. +2. Shop: `Tag` model → action + thin controller + Inertia page → feature tests. +3. Wire banners/menu items to `/tags/{slug}`. + +## Open questions + +- **SEO fields**: reuse `name`/`content`, or extend the schema with `title`/`description`/`no_index`/`canonical` (matches how `categories`/`products` do SEO)? +- **`type`**: single-vendor store has no sellers — is this column needed in shop, or admin-only? +- **One attribute per tag** (as the schema implies) vs. multiple — confirm before building. diff --git a/shop/eslint.config.js b/shop/eslint.config.js new file mode 100644 index 00000000..b87ff641 --- /dev/null +++ b/shop/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js'; +import pluginVue from 'eslint-plugin-vue'; +import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'; +import globals from 'globals'; + +export default [ + { + ignores: ['public/build/**', 'bootstrap/ssr/**', 'node_modules/**', 'vendor/**'], + }, + js.configs.recommended, + ...pluginVue.configs['flat/recommended'], + { + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: { + ...globals.browser, + ...globals.node, + }, + }, + rules: { + // Inertia pages/components are path-based and often single-word. + 'vue/multi-word-component-names': 'off', + // Product/CMS HTML comes from the trusted admin app. + 'vue/no-v-html': 'off', + }, + }, + skipFormatting, +]; diff --git a/shop/package-lock.json b/shop/package-lock.json index 1279b516..375d808a 100644 --- a/shop/package-lock.json +++ b/shop/package-lock.json @@ -5,15 +5,26 @@ "packages": { "": { "dependencies": { + "@fortawesome/fontawesome-svg-core": "^7.2.0", + "@fortawesome/free-brands-svg-icons": "^7.2.0", + "@fortawesome/free-solid-svg-icons": "^7.2.0", + "@fortawesome/vue-fontawesome": "^3.2.0", "@inertiajs/vue3": "^3.4.0", "@vitejs/plugin-vue": "^6.0.7", "@vue/server-renderer": "^3.5.38", "vue": "^3.5.38" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.0.0", + "@vue/eslint-config-prettier": "^10.2.0", "concurrently": "^9.0.1", + "eslint": "^10.6.0", + "eslint-plugin-vue": "^10.9.2", + "globals": "^17.7.0", "laravel-vite-plugin": "^3.1", + "prettier": "^3.8.5", + "prettier-plugin-tailwindcss": "^0.8.0", "tailwindcss": "^4.0.0", "vite": "^8.0.0" } @@ -95,6 +106,255 @@ "tslib": "^2.4.0" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.2.0.tgz", + "integrity": "sha512-IpR0bER9FY25p+e7BmFH25MZKEwFHTfRAfhOyJubgiDnoJNsSvJ7nigLraHtp4VOG/cy8D7uiV0dLkHOne5Fhw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.2.0.tgz", + "integrity": "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-brands-svg-icons": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-7.2.0.tgz", + "integrity": "sha512-VNG8xqOip1JuJcC3zsVsKRQ60oXG9+oYNDCosjoU/H9pgYmLTEwWw8pE0jhPz/JWdHeUuK6+NQ3qsM4gIbdbYQ==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.2.0.tgz", + "integrity": "sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/vue-fontawesome": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.2.0.tgz", + "integrity": "sha512-7BwGjTZn8QDvVEIu8fvkHhsDRRv//tq7jtsldaDhF3dE1fyWLIQcEg3zvIzy33su7kcppWsZZ6XRYP5wp3UCgQ==", + "license": "MIT", + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~1 || ~6 || ~7", + "vue": ">= 3.0.0 < 4" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@inertiajs/core": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-3.4.0.tgz", @@ -202,6 +462,19 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", @@ -289,9 +562,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -308,9 +578,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -327,9 +594,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -346,9 +610,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -365,9 +626,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -384,9 +642,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -601,9 +856,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -621,9 +873,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -641,9 +890,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -661,9 +907,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -762,6 +1005,27 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitejs/plugin-vue": { "version": "6.0.7", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", @@ -828,6 +1092,21 @@ "@vue/shared": "3.5.38" } }, + "node_modules/@vue/eslint-config-prettier": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@vue/eslint-config-prettier/-/eslint-config-prettier-10.2.0.tgz", + "integrity": "sha512-GL3YBLwv/+b86yHcNNfPJxOTtVFJ4Mbc9UU3zR+KVoG7SwGTjPT+32fXamscNumElhcpXW3mT0DgzS9w32S7Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2" + }, + "peerDependencies": { + "eslint": ">= 8.21.0", + "prettier": ">= 3.0.0" + } + }, "node_modules/@vue/reactivity": { "version": "3.5.38", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz", @@ -878,6 +1157,46 @@ "integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==", "license": "MIT" }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -904,6 +1223,36 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -994,12 +1343,65 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1062,29 +1464,355 @@ "node": ">=6" } }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "license": "MIT", "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "node": ">=10" }, - "peerDependenciesMeta": { + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz", + "integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.13" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-vue": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.9.2.tgz", + "integrity": "sha512-4g7ZP3pYcuqd7Zp0pzUKcos0W+RkjBz4EGdhJ92FcYk6v03Ti/GK5NwjgsjxHK+98eXDbHeK7VtX1az7/8doZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "natural-compare": "^1.4.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^7.1.0", + "semver": "^7.6.3", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "@stylistic/eslint-plugin": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "vue-eslint-parser": "^10.3.0" + }, + "peerDependenciesMeta": { + "@stylistic/eslint-plugin": { + "optional": true + }, + "@typescript-eslint/parser": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { "picomatch": { "optional": true } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1109,6 +1837,32 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1126,6 +1880,36 @@ "node": ">=8" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1136,6 +1920,26 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1146,6 +1950,37 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/laravel-precognition": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-2.0.0.tgz", @@ -1190,6 +2025,20 @@ } } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1326,9 +2175,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1349,9 +2195,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1372,9 +2215,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1395,9 +2235,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1451,6 +2288,22 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1460,6 +2313,29 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.15", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", @@ -1478,6 +2354,96 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1524,6 +2490,148 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.5.tgz", + "integrity": "sha512-zxcTTCedNGJM4R8sj/Cq/F0W/c4iE0afWBcBwMTRtw4WHYP9TWkYjdiH3npPRUYsXQCPR0hTU9yjovOu+E6EQA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.8.0.tgz", + "integrity": "sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -1577,6 +2685,42 @@ "tslib": "^2.1.0" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/shell-quote": { "version": "1.8.4", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", @@ -1643,6 +2787,22 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/synckit": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.3.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, "node_modules/tailwindcss": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", @@ -1697,6 +2857,36 @@ "devOptional": true, "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", @@ -1819,6 +3009,57 @@ } } }, + "node_modules/vue-eslint-parser": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.1.tgz", + "integrity": "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "eslint-scope": "^8.2.0 || ^9.0.0", + "eslint-visitor-keys": "^4.2.0 || ^5.0.0", + "espree": "^10.3.0 || ^11.0.0", + "esquery": "^1.6.0", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -1837,6 +3078,16 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -1875,6 +3126,19 @@ "engines": { "node": ">=12" } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/shop/package.json b/shop/package.json index 2d613f2b..5c92f20c 100644 --- a/shop/package.json +++ b/shop/package.json @@ -4,16 +4,31 @@ "type": "module", "scripts": { "build": "vite build && vite build --ssr", - "dev": "vite" + "dev": "vite", + "lint": "eslint resources/js", + "lint:fix": "eslint resources/js --fix", + "format": "prettier --write resources/js", + "format:check": "prettier --check resources/js" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.0.0", + "@vue/eslint-config-prettier": "^10.2.0", "concurrently": "^9.0.1", + "eslint": "^10.6.0", + "eslint-plugin-vue": "^10.9.2", + "globals": "^17.7.0", "laravel-vite-plugin": "^3.1", + "prettier": "^3.8.5", + "prettier-plugin-tailwindcss": "^0.8.0", "tailwindcss": "^4.0.0", "vite": "^8.0.0" }, "dependencies": { + "@fortawesome/fontawesome-svg-core": "^7.2.0", + "@fortawesome/free-brands-svg-icons": "^7.2.0", + "@fortawesome/free-solid-svg-icons": "^7.2.0", + "@fortawesome/vue-fontawesome": "^3.2.0", "@inertiajs/vue3": "^3.4.0", "@vitejs/plugin-vue": "^6.0.7", "@vue/server-renderer": "^3.5.38", diff --git a/shop/phpunit.xml b/shop/phpunit.xml index e7f0a48d..b424eb89 100644 --- a/shop/phpunit.xml +++ b/shop/phpunit.xml @@ -23,12 +23,28 @@ - - - + + + + + + diff --git a/shop/public/icons/icon-1024.png b/shop/public/icons/icon-1024.png new file mode 100644 index 00000000..70cd2552 Binary files /dev/null and b/shop/public/icons/icon-1024.png differ diff --git a/shop/public/icons/icon-180.png b/shop/public/icons/icon-180.png new file mode 100644 index 00000000..df07ffd1 Binary files /dev/null and b/shop/public/icons/icon-180.png differ diff --git a/shop/public/icons/icon-192.png b/shop/public/icons/icon-192.png new file mode 100644 index 00000000..816b31dc Binary files /dev/null and b/shop/public/icons/icon-192.png differ diff --git a/shop/public/icons/icon-512.png b/shop/public/icons/icon-512.png new file mode 100644 index 00000000..19cc696d Binary files /dev/null and b/shop/public/icons/icon-512.png differ diff --git a/shop/public/manifest.webmanifest b/shop/public/manifest.webmanifest new file mode 100644 index 00000000..04800c10 --- /dev/null +++ b/shop/public/manifest.webmanifest @@ -0,0 +1,32 @@ +{ + "name": "ShopFlow", + "short_name": "ShopFlow", + "description": "فروشگاه اینترنتی شاپ‌فلو", + "lang": "fa", + "dir": "rtl", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#ff8615", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/shop/public/robots.txt b/shop/public/robots.txt deleted file mode 100644 index eb053628..00000000 --- a/shop/public/robots.txt +++ /dev/null @@ -1,2 +0,0 @@ -User-agent: * -Disallow: diff --git a/shop/resources/js/Components/Account/AddressFormModal.vue b/shop/resources/js/Components/Account/AddressFormModal.vue new file mode 100644 index 00000000..6513e45b --- /dev/null +++ b/shop/resources/js/Components/Account/AddressFormModal.vue @@ -0,0 +1,370 @@ + + + diff --git a/shop/resources/js/Components/Account/MapPicker.vue b/shop/resources/js/Components/Account/MapPicker.vue new file mode 100644 index 00000000..c0a01678 --- /dev/null +++ b/shop/resources/js/Components/Account/MapPicker.vue @@ -0,0 +1,184 @@ + + + diff --git a/shop/resources/js/Components/Account/NeshanMap.vue b/shop/resources/js/Components/Account/NeshanMap.vue new file mode 100644 index 00000000..ccae4e2d --- /dev/null +++ b/shop/resources/js/Components/Account/NeshanMap.vue @@ -0,0 +1,137 @@ + + + diff --git a/shop/resources/js/Components/AppHead.vue b/shop/resources/js/Components/AppHead.vue new file mode 100644 index 00000000..b44c48d5 --- /dev/null +++ b/shop/resources/js/Components/AppHead.vue @@ -0,0 +1,91 @@ + + + diff --git a/shop/resources/js/Components/AppLink.vue b/shop/resources/js/Components/AppLink.vue index 38abf5d8..4c76a252 100644 --- a/shop/resources/js/Components/AppLink.vue +++ b/shop/resources/js/Components/AppLink.vue @@ -7,27 +7,28 @@ const props = defineProps({ type: String, required: true, }, + newTab: { + type: Boolean, + default: false, + }, }); -const isExternal = computed(() => - /^(https?:)?\/\//.test(props.href) || - props.href.startsWith('mailto:') || - props.href.startsWith('tel:'), +const isExternal = computed( + () => + /^(https?:)?\/\//.test(props.href) || + props.href.startsWith('mailto:') || + props.href.startsWith('tel:'), ); + +const target = computed(() => (props.newTab ? '_blank' : undefined)); +const rel = computed(() => (props.newTab || isExternal.value ? 'noopener noreferrer' : undefined)); diff --git a/shop/resources/js/Components/Brand/BrandFilters.vue b/shop/resources/js/Components/Brand/BrandFilters.vue new file mode 100644 index 00000000..a38b8b98 --- /dev/null +++ b/shop/resources/js/Components/Brand/BrandFilters.vue @@ -0,0 +1,285 @@ + + + + + diff --git a/shop/resources/js/Components/Breadcrumbs.vue b/shop/resources/js/Components/Breadcrumbs.vue new file mode 100644 index 00000000..96894215 --- /dev/null +++ b/shop/resources/js/Components/Breadcrumbs.vue @@ -0,0 +1,37 @@ + + + diff --git a/shop/resources/js/Components/Cart/CartLine.vue b/shop/resources/js/Components/Cart/CartLine.vue new file mode 100644 index 00000000..47074332 --- /dev/null +++ b/shop/resources/js/Components/Cart/CartLine.vue @@ -0,0 +1,116 @@ + + + diff --git a/shop/resources/js/Components/Cart/CartSummary.vue b/shop/resources/js/Components/Cart/CartSummary.vue new file mode 100644 index 00000000..1b2ec933 --- /dev/null +++ b/shop/resources/js/Components/Cart/CartSummary.vue @@ -0,0 +1,83 @@ + + + diff --git a/shop/resources/js/Components/Category/CategoryFilters.vue b/shop/resources/js/Components/Category/CategoryFilters.vue new file mode 100644 index 00000000..47984fdd --- /dev/null +++ b/shop/resources/js/Components/Category/CategoryFilters.vue @@ -0,0 +1,342 @@ + + + + + diff --git a/shop/resources/js/Components/Category/CategoryToolbar.vue b/shop/resources/js/Components/Category/CategoryToolbar.vue new file mode 100644 index 00000000..22600e51 --- /dev/null +++ b/shop/resources/js/Components/Category/CategoryToolbar.vue @@ -0,0 +1,53 @@ + + + diff --git a/shop/resources/js/Components/Checkout/CheckoutSteps.vue b/shop/resources/js/Components/Checkout/CheckoutSteps.vue new file mode 100644 index 00000000..cfb909f6 --- /dev/null +++ b/shop/resources/js/Components/Checkout/CheckoutSteps.vue @@ -0,0 +1,53 @@ + + + diff --git a/shop/resources/js/Components/EmptyState.vue b/shop/resources/js/Components/EmptyState.vue new file mode 100644 index 00000000..be1b1f1d --- /dev/null +++ b/shop/resources/js/Components/EmptyState.vue @@ -0,0 +1,30 @@ + + + diff --git a/shop/resources/js/Components/Footer/AppFooter.vue b/shop/resources/js/Components/Footer/AppFooter.vue index df6733c8..95162e74 100644 --- a/shop/resources/js/Components/Footer/AppFooter.vue +++ b/shop/resources/js/Components/Footer/AppFooter.vue @@ -6,6 +6,10 @@ import FooterNewsletter from '@/Components/Footer/FooterNewsletter.vue'; import FooterCopyright from '@/Components/Footer/FooterCopyright.vue'; defineProps({ + about: { + type: String, + default: '', + }, columns: { type: Array, default: () => [], @@ -34,6 +38,10 @@ const emit = defineEmits(['subscribe']);