diff --git a/.github/workflows/deploy-application.yml b/.github/workflows/deploy-application.yml index 0b8a3fba..986bc6b7 100644 --- a/.github/workflows/deploy-application.yml +++ b/.github/workflows/deploy-application.yml @@ -110,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/admin/AGENTS.md b/admin/AGENTS.md index 80075b94..59cebf25 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 === 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/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/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/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/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/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..95983de3 100644 --- a/admin/database/seeders/CitySeeder.php +++ b/admin/database/seeders/CitySeeder.php @@ -7,6 +7,7 @@ use App\Models\City; use App\Models\Province; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\DB; class CitySeeder extends Seeder { @@ -5747,5 +5748,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/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/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/docs/IMPLEMENTATION.md b/admin/docs/IMPLEMENTATION.md index 70610cff..6d72e663 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) @@ -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/`. 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/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/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/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/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/shop/.env.example b/shop/.env.example index 9403564d..dbbe2a42 100644 --- a/shop/.env.example +++ b/shop/.env.example @@ -4,6 +4,12 @@ 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 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 31aa9a43..e5129a69 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). @@ -203,6 +204,10 @@ The shop UI uses **Inertia + Vue 3** (SSR enabled). Clean, readable code is a ha - 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. +- **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 @@ -230,9 +235,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). @@ -277,7 +294,7 @@ 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}`. +- 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): @@ -296,6 +313,8 @@ DB_DATABASE=shop_flow_test php artisan db:seed --class="Database\Seeders\Setting - **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. +- **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). - **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/Catalog/BuildProductCard.php b/shop/app/Actions/Catalog/BuildProductCard.php new file mode 100644 index 00000000..deab9be4 --- /dev/null +++ b/shop/app/Actions/Catalog/BuildProductCard.php @@ -0,0 +1,41 @@ + + */ + 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)($product->featuredImage)?->toArray(), + 'price' => $pricing['price'], + 'salePrice' => $pricing['salePrice'], + 'discountPercent' => $pricing['discountPercent'], + ]; + } +} 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/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..0f9559b7 --- /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)]) + ->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/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..af78df81 --- /dev/null +++ b/shop/app/Actions/Product/BuildProductDetail.php @@ -0,0 +1,145 @@ + $varieties */ + $varieties = $product->varieties; + + $pricing = $this->pricing->forVarieties($varieties, (int) $product->price); + + 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 => ['value' => $attribute->value]) + ->values() + ->all(), + specs: $product->attributes + ->map(fn (Attribute $attribute): array => ['value' => $attribute->value]) + ->all(), + reviews: $product->reviews + ->map(fn (Review $review): ReviewDTO => new ReviewDTO( + id: $review->id, + heading: $review->heading, + content: $review->content, + author: $review->user?->name, + date: $review->created_at?->toIso8601String(), + )) + ->all(), + reviewCount: $product->reviews->count(), + ); + } + + 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), + ); + } + + /** + * 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..228e38e0 --- /dev/null +++ b/shop/app/Actions/Product/BuildVariantAxes.php @@ -0,0 +1,86 @@ + $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' => [], + ]; + + $axes[$groupId]['options'][$attribute->value] ??= [ + 'value' => $attribute->value, + 'color' => $attribute->color, + ]; + } + } + + $primaryGroupId = $this->primaryGroupId($primaryVotes); + + return array_values(array_map( + fn (array $axis): array => [ + 'id' => $axis['id'], + 'name' => $axis['name'], + 'primary' => $axis['id'] === $primaryGroupId, + 'options' => array_values($axis['options']), + ], + $axes, + )); + } + + /** + * 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..7ff727d3 --- /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)]) + ->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/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/ProductDTO.php b/shop/app/DTOs/ProductDTO.php new file mode 100644 index 00000000..0dd461e7 --- /dev/null +++ b/shop/app/DTOs/ProductDTO.php @@ -0,0 +1,74 @@ + $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, + ) {} + + /** + * @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, + ]; + } +} diff --git a/shop/app/DTOs/ReviewDTO.php b/shop/app/DTOs/ReviewDTO.php new file mode 100644 index 00000000..fe2f283d --- /dev/null +++ b/shop/app/DTOs/ReviewDTO.php @@ -0,0 +1,24 @@ + + */ + public function toArray(): array + { + return get_object_vars($this); + } +} 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/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/ProductController.php b/shop/app/Http/Controllers/ProductController.php new file mode 100644 index 00000000..0ee7d85c --- /dev/null +++ b/shop/app/Http/Controllers/ProductController.php @@ -0,0 +1,51 @@ +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', + '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), + ]); + } +} diff --git a/shop/app/Http/Middleware/HandleInertiaRequests.php b/shop/app/Http/Middleware/HandleInertiaRequests.php index 68f8e410..d19ca121 100644 --- a/shop/app/Http/Middleware/HandleInertiaRequests.php +++ b/shop/app/Http/Middleware/HandleInertiaRequests.php @@ -4,7 +4,10 @@ namespace App\Http\Middleware; +use App\Enums\CategoryStatusEnum; +use App\Models\Category; use App\Models\Setting; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\Request; use Illuminate\Support\Collection; use Inertia\Middleware; @@ -48,6 +51,9 @@ public function share(Request $request): array 'url' => $this->canonicalUrl($request), 'locale' => 'fa_IR', ], + 'nav' => [ + 'categories' => $this->navCategories(), + ], 'footer' => [ 'about' => $this->value($settings, 'footer_about'), 'columns' => [ @@ -67,6 +73,38 @@ public function share(Request $request): array ]; } + /** + * 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 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/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/Review.php b/shop/app/Models/Review.php index e99f0302..a141fba0 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; @@ -20,6 +21,8 @@ * @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 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/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/docs/STOREFRONT_IMPLEMENTATION.md b/shop/docs/STOREFRONT_IMPLEMENTATION.md index 3e6f3f61..ca8b1c30 100644 --- a/shop/docs/STOREFRONT_IMPLEMENTATION.md +++ b/shop/docs/STOREFRONT_IMPLEMENTATION.md @@ -23,7 +23,7 @@ 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` + `AppLink` + `Icon` done; `PriceTag`, `ProductCard`, `QuantityInput`, `Breadcrumbs`, `Pagination`, `RatingStars`, `EmptyState` pending +- [~] Shared UI kit components: `BaseButton` + `AppLink` + `Icon` + `PriceTag` + `ProductCard` + `SectionHeading` + `Breadcrumbs` + `RatingStars` done; `QuantityInput`, `Pagination`, `EmptyState` 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) @@ -35,10 +35,13 @@ A storefront feature is "done" when it has: read/write Eloquent models for the s 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) +- [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 - [ ] 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 +- [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 + - 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). Ratings summary deferred (no numeric rating field in `reviews`) - [ ] Brand page: products for a brand - [ ] Search: keyword search over products with results page - [ ] CMS pages (`pages.{slug}`) and FAQ page (`faqs.{position}`) @@ -62,7 +65,7 @@ Auth uses the shared `users` table. Password reset via mobile (`mobile_password_ 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 +- [ ] Add to cart / update quantity / remove line. Quantity is capped at the variety's available `inventory` (clamp in the UI and reject over-limit amounts server-side) - [ ] Cart page + mini-cart component with live totals - [ ] Merge guest cart into user cart on login - [ ] Coupon preview at cart (validated, not yet committed) 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 0f53d400..375d808a 100644 --- a/shop/package-lock.json +++ b/shop/package-lock.json @@ -15,9 +15,16 @@ "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" } @@ -99,6 +106,134 @@ "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", @@ -154,6 +289,72 @@ "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", @@ -261,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", @@ -348,9 +562,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -367,9 +578,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -386,9 +594,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -405,9 +610,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -424,9 +626,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -443,9 +642,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -660,9 +856,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -680,9 +873,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -700,9 +890,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -720,9 +907,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -821,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", @@ -887,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", @@ -937,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", @@ -963,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", @@ -1053,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", @@ -1121,53 +1464,405 @@ "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/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": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "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/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": ">=12.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" }, "peerDependencies": { - "picomatch": "^3 || ^4" + "jiti": "*" }, "peerDependenciesMeta": { - "picomatch": { + "jiti": { "optional": true } } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": 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", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "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", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, "license": "ISC", "engines": { "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", @@ -1185,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", @@ -1195,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", @@ -1205,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", @@ -1249,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", @@ -1385,9 +2175,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1408,9 +2195,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1431,9 +2215,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1454,9 +2235,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1510,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", @@ -1519,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", @@ -1537,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", @@ -1583,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", @@ -1636,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", @@ -1702,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", @@ -1756,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", @@ -1878,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", @@ -1896,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", @@ -1934,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 b6dc2b5c..5c92f20c 100644 --- a/shop/package.json +++ b/shop/package.json @@ -4,12 +4,23 @@ "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" }, diff --git a/shop/resources/js/Components/AppHead.vue b/shop/resources/js/Components/AppHead.vue index 3396fb18..b44c48d5 100644 --- a/shop/resources/js/Components/AppHead.vue +++ b/shop/resources/js/Components/AppHead.vue @@ -27,6 +27,10 @@ const props = defineProps({ type: Boolean, default: false, }, + jsonLd: { + type: [Object, Array], + default: null, + }, }); const page = usePage(); @@ -38,69 +42,50 @@ const canonicalUrl = computed(() => props.canonical || seo.value.url || ''); const fullTitle = computed(() => props.title ? `${props.title} - ${siteName.value}` : siteName.value, ); +const jsonLdString = computed(() => (props.jsonLd ? JSON.stringify(props.jsonLd) : '')); diff --git a/shop/resources/js/Components/AppLink.vue b/shop/resources/js/Components/AppLink.vue index 38abf5d8..f06c31b7 100644 --- a/shop/resources/js/Components/AppLink.vue +++ b/shop/resources/js/Components/AppLink.vue @@ -9,25 +9,19 @@ const props = defineProps({ }, }); -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:'), ); 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/Footer/AppFooter.vue b/shop/resources/js/Components/Footer/AppFooter.vue index f9510cca..95162e74 100644 --- a/shop/resources/js/Components/Footer/AppFooter.vue +++ b/shop/resources/js/Components/Footer/AppFooter.vue @@ -38,10 +38,7 @@ const emit = defineEmits(['subscribe']);