diff --git a/.agents/skills/developing-with-fortify/SKILL.md b/.agents/skills/developing-with-fortify/SKILL.md new file mode 100644 index 00000000..db3558bc --- /dev/null +++ b/.agents/skills/developing-with-fortify/SKILL.md @@ -0,0 +1,116 @@ +--- +name: developing-with-fortify +description: Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications. +--- + +# Laravel Fortify Development + +Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications. + +## Documentation + +Use `search-docs` for detailed Laravel Fortify patterns and documentation. + +## Usage + +- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints +- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.) +- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field +- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.) +- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc. + +## Available Features + +Enable in `config/fortify.php` features array: + +- `Features::registration()` - User registration +- `Features::resetPasswords()` - Password reset via email +- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail` +- `Features::updateProfileInformation()` - Profile updates +- `Features::updatePasswords()` - Password changes +- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes + +> Use `search-docs` for feature configuration options and customization patterns. + +## Setup Workflows + +### Two-Factor Authentication Setup + +``` +- [ ] Add TwoFactorAuthenticatable trait to User model +- [ ] Enable feature in config/fortify.php +- [ ] Run migrations for 2FA columns +- [ ] Set up view callbacks in FortifyServiceProvider +- [ ] Create 2FA management UI +- [ ] Test QR code and recovery codes +``` + +> Use `search-docs` for TOTP implementation and recovery code handling patterns. + +### Email Verification Setup + +``` +- [ ] Enable emailVerification feature in config +- [ ] Implement MustVerifyEmail interface on User model +- [ ] Set up verifyEmailView callback +- [ ] Add verified middleware to protected routes +- [ ] Test verification email flow +``` + +> Use `search-docs` for MustVerifyEmail implementation patterns. + +### Password Reset Setup + +``` +- [ ] Enable resetPasswords feature in config +- [ ] Set up requestPasswordResetLinkView callback +- [ ] Set up resetPasswordView callback +- [ ] Define password.reset named route (if views disabled) +- [ ] Test reset email and link flow +``` + +> Use `search-docs` for custom password reset flow patterns. + +### SPA Authentication Setup + +``` +- [ ] Set 'views' => false in config/fortify.php +- [ ] Install and configure Laravel Sanctum +- [ ] Use 'web' guard in fortify config +- [ ] Set up CSRF token handling +- [ ] Test XHR authentication flows +``` + +> Use `search-docs` for integration and SPA authentication patterns. + +## Best Practices + +### Custom Authentication Logic + +Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects. + +### Registration Customization + +Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields. + +### Rate Limiting + +Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination. + +## Key Endpoints + +| Feature | Method | Endpoint | +|------------------------|----------|---------------------------------------------| +| Login | POST | `/login` | +| Logout | POST | `/logout` | +| Register | POST | `/register` | +| Password Reset Request | POST | `/forgot-password` | +| Password Reset | POST | `/reset-password` | +| Email Verify Notice | GET | `/email/verify` | +| Resend Verification | POST | `/email/verification-notification` | +| Password Confirm | POST | `/user/confirm-password` | +| Enable 2FA | POST | `/user/two-factor-authentication` | +| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` | +| 2FA Challenge | POST | `/two-factor-challenge` | +| Get QR Code | GET | `/user/two-factor-qr-code` | +| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` | diff --git a/.agents/skills/fluxui-development/SKILL.md b/.agents/skills/fluxui-development/SKILL.md new file mode 100644 index 00000000..d4fb5a03 --- /dev/null +++ b/.agents/skills/fluxui-development/SKILL.md @@ -0,0 +1,81 @@ +--- +name: fluxui-development +description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling." +license: MIT +metadata: + author: laravel +--- + +# Flux UI Development + +## Documentation + +Use `search-docs` for detailed Flux UI patterns and documentation. + +## Basic Usage + +This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components. + +Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize. + +Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs. + + +```blade +Click me +``` + +## Available Components (Free Edition) + +Available: avatar, badge, brand, breadcrumbs, button, callout, card, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, pagination, profile, progress, radio, select, separator, skeleton, switch, table, text, textarea, toast, tooltip + +## Icons + +Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names. + + +```blade +Export +``` + +For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command: + +```bash +php artisan flux:icon crown grip-vertical github +``` + +## Common Patterns + +### Form Fields + + +```blade + + Email + + + +``` + +### Modals + + +```blade + + Title +

Content

+
+``` + +## Verification + +1. Check component renders correctly +2. Test interactive states +3. Verify mobile responsiveness + +## Common Pitfalls + +- Trying to use Pro-only components in the free edition +- Not checking if a Flux component exists before creating custom implementations +- Forgetting to use the `search-docs` tool for component-specific documentation +- Not following existing project patterns for Flux usage diff --git a/.agents/skills/laravel-best-practices/SKILL.md b/.agents/skills/laravel-best-practices/SKILL.md new file mode 100644 index 00000000..d136d755 --- /dev/null +++ b/.agents/skills/laravel-best-practices/SKILL.md @@ -0,0 +1,59 @@ +--- +name: laravel-best-practices +description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns." +license: MIT +metadata: + author: laravel +--- + +# Laravel Best Practices + +Best practices for Laravel, organized as an index of rule files. Each rule file teaches what to do and why. For exact API syntax, verify with `search-docs`. + +## Consistency First + +Before applying any rule, check what the application already does. Laravel offers multiple valid approaches, and the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. + +Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it. Don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides. + +## How to Apply + +1. Check the changed files, nearby code, project configuration, and relevant tests for established patterns. Deviate only for a correctness or security defect, and call the deviation out. +2. Map every affected concern to the rule index below. Read each mapped rule file before editing. Skip unrelated rule files. +3. Make the smallest coherent change. Keep the application's architecture and naming instead of introducing a second pattern for the same job. +4. Verify version-sensitive Laravel APIs for the installed version with `search-docs`, or inspect the installed framework when it is unavailable. +5. Run the narrowest relevant tests first, then the project's formatting and static-analysis checks when the change warrants them. +6. Re-read the diff against every mapped rule before finishing. + +## Rule Index + +Cross-cutting changes often need more than one rule file. + +| Concern | Read | +| --- | --- | +| Query count, eager loading, indexes, large datasets | [`rules/db-performance.md`](rules/db-performance.md) | +| Subqueries, aggregates, complex ordering and query plans | [`rules/advanced-queries.md`](rules/advanced-queries.md) | +| Models, relationships, scopes, casts | [`rules/eloquent.md`](rules/eloquent.md) | +| Authentication, authorization, input safety, secrets, uploads | [`rules/security.md`](rules/security.md) | +| Form Requests and validation rules | [`rules/validation.md`](rules/validation.md) | +| Controllers, route binding, resources, middleware | [`rules/routing.md`](rules/routing.md) | +| Schema changes, columns, foreign keys, indexes | [`rules/migrations.md`](rules/migrations.md) | +| Jobs, retries, uniqueness, batches, Horizon | [`rules/queue-jobs.md`](rules/queue-jobs.md) | +| Cache lifetime, invalidation, locks, memoization | [`rules/caching.md`](rules/caching.md) | +| Outbound requests, retries, timeouts, fakes | [`rules/http-client.md`](rules/http-client.md) | +| Exceptions, reporting, rendering, log context | [`rules/error-handling.md`](rules/error-handling.md) | +| Events and notifications | [`rules/events-notifications.md`](rules/events-notifications.md) | +| Mailables and mail assertions | [`rules/mail.md`](rules/mail.md) | +| Scheduled tasks and overlap protection | [`rules/scheduling.md`](rules/scheduling.md) | +| Collections, lazy iteration, bulk operations | [`rules/collections.md`](rules/collections.md) | +| Blade components, attributes, composers | [`rules/blade-views.md`](rules/blade-views.md) | +| Environment values and application configuration | [`rules/config.md`](rules/config.md) | +| Pest/PHPUnit patterns, factories, fakes | [`rules/testing.md`](rules/testing.md) | +| Naming, helpers, file boundaries, PHP style | [`rules/style.md`](rules/style.md) | +| Actions, services, dependencies, application structure | [`rules/architecture.md`](rules/architecture.md) | + +## Decision Rules + +- Prefer framework features and existing application abstractions over new helpers or dependencies. +- Avoid speculative abstractions. Extract code when it creates a clear domain boundary, removes meaningful duplication, or makes behavior independently testable. +- Keep database access out of Blade views and prevent hidden N+1 queries across controllers, resources, jobs, and serialization. diff --git a/.agents/skills/laravel-best-practices/rules/advanced-queries.md b/.agents/skills/laravel-best-practices/rules/advanced-queries.md new file mode 100644 index 00000000..f12876e4 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/advanced-queries.md @@ -0,0 +1,106 @@ +# Advanced Query Patterns + +## Use `addSelect()` Subqueries for Single Values from Has-Many + +Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries. + +```php +public function scopeWithLastLoginAt($query): void +{ + $query->addSelect([ + 'last_login_at' => Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->withCasts(['last_login_at' => 'datetime']); +} +``` + +## Create Dynamic Relationships via Subquery FK + +Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection. + +```php +public function lastLogin(): BelongsTo +{ + return $this->belongsTo(Login::class); +} + +public function scopeWithLastLogin($query): void +{ + $query->addSelect([ + 'last_login_id' => Login::select('id') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->with('lastLogin'); +} +``` + +## Use Conditional Aggregates Instead of Multiple Count Queries + +Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values. + +```php +$statuses = Feature::toBase() + ->selectRaw("count(case when status = 'Requested' then 1 end) as requested") + ->selectRaw("count(case when status = 'Planned' then 1 end) as planned") + ->selectRaw("count(case when status = 'Completed' then 1 end) as completed") + ->first(); +``` + +## Use `setRelation()` to Prevent Circular N+1 + +When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries. + +```php +$feature->load('comments.user'); +$feature->comments->each->setRelation('feature', $feature); +``` + +## Prefer `whereIn` + Subquery Over `whereHas` + +`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory. + +Incorrect (correlated EXISTS re-executes per row): + +```php +$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term)); +``` + +Correct (index-friendly subquery, no PHP memory overhead): + +```php +$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id')); +``` + +## Sometimes Two Simple Queries Beat One Complex Query + +Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index. + +## Use Compound Indexes Matching `orderBy` Column Order + +When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index. + +```php +// Migration +$table->index(['last_name', 'first_name']); + +// Query — column order must match the index +User::query()->orderBy('last_name')->orderBy('first_name')->paginate(); +``` + +## Use Correlated Subqueries for Has-Many Ordering + +When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading. + +```php +public function scopeOrderByLastLogin($query): void +{ + $query->orderByDesc(Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1) + ); +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/architecture.md b/.agents/skills/laravel-best-practices/rules/architecture.md new file mode 100644 index 00000000..138d5a48 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/architecture.md @@ -0,0 +1,202 @@ +# Architecture Best Practices + +## Single-Purpose Action Classes + +Extract discrete business operations into invokable Action classes. + +```php +class CreateOrderAction +{ + public function __construct(private InventoryService $inventory) {} + + public function handle(array $data): Order + { + $order = Order::create($data); + $this->inventory->reserve($order); + + return $order; + } +} +``` + +## Use Dependency Injection + +Always use constructor injection. Avoid `app()` or `resolve()` inside classes. + +Incorrect: +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request) + { + $service = app(OrderService::class); + + return $service->create($request->validated()); + } +} +``` + +Correct: +```php +class OrderController extends Controller +{ + public function __construct(private OrderService $service) {} + + public function store(StoreOrderRequest $request) + { + return $this->service->create($request->validated()); + } +} +``` + +## Code to Interfaces + +Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability. + +Incorrect (concrete dependency): +```php +class OrderService +{ + public function __construct(private StripeGateway $gateway) {} +} +``` + +Correct (interface dependency): +```php +interface PaymentGateway +{ + public function charge(int $amount, string $customerId): PaymentResult; +} + +class OrderService +{ + public function __construct(private PaymentGateway $gateway) {} +} +``` + +Bind in a service provider: + +```php +$this->app->bind(PaymentGateway::class, StripeGateway::class); +``` + +## Default Sort by Descending + +When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined. + +Incorrect: +```php +$posts = Post::paginate(); +``` + +Correct: +```php +$posts = Post::latest()->paginate(); +``` + +## Use Atomic Locks for Race Conditions + +Prevent race conditions with `Cache::lock()` or `lockForUpdate()`. + +```php +Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) { + $order->process(); +}); + +// Or at query level +$product = Product::where('id', $id)->lockForUpdate()->first(); +``` + +## Use `mb_*` String Functions + +When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters. + +Incorrect: +```php +strlen('José'); // 5 (bytes, not characters) +strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte +``` + +Correct: +```php +mb_strlen('José'); // 4 (characters) +mb_strtolower('MÜNCHEN'); // 'münchen' + +// Prefer Laravel's Str helpers when available +Str::length('José'); // 4 +Str::lower('MÜNCHEN'); // 'münchen' +``` + +## Use `defer()` for Post-Response Work + +For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead. + +Incorrect (job overhead for trivial work): +```php +dispatch(new LogPageView($page)); +``` + +Correct (runs after response, same process): +```php +defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()])); +``` + +Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work. + +## Use `Context` for Request-Scoped Data + +The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually. + +```php +// In middleware +Context::add('tenant_id', $request->header('X-Tenant-ID')); + +// Anywhere later — controllers, jobs, log context +$tenantId = Context::get('tenant_id'); +``` + +Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`. + +## Use `Concurrency::run()` for Parallel Execution + +Run independent operations in parallel using child processes — no async libraries needed. + +```php +use Illuminate\Support\Facades\Concurrency; + +[$users, $orders] = Concurrency::run([ + fn () => User::count(), + fn () => Order::where('status', 'pending')->count(), +]); +``` + +Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially. + +## Convention Over Configuration + +Follow Laravel conventions. Don't override defaults unnecessarily. + +Incorrect: +```php +class Customer extends Model +{ + protected $table = 'Customer'; + protected $primaryKey = 'customer_id'; + + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id'); + } +} +``` + +Correct: +```php +class Customer extends Model +{ + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class); + } +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/blade-views.md b/.agents/skills/laravel-best-practices/rules/blade-views.md new file mode 100644 index 00000000..5f0b3a1e --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/blade-views.md @@ -0,0 +1,36 @@ +# Blade & Views Best Practices + +## Use `$attributes->merge()` in Component Templates + +Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly. + +```blade +
merge(['class' => 'alert alert-'.$type]) }}> + {{ $message }} +
+``` + +## Use `@pushOnce` for Per-Component Scripts + +If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once. + +## Prefer Blade Components Over `@include` + +`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots. + +## Use View Composers for Shared View Data + +If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it. + +## Use Blade Fragments for Partial Re-Renders (htmx/Turbo) + +A single view can return either the full page or just a fragment, keeping routing clean. + +```php +return view('dashboard', compact('users')) + ->fragmentIf($request->hasHeader('HX-Request'), 'user-list'); +``` + +## Use `@aware` for Deeply Nested Component Props + +Avoids re-passing parent props through every level of nested components. diff --git a/.agents/skills/laravel-best-practices/rules/caching.md b/.agents/skills/laravel-best-practices/rules/caching.md new file mode 100644 index 00000000..67408d6e --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/caching.md @@ -0,0 +1,70 @@ +# Caching Best Practices + +## Use `Cache::remember()` Instead of Manual Get/Put + +Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions. + +Incorrect: +```php +$val = Cache::get('stats'); +if (! $val) { + $val = $this->computeStats(); + Cache::put('stats', $val, 60); +} +``` + +Correct: +```php +$val = Cache::remember('stats', 60, fn () => $this->computeStats()); +``` + +## Use `Cache::flexible()` for Stale-While-Revalidate + +On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background. + +Incorrect: `Cache::remember('users', 300, fn () => User::all());` + +Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function. + +## Use `Cache::memo()` to Avoid Redundant Hits Within a Request + +If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory. + +`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5. + +## Use Cache Tags to Invalidate Related Groups + +Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`. + +```php +Cache::tags(['user-1'])->flush(); +``` + +## Use `Cache::add()` for Atomic Conditional Writes + +`add()` only writes if the key does not exist — atomic, no race condition between checking and writing. + +Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }` + +Correct: `Cache::add('lock', true, 10);` + +## Use `once()` for Per-Request Memoization + +`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory. + +```php +public function roles(): Collection +{ + return once(fn () => $this->loadRoles()); +} +``` + +Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching. + +## Configure Failover Cache Stores in Production + +If Redis goes down, the app falls back to a secondary store automatically. + +```php +'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], +``` diff --git a/.agents/skills/laravel-best-practices/rules/collections.md b/.agents/skills/laravel-best-practices/rules/collections.md new file mode 100644 index 00000000..18e8d9e1 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/collections.md @@ -0,0 +1,44 @@ +# Collection Best Practices + +## Use Higher-Order Messages for Simple Operations + +Incorrect: +```php +$users->each(function (User $user) { + $user->markAsVip(); +}); +``` + +Correct: `$users->each->markAsVip();` + +Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc. + +## Choose `cursor()` vs. `lazy()` Correctly + +- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk). +- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading. + +Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored. + +Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work. + +## Use `lazyById()` When Updating Records While Iterating + +`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation. + +## Use `toQuery()` for Bulk Operations on Collections + +Avoids manual `whereIn` construction. + +Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);` + +Correct: `$users->toQuery()->update([...]);` + +## Use `#[CollectedBy]` for Custom Collection Classes + +More declarative than overriding `newCollection()`. + +```php +#[CollectedBy(UserCollection::class)] +class User extends Model {} +``` diff --git a/.agents/skills/laravel-best-practices/rules/config.md b/.agents/skills/laravel-best-practices/rules/config.md new file mode 100644 index 00000000..9bea727b --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/config.md @@ -0,0 +1,73 @@ +# Configuration Best Practices + +## `env()` Only in Config Files + +Direct `env()` calls may return `null` when config is cached. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'key' => env('API_KEY'), + +// Application code +$key = config('services.key'); +``` + +## Use Encrypted Env or External Secrets + +Never store production secrets in plain `.env` files in version control. + +Incorrect: +```bash + +# .env committed to repo or shared in Slack + +STRIPE_SECRET=sk_live_abc123 +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI +``` + +Correct: +```bash +php artisan env:encrypt --env=production --readable +php artisan env:decrypt --env=production +``` + +For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime. + +## Use `App::environment()` for Environment Checks + +Incorrect: +```php +if (env('APP_ENV') === 'production') { +``` + +Correct: +```php +if (app()->isProduction()) { +// or +if (App::environment('production')) { +``` + +## Use Constants and Language Files + +Use class constants instead of hardcoded magic strings for model states, types, and statuses. + +```php +// Incorrect +return $this->type === 'normal'; + +// Correct +return $this->type === self::TYPE_NORMAL; +``` + +If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there. + +```php +// Only when lang files already exist in the project +return back()->with('message', __('app.article_added')); +``` diff --git a/.agents/skills/laravel-best-practices/rules/db-performance.md b/.agents/skills/laravel-best-practices/rules/db-performance.md new file mode 100644 index 00000000..c49ba164 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/db-performance.md @@ -0,0 +1,192 @@ +# Database Performance Best Practices + +## Always Eager Load Relationships + +Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront. + +Incorrect (N+1 — executes 1 + N queries): +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Correct (2 queries total): +```php +$posts = Post::with('author')->get(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Constrain eager loads to select only needed columns (always include the foreign key): + +```php +$users = User::with(['posts' => function ($query) { + $query->select('id', 'user_id', 'title') + ->where('published', true) + ->latest() + ->limit(10); +}])->get(); +``` + +## Prevent Lazy Loading in Development + +Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development. + +```php +public function boot(): void +{ + Model::preventLazyLoading(! app()->isProduction()); +} +``` + +Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded. + +## Select Only Needed Columns + +Avoid `SELECT *` — especially when tables have large text or JSON columns. + +Incorrect: +```php +$posts = Post::with('author')->get(); +``` + +Correct: +```php +$posts = Post::select('id', 'title', 'user_id', 'created_at') + ->with(['author:id,name,avatar']) + ->get(); +``` + +When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match. + +## Chunk Large Datasets + +Never load thousands of records at once. Use chunking for batch processing. + +Incorrect: +```php +$users = User::all(); +foreach ($users as $user) { + $user->notify(new WeeklyDigest); +} +``` + +Correct: +```php +User::where('subscribed', true)->chunk(200, function ($users) { + foreach ($users as $user) { + $user->notify(new WeeklyDigest); + } +}); +``` + +Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change: + +```php +User::where('active', false)->chunkById(200, function ($users) { + $users->each->delete(); +}); +``` + +## Add Database Indexes + +Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->index()->constrained(); + $table->string('status')->index(); + $table->timestamps(); + $table->index(['status', 'created_at']); +}); +``` + +Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`). + +## Use `withCount()` for Counting Relations + +Never load entire collections just to count them. + +Incorrect: +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->comments->count(); +} +``` + +Correct: +```php +$posts = Post::withCount('comments')->get(); +foreach ($posts as $post) { + echo $post->comments_count; +} +``` + +Conditional counting: + +```php +$posts = Post::withCount([ + 'comments', + 'comments as approved_comments_count' => function ($query) { + $query->where('approved', true); + }, +])->get(); +``` + +## Use `cursor()` for Memory-Efficient Iteration + +For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator. + +Incorrect: +```php +$users = User::where('active', true)->get(); +``` + +Correct: +```php +foreach (User::where('active', true)->cursor() as $user) { + ProcessUser::dispatch($user->id); +} +``` + +Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records. + +## No Queries in Blade Templates + +Never execute queries in Blade templates. Pass data from controllers. + +Incorrect: +```blade +@foreach (User::all() as $user) + {{ $user->profile->name }} +@endforeach +``` + +Correct: +```php +// Controller +$users = User::with('profile')->get(); +return view('users.index', compact('users')); +``` + +```blade +@foreach ($users as $user) + {{ $user->profile->name }} +@endforeach +``` diff --git a/.agents/skills/laravel-best-practices/rules/eloquent.md b/.agents/skills/laravel-best-practices/rules/eloquent.md new file mode 100644 index 00000000..413d5da4 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/eloquent.md @@ -0,0 +1,148 @@ +# Eloquent Best Practices + +## Use Correct Relationship Types + +Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints. + +```php +public function comments(): HasMany +{ + return $this->hasMany(Comment::class); +} + +public function author(): BelongsTo +{ + return $this->belongsTo(User::class, 'user_id'); +} +``` + +## Use Local Scopes for Reusable Queries + +Extract reusable query constraints into local scopes to avoid duplication. + +Incorrect: +```php +$active = User::where('verified', true)->whereNotNull('activated_at')->get(); +$articles = Article::whereHas('user', function ($q) { + $q->where('verified', true)->whereNotNull('activated_at'); +})->get(); +``` + +Correct: +```php +public function scopeActive(Builder $query): Builder +{ + return $query->where('verified', true)->whereNotNull('activated_at'); +} + +// Usage +$active = User::active()->get(); +$articles = Article::whereHas('user', fn ($q) => $q->active())->get(); +``` + +## Apply Global Scopes Sparingly + +Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy. + +Incorrect (global scope for a conditional filter): +```php +class PublishedScope implements Scope +{ + public function apply(Builder $builder, Model $model): void + { + $builder->where('published', true); + } +} +// Now admin panels, reports, and background jobs all silently skip drafts +``` + +Correct (local scope you opt into): +```php +public function scopePublished(Builder $query): Builder +{ + return $query->where('published', true); +} + +Post::published()->paginate(); // Explicit +Post::paginate(); // Admin sees all +``` + +## Define Attribute Casts + +Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion. + +```php +protected function casts(): array +{ + return [ + 'is_active' => 'boolean', + 'metadata' => 'array', + 'total' => 'decimal:2', + ]; +} +``` + +## Cast Date Columns Properly + +Always cast date columns. Use Carbon instances in templates instead of formatting strings manually. + +Incorrect: +```blade +{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }} +``` + +Correct: +```php +protected function casts(): array +{ + return [ + 'ordered_at' => 'datetime', + ]; +} +``` + +```blade +{{ $order->ordered_at->toDateString() }} +{{ $order->ordered_at->format('m-d') }} +``` + +## Use `whereBelongsTo()` for Relationship Queries + +Cleaner than manually specifying foreign keys. + +Incorrect: +```php +Post::where('user_id', $user->id)->get(); +``` + +Correct: +```php +Post::whereBelongsTo($user)->get(); +Post::whereBelongsTo($user, 'author')->get(); +``` + +## Avoid Hardcoded Table Names in Queries + +Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string). + +Incorrect: +```php +DB::table('users')->where('active', true)->get(); + +$query->join('companies', 'companies.id', '=', 'users.company_id'); + +DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); +``` + +Correct — reference the model's table: +```php +DB::table((new User)->getTable())->where('active', true)->get(); + +// Even better — use Eloquent or the query builder instead of raw SQL +User::where('active', true)->get(); +Order::where('status', 'pending')->get(); +``` + +Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable. + +**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration. diff --git a/.agents/skills/laravel-best-practices/rules/error-handling.md b/.agents/skills/laravel-best-practices/rules/error-handling.md new file mode 100644 index 00000000..4b148667 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/error-handling.md @@ -0,0 +1,72 @@ +# Error Handling Best Practices + +## Exception Reporting and Rendering + +There are two valid approaches — choose one and apply it consistently across the project. + +**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find: + +```php +class InvalidOrderException extends Exception +{ + public function report(): void { /* custom reporting */ } + + public function render(Request $request): Response + { + return response()->view('errors.invalid-order', status: 422); + } +} +``` + +**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture: + +```php +->withExceptions(function (Exceptions $exceptions) { + $exceptions->report(function (InvalidOrderException $e) { /* ... */ }); + $exceptions->render(function (InvalidOrderException $e, Request $request) { + return response()->view('errors.invalid-order', status: 422); + }); +}) +``` + +Check the existing codebase and follow whichever pattern is already established. + +## Use `ShouldntReport` for Exceptions That Should Never Log + +More discoverable than listing classes in `dontReport()`. + +```php +class PodcastProcessingException extends Exception implements ShouldntReport {} +``` + +## Throttle High-Volume Exceptions + +A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type. + +## Enable `dontReportDuplicates()` + +Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks. + +## Force JSON Error Rendering for API Routes + +Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes. + +```php +$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { + return $request->is('api/*') || $request->expectsJson(); +}); +``` + +## Add Context to Exception Classes + +Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry. + +```php +class InvalidOrderException extends Exception +{ + public function context(): array + { + return ['order_id' => $this->orderId]; + } +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/events-notifications.md b/.agents/skills/laravel-best-practices/rules/events-notifications.md new file mode 100644 index 00000000..82e329e8 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/events-notifications.md @@ -0,0 +1,52 @@ +# Events & Notifications Best Practices + +## Rely on Event Discovery + +Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`. + +## Run `event:cache` in Production Deploy + +Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`. + +## Use `ShouldDispatchAfterCommit` Inside Transactions + +Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet. + +```php +class OrderShipped implements ShouldDispatchAfterCommit {} +``` + +## Always Queue Notifications + +Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response. + +```php +class InvoicePaid extends Notification implements ShouldQueue +{ + use Queueable; +} +``` + +## Use `afterCommit()` on Notifications in Transactions + +Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits. + +```php +$user->notify((new InvoicePaid($invoice))->afterCommit()); +``` + +## Route Notification Channels to Dedicated Queues + +Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues. + +## Use On-Demand Notifications for Non-User Recipients + +Avoid creating dummy models to send notifications to arbitrary addresses. + +```php +Notification::route('mail', 'admin@example.com')->notify(new SystemAlert()); +``` + +## Implement `HasLocalePreference` on Notifiable Models + +Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. diff --git a/.agents/skills/laravel-best-practices/rules/http-client.md b/.agents/skills/laravel-best-practices/rules/http-client.md new file mode 100644 index 00000000..8e2f16e8 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/http-client.md @@ -0,0 +1,160 @@ +# HTTP Client Best Practices + +## Always Set Explicit Timeouts + +The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users'); +``` + +Correct: +```php +$response = Http::timeout(5) + ->connectTimeout(3) + ->get('https://api.example.com/users'); +``` + +For service-specific clients, define timeouts in a macro: + +```php +Http::macro('github', function () { + return Http::baseUrl('https://api.github.com') + ->timeout(10) + ->connectTimeout(3) + ->withToken(config('services.github.token')); +}); + +$response = Http::github()->get('/repos/laravel/framework'); +``` + +## Use Retry with Backoff for External APIs + +External APIs have transient failures. Use `retry()` with increasing delays. + +Incorrect: +```php +$response = Http::post('https://api.stripe.com/v1/charges', $data); + +if ($response->failed()) { + throw new PaymentFailedException('Charge failed'); +} +``` + +Correct: +```php +$response = Http::retry([100, 500, 1000]) + ->timeout(10) + ->post('https://api.stripe.com/v1/charges', $data); +``` + +Only retry on specific errors: + +```php +$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException && $exception->response->serverError()); +})->post('https://api.example.com/data'); +``` + +## Handle Errors Explicitly + +The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users/1'); +$user = $response->json(); // Could be an error body +``` + +Correct: +```php +$response = Http::timeout(5) + ->get('https://api.example.com/users/1') + ->throw(); + +$user = $response->json(); +``` + +For graceful degradation: + +```php +$response = Http::get('https://api.example.com/users/1'); + +if ($response->successful()) { + return $response->json(); +} + +if ($response->notFound()) { + return null; +} + +$response->throw(); +``` + +## Use Request Pooling for Concurrent Requests + +When making multiple independent API calls, use `Http::pool()` instead of sequential calls. + +Incorrect: +```php +$users = Http::get('https://api.example.com/users')->json(); +$posts = Http::get('https://api.example.com/posts')->json(); +$comments = Http::get('https://api.example.com/comments')->json(); +``` + +Correct: +```php +use Illuminate\Http\Client\Pool; + +$responses = Http::pool(fn (Pool $pool) => [ + $pool->as('users')->get('https://api.example.com/users'), + $pool->as('posts')->get('https://api.example.com/posts'), + $pool->as('comments')->get('https://api.example.com/comments'), +]); + +$users = $responses['users']->json(); +$posts = $responses['posts']->json(); +``` + +## Fake HTTP Calls in Tests + +Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`. + +Incorrect: +```php +it('syncs user from API', function () { + $service = new UserSyncService; + $service->sync(1); // Hits the real API +}); +``` + +Correct: +```php +it('syncs user from API', function () { + Http::preventStrayRequests(); + + Http::fake([ + 'api.example.com/users/1' => Http::response([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]), + ]); + + $service = new UserSyncService; + $service->sync(1); + + Http::assertSent(function (Request $request) { + return $request->url() === 'https://api.example.com/users/1'; + }); +}); +``` + +Test failure scenarios too: + +```php +Http::fake([ + 'api.example.com/*' => Http::failedConnection(), +]); +``` diff --git a/.agents/skills/laravel-best-practices/rules/mail.md b/.agents/skills/laravel-best-practices/rules/mail.md new file mode 100644 index 00000000..7c717336 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/mail.md @@ -0,0 +1,27 @@ +# Mail Best Practices + +## Implement `ShouldQueue` on the Mailable Class + +Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it. + +## Use `afterCommit()` on Mailables Inside Transactions + +A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor. + +## Use `assertQueued()` Not `assertSent()` for Queued Mailables + +`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint. + +Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. + +Correct: `Mail::assertQueued(OrderShipped::class);` + +## Use Markdown Mailables for Transactional Emails + +Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag. + +## Separate Content Tests from Sending Tests + +Content tests: instantiate the mailable directly, call `assertSeeInHtml()`. +Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`. +Don't mix them — it conflates concerns and makes tests brittle. diff --git a/.agents/skills/laravel-best-practices/rules/migrations.md b/.agents/skills/laravel-best-practices/rules/migrations.md new file mode 100644 index 00000000..df6f5f33 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/migrations.md @@ -0,0 +1,121 @@ +# Migration Best Practices + +## Generate Migrations with Artisan + +Always use `php artisan make:migration` for consistent naming and timestamps. + +Incorrect (manually created file): +```php +// database/migrations/posts_migration.php ← wrong naming, no timestamp +``` + +Correct (Artisan-generated): +```bash +php artisan make:migration create_posts_table +php artisan make:migration add_slug_to_posts_table +``` + +## Use `constrained()` for Foreign Keys + +Automatic naming and referential integrity. + +```php +$table->foreignId('user_id')->constrained()->cascadeOnDelete(); + +// Non-standard names +$table->foreignId('author_id')->constrained('users'); +``` + +## Never Modify Deployed Migrations + +Once a migration has run in production, treat it as immutable. Create a new migration to change the table. + +Incorrect (editing a deployed migration): +```php +// 2024_01_01_create_posts_table.php — already in production +$table->string('slug')->unique(); // ← added after deployment +``` + +Correct (new migration to alter): +```php +// 2024_03_15_add_slug_to_posts_table.php +Schema::table('posts', function (Blueprint $table) { + $table->string('slug')->unique()->after('title'); +}); +``` + +## Add Indexes in the Migration + +Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->index(); + $table->string('status')->index(); + $table->timestamp('shipped_at')->nullable()->index(); + $table->timestamps(); +}); +``` + +## Mirror Defaults in Model `$attributes` + +When a column has a database default, mirror it in the model so new instances have correct values before saving. + +```php +// Migration +$table->string('status')->default('pending'); + +// Model +protected $attributes = [ + 'status' => 'pending', +]; +``` + +## Write Reversible `down()` Methods by Default + +Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments. + +```php +public function down(): void +{ + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn('slug'); + }); +} +``` + +For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported. + +## Keep Migrations Focused + +One concern per migration. Never mix DDL (schema changes) and DML (data manipulation). + +Incorrect (partial failure creates unrecoverable state): +```php +public function up(): void +{ + Schema::create('settings', function (Blueprint $table) { ... }); + DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +} +``` + +Correct (separate migrations): +```php +// Migration 1: create_settings_table +Schema::create('settings', function (Blueprint $table) { ... }); + +// Migration 2: seed_default_settings +DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +``` diff --git a/.agents/skills/laravel-best-practices/rules/queue-jobs.md b/.agents/skills/laravel-best-practices/rules/queue-jobs.md new file mode 100644 index 00000000..c41915e2 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/queue-jobs.md @@ -0,0 +1,144 @@ +# Queue & Job Best Practices + +## Set `retry_after` Greater Than `timeout` + +If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution. + +Incorrect (`retry_after` ≤ `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 90 ← job retried while still running! +``` + +Correct (`retry_after` > `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 180 ← safely longer than any job timeout +``` + +## Use Exponential Backoff + +Use progressively longer delays between retries to avoid hammering failing services. + +Incorrect (fixed retry interval): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + // Default: retries immediately, overwhelming the API +} +``` + +Correct (exponential backoff): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + public $backoff = [1, 5, 10]; +} +``` + +## Implement `ShouldBeUnique` + +Prevent duplicate job processing. + +```php +class GenerateInvoice implements ShouldQueue, ShouldBeUnique +{ + public function uniqueId(): string + { + return $this->order->id; + } + + public $uniqueFor = 3600; +} +``` + +## Always Implement `failed()` + +Handle errors explicitly — don't rely on silent failure. + +```php +public function failed(?Throwable $exception): void +{ + $this->podcast->update(['status' => 'failed']); + Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]); +} +``` + +## Rate Limit External API Calls in Jobs + +Use `RateLimited` middleware to throttle jobs calling third-party APIs. + +```php +public function middleware(): array +{ + return [new RateLimited('external-api')]; +} +``` + +## Batch Related Jobs + +Use `Bus::batch()` when jobs should succeed or fail together. + +```php +Bus::batch([ + new ImportCsvChunk($chunk1), + new ImportCsvChunk($chunk2), +]) +->then(fn (Batch $batch) => Notification::send($user, new ImportComplete)) +->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed')) +->dispatch(); +``` + +## `retryUntil()` Needs `$tries = 0` + +When using time-based retry limits, set `$tries = 0` to avoid premature failure. + +```php +public $tries = 0; + +public function retryUntil(): \DateTimeInterface +{ + return now()->addHours(4); +} +``` + +## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release + +`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue. + +```php +class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing +{ + // Lock releases when processing begins, not when it finishes +} +``` + +## Use Horizon for Complex Queue Scenarios + +Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities. + +```php +// config/horizon.php +'environments' => [ + 'production' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['high', 'default', 'low'], + 'balance' => 'auto', + 'minProcesses' => 1, + 'maxProcesses' => 10, + 'tries' => 3, + ], + ], +], +``` diff --git a/.agents/skills/laravel-best-practices/rules/routing.md b/.agents/skills/laravel-best-practices/rules/routing.md new file mode 100644 index 00000000..b6e30864 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/routing.md @@ -0,0 +1,99 @@ +# Routing & Controllers Best Practices + +## Use Implicit Route Model Binding + +Let Laravel resolve models automatically from route parameters. + +Incorrect: +```php +public function show(int $id) +{ + $post = Post::findOrFail($id); +} +``` + +Correct: +```php +public function show(Post $post) +{ + return view('posts.show', ['post' => $post]); +} +``` + +## Use Scoped Bindings for Nested Resources + +Enforce parent-child relationships automatically. + +```php +Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { + // $post is automatically scoped to $user +})->scopeBindings(); +``` + +## Use Resource Controllers + +Use `Route::resource()` or `apiResource()` for RESTful endpoints. + +```php +Route::resource('posts', PostController::class); +// In routes/api.php — the /api prefix is applied automatically +Route::apiResource('posts', Api\PostController::class); +``` + +## Keep Controllers Thin + +Aim for under 10 lines per method. Extract business logic to action or service classes. + +Incorrect: +```php +public function store(Request $request) +{ + $validated = $request->validate([...]); + if ($request->hasFile('image')) { + $request->file('image')->move(public_path('images')); + } + $post = Post::create($validated); + $post->tags()->sync($validated['tags']); + event(new PostCreated($post)); + return redirect()->route('posts.show', $post); +} +``` + +Correct: +```php +public function store(StorePostRequest $request, CreatePostAction $create) +{ + $post = $create->execute($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +## Type-Hint Form Requests + +Type-hinting Form Requests triggers automatic validation and authorization before the method executes. + +Incorrect: +```php +public function store(Request $request): RedirectResponse +{ + $validated = $request->validate([ + 'title' => ['required', 'max:255'], + 'body' => ['required'], + ]); + + Post::create($validated); + + return redirect()->route('posts.index'); +} +``` + +Correct: +```php +public function store(StorePostRequest $request): RedirectResponse +{ + Post::create($request->validated()); + + return redirect()->route('posts.index'); +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/scheduling.md b/.agents/skills/laravel-best-practices/rules/scheduling.md new file mode 100644 index 00000000..a9847945 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/scheduling.md @@ -0,0 +1,39 @@ +# Task Scheduling Best Practices + +## Use `withoutOverlapping()` on Variable-Duration Tasks + +Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion. + +## Use `onOneServer()` on Multi-Server Deployments + +Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached). + +## Use `runInBackground()` for Concurrent Long Tasks + +By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes. + +## Use `environments()` to Restrict Tasks + +Prevent accidental execution of production-only tasks (billing, reporting) on staging. + +```php +Schedule::command('billing:charge')->monthly()->environments(['production']); +``` + +## Use `takeUntilTimeout()` for Time-Bounded Processing + +A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time. + +## Use Schedule Groups for Shared Configuration + +Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks. + +```php +Schedule::daily() + ->onOneServer() + ->timezone('America/New_York') + ->group(function () { + Schedule::command('emails:send --force'); + Schedule::command('emails:prune'); + }); +``` diff --git a/.agents/skills/laravel-best-practices/rules/security.md b/.agents/skills/laravel-best-practices/rules/security.md new file mode 100644 index 00000000..2d7200c2 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/security.md @@ -0,0 +1,198 @@ +# Security Best Practices + +## Mass Assignment Protection + +Every model must define `$fillable` (whitelist) or `$guarded` (blacklist). + +Incorrect: +```php +class User extends Model +{ + protected $guarded = []; // All fields are mass assignable +} +``` + +Correct: +```php +class User extends Model +{ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; +} +``` + +Never use `$guarded = []` on models that accept user input. + +## Authorize Every Action + +Use policies or gates in controllers. Never skip authorization. + +Incorrect: +```php +public function update(UpdatePostRequest $request, Post $post) +{ + $post->update($request->validated()); +} +``` + +Correct: +```php +public function update(UpdatePostRequest $request, Post $post) +{ + Gate::authorize('update', $post); + + $post->update($request->validated()); +} +``` + +Or via Form Request: + +```php +public function authorize(): bool +{ + return $this->user()->can('update', $this->route('post')); +} +``` + +## Prevent SQL Injection + +Always use parameter binding. Never interpolate user input into queries. + +Incorrect: +```php +DB::select("SELECT * FROM users WHERE name = '{$request->name}'"); +``` + +Correct: +```php +User::where('name', $request->name)->get(); + +// Raw expressions with bindings +User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get(); +``` + +## Escape Output to Prevent XSS + +Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content. + +Incorrect: +```blade +{!! $user->bio !!} +``` + +Correct: +```blade +{{ $user->bio }} +``` + +## CSRF Protection + +Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied. + +Incorrect: +```blade +
+ +
+``` + +Correct: +```blade +
+ @csrf + +
+``` + +## Rate Limit Auth and API Routes + +Apply `throttle` middleware to authentication and API routes. + +```php +RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by($request->ip()); +}); + +Route::post('/login', LoginController::class)->middleware('throttle:login'); +``` + +## Validate File Uploads + +Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames. + +```php +public function rules(): array +{ + return [ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + ]; +} +``` + +Store with generated filenames: + +```php +$path = $request->file('avatar')->store('avatars', 'public'); +``` + +## Keep Secrets Out of Code + +Never commit `.env`. Access secrets via `config()` only. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'api_key' => env('API_KEY'), + +// In application code +$key = config('services.api_key'); +``` + +## Audit Dependencies + +Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment. + +```bash +composer audit +``` + +## Encrypt Sensitive Database Fields + +Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`. + +Incorrect: +```php +class Integration extends Model +{ + protected function casts(): array + { + return [ + 'api_key' => 'string', + ]; + } +} +``` + +Correct: +```php +class Integration extends Model +{ + protected $hidden = ['api_key', 'api_secret']; + + protected function casts(): array + { + return [ + 'api_key' => 'encrypted', + 'api_secret' => 'encrypted', + ]; + } +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/style.md b/.agents/skills/laravel-best-practices/rules/style.md new file mode 100644 index 00000000..a8afb369 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/style.md @@ -0,0 +1,125 @@ +# Conventions & Style + +## Follow Laravel Naming Conventions + +| What | Convention | Good | Bad | +|------|-----------|------|-----| +| Controller | singular | `ArticleController` | `ArticlesController` | +| Model | singular | `User` | `Users` | +| Table | plural, snake_case | `article_comments` | `articleComments` | +| Pivot table | singular alphabetical | `article_user` | `user_article` | +| Column | snake_case, no model name | `meta_title` | `article_meta_title` | +| Foreign key | singular model + `_id` | `article_id` | `articles_id` | +| Route | plural | `articles/1` | `article/1` | +| Route name | snake_case with dots | `users.show_active` | `users.show-active` | +| Method | camelCase | `getAll` | `get_all` | +| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` | +| Collection | descriptive, plural | `$activeUsers` | `$data` | +| Object | descriptive, singular | `$activeUser` | `$users` | +| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` | +| Config | snake_case | `google_calendar.php` | `googleCalendar.php` | +| Enum | singular | `UserType` | `UserTypes` | + +## Prefer Shorter Readable Syntax + +| Verbose | Shorter | +|---------|---------| +| `Session::get('cart')` | `session('cart')` | +| `$request->session()->get('cart')` | `session('cart')` | +| `$request->input('name')` | `$request->name` | +| `return Redirect::back()` | `return back()` | +| `Carbon::now()` | `now()` | +| `App::make('Class')` | `app('Class')` | +| `->where('column', '=', 1)` | `->where('column', 1)` | +| `->orderBy('created_at', 'desc')` | `->latest()` | +| `->orderBy('created_at', 'asc')` | `->oldest()` | +| `->first()->name` | `->value('name')` | + +## Use Laravel String & Array Helpers + +Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them. + +Strings — use `Str` and fluent `Str::of()` over raw PHP: +```php +// Incorrect +$slug = strtolower(str_replace(' ', '-', $title)); +$short = substr($text, 0, 100) . '...'; +$class = substr(strrchr('App\Models\User', '\\'), 1); + +// Correct +$slug = Str::slug($title); +$short = Str::limit($text, 100); +$class = class_basename('App\Models\User'); +``` + +Fluent strings — chain operations for complex transformations: +```php +// Incorrect +$result = strtolower(trim(str_replace('_', '-', $input))); + +// Correct +$result = Str::of($input)->trim()->replace('_', '-')->lower(); +``` + +Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`. + +Arrays — use `Arr` over raw PHP: +```php +// Incorrect +$name = isset($array['user']['name']) ? $array['user']['name'] : 'default'; + +// Correct +$name = Arr::get($array, 'user.name', 'default'); +``` + +Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`. + +Numbers — use `Number` for display formatting: +```php +Number::format(1000000); // "1,000,000" +Number::currency(1500, 'USD'); // "$1,500.00" +Number::abbreviate(1000000); // "1M" +Number::fileSize(1024 * 1024); // "1 MB" +Number::percentage(75.5); // "75.5%" +``` + +URIs — use `Uri` for URL manipulation: +```php +$uri = Uri::of('https://example.com/search') + ->withQuery(['q' => 'laravel', 'page' => 1]); +``` + +Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining. + +Use `search-docs` for the full list of available methods — these helpers are extensive. + +## No Inline JS/CSS in Blade + +Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes. + +Incorrect: +```blade +let article = `{{ json_encode($article) }}`; +``` + +Correct: +```blade + +``` + +Pass data to JS via data attributes or use a dedicated PHP-to-JS package. + +## No Unnecessary Comments + +Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected. + +Incorrect: +```php +// Check if there are any joins +if (count((array) $builder->getQuery()->joins) > 0) +``` + +Correct: +```php +if ($this->hasJoins()) +``` diff --git a/.agents/skills/laravel-best-practices/rules/testing.md b/.agents/skills/laravel-best-practices/rules/testing.md new file mode 100644 index 00000000..4fbf12f8 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/testing.md @@ -0,0 +1,43 @@ +# Testing Best Practices + +## Use `LazilyRefreshDatabase` Over `RefreshDatabase` + +`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date. + +## Use Model Assertions Over Raw Database Assertions + +Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);` + +Correct: `$this->assertModelExists($user);` + +More expressive, type-safe, and fails with clearer messages. + +## Use Factory States and Sequences + +Named states make tests self-documenting. Sequences eliminate repetitive setup. + +Incorrect: `User::factory()->create(['email_verified_at' => null]);` + +Correct: `User::factory()->unverified()->create();` + +## Use `Exceptions::fake()` to Assert Exception Reporting + +Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally. + +## Call `Event::fake()` After Factory Setup + +Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models. + +Incorrect: `Event::fake(); $user = User::factory()->create();` + +Correct: `$user = User::factory()->create(); Event::fake();` + +## Use `recycle()` to Share Relationship Instances Across Factories + +Without `recycle()`, nested factories create separate instances of the same conceptual entity. + +```php +Ticket::factory() + ->recycle(Airline::factory()->create()) + ->create(); +``` diff --git a/.agents/skills/laravel-best-practices/rules/validation.md b/.agents/skills/laravel-best-practices/rules/validation.md new file mode 100644 index 00000000..5fde1064 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/validation.md @@ -0,0 +1,75 @@ +# Validation & Forms Best Practices + +## Use Form Request Classes + +Extract validation from controllers into dedicated Form Request classes. + +Incorrect: +```php +public function store(Request $request) +{ + $request->validate([ + 'title' => 'required|max:255', + 'body' => 'required', + ]); +} +``` + +Correct: +```php +public function store(StorePostRequest $request) +{ + Post::create($request->validated()); +} +``` + +## Array vs. String Notation for Rules + +Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses. + +```php +// Preferred for new code +'email' => ['required', 'email', Rule::unique('users')], + +// Follow existing convention if the project uses string notation +'email' => 'required|email|unique:users', +``` + +## Always Use `validated()` + +Get only validated data. Never use `$request->all()` for mass operations. + +Incorrect: +```php +Post::create($request->all()); +``` + +Correct: +```php +Post::create($request->validated()); +``` + +## Use `Rule::when()` for Conditional Validation + +```php +'company_name' => [ + Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']), +], +``` + +## Use the `after()` Method for Custom Validation + +Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields. + +```php +public function after(): array +{ + return [ + function (Validator $validator) { + if ($this->quantity > Product::find($this->product_id)?->stock) { + $validator->errors()->add('quantity', 'Not enough stock.'); + } + }, + ]; +} +``` diff --git a/.agents/skills/livewire-development/SKILL.md b/.agents/skills/livewire-development/SKILL.md new file mode 100644 index 00000000..0ae356e5 --- /dev/null +++ b/.agents/skills/livewire-development/SKILL.md @@ -0,0 +1,175 @@ +--- +name: livewire-development +description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire." +license: MIT +metadata: + author: laravel +--- + +# Livewire Development + +## Documentation + +Use `search-docs` for detailed Livewire 4 patterns and documentation. + +## Basic Usage + +### Creating Components + +```bash + +# Single-file component (SFC - default in v4) + +# Creates: resources/views/components/⚡create-post.blade.php + +php artisan make:livewire create-post + +# Page component (SFC - Full Page in v4) + +# Creates: resources/views/pages/⚡create-post.blade.php + +php artisan make:livewire pages::create-post + +# Multi-file component (MFC) + +# Creates: resources/views/components/⚡create-post/create-post.php + +# resources/views/components/⚡create-post/create-post.blade.php + +php artisan make:livewire create-post --mfc + +# Class-based component (v3 style) + +# Creates: app/Livewire/CreatePost.php AND resources/views/livewire/create-post.blade.php + +php artisan make:livewire create-post --class + +# With namespace + +php artisan make:livewire Posts/CreatePost +``` + +### Converting Between Formats + +Use `php artisan livewire:convert create-post` to convert between single-file, multi-file, and class-based formats. + +### Choosing a Component Format + +> **Always follow the project's existing conventions first.** Before creating any component, inspect the project's existing Livewire components to determine the established format (SFC, MFC, or class-based) and directory structure. Check `app/Livewire/`, `resources/views/components/`, and `resources/views/livewire/` for existing components. If the project already uses a consistent format, **use that same format** — even if it differs from the Livewire v4 defaults below. Only fall back to the v4 defaults (SFC in `resources/views/components/`) when no existing convention is established. + +Also check `config/livewire.php` for `make_command.type`, `make_command.emoji`, `component_locations`, and `component_namespaces` overrides, which change the default format and where files are stored. + +### Component Format Reference + +| Format | Flag | Class Path | View Path | +|--------|------|------------|-----------| +| Single-file (SFC) | default | — | `resources/views/components/⚡create-post.blade.php` (PHP + Blade in one file) | +| Full Page SFC | `pages::name` | — | `resources/views/pages/⚡create-post.blade.php` | +| Multi-file (MFC) | `--mfc` | `resources/views/components/⚡create-post/create-post.php` | `resources/views/components/⚡create-post/create-post.blade.php` | +| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` | +| View-based | default (Blade-only) | — | `resources/views/components/⚡create-post.blade.php` (Blade-only with functional state) | + +> **Important:** The ⚡ prefix shown above is the **default** behavior in Livewire v4 — it is **configurable**. Check `config/livewire.php` for the `make_command.emoji` setting. When `true` (default), always include the ⚡ prefix in filenames you create. When `false`, omit the ⚡ prefix from all paths above. + +Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates `resources/views/components/posts/⚡create-post.blade.php` (single-file by default). Use `make:livewire Posts/CreatePost --mfc` for multi-file output at `resources/views/components/posts/⚡create-post/create-post.php` and `resources/views/components/posts/⚡create-post/create-post.blade.php`. + +### Single-File Component Example + + +```php +count++; + } +}; +?> + +
+ +
+``` + +## Livewire 4 Specifics + +### Key Changes From Livewire 3 + +These things changed in Livewire 4, but may not have been updated in this application. Verify this application's setup to ensure you follow existing conventions. + +- Use `Route::livewire()` for full-page components (e.g., `Route::livewire('/posts/create', CreatePost::class)`); config keys renamed: `layout` → `component_layout`, `lazy_placeholder` → `component_placeholder`. +- `wire:model` now ignores child events by default (use `wire:model.deep` for old behavior); `wire:scroll` renamed to `wire:navigate:scroll`. +- Component tags must be properly closed; `wire:transition` now uses View Transitions API (modifiers removed). +- JavaScript: `$wire.$js('name', fn)` → `$wire.$js.name = fn`; `commit`/`request` hooks → `interceptMessage()`/`interceptRequest()`. + +### New Features + +- Component formats: single-file (SFC), multi-file (MFC), view-based components. +- Islands (`@island`) for isolated updates; async actions (`wire:click.async`, `#[Async]`) for parallel execution. +- Deferred/bundled loading: `defer`, `lazy.bundle` for optimized component loading. + +| Feature | Usage | Purpose | +|---------|-------|---------| +| Islands | `@island(name: 'stats')` | Isolated update regions | +| Async | `wire:click.async` or `#[Async]` | Non-blocking actions | +| Deferred | `defer` attribute | Load after page render | +| Bundled | `lazy.bundle` | Load multiple together | + +### New Directives + +- `wire:sort`, `wire:intersect`, `wire:ref`, `.renderless`, `.preserve-scroll` are available for use. +- `data-loading` attribute automatically added to elements triggering network requests. + +| Directive | Purpose | +|-----------|---------| +| `wire:sort` | Drag-and-drop sorting | +| `wire:intersect` | Viewport intersection detection | +| `wire:ref` | Element references for JS | +| `.renderless` | Component without rendering | +| `.preserve-scroll` | Preserve scroll position | + +## Best Practices + +- Always use `wire:key` in loops +- Use `wire:loading` for loading states +- Use `wire:model.live` for instant updates (default is debounced) +- Validate and authorize in actions (treat like HTTP requests) + +## Configuration + +- `smart_wire_keys` defaults to `true`; new configs: `component_locations`, `component_namespaces`, `make_command`, `csp_safe`. + +## Alpine & JavaScript + +- `wire:transition` uses browser View Transitions API; `$errors` and `$intercept` magic properties available. +- Non-blocking `wire:poll` and parallel `wire:model.live` updates improve performance. + +For interceptors and hooks, see [reference/javascript-hooks.md](reference/javascript-hooks.md). + +## Testing + + +```php +Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1); +``` + +## Verification + +1. Browser console: Check for JS errors +2. Network tab: Verify Livewire requests return 200 +3. Ensure `wire:key` on all `@foreach` loops + +## Common Pitfalls + +- Missing `wire:key` in loops → unexpected re-rendering +- Expecting `wire:model` real-time → use `wire:model.live` +- Unclosed component tags → syntax errors in v4 +- Using deprecated config keys or JS hooks +- Including Alpine.js separately (already bundled in Livewire 4) diff --git a/.agents/skills/livewire-development/reference/javascript-hooks.md b/.agents/skills/livewire-development/reference/javascript-hooks.md new file mode 100644 index 00000000..660d66b5 --- /dev/null +++ b/.agents/skills/livewire-development/reference/javascript-hooks.md @@ -0,0 +1,39 @@ +# Livewire 4 JavaScript Integration + +## Interceptor System (v4) + +### Intercept Messages + +```js +Livewire.interceptMessage(({ component, message, onFinish, onSuccess, onError }) => { + onFinish(() => { /* After response, before processing */ }); + onSuccess(({ payload }) => { /* payload.snapshot, payload.effects */ }); + onError(() => { /* Server errors */ }); +}); +``` + +### Intercept Requests + +```js +Livewire.interceptRequest(({ request, onResponse, onSuccess, onError, onFailure }) => { + onResponse(({ response }) => { /* When received */ }); + onSuccess(({ response, responseJson }) => { /* Success */ }); + onError(({ response, responseBody, preventDefault }) => { /* 4xx/5xx */ }); + onFailure(({ error }) => { /* Network failures */ }); +}); +``` + +### Component-Scoped Interceptors + +```blade + +``` + +## Magic Properties + +- `$errors` - Access validation errors from JavaScript +- `$intercept` - Component-scoped interceptors diff --git a/.agents/skills/pest-testing/SKILL.md b/.agents/skills/pest-testing/SKILL.md new file mode 100644 index 00000000..ab271616 --- /dev/null +++ b/.agents/skills/pest-testing/SKILL.md @@ -0,0 +1,166 @@ +--- +name: pest-testing +description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code." +license: MIT +metadata: + author: laravel +--- + +# Pest Testing 4 + +## Documentation + +Use `search-docs` for detailed Pest 4 patterns and documentation. + +## Basic Usage + +### Creating Tests + +All tests must be written using Pest. Use `php artisan make:test --pest {name}`. + +The `{name}` argument should include only the path and test name, but should not include the test suite. +- Incorrect: `php artisan make:test --pest Feature/SomeFeatureTest` will generate `tests/Feature/Feature/SomeFeatureTest.php` +- Correct: `php artisan make:test --pest SomeControllerTest` will generate `tests/Feature/SomeControllerTest.php` +- Incorrect: `php artisan make:test --pest --unit Unit/SomeServiceTest` will generate `tests/Unit/Unit/SomeServiceTest.php` +- Correct: `php artisan make:test --pest --unit SomeServiceTest` will generate `tests/Unit/SomeServiceTest.php` + +### Test Organization + +- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. +- Browser tests: `tests/Browser/` directory. +- Do NOT remove tests without approval - these are core application code. + +### Basic Test Structure + +Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`. + + +```php +it('is true', function () { + expect(true)->toBeTrue(); +}); +``` + +### Running Tests + +- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`. +- Run all tests: `php artisan test --compact`. +- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`. + +## Assertions + +Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: + + +```php +it('returns all', function () { + $this->postJson('/api/docs', [])->assertSuccessful(); +}); +``` + +| Use | Instead of | +|-----|------------| +| `assertSuccessful()` | `assertStatus(200)` | +| `assertNotFound()` | `assertStatus(404)` | +| `assertForbidden()` | `assertStatus(403)` | + +## Mocking + +Import mock function before use: `use function Pest\Laravel\mock;` + +## Datasets + +Use datasets for repetitive tests (validation rules, etc.): + + +```php +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); +``` + +## Pest 4 Features + +| Feature | Purpose | +|---------|---------| +| Browser Testing | Full integration tests in real browsers | +| Smoke Testing | Validate multiple pages quickly | +| Visual Regression | Compare screenshots for visual changes | +| Test Sharding | Parallel CI runs | +| Architecture Testing | Enforce code conventions | + +### Browser Test Example + +Browser tests run in real browsers for full integration testing: + +- Browser tests live in `tests/Browser/`. +- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories. +- Use `RefreshDatabase` for clean state per test. +- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures. +- Test on multiple browsers (Chrome, Firefox, Safari) if requested. +- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested. +- Switch color schemes (light/dark mode) when appropriate. +- Take screenshots or pause tests for debugging. + + +```php +it('may reset the password', function () { + Notification::fake(); + + $this->actingAs(User::factory()->create()); + + $page = visit('/sign-in'); + + $page->assertSee('Sign In') + ->assertNoJavaScriptErrors() + ->click('Forgot Password?') + ->fill('email', 'nuno@laravel.com') + ->click('Send Reset Link') + ->assertSee('We have emailed your password reset link!'); + + Notification::assertSent(ResetPassword::class); +}); +``` + +### Smoke Testing + +Quickly validate multiple pages have no JavaScript errors: + + +```php +$pages = visit(['/', '/about', '/contact']); + +$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs(); +``` + +### Visual Regression Testing + +Capture and compare screenshots to detect visual changes. + +### Test Sharding + +Split tests across parallel processes for faster CI runs. + +### Architecture Testing + +Pest 4 includes architecture testing (from Pest 3): + + +```php +arch('controllers') + ->expect('App\Http\Controllers') + ->toExtendNothing() + ->toHaveSuffix('Controller'); +``` + +## Common Pitfalls + +- Not importing `use function Pest\Laravel\mock;` before using mock +- Using `assertStatus(200)` instead of `assertSuccessful()` +- Forgetting datasets for repetitive validation tests +- Deleting tests without approval +- Forgetting `assertNoJavaScriptErrors()` in browser tests +- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test` diff --git a/.agents/skills/tailwindcss-development/SKILL.md b/.agents/skills/tailwindcss-development/SKILL.md new file mode 100644 index 00000000..c0cb2fbc --- /dev/null +++ b/.agents/skills/tailwindcss-development/SKILL.md @@ -0,0 +1,119 @@ +--- +name: tailwindcss-development +description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS." +license: MIT +metadata: + author: laravel +--- + +# Tailwind CSS Development + +## Documentation + +Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. + +## Basic Usage + +- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. +- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). +- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. + +## Tailwind CSS v4 Specifics + +- Always use Tailwind CSS v4 and avoid deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. + +### CSS-First Configuration + +In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: + + +```css +@theme { + --color-brand: oklch(0.72 0.11 178); +} +``` + +### Import Syntax + +In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: + + +```diff +- @tailwind base; +- @tailwind components; +- @tailwind utilities; ++ @import "tailwindcss"; +``` + +### Replaced Utilities + +Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. + +| Deprecated | Replacement | +|------------|-------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + +## Spacing + +Use `gap` utilities instead of margins for spacing between siblings: + + +```html +
+
Item 1
+
Item 2
+
+``` + +## Dark Mode + +If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: + + +```html +
+ Content adapts to color scheme +
+``` + +## Common Patterns + +### Flexbox Layout + + +```html +
+
Left content
+
Right content
+
+``` + +### Grid Layout + + +```html +
+
Card 1
+
Card 2
+
Card 3
+
+``` + +## Common Pitfalls + +- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.) +- Using `@tailwind` directives instead of `@import "tailwindcss"` +- Trying to use `tailwind.config.js` instead of CSS `@theme` directive +- Using margins for spacing between siblings instead of gap utilities +- Forgetting to add dark mode variants when the project uses dark mode diff --git a/.claude/skills/developing-with-fortify/SKILL.md b/.claude/skills/developing-with-fortify/SKILL.md new file mode 100644 index 00000000..db3558bc --- /dev/null +++ b/.claude/skills/developing-with-fortify/SKILL.md @@ -0,0 +1,116 @@ +--- +name: developing-with-fortify +description: Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications. +--- + +# Laravel Fortify Development + +Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications. + +## Documentation + +Use `search-docs` for detailed Laravel Fortify patterns and documentation. + +## Usage + +- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints +- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.) +- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field +- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.) +- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc. + +## Available Features + +Enable in `config/fortify.php` features array: + +- `Features::registration()` - User registration +- `Features::resetPasswords()` - Password reset via email +- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail` +- `Features::updateProfileInformation()` - Profile updates +- `Features::updatePasswords()` - Password changes +- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes + +> Use `search-docs` for feature configuration options and customization patterns. + +## Setup Workflows + +### Two-Factor Authentication Setup + +``` +- [ ] Add TwoFactorAuthenticatable trait to User model +- [ ] Enable feature in config/fortify.php +- [ ] Run migrations for 2FA columns +- [ ] Set up view callbacks in FortifyServiceProvider +- [ ] Create 2FA management UI +- [ ] Test QR code and recovery codes +``` + +> Use `search-docs` for TOTP implementation and recovery code handling patterns. + +### Email Verification Setup + +``` +- [ ] Enable emailVerification feature in config +- [ ] Implement MustVerifyEmail interface on User model +- [ ] Set up verifyEmailView callback +- [ ] Add verified middleware to protected routes +- [ ] Test verification email flow +``` + +> Use `search-docs` for MustVerifyEmail implementation patterns. + +### Password Reset Setup + +``` +- [ ] Enable resetPasswords feature in config +- [ ] Set up requestPasswordResetLinkView callback +- [ ] Set up resetPasswordView callback +- [ ] Define password.reset named route (if views disabled) +- [ ] Test reset email and link flow +``` + +> Use `search-docs` for custom password reset flow patterns. + +### SPA Authentication Setup + +``` +- [ ] Set 'views' => false in config/fortify.php +- [ ] Install and configure Laravel Sanctum +- [ ] Use 'web' guard in fortify config +- [ ] Set up CSRF token handling +- [ ] Test XHR authentication flows +``` + +> Use `search-docs` for integration and SPA authentication patterns. + +## Best Practices + +### Custom Authentication Logic + +Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects. + +### Registration Customization + +Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields. + +### Rate Limiting + +Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination. + +## Key Endpoints + +| Feature | Method | Endpoint | +|------------------------|----------|---------------------------------------------| +| Login | POST | `/login` | +| Logout | POST | `/logout` | +| Register | POST | `/register` | +| Password Reset Request | POST | `/forgot-password` | +| Password Reset | POST | `/reset-password` | +| Email Verify Notice | GET | `/email/verify` | +| Resend Verification | POST | `/email/verification-notification` | +| Password Confirm | POST | `/user/confirm-password` | +| Enable 2FA | POST | `/user/two-factor-authentication` | +| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` | +| 2FA Challenge | POST | `/two-factor-challenge` | +| Get QR Code | GET | `/user/two-factor-qr-code` | +| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` | diff --git a/.claude/skills/fluxui-development/SKILL.md b/.claude/skills/fluxui-development/SKILL.md new file mode 100644 index 00000000..d4fb5a03 --- /dev/null +++ b/.claude/skills/fluxui-development/SKILL.md @@ -0,0 +1,81 @@ +--- +name: fluxui-development +description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling." +license: MIT +metadata: + author: laravel +--- + +# Flux UI Development + +## Documentation + +Use `search-docs` for detailed Flux UI patterns and documentation. + +## Basic Usage + +This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components. + +Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize. + +Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs. + + +```blade +Click me +``` + +## Available Components (Free Edition) + +Available: avatar, badge, brand, breadcrumbs, button, callout, card, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, pagination, profile, progress, radio, select, separator, skeleton, switch, table, text, textarea, toast, tooltip + +## Icons + +Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names. + + +```blade +Export +``` + +For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command: + +```bash +php artisan flux:icon crown grip-vertical github +``` + +## Common Patterns + +### Form Fields + + +```blade + + Email + + + +``` + +### Modals + + +```blade + + Title +

Content

+
+``` + +## Verification + +1. Check component renders correctly +2. Test interactive states +3. Verify mobile responsiveness + +## Common Pitfalls + +- Trying to use Pro-only components in the free edition +- Not checking if a Flux component exists before creating custom implementations +- Forgetting to use the `search-docs` tool for component-specific documentation +- Not following existing project patterns for Flux usage diff --git a/.claude/skills/laravel-best-practices/SKILL.md b/.claude/skills/laravel-best-practices/SKILL.md new file mode 100644 index 00000000..d136d755 --- /dev/null +++ b/.claude/skills/laravel-best-practices/SKILL.md @@ -0,0 +1,59 @@ +--- +name: laravel-best-practices +description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns." +license: MIT +metadata: + author: laravel +--- + +# Laravel Best Practices + +Best practices for Laravel, organized as an index of rule files. Each rule file teaches what to do and why. For exact API syntax, verify with `search-docs`. + +## Consistency First + +Before applying any rule, check what the application already does. Laravel offers multiple valid approaches, and the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. + +Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it. Don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides. + +## How to Apply + +1. Check the changed files, nearby code, project configuration, and relevant tests for established patterns. Deviate only for a correctness or security defect, and call the deviation out. +2. Map every affected concern to the rule index below. Read each mapped rule file before editing. Skip unrelated rule files. +3. Make the smallest coherent change. Keep the application's architecture and naming instead of introducing a second pattern for the same job. +4. Verify version-sensitive Laravel APIs for the installed version with `search-docs`, or inspect the installed framework when it is unavailable. +5. Run the narrowest relevant tests first, then the project's formatting and static-analysis checks when the change warrants them. +6. Re-read the diff against every mapped rule before finishing. + +## Rule Index + +Cross-cutting changes often need more than one rule file. + +| Concern | Read | +| --- | --- | +| Query count, eager loading, indexes, large datasets | [`rules/db-performance.md`](rules/db-performance.md) | +| Subqueries, aggregates, complex ordering and query plans | [`rules/advanced-queries.md`](rules/advanced-queries.md) | +| Models, relationships, scopes, casts | [`rules/eloquent.md`](rules/eloquent.md) | +| Authentication, authorization, input safety, secrets, uploads | [`rules/security.md`](rules/security.md) | +| Form Requests and validation rules | [`rules/validation.md`](rules/validation.md) | +| Controllers, route binding, resources, middleware | [`rules/routing.md`](rules/routing.md) | +| Schema changes, columns, foreign keys, indexes | [`rules/migrations.md`](rules/migrations.md) | +| Jobs, retries, uniqueness, batches, Horizon | [`rules/queue-jobs.md`](rules/queue-jobs.md) | +| Cache lifetime, invalidation, locks, memoization | [`rules/caching.md`](rules/caching.md) | +| Outbound requests, retries, timeouts, fakes | [`rules/http-client.md`](rules/http-client.md) | +| Exceptions, reporting, rendering, log context | [`rules/error-handling.md`](rules/error-handling.md) | +| Events and notifications | [`rules/events-notifications.md`](rules/events-notifications.md) | +| Mailables and mail assertions | [`rules/mail.md`](rules/mail.md) | +| Scheduled tasks and overlap protection | [`rules/scheduling.md`](rules/scheduling.md) | +| Collections, lazy iteration, bulk operations | [`rules/collections.md`](rules/collections.md) | +| Blade components, attributes, composers | [`rules/blade-views.md`](rules/blade-views.md) | +| Environment values and application configuration | [`rules/config.md`](rules/config.md) | +| Pest/PHPUnit patterns, factories, fakes | [`rules/testing.md`](rules/testing.md) | +| Naming, helpers, file boundaries, PHP style | [`rules/style.md`](rules/style.md) | +| Actions, services, dependencies, application structure | [`rules/architecture.md`](rules/architecture.md) | + +## Decision Rules + +- Prefer framework features and existing application abstractions over new helpers or dependencies. +- Avoid speculative abstractions. Extract code when it creates a clear domain boundary, removes meaningful duplication, or makes behavior independently testable. +- Keep database access out of Blade views and prevent hidden N+1 queries across controllers, resources, jobs, and serialization. diff --git a/.claude/skills/laravel-best-practices/rules/advanced-queries.md b/.claude/skills/laravel-best-practices/rules/advanced-queries.md new file mode 100644 index 00000000..f12876e4 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/advanced-queries.md @@ -0,0 +1,106 @@ +# Advanced Query Patterns + +## Use `addSelect()` Subqueries for Single Values from Has-Many + +Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries. + +```php +public function scopeWithLastLoginAt($query): void +{ + $query->addSelect([ + 'last_login_at' => Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->withCasts(['last_login_at' => 'datetime']); +} +``` + +## Create Dynamic Relationships via Subquery FK + +Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection. + +```php +public function lastLogin(): BelongsTo +{ + return $this->belongsTo(Login::class); +} + +public function scopeWithLastLogin($query): void +{ + $query->addSelect([ + 'last_login_id' => Login::select('id') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->with('lastLogin'); +} +``` + +## Use Conditional Aggregates Instead of Multiple Count Queries + +Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values. + +```php +$statuses = Feature::toBase() + ->selectRaw("count(case when status = 'Requested' then 1 end) as requested") + ->selectRaw("count(case when status = 'Planned' then 1 end) as planned") + ->selectRaw("count(case when status = 'Completed' then 1 end) as completed") + ->first(); +``` + +## Use `setRelation()` to Prevent Circular N+1 + +When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries. + +```php +$feature->load('comments.user'); +$feature->comments->each->setRelation('feature', $feature); +``` + +## Prefer `whereIn` + Subquery Over `whereHas` + +`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory. + +Incorrect (correlated EXISTS re-executes per row): + +```php +$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term)); +``` + +Correct (index-friendly subquery, no PHP memory overhead): + +```php +$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id')); +``` + +## Sometimes Two Simple Queries Beat One Complex Query + +Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index. + +## Use Compound Indexes Matching `orderBy` Column Order + +When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index. + +```php +// Migration +$table->index(['last_name', 'first_name']); + +// Query — column order must match the index +User::query()->orderBy('last_name')->orderBy('first_name')->paginate(); +``` + +## Use Correlated Subqueries for Has-Many Ordering + +When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading. + +```php +public function scopeOrderByLastLogin($query): void +{ + $query->orderByDesc(Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1) + ); +} +``` diff --git a/.claude/skills/laravel-best-practices/rules/architecture.md b/.claude/skills/laravel-best-practices/rules/architecture.md new file mode 100644 index 00000000..138d5a48 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/architecture.md @@ -0,0 +1,202 @@ +# Architecture Best Practices + +## Single-Purpose Action Classes + +Extract discrete business operations into invokable Action classes. + +```php +class CreateOrderAction +{ + public function __construct(private InventoryService $inventory) {} + + public function handle(array $data): Order + { + $order = Order::create($data); + $this->inventory->reserve($order); + + return $order; + } +} +``` + +## Use Dependency Injection + +Always use constructor injection. Avoid `app()` or `resolve()` inside classes. + +Incorrect: +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request) + { + $service = app(OrderService::class); + + return $service->create($request->validated()); + } +} +``` + +Correct: +```php +class OrderController extends Controller +{ + public function __construct(private OrderService $service) {} + + public function store(StoreOrderRequest $request) + { + return $this->service->create($request->validated()); + } +} +``` + +## Code to Interfaces + +Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability. + +Incorrect (concrete dependency): +```php +class OrderService +{ + public function __construct(private StripeGateway $gateway) {} +} +``` + +Correct (interface dependency): +```php +interface PaymentGateway +{ + public function charge(int $amount, string $customerId): PaymentResult; +} + +class OrderService +{ + public function __construct(private PaymentGateway $gateway) {} +} +``` + +Bind in a service provider: + +```php +$this->app->bind(PaymentGateway::class, StripeGateway::class); +``` + +## Default Sort by Descending + +When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined. + +Incorrect: +```php +$posts = Post::paginate(); +``` + +Correct: +```php +$posts = Post::latest()->paginate(); +``` + +## Use Atomic Locks for Race Conditions + +Prevent race conditions with `Cache::lock()` or `lockForUpdate()`. + +```php +Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) { + $order->process(); +}); + +// Or at query level +$product = Product::where('id', $id)->lockForUpdate()->first(); +``` + +## Use `mb_*` String Functions + +When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters. + +Incorrect: +```php +strlen('José'); // 5 (bytes, not characters) +strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte +``` + +Correct: +```php +mb_strlen('José'); // 4 (characters) +mb_strtolower('MÜNCHEN'); // 'münchen' + +// Prefer Laravel's Str helpers when available +Str::length('José'); // 4 +Str::lower('MÜNCHEN'); // 'münchen' +``` + +## Use `defer()` for Post-Response Work + +For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead. + +Incorrect (job overhead for trivial work): +```php +dispatch(new LogPageView($page)); +``` + +Correct (runs after response, same process): +```php +defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()])); +``` + +Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work. + +## Use `Context` for Request-Scoped Data + +The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually. + +```php +// In middleware +Context::add('tenant_id', $request->header('X-Tenant-ID')); + +// Anywhere later — controllers, jobs, log context +$tenantId = Context::get('tenant_id'); +``` + +Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`. + +## Use `Concurrency::run()` for Parallel Execution + +Run independent operations in parallel using child processes — no async libraries needed. + +```php +use Illuminate\Support\Facades\Concurrency; + +[$users, $orders] = Concurrency::run([ + fn () => User::count(), + fn () => Order::where('status', 'pending')->count(), +]); +``` + +Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially. + +## Convention Over Configuration + +Follow Laravel conventions. Don't override defaults unnecessarily. + +Incorrect: +```php +class Customer extends Model +{ + protected $table = 'Customer'; + protected $primaryKey = 'customer_id'; + + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id'); + } +} +``` + +Correct: +```php +class Customer extends Model +{ + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class); + } +} +``` diff --git a/.claude/skills/laravel-best-practices/rules/blade-views.md b/.claude/skills/laravel-best-practices/rules/blade-views.md new file mode 100644 index 00000000..5f0b3a1e --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/blade-views.md @@ -0,0 +1,36 @@ +# Blade & Views Best Practices + +## Use `$attributes->merge()` in Component Templates + +Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly. + +```blade +
merge(['class' => 'alert alert-'.$type]) }}> + {{ $message }} +
+``` + +## Use `@pushOnce` for Per-Component Scripts + +If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once. + +## Prefer Blade Components Over `@include` + +`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots. + +## Use View Composers for Shared View Data + +If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it. + +## Use Blade Fragments for Partial Re-Renders (htmx/Turbo) + +A single view can return either the full page or just a fragment, keeping routing clean. + +```php +return view('dashboard', compact('users')) + ->fragmentIf($request->hasHeader('HX-Request'), 'user-list'); +``` + +## Use `@aware` for Deeply Nested Component Props + +Avoids re-passing parent props through every level of nested components. diff --git a/.claude/skills/laravel-best-practices/rules/caching.md b/.claude/skills/laravel-best-practices/rules/caching.md new file mode 100644 index 00000000..67408d6e --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/caching.md @@ -0,0 +1,70 @@ +# Caching Best Practices + +## Use `Cache::remember()` Instead of Manual Get/Put + +Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions. + +Incorrect: +```php +$val = Cache::get('stats'); +if (! $val) { + $val = $this->computeStats(); + Cache::put('stats', $val, 60); +} +``` + +Correct: +```php +$val = Cache::remember('stats', 60, fn () => $this->computeStats()); +``` + +## Use `Cache::flexible()` for Stale-While-Revalidate + +On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background. + +Incorrect: `Cache::remember('users', 300, fn () => User::all());` + +Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function. + +## Use `Cache::memo()` to Avoid Redundant Hits Within a Request + +If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory. + +`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5. + +## Use Cache Tags to Invalidate Related Groups + +Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`. + +```php +Cache::tags(['user-1'])->flush(); +``` + +## Use `Cache::add()` for Atomic Conditional Writes + +`add()` only writes if the key does not exist — atomic, no race condition between checking and writing. + +Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }` + +Correct: `Cache::add('lock', true, 10);` + +## Use `once()` for Per-Request Memoization + +`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory. + +```php +public function roles(): Collection +{ + return once(fn () => $this->loadRoles()); +} +``` + +Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching. + +## Configure Failover Cache Stores in Production + +If Redis goes down, the app falls back to a secondary store automatically. + +```php +'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], +``` diff --git a/.claude/skills/laravel-best-practices/rules/collections.md b/.claude/skills/laravel-best-practices/rules/collections.md new file mode 100644 index 00000000..18e8d9e1 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/collections.md @@ -0,0 +1,44 @@ +# Collection Best Practices + +## Use Higher-Order Messages for Simple Operations + +Incorrect: +```php +$users->each(function (User $user) { + $user->markAsVip(); +}); +``` + +Correct: `$users->each->markAsVip();` + +Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc. + +## Choose `cursor()` vs. `lazy()` Correctly + +- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk). +- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading. + +Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored. + +Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work. + +## Use `lazyById()` When Updating Records While Iterating + +`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation. + +## Use `toQuery()` for Bulk Operations on Collections + +Avoids manual `whereIn` construction. + +Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);` + +Correct: `$users->toQuery()->update([...]);` + +## Use `#[CollectedBy]` for Custom Collection Classes + +More declarative than overriding `newCollection()`. + +```php +#[CollectedBy(UserCollection::class)] +class User extends Model {} +``` diff --git a/.claude/skills/laravel-best-practices/rules/config.md b/.claude/skills/laravel-best-practices/rules/config.md new file mode 100644 index 00000000..9bea727b --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/config.md @@ -0,0 +1,73 @@ +# Configuration Best Practices + +## `env()` Only in Config Files + +Direct `env()` calls may return `null` when config is cached. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'key' => env('API_KEY'), + +// Application code +$key = config('services.key'); +``` + +## Use Encrypted Env or External Secrets + +Never store production secrets in plain `.env` files in version control. + +Incorrect: +```bash + +# .env committed to repo or shared in Slack + +STRIPE_SECRET=sk_live_abc123 +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI +``` + +Correct: +```bash +php artisan env:encrypt --env=production --readable +php artisan env:decrypt --env=production +``` + +For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime. + +## Use `App::environment()` for Environment Checks + +Incorrect: +```php +if (env('APP_ENV') === 'production') { +``` + +Correct: +```php +if (app()->isProduction()) { +// or +if (App::environment('production')) { +``` + +## Use Constants and Language Files + +Use class constants instead of hardcoded magic strings for model states, types, and statuses. + +```php +// Incorrect +return $this->type === 'normal'; + +// Correct +return $this->type === self::TYPE_NORMAL; +``` + +If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there. + +```php +// Only when lang files already exist in the project +return back()->with('message', __('app.article_added')); +``` diff --git a/.claude/skills/laravel-best-practices/rules/db-performance.md b/.claude/skills/laravel-best-practices/rules/db-performance.md new file mode 100644 index 00000000..c49ba164 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/db-performance.md @@ -0,0 +1,192 @@ +# Database Performance Best Practices + +## Always Eager Load Relationships + +Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront. + +Incorrect (N+1 — executes 1 + N queries): +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Correct (2 queries total): +```php +$posts = Post::with('author')->get(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Constrain eager loads to select only needed columns (always include the foreign key): + +```php +$users = User::with(['posts' => function ($query) { + $query->select('id', 'user_id', 'title') + ->where('published', true) + ->latest() + ->limit(10); +}])->get(); +``` + +## Prevent Lazy Loading in Development + +Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development. + +```php +public function boot(): void +{ + Model::preventLazyLoading(! app()->isProduction()); +} +``` + +Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded. + +## Select Only Needed Columns + +Avoid `SELECT *` — especially when tables have large text or JSON columns. + +Incorrect: +```php +$posts = Post::with('author')->get(); +``` + +Correct: +```php +$posts = Post::select('id', 'title', 'user_id', 'created_at') + ->with(['author:id,name,avatar']) + ->get(); +``` + +When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match. + +## Chunk Large Datasets + +Never load thousands of records at once. Use chunking for batch processing. + +Incorrect: +```php +$users = User::all(); +foreach ($users as $user) { + $user->notify(new WeeklyDigest); +} +``` + +Correct: +```php +User::where('subscribed', true)->chunk(200, function ($users) { + foreach ($users as $user) { + $user->notify(new WeeklyDigest); + } +}); +``` + +Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change: + +```php +User::where('active', false)->chunkById(200, function ($users) { + $users->each->delete(); +}); +``` + +## Add Database Indexes + +Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->index()->constrained(); + $table->string('status')->index(); + $table->timestamps(); + $table->index(['status', 'created_at']); +}); +``` + +Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`). + +## Use `withCount()` for Counting Relations + +Never load entire collections just to count them. + +Incorrect: +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->comments->count(); +} +``` + +Correct: +```php +$posts = Post::withCount('comments')->get(); +foreach ($posts as $post) { + echo $post->comments_count; +} +``` + +Conditional counting: + +```php +$posts = Post::withCount([ + 'comments', + 'comments as approved_comments_count' => function ($query) { + $query->where('approved', true); + }, +])->get(); +``` + +## Use `cursor()` for Memory-Efficient Iteration + +For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator. + +Incorrect: +```php +$users = User::where('active', true)->get(); +``` + +Correct: +```php +foreach (User::where('active', true)->cursor() as $user) { + ProcessUser::dispatch($user->id); +} +``` + +Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records. + +## No Queries in Blade Templates + +Never execute queries in Blade templates. Pass data from controllers. + +Incorrect: +```blade +@foreach (User::all() as $user) + {{ $user->profile->name }} +@endforeach +``` + +Correct: +```php +// Controller +$users = User::with('profile')->get(); +return view('users.index', compact('users')); +``` + +```blade +@foreach ($users as $user) + {{ $user->profile->name }} +@endforeach +``` diff --git a/.claude/skills/laravel-best-practices/rules/eloquent.md b/.claude/skills/laravel-best-practices/rules/eloquent.md new file mode 100644 index 00000000..413d5da4 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/eloquent.md @@ -0,0 +1,148 @@ +# Eloquent Best Practices + +## Use Correct Relationship Types + +Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints. + +```php +public function comments(): HasMany +{ + return $this->hasMany(Comment::class); +} + +public function author(): BelongsTo +{ + return $this->belongsTo(User::class, 'user_id'); +} +``` + +## Use Local Scopes for Reusable Queries + +Extract reusable query constraints into local scopes to avoid duplication. + +Incorrect: +```php +$active = User::where('verified', true)->whereNotNull('activated_at')->get(); +$articles = Article::whereHas('user', function ($q) { + $q->where('verified', true)->whereNotNull('activated_at'); +})->get(); +``` + +Correct: +```php +public function scopeActive(Builder $query): Builder +{ + return $query->where('verified', true)->whereNotNull('activated_at'); +} + +// Usage +$active = User::active()->get(); +$articles = Article::whereHas('user', fn ($q) => $q->active())->get(); +``` + +## Apply Global Scopes Sparingly + +Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy. + +Incorrect (global scope for a conditional filter): +```php +class PublishedScope implements Scope +{ + public function apply(Builder $builder, Model $model): void + { + $builder->where('published', true); + } +} +// Now admin panels, reports, and background jobs all silently skip drafts +``` + +Correct (local scope you opt into): +```php +public function scopePublished(Builder $query): Builder +{ + return $query->where('published', true); +} + +Post::published()->paginate(); // Explicit +Post::paginate(); // Admin sees all +``` + +## Define Attribute Casts + +Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion. + +```php +protected function casts(): array +{ + return [ + 'is_active' => 'boolean', + 'metadata' => 'array', + 'total' => 'decimal:2', + ]; +} +``` + +## Cast Date Columns Properly + +Always cast date columns. Use Carbon instances in templates instead of formatting strings manually. + +Incorrect: +```blade +{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }} +``` + +Correct: +```php +protected function casts(): array +{ + return [ + 'ordered_at' => 'datetime', + ]; +} +``` + +```blade +{{ $order->ordered_at->toDateString() }} +{{ $order->ordered_at->format('m-d') }} +``` + +## Use `whereBelongsTo()` for Relationship Queries + +Cleaner than manually specifying foreign keys. + +Incorrect: +```php +Post::where('user_id', $user->id)->get(); +``` + +Correct: +```php +Post::whereBelongsTo($user)->get(); +Post::whereBelongsTo($user, 'author')->get(); +``` + +## Avoid Hardcoded Table Names in Queries + +Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string). + +Incorrect: +```php +DB::table('users')->where('active', true)->get(); + +$query->join('companies', 'companies.id', '=', 'users.company_id'); + +DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); +``` + +Correct — reference the model's table: +```php +DB::table((new User)->getTable())->where('active', true)->get(); + +// Even better — use Eloquent or the query builder instead of raw SQL +User::where('active', true)->get(); +Order::where('status', 'pending')->get(); +``` + +Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable. + +**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration. diff --git a/.claude/skills/laravel-best-practices/rules/error-handling.md b/.claude/skills/laravel-best-practices/rules/error-handling.md new file mode 100644 index 00000000..4b148667 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/error-handling.md @@ -0,0 +1,72 @@ +# Error Handling Best Practices + +## Exception Reporting and Rendering + +There are two valid approaches — choose one and apply it consistently across the project. + +**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find: + +```php +class InvalidOrderException extends Exception +{ + public function report(): void { /* custom reporting */ } + + public function render(Request $request): Response + { + return response()->view('errors.invalid-order', status: 422); + } +} +``` + +**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture: + +```php +->withExceptions(function (Exceptions $exceptions) { + $exceptions->report(function (InvalidOrderException $e) { /* ... */ }); + $exceptions->render(function (InvalidOrderException $e, Request $request) { + return response()->view('errors.invalid-order', status: 422); + }); +}) +``` + +Check the existing codebase and follow whichever pattern is already established. + +## Use `ShouldntReport` for Exceptions That Should Never Log + +More discoverable than listing classes in `dontReport()`. + +```php +class PodcastProcessingException extends Exception implements ShouldntReport {} +``` + +## Throttle High-Volume Exceptions + +A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type. + +## Enable `dontReportDuplicates()` + +Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks. + +## Force JSON Error Rendering for API Routes + +Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes. + +```php +$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { + return $request->is('api/*') || $request->expectsJson(); +}); +``` + +## Add Context to Exception Classes + +Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry. + +```php +class InvalidOrderException extends Exception +{ + public function context(): array + { + return ['order_id' => $this->orderId]; + } +} +``` diff --git a/.claude/skills/laravel-best-practices/rules/events-notifications.md b/.claude/skills/laravel-best-practices/rules/events-notifications.md new file mode 100644 index 00000000..82e329e8 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/events-notifications.md @@ -0,0 +1,52 @@ +# Events & Notifications Best Practices + +## Rely on Event Discovery + +Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`. + +## Run `event:cache` in Production Deploy + +Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`. + +## Use `ShouldDispatchAfterCommit` Inside Transactions + +Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet. + +```php +class OrderShipped implements ShouldDispatchAfterCommit {} +``` + +## Always Queue Notifications + +Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response. + +```php +class InvoicePaid extends Notification implements ShouldQueue +{ + use Queueable; +} +``` + +## Use `afterCommit()` on Notifications in Transactions + +Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits. + +```php +$user->notify((new InvoicePaid($invoice))->afterCommit()); +``` + +## Route Notification Channels to Dedicated Queues + +Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues. + +## Use On-Demand Notifications for Non-User Recipients + +Avoid creating dummy models to send notifications to arbitrary addresses. + +```php +Notification::route('mail', 'admin@example.com')->notify(new SystemAlert()); +``` + +## Implement `HasLocalePreference` on Notifiable Models + +Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. diff --git a/.claude/skills/laravel-best-practices/rules/http-client.md b/.claude/skills/laravel-best-practices/rules/http-client.md new file mode 100644 index 00000000..8e2f16e8 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/http-client.md @@ -0,0 +1,160 @@ +# HTTP Client Best Practices + +## Always Set Explicit Timeouts + +The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users'); +``` + +Correct: +```php +$response = Http::timeout(5) + ->connectTimeout(3) + ->get('https://api.example.com/users'); +``` + +For service-specific clients, define timeouts in a macro: + +```php +Http::macro('github', function () { + return Http::baseUrl('https://api.github.com') + ->timeout(10) + ->connectTimeout(3) + ->withToken(config('services.github.token')); +}); + +$response = Http::github()->get('/repos/laravel/framework'); +``` + +## Use Retry with Backoff for External APIs + +External APIs have transient failures. Use `retry()` with increasing delays. + +Incorrect: +```php +$response = Http::post('https://api.stripe.com/v1/charges', $data); + +if ($response->failed()) { + throw new PaymentFailedException('Charge failed'); +} +``` + +Correct: +```php +$response = Http::retry([100, 500, 1000]) + ->timeout(10) + ->post('https://api.stripe.com/v1/charges', $data); +``` + +Only retry on specific errors: + +```php +$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException && $exception->response->serverError()); +})->post('https://api.example.com/data'); +``` + +## Handle Errors Explicitly + +The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users/1'); +$user = $response->json(); // Could be an error body +``` + +Correct: +```php +$response = Http::timeout(5) + ->get('https://api.example.com/users/1') + ->throw(); + +$user = $response->json(); +``` + +For graceful degradation: + +```php +$response = Http::get('https://api.example.com/users/1'); + +if ($response->successful()) { + return $response->json(); +} + +if ($response->notFound()) { + return null; +} + +$response->throw(); +``` + +## Use Request Pooling for Concurrent Requests + +When making multiple independent API calls, use `Http::pool()` instead of sequential calls. + +Incorrect: +```php +$users = Http::get('https://api.example.com/users')->json(); +$posts = Http::get('https://api.example.com/posts')->json(); +$comments = Http::get('https://api.example.com/comments')->json(); +``` + +Correct: +```php +use Illuminate\Http\Client\Pool; + +$responses = Http::pool(fn (Pool $pool) => [ + $pool->as('users')->get('https://api.example.com/users'), + $pool->as('posts')->get('https://api.example.com/posts'), + $pool->as('comments')->get('https://api.example.com/comments'), +]); + +$users = $responses['users']->json(); +$posts = $responses['posts']->json(); +``` + +## Fake HTTP Calls in Tests + +Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`. + +Incorrect: +```php +it('syncs user from API', function () { + $service = new UserSyncService; + $service->sync(1); // Hits the real API +}); +``` + +Correct: +```php +it('syncs user from API', function () { + Http::preventStrayRequests(); + + Http::fake([ + 'api.example.com/users/1' => Http::response([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]), + ]); + + $service = new UserSyncService; + $service->sync(1); + + Http::assertSent(function (Request $request) { + return $request->url() === 'https://api.example.com/users/1'; + }); +}); +``` + +Test failure scenarios too: + +```php +Http::fake([ + 'api.example.com/*' => Http::failedConnection(), +]); +``` diff --git a/.claude/skills/laravel-best-practices/rules/mail.md b/.claude/skills/laravel-best-practices/rules/mail.md new file mode 100644 index 00000000..7c717336 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/mail.md @@ -0,0 +1,27 @@ +# Mail Best Practices + +## Implement `ShouldQueue` on the Mailable Class + +Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it. + +## Use `afterCommit()` on Mailables Inside Transactions + +A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor. + +## Use `assertQueued()` Not `assertSent()` for Queued Mailables + +`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint. + +Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. + +Correct: `Mail::assertQueued(OrderShipped::class);` + +## Use Markdown Mailables for Transactional Emails + +Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag. + +## Separate Content Tests from Sending Tests + +Content tests: instantiate the mailable directly, call `assertSeeInHtml()`. +Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`. +Don't mix them — it conflates concerns and makes tests brittle. diff --git a/.claude/skills/laravel-best-practices/rules/migrations.md b/.claude/skills/laravel-best-practices/rules/migrations.md new file mode 100644 index 00000000..df6f5f33 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/migrations.md @@ -0,0 +1,121 @@ +# Migration Best Practices + +## Generate Migrations with Artisan + +Always use `php artisan make:migration` for consistent naming and timestamps. + +Incorrect (manually created file): +```php +// database/migrations/posts_migration.php ← wrong naming, no timestamp +``` + +Correct (Artisan-generated): +```bash +php artisan make:migration create_posts_table +php artisan make:migration add_slug_to_posts_table +``` + +## Use `constrained()` for Foreign Keys + +Automatic naming and referential integrity. + +```php +$table->foreignId('user_id')->constrained()->cascadeOnDelete(); + +// Non-standard names +$table->foreignId('author_id')->constrained('users'); +``` + +## Never Modify Deployed Migrations + +Once a migration has run in production, treat it as immutable. Create a new migration to change the table. + +Incorrect (editing a deployed migration): +```php +// 2024_01_01_create_posts_table.php — already in production +$table->string('slug')->unique(); // ← added after deployment +``` + +Correct (new migration to alter): +```php +// 2024_03_15_add_slug_to_posts_table.php +Schema::table('posts', function (Blueprint $table) { + $table->string('slug')->unique()->after('title'); +}); +``` + +## Add Indexes in the Migration + +Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->index(); + $table->string('status')->index(); + $table->timestamp('shipped_at')->nullable()->index(); + $table->timestamps(); +}); +``` + +## Mirror Defaults in Model `$attributes` + +When a column has a database default, mirror it in the model so new instances have correct values before saving. + +```php +// Migration +$table->string('status')->default('pending'); + +// Model +protected $attributes = [ + 'status' => 'pending', +]; +``` + +## Write Reversible `down()` Methods by Default + +Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments. + +```php +public function down(): void +{ + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn('slug'); + }); +} +``` + +For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported. + +## Keep Migrations Focused + +One concern per migration. Never mix DDL (schema changes) and DML (data manipulation). + +Incorrect (partial failure creates unrecoverable state): +```php +public function up(): void +{ + Schema::create('settings', function (Blueprint $table) { ... }); + DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +} +``` + +Correct (separate migrations): +```php +// Migration 1: create_settings_table +Schema::create('settings', function (Blueprint $table) { ... }); + +// Migration 2: seed_default_settings +DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +``` diff --git a/.claude/skills/laravel-best-practices/rules/queue-jobs.md b/.claude/skills/laravel-best-practices/rules/queue-jobs.md new file mode 100644 index 00000000..c41915e2 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/queue-jobs.md @@ -0,0 +1,144 @@ +# Queue & Job Best Practices + +## Set `retry_after` Greater Than `timeout` + +If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution. + +Incorrect (`retry_after` ≤ `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 90 ← job retried while still running! +``` + +Correct (`retry_after` > `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 180 ← safely longer than any job timeout +``` + +## Use Exponential Backoff + +Use progressively longer delays between retries to avoid hammering failing services. + +Incorrect (fixed retry interval): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + // Default: retries immediately, overwhelming the API +} +``` + +Correct (exponential backoff): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + public $backoff = [1, 5, 10]; +} +``` + +## Implement `ShouldBeUnique` + +Prevent duplicate job processing. + +```php +class GenerateInvoice implements ShouldQueue, ShouldBeUnique +{ + public function uniqueId(): string + { + return $this->order->id; + } + + public $uniqueFor = 3600; +} +``` + +## Always Implement `failed()` + +Handle errors explicitly — don't rely on silent failure. + +```php +public function failed(?Throwable $exception): void +{ + $this->podcast->update(['status' => 'failed']); + Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]); +} +``` + +## Rate Limit External API Calls in Jobs + +Use `RateLimited` middleware to throttle jobs calling third-party APIs. + +```php +public function middleware(): array +{ + return [new RateLimited('external-api')]; +} +``` + +## Batch Related Jobs + +Use `Bus::batch()` when jobs should succeed or fail together. + +```php +Bus::batch([ + new ImportCsvChunk($chunk1), + new ImportCsvChunk($chunk2), +]) +->then(fn (Batch $batch) => Notification::send($user, new ImportComplete)) +->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed')) +->dispatch(); +``` + +## `retryUntil()` Needs `$tries = 0` + +When using time-based retry limits, set `$tries = 0` to avoid premature failure. + +```php +public $tries = 0; + +public function retryUntil(): \DateTimeInterface +{ + return now()->addHours(4); +} +``` + +## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release + +`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue. + +```php +class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing +{ + // Lock releases when processing begins, not when it finishes +} +``` + +## Use Horizon for Complex Queue Scenarios + +Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities. + +```php +// config/horizon.php +'environments' => [ + 'production' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['high', 'default', 'low'], + 'balance' => 'auto', + 'minProcesses' => 1, + 'maxProcesses' => 10, + 'tries' => 3, + ], + ], +], +``` diff --git a/.claude/skills/laravel-best-practices/rules/routing.md b/.claude/skills/laravel-best-practices/rules/routing.md new file mode 100644 index 00000000..b6e30864 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/routing.md @@ -0,0 +1,99 @@ +# Routing & Controllers Best Practices + +## Use Implicit Route Model Binding + +Let Laravel resolve models automatically from route parameters. + +Incorrect: +```php +public function show(int $id) +{ + $post = Post::findOrFail($id); +} +``` + +Correct: +```php +public function show(Post $post) +{ + return view('posts.show', ['post' => $post]); +} +``` + +## Use Scoped Bindings for Nested Resources + +Enforce parent-child relationships automatically. + +```php +Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { + // $post is automatically scoped to $user +})->scopeBindings(); +``` + +## Use Resource Controllers + +Use `Route::resource()` or `apiResource()` for RESTful endpoints. + +```php +Route::resource('posts', PostController::class); +// In routes/api.php — the /api prefix is applied automatically +Route::apiResource('posts', Api\PostController::class); +``` + +## Keep Controllers Thin + +Aim for under 10 lines per method. Extract business logic to action or service classes. + +Incorrect: +```php +public function store(Request $request) +{ + $validated = $request->validate([...]); + if ($request->hasFile('image')) { + $request->file('image')->move(public_path('images')); + } + $post = Post::create($validated); + $post->tags()->sync($validated['tags']); + event(new PostCreated($post)); + return redirect()->route('posts.show', $post); +} +``` + +Correct: +```php +public function store(StorePostRequest $request, CreatePostAction $create) +{ + $post = $create->execute($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +## Type-Hint Form Requests + +Type-hinting Form Requests triggers automatic validation and authorization before the method executes. + +Incorrect: +```php +public function store(Request $request): RedirectResponse +{ + $validated = $request->validate([ + 'title' => ['required', 'max:255'], + 'body' => ['required'], + ]); + + Post::create($validated); + + return redirect()->route('posts.index'); +} +``` + +Correct: +```php +public function store(StorePostRequest $request): RedirectResponse +{ + Post::create($request->validated()); + + return redirect()->route('posts.index'); +} +``` diff --git a/.claude/skills/laravel-best-practices/rules/scheduling.md b/.claude/skills/laravel-best-practices/rules/scheduling.md new file mode 100644 index 00000000..a9847945 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/scheduling.md @@ -0,0 +1,39 @@ +# Task Scheduling Best Practices + +## Use `withoutOverlapping()` on Variable-Duration Tasks + +Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion. + +## Use `onOneServer()` on Multi-Server Deployments + +Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached). + +## Use `runInBackground()` for Concurrent Long Tasks + +By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes. + +## Use `environments()` to Restrict Tasks + +Prevent accidental execution of production-only tasks (billing, reporting) on staging. + +```php +Schedule::command('billing:charge')->monthly()->environments(['production']); +``` + +## Use `takeUntilTimeout()` for Time-Bounded Processing + +A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time. + +## Use Schedule Groups for Shared Configuration + +Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks. + +```php +Schedule::daily() + ->onOneServer() + ->timezone('America/New_York') + ->group(function () { + Schedule::command('emails:send --force'); + Schedule::command('emails:prune'); + }); +``` diff --git a/.claude/skills/laravel-best-practices/rules/security.md b/.claude/skills/laravel-best-practices/rules/security.md new file mode 100644 index 00000000..2d7200c2 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/security.md @@ -0,0 +1,198 @@ +# Security Best Practices + +## Mass Assignment Protection + +Every model must define `$fillable` (whitelist) or `$guarded` (blacklist). + +Incorrect: +```php +class User extends Model +{ + protected $guarded = []; // All fields are mass assignable +} +``` + +Correct: +```php +class User extends Model +{ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; +} +``` + +Never use `$guarded = []` on models that accept user input. + +## Authorize Every Action + +Use policies or gates in controllers. Never skip authorization. + +Incorrect: +```php +public function update(UpdatePostRequest $request, Post $post) +{ + $post->update($request->validated()); +} +``` + +Correct: +```php +public function update(UpdatePostRequest $request, Post $post) +{ + Gate::authorize('update', $post); + + $post->update($request->validated()); +} +``` + +Or via Form Request: + +```php +public function authorize(): bool +{ + return $this->user()->can('update', $this->route('post')); +} +``` + +## Prevent SQL Injection + +Always use parameter binding. Never interpolate user input into queries. + +Incorrect: +```php +DB::select("SELECT * FROM users WHERE name = '{$request->name}'"); +``` + +Correct: +```php +User::where('name', $request->name)->get(); + +// Raw expressions with bindings +User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get(); +``` + +## Escape Output to Prevent XSS + +Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content. + +Incorrect: +```blade +{!! $user->bio !!} +``` + +Correct: +```blade +{{ $user->bio }} +``` + +## CSRF Protection + +Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied. + +Incorrect: +```blade +
+ +
+``` + +Correct: +```blade +
+ @csrf + +
+``` + +## Rate Limit Auth and API Routes + +Apply `throttle` middleware to authentication and API routes. + +```php +RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by($request->ip()); +}); + +Route::post('/login', LoginController::class)->middleware('throttle:login'); +``` + +## Validate File Uploads + +Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames. + +```php +public function rules(): array +{ + return [ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + ]; +} +``` + +Store with generated filenames: + +```php +$path = $request->file('avatar')->store('avatars', 'public'); +``` + +## Keep Secrets Out of Code + +Never commit `.env`. Access secrets via `config()` only. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'api_key' => env('API_KEY'), + +// In application code +$key = config('services.api_key'); +``` + +## Audit Dependencies + +Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment. + +```bash +composer audit +``` + +## Encrypt Sensitive Database Fields + +Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`. + +Incorrect: +```php +class Integration extends Model +{ + protected function casts(): array + { + return [ + 'api_key' => 'string', + ]; + } +} +``` + +Correct: +```php +class Integration extends Model +{ + protected $hidden = ['api_key', 'api_secret']; + + protected function casts(): array + { + return [ + 'api_key' => 'encrypted', + 'api_secret' => 'encrypted', + ]; + } +} +``` diff --git a/.claude/skills/laravel-best-practices/rules/style.md b/.claude/skills/laravel-best-practices/rules/style.md new file mode 100644 index 00000000..a8afb369 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/style.md @@ -0,0 +1,125 @@ +# Conventions & Style + +## Follow Laravel Naming Conventions + +| What | Convention | Good | Bad | +|------|-----------|------|-----| +| Controller | singular | `ArticleController` | `ArticlesController` | +| Model | singular | `User` | `Users` | +| Table | plural, snake_case | `article_comments` | `articleComments` | +| Pivot table | singular alphabetical | `article_user` | `user_article` | +| Column | snake_case, no model name | `meta_title` | `article_meta_title` | +| Foreign key | singular model + `_id` | `article_id` | `articles_id` | +| Route | plural | `articles/1` | `article/1` | +| Route name | snake_case with dots | `users.show_active` | `users.show-active` | +| Method | camelCase | `getAll` | `get_all` | +| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` | +| Collection | descriptive, plural | `$activeUsers` | `$data` | +| Object | descriptive, singular | `$activeUser` | `$users` | +| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` | +| Config | snake_case | `google_calendar.php` | `googleCalendar.php` | +| Enum | singular | `UserType` | `UserTypes` | + +## Prefer Shorter Readable Syntax + +| Verbose | Shorter | +|---------|---------| +| `Session::get('cart')` | `session('cart')` | +| `$request->session()->get('cart')` | `session('cart')` | +| `$request->input('name')` | `$request->name` | +| `return Redirect::back()` | `return back()` | +| `Carbon::now()` | `now()` | +| `App::make('Class')` | `app('Class')` | +| `->where('column', '=', 1)` | `->where('column', 1)` | +| `->orderBy('created_at', 'desc')` | `->latest()` | +| `->orderBy('created_at', 'asc')` | `->oldest()` | +| `->first()->name` | `->value('name')` | + +## Use Laravel String & Array Helpers + +Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them. + +Strings — use `Str` and fluent `Str::of()` over raw PHP: +```php +// Incorrect +$slug = strtolower(str_replace(' ', '-', $title)); +$short = substr($text, 0, 100) . '...'; +$class = substr(strrchr('App\Models\User', '\\'), 1); + +// Correct +$slug = Str::slug($title); +$short = Str::limit($text, 100); +$class = class_basename('App\Models\User'); +``` + +Fluent strings — chain operations for complex transformations: +```php +// Incorrect +$result = strtolower(trim(str_replace('_', '-', $input))); + +// Correct +$result = Str::of($input)->trim()->replace('_', '-')->lower(); +``` + +Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`. + +Arrays — use `Arr` over raw PHP: +```php +// Incorrect +$name = isset($array['user']['name']) ? $array['user']['name'] : 'default'; + +// Correct +$name = Arr::get($array, 'user.name', 'default'); +``` + +Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`. + +Numbers — use `Number` for display formatting: +```php +Number::format(1000000); // "1,000,000" +Number::currency(1500, 'USD'); // "$1,500.00" +Number::abbreviate(1000000); // "1M" +Number::fileSize(1024 * 1024); // "1 MB" +Number::percentage(75.5); // "75.5%" +``` + +URIs — use `Uri` for URL manipulation: +```php +$uri = Uri::of('https://example.com/search') + ->withQuery(['q' => 'laravel', 'page' => 1]); +``` + +Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining. + +Use `search-docs` for the full list of available methods — these helpers are extensive. + +## No Inline JS/CSS in Blade + +Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes. + +Incorrect: +```blade +let article = `{{ json_encode($article) }}`; +``` + +Correct: +```blade + +``` + +Pass data to JS via data attributes or use a dedicated PHP-to-JS package. + +## No Unnecessary Comments + +Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected. + +Incorrect: +```php +// Check if there are any joins +if (count((array) $builder->getQuery()->joins) > 0) +``` + +Correct: +```php +if ($this->hasJoins()) +``` diff --git a/.claude/skills/laravel-best-practices/rules/testing.md b/.claude/skills/laravel-best-practices/rules/testing.md new file mode 100644 index 00000000..4fbf12f8 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/testing.md @@ -0,0 +1,43 @@ +# Testing Best Practices + +## Use `LazilyRefreshDatabase` Over `RefreshDatabase` + +`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date. + +## Use Model Assertions Over Raw Database Assertions + +Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);` + +Correct: `$this->assertModelExists($user);` + +More expressive, type-safe, and fails with clearer messages. + +## Use Factory States and Sequences + +Named states make tests self-documenting. Sequences eliminate repetitive setup. + +Incorrect: `User::factory()->create(['email_verified_at' => null]);` + +Correct: `User::factory()->unverified()->create();` + +## Use `Exceptions::fake()` to Assert Exception Reporting + +Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally. + +## Call `Event::fake()` After Factory Setup + +Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models. + +Incorrect: `Event::fake(); $user = User::factory()->create();` + +Correct: `$user = User::factory()->create(); Event::fake();` + +## Use `recycle()` to Share Relationship Instances Across Factories + +Without `recycle()`, nested factories create separate instances of the same conceptual entity. + +```php +Ticket::factory() + ->recycle(Airline::factory()->create()) + ->create(); +``` diff --git a/.claude/skills/laravel-best-practices/rules/validation.md b/.claude/skills/laravel-best-practices/rules/validation.md new file mode 100644 index 00000000..5fde1064 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/validation.md @@ -0,0 +1,75 @@ +# Validation & Forms Best Practices + +## Use Form Request Classes + +Extract validation from controllers into dedicated Form Request classes. + +Incorrect: +```php +public function store(Request $request) +{ + $request->validate([ + 'title' => 'required|max:255', + 'body' => 'required', + ]); +} +``` + +Correct: +```php +public function store(StorePostRequest $request) +{ + Post::create($request->validated()); +} +``` + +## Array vs. String Notation for Rules + +Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses. + +```php +// Preferred for new code +'email' => ['required', 'email', Rule::unique('users')], + +// Follow existing convention if the project uses string notation +'email' => 'required|email|unique:users', +``` + +## Always Use `validated()` + +Get only validated data. Never use `$request->all()` for mass operations. + +Incorrect: +```php +Post::create($request->all()); +``` + +Correct: +```php +Post::create($request->validated()); +``` + +## Use `Rule::when()` for Conditional Validation + +```php +'company_name' => [ + Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']), +], +``` + +## Use the `after()` Method for Custom Validation + +Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields. + +```php +public function after(): array +{ + return [ + function (Validator $validator) { + if ($this->quantity > Product::find($this->product_id)?->stock) { + $validator->errors()->add('quantity', 'Not enough stock.'); + } + }, + ]; +} +``` diff --git a/.claude/skills/livewire-development/SKILL.md b/.claude/skills/livewire-development/SKILL.md new file mode 100644 index 00000000..0ae356e5 --- /dev/null +++ b/.claude/skills/livewire-development/SKILL.md @@ -0,0 +1,175 @@ +--- +name: livewire-development +description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire." +license: MIT +metadata: + author: laravel +--- + +# Livewire Development + +## Documentation + +Use `search-docs` for detailed Livewire 4 patterns and documentation. + +## Basic Usage + +### Creating Components + +```bash + +# Single-file component (SFC - default in v4) + +# Creates: resources/views/components/⚡create-post.blade.php + +php artisan make:livewire create-post + +# Page component (SFC - Full Page in v4) + +# Creates: resources/views/pages/⚡create-post.blade.php + +php artisan make:livewire pages::create-post + +# Multi-file component (MFC) + +# Creates: resources/views/components/⚡create-post/create-post.php + +# resources/views/components/⚡create-post/create-post.blade.php + +php artisan make:livewire create-post --mfc + +# Class-based component (v3 style) + +# Creates: app/Livewire/CreatePost.php AND resources/views/livewire/create-post.blade.php + +php artisan make:livewire create-post --class + +# With namespace + +php artisan make:livewire Posts/CreatePost +``` + +### Converting Between Formats + +Use `php artisan livewire:convert create-post` to convert between single-file, multi-file, and class-based formats. + +### Choosing a Component Format + +> **Always follow the project's existing conventions first.** Before creating any component, inspect the project's existing Livewire components to determine the established format (SFC, MFC, or class-based) and directory structure. Check `app/Livewire/`, `resources/views/components/`, and `resources/views/livewire/` for existing components. If the project already uses a consistent format, **use that same format** — even if it differs from the Livewire v4 defaults below. Only fall back to the v4 defaults (SFC in `resources/views/components/`) when no existing convention is established. + +Also check `config/livewire.php` for `make_command.type`, `make_command.emoji`, `component_locations`, and `component_namespaces` overrides, which change the default format and where files are stored. + +### Component Format Reference + +| Format | Flag | Class Path | View Path | +|--------|------|------------|-----------| +| Single-file (SFC) | default | — | `resources/views/components/⚡create-post.blade.php` (PHP + Blade in one file) | +| Full Page SFC | `pages::name` | — | `resources/views/pages/⚡create-post.blade.php` | +| Multi-file (MFC) | `--mfc` | `resources/views/components/⚡create-post/create-post.php` | `resources/views/components/⚡create-post/create-post.blade.php` | +| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` | +| View-based | default (Blade-only) | — | `resources/views/components/⚡create-post.blade.php` (Blade-only with functional state) | + +> **Important:** The ⚡ prefix shown above is the **default** behavior in Livewire v4 — it is **configurable**. Check `config/livewire.php` for the `make_command.emoji` setting. When `true` (default), always include the ⚡ prefix in filenames you create. When `false`, omit the ⚡ prefix from all paths above. + +Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates `resources/views/components/posts/⚡create-post.blade.php` (single-file by default). Use `make:livewire Posts/CreatePost --mfc` for multi-file output at `resources/views/components/posts/⚡create-post/create-post.php` and `resources/views/components/posts/⚡create-post/create-post.blade.php`. + +### Single-File Component Example + + +```php +count++; + } +}; +?> + +
+ +
+``` + +## Livewire 4 Specifics + +### Key Changes From Livewire 3 + +These things changed in Livewire 4, but may not have been updated in this application. Verify this application's setup to ensure you follow existing conventions. + +- Use `Route::livewire()` for full-page components (e.g., `Route::livewire('/posts/create', CreatePost::class)`); config keys renamed: `layout` → `component_layout`, `lazy_placeholder` → `component_placeholder`. +- `wire:model` now ignores child events by default (use `wire:model.deep` for old behavior); `wire:scroll` renamed to `wire:navigate:scroll`. +- Component tags must be properly closed; `wire:transition` now uses View Transitions API (modifiers removed). +- JavaScript: `$wire.$js('name', fn)` → `$wire.$js.name = fn`; `commit`/`request` hooks → `interceptMessage()`/`interceptRequest()`. + +### New Features + +- Component formats: single-file (SFC), multi-file (MFC), view-based components. +- Islands (`@island`) for isolated updates; async actions (`wire:click.async`, `#[Async]`) for parallel execution. +- Deferred/bundled loading: `defer`, `lazy.bundle` for optimized component loading. + +| Feature | Usage | Purpose | +|---------|-------|---------| +| Islands | `@island(name: 'stats')` | Isolated update regions | +| Async | `wire:click.async` or `#[Async]` | Non-blocking actions | +| Deferred | `defer` attribute | Load after page render | +| Bundled | `lazy.bundle` | Load multiple together | + +### New Directives + +- `wire:sort`, `wire:intersect`, `wire:ref`, `.renderless`, `.preserve-scroll` are available for use. +- `data-loading` attribute automatically added to elements triggering network requests. + +| Directive | Purpose | +|-----------|---------| +| `wire:sort` | Drag-and-drop sorting | +| `wire:intersect` | Viewport intersection detection | +| `wire:ref` | Element references for JS | +| `.renderless` | Component without rendering | +| `.preserve-scroll` | Preserve scroll position | + +## Best Practices + +- Always use `wire:key` in loops +- Use `wire:loading` for loading states +- Use `wire:model.live` for instant updates (default is debounced) +- Validate and authorize in actions (treat like HTTP requests) + +## Configuration + +- `smart_wire_keys` defaults to `true`; new configs: `component_locations`, `component_namespaces`, `make_command`, `csp_safe`. + +## Alpine & JavaScript + +- `wire:transition` uses browser View Transitions API; `$errors` and `$intercept` magic properties available. +- Non-blocking `wire:poll` and parallel `wire:model.live` updates improve performance. + +For interceptors and hooks, see [reference/javascript-hooks.md](reference/javascript-hooks.md). + +## Testing + + +```php +Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1); +``` + +## Verification + +1. Browser console: Check for JS errors +2. Network tab: Verify Livewire requests return 200 +3. Ensure `wire:key` on all `@foreach` loops + +## Common Pitfalls + +- Missing `wire:key` in loops → unexpected re-rendering +- Expecting `wire:model` real-time → use `wire:model.live` +- Unclosed component tags → syntax errors in v4 +- Using deprecated config keys or JS hooks +- Including Alpine.js separately (already bundled in Livewire 4) diff --git a/.claude/skills/livewire-development/reference/javascript-hooks.md b/.claude/skills/livewire-development/reference/javascript-hooks.md new file mode 100644 index 00000000..660d66b5 --- /dev/null +++ b/.claude/skills/livewire-development/reference/javascript-hooks.md @@ -0,0 +1,39 @@ +# Livewire 4 JavaScript Integration + +## Interceptor System (v4) + +### Intercept Messages + +```js +Livewire.interceptMessage(({ component, message, onFinish, onSuccess, onError }) => { + onFinish(() => { /* After response, before processing */ }); + onSuccess(({ payload }) => { /* payload.snapshot, payload.effects */ }); + onError(() => { /* Server errors */ }); +}); +``` + +### Intercept Requests + +```js +Livewire.interceptRequest(({ request, onResponse, onSuccess, onError, onFailure }) => { + onResponse(({ response }) => { /* When received */ }); + onSuccess(({ response, responseJson }) => { /* Success */ }); + onError(({ response, responseBody, preventDefault }) => { /* 4xx/5xx */ }); + onFailure(({ error }) => { /* Network failures */ }); +}); +``` + +### Component-Scoped Interceptors + +```blade + +``` + +## Magic Properties + +- `$errors` - Access validation errors from JavaScript +- `$intercept` - Component-scoped interceptors diff --git a/.claude/skills/pest-testing/SKILL.md b/.claude/skills/pest-testing/SKILL.md new file mode 100644 index 00000000..ab271616 --- /dev/null +++ b/.claude/skills/pest-testing/SKILL.md @@ -0,0 +1,166 @@ +--- +name: pest-testing +description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code." +license: MIT +metadata: + author: laravel +--- + +# Pest Testing 4 + +## Documentation + +Use `search-docs` for detailed Pest 4 patterns and documentation. + +## Basic Usage + +### Creating Tests + +All tests must be written using Pest. Use `php artisan make:test --pest {name}`. + +The `{name}` argument should include only the path and test name, but should not include the test suite. +- Incorrect: `php artisan make:test --pest Feature/SomeFeatureTest` will generate `tests/Feature/Feature/SomeFeatureTest.php` +- Correct: `php artisan make:test --pest SomeControllerTest` will generate `tests/Feature/SomeControllerTest.php` +- Incorrect: `php artisan make:test --pest --unit Unit/SomeServiceTest` will generate `tests/Unit/Unit/SomeServiceTest.php` +- Correct: `php artisan make:test --pest --unit SomeServiceTest` will generate `tests/Unit/SomeServiceTest.php` + +### Test Organization + +- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. +- Browser tests: `tests/Browser/` directory. +- Do NOT remove tests without approval - these are core application code. + +### Basic Test Structure + +Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`. + + +```php +it('is true', function () { + expect(true)->toBeTrue(); +}); +``` + +### Running Tests + +- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`. +- Run all tests: `php artisan test --compact`. +- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`. + +## Assertions + +Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: + + +```php +it('returns all', function () { + $this->postJson('/api/docs', [])->assertSuccessful(); +}); +``` + +| Use | Instead of | +|-----|------------| +| `assertSuccessful()` | `assertStatus(200)` | +| `assertNotFound()` | `assertStatus(404)` | +| `assertForbidden()` | `assertStatus(403)` | + +## Mocking + +Import mock function before use: `use function Pest\Laravel\mock;` + +## Datasets + +Use datasets for repetitive tests (validation rules, etc.): + + +```php +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); +``` + +## Pest 4 Features + +| Feature | Purpose | +|---------|---------| +| Browser Testing | Full integration tests in real browsers | +| Smoke Testing | Validate multiple pages quickly | +| Visual Regression | Compare screenshots for visual changes | +| Test Sharding | Parallel CI runs | +| Architecture Testing | Enforce code conventions | + +### Browser Test Example + +Browser tests run in real browsers for full integration testing: + +- Browser tests live in `tests/Browser/`. +- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories. +- Use `RefreshDatabase` for clean state per test. +- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures. +- Test on multiple browsers (Chrome, Firefox, Safari) if requested. +- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested. +- Switch color schemes (light/dark mode) when appropriate. +- Take screenshots or pause tests for debugging. + + +```php +it('may reset the password', function () { + Notification::fake(); + + $this->actingAs(User::factory()->create()); + + $page = visit('/sign-in'); + + $page->assertSee('Sign In') + ->assertNoJavaScriptErrors() + ->click('Forgot Password?') + ->fill('email', 'nuno@laravel.com') + ->click('Send Reset Link') + ->assertSee('We have emailed your password reset link!'); + + Notification::assertSent(ResetPassword::class); +}); +``` + +### Smoke Testing + +Quickly validate multiple pages have no JavaScript errors: + + +```php +$pages = visit(['/', '/about', '/contact']); + +$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs(); +``` + +### Visual Regression Testing + +Capture and compare screenshots to detect visual changes. + +### Test Sharding + +Split tests across parallel processes for faster CI runs. + +### Architecture Testing + +Pest 4 includes architecture testing (from Pest 3): + + +```php +arch('controllers') + ->expect('App\Http\Controllers') + ->toExtendNothing() + ->toHaveSuffix('Controller'); +``` + +## Common Pitfalls + +- Not importing `use function Pest\Laravel\mock;` before using mock +- Using `assertStatus(200)` instead of `assertSuccessful()` +- Forgetting datasets for repetitive validation tests +- Deleting tests without approval +- Forgetting `assertNoJavaScriptErrors()` in browser tests +- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test` diff --git a/.claude/skills/tailwindcss-development/SKILL.md b/.claude/skills/tailwindcss-development/SKILL.md new file mode 100644 index 00000000..c0cb2fbc --- /dev/null +++ b/.claude/skills/tailwindcss-development/SKILL.md @@ -0,0 +1,119 @@ +--- +name: tailwindcss-development +description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS." +license: MIT +metadata: + author: laravel +--- + +# Tailwind CSS Development + +## Documentation + +Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. + +## Basic Usage + +- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. +- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). +- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. + +## Tailwind CSS v4 Specifics + +- Always use Tailwind CSS v4 and avoid deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. + +### CSS-First Configuration + +In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: + + +```css +@theme { + --color-brand: oklch(0.72 0.11 178); +} +``` + +### Import Syntax + +In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: + + +```diff +- @tailwind base; +- @tailwind components; +- @tailwind utilities; ++ @import "tailwindcss"; +``` + +### Replaced Utilities + +Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. + +| Deprecated | Replacement | +|------------|-------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + +## Spacing + +Use `gap` utilities instead of margins for spacing between siblings: + + +```html +
+
Item 1
+
Item 2
+
+``` + +## Dark Mode + +If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: + + +```html +
+ Content adapts to color scheme +
+``` + +## Common Patterns + +### Flexbox Layout + + +```html +
+
Left content
+
Right content
+
+``` + +### Grid Layout + + +```html +
+
Card 1
+
Card 2
+
Card 3
+
+``` + +## Common Pitfalls + +- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.) +- Using `@tailwind` directives instead of `@import "tailwindcss"` +- Trying to use `tailwind.config.js` instead of CSS `@theme` directive +- Using margins for spacing between siblings instead of gap utilities +- Forgetting to add dark mode variants when the project uses dark mode diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 00000000..864e1fbd --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,3 @@ +[mcp_servers.laravel-boost] +command = "php" +args = ["artisan", "boost:mcp"] diff --git a/.env.testing b/.env.testing new file mode 100644 index 00000000..796f660f --- /dev/null +++ b/.env.testing @@ -0,0 +1,49 @@ +APP_NAME=Shop +APP_ENV=testing +APP_KEY=base64:2EWqBvgCtrFAYOtd+JT2CcIKngs8qnNhdUT8Q8yX6p8= +APP_DEBUG=true +APP_URL=http://acme-fashion.test +FRONTEND_URL=http://acme-fashion.test + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file + +BCRYPT_ROUNDS=4 + +LOG_CHANNEL=json +LOG_LEVEL=debug +LOG_DEPRECATIONS_CHANNEL=null + +DB_CONNECTION=sqlite +DB_DATABASE=/Users/fabianwesner/Herd/shop/database/testing.sqlite +DB_FOREIGN_KEYS=true + +SESSION_DRIVER=array +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_COOKIE=shop_session +SESSION_PATH=/ +SESSION_DOMAIN=null +SESSION_SECURE_COOKIE=false +SESSION_HTTP_ONLY=true +SESSION_SAME_SITE=lax + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=sync +CACHE_STORE=array + +MAIL_MAILER=array +MAIL_FROM_ADDRESS="hello@shop.test" +MAIL_FROM_NAME="${APP_NAME}" + +SANCTUM_STATEFUL_DOMAINS=shop.test,acme-fashion.test,admin.acme-fashion.test,acme-electronics.test,127.0.0.1,localhost +SANCTUM_TOKEN_PREFIX=shop_ +SANCTUM_EXPIRATION=525600 + +PAYMENT_PROVIDER=mock + +VITE_APP_NAME="${APP_NAME}" diff --git a/.mcp.json b/.mcp.json index 0ad95248..5d75d4a9 100644 --- a/.mcp.json +++ b/.mcp.json @@ -3,10 +3,16 @@ "laravel-boost": { "command": "php", "args": [ - "./artisan", + "artisan", "boost:mcp" ] }, + "playwright": { + "command": "npx", + "args": [ + "@playwright/mcp@latest" + ] + }, "herd": { "command": "php", "args": [ diff --git a/.playwright-mcp/console-2026-07-18T09-15-35-246Z.log b/.playwright-mcp/console-2026-07-18T09-15-35-246Z.log new file mode 100644 index 00000000..dd59948e --- /dev/null +++ b/.playwright-mcp/console-2026-07-18T09-15-35-246Z.log @@ -0,0 +1 @@ +[ 100ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/:27 diff --git a/.playwright-mcp/console-2026-07-18T11-28-30-241Z.log b/.playwright-mcp/console-2026-07-18T11-28-30-241Z.log new file mode 100644 index 00000000..fda72a8f --- /dev/null +++ b/.playwright-mcp/console-2026-07-18T11-28-30-241Z.log @@ -0,0 +1,3 @@ +[ 233ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/:48 +[ 335ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://acme-fashion.test/favicon.ico:0 +[ 11820ms] [WARNING] The resource http://acme-fashion.test/build/assets/app-hdvTSHkI.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://acme-fashion.test/collections/t-shirts:0 diff --git a/.playwright-mcp/console-2026-07-18T11-28-44-297Z.log b/.playwright-mcp/console-2026-07-18T11-28-44-297Z.log new file mode 100644 index 00000000..b8800a28 --- /dev/null +++ b/.playwright-mcp/console-2026-07-18T11-28-44-297Z.log @@ -0,0 +1 @@ +[ 69ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/products/classic-cotton-t-shirt:48 diff --git a/.playwright-mcp/console-2026-07-18T11-28-53-067Z.log b/.playwright-mcp/console-2026-07-18T11-28-53-067Z.log new file mode 100644 index 00000000..d9d23dcd --- /dev/null +++ b/.playwright-mcp/console-2026-07-18T11-28-53-067Z.log @@ -0,0 +1,3 @@ +[ 69ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/cart:48 +[ 6454ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/checkout/1:48 +[ 26319ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/checkout/1/confirmation:48 diff --git a/.playwright-mcp/console-2026-07-18T11-29-27-652Z.log b/.playwright-mcp/console-2026-07-18T11-29-27-652Z.log new file mode 100644 index 00000000..453e726d --- /dev/null +++ b/.playwright-mcp/console-2026-07-18T11-29-27-652Z.log @@ -0,0 +1,18 @@ +[ 58ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/login:53 +[ 6808ms] [WARNING] The resource http://acme-fashion.test/build/assets/app-hdvTSHkI.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://acme-fashion.test/admin:0 +[ 8400ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/products:53 +[ 8928ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/orders:53 +[ 9451ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/orders/1:53 +[ 9954ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/customers:53 +[ 10472ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/discounts:53 +[ 11013ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/settings:53 +[ 11528ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/settings/shipping:53 +[ 12019ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/analytics:53 +[ 12512ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/collections:53 +[ 21237ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/orders/1:53 +[ 29691ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/orders/1:53 +[ 38011ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/settings/shipping:53 +[ 38095ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/account/login:48 +[ 38103ms] [VERBOSE] [DOM] Input elements should have autocomplete attributes (suggested: "current-password"): (More info: https://goo.gl/9p2vKq) %o @ http://acme-fashion.test/account/login:0 +[ 39467ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/orders/1:53 +[ 44741ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin:53 diff --git a/.playwright-mcp/console-2026-07-18T11-30-44-355Z.log b/.playwright-mcp/console-2026-07-18T11-30-44-355Z.log new file mode 100644 index 00000000..ef380af5 --- /dev/null +++ b/.playwright-mcp/console-2026-07-18T11-30-44-355Z.log @@ -0,0 +1,3 @@ +[ 83ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/orders/1:53 +[ 13846ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://acme-fashion.test/livewire-0972654c/update:0 +[ 13860ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ :7 diff --git a/.playwright-mcp/console-2026-07-18T11-31-07-313Z.log b/.playwright-mcp/console-2026-07-18T11-31-07-313Z.log new file mode 100644 index 00000000..b7e6b4df --- /dev/null +++ b/.playwright-mcp/console-2026-07-18T11-31-07-313Z.log @@ -0,0 +1,2 @@ +[ 76ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/orders/1:53 +[ 4999ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/orders/1:53 diff --git a/.playwright-mcp/console-2026-07-26T08-19-59-887Z.log b/.playwright-mcp/console-2026-07-26T08-19-59-887Z.log new file mode 100644 index 00000000..becf2bca --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-19-59-887Z.log @@ -0,0 +1,2 @@ +[ 104ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/:99 +[ 147ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://acme-fashion.test/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-07-26T08-21-37-157Z.log b/.playwright-mcp/console-2026-07-26T08-21-37-157Z.log new file mode 100644 index 00000000..6dbe5881 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-21-37-157Z.log @@ -0,0 +1 @@ +[ 95ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/:100 diff --git a/.playwright-mcp/console-2026-07-26T08-23-00-463Z.log b/.playwright-mcp/console-2026-07-26T08-23-00-463Z.log new file mode 100644 index 00000000..1551294c --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-23-00-463Z.log @@ -0,0 +1,3 @@ +[ 87ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/products/classic-cotton-t-shirt:107 +[ 24681ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://acme-fashion.test/livewire-0972654c/update:0 +[ 24694ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ :7 diff --git a/.playwright-mcp/console-2026-07-26T08-31-01-516Z.log b/.playwright-mcp/console-2026-07-26T08-31-01-516Z.log new file mode 100644 index 00000000..7687a88a --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-31-01-516Z.log @@ -0,0 +1,4 @@ +[ 116ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/products/classic-cotton-t-shirt:107 +[ 60771ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/checkout/new:100 +[ 95040ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/checkout/1:100 +[ 223745ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/checkout/1/confirmation:100 diff --git a/.playwright-mcp/console-2026-07-26T08-35-29-214Z.log b/.playwright-mcp/console-2026-07-26T08-35-29-214Z.log new file mode 100644 index 00000000..503df496 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-35-29-214Z.log @@ -0,0 +1,5 @@ +[ 91ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/products/premium-slim-fit-jeans:107 +[ 27153ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/checkout/new:100 +[ 83160ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/checkout/2:100 +[ 193825ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://acme-fashion.test/livewire-0972654c/update:0 +[ 193839ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ :7 diff --git a/.playwright-mcp/console-2026-07-26T08-40-55-167Z.log b/.playwright-mcp/console-2026-07-26T08-40-55-167Z.log new file mode 100644 index 00000000..c08d2efa --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-40-55-167Z.log @@ -0,0 +1,2 @@ +[ 109ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/checkout/2:100 +[ 23073ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/checkout/2/confirmation:100 diff --git a/.playwright-mcp/console-2026-07-26T08-43-28-417Z.log b/.playwright-mcp/console-2026-07-26T08-43-28-417Z.log new file mode 100644 index 00000000..a4e8b559 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-43-28-417Z.log @@ -0,0 +1,2 @@ +[ 85ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/login:45 +[ 36115ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin:54 diff --git a/.playwright-mcp/console-2026-07-26T08-44-44-171Z.log b/.playwright-mcp/console-2026-07-26T08-44-44-171Z.log new file mode 100644 index 00000000..3fd5ed82 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-44-44-171Z.log @@ -0,0 +1 @@ +[ 107ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/orders:54 diff --git a/.playwright-mcp/console-2026-07-26T08-48-16-108Z.log b/.playwright-mcp/console-2026-07-26T08-48-16-108Z.log new file mode 100644 index 00000000..ebd34cc2 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-48-16-108Z.log @@ -0,0 +1 @@ +[ 110ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/orders/19:54 diff --git a/.playwright-mcp/console-2026-07-26T08-50-01-713Z.log b/.playwright-mcp/console-2026-07-26T08-50-01-713Z.log new file mode 100644 index 00000000..d730c556 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-50-01-713Z.log @@ -0,0 +1,9 @@ +[ 102ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/products:54 +[ 139815ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/discounts:54 +[ 169965ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/collections:54 +[ 200102ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/customers:54 +[ 214554ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/discounts:54 +[ 230272ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/settings:54 +[ 244668ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/collections:54 +[ 260426ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/settings/shipping:54 +[ 274779ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/customers:54 diff --git a/.playwright-mcp/console-2026-07-26T08-54-48-558Z.log b/.playwright-mcp/console-2026-07-26T08-54-48-558Z.log new file mode 100644 index 00000000..5d0bf060 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-54-48-558Z.log @@ -0,0 +1,14 @@ +[ 90ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/discounts:54 +[ 3711ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/settings/taxes:54 +[ 3727ms] TypeError: ((intermediate value) || (intermediate value))(...) is not a function + at set checked (http://admin.acme-fashion.test/flux/flux.js?id=dca30f4a:128:2900) + at bindInputValue (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:2250:22) + at bind (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:2224:9) + at http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:3732:23 + at mutateDom (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:1067:18) + at el._x_forceModelUpdate (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:3732:7) + at http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:3739:10 + at reactiveEffect (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:2693:18) + at Object.effect2 [as effect] (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:2668:7) + at effect (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:935:35) +[ 18078ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/settings:54 diff --git a/.playwright-mcp/console-2026-07-26T08-55-12-424Z.log b/.playwright-mcp/console-2026-07-26T08-55-12-424Z.log new file mode 100644 index 00000000..9e9c7bde --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-55-12-424Z.log @@ -0,0 +1,2 @@ +[ 135ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/collections:54 +[ 9975ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/themes:54 diff --git a/.playwright-mcp/console-2026-07-26T08-55-22-964Z.log b/.playwright-mcp/console-2026-07-26T08-55-22-964Z.log new file mode 100644 index 00000000..9b57d672 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-55-22-964Z.log @@ -0,0 +1 @@ +[ 88ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/customers:54 diff --git a/.playwright-mcp/console-2026-07-26T08-55-43-925Z.log b/.playwright-mcp/console-2026-07-26T08-55-43-925Z.log new file mode 100644 index 00000000..3726a9d9 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-55-43-925Z.log @@ -0,0 +1,2 @@ +[ 135ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/settings:54 +[ 8604ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/pages:54 diff --git a/.playwright-mcp/console-2026-07-26T08-55-54-929Z.log b/.playwright-mcp/console-2026-07-26T08-55-54-929Z.log new file mode 100644 index 00000000..76ffef11 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-55-54-929Z.log @@ -0,0 +1 @@ +[ 100ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/settings/shipping:54 diff --git a/.playwright-mcp/console-2026-07-26T08-56-06-832Z.log b/.playwright-mcp/console-2026-07-26T08-56-06-832Z.log new file mode 100644 index 00000000..d83dc422 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-56-06-832Z.log @@ -0,0 +1,15 @@ +[ 103ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/settings/taxes:54 +[ 120ms] TypeError: ((intermediate value) || (intermediate value))(...) is not a function + at set checked (http://admin.acme-fashion.test/flux/flux.js?id=dca30f4a:128:2900) + at bindInputValue (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:2250:22) + at bind (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:2224:9) + at http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:3732:23 + at mutateDom (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:1067:18) + at el._x_forceModelUpdate (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:3732:7) + at http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:3739:10 + at reactiveEffect (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:2693:18) + at Object.effect2 [as effect] (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:2668:7) + at effect (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:935:35) +[ 15844ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/navigation:54 +[ 45971ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/analytics:54 +[ 76095ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/search/settings:54 diff --git a/.playwright-mcp/console-2026-07-26T08-57-34-637Z.log b/.playwright-mcp/console-2026-07-26T08-57-34-637Z.log new file mode 100644 index 00000000..c4edddaf --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-57-34-637Z.log @@ -0,0 +1 @@ +[ 106ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/settings/taxes:54 diff --git a/.playwright-mcp/console-2026-07-26T08-57-46-673Z.log b/.playwright-mcp/console-2026-07-26T08-57-46-673Z.log new file mode 100644 index 00000000..13a654b1 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-57-46-673Z.log @@ -0,0 +1,18 @@ +[ 92ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/themes:54 +[ 6383ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/apps:54 +[ 6393ms] [WARNING] Alpine Expression Error: Invalid or unexpected token + +Expression: "argumentsToArray(@js($entry['name']))" + + JSHandle@node @ http://admin.acme-fashion.test/admin/apps:124 +[ 6401ms] SyntaxError: Invalid or unexpected token + at new AsyncFunction () + at safeAsyncFunction (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:1385:21) + at generateFunctionFromString (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:1398:16) + at generateEvaluatorFromString (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:1403:16) + at normalEvaluator (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:1361:111) + at evaluateLater (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:1347:12) + at Object.evaluate (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:1343:5) + at Directive.parseOutMethodsAndParams (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:5783:29) + at get methods (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:5764:19) + at getTargets (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:14665:18) diff --git a/.playwright-mcp/console-2026-07-26T08-57-56-369Z.log b/.playwright-mcp/console-2026-07-26T08-57-56-369Z.log new file mode 100644 index 00000000..585432a8 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-57-56-369Z.log @@ -0,0 +1 @@ +[ 93ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/pages:54 diff --git a/.playwright-mcp/console-2026-07-26T08-58-07-517Z.log b/.playwright-mcp/console-2026-07-26T08-58-07-517Z.log new file mode 100644 index 00000000..c0f2de62 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-58-07-517Z.log @@ -0,0 +1 @@ +[ 111ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/navigation:54 diff --git a/.playwright-mcp/console-2026-07-26T08-58-18-442Z.log b/.playwright-mcp/console-2026-07-26T08-58-18-442Z.log new file mode 100644 index 00000000..7fe33c91 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-58-18-442Z.log @@ -0,0 +1,2 @@ +[ 92ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/analytics:54 +[ 4767ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/developers:54 diff --git a/.playwright-mcp/console-2026-07-26T08-58-42-762Z.log b/.playwright-mcp/console-2026-07-26T08-58-42-762Z.log new file mode 100644 index 00000000..e425cfaa --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-58-42-762Z.log @@ -0,0 +1 @@ +[ 107ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/search/settings:54 diff --git a/.playwright-mcp/console-2026-07-26T08-58-53-050Z.log b/.playwright-mcp/console-2026-07-26T08-58-53-050Z.log new file mode 100644 index 00000000..44f19050 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T08-58-53-050Z.log @@ -0,0 +1,18 @@ +[ 95ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/apps:54 +[ 106ms] [WARNING] Alpine Expression Error: Invalid or unexpected token + +Expression: "argumentsToArray(@js($entry['name']))" + + JSHandle@node @ http://admin.acme-fashion.test/admin/apps:124 +[ 115ms] SyntaxError: Invalid or unexpected token + at new AsyncFunction () + at safeAsyncFunction (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:1385:21) + at generateFunctionFromString (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:1398:16) + at generateEvaluatorFromString (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:1403:16) + at normalEvaluator (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:1361:111) + at evaluateLater (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:1347:12) + at Object.evaluate (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:1343:5) + at Directive.parseOutMethodsAndParams (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:5783:29) + at get methods (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:5764:19) + at getTargets (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:14665:18) +[ 278ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/inventory:54 diff --git a/.playwright-mcp/console-2026-07-26T09-00-54-166Z.log b/.playwright-mcp/console-2026-07-26T09-00-54-166Z.log new file mode 100644 index 00000000..409e78dc --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-00-54-166Z.log @@ -0,0 +1 @@ +[ 140ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/apps:54 diff --git a/.playwright-mcp/console-2026-07-26T09-01-44-430Z.log b/.playwright-mcp/console-2026-07-26T09-01-44-430Z.log new file mode 100644 index 00000000..73825d4a --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-01-44-430Z.log @@ -0,0 +1,17 @@ +[ 134ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/developers:54 +[ 148542ms] [WARNING] Alpine Expression Error: Invalid or unexpected token + +Expression: "navigator.clipboard.writeText(@js($generatedToken))" + + JSHandle@node @ http://admin.acme-fashion.test/admin/developers:124 +[ 148560ms] SyntaxError: Invalid or unexpected token + at new AsyncFunction () + at safeAsyncFunction (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:1385:21) + at generateFunctionFromString (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:1398:16) + at generateEvaluatorFromString (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:1403:16) + at normalEvaluator (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:1361:111) + at evaluateLater (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:1347:12) + at http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:4206:35 + at Function. (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:2162:58) + at flushHandlers (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:1527:48) + at stopDeferring (http://admin.acme-fashion.test/livewire-0972654c/livewire.js?id=cfc5c1ae:1532:7) diff --git a/.playwright-mcp/console-2026-07-26T09-05-58-490Z.log b/.playwright-mcp/console-2026-07-26T09-05-58-490Z.log new file mode 100644 index 00000000..56ea653d --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-05-58-490Z.log @@ -0,0 +1 @@ +[ 187ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/developers:54 diff --git a/.playwright-mcp/console-2026-07-26T09-08-05-152Z.log b/.playwright-mcp/console-2026-07-26T09-08-05-152Z.log new file mode 100644 index 00000000..5b3dbeb2 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-08-05-152Z.log @@ -0,0 +1,2 @@ +[ 107ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/account/register:100 +[ 37140ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/account:100 diff --git a/.playwright-mcp/console-2026-07-26T09-09-06-094Z.log b/.playwright-mcp/console-2026-07-26T09-09-06-094Z.log new file mode 100644 index 00000000..35836e12 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-09-06-094Z.log @@ -0,0 +1 @@ +[ 113ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/account/addresses:100 diff --git a/.playwright-mcp/console-2026-07-26T09-10-43-758Z.log b/.playwright-mcp/console-2026-07-26T09-10-43-758Z.log new file mode 100644 index 00000000..315dbc81 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-10-43-758Z.log @@ -0,0 +1 @@ +[ 291ms] [ERROR] Failed to load resource: the server responded with a status of 405 (Method Not Allowed) @ http://acme-fashion.test/account/logout:0 diff --git a/.playwright-mcp/console-2026-07-26T09-10-57-856Z.log b/.playwright-mcp/console-2026-07-26T09-10-57-856Z.log new file mode 100644 index 00000000..a579b4b4 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-10-57-856Z.log @@ -0,0 +1,3 @@ +[ 121ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/account:100 +[ 21107ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/account/login:100 +[ 56932ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/account:100 diff --git a/.playwright-mcp/console-2026-07-26T09-12-07-041Z.log b/.playwright-mcp/console-2026-07-26T09-12-07-041Z.log new file mode 100644 index 00000000..a8f60a8b --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-12-07-041Z.log @@ -0,0 +1,2 @@ +[ 90ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/account/orders:100 +[ 24574ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/account/orders/1001:100 diff --git a/.playwright-mcp/console-2026-07-26T09-13-09-510Z.log b/.playwright-mcp/console-2026-07-26T09-13-09-510Z.log new file mode 100644 index 00000000..2f99aa42 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-13-09-510Z.log @@ -0,0 +1 @@ +[ 107ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/products/limited-edition-sneakers:107 diff --git a/.playwright-mcp/console-2026-07-26T09-13-29-930Z.log b/.playwright-mcp/console-2026-07-26T09-13-29-930Z.log new file mode 100644 index 00000000..16127a17 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-13-29-930Z.log @@ -0,0 +1 @@ +[ 105ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/products/backorder-denim-jacket:107 diff --git a/.playwright-mcp/console-2026-07-26T09-14-10-904Z.log b/.playwright-mcp/console-2026-07-26T09-14-10-904Z.log new file mode 100644 index 00000000..fc255e9d --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-14-10-904Z.log @@ -0,0 +1,2 @@ +[ 97ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/:100 +[ 96718ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/search?q=cotton:100 diff --git a/.playwright-mcp/console-2026-07-26T09-16-14-823Z.log b/.playwright-mcp/console-2026-07-26T09-16-14-823Z.log new file mode 100644 index 00000000..4c29dea3 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-16-14-823Z.log @@ -0,0 +1 @@ +[ 96ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/collections/t-shirts:101 diff --git a/.playwright-mcp/console-2026-07-26T09-23-40-510Z.log b/.playwright-mcp/console-2026-07-26T09-23-40-510Z.log new file mode 100644 index 00000000..b55837fb --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-23-40-510Z.log @@ -0,0 +1 @@ +[ 147ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/collections/t-shirts:101 diff --git a/.playwright-mcp/console-2026-07-26T09-25-23-000Z.log b/.playwright-mcp/console-2026-07-26T09-25-23-000Z.log new file mode 100644 index 00000000..869c02a1 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-25-23-000Z.log @@ -0,0 +1 @@ +[ 114ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/products/organic-hoodie:107 diff --git a/.playwright-mcp/console-2026-07-26T09-25-46-263Z.log b/.playwright-mcp/console-2026-07-26T09-25-46-263Z.log new file mode 100644 index 00000000..3a6c5f9a --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-25-46-263Z.log @@ -0,0 +1,4 @@ +[ 96ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/cart:100 +[ 143082ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/checkout/new:100 +[ 185654ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/checkout/3:100 +[ 275301ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/checkout/3/confirmation:100 diff --git a/.playwright-mcp/console-2026-07-26T09-30-38-718Z.log b/.playwright-mcp/console-2026-07-26T09-30-38-718Z.log new file mode 100644 index 00000000..1c727b14 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-30-38-718Z.log @@ -0,0 +1,3 @@ +[ 92ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin:54 +[ 35571ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/login:45 +[ 65361ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin:54 diff --git a/.playwright-mcp/console-2026-07-26T09-32-11-523Z.log b/.playwright-mcp/console-2026-07-26T09-32-11-523Z.log new file mode 100644 index 00000000..e0f16790 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-32-11-523Z.log @@ -0,0 +1,2 @@ +[ 69ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://admin.acme-fashion.test/admin/settings:0 +[ 71ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/settings:24 diff --git a/.playwright-mcp/console-2026-07-26T09-32-28-872Z.log b/.playwright-mcp/console-2026-07-26T09-32-28-872Z.log new file mode 100644 index 00000000..be2a480f --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-32-28-872Z.log @@ -0,0 +1,4 @@ +[ 104ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin:54 +[ 72600ms] [ERROR] Failed to load resource: the server responded with a status of 419 (unknown status) @ http://admin.acme-fashion.test/admin/logout:0 +[ 72696ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin:54 +[ 273500ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/login:45 diff --git a/.playwright-mcp/console-2026-07-26T09-40-10-777Z.log b/.playwright-mcp/console-2026-07-26T09-40-10-777Z.log new file mode 100644 index 00000000..50aac85c --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-40-10-777Z.log @@ -0,0 +1,2 @@ +[ 74ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/login:45 +[ 98797ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin:54 diff --git a/.playwright-mcp/console-2026-07-26T09-42-32-101Z.log b/.playwright-mcp/console-2026-07-26T09-42-32-101Z.log new file mode 100644 index 00000000..824b4e67 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-42-32-101Z.log @@ -0,0 +1 @@ +[ 112ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/orders/19:54 diff --git a/.playwright-mcp/console-2026-07-26T09-43-08-701Z.log b/.playwright-mcp/console-2026-07-26T09-43-08-701Z.log new file mode 100644 index 00000000..b0f74977 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-43-08-701Z.log @@ -0,0 +1 @@ +[ 108ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/:100 diff --git a/.playwright-mcp/console-2026-07-26T09-44-15-673Z.log b/.playwright-mcp/console-2026-07-26T09-44-15-673Z.log new file mode 100644 index 00000000..eae486b7 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-44-15-673Z.log @@ -0,0 +1 @@ +[ 104ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/products:54 diff --git a/.playwright-mcp/console-2026-07-26T09-47-48-171Z.log b/.playwright-mcp/console-2026-07-26T09-47-48-171Z.log new file mode 100644 index 00000000..5b9c1b20 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-47-48-171Z.log @@ -0,0 +1 @@ +[ 145ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/products:54 diff --git a/.playwright-mcp/console-2026-07-26T09-48-25-828Z.log b/.playwright-mcp/console-2026-07-26T09-48-25-828Z.log new file mode 100644 index 00000000..8d5dfda0 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-48-25-828Z.log @@ -0,0 +1 @@ +[ 136ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/products:54 diff --git a/.playwright-mcp/console-2026-07-26T09-48-50-154Z.log b/.playwright-mcp/console-2026-07-26T09-48-50-154Z.log new file mode 100644 index 00000000..dc840308 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-48-50-154Z.log @@ -0,0 +1 @@ +[ 100ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/orders/20:54 diff --git a/.playwright-mcp/console-2026-07-26T09-51-01-466Z.log b/.playwright-mcp/console-2026-07-26T09-51-01-466Z.log new file mode 100644 index 00000000..b5218022 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-51-01-466Z.log @@ -0,0 +1 @@ +[ 125ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/orders/20:54 diff --git a/.playwright-mcp/console-2026-07-26T09-51-25-221Z.log b/.playwright-mcp/console-2026-07-26T09-51-25-221Z.log new file mode 100644 index 00000000..5f505b62 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-51-25-221Z.log @@ -0,0 +1 @@ +[ 94ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/:100 diff --git a/.playwright-mcp/console-2026-07-26T09-51-53-504Z.log b/.playwright-mcp/console-2026-07-26T09-51-53-504Z.log new file mode 100644 index 00000000..62c5d871 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-51-53-504Z.log @@ -0,0 +1,3 @@ +[ 108ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin:54 +[ 11434ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/login:45 +[ 23888ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin:54 diff --git a/.playwright-mcp/console-2026-07-26T09-52-44-283Z.log b/.playwright-mcp/console-2026-07-26T09-52-44-283Z.log new file mode 100644 index 00000000..390e579f --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T09-52-44-283Z.log @@ -0,0 +1 @@ +[ 96ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/products:54 diff --git a/.playwright-mcp/console-2026-07-26T21-00-43-663Z.log b/.playwright-mcp/console-2026-07-26T21-00-43-663Z.log new file mode 100644 index 00000000..6197a86f --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T21-00-43-663Z.log @@ -0,0 +1,2 @@ +[ 93ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/account/login:0 +[ 125ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/login:24 diff --git a/.playwright-mcp/console-2026-07-26T21-00-51-412Z.log b/.playwright-mcp/console-2026-07-26T21-00-51-412Z.log new file mode 100644 index 00000000..42d6d782 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T21-00-51-412Z.log @@ -0,0 +1 @@ +[ 81ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/account/login:100 diff --git a/.playwright-mcp/console-2026-07-26T21-17-40-817Z.log b/.playwright-mcp/console-2026-07-26T21-17-40-817Z.log new file mode 100644 index 00000000..5062ea6b --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T21-17-40-817Z.log @@ -0,0 +1,3 @@ +[ 296ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/login:45 +[ 11094ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin:54 +[ 12721ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/discounts/create:54 diff --git a/.playwright-mcp/console-2026-07-26T21-36-48-029Z.log b/.playwright-mcp/console-2026-07-26T21-36-48-029Z.log new file mode 100644 index 00000000..c805e3e4 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T21-36-48-029Z.log @@ -0,0 +1 @@ +[ 154ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/:100 diff --git a/.playwright-mcp/console-2026-07-26T21-37-34-017Z.log b/.playwright-mcp/console-2026-07-26T21-37-34-017Z.log new file mode 100644 index 00000000..4edf37e9 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T21-37-34-017Z.log @@ -0,0 +1,4 @@ +[ 119ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/products/classic-cotton-t-shirt:107 +[ 65878ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/checkout/new:100 +[ 118643ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/checkout/1:100 +[ 227687ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/checkout/1/confirmation:100 diff --git a/.playwright-mcp/console-2026-07-26T21-41-59-552Z.log b/.playwright-mcp/console-2026-07-26T21-41-59-552Z.log new file mode 100644 index 00000000..eae7dfa8 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T21-41-59-552Z.log @@ -0,0 +1 @@ +[ 115ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin:54 diff --git a/.playwright-mcp/console-2026-07-26T21-42-34-463Z.log b/.playwright-mcp/console-2026-07-26T21-42-34-463Z.log new file mode 100644 index 00000000..902ee373 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T21-42-34-463Z.log @@ -0,0 +1 @@ +[ 110ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/products:54 diff --git a/.playwright-mcp/console-2026-07-26T21-43-08-289Z.log b/.playwright-mcp/console-2026-07-26T21-43-08-289Z.log new file mode 100644 index 00000000..7dae4e09 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T21-43-08-289Z.log @@ -0,0 +1,2 @@ +[ 71ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://admin.acme-fashion.test/admin/orders/16:0 +[ 73ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/orders/16:24 diff --git a/.playwright-mcp/console-2026-07-26T21-43-28-110Z.log b/.playwright-mcp/console-2026-07-26T21-43-28-110Z.log new file mode 100644 index 00000000..0b9a8926 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T21-43-28-110Z.log @@ -0,0 +1 @@ +[ 93ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/orders/19:54 diff --git a/.playwright-mcp/console-2026-07-26T21-44-09-762Z.log b/.playwright-mcp/console-2026-07-26T21-44-09-762Z.log new file mode 100644 index 00000000..595c29d5 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T21-44-09-762Z.log @@ -0,0 +1 @@ +[ 144ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin/analytics:54 diff --git a/.playwright-mcp/console-2026-07-26T21-44-44-263Z.log b/.playwright-mcp/console-2026-07-26T21-44-44-263Z.log new file mode 100644 index 00000000..8b83f514 --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T21-44-44-263Z.log @@ -0,0 +1 @@ +[ 84ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/:100 diff --git a/.playwright-mcp/console-2026-07-26T21-45-46-956Z.log b/.playwright-mcp/console-2026-07-26T21-45-46-956Z.log new file mode 100644 index 00000000..727fb5bd --- /dev/null +++ b/.playwright-mcp/console-2026-07-26T21-45-46-956Z.log @@ -0,0 +1 @@ +[ 93ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://admin.acme-fashion.test/_boost/browser-logs @ http://admin.acme-fashion.test/admin:54 diff --git a/.playwright-mcp/page-2026-07-18T09-15-35-387Z.yml b/.playwright-mcp/page-2026-07-18T09-15-35-387Z.yml new file mode 100644 index 00000000..ab458677 --- /dev/null +++ b/.playwright-mcp/page-2026-07-18T09-15-35-387Z.yml @@ -0,0 +1,26 @@ +- generic [active] [ref=f1e1]: + - banner [ref=f1e2]: + - navigation [ref=f1e3]: + - link "Log in" [ref=f1e4] [cursor=pointer]: + - /url: http://shop.test/login + - link "Register" [ref=f1e5] [cursor=pointer]: + - /url: http://shop.test/register + - main [ref=f1e7]: + - generic [ref=f1e8]: + - heading "Let's get started" [level=1] [ref=f1e9] + - paragraph [ref=f1e10]: Laravel has an incredibly rich ecosystem. We suggest starting with the following. + - list [ref=f1e11]: + - listitem [ref=f1e12]: + - generic [ref=f1e16]: + - text: Read the + - link "Documentation" [ref=f1e17] [cursor=pointer]: + - /url: https://laravel.com/docs + - listitem [ref=f1e21]: + - generic [ref=f1e25]: + - text: Watch video tutorials at + - link "Laracasts" [ref=f1e26] [cursor=pointer]: + - /url: https://laracasts.com + - list [ref=f1e30]: + - listitem [ref=f1e31]: + - link "Deploy now" [ref=f1e32] [cursor=pointer]: + - /url: https://cloud.laravel.com \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-18T11-28-30-586Z.yml b/.playwright-mcp/page-2026-07-18T11-28-30-586Z.yml new file mode 100644 index 00000000..06a51217 --- /dev/null +++ b/.playwright-mcp/page-2026-07-18T11-28-30-586Z.yml @@ -0,0 +1,159 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - banner [ref=e3]: + - generic [ref=e4]: + - link "Acme Fashion" [ref=e5] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main" [ref=e6]: + - link "Home" [ref=e7] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=e8] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - link "T-Shirts" [ref=e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - link "Pants & Jeans" [ref=e10] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/pants-jeans + - link "Sale" [ref=e11] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/sale + - generic [ref=e12]: + - link "Search" [ref=e13] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - button "Open cart" [ref=e17] + - link "Account" [ref=e20] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - main [ref=e23]: + - generic [ref=e24]: + - generic [ref=e26]: + - heading "Welcome to Acme Fashion" [level=1] [ref=e27] + - paragraph [ref=e28]: Discover our latest collections and find something you'll love. + - link "Shop now" [ref=e30] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - generic [ref=e31]: + - heading "Shop by Collection" [level=2] [ref=e32] + - generic [ref=e33]: + - link [ref=e34] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - paragraph [ref=e36]: New Arrivals + - link [ref=e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/pants-jeans + - paragraph [ref=e39]: Pants & Jeans + - link [ref=e40] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/sale + - paragraph [ref=e42]: Sale + - link [ref=e43] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - paragraph [ref=e45]: T-Shirts + - generic [ref=e46]: + - heading "Featured Products" [level=2] [ref=e47] + - generic [ref=e48]: + - generic [ref=e49]: + - link [ref=e50] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cashmere-overcoat + - heading "Cashmere Overcoat" [level=3] [ref=e55] + - generic [ref=e56]: 499.99 EUR + - link "Choose options" [ref=e59] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cashmere-overcoat + - generic [ref=e60]: + - link [ref=e61] [cursor=pointer]: + - /url: http://acme-fashion.test/products/gift-card + - heading "Gift Card" [level=3] [ref=e66] + - generic [ref=e67]: 25.00 EUR + - link "Choose options" [ref=e70] [cursor=pointer]: + - /url: http://acme-fashion.test/products/gift-card + - generic [ref=e71]: + - link [ref=e72] [cursor=pointer]: + - /url: http://acme-fashion.test/products/backorder-denim-jacket + - heading "Backorder Denim Jacket" [level=3] [ref=e77] + - generic [ref=e78]: 99.99 EUR + - link "Choose options" [ref=e81] [cursor=pointer]: + - /url: http://acme-fashion.test/products/backorder-denim-jacket + - generic [ref=e82]: + - link "Sold out Limited Edition Sneakers" [ref=e83] [cursor=pointer]: + - /url: http://acme-fashion.test/products/limited-edition-sneakers + - generic [ref=e84]: Sold out + - heading "Limited Edition Sneakers" [level=3] [ref=e90] + - generic [ref=e91]: 159.99 EUR + - link "Choose options" [ref=e94] [cursor=pointer]: + - /url: http://acme-fashion.test/products/limited-edition-sneakers + - generic [ref=e95]: + - link [ref=e96] [cursor=pointer]: + - /url: http://acme-fashion.test/products/bucket-hat + - heading "Bucket Hat" [level=3] [ref=e101] + - generic [ref=e102]: 24.99 EUR + - link "Choose options" [ref=e105] [cursor=pointer]: + - /url: http://acme-fashion.test/products/bucket-hat + - generic [ref=e106]: + - link [ref=e107] [cursor=pointer]: + - /url: http://acme-fashion.test/products/canvas-tote-bag + - heading "Canvas Tote Bag" [level=3] [ref=e112] + - generic [ref=e113]: 19.99 EUR + - link "Choose options" [ref=e116] [cursor=pointer]: + - /url: http://acme-fashion.test/products/canvas-tote-bag + - generic [ref=e117]: + - link [ref=e118] [cursor=pointer]: + - /url: http://acme-fashion.test/products/wool-scarf + - heading "Wool Scarf" [level=3] [ref=e123] + - generic [ref=e124]: 29.99 EUR + - link "Choose options" [ref=e127] [cursor=pointer]: + - /url: http://acme-fashion.test/products/wool-scarf + - generic [ref=e128]: + - link "Sale Wide Leg Trousers" [ref=e129] [cursor=pointer]: + - /url: http://acme-fashion.test/products/wide-leg-trousers + - generic [ref=e130]: Sale + - heading "Wide Leg Trousers" [level=3] [ref=e136] + - generic [ref=e138]: + - generic [ref=e139]: 49.99 EUR + - generic [ref=e140]: 69.99 EUR + - generic [ref=e141]: Sale + - link "Choose options" [ref=e142] [cursor=pointer]: + - /url: http://acme-fashion.test/products/wide-leg-trousers + - generic [ref=e144]: + - heading "Stay in the loop" [level=2] [ref=e145] + - paragraph [ref=e146]: Subscribe for exclusive offers and new arrivals. + - generic [ref=e147]: + - textbox "Email address" [ref=e149]: + - /placeholder: Your email address + - button "Subscribe" [ref=e150] + - contentinfo [ref=e156]: + - generic [ref=e157]: + - generic [ref=e158]: + - generic [ref=e159]: + - heading "Shop" [level=3] [ref=e160] + - list [ref=e161]: + - listitem [ref=e162]: + - link "About Us" [ref=e163] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/about + - listitem [ref=e164]: + - link "FAQ" [ref=e165] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/faq + - listitem [ref=e166]: + - link "Shipping & Returns" [ref=e167] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/shipping-returns + - listitem [ref=e168]: + - link "Privacy Policy" [ref=e169] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/privacy-policy + - listitem [ref=e170]: + - link "Terms of Service" [ref=e171] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/terms + - generic [ref=e172]: + - heading "Acme Fashion" [level=3] [ref=e173] + - paragraph [ref=e175]: Acme Fashion + - generic [ref=e176]: + - link "Acme Fashion on Facebook" [ref=e177] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on Instagram" [ref=e180] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on Twitter/X" [ref=e183] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on TikTok" [ref=e186] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on YouTube" [ref=e189] [cursor=pointer]: + - /url: "#" + - generic [ref=e192]: + - paragraph [ref=e193]: © 2026 Acme Fashion. All rights reserved. + - generic [ref=e194]: + - generic [ref=e195]: Visa + - generic [ref=e196]: Mastercard + - generic [ref=e197]: Amex + - generic [ref=e198]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-18T11-28-40-022Z.yml b/.playwright-mcp/page-2026-07-18T11-28-40-022Z.yml new file mode 100644 index 00000000..ab1205f2 --- /dev/null +++ b/.playwright-mcp/page-2026-07-18T11-28-40-022Z.yml @@ -0,0 +1,147 @@ +- generic [active] [ref=e199]: + - link "Skip to main content" [ref=e200] [cursor=pointer]: + - /url: "#main-content" + - banner [ref=e201]: + - generic [ref=e202]: + - link "Acme Fashion" [ref=e203] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main" [ref=e204]: + - link "Home" [ref=e205] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=e206] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - link "T-Shirts" [ref=e207] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - link "Pants & Jeans" [ref=e208] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/pants-jeans + - link "Sale" [ref=e209] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/sale + - generic [ref=e210]: + - link "Search" [ref=e211] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - button "Open cart" [ref=e215] + - link "Account" [ref=e218] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - main [ref=e221]: + - generic [ref=e222]: + - navigation "Breadcrumb" [ref=e223]: + - list [ref=e224]: + - listitem [ref=e225]: + - link "Home" [ref=e226] [cursor=pointer]: + - /url: http://acme-fashion.test + - generic [ref=e227]: / + - listitem [ref=e228]: + - link "Collections" [ref=e229] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - generic [ref=e230]: / + - listitem [ref=e231]: + - generic [ref=e232]: T-Shirts + - generic [ref=e233]: + - generic [ref=e234]: + - heading "T-Shirts" [level=1] [ref=e235] + - paragraph [ref=e237]: Premium cotton tees for every occasion. + - generic [ref=e238]: + - generic [ref=e239]: Sort by + - combobox "Sort by" [ref=e240]: + - option "Featured" [selected] + - option "Newest" + - 'option "Price: Low to High"' + - 'option "Price: High to Low"' + - generic [ref=e241]: + - complementary "Filters" [ref=e242]: + - heading "Filters" [level=2] [ref=e244] + - generic [ref=e245]: + - generic [ref=e246]: + - checkbox "In stock only" [ref=e247] + - text: In stock only + - generic [ref=e248]: + - paragraph [ref=e249]: Price + - generic [ref=e250]: + - spinbutton "Minimum price" [ref=e252] + - generic [ref=e254]: "-" + - spinbutton "Maximum price" [ref=e256] + - generic [ref=e258]: + - paragraph [ref=e259]: Product type + - generic [ref=e261]: + - checkbox "T-Shirts" [ref=e262] + - text: T-Shirts + - generic [ref=e263]: + - paragraph [ref=e264]: Vendor + - generic [ref=e266]: + - checkbox "Acme Basics" [ref=e267] + - text: Acme Basics + - generic [ref=e269]: + - generic [ref=e270]: + - link [ref=e271] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=e276] + - generic [ref=e277]: 24.99 EUR + - link "Choose options" [ref=e280] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=e281]: + - link [ref=e282] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=e287] + - generic [ref=e288]: 29.99 EUR + - link "Choose options" [ref=e291] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - generic [ref=e292]: + - link [ref=e293] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - heading "V-Neck Linen Tee" [level=3] [ref=e298] + - generic [ref=e299]: 34.99 EUR + - link "Choose options" [ref=e302] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - generic [ref=e303]: + - link "Sale Striped Polo Shirt" [ref=e304] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - generic [ref=e305]: Sale + - heading "Striped Polo Shirt" [level=3] [ref=e311] + - generic [ref=e313]: + - generic [ref=e314]: 27.99 EUR + - generic [ref=e315]: 39.99 EUR + - generic [ref=e316]: Sale + - link "Choose options" [ref=e317] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - contentinfo [ref=e318]: + - generic [ref=e319]: + - generic [ref=e320]: + - generic [ref=e321]: + - heading "Shop" [level=3] [ref=e322] + - list [ref=e323]: + - listitem [ref=e324]: + - link "About Us" [ref=e325] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/about + - listitem [ref=e326]: + - link "FAQ" [ref=e327] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/faq + - listitem [ref=e328]: + - link "Shipping & Returns" [ref=e329] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/shipping-returns + - listitem [ref=e330]: + - link "Privacy Policy" [ref=e331] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/privacy-policy + - listitem [ref=e332]: + - link "Terms of Service" [ref=e333] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/terms + - generic [ref=e334]: + - heading "Acme Fashion" [level=3] [ref=e335] + - paragraph [ref=e337]: Acme Fashion + - generic [ref=e338]: + - link "Acme Fashion on Facebook" [ref=e339] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on Instagram" [ref=e342] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on Twitter/X" [ref=e345] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on TikTok" [ref=e348] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on YouTube" [ref=e351] [cursor=pointer]: + - /url: "#" + - generic [ref=e354]: + - paragraph [ref=e355]: © 2026 Acme Fashion. All rights reserved. + - generic [ref=e356]: + - generic [ref=e357]: Visa + - generic [ref=e358]: Mastercard + - generic [ref=e359]: Amex + - generic [ref=e360]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-18T11-28-44-401Z.yml b/.playwright-mcp/page-2026-07-18T11-28-44-401Z.yml new file mode 100644 index 00000000..ab7c7b77 --- /dev/null +++ b/.playwright-mcp/page-2026-07-18T11-28-44-401Z.yml @@ -0,0 +1,107 @@ +- generic [active] [ref=f1e1]: + - link "Skip to main content" [ref=f1e2] [cursor=pointer]: + - /url: "#main-content" + - banner [ref=f1e3]: + - generic [ref=f1e4]: + - link "Acme Fashion" [ref=f1e5] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main" [ref=f1e6]: + - link "Home" [ref=f1e7] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f1e8] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - link "T-Shirts" [ref=f1e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - link "Pants & Jeans" [ref=f1e10] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/pants-jeans + - link "Sale" [ref=f1e11] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/sale + - generic [ref=f1e12]: + - link "Search" [ref=f1e13] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - button "Open cart" [ref=f1e17] + - link "Account" [ref=f1e20] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - main [ref=f1e23]: + - generic [ref=f1e24]: + - navigation "Breadcrumb" [ref=f1e25]: + - list [ref=f1e26]: + - listitem [ref=f1e27]: + - link "Home" [ref=f1e28] [cursor=pointer]: + - /url: http://acme-fashion.test + - generic [ref=f1e29]: / + - listitem [ref=f1e30]: + - link "New Arrivals" [ref=f1e31] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - generic [ref=f1e32]: / + - listitem [ref=f1e33]: + - generic [ref=f1e34]: Classic Cotton T-Shirt + - generic [ref=f1e35]: + - region "Product images" [ref=f1e36] + - generic [ref=f1e41]: + - heading "Classic Cotton T-Shirt" [level=1] [ref=f1e42] + - generic [ref=f1e43]: 24.99 EUR + - group "Size" [ref=f1e46]: + - generic [ref=f1e48]: + - button "S" [pressed] [ref=f1e49] + - button "M" [ref=f1e50] + - button "L" [ref=f1e51] + - button "XL" [ref=f1e52] + - group "Color" [ref=f1e53]: + - generic [ref=f1e55]: + - button "White" [pressed] [ref=f1e56] + - button "Black" [ref=f1e57] + - button "Navy" [ref=f1e58] + - generic [ref=f1e59]: In stock + - generic [ref=f1e64]: + - button "Decrease quantity" [disabled] [ref=f1e65] + - generic [ref=f1e67]: Quantity + - spinbutton "Quantity" [ref=f1e68]: "1" + - button "Increase quantity" [ref=f1e69] + - button "Add to cart" [ref=f1e72] + - paragraph [ref=f1e79]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - generic [ref=f1e80]: + - generic [ref=f1e81]: new + - generic [ref=f1e82]: popular + - contentinfo [ref=f1e83]: + - generic [ref=f1e84]: + - generic [ref=f1e85]: + - generic [ref=f1e86]: + - heading "Shop" [level=3] [ref=f1e87] + - list [ref=f1e88]: + - listitem [ref=f1e89]: + - link "About Us" [ref=f1e90] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/about + - listitem [ref=f1e91]: + - link "FAQ" [ref=f1e92] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/faq + - listitem [ref=f1e93]: + - link "Shipping & Returns" [ref=f1e94] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/shipping-returns + - listitem [ref=f1e95]: + - link "Privacy Policy" [ref=f1e96] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/privacy-policy + - listitem [ref=f1e97]: + - link "Terms of Service" [ref=f1e98] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/terms + - generic [ref=f1e99]: + - heading "Acme Fashion" [level=3] [ref=f1e100] + - paragraph [ref=f1e102]: Acme Fashion + - generic [ref=f1e103]: + - link "Acme Fashion on Facebook" [ref=f1e104] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on Instagram" [ref=f1e107] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on Twitter/X" [ref=f1e110] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on TikTok" [ref=f1e113] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on YouTube" [ref=f1e116] [cursor=pointer]: + - /url: "#" + - generic [ref=f1e119]: + - paragraph [ref=f1e120]: © 2026 Acme Fashion. All rights reserved. + - generic [ref=f1e121]: + - generic [ref=f1e122]: Visa + - generic [ref=f1e123]: Mastercard + - generic [ref=f1e124]: Amex + - generic [ref=f1e125]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-18T11-28-51-361Z.yml b/.playwright-mcp/page-2026-07-18T11-28-51-361Z.yml new file mode 100644 index 00000000..813ca031 --- /dev/null +++ b/.playwright-mcp/page-2026-07-18T11-28-51-361Z.yml @@ -0,0 +1,144 @@ +- generic [active] [ref=f1e1]: + - link "Skip to main content" [ref=f1e2] [cursor=pointer]: + - /url: "#main-content" + - banner [ref=f1e3]: + - generic [ref=f1e4]: + - link "Acme Fashion" [ref=f1e5] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main" [ref=f1e6]: + - link "Home" [ref=f1e7] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f1e8] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - link "T-Shirts" [ref=f1e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - link "Pants & Jeans" [ref=f1e10] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/pants-jeans + - link "Sale" [ref=f1e11] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/sale + - generic [ref=f1e12]: + - link "Search" [ref=f1e13] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - generic [ref=f1e16]: + - button "Open cart" [ref=f1e17]: + - generic [ref=f1e126]: "1" + - dialog "Shopping cart" [ref=f1e127]: + - generic [ref=f1e129]: + - generic [ref=f1e130]: + - heading "Your Cart (1)" [level=2] [ref=f1e131] + - button "Close cart" [ref=f1e132] + - list [ref=f1e136]: + - listitem [ref=f1e137]: + - generic [ref=f1e139]: + - paragraph [ref=f1e140]: Classic Cotton T-Shirt + - paragraph [ref=f1e141]: S / White + - generic [ref=f1e142]: + - generic [ref=f1e143]: + - button "Decrease quantity" [disabled] [ref=f1e144] + - generic [ref=f1e146]: Quantity + - spinbutton "Quantity" [ref=f1e147]: "1" + - button "Increase quantity" [ref=f1e148] + - generic [ref=f1e151]: 24.99 EUR + - button "Remove Classic Cotton T-Shirt from cart" [ref=f1e153] + - generic [ref=f1e156]: + - generic [ref=f1e157]: + - textbox "Discount code" [ref=f1e159] + - button "Apply" [ref=f1e160] + - generic [ref=f1e166]: + - generic [ref=f1e167]: + - term [ref=f1e168]: Subtotal + - definition [ref=f1e169]: + - generic [ref=f1e170]: 24.99 EUR + - generic [ref=f1e172]: + - term [ref=f1e173]: Estimated total + - definition [ref=f1e174]: + - generic [ref=f1e175]: 24.99 EUR + - paragraph [ref=f1e177]: Shipping and taxes calculated at checkout. + - button "Checkout" [ref=f1e178] + - button "Continue shopping" [ref=f1e185] + - link "Account" [ref=f1e20] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - main [ref=f1e23]: + - generic [ref=f1e24]: + - navigation "Breadcrumb" [ref=f1e25]: + - list [ref=f1e26]: + - listitem [ref=f1e27]: + - link "Home" [ref=f1e28] [cursor=pointer]: + - /url: http://acme-fashion.test + - generic [ref=f1e29]: / + - listitem [ref=f1e30]: + - link "New Arrivals" [ref=f1e31] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - generic [ref=f1e32]: / + - listitem [ref=f1e33]: + - generic [ref=f1e34]: Classic Cotton T-Shirt + - generic [ref=f1e35]: + - region "Product images" [ref=f1e36] + - generic [ref=f1e41]: + - heading "Classic Cotton T-Shirt" [level=1] [ref=f1e42] + - generic [ref=f1e43]: 24.99 EUR + - group "Size" [ref=f1e46]: + - generic [ref=f1e48]: + - button "S" [pressed] [ref=f1e49] + - button "M" [ref=f1e50] + - button "L" [ref=f1e51] + - button "XL" [ref=f1e52] + - group "Color" [ref=f1e53]: + - generic [ref=f1e55]: + - button "White" [pressed] [ref=f1e56] + - button "Black" [ref=f1e57] + - button "Navy" [ref=f1e58] + - generic [ref=f1e59]: In stock + - generic [ref=f1e64]: + - button "Decrease quantity" [disabled] [ref=f1e65] + - generic [ref=f1e67]: Quantity + - spinbutton "Quantity" [ref=f1e68]: "1" + - button "Increase quantity" [ref=f1e69] + - button "Add to cart" [ref=f1e72] + - status [ref=f1e186]: Added to cart + - paragraph [ref=f1e79]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - generic [ref=f1e80]: + - generic [ref=f1e81]: new + - generic [ref=f1e82]: popular + - contentinfo [ref=f1e83]: + - generic [ref=f1e84]: + - generic [ref=f1e85]: + - generic [ref=f1e86]: + - heading "Shop" [level=3] [ref=f1e87] + - list [ref=f1e88]: + - listitem [ref=f1e89]: + - link "About Us" [ref=f1e90] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/about + - listitem [ref=f1e91]: + - link "FAQ" [ref=f1e92] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/faq + - listitem [ref=f1e93]: + - link "Shipping & Returns" [ref=f1e94] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/shipping-returns + - listitem [ref=f1e95]: + - link "Privacy Policy" [ref=f1e96] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/privacy-policy + - listitem [ref=f1e97]: + - link "Terms of Service" [ref=f1e98] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/terms + - generic [ref=f1e99]: + - heading "Acme Fashion" [level=3] [ref=f1e100] + - paragraph [ref=f1e102]: Acme Fashion + - generic [ref=f1e103]: + - link "Acme Fashion on Facebook" [ref=f1e104] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on Instagram" [ref=f1e107] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on Twitter/X" [ref=f1e110] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on TikTok" [ref=f1e113] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on YouTube" [ref=f1e116] [cursor=pointer]: + - /url: "#" + - generic [ref=f1e119]: + - paragraph [ref=f1e120]: © 2026 Acme Fashion. All rights reserved. + - generic [ref=f1e121]: + - generic [ref=f1e122]: Visa + - generic [ref=f1e123]: Mastercard + - generic [ref=f1e124]: Amex + - generic [ref=f1e125]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-18T11-28-53-172Z.yml b/.playwright-mcp/page-2026-07-18T11-28-53-172Z.yml new file mode 100644 index 00000000..7a9171c3 --- /dev/null +++ b/.playwright-mcp/page-2026-07-18T11-28-53-172Z.yml @@ -0,0 +1,112 @@ +- generic [active] [ref=f2e1]: + - link "Skip to main content" [ref=f2e2] [cursor=pointer]: + - /url: "#main-content" + - banner [ref=f2e3]: + - generic [ref=f2e4]: + - link "Acme Fashion" [ref=f2e5] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main" [ref=f2e6]: + - link "Home" [ref=f2e7] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f2e8] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - link "T-Shirts" [ref=f2e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - link "Pants & Jeans" [ref=f2e10] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/pants-jeans + - link "Sale" [ref=f2e11] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/sale + - generic [ref=f2e12]: + - link "Search" [ref=f2e13] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - button "Open cart" [ref=f2e17]: + - generic [ref=f2e20]: "1" + - link "Account" [ref=f2e21] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - main [ref=f2e24]: + - generic [ref=f2e25]: + - heading "Your Cart" [level=1] [ref=f2e26] + - generic [ref=f2e27]: + - table [ref=f2e29]: + - rowgroup [ref=f2e30]: + - row [ref=f2e31]: + - columnheader "Product" [ref=f2e32] + - columnheader "Price" [ref=f2e33] + - columnheader "Quantity" [ref=f2e34] + - columnheader "Total" [ref=f2e35] + - columnheader "Remove" [ref=f2e36] + - rowgroup [ref=f2e38]: + - row [ref=f2e39]: + - cell "Classic Cotton T-Shirt S / White" [ref=f2e40]: + - generic [ref=f2e43]: + - paragraph [ref=f2e44]: Classic Cotton T-Shirt + - paragraph [ref=f2e45]: S / White + - cell "24.99 EUR" [ref=f2e46] + - cell "Decrease quantity Quantity Increase quantity" [ref=f2e49]: + - generic [ref=f2e50]: + - button "Decrease quantity" [disabled] [ref=f2e51] + - generic [ref=f2e53]: Quantity + - spinbutton [ref=f2e54]: "1" + - button "Increase quantity" [ref=f2e55] + - cell "24.99 EUR" [ref=f2e58] + - cell [ref=f2e61]: + - button "Remove Classic Cotton T-Shirt from cart" [ref=f2e62] + - generic [ref=f2e66]: + - generic [ref=f2e67]: + - textbox "Discount code" [ref=f2e69] + - button "Apply" [ref=f2e70] + - generic [ref=f2e76]: + - generic [ref=f2e77]: + - term [ref=f2e78]: Subtotal + - definition [ref=f2e79]: + - generic [ref=f2e80]: 24.99 EUR + - generic [ref=f2e82]: + - term [ref=f2e83]: Total + - definition [ref=f2e84]: + - generic [ref=f2e85]: 24.99 EUR + - paragraph [ref=f2e87]: Shipping and taxes calculated at checkout. + - button "Checkout" [ref=f2e88] + - link "Continue shopping" [ref=f2e95] [cursor=pointer]: + - /url: http://acme-fashion.test + - contentinfo [ref=f2e96]: + - generic [ref=f2e97]: + - generic [ref=f2e98]: + - generic [ref=f2e99]: + - heading "Shop" [level=3] [ref=f2e100] + - list [ref=f2e101]: + - listitem [ref=f2e102]: + - link "About Us" [ref=f2e103] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/about + - listitem [ref=f2e104]: + - link "FAQ" [ref=f2e105] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/faq + - listitem [ref=f2e106]: + - link "Shipping & Returns" [ref=f2e107] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/shipping-returns + - listitem [ref=f2e108]: + - link "Privacy Policy" [ref=f2e109] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/privacy-policy + - listitem [ref=f2e110]: + - link "Terms of Service" [ref=f2e111] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/terms + - generic [ref=f2e112]: + - heading "Acme Fashion" [level=3] [ref=f2e113] + - paragraph [ref=f2e115]: Acme Fashion + - generic [ref=f2e116]: + - link "Acme Fashion on Facebook" [ref=f2e117] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on Instagram" [ref=f2e120] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on Twitter/X" [ref=f2e123] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on TikTok" [ref=f2e126] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on YouTube" [ref=f2e129] [cursor=pointer]: + - /url: "#" + - generic [ref=f2e132]: + - paragraph [ref=f2e133]: © 2026 Acme Fashion. All rights reserved. + - generic [ref=f2e134]: + - generic [ref=f2e135]: Visa + - generic [ref=f2e136]: Mastercard + - generic [ref=f2e137]: Amex + - generic [ref=f2e138]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-18T11-28-59-558Z.yml b/.playwright-mcp/page-2026-07-18T11-28-59-558Z.yml new file mode 100644 index 00000000..92b53569 --- /dev/null +++ b/.playwright-mcp/page-2026-07-18T11-28-59-558Z.yml @@ -0,0 +1,159 @@ +- generic [ref=f3e1]: + - link "Skip to main content" [ref=f3e2] [cursor=pointer]: + - /url: "#main-content" + - banner [ref=f3e3]: + - generic [ref=f3e4]: + - link "Acme Fashion" [ref=f3e5] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main" [ref=f3e6]: + - link "Home" [ref=f3e7] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f3e8] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - link "T-Shirts" [ref=f3e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - link "Pants & Jeans" [ref=f3e10] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/pants-jeans + - link "Sale" [ref=f3e11] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/sale + - generic [ref=f3e12]: + - link "Search" [ref=f3e13] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - button "Open cart" [ref=f3e17]: + - generic [ref=f3e20]: "1" + - link "Account" [ref=f3e21] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - main [ref=f3e24]: + - generic [ref=f3e25]: + - heading "Checkout" [level=1] [ref=f3e26] + - generic [ref=f3e27]: + - generic [ref=f3e28]: + - generic [ref=f3e29]: + - heading "1. Contact & Shipping Address" [level=2] [ref=f3e31] + - generic [ref=f3e32]: + - generic [ref=f3e33]: + - generic [ref=f3e34]: + - text: Email + - generic [ref=f3e35]: "*" + - textbox [active] [ref=f3e37] + - paragraph [ref=f3e38]: + - link "Already have an account? Log in" [ref=f3e39] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=f3e40]: + - generic [ref=f3e41]: + - generic [ref=f3e42]: + - text: First name + - generic [ref=f3e43]: "*" + - textbox [ref=f3e45] + - generic [ref=f3e46]: + - generic [ref=f3e47]: + - text: Last name + - generic [ref=f3e48]: "*" + - textbox [ref=f3e50] + - generic [ref=f3e51]: + - generic [ref=f3e52]: + - text: Address line 1 + - generic [ref=f3e53]: "*" + - textbox [ref=f3e55] + - generic [ref=f3e56]: + - generic [ref=f3e57]: Address line 2 + - textbox [ref=f3e59] + - generic [ref=f3e60]: + - generic [ref=f3e61]: + - text: City + - generic [ref=f3e62]: "*" + - textbox [ref=f3e64] + - generic [ref=f3e65]: + - generic [ref=f3e66]: State / Province + - textbox [ref=f3e68] + - generic [ref=f3e69]: + - generic [ref=f3e70]: + - text: Postal code + - generic [ref=f3e71]: "*" + - textbox [ref=f3e73] + - generic [ref=f3e74]: + - generic [ref=f3e75]: + - text: Country + - generic [ref=f3e76]: "*" + - combobox [ref=f3e77]: + - option "Germany" [selected] + - option "Austria" + - option "Switzerland" + - option "United States" + - option "United Kingdom" + - option "France" + - generic [ref=f3e78]: + - generic [ref=f3e79]: Phone + - textbox [ref=f3e81] + - button "Continue to shipping" [ref=f3e82] + - heading "2. Shipping Method" [level=2] [ref=f3e90] + - heading "3. Payment Method & Pay" [level=2] [ref=f3e93] + - generic [ref=f3e96]: + - heading "Order Summary" [level=2] [ref=f3e97] + - list [ref=f3e98]: + - listitem [ref=f3e99]: + - generic [ref=f3e100]: "1" + - generic [ref=f3e102]: + - paragraph [ref=f3e103]: Classic Cotton T-Shirt + - paragraph [ref=f3e104]: S / White + - generic [ref=f3e105]: 24.99 EUR + - generic [ref=f3e108]: + - textbox "Discount code" [ref=f3e110] + - button "Apply" [ref=f3e111] + - generic [ref=f3e117]: + - generic [ref=f3e118]: + - term [ref=f3e119]: Subtotal + - definition [ref=f3e120]: + - generic [ref=f3e121]: 24.99 EUR + - generic [ref=f3e123]: + - term [ref=f3e124]: Shipping + - definition [ref=f3e125]: Calculated at next step + - generic [ref=f3e126]: + - term [ref=f3e127]: Tax + - definition [ref=f3e128]: 0.00 EUR + - generic [ref=f3e129]: + - term [ref=f3e130]: Total + - definition [ref=f3e131]: + - generic [ref=f3e132]: 24.99 EUR + - contentinfo [ref=f3e134]: + - generic [ref=f3e135]: + - generic [ref=f3e136]: + - generic [ref=f3e137]: + - heading "Shop" [level=3] [ref=f3e138] + - list [ref=f3e139]: + - listitem [ref=f3e140]: + - link "About Us" [ref=f3e141] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/about + - listitem [ref=f3e142]: + - link "FAQ" [ref=f3e143] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/faq + - listitem [ref=f3e144]: + - link "Shipping & Returns" [ref=f3e145] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/shipping-returns + - listitem [ref=f3e146]: + - link "Privacy Policy" [ref=f3e147] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/privacy-policy + - listitem [ref=f3e148]: + - link "Terms of Service" [ref=f3e149] [cursor=pointer]: + - /url: http://acme-fashion.test/pages/terms + - generic [ref=f3e150]: + - heading "Acme Fashion" [level=3] [ref=f3e151] + - paragraph [ref=f3e153]: Acme Fashion + - generic [ref=f3e154]: + - link "Acme Fashion on Facebook" [ref=f3e155] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on Instagram" [ref=f3e158] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on Twitter/X" [ref=f3e161] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on TikTok" [ref=f3e164] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on YouTube" [ref=f3e167] [cursor=pointer]: + - /url: "#" + - generic [ref=f3e170]: + - paragraph [ref=f3e171]: © 2026 Acme Fashion. All rights reserved. + - generic [ref=f3e172]: + - generic [ref=f3e173]: Visa + - generic [ref=f3e174]: Mastercard + - generic [ref=f3e175]: Amex + - generic [ref=f3e176]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-18T11-29-27-744Z.yml b/.playwright-mcp/page-2026-07-18T11-29-27-744Z.yml new file mode 100644 index 00000000..35ed2385 --- /dev/null +++ b/.playwright-mcp/page-2026-07-18T11-29-27-744Z.yml @@ -0,0 +1,23 @@ +- generic [ref=f5e3]: + - link "Shop" [ref=f5e4] [cursor=pointer]: + - /url: http://acme-fashion.test + - generic [ref=f5e10]: + - generic [ref=f5e11]: + - generic [ref=f5e12]: Admin sign in + - paragraph [ref=f5e13]: Manage your store from one place. + - generic [ref=f5e14]: + - generic [ref=f5e15]: + - generic [ref=f5e16]: Email address + - textbox "Email address" [active] [ref=f5e18] + - generic [ref=f5e19]: + - generic [ref=f5e20]: Password + - generic [ref=f5e21]: + - textbox "Password" [ref=f5e22] + - button "Toggle password visibility" [ref=f5e24] + - generic [ref=f5e28]: + - generic [ref=f5e29]: + - checkbox "Remember me" [ref=f5e30] + - generic [ref=f5e32]: Remember me + - link "Forgot password?" [ref=f5e33] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/forgot-password + - button "Sign in" [ref=f5e34] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-18T11-30-44-478Z.yml b/.playwright-mcp/page-2026-07-18T11-30-44-478Z.yml new file mode 100644 index 00000000..51933cf8 --- /dev/null +++ b/.playwright-mcp/page-2026-07-18T11-30-44-478Z.yml @@ -0,0 +1,108 @@ +- generic [active] [ref=f21e1]: + - complementary [ref=f21e2]: + - generic [ref=f21e3]: + - link "Shop Admin" [ref=f21e4] [cursor=pointer]: + - /url: http://acme-fashion.test/admin + - navigation "Admin navigation" [ref=f21e6]: + - link "Dashboard" [ref=f21e7] [cursor=pointer]: + - /url: http://acme-fashion.test/admin + - link "Products" [ref=f21e10] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/products + - link "Collections" [ref=f21e13] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/collections + - link "Inventory" [ref=f21e16] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/inventory + - link "Orders" [ref=f21e19] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/orders + - link "Customers" [ref=f21e22] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/customers + - link "Discounts" [ref=f21e25] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/discounts + - link "Pages" [ref=f21e29] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/pages + - link "Navigation" [ref=f21e32] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/navigation + - link "Themes" [ref=f21e35] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/themes + - link "Analytics" [ref=f21e38] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/analytics + - link "Settings" [ref=f21e42] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/settings + - link "Apps" [ref=f21e46] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/apps + - link "Developers" [ref=f21e49] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/developers + - button "Log out" [ref=f21e52] + - generic [ref=f21e60]: + - banner [ref=f21e61]: + - button "Acme Fashion" [ref=f21e64] + - generic [ref=f21e68]: + - button "Notifications" [ref=f21e69] + - button "AU Admin User" [ref=f21e73]: + - generic [ref=f21e74]: AU + - generic [ref=f21e77]: Admin User + - main [ref=f21e81]: + - generic [ref=f21e82]: + - generic [ref=f21e83]: + - generic [ref=f21e84]: + - link "Home" [ref=f21e86] [cursor=pointer]: + - /url: http://acme-fashion.test/admin + - link "Orders" [ref=f21e90] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/orders + - generic [ref=f21e93]: "#1001" + - generic [ref=f21e95]: + - generic [ref=f21e96]: "#1001" + - generic [ref=f21e97]: Paid + - generic [ref=f21e98]: Fulfilled + - paragraph [ref=f21e99]: Jul 16, 2026 11:27 AM + - generic [ref=f21e100]: + - button "Create fulfillment" [ref=f21e102] + - button "Refund" [ref=f21e104] + - generic [ref=f21e105]: + - generic [ref=f21e106]: + - generic [ref=f21e107]: + - generic [ref=f21e108]: Order lines + - table [ref=f21e110]: + - rowgroup [ref=f21e111]: + - row [ref=f21e112]: + - columnheader "Product" [ref=f21e113] + - columnheader "SKU" [ref=f21e114] + - columnheader "Quantity" [ref=f21e115] + - columnheader "Total" [ref=f21e116] + - rowgroup [ref=f21e117]: + - row [ref=f21e118]: + - cell "Classic Cotton T-Shirt" [ref=f21e119] + - cell "ACME-CTSH-S-WHT" [ref=f21e120] + - cell "2" [ref=f21e121] + - cell "49.98 EUR" [ref=f21e122] + - generic [ref=f21e123]: + - generic [ref=f21e124]: Subtotal + - generic [ref=f21e125]: "49.98" + - generic [ref=f21e126]: Discount + - generic [ref=f21e127]: "-0.00" + - generic [ref=f21e128]: Shipping + - generic [ref=f21e129]: "4.99" + - generic [ref=f21e130]: Tax + - generic [ref=f21e131]: "7.98" + - strong [ref=f21e132]: Total + - strong [ref=f21e133]: 54.97 EUR + - generic [ref=f21e134]: + - generic [ref=f21e135]: Fulfillments + - article [ref=f21e136]: + - generic [ref=f21e137]: + - generic [ref=f21e138]: Pending + - button "Mark shipped" [ref=f21e140] + - paragraph [ref=f21e146]: DHL TRACK123 + - complementary [ref=f21e147]: + - generic [ref=f21e148]: + - generic [ref=f21e149]: Customer + - paragraph [ref=f21e150]: John Doe + - paragraph [ref=f21e151]: customer@acme.test + - link "View customer" [ref=f21e152] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/customers/1 + - generic [ref=f21e153]: + - generic [ref=f21e154]: Shipping address + - generic [ref=f21e155]: Hauptstrasse 1 BerlinDE + - generic [ref=f21e156]: + - generic [ref=f21e157]: Billing address + - generic [ref=f21e158]: Hauptstrasse 1 BerlinDE \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-18T11-31-07-430Z.yml b/.playwright-mcp/page-2026-07-18T11-31-07-430Z.yml new file mode 100644 index 00000000..b6ff9e55 --- /dev/null +++ b/.playwright-mcp/page-2026-07-18T11-31-07-430Z.yml @@ -0,0 +1,108 @@ +- generic [active] [ref=f23e1]: + - complementary [ref=f23e2]: + - generic [ref=f23e3]: + - link "Shop Admin" [ref=f23e4] [cursor=pointer]: + - /url: http://acme-fashion.test/admin + - navigation "Admin navigation" [ref=f23e6]: + - link "Dashboard" [ref=f23e7] [cursor=pointer]: + - /url: http://acme-fashion.test/admin + - link "Products" [ref=f23e10] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/products + - link "Collections" [ref=f23e13] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/collections + - link "Inventory" [ref=f23e16] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/inventory + - link "Orders" [ref=f23e19] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/orders + - link "Customers" [ref=f23e22] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/customers + - link "Discounts" [ref=f23e25] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/discounts + - link "Pages" [ref=f23e29] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/pages + - link "Navigation" [ref=f23e32] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/navigation + - link "Themes" [ref=f23e35] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/themes + - link "Analytics" [ref=f23e38] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/analytics + - link "Settings" [ref=f23e42] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/settings + - link "Apps" [ref=f23e46] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/apps + - link "Developers" [ref=f23e49] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/developers + - button "Log out" [ref=f23e52] + - generic [ref=f23e60]: + - banner [ref=f23e61]: + - button "Acme Fashion" [ref=f23e64] + - generic [ref=f23e68]: + - button "Notifications" [ref=f23e69] + - button "AU Admin User" [ref=f23e73]: + - generic [ref=f23e74]: AU + - generic [ref=f23e77]: Admin User + - main [ref=f23e81]: + - generic [ref=f23e82]: + - generic [ref=f23e83]: + - generic [ref=f23e84]: + - link "Home" [ref=f23e86] [cursor=pointer]: + - /url: http://acme-fashion.test/admin + - link "Orders" [ref=f23e90] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/orders + - generic [ref=f23e93]: "#1001" + - generic [ref=f23e95]: + - generic [ref=f23e96]: "#1001" + - generic [ref=f23e97]: Paid + - generic [ref=f23e98]: Fulfilled + - paragraph [ref=f23e99]: Jul 16, 2026 11:27 AM + - generic [ref=f23e100]: + - button "Create fulfillment" [ref=f23e102] + - button "Refund" [ref=f23e104] + - generic [ref=f23e105]: + - generic [ref=f23e106]: + - generic [ref=f23e107]: + - generic [ref=f23e108]: Order lines + - table [ref=f23e110]: + - rowgroup [ref=f23e111]: + - row [ref=f23e112]: + - columnheader "Product" [ref=f23e113] + - columnheader "SKU" [ref=f23e114] + - columnheader "Quantity" [ref=f23e115] + - columnheader "Total" [ref=f23e116] + - rowgroup [ref=f23e117]: + - row [ref=f23e118]: + - cell "Classic Cotton T-Shirt" [ref=f23e119] + - cell "ACME-CTSH-S-WHT" [ref=f23e120] + - cell "2" [ref=f23e121] + - cell "49.98 EUR" [ref=f23e122] + - generic [ref=f23e123]: + - generic [ref=f23e124]: Subtotal + - generic [ref=f23e125]: "49.98" + - generic [ref=f23e126]: Discount + - generic [ref=f23e127]: "-0.00" + - generic [ref=f23e128]: Shipping + - generic [ref=f23e129]: "4.99" + - generic [ref=f23e130]: Tax + - generic [ref=f23e131]: "7.98" + - strong [ref=f23e132]: Total + - strong [ref=f23e133]: 54.97 EUR + - generic [ref=f23e134]: + - generic [ref=f23e135]: Fulfillments + - article [ref=f23e136]: + - generic [ref=f23e137]: + - generic [ref=f23e138]: Shipped + - button "Mark delivered" [ref=f23e140] + - paragraph [ref=f23e146]: DHL TRACK123 + - complementary [ref=f23e147]: + - generic [ref=f23e148]: + - generic [ref=f23e149]: Customer + - paragraph [ref=f23e150]: John Doe + - paragraph [ref=f23e151]: customer@acme.test + - link "View customer" [ref=f23e152] [cursor=pointer]: + - /url: http://acme-fashion.test/admin/customers/1 + - generic [ref=f23e153]: + - generic [ref=f23e154]: Shipping address + - generic [ref=f23e155]: Hauptstrasse 1 BerlinDE + - generic [ref=f23e156]: + - generic [ref=f23e157]: Billing address + - generic [ref=f23e158]: Hauptstrasse 1 BerlinDE \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-18T13-20-19-774Z.yml b/.playwright-mcp/page-2026-07-18T13-20-19-774Z.yml new file mode 100644 index 00000000..7fded439 --- /dev/null +++ b/.playwright-mcp/page-2026-07-18T13-20-19-774Z.yml @@ -0,0 +1,159 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - banner [ref=e3]: + - generic [ref=e4]: + - link "Acme Fashion" [ref=e5] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev + - navigation "Main" [ref=e6]: + - link "Home" [ref=e7] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=e8] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/new-arrivals + - link "T-Shirts" [ref=e9] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/t-shirts + - link "Pants & Jeans" [ref=e10] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/pants-jeans + - link "Sale" [ref=e11] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/sale + - generic [ref=e12]: + - link "Search" [ref=e13] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/search + - button "Open cart" [ref=e17] + - link "Account" [ref=e20] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/account/login + - main [ref=e23]: + - generic [ref=e24]: + - generic [ref=e26]: + - heading "Welcome to Acme Fashion" [level=1] [ref=e27] + - paragraph [ref=e28]: Discover our latest collections and find something you'll love. + - link "Shop now" [ref=e30] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections + - generic [ref=e31]: + - heading "Shop by Collection" [level=2] [ref=e32] + - generic [ref=e33]: + - link [ref=e34] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/new-arrivals + - paragraph [ref=e36]: New Arrivals + - link [ref=e37] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/pants-jeans + - paragraph [ref=e39]: Pants & Jeans + - link [ref=e40] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/sale + - paragraph [ref=e42]: Sale + - link [ref=e43] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/t-shirts + - paragraph [ref=e45]: T-Shirts + - generic [ref=e46]: + - heading "Featured Products" [level=2] [ref=e47] + - generic [ref=e48]: + - generic [ref=e49]: + - link [ref=e50] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/cashmere-overcoat + - heading "Cashmere Overcoat" [level=3] [ref=e55] + - generic [ref=e56]: 499.99 EUR + - link "Choose options" [ref=e59] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/cashmere-overcoat + - generic [ref=e60]: + - link [ref=e61] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/gift-card + - heading "Gift Card" [level=3] [ref=e66] + - generic [ref=e67]: 25.00 EUR + - link "Choose options" [ref=e70] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/gift-card + - generic [ref=e71]: + - link [ref=e72] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/backorder-denim-jacket + - heading "Backorder Denim Jacket" [level=3] [ref=e77] + - generic [ref=e78]: 99.99 EUR + - link "Choose options" [ref=e81] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/backorder-denim-jacket + - generic [ref=e82]: + - link "Sold out Limited Edition Sneakers" [ref=e83] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/limited-edition-sneakers + - generic [ref=e84]: Sold out + - heading "Limited Edition Sneakers" [level=3] [ref=e90] + - generic [ref=e91]: 159.99 EUR + - link "Choose options" [ref=e94] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/limited-edition-sneakers + - generic [ref=e95]: + - link [ref=e96] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/bucket-hat + - heading "Bucket Hat" [level=3] [ref=e101] + - generic [ref=e102]: 24.99 EUR + - link "Choose options" [ref=e105] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/bucket-hat + - generic [ref=e106]: + - link [ref=e107] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/canvas-tote-bag + - heading "Canvas Tote Bag" [level=3] [ref=e112] + - generic [ref=e113]: 19.99 EUR + - link "Choose options" [ref=e116] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/canvas-tote-bag + - generic [ref=e117]: + - link [ref=e118] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/wool-scarf + - heading "Wool Scarf" [level=3] [ref=e123] + - generic [ref=e124]: 29.99 EUR + - link "Choose options" [ref=e127] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/wool-scarf + - generic [ref=e128]: + - link "Sale Wide Leg Trousers" [ref=e129] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/wide-leg-trousers + - generic [ref=e130]: Sale + - heading "Wide Leg Trousers" [level=3] [ref=e136] + - generic [ref=e138]: + - generic [ref=e139]: 49.99 EUR + - generic [ref=e140]: 69.99 EUR + - generic [ref=e141]: Sale + - link "Choose options" [ref=e142] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/wide-leg-trousers + - generic [ref=e144]: + - heading "Stay in the loop" [level=2] [ref=e145] + - paragraph [ref=e146]: Subscribe for exclusive offers and new arrivals. + - generic [ref=e147]: + - textbox "Email address" [ref=e149]: + - /placeholder: Your email address + - button "Subscribe" [ref=e150] + - contentinfo [ref=e156]: + - generic [ref=e157]: + - generic [ref=e158]: + - generic [ref=e159]: + - heading "Shop" [level=3] [ref=e160] + - list [ref=e161]: + - listitem [ref=e162]: + - link "About Us" [ref=e163] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/pages/about + - listitem [ref=e164]: + - link "FAQ" [ref=e165] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/pages/faq + - listitem [ref=e166]: + - link "Shipping & Returns" [ref=e167] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/pages/shipping-returns + - listitem [ref=e168]: + - link "Privacy Policy" [ref=e169] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/pages/privacy-policy + - listitem [ref=e170]: + - link "Terms of Service" [ref=e171] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/pages/terms + - generic [ref=e172]: + - heading "Acme Fashion" [level=3] [ref=e173] + - paragraph [ref=e175]: Acme Fashion + - generic [ref=e176]: + - link "Acme Fashion on Facebook" [ref=e177] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on Instagram" [ref=e180] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on Twitter/X" [ref=e183] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on TikTok" [ref=e186] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on YouTube" [ref=e189] [cursor=pointer]: + - /url: "#" + - generic [ref=e192]: + - paragraph [ref=e193]: © 2026 Acme Fashion. All rights reserved. + - generic [ref=e194]: + - generic [ref=e195]: Visa + - generic [ref=e196]: Mastercard + - generic [ref=e197]: Amex + - generic [ref=e198]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-18T13-20-42-149Z.yml b/.playwright-mcp/page-2026-07-18T13-20-42-149Z.yml new file mode 100644 index 00000000..81a1df25 --- /dev/null +++ b/.playwright-mcp/page-2026-07-18T13-20-42-149Z.yml @@ -0,0 +1,197 @@ +- generic [active] [ref=e199]: + - link "Skip to main content" [ref=e200] [cursor=pointer]: + - /url: "#main-content" + - banner [ref=e201]: + - generic [ref=e202]: + - link "Acme Fashion" [ref=e203] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev + - navigation "Main" [ref=e204]: + - link "Home" [ref=e205] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=e206] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/new-arrivals + - link "T-Shirts" [ref=e207] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/t-shirts + - link "Pants & Jeans" [ref=e208] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/pants-jeans + - link "Sale" [ref=e209] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/sale + - generic [ref=e210]: + - link "Search" [ref=e211] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/search + - button "Open cart" [ref=e215] + - link "Account" [ref=e218] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/account/login + - main [ref=e221]: + - generic [ref=e222]: + - navigation "Breadcrumb" [ref=e223]: + - list [ref=e224]: + - listitem [ref=e225]: + - link "Home" [ref=e226] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev + - generic [ref=e227]: / + - listitem [ref=e228]: + - link "Collections" [ref=e229] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections + - generic [ref=e230]: / + - listitem [ref=e231]: + - generic [ref=e232]: New Arrivals + - generic [ref=e233]: + - generic [ref=e234]: + - heading "New Arrivals" [level=1] [ref=e235] + - paragraph [ref=e237]: Discover the latest additions to our store. + - generic [ref=e238]: + - generic [ref=e239]: Sort by + - combobox "Sort by" [ref=e240]: + - option "Featured" [selected] + - option "Newest" + - 'option "Price: Low to High"' + - 'option "Price: High to Low"' + - generic [ref=e241]: + - complementary "Filters" [ref=e242]: + - heading "Filters" [level=2] [ref=e244] + - generic [ref=e245]: + - generic [ref=e246]: + - checkbox "In stock only" [ref=e247] + - text: In stock only + - generic [ref=e248]: + - paragraph [ref=e249]: Price + - generic [ref=e250]: + - spinbutton "Minimum price" [ref=e252] + - generic [ref=e254]: "-" + - spinbutton "Maximum price" [ref=e256] + - generic [ref=e258]: + - paragraph [ref=e259]: Product type + - generic [ref=e260]: + - generic [ref=e261]: + - checkbox "Accessories" [ref=e262] + - text: Accessories + - generic [ref=e263]: + - checkbox "Hoodies" [ref=e264] + - text: Hoodies + - generic [ref=e265]: + - checkbox "Jackets" [ref=e266] + - text: Jackets + - generic [ref=e267]: + - checkbox "Pants" [ref=e268] + - text: Pants + - generic [ref=e269]: + - checkbox "Shoes" [ref=e270] + - text: Shoes + - generic [ref=e271]: + - checkbox "T-Shirts" [ref=e272] + - text: T-Shirts + - generic [ref=e273]: + - paragraph [ref=e274]: Vendor + - generic [ref=e275]: + - generic [ref=e276]: + - checkbox "Acme Accessories" [ref=e277] + - text: Acme Accessories + - generic [ref=e278]: + - checkbox "Acme Basics" [ref=e279] + - text: Acme Basics + - generic [ref=e280]: + - checkbox "Acme Denim" [ref=e281] + - text: Acme Denim + - generic [ref=e282]: + - checkbox "Acme Premium" [ref=e283] + - text: Acme Premium + - generic [ref=e284]: + - checkbox "Acme Sport" [ref=e285] + - text: Acme Sport + - generic [ref=e287]: + - generic [ref=e288]: + - link [ref=e289] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=e294] + - generic [ref=e295]: 24.99 EUR + - link "Choose options" [ref=e298] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/classic-cotton-t-shirt + - generic [ref=e299]: + - link "Sale Premium Slim Fit Jeans" [ref=e300] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/premium-slim-fit-jeans + - generic [ref=e301]: Sale + - heading "Premium Slim Fit Jeans" [level=3] [ref=e307] + - generic [ref=e309]: + - generic [ref=e310]: 79.99 EUR + - generic [ref=e311]: 99.99 EUR + - generic [ref=e312]: Sale + - link "Choose options" [ref=e313] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/premium-slim-fit-jeans + - generic [ref=e314]: + - link [ref=e315] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/organic-hoodie + - heading "Organic Hoodie" [level=3] [ref=e320] + - generic [ref=e321]: 59.99 EUR + - link "Choose options" [ref=e324] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/organic-hoodie + - generic [ref=e325]: + - link [ref=e326] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/running-sneakers + - heading "Running Sneakers" [level=3] [ref=e331] + - generic [ref=e332]: 119.99 EUR + - link "Choose options" [ref=e335] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/running-sneakers + - generic [ref=e336]: + - link [ref=e337] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/chino-shorts + - heading "Chino Shorts" [level=3] [ref=e342] + - generic [ref=e343]: 39.99 EUR + - link "Choose options" [ref=e346] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/chino-shorts + - generic [ref=e347]: + - link [ref=e348] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/bucket-hat + - heading "Bucket Hat" [level=3] [ref=e353] + - generic [ref=e354]: 24.99 EUR + - link "Choose options" [ref=e357] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/bucket-hat + - generic [ref=e358]: + - link [ref=e359] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/cashmere-overcoat + - heading "Cashmere Overcoat" [level=3] [ref=e364] + - generic [ref=e365]: 499.99 EUR + - link "Choose options" [ref=e368] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/cashmere-overcoat + - contentinfo [ref=e369]: + - generic [ref=e370]: + - generic [ref=e371]: + - generic [ref=e372]: + - heading "Shop" [level=3] [ref=e373] + - list [ref=e374]: + - listitem [ref=e375]: + - link "About Us" [ref=e376] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/pages/about + - listitem [ref=e377]: + - link "FAQ" [ref=e378] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/pages/faq + - listitem [ref=e379]: + - link "Shipping & Returns" [ref=e380] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/pages/shipping-returns + - listitem [ref=e381]: + - link "Privacy Policy" [ref=e382] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/pages/privacy-policy + - listitem [ref=e383]: + - link "Terms of Service" [ref=e384] [cursor=pointer]: + - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/pages/terms + - generic [ref=e385]: + - heading "Acme Fashion" [level=3] [ref=e386] + - paragraph [ref=e388]: Acme Fashion + - generic [ref=e389]: + - link "Acme Fashion on Facebook" [ref=e390] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on Instagram" [ref=e393] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on Twitter/X" [ref=e396] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on TikTok" [ref=e399] [cursor=pointer]: + - /url: "#" + - link "Acme Fashion on YouTube" [ref=e402] [cursor=pointer]: + - /url: "#" + - generic [ref=e405]: + - paragraph [ref=e406]: © 2026 Acme Fashion. All rights reserved. + - generic [ref=e407]: + - generic [ref=e408]: Visa + - generic [ref=e409]: Mastercard + - generic [ref=e410]: Amex + - generic [ref=e411]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-20-00-049Z.yml b/.playwright-mcp/page-2026-07-26T08-20-00-049Z.yml new file mode 100644 index 00000000..ac2abd3d --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-20-00-049Z.yml @@ -0,0 +1,170 @@ +- generic [active] [ref=f3e1]: + - link "Skip to main content" [ref=f3e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f3e4]: + - paragraph [ref=f3e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f3e6] + - banner [ref=f3e9]: + - generic [ref=f3e10]: + - button "Open navigation menu" [ref=f3e11] + - link "Acme Fashion" [ref=f3e14] [cursor=pointer]: + - /url: http://acme-fashion.test + - button "Open cart" [ref=f3e17] + - main [ref=f3e20]: + - generic [ref=f3e21]: + - generic [ref=f3e25]: + - heading "Welcome to Acme Fashion" [level=1] [ref=f3e26] + - paragraph [ref=f3e27]: Discover our curated collection of modern essentials + - link "Shop New Arrivals" [ref=f3e28] [cursor=pointer]: + - /url: /collections/new-arrivals + - region [ref=f3e29]: + - heading "Featured collections" [level=2] [ref=f3e30] + - generic [ref=f3e31]: + - link "New Arrivals" [ref=f3e32] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - generic [ref=f3e34]: + - generic [ref=f3e35]: New Arrivals + - generic [ref=f3e36]: Shop now + - link "T-Shirts" [ref=f3e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - generic [ref=f3e39]: + - generic [ref=f3e40]: T-Shirts + - generic [ref=f3e41]: Shop now + - link "Sale" [ref=f3e42] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/sale + - generic [ref=f3e44]: + - generic [ref=f3e45]: Sale + - generic [ref=f3e46]: Shop now + - region [ref=f3e47]: + - heading "Featured products" [level=2] [ref=f3e48] + - generic [ref=f3e49]: + - generic [ref=f3e50]: + - link [ref=f3e52] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f3e56] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f3e57] + - generic [ref=f3e58]: 24.99 EUR + - link "Choose options" [ref=f3e62] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f3e63]: + - generic [ref=f3e64]: + - link [ref=f3e65] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f3e70]: + - generic [ref=f3e71]: "On sale:" + - text: Sale + - 'link "Premium Slim Fit Jeans 79.99 EUR 99.99 EUR On sale: Sale" [ref=f3e72] [cursor=pointer]': + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - heading "Premium Slim Fit Jeans" [level=3] [ref=f3e73] + - generic [ref=f3e75]: + - generic [ref=f3e76]: 79.99 EUR + - generic [ref=f3e77]: 99.99 EUR + - generic [ref=f3e78]: + - generic [ref=f3e79]: "On sale:" + - text: Sale + - link "Choose options" [ref=f3e81] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f3e82]: + - link [ref=f3e84] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - link "Organic Hoodie 59.99 EUR" [ref=f3e88] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - heading "Organic Hoodie" [level=3] [ref=f3e89] + - generic [ref=f3e90]: 59.99 EUR + - link "Choose options" [ref=f3e94] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - generic [ref=f3e95]: + - link [ref=f3e97] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - link "Leather Belt 34.99 EUR" [ref=f3e101] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - heading "Leather Belt" [level=3] [ref=f3e102] + - generic [ref=f3e103]: 34.99 EUR + - link "Choose options" [ref=f3e107] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - generic [ref=f3e108]: + - link [ref=f3e110] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - link "Running Sneakers 119.99 EUR" [ref=f3e114] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - heading "Running Sneakers" [level=3] [ref=f3e115] + - generic [ref=f3e116]: 119.99 EUR + - link "Choose options" [ref=f3e120] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - generic [ref=f3e121]: + - link [ref=f3e123] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f3e127] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f3e128] + - generic [ref=f3e129]: 29.99 EUR + - link "Choose options" [ref=f3e133] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - generic [ref=f3e134]: + - link [ref=f3e136] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - link "V-Neck Linen Tee 34.99 EUR" [ref=f3e140] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - heading "V-Neck Linen Tee" [level=3] [ref=f3e141] + - generic [ref=f3e142]: 34.99 EUR + - link "Choose options" [ref=f3e146] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - generic [ref=f3e147]: + - generic [ref=f3e148]: + - link [ref=f3e149] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - generic [ref=f3e154]: + - generic [ref=f3e155]: "On sale:" + - text: Sale + - 'link "Striped Polo Shirt 27.99 EUR 39.99 EUR On sale: Sale" [ref=f3e156] [cursor=pointer]': + - /url: http://acme-fashion.test/products/striped-polo-shirt + - heading "Striped Polo Shirt" [level=3] [ref=f3e157] + - generic [ref=f3e159]: + - generic [ref=f3e160]: 27.99 EUR + - generic [ref=f3e161]: 39.99 EUR + - generic [ref=f3e162]: + - generic [ref=f3e163]: "On sale:" + - text: Sale + - link "Choose options" [ref=f3e165] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - region [ref=f3e166]: + - generic [ref=f3e167]: + - heading "Stay in the loop" [level=2] [ref=f3e168] + - paragraph [ref=f3e169]: Subscribe for exclusive offers and updates. + - generic [ref=f3e171]: + - generic [ref=f3e172]: Email address + - textbox "Email address" [ref=f3e173]: + - /placeholder: Enter your email + - button "Subscribe" [ref=f3e174] + - contentinfo [ref=f3e175]: + - generic [ref=f3e176]: + - generic [ref=f3e177]: + - generic [ref=f3e178]: + - heading "Shop" [level=2] [ref=f3e179] + - list [ref=f3e180]: + - listitem [ref=f3e181]: + - link "About Us" [ref=f3e182] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f3e183]: + - link "FAQ" [ref=f3e184] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f3e185]: + - link "Shipping & Returns" [ref=f3e186] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f3e187]: + - link "Privacy Policy" [ref=f3e188] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f3e189]: + - link "Terms of Service" [ref=f3e190] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f3e191]: + - heading "Acme Fashion" [level=2] [ref=f3e192] + - paragraph [ref=f3e193]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f3e194]: + - paragraph [ref=f3e195]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f3e196]: + - generic [ref=f3e197]: VISA + - generic [ref=f3e198]: MASTERCARD + - generic [ref=f3e199]: AMEX + - generic [ref=f3e200]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-21-37-290Z.yml b/.playwright-mcp/page-2026-07-26T08-21-37-290Z.yml new file mode 100644 index 00000000..78d8a5d0 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-21-37-290Z.yml @@ -0,0 +1,170 @@ +- generic [active] [ref=f4e1]: + - link "Skip to main content" [ref=f4e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f4e4]: + - paragraph [ref=f4e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f4e6] + - banner [ref=f4e9]: + - generic [ref=f4e10]: + - button "Open navigation menu" [ref=f4e11] + - link "Acme Fashion" [ref=f4e14] [cursor=pointer]: + - /url: http://acme-fashion.test + - button "Open cart" [ref=f4e17] + - main [ref=f4e20]: + - generic [ref=f4e21]: + - generic [ref=f4e25]: + - heading "Welcome to Acme Fashion" [level=1] [ref=f4e26] + - paragraph [ref=f4e27]: Discover our curated collection of modern essentials + - link "Shop New Arrivals" [ref=f4e28] [cursor=pointer]: + - /url: /collections/new-arrivals + - region [ref=f4e29]: + - heading "Featured collections" [level=2] [ref=f4e30] + - generic [ref=f4e31]: + - link "New Arrivals" [ref=f4e32] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - generic [ref=f4e34]: + - generic [ref=f4e35]: New Arrivals + - generic [ref=f4e36]: Shop now + - link "T-Shirts" [ref=f4e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - generic [ref=f4e39]: + - generic [ref=f4e40]: T-Shirts + - generic [ref=f4e41]: Shop now + - link "Sale" [ref=f4e42] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/sale + - generic [ref=f4e44]: + - generic [ref=f4e45]: Sale + - generic [ref=f4e46]: Shop now + - region [ref=f4e47]: + - heading "Featured products" [level=2] [ref=f4e48] + - generic [ref=f4e49]: + - generic [ref=f4e50]: + - link [ref=f4e52] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f4e56] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f4e57] + - generic [ref=f4e58]: 24.99 EUR + - link "Choose options" [ref=f4e62] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f4e63]: + - generic [ref=f4e64]: + - link [ref=f4e65] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f4e70]: + - generic [ref=f4e71]: "On sale:" + - text: Sale + - 'link "Premium Slim Fit Jeans 79.99 EUR 99.99 EUR On sale: Sale" [ref=f4e72] [cursor=pointer]': + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - heading "Premium Slim Fit Jeans" [level=3] [ref=f4e73] + - generic [ref=f4e75]: + - generic [ref=f4e76]: 79.99 EUR + - generic [ref=f4e77]: 99.99 EUR + - generic [ref=f4e78]: + - generic [ref=f4e79]: "On sale:" + - text: Sale + - link "Choose options" [ref=f4e81] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f4e82]: + - link [ref=f4e84] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - link "Organic Hoodie 59.99 EUR" [ref=f4e88] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - heading "Organic Hoodie" [level=3] [ref=f4e89] + - generic [ref=f4e90]: 59.99 EUR + - link "Choose options" [ref=f4e94] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - generic [ref=f4e95]: + - link [ref=f4e97] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - link "Leather Belt 34.99 EUR" [ref=f4e101] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - heading "Leather Belt" [level=3] [ref=f4e102] + - generic [ref=f4e103]: 34.99 EUR + - link "Choose options" [ref=f4e107] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - generic [ref=f4e108]: + - link [ref=f4e110] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - link "Running Sneakers 119.99 EUR" [ref=f4e114] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - heading "Running Sneakers" [level=3] [ref=f4e115] + - generic [ref=f4e116]: 119.99 EUR + - link "Choose options" [ref=f4e120] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - generic [ref=f4e121]: + - link [ref=f4e123] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f4e127] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f4e128] + - generic [ref=f4e129]: 29.99 EUR + - link "Choose options" [ref=f4e133] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - generic [ref=f4e134]: + - link [ref=f4e136] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - link "V-Neck Linen Tee 34.99 EUR" [ref=f4e140] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - heading "V-Neck Linen Tee" [level=3] [ref=f4e141] + - generic [ref=f4e142]: 34.99 EUR + - link "Choose options" [ref=f4e146] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - generic [ref=f4e147]: + - generic [ref=f4e148]: + - link [ref=f4e149] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - generic [ref=f4e154]: + - generic [ref=f4e155]: "On sale:" + - text: Sale + - 'link "Striped Polo Shirt 27.99 EUR 39.99 EUR On sale: Sale" [ref=f4e156] [cursor=pointer]': + - /url: http://acme-fashion.test/products/striped-polo-shirt + - heading "Striped Polo Shirt" [level=3] [ref=f4e157] + - generic [ref=f4e159]: + - generic [ref=f4e160]: 27.99 EUR + - generic [ref=f4e161]: 39.99 EUR + - generic [ref=f4e162]: + - generic [ref=f4e163]: "On sale:" + - text: Sale + - link "Choose options" [ref=f4e165] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - region [ref=f4e166]: + - generic [ref=f4e167]: + - heading "Stay in the loop" [level=2] [ref=f4e168] + - paragraph [ref=f4e169]: Subscribe for exclusive offers and updates. + - generic [ref=f4e171]: + - generic [ref=f4e172]: Email address + - textbox "Email address" [ref=f4e173]: + - /placeholder: Enter your email + - button "Subscribe" [ref=f4e174] + - contentinfo [ref=f4e175]: + - generic [ref=f4e176]: + - generic [ref=f4e177]: + - generic [ref=f4e178]: + - heading "Shop" [level=2] [ref=f4e179] + - list [ref=f4e180]: + - listitem [ref=f4e181]: + - link "About Us" [ref=f4e182] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f4e183]: + - link "FAQ" [ref=f4e184] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f4e185]: + - link "Shipping & Returns" [ref=f4e186] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f4e187]: + - link "Privacy Policy" [ref=f4e188] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f4e189]: + - link "Terms of Service" [ref=f4e190] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f4e191]: + - heading "Acme Fashion" [level=2] [ref=f4e192] + - paragraph [ref=f4e193]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f4e194]: + - paragraph [ref=f4e195]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f4e196]: + - generic [ref=f4e197]: VISA + - generic [ref=f4e198]: MASTERCARD + - generic [ref=f4e199]: AMEX + - generic [ref=f4e200]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-23-00-586Z.yml b/.playwright-mcp/page-2026-07-26T08-23-00-586Z.yml new file mode 100644 index 00000000..bcaeaa29 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-23-00-586Z.yml @@ -0,0 +1,101 @@ +- generic [active] [ref=f5e1]: + - link "Skip to main content" [ref=f5e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f5e4]: + - paragraph [ref=f5e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f5e6] + - banner [ref=f5e9]: + - generic [ref=f5e10]: + - link "Acme Fashion" [ref=f5e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f5e13]: + - link "Home" [ref=f5e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f5e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f5e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f5e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f5e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f5e19]: + - button "Search" [ref=f5e20] + - link "Account" [ref=f5e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f5e26] + - main [ref=f5e29]: + - generic [ref=f5e30]: + - navigation "Breadcrumb" [ref=f5e31]: + - list [ref=f5e32]: + - listitem [ref=f5e33]: + - link "Home" [ref=f5e34] [cursor=pointer]: + - /url: http://acme-fashion.test + - listitem [ref=f5e35]: + - generic [ref=f5e36]: / + - link "New Arrivals" [ref=f5e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - listitem [ref=f5e38]: + - generic [ref=f5e39]: / + - generic [ref=f5e40]: Classic Cotton T-Shirt + - generic [ref=f5e41]: + - region "Product images" [ref=f5e42] + - generic [ref=f5e48]: + - heading "Classic Cotton T-Shirt" [level=1] [ref=f5e49] + - paragraph [ref=f5e50]: Acme Basics + - generic [ref=f5e51]: 24.99 EUR + - group "SizeS" [ref=f5e53]: + - generic [ref=f5e55]: + - button "S" [pressed] [ref=f5e56] + - button "M" [ref=f5e57] + - button "L" [ref=f5e58] + - button "XL" [ref=f5e59] + - group "ColorWhite" [ref=f5e60]: + - generic [ref=f5e62]: + - button "White" [pressed] [ref=f5e63] + - button "Black" [ref=f5e64] + - button "Navy" [ref=f5e65] + - paragraph [ref=f5e66]: In stock + - generic [ref=f5e69]: + - generic [ref=f5e70]: + - button "Decrease quantity" [disabled] [ref=f5e71] + - generic [ref=f5e73]: Quantity + - spinbutton "Quantity" [ref=f5e74]: "1" + - button "Increase quantity" [ref=f5e75] + - button "Add to cart" [ref=f5e78] + - separator [ref=f5e79] + - paragraph [ref=f5e81]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - generic [ref=f5e82]: + - generic [ref=f5e83]: new + - generic [ref=f5e84]: popular + - contentinfo [ref=f5e85]: + - generic [ref=f5e86]: + - generic [ref=f5e87]: + - generic [ref=f5e88]: + - heading "Shop" [level=2] [ref=f5e89] + - list [ref=f5e90]: + - listitem [ref=f5e91]: + - link "About Us" [ref=f5e92] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f5e93]: + - link "FAQ" [ref=f5e94] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f5e95]: + - link "Shipping & Returns" [ref=f5e96] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f5e97]: + - link "Privacy Policy" [ref=f5e98] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f5e99]: + - link "Terms of Service" [ref=f5e100] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f5e101]: + - heading "Acme Fashion" [level=2] [ref=f5e102] + - paragraph [ref=f5e103]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f5e104]: + - paragraph [ref=f5e105]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f5e106]: + - generic [ref=f5e107]: VISA + - generic [ref=f5e108]: MASTERCARD + - generic [ref=f5e109]: AMEX + - generic [ref=f5e110]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-23-26-140Z.yml b/.playwright-mcp/page-2026-07-26T08-23-26-140Z.yml new file mode 100644 index 00000000..003cab5c --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-23-26-140Z.yml @@ -0,0 +1,281 @@ +- generic [active] [ref=f5e1]: + - dialog [ref=f5e111]: + - iframe [ref=f5e112]: + - generic [ref=f6e2]: + - generic [ref=f6e4]: + - generic [ref=f6e5]: Internal Server Error + - button "Copy as Markdown" [ref=f6e11] [cursor=pointer] + - generic [ref=f6e18]: + - generic [ref=f6e19]: + - heading "Illuminate\\Contracts\\Container\\BindingResolutionException" [level=1] [ref=f6e20] + - generic [ref=f6e21]: vendor/laravel/framework/src/Illuminate/Container/Container.php:1124 + - paragraph [ref=f6e23]: Target class [current_store] does not exist. + - generic [ref=f6e24]: + - generic [ref=f6e25]: + - generic [ref=f6e26]: + - generic [ref=f6e27]: LARAVEL + - generic [ref=f6e28]: 12.51.0 + - generic [ref=f6e29]: + - generic [ref=f6e30]: PHP + - generic [ref=f6e31]: 8.4.17 + - generic [ref=f6e32]: UNHANDLED + - generic [ref=f6e36]: CODE 0 + - generic [ref=f6e38]: + - generic [ref=f6e39]: "500" + - generic [ref=f6e43]: POST + - generic [ref=f6e47]: http://acme-fashion.test/livewire-0972654c/update + - button [ref=f6e48] [cursor=pointer] + - generic [ref=f6e53]: + - generic [ref=f6e54]: + - heading "Exception trace" [level=3] [ref=f6e60] + - generic [ref=f6e61]: + - generic [ref=f6e63] [cursor=pointer]: + - generic [ref=f6e68]: 6 vendor frames + - button [ref=f6e69] + - generic [ref=f6e74]: + - generic [ref=f6e75] [cursor=pointer]: + - generic [ref=f6e78]: + - code [ref=f6e82]: + - generic [ref=f6e83]: app/Livewire/Storefront/Products/Show.php + - generic [ref=f6e84]: app/Livewire/Storefront/Products/Show.php:147 + - button [ref=f6e87] + - code [ref=f6e96]: + - generic [ref=f6e97]: "142 if ($variant === null || (! $variant->isInStock() && ! $variant->isBackorderable())) {" + - generic [ref=f6e98]: 143 return; + - generic [ref=f6e99]: "144 }" + - generic [ref=f6e100]: "145" + - generic [ref=f6e101]: 146 $carts = app(\App\Services\CartService::class); + - generic [ref=f6e102]: 147 $cart = $carts->getOrCreateForSession(app('current_store'), auth('customer')->user()); + - generic [ref=f6e103]: "148" + - generic [ref=f6e104]: "149 try {" + - generic [ref=f6e105]: 150 $carts->addLine($cart, $variant->id, max(1, $this->quantity)); + - generic [ref=f6e106]: "151 } catch (\\App\\Exceptions\\InsufficientInventoryException|\\Illuminate\\Validation\\ValidationException) {" + - generic [ref=f6e107]: 152 return; + - generic [ref=f6e108]: "153 }" + - generic [ref=f6e109]: "154" + - generic [ref=f6e110]: "155 $this->dispatch('cart-updated', count: $cart->refresh()->itemCount());" + - generic [ref=f6e111]: 156 $this->dispatch('cart-drawer-open'); + - generic [ref=f6e112]: "157 }" + - generic [ref=f6e113]: "158" + - generic [ref=f6e114]: "159" + - generic [ref=f6e116] [cursor=pointer]: + - generic [ref=f6e121]: 58 vendor frames + - button [ref=f6e122] + - generic [ref=f6e128] [cursor=pointer]: + - generic [ref=f6e131]: + - code [ref=f6e135]: + - generic [ref=f6e136]: public/index.php + - generic [ref=f6e137]: public/index.php:20 + - button [ref=f6e140] + - generic [ref=f6e146] [cursor=pointer]: + - generic [ref=f6e151]: 1 vendor frame + - button [ref=f6e152] + - generic [ref=f6e157]: + - generic [ref=f6e158]: + - heading "Queries" [level=3] [ref=f6e163] + - generic [ref=f6e164]: 1-5 of 5 + - generic [ref=f6e166]: + - generic [ref=f6e167]: + - generic [ref=f6e168]: + - generic [ref=f6e169]: sqlite + - code [ref=f6e176]: + - generic [ref=f6e177]: select * from "products" where "products"."id" = 1 limit 1 + - generic [ref=f6e178]: 1.13ms + - generic [ref=f6e179]: + - generic [ref=f6e180]: + - generic [ref=f6e181]: sqlite + - code [ref=f6e188]: + - generic [ref=f6e189]: select * from "product_variants" where "product_variants"."product_id" = 1 and "product_variants"."product_id" is not null order by "position" asc + - generic [ref=f6e190]: 0.06ms + - generic [ref=f6e191]: + - generic [ref=f6e192]: + - generic [ref=f6e193]: sqlite + - code [ref=f6e200]: + - generic [ref=f6e201]: select * from "product_options" where "product_options"."product_id" = 1 and "product_options"."product_id" is not null order by "position" asc + - generic [ref=f6e202]: 0.02ms + - generic [ref=f6e203]: + - generic [ref=f6e204]: + - generic [ref=f6e205]: sqlite + - code [ref=f6e212]: + - generic [ref=f6e213]: select "product_option_values".*, "variant_option_values"."variant_id" as "pivot_variant_id", "variant_option_values"."product_option_value_id" as "pivot_product_option_value_id" from "product_option_values" inner join "variant_option_values" on "product_option_values"."id" = "variant_option_values"."product_option_value_id" where "variant_option_values"."variant_id" = 1 + - generic [ref=f6e214]: 0.05ms + - generic [ref=f6e215]: + - generic [ref=f6e216]: + - generic [ref=f6e217]: sqlite + - code [ref=f6e224]: + - generic [ref=f6e225]: select * from "inventory_items" where "inventory_items"."variant_id" = 1 and "inventory_items"."variant_id" is not null limit 1 + - generic [ref=f6e226]: 0.07ms + - generic [ref=f6e228]: + - generic [ref=f6e229]: + - heading "Headers" [level=2] [ref=f6e230] + - generic [ref=f6e231]: + - generic [ref=f6e232]: + - generic [ref=f6e233]: cookie + - generic [ref=f6e235]: XSRF-TOKEN=eyJpdiI6Ik1ZS0JjdHNSQ0lISVFIaGJzSUJXbHc9PSIsInZhbHVlIjoia2hBWUtOTUU2eG1Cak4wa3hPZTdiSzE5TloxVmZucjFrRm9qWHJhZGRBMnRGcDlFSmt5alk4ZGg4OVdnVVFLakxTNlB0VXpXUkRseWRyMy9oR2xaZTVHNFp3UzF4SmNTdGxQZkpVSUowYjlIRWFMVElVYXE3S2VseVRRT2hxcFAiLCJtYWMiOiIxMjZkYjMxZTk2MzBhMTM2YmZjYzhhNmExOWUwYzQ2MDA2ZTNkYzllMDIzM2FkNTZmOTFjMDkyNTU4YjM4MTI1IiwidGFnIjoiIn0%3D; shop_session=eyJpdiI6Im5iSm9VQjQyQTJ0ZGpGS05BbFdOZ1E9PSIsInZhbHVlIjoiMmdWd3ZUYXNzWFpEVlFuQXljVittTHlYajhiY2tpZytPMkdGYVBYMWc5dEx1U1luNytLSXpkMFZDU0htV1gvYnJYbUZ1NjVQZS9MY1FKRUN3cWI3Mlo4Sm1vS0hkOUNwZkoxdnZvS3ErbVFaZkJVUG1IQk1TS0ZFWEk3ZGlTOWYiLCJtYWMiOiIxMzBlNzkwZDVmNzRjMGM0ZDY1ZjcyZjNjZDk1YjkyYTM4NTU1NzRiMDEzZDMzZTk4NTg5ZTIzMGZmNmI3OGFlIiwidGFnIjoiIn0%3D + - generic [ref=f6e236]: + - generic [ref=f6e237]: accept-language + - generic [ref=f6e239]: en-GB,en-US;q=0.9,en;q=0.8 + - generic [ref=f6e240]: + - generic [ref=f6e241]: accept-encoding + - generic [ref=f6e243]: gzip, deflate + - generic [ref=f6e244]: + - generic [ref=f6e245]: referer + - generic [ref=f6e247]: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f6e248]: + - generic [ref=f6e249]: origin + - generic [ref=f6e251]: http://acme-fashion.test + - generic [ref=f6e252]: + - generic [ref=f6e253]: accept + - generic [ref=f6e255]: "*/*" + - generic [ref=f6e256]: + - generic [ref=f6e257]: x-livewire + - generic [ref=f6e259]: "1" + - generic [ref=f6e260]: + - generic [ref=f6e261]: content-type + - generic [ref=f6e263]: application/json + - generic [ref=f6e264]: + - generic [ref=f6e265]: user-agent + - generic [ref=f6e267]: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36 + - generic [ref=f6e268]: + - generic [ref=f6e269]: content-length + - generic [ref=f6e271]: "685" + - generic [ref=f6e272]: + - generic [ref=f6e273]: connection + - generic [ref=f6e275]: keep-alive + - generic [ref=f6e276]: + - generic [ref=f6e277]: host + - generic [ref=f6e279]: acme-fashion.test + - generic [ref=f6e280]: + - heading "Body" [level=2] [ref=f6e281] + - code [ref=f6e286]: + - generic [ref=f6e287]: "{" + - generic [ref=f6e288]: "\"_token\": \"J5t0ymZWZtEVqNMQv9kh8UuSJPnsTQyuighCl8F6\"," + - generic [ref=f6e289]: "\"components\": [" + - generic [ref=f6e290]: "{" + - generic [ref=f6e291]: "\"snapshot\": \"{\"data\":{\"product\":[null,{\"class\":\"AppModelsProduct\",\"key\":1,\"s\":\"mdl\"}],\"selectedOptions\":[{\"Size\":\"S\",\"Color\":\"White\"},{\"s\":\"arr\"}],\"quantity\":1},\"memo\":{\"id\":\"jDRTyPvvkmSrNwBZgMrc\",\"name\":\"storefront.products.show\",\"path\":\"products/classic-cotton-t-shirt\",\"method\":\"GET\",\"release\":\"a-a-a\",\"children\":[],\"scripts\":[],\"assets\":[],\"errors\":[],\"locale\":\"en\",\"islands\":[]},\"checksum\":\"ffb5a3fab0e7c7fc3dcfbbbb11688d265d0a6e6310d700d0c8105c66479b820e\"}\"," + - generic [ref=f6e292]: "\"updates\": []," + - generic [ref=f6e293]: "\"calls\": [" + - generic [ref=f6e294]: "{" + - generic [ref=f6e295]: "\"method\": \"addToCart\"," + - generic [ref=f6e296]: "\"params\": []," + - generic [ref=f6e297]: "\"metadata\": []" + - generic [ref=f6e298]: "}" + - generic [ref=f6e299]: "]" + - generic [ref=f6e300]: "}" + - generic [ref=f6e301]: "]" + - generic [ref=f6e302]: "}" + - generic [ref=f6e303]: + - heading "Routing" [level=2] [ref=f6e304] + - generic [ref=f6e305]: + - generic [ref=f6e306]: + - generic [ref=f6e307]: controller + - generic [ref=f6e309]: Livewire\Mechanisms\HandleRequests\HandleRequests@handleUpdate + - generic [ref=f6e310]: + - generic [ref=f6e311]: route name + - generic [ref=f6e313]: default-livewire.update + - generic [ref=f6e314]: + - generic [ref=f6e315]: middleware + - generic [ref=f6e317]: web + - generic [ref=f6e318]: + - heading "Routing parameters" [level=2] [ref=f6e319] + - generic [ref=f6e320]: // No routing parameters + - link "Skip to main content" [ref=f5e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f5e4]: + - paragraph [ref=f5e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f5e6] + - banner [ref=f5e9]: + - generic [ref=f5e10]: + - link "Acme Fashion" [ref=f5e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f5e13]: + - link "Home" [ref=f5e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f5e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f5e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f5e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f5e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f5e19]: + - button "Search" [ref=f5e20] + - link "Account" [ref=f5e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f5e26] + - main [ref=f5e29]: + - generic [ref=f5e30]: + - navigation "Breadcrumb" [ref=f5e31]: + - list [ref=f5e32]: + - listitem [ref=f5e33]: + - link "Home" [ref=f5e34] [cursor=pointer]: + - /url: http://acme-fashion.test + - listitem [ref=f5e35]: + - generic [ref=f5e36]: / + - link "New Arrivals" [ref=f5e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - listitem [ref=f5e38]: + - generic [ref=f5e39]: / + - generic [ref=f5e40]: Classic Cotton T-Shirt + - generic [ref=f5e41]: + - region "Product images" [ref=f5e42] + - generic [ref=f5e48]: + - heading "Classic Cotton T-Shirt" [level=1] [ref=f5e49] + - paragraph [ref=f5e50]: Acme Basics + - generic [ref=f5e51]: 24.99 EUR + - group "SizeS" [ref=f5e53]: + - generic [ref=f5e55]: + - button "S" [pressed] [ref=f5e56] + - button "M" [ref=f5e57] + - button "L" [ref=f5e58] + - button "XL" [ref=f5e59] + - group "ColorWhite" [ref=f5e60]: + - generic [ref=f5e62]: + - button "White" [pressed] [ref=f5e63] + - button "Black" [ref=f5e64] + - button "Navy" [ref=f5e65] + - paragraph [ref=f5e66]: In stock + - generic [ref=f5e69]: + - generic [ref=f5e70]: + - button "Decrease quantity" [disabled] [ref=f5e71] + - generic [ref=f5e73]: Quantity + - spinbutton "Quantity" [ref=f5e74]: "1" + - button "Increase quantity" [ref=f5e75] + - button "Add to cart" [ref=f5e78] + - separator [ref=f5e79] + - paragraph [ref=f5e81]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - generic [ref=f5e82]: + - generic [ref=f5e83]: new + - generic [ref=f5e84]: popular + - contentinfo [ref=f5e85]: + - generic [ref=f5e86]: + - generic [ref=f5e87]: + - generic [ref=f5e88]: + - heading "Shop" [level=2] [ref=f5e89] + - list [ref=f5e90]: + - listitem [ref=f5e91]: + - link "About Us" [ref=f5e92] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f5e93]: + - link "FAQ" [ref=f5e94] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f5e95]: + - link "Shipping & Returns" [ref=f5e96] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f5e97]: + - link "Privacy Policy" [ref=f5e98] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f5e99]: + - link "Terms of Service" [ref=f5e100] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f5e101]: + - heading "Acme Fashion" [level=2] [ref=f5e102] + - paragraph [ref=f5e103]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f5e104]: + - paragraph [ref=f5e105]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f5e106]: + - generic [ref=f5e107]: VISA + - generic [ref=f5e108]: MASTERCARD + - generic [ref=f5e109]: AMEX + - generic [ref=f5e110]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-31-01-667Z.yml b/.playwright-mcp/page-2026-07-26T08-31-01-667Z.yml new file mode 100644 index 00000000..14710f2e --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-31-01-667Z.yml @@ -0,0 +1,101 @@ +- generic [active] [ref=f7e1]: + - link "Skip to main content" [ref=f7e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f7e4]: + - paragraph [ref=f7e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f7e6] + - banner [ref=f7e9]: + - generic [ref=f7e10]: + - link "Acme Fashion" [ref=f7e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f7e13]: + - link "Home" [ref=f7e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f7e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f7e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f7e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f7e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f7e19]: + - button "Search" [ref=f7e20] + - link "Account" [ref=f7e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f7e26] + - main [ref=f7e29]: + - generic [ref=f7e30]: + - navigation "Breadcrumb" [ref=f7e31]: + - list [ref=f7e32]: + - listitem [ref=f7e33]: + - link "Home" [ref=f7e34] [cursor=pointer]: + - /url: http://acme-fashion.test + - listitem [ref=f7e35]: + - generic [ref=f7e36]: / + - link "New Arrivals" [ref=f7e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - listitem [ref=f7e38]: + - generic [ref=f7e39]: / + - generic [ref=f7e40]: Classic Cotton T-Shirt + - generic [ref=f7e41]: + - region "Product images" [ref=f7e42] + - generic [ref=f7e48]: + - heading "Classic Cotton T-Shirt" [level=1] [ref=f7e49] + - paragraph [ref=f7e50]: Acme Basics + - generic [ref=f7e51]: 24.99 EUR + - group "SizeS" [ref=f7e53]: + - generic [ref=f7e55]: + - button "S" [pressed] [ref=f7e56] + - button "M" [ref=f7e57] + - button "L" [ref=f7e58] + - button "XL" [ref=f7e59] + - group "ColorWhite" [ref=f7e60]: + - generic [ref=f7e62]: + - button "White" [pressed] [ref=f7e63] + - button "Black" [ref=f7e64] + - button "Navy" [ref=f7e65] + - paragraph [ref=f7e66]: In stock + - generic [ref=f7e69]: + - generic [ref=f7e70]: + - button "Decrease quantity" [disabled] [ref=f7e71] + - generic [ref=f7e73]: Quantity + - spinbutton "Quantity" [ref=f7e74]: "1" + - button "Increase quantity" [ref=f7e75] + - button "Add to cart" [ref=f7e78] + - separator [ref=f7e79] + - paragraph [ref=f7e81]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - generic [ref=f7e82]: + - generic [ref=f7e83]: new + - generic [ref=f7e84]: popular + - contentinfo [ref=f7e85]: + - generic [ref=f7e86]: + - generic [ref=f7e87]: + - generic [ref=f7e88]: + - heading "Shop" [level=2] [ref=f7e89] + - list [ref=f7e90]: + - listitem [ref=f7e91]: + - link "About Us" [ref=f7e92] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f7e93]: + - link "FAQ" [ref=f7e94] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f7e95]: + - link "Shipping & Returns" [ref=f7e96] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f7e97]: + - link "Privacy Policy" [ref=f7e98] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f7e99]: + - link "Terms of Service" [ref=f7e100] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f7e101]: + - heading "Acme Fashion" [level=2] [ref=f7e102] + - paragraph [ref=f7e103]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f7e104]: + - paragraph [ref=f7e105]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f7e106]: + - generic [ref=f7e107]: VISA + - generic [ref=f7e108]: MASTERCARD + - generic [ref=f7e109]: AMEX + - generic [ref=f7e110]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-31-14-224Z.yml b/.playwright-mcp/page-2026-07-26T08-31-14-224Z.yml new file mode 100644 index 00000000..50b1f735 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-31-14-224Z.yml @@ -0,0 +1,135 @@ +- generic [ref=f7e1]: + - link "Skip to main content" [ref=f7e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f7e4]: + - paragraph [ref=f7e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f7e6] + - banner [ref=f7e9]: + - generic [ref=f7e10]: + - link "Acme Fashion" [ref=f7e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f7e13]: + - link "Home" [ref=f7e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f7e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f7e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f7e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f7e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f7e19]: + - button "Search" [ref=f7e20] + - link "Account" [ref=f7e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f7e26]: + - generic [ref=f7e111]: "1" + - main [ref=f7e29]: + - generic [ref=f7e30]: + - navigation "Breadcrumb" [ref=f7e31]: + - list [ref=f7e32]: + - listitem [ref=f7e33]: + - link "Home" [ref=f7e34] [cursor=pointer]: + - /url: http://acme-fashion.test + - listitem [ref=f7e35]: + - generic [ref=f7e36]: / + - link "New Arrivals" [ref=f7e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - listitem [ref=f7e38]: + - generic [ref=f7e39]: / + - generic [ref=f7e40]: Classic Cotton T-Shirt + - generic [ref=f7e41]: + - region "Product images" [ref=f7e42] + - generic [ref=f7e48]: + - heading "Classic Cotton T-Shirt" [level=1] [ref=f7e49] + - paragraph [ref=f7e50]: Acme Basics + - generic [ref=f7e51]: 24.99 EUR + - group "SizeS" [ref=f7e53]: + - generic [ref=f7e55]: + - button "S" [pressed] [ref=f7e56] + - button "M" [ref=f7e57] + - button "L" [ref=f7e58] + - button "XL" [ref=f7e59] + - group "ColorWhite" [ref=f7e60]: + - generic [ref=f7e62]: + - button "White" [pressed] [ref=f7e63] + - button "Black" [ref=f7e64] + - button "Navy" [ref=f7e65] + - paragraph [ref=f7e66]: In stock + - generic [ref=f7e69]: + - generic [ref=f7e70]: + - button "Decrease quantity" [disabled] [ref=f7e71] + - generic [ref=f7e73]: Quantity + - spinbutton "Quantity" [ref=f7e74]: "1" + - button "Increase quantity" [ref=f7e75] + - button "Add to cart" [ref=f7e78] + - separator [ref=f7e79] + - paragraph [ref=f7e81]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - generic [ref=f7e82]: + - generic [ref=f7e83]: new + - generic [ref=f7e84]: popular + - contentinfo [ref=f7e85]: + - generic [ref=f7e86]: + - generic [ref=f7e87]: + - generic [ref=f7e88]: + - heading "Shop" [level=2] [ref=f7e89] + - list [ref=f7e90]: + - listitem [ref=f7e91]: + - link "About Us" [ref=f7e92] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f7e93]: + - link "FAQ" [ref=f7e94] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f7e95]: + - link "Shipping & Returns" [ref=f7e96] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f7e97]: + - link "Privacy Policy" [ref=f7e98] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f7e99]: + - link "Terms of Service" [ref=f7e100] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f7e101]: + - heading "Acme Fashion" [level=2] [ref=f7e102] + - paragraph [ref=f7e103]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f7e104]: + - paragraph [ref=f7e105]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f7e106]: + - generic [ref=f7e107]: VISA + - generic [ref=f7e108]: MASTERCARD + - generic [ref=f7e109]: AMEX + - generic [ref=f7e110]: PAYPAL + - generic: + - dialog "Your Cart (1)": + - generic [ref=f7e114]: + - generic [ref=f7e115]: + - heading "Your Cart (1)" [level=2] [ref=f7e116] + - button "Close cart" [active] [ref=f7e117] + - list [ref=f7e120]: + - listitem [ref=f7e121]: + - generic [ref=f7e125]: + - paragraph [ref=f7e126]: Classic Cotton T-Shirt + - paragraph [ref=f7e127]: S / White + - generic [ref=f7e128]: + - generic [ref=f7e129]: + - button "Decrease quantity of Classic Cotton T-Shirt" [ref=f7e130] + - generic [ref=f7e132]: "1" + - button "Increase quantity of Classic Cotton T-Shirt" [ref=f7e133] + - paragraph [ref=f7e136]: 24.99 EUR + - button "Remove Classic Cotton T-Shirt from cart" [ref=f7e138] + - generic [ref=f7e142]: + - generic [ref=f7e143]: Discount code + - textbox "Discount code" [ref=f7e144] + - button "Apply" [ref=f7e145] + - generic [ref=f7e146]: + - generic [ref=f7e147]: + - generic [ref=f7e148]: + - term [ref=f7e149]: Subtotal + - definition [ref=f7e150]: 24.99 EUR + - generic [ref=f7e151]: + - term [ref=f7e152]: Estimated total + - definition [ref=f7e153]: 24.99 EUR + - paragraph [ref=f7e154]: Shipping and taxes calculated at checkout + - button "Checkout" [ref=f7e155] + - button "Continue shopping" [ref=f7e157] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-31-41-091Z.yml b/.playwright-mcp/page-2026-07-26T08-31-41-091Z.yml new file mode 100644 index 00000000..ef884d1f --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-31-41-091Z.yml @@ -0,0 +1,137 @@ +- generic [active] [ref=f7e1]: + - link "Skip to main content" [ref=f7e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f7e4]: + - paragraph [ref=f7e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f7e6] + - banner [ref=f7e9]: + - generic [ref=f7e10]: + - link "Acme Fashion" [ref=f7e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f7e13]: + - link "Home" [ref=f7e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f7e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f7e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f7e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f7e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f7e19]: + - button "Search" [ref=f7e20] + - link "Account" [ref=f7e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f7e26]: + - generic [ref=f7e111]: "1" + - main [ref=f7e29]: + - generic [ref=f7e30]: + - navigation "Breadcrumb" [ref=f7e31]: + - list [ref=f7e32]: + - listitem [ref=f7e33]: + - link "Home" [ref=f7e34] [cursor=pointer]: + - /url: http://acme-fashion.test + - listitem [ref=f7e35]: + - generic [ref=f7e36]: / + - link "New Arrivals" [ref=f7e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - listitem [ref=f7e38]: + - generic [ref=f7e39]: / + - generic [ref=f7e40]: Classic Cotton T-Shirt + - generic [ref=f7e41]: + - region "Product images" [ref=f7e42] + - generic [ref=f7e48]: + - heading "Classic Cotton T-Shirt" [level=1] [ref=f7e49] + - paragraph [ref=f7e50]: Acme Basics + - generic [ref=f7e51]: 24.99 EUR + - group "SizeS" [ref=f7e53]: + - generic [ref=f7e55]: + - button "S" [pressed] [ref=f7e56] + - button "M" [ref=f7e57] + - button "L" [ref=f7e58] + - button "XL" [ref=f7e59] + - group "ColorWhite" [ref=f7e60]: + - generic [ref=f7e62]: + - button "White" [pressed] [ref=f7e63] + - button "Black" [ref=f7e64] + - button "Navy" [ref=f7e65] + - paragraph [ref=f7e66]: In stock + - generic [ref=f7e69]: + - generic [ref=f7e70]: + - button "Decrease quantity" [disabled] [ref=f7e71] + - generic [ref=f7e73]: Quantity + - spinbutton "Quantity" [ref=f7e74]: "1" + - button "Increase quantity" [ref=f7e75] + - button "Add to cart" [ref=f7e78] + - separator [ref=f7e79] + - paragraph [ref=f7e81]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - generic [ref=f7e82]: + - generic [ref=f7e83]: new + - generic [ref=f7e84]: popular + - contentinfo [ref=f7e85]: + - generic [ref=f7e86]: + - generic [ref=f7e87]: + - generic [ref=f7e88]: + - heading "Shop" [level=2] [ref=f7e89] + - list [ref=f7e90]: + - listitem [ref=f7e91]: + - link "About Us" [ref=f7e92] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f7e93]: + - link "FAQ" [ref=f7e94] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f7e95]: + - link "Shipping & Returns" [ref=f7e96] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f7e97]: + - link "Privacy Policy" [ref=f7e98] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f7e99]: + - link "Terms of Service" [ref=f7e100] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f7e101]: + - heading "Acme Fashion" [level=2] [ref=f7e102] + - paragraph [ref=f7e103]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f7e104]: + - paragraph [ref=f7e105]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f7e106]: + - generic [ref=f7e107]: VISA + - generic [ref=f7e108]: MASTERCARD + - generic [ref=f7e109]: AMEX + - generic [ref=f7e110]: PAYPAL + - generic: + - dialog "Your Cart (1)": + - generic [ref=f7e114]: + - generic [ref=f7e115]: + - heading "Your Cart (1)" [level=2] [ref=f7e116] + - button "Close cart" [ref=f7e117] + - list [ref=f7e120]: + - listitem [ref=f7e121]: + - generic [ref=f7e125]: + - paragraph [ref=f7e126]: Classic Cotton T-Shirt + - paragraph [ref=f7e127]: S / White + - generic [ref=f7e128]: + - generic [ref=f7e129]: + - button "Decrease quantity of Classic Cotton T-Shirt" [ref=f7e130] + - generic [ref=f7e132]: "1" + - button "Increase quantity of Classic Cotton T-Shirt" [ref=f7e133] + - paragraph [ref=f7e136]: 24.99 EUR + - button "Remove Classic Cotton T-Shirt from cart" [ref=f7e138] + - generic [ref=f7e158]: + - paragraph [ref=f7e159]: WELCOME10 (-10%) + - button "Remove" [ref=f7e160] + - generic [ref=f7e146]: + - generic [ref=f7e147]: + - generic [ref=f7e148]: + - term [ref=f7e149]: Subtotal + - definition [ref=f7e150]: 24.99 EUR + - generic [ref=f7e161]: + - term [ref=f7e162]: Discount (WELCOME10) + - definition [ref=f7e163]: "-2.49 EUR" + - generic [ref=f7e151]: + - term [ref=f7e152]: Estimated total + - definition [ref=f7e153]: 22.50 EUR + - paragraph [ref=f7e154]: Shipping and taxes calculated at checkout + - button "Checkout" [ref=f7e155] + - button "Continue shopping" [ref=f7e157] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-32-02-318Z.yml b/.playwright-mcp/page-2026-07-26T08-32-02-318Z.yml new file mode 100644 index 00000000..d52b2b4e --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-32-02-318Z.yml @@ -0,0 +1,126 @@ +- generic [active] [ref=f8e1]: + - link "Skip to main content" [ref=f8e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f8e4]: + - paragraph [ref=f8e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f8e6] + - banner [ref=f8e9]: + - generic [ref=f8e10]: + - link "Acme Fashion" [ref=f8e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f8e13]: + - link "Home" [ref=f8e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f8e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f8e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f8e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f8e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f8e19]: + - button "Search" [ref=f8e20] + - link "Account" [ref=f8e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f8e26] + - main [ref=f8e29]: + - generic [ref=f8e30]: + - heading "Checkout" [level=1] [ref=f8e31] + - generic [ref=f8e32]: + - generic [ref=f8e33]: + - region [ref=f8e34]: + - heading "1. Contact & shipping address" [level=2] [ref=f8e35] + - generic [ref=f8e37]: + - generic [ref=f8e38]: + - generic [ref=f8e39]: Email * + - textbox "Email" [ref=f8e40] + - generic [ref=f8e41]: + - generic [ref=f8e42]: + - generic [ref=f8e43]: First name * + - textbox "First name" [ref=f8e44] + - generic [ref=f8e45]: + - generic [ref=f8e46]: Last name * + - textbox "Last name" [ref=f8e47] + - generic [ref=f8e48]: + - generic [ref=f8e49]: Address line 1 * + - textbox "Address line 1" [ref=f8e50] + - generic [ref=f8e51]: + - generic [ref=f8e52]: Address line 2 (optional) + - textbox "Address line 2 (optional)" [ref=f8e53] + - generic [ref=f8e54]: + - generic [ref=f8e55]: City * + - textbox "City" [ref=f8e56] + - generic [ref=f8e57]: + - generic [ref=f8e58]: State / Province (optional) + - textbox "State / Province (optional)" [ref=f8e59] + - generic [ref=f8e60]: + - generic [ref=f8e61]: Postal code * + - textbox "Postal code" [ref=f8e62] + - generic [ref=f8e63]: + - generic [ref=f8e64]: Country code (e.g. DE) * + - textbox "Country code (e.g. DE)" [ref=f8e65] + - generic [ref=f8e66]: + - generic [ref=f8e67]: Phone (optional) + - textbox "Phone (optional)" [ref=f8e68] + - generic [ref=f8e69]: + - checkbox "Billing address same as shipping" [checked] [ref=f8e70] + - text: Billing address same as shipping + - button "Continue to shipping" [ref=f8e71] + - region [ref=f8e72]: + - heading "2. Shipping method" [level=2] [ref=f8e73] + - region [ref=f8e74]: + - heading "3. Payment" [level=2] [ref=f8e75] + - complementary "Order summary" [ref=f8e76]: + - generic [ref=f8e77]: + - heading "Order Summary" [level=2] [ref=f8e78] + - list [ref=f8e79]: + - listitem [ref=f8e80]: + - generic [ref=f8e84]: + - paragraph [ref=f8e85]: Classic Cotton T-Shirt ×1 + - paragraph [ref=f8e86]: S / White + - paragraph [ref=f8e87]: 24.99 EUR + - generic [ref=f8e88]: + - generic [ref=f8e89]: + - term [ref=f8e90]: Subtotal + - definition [ref=f8e91]: 24.99 EUR + - generic [ref=f8e92]: + - term [ref=f8e93]: Shipping + - definition [ref=f8e94]: Calculated at next step + - generic [ref=f8e95]: + - term [ref=f8e96]: Tax + - definition [ref=f8e97]: 0.00 EUR + - generic [ref=f8e98]: + - term [ref=f8e99]: Total + - definition [ref=f8e100]: 24.99 EUR + - contentinfo [ref=f8e101]: + - generic [ref=f8e102]: + - generic [ref=f8e103]: + - generic [ref=f8e104]: + - heading "Shop" [level=2] [ref=f8e105] + - list [ref=f8e106]: + - listitem [ref=f8e107]: + - link "About Us" [ref=f8e108] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f8e109]: + - link "FAQ" [ref=f8e110] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f8e111]: + - link "Shipping & Returns" [ref=f8e112] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f8e113]: + - link "Privacy Policy" [ref=f8e114] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f8e115]: + - link "Terms of Service" [ref=f8e116] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f8e117]: + - heading "Acme Fashion" [level=2] [ref=f8e118] + - paragraph [ref=f8e119]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f8e120]: + - paragraph [ref=f8e121]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f8e122]: + - generic [ref=f8e123]: VISA + - generic [ref=f8e124]: MASTERCARD + - generic [ref=f8e125]: AMEX + - generic [ref=f8e126]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-32-36-584Z.yml b/.playwright-mcp/page-2026-07-26T08-32-36-584Z.yml new file mode 100644 index 00000000..2671238f --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-32-36-584Z.yml @@ -0,0 +1,106 @@ +- generic [active] [ref=f9e1]: + - link "Skip to main content" [ref=f9e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f9e4]: + - paragraph [ref=f9e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f9e6] + - banner [ref=f9e9]: + - generic [ref=f9e10]: + - link "Acme Fashion" [ref=f9e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f9e13]: + - link "Home" [ref=f9e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f9e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f9e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f9e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f9e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f9e19]: + - button "Search" [ref=f9e20] + - link "Account" [ref=f9e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f9e26] + - main [ref=f9e29]: + - generic [ref=f9e30]: + - heading "Checkout" [level=1] [ref=f9e31] + - generic [ref=f9e32]: + - generic [ref=f9e33]: + - region [ref=f9e34]: + - heading "1. Contact & shipping address jane@example.com" [level=2] [ref=f9e35]: + - generic [ref=f9e36]: 1. Contact & shipping address + - generic [ref=f9e37]: jane@example.com + - generic [ref=f9e38]: Jane Doe, 123 Main St, 10115 Berlin, DE + - region [ref=f9e39]: + - heading "2. Shipping method" [level=2] [ref=f9e40] + - group "Available shipping methods" [ref=f9e41]: + - button "Standard Shipping 4.99 EUR" [ref=f9e43]: + - generic [ref=f9e44]: Standard Shipping + - generic [ref=f9e45]: 4.99 EUR + - button "Express Shipping 9.99 EUR" [ref=f9e46]: + - generic [ref=f9e47]: Express Shipping + - generic [ref=f9e48]: 9.99 EUR + - region [ref=f9e49]: + - heading "3. Payment" [level=2] [ref=f9e50] + - complementary "Order summary" [ref=f9e51]: + - generic [ref=f9e52]: + - heading "Order Summary" [level=2] [ref=f9e53] + - list [ref=f9e54]: + - listitem [ref=f9e55]: + - generic [ref=f9e59]: + - paragraph [ref=f9e60]: Classic Cotton T-Shirt ×1 + - paragraph [ref=f9e61]: S / White + - paragraph [ref=f9e62]: 22.50 EUR + - generic [ref=f9e64]: + - paragraph [ref=f9e65]: WELCOME10 + - button "Remove" [ref=f9e66] + - generic [ref=f9e67]: + - generic [ref=f9e68]: + - term [ref=f9e69]: Subtotal + - definition [ref=f9e70]: 24.99 EUR + - generic [ref=f9e71]: + - term [ref=f9e72]: Discount + - definition [ref=f9e73]: "-2.49 EUR" + - generic [ref=f9e74]: + - term [ref=f9e75]: Shipping + - definition [ref=f9e76]: 0.00 EUR + - generic [ref=f9e77]: + - term [ref=f9e78]: Tax + - definition [ref=f9e79]: 3.60 EUR + - generic [ref=f9e80]: + - term [ref=f9e81]: Total + - definition [ref=f9e82]: 22.50 EUR + - contentinfo [ref=f9e83]: + - generic [ref=f9e84]: + - generic [ref=f9e85]: + - generic [ref=f9e86]: + - heading "Shop" [level=2] [ref=f9e87] + - list [ref=f9e88]: + - listitem [ref=f9e89]: + - link "About Us" [ref=f9e90] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f9e91]: + - link "FAQ" [ref=f9e92] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f9e93]: + - link "Shipping & Returns" [ref=f9e94] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f9e95]: + - link "Privacy Policy" [ref=f9e96] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f9e97]: + - link "Terms of Service" [ref=f9e98] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f9e99]: + - heading "Acme Fashion" [level=2] [ref=f9e100] + - paragraph [ref=f9e101]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f9e102]: + - paragraph [ref=f9e103]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f9e104]: + - generic [ref=f9e105]: VISA + - generic [ref=f9e106]: MASTERCARD + - generic [ref=f9e107]: AMEX + - generic [ref=f9e108]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-33-01-325Z.yml b/.playwright-mcp/page-2026-07-26T08-33-01-325Z.yml new file mode 100644 index 00000000..c6eb7f9e --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-33-01-325Z.yml @@ -0,0 +1,112 @@ +- generic [active] [ref=f9e1]: + - link "Skip to main content" [ref=f9e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f9e4]: + - paragraph [ref=f9e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f9e6] + - banner [ref=f9e9]: + - generic [ref=f9e10]: + - link "Acme Fashion" [ref=f9e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f9e13]: + - link "Home" [ref=f9e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f9e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f9e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f9e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f9e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f9e19]: + - button "Search" [ref=f9e20] + - link "Account" [ref=f9e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f9e26] + - main [ref=f9e29]: + - generic [ref=f9e30]: + - heading "Checkout" [level=1] [ref=f9e31] + - generic [ref=f9e32]: + - generic [ref=f9e33]: + - region [ref=f9e34]: + - heading "1. Contact & shipping address jane@example.com" [level=2] [ref=f9e35]: + - generic [ref=f9e36]: 1. Contact & shipping address + - generic [ref=f9e37]: jane@example.com + - generic [ref=f9e38]: Jane Doe, 123 Main St, 10115 Berlin, DE + - region [ref=f9e39]: + - heading "2. Shipping method" [level=2] [ref=f9e40] + - generic [ref=f9e109]: Shipping method selected + - region [ref=f9e49]: + - heading "3. Payment" [level=2] [ref=f9e50] + - generic [ref=f9e110]: + - group "Select a payment method" [ref=f9e111]: + - generic [ref=f9e113] [cursor=pointer]: + - radio "Credit Card" [checked] [ref=f9e114] + - generic [ref=f9e115]: Credit Card + - generic [ref=f9e116] [cursor=pointer]: + - radio "PayPal" [ref=f9e117] + - generic [ref=f9e118]: PayPal + - generic [ref=f9e119] [cursor=pointer]: + - radio "Bank Transfer" [ref=f9e120] + - generic [ref=f9e121]: Bank Transfer + - button "Continue" [ref=f9e122] + - complementary "Order summary" [ref=f9e51]: + - generic [ref=f9e52]: + - heading "Order Summary" [level=2] [ref=f9e53] + - list [ref=f9e54]: + - listitem [ref=f9e55]: + - generic [ref=f9e59]: + - paragraph [ref=f9e60]: Classic Cotton T-Shirt ×1 + - paragraph [ref=f9e61]: S / White + - paragraph [ref=f9e62]: 22.50 EUR + - generic [ref=f9e64]: + - paragraph [ref=f9e65]: WELCOME10 + - button "Remove" [ref=f9e66] + - generic [ref=f9e67]: + - generic [ref=f9e68]: + - term [ref=f9e69]: Subtotal + - definition [ref=f9e70]: 24.99 EUR + - generic [ref=f9e71]: + - term [ref=f9e72]: Discount + - definition [ref=f9e73]: "-2.49 EUR" + - generic [ref=f9e74]: + - term [ref=f9e75]: Shipping + - definition [ref=f9e76]: 4.99 EUR + - generic [ref=f9e77]: + - term [ref=f9e78]: Tax + - definition [ref=f9e79]: 4.40 EUR + - generic [ref=f9e80]: + - term [ref=f9e81]: Total + - definition [ref=f9e82]: 27.49 EUR + - contentinfo [ref=f9e83]: + - generic [ref=f9e84]: + - generic [ref=f9e85]: + - generic [ref=f9e86]: + - heading "Shop" [level=2] [ref=f9e87] + - list [ref=f9e88]: + - listitem [ref=f9e89]: + - link "About Us" [ref=f9e90] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f9e91]: + - link "FAQ" [ref=f9e92] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f9e93]: + - link "Shipping & Returns" [ref=f9e94] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f9e95]: + - link "Privacy Policy" [ref=f9e96] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f9e97]: + - link "Terms of Service" [ref=f9e98] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f9e99]: + - heading "Acme Fashion" [level=2] [ref=f9e100] + - paragraph [ref=f9e101]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f9e102]: + - paragraph [ref=f9e103]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f9e104]: + - generic [ref=f9e105]: VISA + - generic [ref=f9e106]: MASTERCARD + - generic [ref=f9e107]: AMEX + - generic [ref=f9e108]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-33-24-454Z.yml b/.playwright-mcp/page-2026-07-26T08-33-24-454Z.yml new file mode 100644 index 00000000..b48f9d41 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-33-24-454Z.yml @@ -0,0 +1,129 @@ +- generic [active] [ref=f9e1]: + - link "Skip to main content" [ref=f9e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f9e4]: + - paragraph [ref=f9e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f9e6] + - banner [ref=f9e9]: + - generic [ref=f9e10]: + - link "Acme Fashion" [ref=f9e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f9e13]: + - link "Home" [ref=f9e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f9e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f9e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f9e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f9e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f9e19]: + - button "Search" [ref=f9e20] + - link "Account" [ref=f9e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f9e26] + - main [ref=f9e29]: + - generic [ref=f9e30]: + - heading "Checkout" [level=1] [ref=f9e31] + - generic [ref=f9e32]: + - generic [ref=f9e33]: + - region [ref=f9e34]: + - heading "1. Contact & shipping address jane@example.com" [level=2] [ref=f9e35]: + - generic [ref=f9e36]: 1. Contact & shipping address + - generic [ref=f9e37]: jane@example.com + - generic [ref=f9e38]: Jane Doe, 123 Main St, 10115 Berlin, DE + - region [ref=f9e39]: + - heading "2. Shipping method" [level=2] [ref=f9e40] + - generic [ref=f9e109]: Shipping method selected + - region [ref=f9e49]: + - heading "3. Payment" [level=2] [ref=f9e50] + - generic [ref=f9e110]: + - group "Select a payment method" [ref=f9e111]: + - generic [ref=f9e113] [cursor=pointer]: + - radio "Credit Card" [checked] [disabled] [ref=f9e114] + - generic [ref=f9e115]: Credit Card + - generic [ref=f9e116] [cursor=pointer]: + - radio "PayPal" [disabled] [ref=f9e117] + - generic [ref=f9e118]: PayPal + - generic [ref=f9e119] [cursor=pointer]: + - radio "Bank Transfer" [disabled] [ref=f9e120] + - generic [ref=f9e121]: Bank Transfer + - generic [ref=f9e123]: + - generic [ref=f9e124]: + - generic [ref=f9e125]: Card number * + - textbox "Card number" [ref=f9e126]: + - /placeholder: 4242 4242 4242 4242 + - generic [ref=f9e127]: + - generic [ref=f9e128]: Cardholder name * + - textbox "Cardholder name" [ref=f9e129] + - generic [ref=f9e130]: + - generic [ref=f9e131]: + - generic [ref=f9e132]: Expiry (MM/YY) * + - textbox "Expiry (MM/YY)" [ref=f9e133]: + - /placeholder: 12/28 + - generic [ref=f9e134]: + - generic [ref=f9e135]: CVC * + - textbox "CVC" [ref=f9e136]: + - /placeholder: "123" + - button "Pay now - 27.49 EUR" [ref=f9e137] + - complementary "Order summary" [ref=f9e51]: + - generic [ref=f9e52]: + - heading "Order Summary" [level=2] [ref=f9e53] + - list [ref=f9e54]: + - listitem [ref=f9e55]: + - generic [ref=f9e59]: + - paragraph [ref=f9e60]: Classic Cotton T-Shirt ×1 + - paragraph [ref=f9e61]: S / White + - paragraph [ref=f9e62]: 22.50 EUR + - generic [ref=f9e64]: + - paragraph [ref=f9e65]: WELCOME10 + - button "Remove" [ref=f9e66] + - generic [ref=f9e67]: + - generic [ref=f9e68]: + - term [ref=f9e69]: Subtotal + - definition [ref=f9e70]: 24.99 EUR + - generic [ref=f9e71]: + - term [ref=f9e72]: Discount + - definition [ref=f9e73]: "-2.49 EUR" + - generic [ref=f9e74]: + - term [ref=f9e75]: Shipping + - definition [ref=f9e76]: 4.99 EUR + - generic [ref=f9e77]: + - term [ref=f9e78]: Tax + - definition [ref=f9e79]: 4.40 EUR + - generic [ref=f9e80]: + - term [ref=f9e81]: Total + - definition [ref=f9e82]: 27.49 EUR + - contentinfo [ref=f9e83]: + - generic [ref=f9e84]: + - generic [ref=f9e85]: + - generic [ref=f9e86]: + - heading "Shop" [level=2] [ref=f9e87] + - list [ref=f9e88]: + - listitem [ref=f9e89]: + - link "About Us" [ref=f9e90] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f9e91]: + - link "FAQ" [ref=f9e92] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f9e93]: + - link "Shipping & Returns" [ref=f9e94] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f9e95]: + - link "Privacy Policy" [ref=f9e96] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f9e97]: + - link "Terms of Service" [ref=f9e98] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f9e99]: + - heading "Acme Fashion" [level=2] [ref=f9e100] + - paragraph [ref=f9e101]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f9e102]: + - paragraph [ref=f9e103]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f9e104]: + - generic [ref=f9e105]: VISA + - generic [ref=f9e106]: MASTERCARD + - generic [ref=f9e107]: AMEX + - generic [ref=f9e108]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-34-00-735Z.yml b/.playwright-mcp/page-2026-07-26T08-34-00-735Z.yml new file mode 100644 index 00000000..607feccf --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-34-00-735Z.yml @@ -0,0 +1,133 @@ +- generic [active] [ref=f9e1]: + - link "Skip to main content" [ref=f9e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f9e4]: + - paragraph [ref=f9e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f9e6] + - banner [ref=f9e9]: + - generic [ref=f9e10]: + - link "Acme Fashion" [ref=f9e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f9e13]: + - link "Home" [ref=f9e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f9e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f9e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f9e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f9e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f9e19]: + - button "Search" [ref=f9e20] + - link "Account" [ref=f9e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f9e26] + - main [ref=f9e29]: + - generic [ref=f9e30]: + - heading "Checkout" [level=1] [ref=f9e31] + - generic [ref=f9e32]: + - generic [ref=f9e33]: + - region [ref=f9e34]: + - heading "1. Contact & shipping address jane@example.com" [level=2] [ref=f9e35]: + - generic [ref=f9e36]: 1. Contact & shipping address + - generic [ref=f9e37]: jane@example.com + - generic [ref=f9e38]: Jane Doe, 123 Main St, 10115 Berlin, DE + - region [ref=f9e39]: + - heading "2. Shipping method" [level=2] [ref=f9e40] + - generic [ref=f9e109]: Shipping method selected + - region [ref=f9e49]: + - heading "3. Payment" [level=2] [ref=f9e50] + - generic [ref=f9e110]: + - group "Select a payment method" [ref=f9e111]: + - generic [ref=f9e113] [cursor=pointer]: + - radio "Credit Card" [checked] [disabled] [ref=f9e114] + - generic [ref=f9e115]: Credit Card + - generic [ref=f9e116] [cursor=pointer]: + - radio "PayPal" [disabled] [ref=f9e117] + - generic [ref=f9e118]: PayPal + - generic [ref=f9e119] [cursor=pointer]: + - radio "Bank Transfer" [disabled] [ref=f9e120] + - generic [ref=f9e121]: Bank Transfer + - generic [ref=f9e123]: + - generic [ref=f9e124]: + - generic [ref=f9e125]: Card number * + - textbox "Card number" [ref=f9e126]: + - /placeholder: 4242 4242 4242 4242 + - text: "4000000000000002" + - generic [ref=f9e127]: + - generic [ref=f9e128]: Cardholder name * + - textbox "Cardholder name" [ref=f9e129]: Jane Doe + - generic [ref=f9e130]: + - generic [ref=f9e131]: + - generic [ref=f9e132]: Expiry (MM/YY) * + - textbox "Expiry (MM/YY)" [ref=f9e133]: + - /placeholder: 12/28 + - text: 12/28 + - generic [ref=f9e134]: + - generic [ref=f9e135]: CVC * + - textbox "CVC" [ref=f9e136]: + - /placeholder: "123" + - text: "123" + - alert [ref=f9e139]: "Payment declined: Your card was declined." + - button "Pay now - 27.49 EUR" [ref=f9e137] + - complementary "Order summary" [ref=f9e51]: + - generic [ref=f9e52]: + - heading "Order Summary" [level=2] [ref=f9e53] + - list [ref=f9e54]: + - listitem [ref=f9e55]: + - generic [ref=f9e59]: + - paragraph [ref=f9e60]: Classic Cotton T-Shirt ×1 + - paragraph [ref=f9e61]: S / White + - paragraph [ref=f9e62]: 22.50 EUR + - generic [ref=f9e64]: + - paragraph [ref=f9e65]: WELCOME10 + - button "Remove" [ref=f9e66] + - generic [ref=f9e67]: + - generic [ref=f9e68]: + - term [ref=f9e69]: Subtotal + - definition [ref=f9e70]: 24.99 EUR + - generic [ref=f9e71]: + - term [ref=f9e72]: Discount + - definition [ref=f9e73]: "-2.49 EUR" + - generic [ref=f9e74]: + - term [ref=f9e75]: Shipping + - definition [ref=f9e76]: 4.99 EUR + - generic [ref=f9e77]: + - term [ref=f9e78]: Tax + - definition [ref=f9e79]: 4.40 EUR + - generic [ref=f9e80]: + - term [ref=f9e81]: Total + - definition [ref=f9e82]: 27.49 EUR + - contentinfo [ref=f9e83]: + - generic [ref=f9e84]: + - generic [ref=f9e85]: + - generic [ref=f9e86]: + - heading "Shop" [level=2] [ref=f9e87] + - list [ref=f9e88]: + - listitem [ref=f9e89]: + - link "About Us" [ref=f9e90] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f9e91]: + - link "FAQ" [ref=f9e92] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f9e93]: + - link "Shipping & Returns" [ref=f9e94] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f9e95]: + - link "Privacy Policy" [ref=f9e96] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f9e97]: + - link "Terms of Service" [ref=f9e98] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f9e99]: + - heading "Acme Fashion" [level=2] [ref=f9e100] + - paragraph [ref=f9e101]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f9e102]: + - paragraph [ref=f9e103]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f9e104]: + - generic [ref=f9e105]: VISA + - generic [ref=f9e106]: MASTERCARD + - generic [ref=f9e107]: AMEX + - generic [ref=f9e108]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-34-46-271Z.yml b/.playwright-mcp/page-2026-07-26T08-34-46-271Z.yml new file mode 100644 index 00000000..5ce7afb9 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-34-46-271Z.yml @@ -0,0 +1,100 @@ +- generic [active] [ref=f10e1]: + - link "Skip to main content" [ref=f10e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f10e4]: + - paragraph [ref=f10e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f10e6] + - banner [ref=f10e9]: + - generic [ref=f10e10]: + - link "Acme Fashion" [ref=f10e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f10e13]: + - link "Home" [ref=f10e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f10e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f10e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f10e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f10e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f10e19]: + - button "Search" [ref=f10e20] + - link "Account" [ref=f10e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f10e26] + - main [ref=f10e29]: + - generic [ref=f10e30]: + - generic [ref=f10e31]: + - heading "Thank you for your order!" [level=1] [ref=f10e35] + - paragraph [ref=f10e36]: "Order #1016" + - paragraph [ref=f10e37]: We've sent a confirmation to jane@example.com + - region [ref=f10e38]: + - heading "Order Summary" [level=2] [ref=f10e39] + - list [ref=f10e40]: + - listitem [ref=f10e41]: + - generic [ref=f10e42]: + - paragraph [ref=f10e43]: Classic Cotton T-Shirt - S / White + - paragraph [ref=f10e44]: "SKU: ACME-CTSH-S-WHT" + - paragraph [ref=f10e45]: ×1 + - paragraph [ref=f10e46]: 22.50 EUR + - generic [ref=f10e47]: + - region [ref=f10e48]: + - heading "Shipping Address" [level=2] [ref=f10e49] + - generic [ref=f10e50]: Jane Doe 123 Main St 10115 Berlin DE + - region [ref=f10e51]: + - heading "Payment Method" [level=2] [ref=f10e52] + - paragraph [ref=f10e53]: Credit Card ending in 4242 + - generic [ref=f10e54]: + - generic [ref=f10e55]: + - term [ref=f10e56]: Subtotal + - definition [ref=f10e57]: 24.99 EUR + - generic [ref=f10e58]: + - term [ref=f10e59]: Discount + - definition [ref=f10e60]: "-2.49 EUR" + - generic [ref=f10e61]: + - term [ref=f10e62]: Shipping + - definition [ref=f10e63]: 4.99 EUR + - generic [ref=f10e64]: + - term [ref=f10e65]: Tax + - definition [ref=f10e66]: 4.40 EUR + - generic [ref=f10e67]: + - term [ref=f10e68]: Total + - definition [ref=f10e69]: 27.49 EUR + - generic [ref=f10e70]: + - link "Continue shopping" [ref=f10e71] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "View order status" [ref=f10e72] [cursor=pointer]: + - /url: /api/storefront/v1/orders/%231016?token=bc7fc02b48bc4b0591260b51eaddfbb23a69e5db1858694c5e0a7f0b30c92c3f + - contentinfo [ref=f10e73]: + - generic [ref=f10e74]: + - generic [ref=f10e75]: + - generic [ref=f10e76]: + - heading "Shop" [level=2] [ref=f10e77] + - list [ref=f10e78]: + - listitem [ref=f10e79]: + - link "About Us" [ref=f10e80] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f10e81]: + - link "FAQ" [ref=f10e82] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f10e83]: + - link "Shipping & Returns" [ref=f10e84] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f10e85]: + - link "Privacy Policy" [ref=f10e86] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f10e87]: + - link "Terms of Service" [ref=f10e88] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f10e89]: + - heading "Acme Fashion" [level=2] [ref=f10e90] + - paragraph [ref=f10e91]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f10e92]: + - paragraph [ref=f10e93]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f10e94]: + - generic [ref=f10e95]: VISA + - generic [ref=f10e96]: MASTERCARD + - generic [ref=f10e97]: AMEX + - generic [ref=f10e98]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-35-29-336Z.yml b/.playwright-mcp/page-2026-07-26T08-35-29-336Z.yml new file mode 100644 index 00000000..fcfc3c4c --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-35-29-336Z.yml @@ -0,0 +1,106 @@ +- generic [active] [ref=f11e1]: + - link "Skip to main content" [ref=f11e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f11e4]: + - paragraph [ref=f11e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f11e6] + - banner [ref=f11e9]: + - generic [ref=f11e10]: + - link "Acme Fashion" [ref=f11e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f11e13]: + - link "Home" [ref=f11e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f11e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f11e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f11e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f11e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f11e19]: + - button "Search" [ref=f11e20] + - link "Account" [ref=f11e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f11e26] + - main [ref=f11e29]: + - generic [ref=f11e30]: + - navigation "Breadcrumb" [ref=f11e31]: + - list [ref=f11e32]: + - listitem [ref=f11e33]: + - link "Home" [ref=f11e34] [cursor=pointer]: + - /url: http://acme-fashion.test + - listitem [ref=f11e35]: + - generic [ref=f11e36]: / + - link "Pants & Jeans" [ref=f11e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/pants-jeans + - listitem [ref=f11e38]: + - generic [ref=f11e39]: / + - generic [ref=f11e40]: Premium Slim Fit Jeans + - generic [ref=f11e41]: + - region "Product images" [ref=f11e42] + - generic [ref=f11e48]: + - heading "Premium Slim Fit Jeans" [level=1] [ref=f11e49] + - paragraph [ref=f11e50]: Acme Denim + - generic [ref=f11e51]: + - generic [ref=f11e52]: 79.99 EUR + - generic [ref=f11e53]: 99.99 EUR + - generic [ref=f11e54]: + - generic [ref=f11e55]: "On sale:" + - text: Sale + - group "Size28" [ref=f11e56]: + - generic [ref=f11e58]: + - button "28" [pressed] [ref=f11e59] + - button "30" [ref=f11e60] + - button "32" [ref=f11e61] + - button "34" [ref=f11e62] + - button "36" [ref=f11e63] + - group "ColorBlue" [ref=f11e64]: + - generic [ref=f11e66]: + - button "Blue" [pressed] [ref=f11e67] + - button "Black" [ref=f11e68] + - paragraph [ref=f11e69]: Only 8 left in stock + - generic [ref=f11e72]: + - generic [ref=f11e73]: + - button "Decrease quantity" [disabled] [ref=f11e74] + - generic [ref=f11e76]: Quantity + - spinbutton "Quantity" [ref=f11e77]: "1" + - button "Increase quantity" [ref=f11e78] + - button "Add to cart" [ref=f11e81] + - separator [ref=f11e82] + - paragraph [ref=f11e84]: Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette. + - generic [ref=f11e85]: + - generic [ref=f11e86]: new + - generic [ref=f11e87]: sale + - contentinfo [ref=f11e88]: + - generic [ref=f11e89]: + - generic [ref=f11e90]: + - generic [ref=f11e91]: + - heading "Shop" [level=2] [ref=f11e92] + - list [ref=f11e93]: + - listitem [ref=f11e94]: + - link "About Us" [ref=f11e95] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f11e96]: + - link "FAQ" [ref=f11e97] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f11e98]: + - link "Shipping & Returns" [ref=f11e99] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f11e100]: + - link "Privacy Policy" [ref=f11e101] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f11e102]: + - link "Terms of Service" [ref=f11e103] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f11e104]: + - heading "Acme Fashion" [level=2] [ref=f11e105] + - paragraph [ref=f11e106]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f11e107]: + - paragraph [ref=f11e108]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f11e109]: + - generic [ref=f11e110]: VISA + - generic [ref=f11e111]: MASTERCARD + - generic [ref=f11e112]: AMEX + - generic [ref=f11e113]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-35-43-401Z.yml b/.playwright-mcp/page-2026-07-26T08-35-43-401Z.yml new file mode 100644 index 00000000..a11d7568 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-35-43-401Z.yml @@ -0,0 +1,140 @@ +- generic [ref=f11e1]: + - link "Skip to main content" [ref=f11e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f11e4]: + - paragraph [ref=f11e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f11e6] + - banner [ref=f11e9]: + - generic [ref=f11e10]: + - link "Acme Fashion" [ref=f11e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f11e13]: + - link "Home" [ref=f11e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f11e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f11e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f11e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f11e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f11e19]: + - button "Search" [ref=f11e20] + - link "Account" [ref=f11e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f11e26]: + - generic [ref=f11e114]: "1" + - main [ref=f11e29]: + - generic [ref=f11e30]: + - navigation "Breadcrumb" [ref=f11e31]: + - list [ref=f11e32]: + - listitem [ref=f11e33]: + - link "Home" [ref=f11e34] [cursor=pointer]: + - /url: http://acme-fashion.test + - listitem [ref=f11e35]: + - generic [ref=f11e36]: / + - link "Pants & Jeans" [ref=f11e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/pants-jeans + - listitem [ref=f11e38]: + - generic [ref=f11e39]: / + - generic [ref=f11e40]: Premium Slim Fit Jeans + - generic [ref=f11e41]: + - region "Product images" [ref=f11e42] + - generic [ref=f11e48]: + - heading "Premium Slim Fit Jeans" [level=1] [ref=f11e49] + - paragraph [ref=f11e50]: Acme Denim + - generic [ref=f11e51]: + - generic [ref=f11e52]: 79.99 EUR + - generic [ref=f11e53]: 99.99 EUR + - generic [ref=f11e54]: + - generic [ref=f11e55]: "On sale:" + - text: Sale + - group "Size28" [ref=f11e56]: + - generic [ref=f11e58]: + - button "28" [pressed] [ref=f11e59] + - button "30" [ref=f11e60] + - button "32" [ref=f11e61] + - button "34" [ref=f11e62] + - button "36" [ref=f11e63] + - group "ColorBlue" [ref=f11e64]: + - generic [ref=f11e66]: + - button "Blue" [pressed] [ref=f11e67] + - button "Black" [ref=f11e68] + - paragraph [ref=f11e69]: Only 8 left in stock + - generic [ref=f11e72]: + - generic [ref=f11e73]: + - button "Decrease quantity" [disabled] [ref=f11e74] + - generic [ref=f11e76]: Quantity + - spinbutton "Quantity" [ref=f11e77]: "1" + - button "Increase quantity" [ref=f11e78] + - button "Add to cart" [ref=f11e81] + - separator [ref=f11e82] + - paragraph [ref=f11e84]: Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette. + - generic [ref=f11e85]: + - generic [ref=f11e86]: new + - generic [ref=f11e87]: sale + - contentinfo [ref=f11e88]: + - generic [ref=f11e89]: + - generic [ref=f11e90]: + - generic [ref=f11e91]: + - heading "Shop" [level=2] [ref=f11e92] + - list [ref=f11e93]: + - listitem [ref=f11e94]: + - link "About Us" [ref=f11e95] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f11e96]: + - link "FAQ" [ref=f11e97] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f11e98]: + - link "Shipping & Returns" [ref=f11e99] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f11e100]: + - link "Privacy Policy" [ref=f11e101] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f11e102]: + - link "Terms of Service" [ref=f11e103] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f11e104]: + - heading "Acme Fashion" [level=2] [ref=f11e105] + - paragraph [ref=f11e106]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f11e107]: + - paragraph [ref=f11e108]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f11e109]: + - generic [ref=f11e110]: VISA + - generic [ref=f11e111]: MASTERCARD + - generic [ref=f11e112]: AMEX + - generic [ref=f11e113]: PAYPAL + - generic: + - dialog "Your Cart (1)": + - generic [ref=f11e117]: + - generic [ref=f11e118]: + - heading "Your Cart (1)" [level=2] [ref=f11e119] + - button "Close cart" [active] [ref=f11e120] + - list [ref=f11e123]: + - listitem [ref=f11e124]: + - generic [ref=f11e128]: + - paragraph [ref=f11e129]: Premium Slim Fit Jeans + - paragraph [ref=f11e130]: 28 / Blue + - generic [ref=f11e131]: + - generic [ref=f11e132]: + - button "Decrease quantity of Premium Slim Fit Jeans" [ref=f11e133] + - generic [ref=f11e135]: "1" + - button "Increase quantity of Premium Slim Fit Jeans" [ref=f11e136] + - paragraph [ref=f11e139]: 79.99 EUR + - button "Remove Premium Slim Fit Jeans from cart" [ref=f11e141] + - generic [ref=f11e145]: + - generic [ref=f11e146]: Discount code + - textbox "Discount code" [ref=f11e147] + - button "Apply" [ref=f11e148] + - generic [ref=f11e149]: + - generic [ref=f11e150]: + - generic [ref=f11e151]: + - term [ref=f11e152]: Subtotal + - definition [ref=f11e153]: 79.99 EUR + - generic [ref=f11e154]: + - term [ref=f11e155]: Estimated total + - definition [ref=f11e156]: 79.99 EUR + - paragraph [ref=f11e157]: Shipping and taxes calculated at checkout + - button "Checkout" [ref=f11e158] + - button "Continue shopping" [ref=f11e160] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-35-57-391Z.yml b/.playwright-mcp/page-2026-07-26T08-35-57-391Z.yml new file mode 100644 index 00000000..72734e01 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-35-57-391Z.yml @@ -0,0 +1,126 @@ +- generic [active] [ref=f12e1]: + - link "Skip to main content" [ref=f12e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f12e4]: + - paragraph [ref=f12e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f12e6] + - banner [ref=f12e9]: + - generic [ref=f12e10]: + - link "Acme Fashion" [ref=f12e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f12e13]: + - link "Home" [ref=f12e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f12e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f12e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f12e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f12e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f12e19]: + - button "Search" [ref=f12e20] + - link "Account" [ref=f12e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f12e26] + - main [ref=f12e29]: + - generic [ref=f12e30]: + - heading "Checkout" [level=1] [ref=f12e31] + - generic [ref=f12e32]: + - generic [ref=f12e33]: + - region [ref=f12e34]: + - heading "1. Contact & shipping address" [level=2] [ref=f12e35] + - generic [ref=f12e37]: + - generic [ref=f12e38]: + - generic [ref=f12e39]: Email * + - textbox "Email" [ref=f12e40] + - generic [ref=f12e41]: + - generic [ref=f12e42]: + - generic [ref=f12e43]: First name * + - textbox "First name" [ref=f12e44] + - generic [ref=f12e45]: + - generic [ref=f12e46]: Last name * + - textbox "Last name" [ref=f12e47] + - generic [ref=f12e48]: + - generic [ref=f12e49]: Address line 1 * + - textbox "Address line 1" [ref=f12e50] + - generic [ref=f12e51]: + - generic [ref=f12e52]: Address line 2 (optional) + - textbox "Address line 2 (optional)" [ref=f12e53] + - generic [ref=f12e54]: + - generic [ref=f12e55]: City * + - textbox "City" [ref=f12e56] + - generic [ref=f12e57]: + - generic [ref=f12e58]: State / Province (optional) + - textbox "State / Province (optional)" [ref=f12e59] + - generic [ref=f12e60]: + - generic [ref=f12e61]: Postal code * + - textbox "Postal code" [ref=f12e62] + - generic [ref=f12e63]: + - generic [ref=f12e64]: Country code (e.g. DE) * + - textbox "Country code (e.g. DE)" [ref=f12e65] + - generic [ref=f12e66]: + - generic [ref=f12e67]: Phone (optional) + - textbox "Phone (optional)" [ref=f12e68] + - generic [ref=f12e69]: + - checkbox "Billing address same as shipping" [checked] [ref=f12e70] + - text: Billing address same as shipping + - button "Continue to shipping" [ref=f12e71] + - region [ref=f12e72]: + - heading "2. Shipping method" [level=2] [ref=f12e73] + - region [ref=f12e74]: + - heading "3. Payment" [level=2] [ref=f12e75] + - complementary "Order summary" [ref=f12e76]: + - generic [ref=f12e77]: + - heading "Order Summary" [level=2] [ref=f12e78] + - list [ref=f12e79]: + - listitem [ref=f12e80]: + - generic [ref=f12e84]: + - paragraph [ref=f12e85]: Premium Slim Fit Jeans ×1 + - paragraph [ref=f12e86]: 28 / Blue + - paragraph [ref=f12e87]: 79.99 EUR + - generic [ref=f12e88]: + - generic [ref=f12e89]: + - term [ref=f12e90]: Subtotal + - definition [ref=f12e91]: 79.99 EUR + - generic [ref=f12e92]: + - term [ref=f12e93]: Shipping + - definition [ref=f12e94]: Calculated at next step + - generic [ref=f12e95]: + - term [ref=f12e96]: Tax + - definition [ref=f12e97]: 0.00 EUR + - generic [ref=f12e98]: + - term [ref=f12e99]: Total + - definition [ref=f12e100]: 79.99 EUR + - contentinfo [ref=f12e101]: + - generic [ref=f12e102]: + - generic [ref=f12e103]: + - generic [ref=f12e104]: + - heading "Shop" [level=2] [ref=f12e105] + - list [ref=f12e106]: + - listitem [ref=f12e107]: + - link "About Us" [ref=f12e108] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f12e109]: + - link "FAQ" [ref=f12e110] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f12e111]: + - link "Shipping & Returns" [ref=f12e112] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f12e113]: + - link "Privacy Policy" [ref=f12e114] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f12e115]: + - link "Terms of Service" [ref=f12e116] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f12e117]: + - heading "Acme Fashion" [level=2] [ref=f12e118] + - paragraph [ref=f12e119]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f12e120]: + - paragraph [ref=f12e121]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f12e122]: + - generic [ref=f12e123]: VISA + - generic [ref=f12e124]: MASTERCARD + - generic [ref=f12e125]: AMEX + - generic [ref=f12e126]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-36-53-403Z.yml b/.playwright-mcp/page-2026-07-26T08-36-53-403Z.yml new file mode 100644 index 00000000..abc1d280 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-36-53-403Z.yml @@ -0,0 +1,104 @@ +- generic [active] [ref=f13e1]: + - link "Skip to main content" [ref=f13e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f13e4]: + - paragraph [ref=f13e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f13e6] + - banner [ref=f13e9]: + - generic [ref=f13e10]: + - link "Acme Fashion" [ref=f13e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f13e13]: + - link "Home" [ref=f13e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f13e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f13e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f13e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f13e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f13e19]: + - button "Search" [ref=f13e20] + - link "Account" [ref=f13e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f13e26] + - main [ref=f13e29]: + - generic [ref=f13e30]: + - heading "Checkout" [level=1] [ref=f13e31] + - generic [ref=f13e32]: + - generic [ref=f13e33]: + - region [ref=f13e34]: + - heading "1. Contact & shipping address jane@example.com" [level=2] [ref=f13e35]: + - generic [ref=f13e36]: 1. Contact & shipping address + - generic [ref=f13e37]: jane@example.com + - generic [ref=f13e38]: Jane Doe, 123 Main St, 10115 Berlin, DE + - region [ref=f13e39]: + - heading "2. Shipping method" [level=2] [ref=f13e40] + - group "Available shipping methods" [ref=f13e41]: + - button "Standard Shipping 4.99 EUR" [ref=f13e43]: + - generic [ref=f13e44]: Standard Shipping + - generic [ref=f13e45]: 4.99 EUR + - button "Express Shipping 9.99 EUR" [ref=f13e46]: + - generic [ref=f13e47]: Express Shipping + - generic [ref=f13e48]: 9.99 EUR + - region [ref=f13e49]: + - heading "3. Payment" [level=2] [ref=f13e50] + - complementary "Order summary" [ref=f13e51]: + - generic [ref=f13e52]: + - heading "Order Summary" [level=2] [ref=f13e53] + - list [ref=f13e54]: + - listitem [ref=f13e55]: + - generic [ref=f13e59]: + - paragraph [ref=f13e60]: Premium Slim Fit Jeans ×1 + - paragraph [ref=f13e61]: 28 / Blue + - paragraph [ref=f13e62]: 79.99 EUR + - generic [ref=f13e64]: + - generic [ref=f13e65]: Discount code + - textbox "Discount code" [ref=f13e66] + - button "Apply" [ref=f13e67] + - generic [ref=f13e68]: + - generic [ref=f13e69]: + - term [ref=f13e70]: Subtotal + - definition [ref=f13e71]: 79.99 EUR + - generic [ref=f13e72]: + - term [ref=f13e73]: Shipping + - definition [ref=f13e74]: 0.00 EUR + - generic [ref=f13e75]: + - term [ref=f13e76]: Tax + - definition [ref=f13e77]: 12.78 EUR + - generic [ref=f13e78]: + - term [ref=f13e79]: Total + - definition [ref=f13e80]: 79.99 EUR + - contentinfo [ref=f13e81]: + - generic [ref=f13e82]: + - generic [ref=f13e83]: + - generic [ref=f13e84]: + - heading "Shop" [level=2] [ref=f13e85] + - list [ref=f13e86]: + - listitem [ref=f13e87]: + - link "About Us" [ref=f13e88] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f13e89]: + - link "FAQ" [ref=f13e90] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f13e91]: + - link "Shipping & Returns" [ref=f13e92] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f13e93]: + - link "Privacy Policy" [ref=f13e94] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f13e95]: + - link "Terms of Service" [ref=f13e96] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f13e97]: + - heading "Acme Fashion" [level=2] [ref=f13e98] + - paragraph [ref=f13e99]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f13e100]: + - paragraph [ref=f13e101]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f13e102]: + - generic [ref=f13e103]: VISA + - generic [ref=f13e104]: MASTERCARD + - generic [ref=f13e105]: AMEX + - generic [ref=f13e106]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-37-04-780Z.yml b/.playwright-mcp/page-2026-07-26T08-37-04-780Z.yml new file mode 100644 index 00000000..3b8a9264 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-37-04-780Z.yml @@ -0,0 +1,110 @@ +- generic [active] [ref=f13e1]: + - link "Skip to main content" [ref=f13e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f13e4]: + - paragraph [ref=f13e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f13e6] + - banner [ref=f13e9]: + - generic [ref=f13e10]: + - link "Acme Fashion" [ref=f13e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f13e13]: + - link "Home" [ref=f13e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f13e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f13e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f13e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f13e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f13e19]: + - button "Search" [ref=f13e20] + - link "Account" [ref=f13e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f13e26] + - main [ref=f13e29]: + - generic [ref=f13e30]: + - heading "Checkout" [level=1] [ref=f13e31] + - generic [ref=f13e32]: + - generic [ref=f13e33]: + - region [ref=f13e34]: + - heading "1. Contact & shipping address jane@example.com" [level=2] [ref=f13e35]: + - generic [ref=f13e36]: 1. Contact & shipping address + - generic [ref=f13e37]: jane@example.com + - generic [ref=f13e38]: Jane Doe, 123 Main St, 10115 Berlin, DE + - region [ref=f13e39]: + - heading "2. Shipping method" [level=2] [ref=f13e40] + - generic [ref=f13e107]: Shipping method selected + - region [ref=f13e49]: + - heading "3. Payment" [level=2] [ref=f13e50] + - generic [ref=f13e108]: + - group "Select a payment method" [ref=f13e109]: + - generic [ref=f13e111] [cursor=pointer]: + - radio "Credit Card" [checked] [ref=f13e112] + - generic [ref=f13e113]: Credit Card + - generic [ref=f13e114] [cursor=pointer]: + - radio "PayPal" [ref=f13e115] + - generic [ref=f13e116]: PayPal + - generic [ref=f13e117] [cursor=pointer]: + - radio "Bank Transfer" [ref=f13e118] + - generic [ref=f13e119]: Bank Transfer + - button "Continue" [ref=f13e120] + - complementary "Order summary" [ref=f13e51]: + - generic [ref=f13e52]: + - heading "Order Summary" [level=2] [ref=f13e53] + - list [ref=f13e54]: + - listitem [ref=f13e55]: + - generic [ref=f13e59]: + - paragraph [ref=f13e60]: Premium Slim Fit Jeans ×1 + - paragraph [ref=f13e61]: 28 / Blue + - paragraph [ref=f13e62]: 79.99 EUR + - generic [ref=f13e64]: + - generic [ref=f13e65]: Discount code + - textbox "Discount code" [ref=f13e66] + - button "Apply" [ref=f13e67] + - generic [ref=f13e68]: + - generic [ref=f13e69]: + - term [ref=f13e70]: Subtotal + - definition [ref=f13e71]: 79.99 EUR + - generic [ref=f13e72]: + - term [ref=f13e73]: Shipping + - definition [ref=f13e74]: 4.99 EUR + - generic [ref=f13e75]: + - term [ref=f13e76]: Tax + - definition [ref=f13e77]: 13.58 EUR + - generic [ref=f13e78]: + - term [ref=f13e79]: Total + - definition [ref=f13e80]: 84.98 EUR + - contentinfo [ref=f13e81]: + - generic [ref=f13e82]: + - generic [ref=f13e83]: + - generic [ref=f13e84]: + - heading "Shop" [level=2] [ref=f13e85] + - list [ref=f13e86]: + - listitem [ref=f13e87]: + - link "About Us" [ref=f13e88] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f13e89]: + - link "FAQ" [ref=f13e90] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f13e91]: + - link "Shipping & Returns" [ref=f13e92] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f13e93]: + - link "Privacy Policy" [ref=f13e94] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f13e95]: + - link "Terms of Service" [ref=f13e96] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f13e97]: + - heading "Acme Fashion" [level=2] [ref=f13e98] + - paragraph [ref=f13e99]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f13e100]: + - paragraph [ref=f13e101]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f13e102]: + - generic [ref=f13e103]: VISA + - generic [ref=f13e104]: MASTERCARD + - generic [ref=f13e105]: AMEX + - generic [ref=f13e106]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-37-35-999Z.yml b/.playwright-mcp/page-2026-07-26T08-37-35-999Z.yml new file mode 100644 index 00000000..8057a869 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-37-35-999Z.yml @@ -0,0 +1,110 @@ +- generic [ref=f13e1]: + - link "Skip to main content" [ref=f13e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f13e4]: + - paragraph [ref=f13e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f13e6] + - banner [ref=f13e9]: + - generic [ref=f13e10]: + - link "Acme Fashion" [ref=f13e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f13e13]: + - link "Home" [ref=f13e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f13e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f13e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f13e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f13e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f13e19]: + - button "Search" [ref=f13e20] + - link "Account" [ref=f13e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f13e26] + - main [ref=f13e29]: + - generic [ref=f13e30]: + - heading "Checkout" [level=1] [ref=f13e31] + - generic [ref=f13e32]: + - generic [ref=f13e33]: + - region [ref=f13e34]: + - heading "1. Contact & shipping address jane@example.com" [level=2] [ref=f13e35]: + - generic [ref=f13e36]: 1. Contact & shipping address + - generic [ref=f13e37]: jane@example.com + - generic [ref=f13e38]: Jane Doe, 123 Main St, 10115 Berlin, DE + - region [ref=f13e39]: + - heading "2. Shipping method" [level=2] [ref=f13e40] + - generic [ref=f13e107]: Shipping method selected + - region [ref=f13e49]: + - heading "3. Payment" [level=2] [ref=f13e50] + - generic [ref=f13e108]: + - group "Select a payment method" [ref=f13e109]: + - generic [ref=f13e111] [cursor=pointer]: + - radio "Credit Card" [ref=f13e112] + - generic [ref=f13e113]: Credit Card + - generic [ref=f13e114] [cursor=pointer]: + - radio "PayPal" [ref=f13e115] + - generic [ref=f13e116]: PayPal + - generic [ref=f13e117] [cursor=pointer]: + - radio "Bank Transfer" [checked] [active] [ref=f13e118] + - generic [ref=f13e119]: Bank Transfer + - button "Continue" [ref=f13e120] + - complementary "Order summary" [ref=f13e51]: + - generic [ref=f13e52]: + - heading "Order Summary" [level=2] [ref=f13e53] + - list [ref=f13e54]: + - listitem [ref=f13e55]: + - generic [ref=f13e59]: + - paragraph [ref=f13e60]: Premium Slim Fit Jeans ×1 + - paragraph [ref=f13e61]: 28 / Blue + - paragraph [ref=f13e62]: 79.99 EUR + - generic [ref=f13e64]: + - generic [ref=f13e65]: Discount code + - textbox "Discount code" [ref=f13e66] + - button "Apply" [ref=f13e67] + - generic [ref=f13e68]: + - generic [ref=f13e69]: + - term [ref=f13e70]: Subtotal + - definition [ref=f13e71]: 79.99 EUR + - generic [ref=f13e72]: + - term [ref=f13e73]: Shipping + - definition [ref=f13e74]: 4.99 EUR + - generic [ref=f13e75]: + - term [ref=f13e76]: Tax + - definition [ref=f13e77]: 13.58 EUR + - generic [ref=f13e78]: + - term [ref=f13e79]: Total + - definition [ref=f13e80]: 84.98 EUR + - contentinfo [ref=f13e81]: + - generic [ref=f13e82]: + - generic [ref=f13e83]: + - generic [ref=f13e84]: + - heading "Shop" [level=2] [ref=f13e85] + - list [ref=f13e86]: + - listitem [ref=f13e87]: + - link "About Us" [ref=f13e88] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f13e89]: + - link "FAQ" [ref=f13e90] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f13e91]: + - link "Shipping & Returns" [ref=f13e92] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f13e93]: + - link "Privacy Policy" [ref=f13e94] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f13e95]: + - link "Terms of Service" [ref=f13e96] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f13e97]: + - heading "Acme Fashion" [level=2] [ref=f13e98] + - paragraph [ref=f13e99]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f13e100]: + - paragraph [ref=f13e101]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f13e102]: + - generic [ref=f13e103]: VISA + - generic [ref=f13e104]: MASTERCARD + - generic [ref=f13e105]: AMEX + - generic [ref=f13e106]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-38-21-052Z.yml b/.playwright-mcp/page-2026-07-26T08-38-21-052Z.yml new file mode 100644 index 00000000..1e920c7a --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-38-21-052Z.yml @@ -0,0 +1,112 @@ +- generic [active] [ref=f13e1]: + - link "Skip to main content" [ref=f13e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f13e4]: + - paragraph [ref=f13e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f13e6] + - banner [ref=f13e9]: + - generic [ref=f13e10]: + - link "Acme Fashion" [ref=f13e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f13e13]: + - link "Home" [ref=f13e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f13e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f13e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f13e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f13e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f13e19]: + - button "Search" [ref=f13e20] + - link "Account" [ref=f13e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f13e26] + - main [ref=f13e29]: + - generic [ref=f13e30]: + - heading "Checkout" [level=1] [ref=f13e31] + - generic [ref=f13e32]: + - generic [ref=f13e33]: + - region [ref=f13e34]: + - heading "1. Contact & shipping address jane@example.com" [level=2] [ref=f13e35]: + - generic [ref=f13e36]: 1. Contact & shipping address + - generic [ref=f13e37]: jane@example.com + - generic [ref=f13e38]: Jane Doe, 123 Main St, 10115 Berlin, DE + - region [ref=f13e39]: + - heading "2. Shipping method" [level=2] [ref=f13e40] + - generic [ref=f13e107]: Shipping method selected + - region [ref=f13e49]: + - heading "3. Payment" [level=2] [ref=f13e50] + - generic [ref=f13e108]: + - group "Select a payment method" [ref=f13e109]: + - generic [ref=f13e111] [cursor=pointer]: + - radio "Credit Card" [disabled] [ref=f13e112] + - generic [ref=f13e113]: Credit Card + - generic [ref=f13e114] [cursor=pointer]: + - radio "PayPal" [disabled] [ref=f13e115] + - generic [ref=f13e116]: PayPal + - generic [ref=f13e117] [cursor=pointer]: + - radio "Bank Transfer" [checked] [disabled] [ref=f13e118] + - generic [ref=f13e119]: Bank Transfer + - generic [ref=f13e121]: + - paragraph [ref=f13e122]: After placing your order, you will receive bank transfer instructions. Your order will be held while we await your payment. + - button "Place order - 84.98 EUR" [ref=f13e123] + - complementary "Order summary" [ref=f13e51]: + - generic [ref=f13e52]: + - heading "Order Summary" [level=2] [ref=f13e53] + - list [ref=f13e54]: + - listitem [ref=f13e55]: + - generic [ref=f13e59]: + - paragraph [ref=f13e60]: Premium Slim Fit Jeans ×1 + - paragraph [ref=f13e61]: 28 / Blue + - paragraph [ref=f13e62]: 79.99 EUR + - generic [ref=f13e64]: + - generic [ref=f13e65]: Discount code + - textbox "Discount code" [ref=f13e66] + - button "Apply" [ref=f13e67] + - generic [ref=f13e68]: + - generic [ref=f13e69]: + - term [ref=f13e70]: Subtotal + - definition [ref=f13e71]: 79.99 EUR + - generic [ref=f13e72]: + - term [ref=f13e73]: Shipping + - definition [ref=f13e74]: 4.99 EUR + - generic [ref=f13e75]: + - term [ref=f13e76]: Tax + - definition [ref=f13e77]: 13.58 EUR + - generic [ref=f13e78]: + - term [ref=f13e79]: Total + - definition [ref=f13e80]: 84.98 EUR + - contentinfo [ref=f13e81]: + - generic [ref=f13e82]: + - generic [ref=f13e83]: + - generic [ref=f13e84]: + - heading "Shop" [level=2] [ref=f13e85] + - list [ref=f13e86]: + - listitem [ref=f13e87]: + - link "About Us" [ref=f13e88] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f13e89]: + - link "FAQ" [ref=f13e90] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f13e91]: + - link "Shipping & Returns" [ref=f13e92] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f13e93]: + - link "Privacy Policy" [ref=f13e94] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f13e95]: + - link "Terms of Service" [ref=f13e96] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f13e97]: + - heading "Acme Fashion" [level=2] [ref=f13e98] + - paragraph [ref=f13e99]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f13e100]: + - paragraph [ref=f13e101]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f13e102]: + - generic [ref=f13e103]: VISA + - generic [ref=f13e104]: MASTERCARD + - generic [ref=f13e105]: AMEX + - generic [ref=f13e106]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-38-44-051Z.yml b/.playwright-mcp/page-2026-07-26T08-38-44-051Z.yml new file mode 100644 index 00000000..7f0c1e9d --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-38-44-051Z.yml @@ -0,0 +1,274 @@ +- generic [active] [ref=f13e1]: + - dialog [ref=f13e125]: + - iframe [ref=f13e126]: + - generic [ref=f14e2]: + - generic [ref=f14e4]: + - generic [ref=f14e5]: Internal Server Error + - button "Copy as Markdown" [ref=f14e11] [cursor=pointer] + - generic [ref=f14e18]: + - generic [ref=f14e19]: + - heading "Livewire\\Exceptions\\MissingRulesException" [level=1] [ref=f14e20] + - generic [ref=f14e21]: vendor/livewire/livewire/src/Features/SupportValidation/HandlesValidation.php:493 + - paragraph [ref=f14e23]: "Missing [$rules/rules()] property/method on: [App\\Livewire\\Storefront\\Checkout\\Show]." + - generic [ref=f14e24]: + - generic [ref=f14e25]: + - generic [ref=f14e26]: + - generic [ref=f14e27]: LARAVEL + - generic [ref=f14e28]: 12.51.0 + - generic [ref=f14e29]: + - generic [ref=f14e30]: PHP + - generic [ref=f14e31]: 8.4.17 + - generic [ref=f14e32]: UNHANDLED + - generic [ref=f14e36]: CODE 0 + - generic [ref=f14e38]: + - generic [ref=f14e39]: "500" + - generic [ref=f14e43]: POST + - generic [ref=f14e47]: http://acme-fashion.test/livewire-0972654c/update + - button [ref=f14e48] [cursor=pointer] + - generic [ref=f14e53]: + - generic [ref=f14e54]: + - heading "Exception trace" [level=3] [ref=f14e60] + - generic [ref=f14e61]: + - generic [ref=f14e63] [cursor=pointer]: + - generic [ref=f14e68]: 2 vendor frames + - button [ref=f14e69] + - generic [ref=f14e74]: + - generic [ref=f14e75] [cursor=pointer]: + - generic [ref=f14e78]: + - code [ref=f14e82]: + - generic [ref=f14e83]: app/Livewire/Storefront/Checkout/Show.php + - generic [ref=f14e84]: app/Livewire/Storefront/Checkout/Show.php:246 + - button [ref=f14e87] + - code [ref=f14e96]: + - generic [ref=f14e97]: 241 'cardCvc' => ['required', 'string', 'max:4'], + - generic [ref=f14e98]: 242 'cardHolder' => ['required', 'string', 'max:255'], + - generic [ref=f14e99]: 243 ]; + - generic [ref=f14e100]: "244 }" + - generic [ref=f14e101]: "245" + - generic [ref=f14e102]: 246 $this->validate($rules); + - generic [ref=f14e103]: "247" + - generic [ref=f14e104]: "248 try {" + - generic [ref=f14e105]: 249 app(CheckoutService::class)->completeCheckout($checkout, [ + - generic [ref=f14e106]: 250 'payment_method' => $method, + - generic [ref=f14e107]: 251 'card_number' => $this->cardNumber, + - generic [ref=f14e108]: 252 'card_expiry' => $this->cardExpiry, + - generic [ref=f14e109]: 253 'card_cvc' => $this->cardCvc, + - generic [ref=f14e110]: 254 'card_holder' => $this->cardHolder, + - generic [ref=f14e111]: 255 ]); + - generic [ref=f14e112]: "256 } catch (PaymentFailedException $exception) {" + - generic [ref=f14e113]: "257 $this->paymentError = 'Payment declined: '.$exception->getMessage();" + - generic [ref=f14e114]: "258" + - generic [ref=f14e116] [cursor=pointer]: + - generic [ref=f14e121]: 58 vendor frames + - button [ref=f14e122] + - generic [ref=f14e128] [cursor=pointer]: + - generic [ref=f14e131]: + - code [ref=f14e135]: + - generic [ref=f14e136]: public/index.php + - generic [ref=f14e137]: public/index.php:20 + - button [ref=f14e140] + - generic [ref=f14e146] [cursor=pointer]: + - generic [ref=f14e151]: 1 vendor frame + - button [ref=f14e152] + - generic [ref=f14e157]: + - generic [ref=f14e158]: + - heading "Queries" [level=3] [ref=f14e163] + - generic [ref=f14e164]: 1-2 of 2 + - generic [ref=f14e166]: + - generic [ref=f14e167]: + - generic [ref=f14e168]: + - generic [ref=f14e169]: sqlite + - code [ref=f14e176]: + - generic [ref=f14e177]: select * from "stores" where "stores"."id" = 1 limit 1 + - generic [ref=f14e178]: 1.55ms + - generic [ref=f14e179]: + - generic [ref=f14e180]: + - generic [ref=f14e181]: sqlite + - code [ref=f14e188]: + - generic [ref=f14e189]: select * from "checkouts" where "checkouts"."id" = 2 and "checkouts"."store_id" = 1 limit 1 + - generic [ref=f14e190]: 0.05ms + - generic [ref=f14e192]: + - generic [ref=f14e193]: + - heading "Headers" [level=2] [ref=f14e194] + - generic [ref=f14e195]: + - generic [ref=f14e196]: + - generic [ref=f14e197]: cookie + - generic [ref=f14e199]: XSRF-TOKEN=eyJpdiI6IkNTMElkQWNiaks3ZHFROUlwRkVPbmc9PSIsInZhbHVlIjoia0t2bEpMYVRjZjNYc2xFb3FBWnBwOVhSMThvY0diQ1BQY1RxYjVKTG1CRDRHQ0l2b0F5dlcwMHlDVnkrZHVGaTNlR25nMkhJbFRWZVd4UHQ2cUQ0ZHVYWTFjenpib1dsM2FMcGk1dnViWlNKMzFkTk1FNjlJTy9ITjZRaE43MHMiLCJtYWMiOiI0NDVmYzdlNGZjZmFjOTIwMjMyZDIxY2NiNTI4YWU2YzZhNWI2ZWNmNzNiNGQxYTgzNzBmNmJkYzRlZjg3MWJiIiwidGFnIjoiIn0%3D; shop_session=eyJpdiI6Im1qZTFUN1ZyQzdrUElrVkRGYkw3M2c9PSIsInZhbHVlIjoia2k5UnFiRzhxWkZ5NXd0Z3NoVmFzNVZ0YUsrK3cxQjVmL0V5L0VRMko1dWNnVXV0aEtQcjRhelJoeWQ4R3pTVG14VDF5SkQ4MDBsUTlwZHB3aHIycmxZT1VndDFtK3l1NnZETGlDOTFxMDZxZDlLaDFRSUNTaFltTlFKajFjenAiLCJtYWMiOiIzZGEyOWM1MDYyNDY4ODU5YmNlYmI1ODJiZmIxMmM5YzhmYmE4OThkN2I4ZGRmODBlYjRmZjEyMTc3MmJiZTA3IiwidGFnIjoiIn0%3D + - generic [ref=f14e200]: + - generic [ref=f14e201]: accept-language + - generic [ref=f14e203]: en-GB,en-US;q=0.9,en;q=0.8 + - generic [ref=f14e204]: + - generic [ref=f14e205]: accept-encoding + - generic [ref=f14e207]: gzip, deflate + - generic [ref=f14e208]: + - generic [ref=f14e209]: referer + - generic [ref=f14e211]: http://acme-fashion.test/checkout/2 + - generic [ref=f14e212]: + - generic [ref=f14e213]: origin + - generic [ref=f14e215]: http://acme-fashion.test + - generic [ref=f14e216]: + - generic [ref=f14e217]: accept + - generic [ref=f14e219]: "*/*" + - generic [ref=f14e220]: + - generic [ref=f14e221]: x-livewire + - generic [ref=f14e223]: "1" + - generic [ref=f14e224]: + - generic [ref=f14e225]: content-type + - generic [ref=f14e227]: application/json + - generic [ref=f14e228]: + - generic [ref=f14e229]: user-agent + - generic [ref=f14e231]: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36 + - generic [ref=f14e232]: + - generic [ref=f14e233]: content-length + - generic [ref=f14e235]: "1085" + - generic [ref=f14e236]: + - generic [ref=f14e237]: connection + - generic [ref=f14e239]: keep-alive + - generic [ref=f14e240]: + - generic [ref=f14e241]: host + - generic [ref=f14e243]: acme-fashion.test + - generic [ref=f14e244]: + - heading "Body" [level=2] [ref=f14e245] + - code [ref=f14e250]: + - generic [ref=f14e251]: "{" + - generic [ref=f14e252]: "\"_token\": \"J5t0ymZWZtEVqNMQv9kh8UuSJPnsTQyuighCl8F6\"," + - generic [ref=f14e253]: "\"components\": [" + - generic [ref=f14e254]: "{" + - generic [ref=f14e255]: "\"snapshot\": \"{\"data\":{\"checkoutDbId\":2,\"expired\":false,\"step\":3,\"email\":\"jane@example.com\",\"address\":[{\"first_name\":\"Jane\",\"last_name\":\"Doe\",\"company\":\"\",\"address1\":\"123 Main St\",\"address2\":\"\",\"city\":\"Berlin\",\"province\":\"\",\"province_code\":\"\",\"country\":\"DE\",\"country_code\":\"DE\",\"postal_code\":\"10115\",\"phone\":\"\"},{\"s\":\"arr\"}],\"useShippingAsBilling\":true,\"paymentMethod\":\"bank_transfer\",\"paymentSelected\":true,\"cardNumber\":\"\",\"cardExpiry\":\"\",\"cardCvc\":\"\",\"cardHolder\":\"\",\"paymentError\":null,\"discountCode\":\"\",\"discountError\":null},\"memo\":{\"id\":\"g3TYRglm2H0cHit3iG8v\",\"name\":\"storefront.checkout.show\",\"path\":\"checkout/2\",\"method\":\"GET\",\"release\":\"a-a-a\",\"children\":[],\"scripts\":[],\"assets\":[],\"errors\":[],\"locale\":\"en\",\"islands\":[]},\"checksum\":\"485f1b64ea47f9f94a89f902fd51d6eae5fdf36fe2495ec8a6143aa46a82bffb\"}\"," + - generic [ref=f14e256]: "\"updates\": []," + - generic [ref=f14e257]: "\"calls\": [" + - generic [ref=f14e258]: "{" + - generic [ref=f14e259]: "\"method\": \"pay\"," + - generic [ref=f14e260]: "\"params\": []," + - generic [ref=f14e261]: "\"metadata\": []" + - generic [ref=f14e262]: "}" + - generic [ref=f14e263]: "]" + - generic [ref=f14e264]: "}" + - generic [ref=f14e265]: "]" + - generic [ref=f14e266]: "}" + - generic [ref=f14e267]: + - heading "Routing" [level=2] [ref=f14e268] + - generic [ref=f14e269]: + - generic [ref=f14e270]: + - generic [ref=f14e271]: controller + - generic [ref=f14e273]: Livewire\Mechanisms\HandleRequests\HandleRequests@handleUpdate + - generic [ref=f14e274]: + - generic [ref=f14e275]: route name + - generic [ref=f14e277]: default-livewire.update + - generic [ref=f14e278]: + - generic [ref=f14e279]: middleware + - generic [ref=f14e281]: web + - generic [ref=f14e282]: + - heading "Routing parameters" [level=2] [ref=f14e283] + - generic [ref=f14e284]: // No routing parameters + - link "Skip to main content" [ref=f13e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f13e4]: + - paragraph [ref=f13e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f13e6] + - banner [ref=f13e9]: + - generic [ref=f13e10]: + - link "Acme Fashion" [ref=f13e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f13e13]: + - link "Home" [ref=f13e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f13e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f13e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f13e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f13e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f13e19]: + - button "Search" [ref=f13e20] + - link "Account" [ref=f13e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f13e26] + - main [ref=f13e29]: + - generic [ref=f13e30]: + - heading "Checkout" [level=1] [ref=f13e31] + - generic [ref=f13e32]: + - generic [ref=f13e33]: + - region [ref=f13e34]: + - heading "1. Contact & shipping address jane@example.com" [level=2] [ref=f13e35]: + - generic [ref=f13e36]: 1. Contact & shipping address + - generic [ref=f13e37]: jane@example.com + - generic [ref=f13e38]: Jane Doe, 123 Main St, 10115 Berlin, DE + - region [ref=f13e39]: + - heading "2. Shipping method" [level=2] [ref=f13e40] + - generic [ref=f13e107]: Shipping method selected + - region [ref=f13e49]: + - heading "3. Payment" [level=2] [ref=f13e50] + - generic [ref=f13e108]: + - group "Select a payment method" [ref=f13e109]: + - generic [ref=f13e111] [cursor=pointer]: + - radio "Credit Card" [disabled] [ref=f13e112] + - generic [ref=f13e113]: Credit Card + - generic [ref=f13e114] [cursor=pointer]: + - radio "PayPal" [disabled] [ref=f13e115] + - generic [ref=f13e116]: PayPal + - generic [ref=f13e117] [cursor=pointer]: + - radio "Bank Transfer" [checked] [disabled] [ref=f13e118] + - generic [ref=f13e119]: Bank Transfer + - generic [ref=f13e121]: + - paragraph [ref=f13e122]: After placing your order, you will receive bank transfer instructions. Your order will be held while we await your payment. + - button "Place order - 84.98 EUR" [ref=f13e123] + - complementary "Order summary" [ref=f13e51]: + - generic [ref=f13e52]: + - heading "Order Summary" [level=2] [ref=f13e53] + - list [ref=f13e54]: + - listitem [ref=f13e55]: + - generic [ref=f13e59]: + - paragraph [ref=f13e60]: Premium Slim Fit Jeans ×1 + - paragraph [ref=f13e61]: 28 / Blue + - paragraph [ref=f13e62]: 79.99 EUR + - generic [ref=f13e64]: + - generic [ref=f13e65]: Discount code + - textbox "Discount code" [ref=f13e66] + - button "Apply" [ref=f13e67] + - generic [ref=f13e68]: + - generic [ref=f13e69]: + - term [ref=f13e70]: Subtotal + - definition [ref=f13e71]: 79.99 EUR + - generic [ref=f13e72]: + - term [ref=f13e73]: Shipping + - definition [ref=f13e74]: 4.99 EUR + - generic [ref=f13e75]: + - term [ref=f13e76]: Tax + - definition [ref=f13e77]: 13.58 EUR + - generic [ref=f13e78]: + - term [ref=f13e79]: Total + - definition [ref=f13e80]: 84.98 EUR + - contentinfo [ref=f13e81]: + - generic [ref=f13e82]: + - generic [ref=f13e83]: + - generic [ref=f13e84]: + - heading "Shop" [level=2] [ref=f13e85] + - list [ref=f13e86]: + - listitem [ref=f13e87]: + - link "About Us" [ref=f13e88] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f13e89]: + - link "FAQ" [ref=f13e90] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f13e91]: + - link "Shipping & Returns" [ref=f13e92] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f13e93]: + - link "Privacy Policy" [ref=f13e94] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f13e95]: + - link "Terms of Service" [ref=f13e96] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f13e97]: + - heading "Acme Fashion" [level=2] [ref=f13e98] + - paragraph [ref=f13e99]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f13e100]: + - paragraph [ref=f13e101]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f13e102]: + - generic [ref=f13e103]: VISA + - generic [ref=f13e104]: MASTERCARD + - generic [ref=f13e105]: AMEX + - generic [ref=f13e106]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-40-55-311Z.yml b/.playwright-mcp/page-2026-07-26T08-40-55-311Z.yml new file mode 100644 index 00000000..4bef7135 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-40-55-311Z.yml @@ -0,0 +1,112 @@ +- generic [active] [ref=f15e1]: + - link "Skip to main content" [ref=f15e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f15e4]: + - paragraph [ref=f15e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f15e6] + - banner [ref=f15e9]: + - generic [ref=f15e10]: + - link "Acme Fashion" [ref=f15e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f15e13]: + - link "Home" [ref=f15e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f15e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f15e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f15e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f15e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f15e19]: + - button "Search" [ref=f15e20] + - link "Account" [ref=f15e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f15e26] + - main [ref=f15e29]: + - generic [ref=f15e30]: + - heading "Checkout" [level=1] [ref=f15e31] + - generic [ref=f15e32]: + - generic [ref=f15e33]: + - region [ref=f15e34]: + - heading "1. Contact & shipping address jane@example.com" [level=2] [ref=f15e35]: + - generic [ref=f15e36]: 1. Contact & shipping address + - generic [ref=f15e37]: jane@example.com + - generic [ref=f15e38]: Jane Doe, 123 Main St, 10115 Berlin, DE + - region [ref=f15e39]: + - heading "2. Shipping method" [level=2] [ref=f15e40] + - generic [ref=f15e41]: Shipping method selected + - region [ref=f15e42]: + - heading "3. Payment" [level=2] [ref=f15e43] + - generic [ref=f15e44]: + - group "Select a payment method" [ref=f15e45]: + - generic [ref=f15e47] [cursor=pointer]: + - radio "Credit Card" [disabled] [ref=f15e48] + - generic [ref=f15e49]: Credit Card + - generic [ref=f15e50] [cursor=pointer]: + - radio "PayPal" [disabled] [ref=f15e51] + - generic [ref=f15e52]: PayPal + - generic [ref=f15e53] [cursor=pointer]: + - radio "Bank Transfer" [checked] [disabled] [ref=f15e54] + - generic [ref=f15e55]: Bank Transfer + - generic [ref=f15e56]: + - paragraph [ref=f15e57]: After placing your order, you will receive bank transfer instructions. Your order will be held while we await your payment. + - button "Place order - 84.98 EUR" [ref=f15e58] + - complementary "Order summary" [ref=f15e60]: + - generic [ref=f15e61]: + - heading "Order Summary" [level=2] [ref=f15e62] + - list [ref=f15e63]: + - listitem [ref=f15e64]: + - generic [ref=f15e68]: + - paragraph [ref=f15e69]: Premium Slim Fit Jeans ×1 + - paragraph [ref=f15e70]: 28 / Blue + - paragraph [ref=f15e71]: 79.99 EUR + - generic [ref=f15e73]: + - generic [ref=f15e74]: Discount code + - textbox "Discount code" [ref=f15e75] + - button "Apply" [ref=f15e76] + - generic [ref=f15e77]: + - generic [ref=f15e78]: + - term [ref=f15e79]: Subtotal + - definition [ref=f15e80]: 79.99 EUR + - generic [ref=f15e81]: + - term [ref=f15e82]: Shipping + - definition [ref=f15e83]: 4.99 EUR + - generic [ref=f15e84]: + - term [ref=f15e85]: Tax + - definition [ref=f15e86]: 13.58 EUR + - generic [ref=f15e87]: + - term [ref=f15e88]: Total + - definition [ref=f15e89]: 84.98 EUR + - contentinfo [ref=f15e90]: + - generic [ref=f15e91]: + - generic [ref=f15e92]: + - generic [ref=f15e93]: + - heading "Shop" [level=2] [ref=f15e94] + - list [ref=f15e95]: + - listitem [ref=f15e96]: + - link "About Us" [ref=f15e97] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f15e98]: + - link "FAQ" [ref=f15e99] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f15e100]: + - link "Shipping & Returns" [ref=f15e101] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f15e102]: + - link "Privacy Policy" [ref=f15e103] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f15e104]: + - link "Terms of Service" [ref=f15e105] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f15e106]: + - heading "Acme Fashion" [level=2] [ref=f15e107] + - paragraph [ref=f15e108]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f15e109]: + - paragraph [ref=f15e110]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f15e111]: + - generic [ref=f15e112]: VISA + - generic [ref=f15e113]: MASTERCARD + - generic [ref=f15e114]: AMEX + - generic [ref=f15e115]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-41-19-257Z.yml b/.playwright-mcp/page-2026-07-26T08-41-19-257Z.yml new file mode 100644 index 00000000..06e62d5e --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-41-19-257Z.yml @@ -0,0 +1,117 @@ +- generic [active] [ref=f16e1]: + - link "Skip to main content" [ref=f16e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f16e4]: + - paragraph [ref=f16e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f16e6] + - banner [ref=f16e9]: + - generic [ref=f16e10]: + - link "Acme Fashion" [ref=f16e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f16e13]: + - link "Home" [ref=f16e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f16e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f16e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f16e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f16e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f16e19]: + - button "Search" [ref=f16e20] + - link "Account" [ref=f16e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f16e26] + - main [ref=f16e29]: + - generic [ref=f16e30]: + - generic [ref=f16e31]: + - heading "Thank you for your order!" [level=1] [ref=f16e35] + - paragraph [ref=f16e36]: "Order #1017" + - paragraph [ref=f16e37]: We've sent a confirmation to jane@example.com + - region [ref=f16e38]: + - heading "Order Summary" [level=2] [ref=f16e39] + - list [ref=f16e40]: + - listitem [ref=f16e41]: + - generic [ref=f16e42]: + - paragraph [ref=f16e43]: Premium Slim Fit Jeans - 28 / Blue + - paragraph [ref=f16e44]: "SKU: ACME-PSFJ-28-BLUE" + - paragraph [ref=f16e45]: ×1 + - paragraph [ref=f16e46]: 79.99 EUR + - generic [ref=f16e47]: + - region [ref=f16e48]: + - heading "Shipping Address" [level=2] [ref=f16e49] + - generic [ref=f16e50]: Jane Doe 123 Main St 10115 Berlin DE + - region [ref=f16e51]: + - heading "Payment Method" [level=2] [ref=f16e52] + - paragraph [ref=f16e53]: Bank Transfer + - region [ref=f16e54]: + - heading "Bank Transfer Instructions" [level=2] [ref=f16e55] + - paragraph [ref=f16e58]: "Please transfer the total amount to the following account:" + - generic [ref=f16e59]: + - generic [ref=f16e60]: + - term [ref=f16e61]: Bank + - definition [ref=f16e62]: Mock Bank AG + - generic [ref=f16e63]: + - term [ref=f16e64]: IBAN + - definition [ref=f16e65]: DE89 3704 0044 0532 0130 00 + - generic [ref=f16e66]: + - term [ref=f16e67]: BIC + - definition [ref=f16e68]: COBADEFFXXX + - generic [ref=f16e69]: + - term [ref=f16e70]: Amount + - definition [ref=f16e71]: 84.98 EUR + - generic [ref=f16e72]: + - term [ref=f16e73]: Reference + - definition [ref=f16e74]: "#1017" + - paragraph [ref=f16e75]: Please complete your transfer within 7 days. Your order will be processed once payment is confirmed by our team. + - generic [ref=f16e76]: + - generic [ref=f16e77]: + - term [ref=f16e78]: Subtotal + - definition [ref=f16e79]: 79.99 EUR + - generic [ref=f16e80]: + - term [ref=f16e81]: Shipping + - definition [ref=f16e82]: 4.99 EUR + - generic [ref=f16e83]: + - term [ref=f16e84]: Tax + - definition [ref=f16e85]: 13.58 EUR + - generic [ref=f16e86]: + - term [ref=f16e87]: Total + - definition [ref=f16e88]: 84.98 EUR + - generic [ref=f16e89]: + - link "Continue shopping" [ref=f16e90] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "View order status" [ref=f16e91] [cursor=pointer]: + - /url: /api/storefront/v1/orders/%231017?token=0dd2cf7c54960f0399c37d1a544f26564e28b2d2c75cd13344bd3312541699b0 + - contentinfo [ref=f16e92]: + - generic [ref=f16e93]: + - generic [ref=f16e94]: + - generic [ref=f16e95]: + - heading "Shop" [level=2] [ref=f16e96] + - list [ref=f16e97]: + - listitem [ref=f16e98]: + - link "About Us" [ref=f16e99] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f16e100]: + - link "FAQ" [ref=f16e101] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f16e102]: + - link "Shipping & Returns" [ref=f16e103] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f16e104]: + - link "Privacy Policy" [ref=f16e105] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f16e106]: + - link "Terms of Service" [ref=f16e107] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f16e108]: + - heading "Acme Fashion" [level=2] [ref=f16e109] + - paragraph [ref=f16e110]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f16e111]: + - paragraph [ref=f16e112]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f16e113]: + - generic [ref=f16e114]: VISA + - generic [ref=f16e115]: MASTERCARD + - generic [ref=f16e116]: AMEX + - generic [ref=f16e117]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-43-28-529Z.yml b/.playwright-mcp/page-2026-07-26T08-43-28-529Z.yml new file mode 100644 index 00000000..7336283d --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-43-28-529Z.yml @@ -0,0 +1,22 @@ +- generic [ref=f17e1]: + - link "Skip to main content" [ref=f17e2] [cursor=pointer]: + - /url: "#main-content" + - main [ref=f17e3]: + - generic [ref=f17e5]: + - generic [ref=f17e6]: + - heading "Log in" [level=1] [ref=f17e7] + - paragraph [ref=f17e8]: Sign in to your admin account + - generic [ref=f17e9]: + - generic [ref=f17e10]: + - generic [ref=f17e11]: Email + - textbox "Email" [active] [ref=f17e13] + - generic [ref=f17e14]: + - generic [ref=f17e15]: Password + - textbox "Password" [ref=f17e17] + - generic [ref=f17e18]: + - generic [ref=f17e19]: + - checkbox "Remember me" [ref=f17e20] + - generic [ref=f17e22]: Remember me + - link "Forgot password?" [ref=f17e23] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/forgot-password + - button "Log in" [ref=f17e24] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-44-05-567Z.yml b/.playwright-mcp/page-2026-07-26T08-44-05-567Z.yml new file mode 100644 index 00000000..29ffe93f --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-44-05-567Z.yml @@ -0,0 +1,177 @@ +- generic [active] [ref=f18e1]: + - link "Skip to main content" [ref=f18e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f18e3]: + - complementary "Admin navigation" [ref=f18e4]: + - generic [ref=f18e5]: + - link "Acme Fashion" [ref=f18e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f18e12]: + - navigation [ref=f18e13]: + - link "Dashboard" [ref=f18e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f18e19]: Products + - navigation [ref=f18e20]: + - link "Products" [ref=f18e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f18e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f18e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f18e36]: Orders + - navigation [ref=f18e37]: + - link "Orders" [ref=f18e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f18e43]: Customers + - navigation [ref=f18e44]: + - link "Customers" [ref=f18e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f18e50]: Discounts + - navigation [ref=f18e51]: + - link "Discounts" [ref=f18e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f18e58]: Content + - navigation [ref=f18e59]: + - link "Pages" [ref=f18e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f18e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f18e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f18e75]: + - link "Analytics" [ref=f18e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f18e82]: Settings + - navigation [ref=f18e83]: + - link "Settings" [ref=f18e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f18e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f18e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f18e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f18e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f18e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f18e115]: + - banner [ref=f18e116]: + - button "Acme Fashion" [ref=f18e118] + - button "Notifications" [ref=f18e123] + - button "AU Admin User" [ref=f18e127]: + - generic [ref=f18e128]: AU + - generic [ref=f18e131]: Admin User + - main [ref=f18e135]: + - generic [ref=f18e136]: + - generic [ref=f18e137]: Home + - generic [ref=f18e141]: Dashboard + - generic [ref=f18e143]: + - generic [ref=f18e144]: + - heading "Dashboard" [level=1] [ref=f18e145] + - combobox "Date range" [ref=f18e146]: + - option "Last 7 days" + - option "Last 30 days" [selected] + - option "Last 90 days" + - generic [ref=f18e147]: + - generic [ref=f18e148]: + - paragraph [ref=f18e149]: Total Sales + - generic [ref=f18e150]: 1,629.59 EUR + - generic [ref=f18e151]: + - paragraph [ref=f18e152]: Orders + - generic [ref=f18e153]: "17" + - generic [ref=f18e154]: + - paragraph [ref=f18e155]: Avg. Order Value + - generic [ref=f18e156]: 95.85 EUR + - generic [ref=f18e157]: + - paragraph [ref=f18e158]: Conversion Rate + - generic [ref=f18e159]: 47.2% + - generic [ref=f18e160]: + - heading "Orders over time" [level=2] [ref=f18e161] + - generic [ref=f18e162]: + - img "Daily order counts for the selected period" [ref=f18e163] + - generic [ref=f18e165]: + - generic [ref=f18e166]: 2026-06-27 + - generic [ref=f18e167]: 2026-07-26 + - generic [ref=f18e168]: + - heading "Recent orders" [level=2] [ref=f18e169] + - table [ref=f18e171]: + - rowgroup [ref=f18e172]: + - row [ref=f18e173]: + - columnheader "Order" [ref=f18e174] + - columnheader "Date" [ref=f18e175] + - columnheader "Customer" [ref=f18e176] + - columnheader "Payment" [ref=f18e177] + - columnheader "Fulfillment" [ref=f18e178] + - columnheader "Total" [ref=f18e179] + - rowgroup [ref=f18e180]: + - row [ref=f18e181]: + - cell "#1017" [ref=f18e182] + - cell "Jul 26, 2026" [ref=f18e183] + - cell "Jane Smith" [ref=f18e184] + - cell "Pending" [ref=f18e185] + - cell "Unfulfilled" [ref=f18e187] + - cell "84.98 EUR" [ref=f18e189] + - row [ref=f18e190]: + - cell "#1016" [ref=f18e191] + - cell "Jul 26, 2026" [ref=f18e192] + - cell "Jane Smith" [ref=f18e193] + - cell "Paid" [ref=f18e194] + - cell "Unfulfilled" [ref=f18e196] + - cell "27.49 EUR" [ref=f18e198] + - row [ref=f18e199]: + - cell "#1015" [ref=f18e200] + - cell "Jul 26, 2026" [ref=f18e201] + - cell "John Doe" [ref=f18e202] + - cell "Paid" [ref=f18e203] + - cell "Unfulfilled" [ref=f18e205] + - cell "54.47 EUR" [ref=f18e207] + - row [ref=f18e208]: + - cell "#1005" [ref=f18e209] + - cell "Jul 26, 2026" [ref=f18e210] + - cell "Jane Smith" [ref=f18e211] + - cell "Pending" [ref=f18e212] + - cell "Unfulfilled" [ref=f18e214] + - cell "39.98 EUR" [ref=f18e216] + - row [ref=f18e217]: + - cell "#1013" [ref=f18e218] + - cell "Jul 25, 2026" [ref=f18e219] + - cell "Robert Martinez" [ref=f18e220] + - cell "Paid" [ref=f18e221] + - cell "Unfulfilled" [ref=f18e223] + - cell "84.97 EUR" [ref=f18e225] + - row [ref=f18e226]: + - cell "#1010" [ref=f18e227] + - cell "Jul 25, 2026" [ref=f18e228] + - cell "John Doe" [ref=f18e229] + - cell "Paid" [ref=f18e230] + - cell "Unfulfilled" [ref=f18e232] + - cell "504.98 EUR" [ref=f18e234] + - row [ref=f18e235]: + - cell "#1006" [ref=f18e236] + - cell "Jul 25, 2026" [ref=f18e237] + - cell "Michael Brown" [ref=f18e238] + - cell "Paid" [ref=f18e239] + - cell "Unfulfilled" [ref=f18e241] + - cell "124.98 EUR" [ref=f18e243] + - row [ref=f18e244]: + - cell "#1001" [ref=f18e245] + - cell "Jul 24, 2026" [ref=f18e246] + - cell "John Doe" [ref=f18e247] + - cell "Paid" [ref=f18e248] + - cell "Unfulfilled" [ref=f18e250] + - cell "54.97 EUR" [ref=f18e252] + - row [ref=f18e253]: + - cell "#1009" [ref=f18e254] + - cell "Jul 23, 2026" [ref=f18e255] + - cell "Emma Garcia" [ref=f18e256] + - cell "Paid" [ref=f18e257] + - cell "Unfulfilled" [ref=f18e259] + - cell "49.97 EUR" [ref=f18e261] + - row [ref=f18e262]: + - cell "#1012" [ref=f18e263] + - cell "Jul 22, 2026" [ref=f18e264] + - cell "Lisa Anderson" [ref=f18e265] + - cell "Paid" [ref=f18e266] + - cell "Unfulfilled" [ref=f18e268] + - cell "84.97 EUR" [ref=f18e270] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-44-44-345Z.yml b/.playwright-mcp/page-2026-07-26T08-44-44-345Z.yml new file mode 100644 index 00000000..02000f8d --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-44-44-345Z.yml @@ -0,0 +1,254 @@ +- generic [active] [ref=f19e1]: + - link "Skip to main content" [ref=f19e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f19e3]: + - complementary "Admin navigation" [ref=f19e4]: + - generic [ref=f19e5]: + - link "Acme Fashion" [ref=f19e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f19e12]: + - navigation [ref=f19e13]: + - link "Dashboard" [ref=f19e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f19e19]: Products + - navigation [ref=f19e20]: + - link "Products" [ref=f19e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f19e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f19e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f19e36]: Orders + - navigation [ref=f19e37]: + - link "Orders" [ref=f19e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f19e43]: Customers + - navigation [ref=f19e44]: + - link "Customers" [ref=f19e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f19e50]: Discounts + - navigation [ref=f19e51]: + - link "Discounts" [ref=f19e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f19e58]: Content + - navigation [ref=f19e59]: + - link "Pages" [ref=f19e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f19e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f19e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f19e75]: + - link "Analytics" [ref=f19e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f19e82]: Settings + - navigation [ref=f19e83]: + - link "Settings" [ref=f19e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f19e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f19e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f19e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f19e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f19e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f19e115]: + - banner [ref=f19e116]: + - button "Acme Fashion" [ref=f19e118] + - button "Notifications" [ref=f19e123] + - button "AU Admin User" [ref=f19e127]: + - generic [ref=f19e128]: AU + - generic [ref=f19e131]: Admin User + - main [ref=f19e135]: + - generic [ref=f19e136]: + - link "Home" [ref=f19e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f19e141]: Orders + - generic [ref=f19e143]: + - generic [ref=f19e144]: Orders + - generic [ref=f19e146]: + - textbox "Search orders" [ref=f19e148]: + - /placeholder: "Search by order # or email..." + - combobox "Financial status filter" [ref=f19e150]: + - option "All payments" [selected] + - option "Pending" + - option "Authorized" + - option "Paid" + - option "Partially Refunded" + - option "Refunded" + - option "Voided" + - combobox "Fulfillment status filter" [ref=f19e151]: + - option "All fulfillments" [selected] + - option "Unfulfilled" + - option "Partial" + - option "Fulfilled" + - generic [ref=f19e152]: + - textbox "Placed from" [ref=f19e154] + - paragraph [ref=f19e156]: – + - textbox "Placed to" [ref=f19e158] + - tablist "Status filter" [ref=f19e160]: + - tab "All" [selected] [ref=f19e161] + - tab "Pending" [ref=f19e162] + - tab "Paid" [ref=f19e163] + - tab "Fulfilled" [ref=f19e164] + - tab "Cancelled" [ref=f19e165] + - tab "Refunded" [ref=f19e166] + - table [ref=f19e168]: + - rowgroup [ref=f19e169]: + - row [ref=f19e170]: + - columnheader [ref=f19e171]: + - button "Order" [ref=f19e172] + - columnheader [ref=f19e173]: + - button "Date" [ref=f19e174] + - columnheader "Customer" [ref=f19e177] + - columnheader "Payment" [ref=f19e178] + - columnheader "Fulfillment" [ref=f19e179] + - columnheader [ref=f19e180]: + - button "Total" [ref=f19e181] + - rowgroup [ref=f19e182]: + - row [ref=f19e183]: + - cell [ref=f19e184]: + - link "#1017" [ref=f19e185] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders/20 + - cell "Jul 26, 2026 8:41 AM" [ref=f19e186] + - cell "Jane Smith" [ref=f19e187] + - cell "Pending" [ref=f19e188] + - cell "Unfulfilled" [ref=f19e190] + - cell "84.98 EUR" [ref=f19e192] + - row [ref=f19e193]: + - cell [ref=f19e194]: + - link "#1016" [ref=f19e195] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders/19 + - cell "Jul 26, 2026 8:34 AM" [ref=f19e196] + - cell "Jane Smith" [ref=f19e197] + - cell "Paid" [ref=f19e198] + - cell "Unfulfilled" [ref=f19e200] + - cell "27.49 EUR" [ref=f19e202] + - row [ref=f19e203]: + - cell [ref=f19e204]: + - link "#1015" [ref=f19e205] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders/15 + - cell "Jul 26, 2026 8:14 AM" [ref=f19e206] + - cell "John Doe" [ref=f19e207] + - cell "Paid" [ref=f19e208] + - cell "Unfulfilled" [ref=f19e210] + - cell "54.47 EUR" [ref=f19e212] + - row [ref=f19e213]: + - cell [ref=f19e214]: + - link "#1005" [ref=f19e215] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders/5 + - cell "Jul 26, 2026 6:14 AM" [ref=f19e216] + - cell "Jane Smith" [ref=f19e217] + - cell "Pending" [ref=f19e218] + - cell "Unfulfilled" [ref=f19e220] + - cell "39.98 EUR" [ref=f19e222] + - row [ref=f19e223]: + - cell [ref=f19e224]: + - link "#1013" [ref=f19e225] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders/13 + - cell "Jul 25, 2026 8:14 AM" [ref=f19e226] + - cell "Robert Martinez" [ref=f19e227] + - cell "Paid" [ref=f19e228] + - cell "Unfulfilled" [ref=f19e230] + - cell "84.97 EUR" [ref=f19e232] + - row [ref=f19e233]: + - cell [ref=f19e234]: + - link "#1010" [ref=f19e235] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders/10 + - cell "Jul 25, 2026 8:14 AM" [ref=f19e236] + - cell "John Doe" [ref=f19e237] + - cell "Paid" [ref=f19e238] + - cell "Unfulfilled" [ref=f19e240] + - cell "504.98 EUR" [ref=f19e242] + - row [ref=f19e243]: + - cell [ref=f19e244]: + - link "#1006" [ref=f19e245] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders/6 + - cell "Jul 25, 2026 8:14 AM" [ref=f19e246] + - cell "Michael Brown" [ref=f19e247] + - cell "Paid" [ref=f19e248] + - cell "Unfulfilled" [ref=f19e250] + - cell "124.98 EUR" [ref=f19e252] + - row [ref=f19e253]: + - cell [ref=f19e254]: + - link "#1001" [ref=f19e255] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders/1 + - cell "Jul 24, 2026 8:14 AM" [ref=f19e256] + - cell "John Doe" [ref=f19e257] + - cell "Paid" [ref=f19e258] + - cell "Unfulfilled" [ref=f19e260] + - cell "54.97 EUR" [ref=f19e262] + - row [ref=f19e263]: + - cell [ref=f19e264]: + - link "#1009" [ref=f19e265] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders/9 + - cell "Jul 23, 2026 8:14 AM" [ref=f19e266] + - cell "Emma Garcia" [ref=f19e267] + - cell "Paid" [ref=f19e268] + - cell "Unfulfilled" [ref=f19e270] + - cell "49.97 EUR" [ref=f19e272] + - row [ref=f19e273]: + - cell [ref=f19e274]: + - link "#1012" [ref=f19e275] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders/12 + - cell "Jul 22, 2026 8:14 AM" [ref=f19e276] + - cell "Lisa Anderson" [ref=f19e277] + - cell "Paid" [ref=f19e278] + - cell "Unfulfilled" [ref=f19e280] + - cell "84.97 EUR" [ref=f19e282] + - row [ref=f19e283]: + - cell [ref=f19e284]: + - link "#1003" [ref=f19e285] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders/3 + - cell "Jul 21, 2026 8:14 AM" [ref=f19e286] + - cell "Jane Smith" [ref=f19e287] + - cell "Paid" [ref=f19e288] + - cell "Partial" [ref=f19e290] + - cell "119.97 EUR" [ref=f19e292] + - row [ref=f19e293]: + - cell [ref=f19e294]: + - link "#1002" [ref=f19e295] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders/2 + - cell "Jul 16, 2026 8:14 AM" [ref=f19e296] + - cell "John Doe" [ref=f19e297] + - cell "Paid" [ref=f19e298] + - cell "Fulfilled" [ref=f19e300] + - cell "89.97 EUR" [ref=f19e302] + - row [ref=f19e303]: + - cell [ref=f19e304]: + - link "#1008" [ref=f19e305] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders/8 + - cell "Jul 14, 2026 8:14 AM" [ref=f19e306] + - cell "David Lee" [ref=f19e307] + - cell "Partially Refunded" [ref=f19e308] + - cell "Fulfilled" [ref=f19e310] + - cell "89.97 EUR" [ref=f19e312] + - row [ref=f19e313]: + - cell [ref=f19e314]: + - link "#1014" [ref=f19e315] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders/14 + - cell "Jul 12, 2026 8:14 AM" [ref=f19e316] + - cell "Anna Thomas" [ref=f19e317] + - cell "Paid" [ref=f19e318] + - cell "Fulfilled" [ref=f19e320] + - cell "50.00 EUR" [ref=f19e322] + - row [ref=f19e323]: + - cell [ref=f19e324]: + - link "#1004" [ref=f19e325] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders/4 + - cell "Jul 11, 2026 8:14 AM" [ref=f19e326] + - cell "John Doe" [ref=f19e327] + - cell "Refunded" [ref=f19e328] + - cell "Unfulfilled" [ref=f19e330] + - cell "29.98 EUR" [ref=f19e332] + - navigation "Pagination Navigation" [ref=f19e334]: + - generic [ref=f19e335]: + - paragraph [ref=f19e337]: Showing 1 to 15 of 17 results + - generic [ref=f19e339]: + - generic "« Previous" [ref=f19e341] + - generic [ref=f19e345]: "1" + - button "Go to page 2" [ref=f19e349]: "2" + - button "Next »" [ref=f19e351] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-45-09-834Z.yml b/.playwright-mcp/page-2026-07-26T08-45-09-834Z.yml new file mode 100644 index 00000000..cbfb08c5 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-45-09-834Z.yml @@ -0,0 +1,148 @@ +- generic [active] [ref=f19e354]: + - link "Skip to main content" [ref=f19e355] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f19e356]: + - complementary "Admin navigation" [ref=f19e357]: + - generic [ref=f19e358]: + - link "Acme Fashion" [ref=f19e360] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f19e365]: + - navigation [ref=f19e366]: + - link "Dashboard" [ref=f19e367] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f19e372]: Products + - navigation [ref=f19e373]: + - link "Products" [ref=f19e374] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f19e379] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f19e384] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f19e389]: Orders + - navigation [ref=f19e390]: + - link "Orders" [ref=f19e391] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f19e396]: Customers + - navigation [ref=f19e397]: + - link "Customers" [ref=f19e398] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f19e403]: Discounts + - navigation [ref=f19e404]: + - link "Discounts" [ref=f19e405] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f19e411]: Content + - navigation [ref=f19e412]: + - link "Pages" [ref=f19e413] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f19e418] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f19e423] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f19e428]: + - link "Analytics" [ref=f19e429] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f19e435]: Settings + - navigation [ref=f19e436]: + - link "Settings" [ref=f19e437] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f19e443] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f19e448] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f19e453] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f19e458] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f19e463] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f19e468]: + - banner [ref=f19e469]: + - button "Acme Fashion" [ref=f19e471] + - button "Notifications" [ref=f19e476] + - button "AU Admin User" [ref=f19e480]: + - generic [ref=f19e481]: AU + - generic [ref=f19e484]: Admin User + - main [ref=f19e488]: + - generic [ref=f19e489]: + - link "Home" [ref=f19e491] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - link "Orders" [ref=f19e495] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - generic [ref=f19e498]: "#1017" + - generic [ref=f19e500]: + - generic [ref=f19e501]: + - generic [ref=f19e502]: "#1017" + - generic [ref=f19e503]: Pending + - generic [ref=f19e504]: Unfulfilled + - paragraph [ref=f19e505]: Jul 26, 2026 8:41 AM + - generic [ref=f19e506]: + - button "Confirm payment" [ref=f19e507] + - button "Create fulfillment" [disabled] + - button "Cancel order" [ref=f19e513] + - generic [ref=f19e521]: + - generic [ref=f19e522]: Cannot create fulfillment + - generic [ref=f19e523]: + - text: "Fulfillment cannot be created until payment is confirmed. Current financial status:" + - emphasis [ref=f19e524]: Pending + - text: . + - generic [ref=f19e525]: + - generic [ref=f19e526]: + - generic [ref=f19e527]: + - generic [ref=f19e528]: Timeline + - list [ref=f19e529]: + - listitem [ref=f19e530]: + - paragraph [ref=f19e532]: Order placed + - paragraph [ref=f19e533]: Jul 26, 2026 8:41 AM + - generic [ref=f19e534]: + - generic [ref=f19e535]: Order lines + - table [ref=f19e537]: + - rowgroup [ref=f19e538]: + - row [ref=f19e539]: + - columnheader "Image" [ref=f19e540] + - columnheader "Product" [ref=f19e542] + - columnheader "Qty" [ref=f19e543] + - columnheader "Unit price" [ref=f19e544] + - columnheader "Total" [ref=f19e545] + - rowgroup [ref=f19e546]: + - row [ref=f19e547]: + - cell [ref=f19e548] + - 'cell "Premium Slim Fit Jeans - 28 / Blue SKU: ACME-PSFJ-28-BLUE" [ref=f19e552]': + - generic [ref=f19e553]: Premium Slim Fit Jeans - 28 / Blue + - generic [ref=f19e554]: "SKU: ACME-PSFJ-28-BLUE" + - cell "1" [ref=f19e555] + - cell "79.99 EUR" [ref=f19e556] + - cell "79.99 EUR" [ref=f19e557] + - generic [ref=f19e558]: + - generic [ref=f19e559]: + - generic [ref=f19e560]: Subtotal + - generic [ref=f19e561]: 79.99 EUR + - generic [ref=f19e562]: + - generic [ref=f19e563]: Shipping + - generic [ref=f19e564]: 4.99 EUR + - generic [ref=f19e565]: + - generic [ref=f19e566]: Tax + - generic [ref=f19e567]: 13.58 EUR + - generic [ref=f19e568]: + - generic [ref=f19e569]: Total + - generic [ref=f19e570]: 84.98 EUR + - generic [ref=f19e571]: + - generic [ref=f19e572]: Payment details + - generic [ref=f19e574]: + - generic [ref=f19e575]: + - paragraph [ref=f19e576]: Bank Transfer + - paragraph [ref=f19e577]: "84.98 EUR · Ref: mock_xFqqZ3TJnXOxLjhZ · Jul 26, 2026 8:41 AM" + - generic [ref=f19e578]: Pending + - button "Confirm payment" [ref=f19e579] + - generic [ref=f19e585]: + - generic [ref=f19e586]: + - generic [ref=f19e587]: Customer + - paragraph [ref=f19e588]: Jane Smith + - paragraph [ref=f19e589]: jane@example.com + - link "View customer" [ref=f19e591] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/2 + - generic [ref=f19e592]: + - generic [ref=f19e593]: Shipping address + - generic [ref=f19e594]: Jane Doe 123 Main St Berlin 10115 DE + - generic [ref=f19e595]: + - generic [ref=f19e596]: Billing address + - generic [ref=f19e597]: Jane Doe 123 Main St Berlin 10115 DE \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-45-33-670Z.yml b/.playwright-mcp/page-2026-07-26T08-45-33-670Z.yml new file mode 100644 index 00000000..263b28da --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-45-33-670Z.yml @@ -0,0 +1,147 @@ +- generic [active] [ref=f19e354]: + - link "Skip to main content" [ref=f19e355] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f19e356]: + - complementary "Admin navigation" [ref=f19e357]: + - generic [ref=f19e358]: + - link "Acme Fashion" [ref=f19e360] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f19e365]: + - navigation [ref=f19e366]: + - link "Dashboard" [ref=f19e367] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f19e372]: Products + - navigation [ref=f19e373]: + - link "Products" [ref=f19e374] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f19e379] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f19e384] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f19e389]: Orders + - navigation [ref=f19e390]: + - link "Orders" [ref=f19e391] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f19e396]: Customers + - navigation [ref=f19e397]: + - link "Customers" [ref=f19e398] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f19e403]: Discounts + - navigation [ref=f19e404]: + - link "Discounts" [ref=f19e405] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f19e411]: Content + - navigation [ref=f19e412]: + - link "Pages" [ref=f19e413] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f19e418] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f19e423] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f19e428]: + - link "Analytics" [ref=f19e429] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f19e435]: Settings + - navigation [ref=f19e436]: + - link "Settings" [ref=f19e437] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f19e443] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f19e448] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f19e453] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f19e458] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f19e463] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f19e468]: + - banner [ref=f19e469]: + - button "Acme Fashion" [ref=f19e471] + - button "Notifications" [ref=f19e476] + - button "AU Admin User" [ref=f19e480]: + - generic [ref=f19e481]: AU + - generic [ref=f19e484]: Admin User + - main [ref=f19e488]: + - generic [ref=f19e489]: + - link "Home" [ref=f19e491] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - link "Orders" [ref=f19e495] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - generic [ref=f19e498]: "#1017" + - generic [ref=f19e500]: + - generic [ref=f19e501]: + - generic [ref=f19e502]: "#1017" + - generic [ref=f19e503]: Paid + - generic [ref=f19e504]: Unfulfilled + - paragraph [ref=f19e505]: Jul 26, 2026 8:41 AM + - generic [ref=f19e506]: + - button "Create fulfillment" [ref=f19e598] + - button "Refund" [ref=f19e604] + - button "Cancel order" [ref=f19e513] + - generic [ref=f19e525]: + - generic [ref=f19e526]: + - generic [ref=f19e527]: + - generic [ref=f19e528]: Timeline + - list [ref=f19e529]: + - listitem [ref=f19e530]: + - paragraph [ref=f19e532]: Order placed + - paragraph [ref=f19e533]: Jul 26, 2026 8:41 AM + - listitem [ref=f19e610]: + - paragraph [ref=f19e612]: Payment received + - paragraph [ref=f19e613]: Jul 26, 2026 8:41 AM + - generic [ref=f19e534]: + - generic [ref=f19e535]: Order lines + - table [ref=f19e537]: + - rowgroup [ref=f19e538]: + - row [ref=f19e539]: + - columnheader "Image" [ref=f19e540] + - columnheader "Product" [ref=f19e542] + - columnheader "Qty" [ref=f19e543] + - columnheader "Unit price" [ref=f19e544] + - columnheader "Total" [ref=f19e545] + - rowgroup [ref=f19e546]: + - row [ref=f19e547]: + - cell [ref=f19e548] + - 'cell "Premium Slim Fit Jeans - 28 / Blue SKU: ACME-PSFJ-28-BLUE" [ref=f19e552]': + - generic [ref=f19e553]: Premium Slim Fit Jeans - 28 / Blue + - generic [ref=f19e554]: "SKU: ACME-PSFJ-28-BLUE" + - cell "1" [ref=f19e555] + - cell "79.99 EUR" [ref=f19e556] + - cell "79.99 EUR" [ref=f19e557] + - generic [ref=f19e558]: + - generic [ref=f19e559]: + - generic [ref=f19e560]: Subtotal + - generic [ref=f19e561]: 79.99 EUR + - generic [ref=f19e562]: + - generic [ref=f19e563]: Shipping + - generic [ref=f19e564]: 4.99 EUR + - generic [ref=f19e565]: + - generic [ref=f19e566]: Tax + - generic [ref=f19e567]: 13.58 EUR + - generic [ref=f19e568]: + - generic [ref=f19e569]: Total + - generic [ref=f19e570]: 84.98 EUR + - generic [ref=f19e571]: + - generic [ref=f19e572]: Payment details + - generic [ref=f19e574]: + - generic [ref=f19e575]: + - paragraph [ref=f19e576]: Bank Transfer + - paragraph [ref=f19e577]: "84.98 EUR · Ref: mock_xFqqZ3TJnXOxLjhZ · Jul 26, 2026 8:41 AM" + - generic [ref=f19e578]: Captured + - generic [ref=f19e585]: + - generic [ref=f19e586]: + - generic [ref=f19e587]: Customer + - paragraph [ref=f19e588]: Jane Smith + - paragraph [ref=f19e589]: jane@example.com + - link "View customer" [ref=f19e591] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/2 + - generic [ref=f19e592]: + - generic [ref=f19e593]: Shipping address + - generic [ref=f19e594]: Jane Doe 123 Main St Berlin 10115 DE + - generic [ref=f19e595]: + - generic [ref=f19e596]: Billing address + - generic [ref=f19e597]: Jane Doe 123 Main St Berlin 10115 DE + - alert [ref=f19e614]: + - paragraph [ref=f19e617]: Payment confirmed + - button "Dismiss" [ref=f19e618] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-45-58-795Z.yml b/.playwright-mcp/page-2026-07-26T08-45-58-795Z.yml new file mode 100644 index 00000000..a5b6fcfc --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-45-58-795Z.yml @@ -0,0 +1,165 @@ +- generic [active] [ref=f19e354]: + - link "Skip to main content" [ref=f19e355] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f19e356]: + - complementary "Admin navigation" [ref=f19e357]: + - generic [ref=f19e358]: + - link "Acme Fashion" [ref=f19e360] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f19e365]: + - navigation [ref=f19e366]: + - link "Dashboard" [ref=f19e367] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f19e372]: Products + - navigation [ref=f19e373]: + - link "Products" [ref=f19e374] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f19e379] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f19e384] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f19e389]: Orders + - navigation [ref=f19e390]: + - link "Orders" [ref=f19e391] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f19e396]: Customers + - navigation [ref=f19e397]: + - link "Customers" [ref=f19e398] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f19e403]: Discounts + - navigation [ref=f19e404]: + - link "Discounts" [ref=f19e405] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f19e411]: Content + - navigation [ref=f19e412]: + - link "Pages" [ref=f19e413] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f19e418] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f19e423] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f19e428]: + - link "Analytics" [ref=f19e429] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f19e435]: Settings + - navigation [ref=f19e436]: + - link "Settings" [ref=f19e437] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f19e443] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f19e448] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f19e453] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f19e458] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f19e463] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f19e468]: + - banner [ref=f19e469]: + - button "Acme Fashion" [ref=f19e471] + - button "Notifications" [ref=f19e476] + - button "AU Admin User" [ref=f19e480]: + - generic [ref=f19e481]: AU + - generic [ref=f19e484]: Admin User + - main [ref=f19e488]: + - generic [ref=f19e489]: + - link "Home" [ref=f19e491] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - link "Orders" [ref=f19e495] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - generic [ref=f19e498]: "#1017" + - generic [ref=f19e500]: + - generic [ref=f19e501]: + - generic [ref=f19e502]: "#1017" + - generic [ref=f19e503]: Paid + - generic [ref=f19e504]: Unfulfilled + - paragraph [ref=f19e505]: Jul 26, 2026 8:41 AM + - generic [ref=f19e506]: + - button "Create fulfillment" [ref=f19e598] + - button "Refund" [ref=f19e604] + - button "Cancel order" [ref=f19e513] + - generic [ref=f19e525]: + - generic [ref=f19e526]: + - generic [ref=f19e527]: + - generic [ref=f19e528]: Timeline + - list [ref=f19e529]: + - listitem [ref=f19e530]: + - paragraph [ref=f19e532]: Order placed + - paragraph [ref=f19e533]: Jul 26, 2026 8:41 AM + - listitem [ref=f19e610]: + - paragraph [ref=f19e612]: Payment received + - paragraph [ref=f19e613]: Jul 26, 2026 8:41 AM + - generic [ref=f19e534]: + - generic [ref=f19e535]: Order lines + - table [ref=f19e537]: + - rowgroup [ref=f19e538]: + - row [ref=f19e539]: + - columnheader "Image" [ref=f19e540] + - columnheader "Product" [ref=f19e542] + - columnheader "Qty" [ref=f19e543] + - columnheader "Unit price" [ref=f19e544] + - columnheader "Total" [ref=f19e545] + - rowgroup [ref=f19e546]: + - row [ref=f19e547]: + - cell [ref=f19e548] + - 'cell "Premium Slim Fit Jeans - 28 / Blue SKU: ACME-PSFJ-28-BLUE" [ref=f19e552]': + - generic [ref=f19e553]: Premium Slim Fit Jeans - 28 / Blue + - generic [ref=f19e554]: "SKU: ACME-PSFJ-28-BLUE" + - cell "1" [ref=f19e555] + - cell "79.99 EUR" [ref=f19e556] + - cell "79.99 EUR" [ref=f19e557] + - generic [ref=f19e558]: + - generic [ref=f19e559]: + - generic [ref=f19e560]: Subtotal + - generic [ref=f19e561]: 79.99 EUR + - generic [ref=f19e562]: + - generic [ref=f19e563]: Shipping + - generic [ref=f19e564]: 4.99 EUR + - generic [ref=f19e565]: + - generic [ref=f19e566]: Tax + - generic [ref=f19e567]: 13.58 EUR + - generic [ref=f19e568]: + - generic [ref=f19e569]: Total + - generic [ref=f19e570]: 84.98 EUR + - generic [ref=f19e571]: + - generic [ref=f19e572]: Payment details + - generic [ref=f19e574]: + - generic [ref=f19e575]: + - paragraph [ref=f19e576]: Bank Transfer + - paragraph [ref=f19e577]: "84.98 EUR · Ref: mock_xFqqZ3TJnXOxLjhZ · Jul 26, 2026 8:41 AM" + - generic [ref=f19e578]: Captured + - generic [ref=f19e585]: + - generic [ref=f19e586]: + - generic [ref=f19e587]: Customer + - paragraph [ref=f19e588]: Jane Smith + - paragraph [ref=f19e589]: jane@example.com + - link "View customer" [ref=f19e591] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/2 + - generic [ref=f19e592]: + - generic [ref=f19e593]: Shipping address + - generic [ref=f19e594]: Jane Doe 123 Main St Berlin 10115 DE + - generic [ref=f19e595]: + - generic [ref=f19e596]: Billing address + - generic [ref=f19e597]: Jane Doe 123 Main St Berlin 10115 DE + - dialog [ref=f19e621]: + - generic [ref=f19e622]: + - generic [ref=f19e623]: Create fulfillment + - generic [ref=f19e625]: + - paragraph [ref=f19e626]: Premium Slim Fit Jeans - 28 / Blue (1 unfulfilled) + - spinbutton "Quantity to fulfill for Premium Slim Fit Jeans - 28 / Blue" [ref=f19e628]: "1" + - generic [ref=f19e629]: + - generic [ref=f19e630]: Tracking company + - textbox "Tracking company" [ref=f19e632]: + - /placeholder: UPS, FedEx, DHL... + - generic [ref=f19e633]: + - generic [ref=f19e634]: Tracking number + - textbox "Tracking number" [ref=f19e636] + - generic [ref=f19e637]: + - generic [ref=f19e638]: Tracking URL + - textbox "Tracking URL" [ref=f19e640]: + - /placeholder: https:// + - generic [ref=f19e641]: + - button "Cancel" [ref=f19e642] + - button "Create fulfillment" [ref=f19e648] + - button "Close modal" [ref=f19e656] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-46-34-161Z.yml b/.playwright-mcp/page-2026-07-26T08-46-34-161Z.yml new file mode 100644 index 00000000..024667a5 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-46-34-161Z.yml @@ -0,0 +1,158 @@ +- generic [active] [ref=f19e354]: + - link "Skip to main content" [ref=f19e355] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f19e356]: + - complementary "Admin navigation" [ref=f19e357]: + - generic [ref=f19e358]: + - link "Acme Fashion" [ref=f19e360] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f19e365]: + - navigation [ref=f19e366]: + - link "Dashboard" [ref=f19e367] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f19e372]: Products + - navigation [ref=f19e373]: + - link "Products" [ref=f19e374] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f19e379] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f19e384] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f19e389]: Orders + - navigation [ref=f19e390]: + - link "Orders" [ref=f19e391] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f19e396]: Customers + - navigation [ref=f19e397]: + - link "Customers" [ref=f19e398] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f19e403]: Discounts + - navigation [ref=f19e404]: + - link "Discounts" [ref=f19e405] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f19e411]: Content + - navigation [ref=f19e412]: + - link "Pages" [ref=f19e413] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f19e418] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f19e423] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f19e428]: + - link "Analytics" [ref=f19e429] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f19e435]: Settings + - navigation [ref=f19e436]: + - link "Settings" [ref=f19e437] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f19e443] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f19e448] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f19e453] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f19e458] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f19e463] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f19e468]: + - banner [ref=f19e469]: + - button "Acme Fashion" [ref=f19e471] + - button "Notifications" [ref=f19e476] + - button "AU Admin User" [ref=f19e480]: + - generic [ref=f19e481]: AU + - generic [ref=f19e484]: Admin User + - main [ref=f19e488]: + - generic [ref=f19e489]: + - link "Home" [ref=f19e491] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - link "Orders" [ref=f19e495] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - generic [ref=f19e498]: "#1017" + - generic [ref=f19e500]: + - generic [ref=f19e501]: + - generic [ref=f19e502]: "#1017" + - generic [ref=f19e503]: Paid + - generic [ref=f19e504]: Fulfilled + - paragraph [ref=f19e505]: Jul 26, 2026 8:41 AM + - button "Refund" [ref=f19e604] + - generic [ref=f19e525]: + - generic [ref=f19e526]: + - generic [ref=f19e527]: + - generic [ref=f19e528]: Timeline + - list [ref=f19e529]: + - listitem [ref=f19e530]: + - paragraph [ref=f19e532]: Order placed + - paragraph [ref=f19e533]: Jul 26, 2026 8:41 AM + - listitem [ref=f19e610]: + - paragraph [ref=f19e612]: Payment received + - paragraph [ref=f19e613]: Jul 26, 2026 8:41 AM + - listitem [ref=f19e659]: + - paragraph [ref=f19e661]: Fulfillment created + - paragraph [ref=f19e662]: Jul 26, 2026 8:46 AM + - generic [ref=f19e534]: + - generic [ref=f19e535]: Order lines + - table [ref=f19e537]: + - rowgroup [ref=f19e538]: + - row [ref=f19e539]: + - columnheader "Image" [ref=f19e540] + - columnheader "Product" [ref=f19e542] + - columnheader "Qty" [ref=f19e543] + - columnheader "Unit price" [ref=f19e544] + - columnheader "Total" [ref=f19e545] + - rowgroup [ref=f19e546]: + - row [ref=f19e547]: + - cell [ref=f19e548] + - 'cell "Premium Slim Fit Jeans - 28 / Blue SKU: ACME-PSFJ-28-BLUE" [ref=f19e552]': + - generic [ref=f19e553]: Premium Slim Fit Jeans - 28 / Blue + - generic [ref=f19e554]: "SKU: ACME-PSFJ-28-BLUE" + - cell "1" [ref=f19e555] + - cell "79.99 EUR" [ref=f19e556] + - cell "79.99 EUR" [ref=f19e557] + - generic [ref=f19e558]: + - generic [ref=f19e559]: + - generic [ref=f19e560]: Subtotal + - generic [ref=f19e561]: 79.99 EUR + - generic [ref=f19e562]: + - generic [ref=f19e563]: Shipping + - generic [ref=f19e564]: 4.99 EUR + - generic [ref=f19e565]: + - generic [ref=f19e566]: Tax + - generic [ref=f19e567]: 13.58 EUR + - generic [ref=f19e568]: + - generic [ref=f19e569]: Total + - generic [ref=f19e570]: 84.98 EUR + - generic [ref=f19e571]: + - generic [ref=f19e572]: Payment details + - generic [ref=f19e574]: + - generic [ref=f19e575]: + - paragraph [ref=f19e576]: Bank Transfer + - paragraph [ref=f19e577]: "84.98 EUR · Ref: mock_xFqqZ3TJnXOxLjhZ · Jul 26, 2026 8:41 AM" + - generic [ref=f19e578]: Captured + - generic [ref=f19e664]: + - generic [ref=f19e665]: + - generic [ref=f19e666]: + - generic [ref=f19e667]: "Fulfillment #8" + - generic [ref=f19e668]: Pending + - button "Mark as shipped" [ref=f19e670] + - paragraph [ref=f19e676]: "Tracking: DHL 1234567890" + - list [ref=f19e677]: + - listitem [ref=f19e678]: + - generic [ref=f19e679]: Premium Slim Fit Jeans - 28 / Blue + - generic [ref=f19e680]: × 1 + - generic [ref=f19e585]: + - generic [ref=f19e586]: + - generic [ref=f19e587]: Customer + - paragraph [ref=f19e588]: Jane Smith + - paragraph [ref=f19e589]: jane@example.com + - link "View customer" [ref=f19e591] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/2 + - generic [ref=f19e592]: + - generic [ref=f19e593]: Shipping address + - generic [ref=f19e594]: Jane Doe 123 Main St Berlin 10115 DE + - generic [ref=f19e595]: + - generic [ref=f19e596]: Billing address + - generic [ref=f19e597]: Jane Doe 123 Main St Berlin 10115 DE + - alert [ref=f19e681]: + - paragraph [ref=f19e684]: Fulfillment created + - button "Dismiss" [ref=f19e685] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-46-57-804Z.yml b/.playwright-mcp/page-2026-07-26T08-46-57-804Z.yml new file mode 100644 index 00000000..ecb1b19a --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-46-57-804Z.yml @@ -0,0 +1,174 @@ +- generic [active] [ref=f19e354]: + - link "Skip to main content" [ref=f19e355] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f19e356]: + - complementary "Admin navigation" [ref=f19e357]: + - generic [ref=f19e358]: + - link "Acme Fashion" [ref=f19e360] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f19e365]: + - navigation [ref=f19e366]: + - link "Dashboard" [ref=f19e367] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f19e372]: Products + - navigation [ref=f19e373]: + - link "Products" [ref=f19e374] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f19e379] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f19e384] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f19e389]: Orders + - navigation [ref=f19e390]: + - link "Orders" [ref=f19e391] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f19e396]: Customers + - navigation [ref=f19e397]: + - link "Customers" [ref=f19e398] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f19e403]: Discounts + - navigation [ref=f19e404]: + - link "Discounts" [ref=f19e405] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f19e411]: Content + - navigation [ref=f19e412]: + - link "Pages" [ref=f19e413] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f19e418] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f19e423] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f19e428]: + - link "Analytics" [ref=f19e429] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f19e435]: Settings + - navigation [ref=f19e436]: + - link "Settings" [ref=f19e437] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f19e443] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f19e448] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f19e453] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f19e458] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f19e463] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f19e468]: + - banner [ref=f19e469]: + - button "Acme Fashion" [ref=f19e471] + - button "Notifications" [ref=f19e476] + - button "AU Admin User" [ref=f19e480]: + - generic [ref=f19e481]: AU + - generic [ref=f19e484]: Admin User + - main [ref=f19e488]: + - generic [ref=f19e489]: + - link "Home" [ref=f19e491] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - link "Orders" [ref=f19e495] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - generic [ref=f19e498]: "#1017" + - generic [ref=f19e500]: + - generic [ref=f19e501]: + - generic [ref=f19e502]: "#1017" + - generic [ref=f19e503]: Paid + - generic [ref=f19e504]: Fulfilled + - paragraph [ref=f19e505]: Jul 26, 2026 8:41 AM + - button "Refund" [ref=f19e604] + - generic [ref=f19e525]: + - generic [ref=f19e526]: + - generic [ref=f19e527]: + - generic [ref=f19e528]: Timeline + - list [ref=f19e529]: + - listitem [ref=f19e530]: + - paragraph [ref=f19e532]: Order placed + - paragraph [ref=f19e533]: Jul 26, 2026 8:41 AM + - listitem [ref=f19e610]: + - paragraph [ref=f19e612]: Payment received + - paragraph [ref=f19e613]: Jul 26, 2026 8:41 AM + - listitem [ref=f19e659]: + - paragraph [ref=f19e661]: Fulfillment created + - paragraph [ref=f19e662]: Jul 26, 2026 8:46 AM + - generic [ref=f19e534]: + - generic [ref=f19e535]: Order lines + - table [ref=f19e537]: + - rowgroup [ref=f19e538]: + - row [ref=f19e539]: + - columnheader "Image" [ref=f19e540] + - columnheader "Product" [ref=f19e542] + - columnheader "Qty" [ref=f19e543] + - columnheader "Unit price" [ref=f19e544] + - columnheader "Total" [ref=f19e545] + - rowgroup [ref=f19e546]: + - row [ref=f19e547]: + - cell [ref=f19e548] + - 'cell "Premium Slim Fit Jeans - 28 / Blue SKU: ACME-PSFJ-28-BLUE" [ref=f19e552]': + - generic [ref=f19e553]: Premium Slim Fit Jeans - 28 / Blue + - generic [ref=f19e554]: "SKU: ACME-PSFJ-28-BLUE" + - cell "1" [ref=f19e555] + - cell "79.99 EUR" [ref=f19e556] + - cell "79.99 EUR" [ref=f19e557] + - generic [ref=f19e558]: + - generic [ref=f19e559]: + - generic [ref=f19e560]: Subtotal + - generic [ref=f19e561]: 79.99 EUR + - generic [ref=f19e562]: + - generic [ref=f19e563]: Shipping + - generic [ref=f19e564]: 4.99 EUR + - generic [ref=f19e565]: + - generic [ref=f19e566]: Tax + - generic [ref=f19e567]: 13.58 EUR + - generic [ref=f19e568]: + - generic [ref=f19e569]: Total + - generic [ref=f19e570]: 84.98 EUR + - generic [ref=f19e571]: + - generic [ref=f19e572]: Payment details + - generic [ref=f19e574]: + - generic [ref=f19e575]: + - paragraph [ref=f19e576]: Bank Transfer + - paragraph [ref=f19e577]: "84.98 EUR · Ref: mock_xFqqZ3TJnXOxLjhZ · Jul 26, 2026 8:41 AM" + - generic [ref=f19e578]: Captured + - generic [ref=f19e664]: + - generic [ref=f19e665]: + - generic [ref=f19e666]: + - generic [ref=f19e667]: "Fulfillment #8" + - generic [ref=f19e668]: Pending + - button "Mark as shipped" [ref=f19e670] + - paragraph [ref=f19e676]: "Tracking: DHL 1234567890" + - list [ref=f19e677]: + - listitem [ref=f19e678]: + - generic [ref=f19e679]: Premium Slim Fit Jeans - 28 / Blue + - generic [ref=f19e680]: × 1 + - generic [ref=f19e585]: + - generic [ref=f19e586]: + - generic [ref=f19e587]: Customer + - paragraph [ref=f19e588]: Jane Smith + - paragraph [ref=f19e589]: jane@example.com + - link "View customer" [ref=f19e591] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/2 + - generic [ref=f19e592]: + - generic [ref=f19e593]: Shipping address + - generic [ref=f19e594]: Jane Doe 123 Main St Berlin 10115 DE + - generic [ref=f19e595]: + - generic [ref=f19e596]: Billing address + - generic [ref=f19e597]: Jane Doe 123 Main St Berlin 10115 DE + - dialog [ref=f19e688]: + - generic [ref=f19e689]: + - generic [ref=f19e690]: Mark as shipped + - generic [ref=f19e691]: + - generic [ref=f19e692]: Tracking company + - textbox "Tracking company" [ref=f19e694]: + - /placeholder: UPS, FedEx, DHL... + - text: DHL + - generic [ref=f19e695]: + - generic [ref=f19e696]: Tracking number + - textbox "Tracking number" [ref=f19e698]: "1234567890" + - generic [ref=f19e699]: + - generic [ref=f19e700]: Tracking URL + - textbox "Tracking URL" [ref=f19e702]: + - /placeholder: https:// + - generic [ref=f19e703]: + - button "Cancel" [ref=f19e704] + - button "Mark as shipped" [ref=f19e710] + - button "Close modal" [ref=f19e718] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-47-31-837Z.yml b/.playwright-mcp/page-2026-07-26T08-47-31-837Z.yml new file mode 100644 index 00000000..fffc5353 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-47-31-837Z.yml @@ -0,0 +1,161 @@ +- generic [ref=f19e354]: + - link "Skip to main content" [ref=f19e355] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f19e356]: + - complementary "Admin navigation" [ref=f19e357]: + - generic [ref=f19e358]: + - link "Acme Fashion" [ref=f19e360] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f19e365]: + - navigation [ref=f19e366]: + - link "Dashboard" [ref=f19e367] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f19e372]: Products + - navigation [ref=f19e373]: + - link "Products" [ref=f19e374] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f19e379] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f19e384] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f19e389]: Orders + - navigation [ref=f19e390]: + - link "Orders" [ref=f19e391] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f19e396]: Customers + - navigation [ref=f19e397]: + - link "Customers" [ref=f19e398] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f19e403]: Discounts + - navigation [ref=f19e404]: + - link "Discounts" [ref=f19e405] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f19e411]: Content + - navigation [ref=f19e412]: + - link "Pages" [ref=f19e413] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f19e418] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f19e423] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f19e428]: + - link "Analytics" [ref=f19e429] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f19e435]: Settings + - navigation [ref=f19e436]: + - link "Settings" [ref=f19e437] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f19e443] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f19e448] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f19e453] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f19e458] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f19e463] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f19e468]: + - banner [ref=f19e469]: + - button "Acme Fashion" [ref=f19e471] + - button "Notifications" [ref=f19e476] + - button "AU Admin User" [ref=f19e480]: + - generic [ref=f19e481]: AU + - generic [ref=f19e484]: Admin User + - main [ref=f19e488]: + - generic [ref=f19e489]: + - link "Home" [ref=f19e491] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - link "Orders" [ref=f19e495] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - generic [ref=f19e498]: "#1017" + - generic [ref=f19e500]: + - generic [ref=f19e501]: + - generic [ref=f19e502]: "#1017" + - generic [ref=f19e503]: Paid + - generic [ref=f19e504]: Fulfilled + - paragraph [ref=f19e505]: Jul 26, 2026 8:41 AM + - button "Refund" [ref=f19e604] + - generic [ref=f19e525]: + - generic [ref=f19e526]: + - generic [ref=f19e527]: + - generic [ref=f19e528]: Timeline + - list [ref=f19e529]: + - listitem [ref=f19e530]: + - paragraph [ref=f19e532]: Order placed + - paragraph [ref=f19e533]: Jul 26, 2026 8:41 AM + - listitem [ref=f19e610]: + - paragraph [ref=f19e612]: Payment received + - paragraph [ref=f19e613]: Jul 26, 2026 8:41 AM + - listitem [ref=f19e659]: + - paragraph [ref=f19e661]: Fulfillment created + - paragraph [ref=f19e662]: Jul 26, 2026 8:46 AM + - listitem [ref=f19e721]: + - paragraph [ref=f19e723]: Fulfillment shipped + - paragraph [ref=f19e724]: Jul 26, 2026 8:47 AM + - generic [ref=f19e534]: + - generic [ref=f19e535]: Order lines + - table [ref=f19e537]: + - rowgroup [ref=f19e538]: + - row [ref=f19e539]: + - columnheader "Image" [ref=f19e540] + - columnheader "Product" [ref=f19e542] + - columnheader "Qty" [ref=f19e543] + - columnheader "Unit price" [ref=f19e544] + - columnheader "Total" [ref=f19e545] + - rowgroup [ref=f19e546]: + - row [ref=f19e547]: + - cell [ref=f19e548] + - 'cell "Premium Slim Fit Jeans - 28 / Blue SKU: ACME-PSFJ-28-BLUE" [ref=f19e552]': + - generic [ref=f19e553]: Premium Slim Fit Jeans - 28 / Blue + - generic [ref=f19e554]: "SKU: ACME-PSFJ-28-BLUE" + - cell "1" [ref=f19e555] + - cell "79.99 EUR" [ref=f19e556] + - cell "79.99 EUR" [ref=f19e557] + - generic [ref=f19e558]: + - generic [ref=f19e559]: + - generic [ref=f19e560]: Subtotal + - generic [ref=f19e561]: 79.99 EUR + - generic [ref=f19e562]: + - generic [ref=f19e563]: Shipping + - generic [ref=f19e564]: 4.99 EUR + - generic [ref=f19e565]: + - generic [ref=f19e566]: Tax + - generic [ref=f19e567]: 13.58 EUR + - generic [ref=f19e568]: + - generic [ref=f19e569]: Total + - generic [ref=f19e570]: 84.98 EUR + - generic [ref=f19e571]: + - generic [ref=f19e572]: Payment details + - generic [ref=f19e574]: + - generic [ref=f19e575]: + - paragraph [ref=f19e576]: Bank Transfer + - paragraph [ref=f19e577]: "84.98 EUR · Ref: mock_xFqqZ3TJnXOxLjhZ · Jul 26, 2026 8:41 AM" + - generic [ref=f19e578]: Captured + - generic [ref=f19e664]: + - generic [ref=f19e665]: + - generic [ref=f19e666]: + - generic [ref=f19e667]: "Fulfillment #8" + - generic [ref=f19e668]: Shipped + - button "Mark as delivered" [active] [ref=f19e725] + - paragraph [ref=f19e676]: "Tracking: DHL 1234567890" + - list [ref=f19e677]: + - listitem [ref=f19e678]: + - generic [ref=f19e679]: Premium Slim Fit Jeans - 28 / Blue + - generic [ref=f19e680]: × 1 + - generic [ref=f19e585]: + - generic [ref=f19e586]: + - generic [ref=f19e587]: Customer + - paragraph [ref=f19e588]: Jane Smith + - paragraph [ref=f19e589]: jane@example.com + - link "View customer" [ref=f19e591] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/2 + - generic [ref=f19e592]: + - generic [ref=f19e593]: Shipping address + - generic [ref=f19e594]: Jane Doe 123 Main St Berlin 10115 DE + - generic [ref=f19e595]: + - generic [ref=f19e596]: Billing address + - generic [ref=f19e597]: Jane Doe 123 Main St Berlin 10115 DE + - alert [ref=f19e726]: + - paragraph [ref=f19e729]: Fulfillment marked as shipped + - button "Dismiss" [ref=f19e730] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-47-53-876Z.yml b/.playwright-mcp/page-2026-07-26T08-47-53-876Z.yml new file mode 100644 index 00000000..cb34883a --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-47-53-876Z.yml @@ -0,0 +1,159 @@ +- generic [active] [ref=f19e354]: + - link "Skip to main content" [ref=f19e355] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f19e356]: + - complementary "Admin navigation" [ref=f19e357]: + - generic [ref=f19e358]: + - link "Acme Fashion" [ref=f19e360] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f19e365]: + - navigation [ref=f19e366]: + - link "Dashboard" [ref=f19e367] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f19e372]: Products + - navigation [ref=f19e373]: + - link "Products" [ref=f19e374] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f19e379] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f19e384] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f19e389]: Orders + - navigation [ref=f19e390]: + - link "Orders" [ref=f19e391] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f19e396]: Customers + - navigation [ref=f19e397]: + - link "Customers" [ref=f19e398] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f19e403]: Discounts + - navigation [ref=f19e404]: + - link "Discounts" [ref=f19e405] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f19e411]: Content + - navigation [ref=f19e412]: + - link "Pages" [ref=f19e413] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f19e418] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f19e423] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f19e428]: + - link "Analytics" [ref=f19e429] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f19e435]: Settings + - navigation [ref=f19e436]: + - link "Settings" [ref=f19e437] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f19e443] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f19e448] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f19e453] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f19e458] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f19e463] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f19e468]: + - banner [ref=f19e469]: + - button "Acme Fashion" [ref=f19e471] + - button "Notifications" [ref=f19e476] + - button "AU Admin User" [ref=f19e480]: + - generic [ref=f19e481]: AU + - generic [ref=f19e484]: Admin User + - main [ref=f19e488]: + - generic [ref=f19e489]: + - link "Home" [ref=f19e491] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - link "Orders" [ref=f19e495] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - generic [ref=f19e498]: "#1017" + - generic [ref=f19e500]: + - generic [ref=f19e501]: + - generic [ref=f19e502]: "#1017" + - generic [ref=f19e503]: Paid + - generic [ref=f19e504]: Fulfilled + - paragraph [ref=f19e505]: Jul 26, 2026 8:41 AM + - button "Refund" [ref=f19e604] + - generic [ref=f19e525]: + - generic [ref=f19e526]: + - generic [ref=f19e527]: + - generic [ref=f19e528]: Timeline + - list [ref=f19e529]: + - listitem [ref=f19e530]: + - paragraph [ref=f19e532]: Order placed + - paragraph [ref=f19e533]: Jul 26, 2026 8:41 AM + - listitem [ref=f19e610]: + - paragraph [ref=f19e612]: Payment received + - paragraph [ref=f19e613]: Jul 26, 2026 8:41 AM + - listitem [ref=f19e659]: + - paragraph [ref=f19e661]: Fulfillment created + - paragraph [ref=f19e662]: Jul 26, 2026 8:46 AM + - listitem [ref=f19e721]: + - paragraph [ref=f19e723]: Fulfillment shipped + - paragraph [ref=f19e724]: Jul 26, 2026 8:47 AM + - generic [ref=f19e534]: + - generic [ref=f19e535]: Order lines + - table [ref=f19e537]: + - rowgroup [ref=f19e538]: + - row [ref=f19e539]: + - columnheader "Image" [ref=f19e540] + - columnheader "Product" [ref=f19e542] + - columnheader "Qty" [ref=f19e543] + - columnheader "Unit price" [ref=f19e544] + - columnheader "Total" [ref=f19e545] + - rowgroup [ref=f19e546]: + - row [ref=f19e547]: + - cell [ref=f19e548] + - 'cell "Premium Slim Fit Jeans - 28 / Blue SKU: ACME-PSFJ-28-BLUE" [ref=f19e552]': + - generic [ref=f19e553]: Premium Slim Fit Jeans - 28 / Blue + - generic [ref=f19e554]: "SKU: ACME-PSFJ-28-BLUE" + - cell "1" [ref=f19e555] + - cell "79.99 EUR" [ref=f19e556] + - cell "79.99 EUR" [ref=f19e557] + - generic [ref=f19e558]: + - generic [ref=f19e559]: + - generic [ref=f19e560]: Subtotal + - generic [ref=f19e561]: 79.99 EUR + - generic [ref=f19e562]: + - generic [ref=f19e563]: Shipping + - generic [ref=f19e564]: 4.99 EUR + - generic [ref=f19e565]: + - generic [ref=f19e566]: Tax + - generic [ref=f19e567]: 13.58 EUR + - generic [ref=f19e568]: + - generic [ref=f19e569]: Total + - generic [ref=f19e570]: 84.98 EUR + - generic [ref=f19e571]: + - generic [ref=f19e572]: Payment details + - generic [ref=f19e574]: + - generic [ref=f19e575]: + - paragraph [ref=f19e576]: Bank Transfer + - paragraph [ref=f19e577]: "84.98 EUR · Ref: mock_xFqqZ3TJnXOxLjhZ · Jul 26, 2026 8:41 AM" + - generic [ref=f19e578]: Captured + - generic [ref=f19e664]: + - generic [ref=f19e666]: + - generic [ref=f19e667]: "Fulfillment #8" + - generic [ref=f19e668]: Delivered + - paragraph [ref=f19e676]: "Tracking: DHL 1234567890" + - list [ref=f19e677]: + - listitem [ref=f19e678]: + - generic [ref=f19e679]: Premium Slim Fit Jeans - 28 / Blue + - generic [ref=f19e680]: × 1 + - generic [ref=f19e585]: + - generic [ref=f19e586]: + - generic [ref=f19e587]: Customer + - paragraph [ref=f19e588]: Jane Smith + - paragraph [ref=f19e589]: jane@example.com + - link "View customer" [ref=f19e591] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/2 + - generic [ref=f19e592]: + - generic [ref=f19e593]: Shipping address + - generic [ref=f19e594]: Jane Doe 123 Main St Berlin 10115 DE + - generic [ref=f19e595]: + - generic [ref=f19e596]: Billing address + - generic [ref=f19e597]: Jane Doe 123 Main St Berlin 10115 DE + - alert [ref=f19e733]: + - paragraph [ref=f19e736]: Fulfillment marked as delivered + - button "Dismiss" [ref=f19e737] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-48-16-265Z.yml b/.playwright-mcp/page-2026-07-26T08-48-16-265Z.yml new file mode 100644 index 00000000..f88b8edb --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-48-16-265Z.yml @@ -0,0 +1,147 @@ +- generic [active] [ref=f20e1]: + - link "Skip to main content" [ref=f20e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f20e3]: + - complementary "Admin navigation" [ref=f20e4]: + - generic [ref=f20e5]: + - link "Acme Fashion" [ref=f20e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f20e12]: + - navigation [ref=f20e13]: + - link "Dashboard" [ref=f20e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f20e19]: Products + - navigation [ref=f20e20]: + - link "Products" [ref=f20e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f20e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f20e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f20e36]: Orders + - navigation [ref=f20e37]: + - link "Orders" [ref=f20e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f20e43]: Customers + - navigation [ref=f20e44]: + - link "Customers" [ref=f20e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f20e50]: Discounts + - navigation [ref=f20e51]: + - link "Discounts" [ref=f20e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f20e58]: Content + - navigation [ref=f20e59]: + - link "Pages" [ref=f20e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f20e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f20e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f20e75]: + - link "Analytics" [ref=f20e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f20e82]: Settings + - navigation [ref=f20e83]: + - link "Settings" [ref=f20e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f20e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f20e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f20e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f20e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f20e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f20e115]: + - banner [ref=f20e116]: + - button "Acme Fashion" [ref=f20e118] + - button "Notifications" [ref=f20e123] + - button "AU Admin User" [ref=f20e127]: + - generic [ref=f20e128]: AU + - generic [ref=f20e131]: Admin User + - main [ref=f20e135]: + - generic [ref=f20e136]: + - link "Home" [ref=f20e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - link "Orders" [ref=f20e142] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - generic [ref=f20e145]: "#1016" + - generic [ref=f20e147]: + - generic [ref=f20e148]: + - generic [ref=f20e149]: "#1016" + - generic [ref=f20e150]: Paid + - generic [ref=f20e151]: Unfulfilled + - paragraph [ref=f20e152]: Jul 26, 2026 8:34 AM + - generic [ref=f20e153]: + - button "Create fulfillment" [ref=f20e154] + - button "Refund" [ref=f20e160] + - button "Cancel order" [ref=f20e166] + - generic [ref=f20e172]: + - generic [ref=f20e173]: + - generic [ref=f20e174]: + - generic [ref=f20e175]: Timeline + - list [ref=f20e176]: + - listitem [ref=f20e177]: + - paragraph [ref=f20e179]: Order placed + - paragraph [ref=f20e180]: Jul 26, 2026 8:34 AM + - listitem [ref=f20e181]: + - paragraph [ref=f20e183]: Payment received + - paragraph [ref=f20e184]: Jul 26, 2026 8:34 AM + - generic [ref=f20e185]: + - generic [ref=f20e186]: Order lines + - table [ref=f20e188]: + - rowgroup [ref=f20e189]: + - row [ref=f20e190]: + - columnheader "Image" [ref=f20e191] + - columnheader "Product" [ref=f20e193] + - columnheader "Qty" [ref=f20e194] + - columnheader "Unit price" [ref=f20e195] + - columnheader "Total" [ref=f20e196] + - rowgroup [ref=f20e197]: + - row [ref=f20e198]: + - cell [ref=f20e199] + - 'cell "Classic Cotton T-Shirt - S / White SKU: ACME-CTSH-S-WHT" [ref=f20e203]': + - generic [ref=f20e204]: Classic Cotton T-Shirt - S / White + - generic [ref=f20e205]: "SKU: ACME-CTSH-S-WHT" + - cell "1" [ref=f20e206] + - cell "24.99 EUR" [ref=f20e207] + - cell "22.50 EUR" [ref=f20e208] + - generic [ref=f20e209]: + - generic [ref=f20e210]: + - generic [ref=f20e211]: Subtotal + - generic [ref=f20e212]: 24.99 EUR + - generic [ref=f20e213]: + - generic [ref=f20e214]: Discount + - generic [ref=f20e215]: "-2.49 EUR" + - generic [ref=f20e216]: + - generic [ref=f20e217]: Shipping + - generic [ref=f20e218]: 4.99 EUR + - generic [ref=f20e219]: + - generic [ref=f20e220]: Tax + - generic [ref=f20e221]: 4.40 EUR + - generic [ref=f20e222]: + - generic [ref=f20e223]: Total + - generic [ref=f20e224]: 27.49 EUR + - generic [ref=f20e225]: + - generic [ref=f20e226]: Payment details + - generic [ref=f20e228]: + - generic [ref=f20e229]: + - paragraph [ref=f20e230]: Credit Card + - paragraph [ref=f20e231]: "27.49 EUR · Ref: mock_RuUajoEnFLV6EEVn · Jul 26, 2026 8:34 AM" + - generic [ref=f20e232]: Captured + - generic [ref=f20e233]: + - generic [ref=f20e234]: + - generic [ref=f20e235]: Customer + - paragraph [ref=f20e236]: Jane Smith + - paragraph [ref=f20e237]: jane@example.com + - link "View customer" [ref=f20e239] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/2 + - generic [ref=f20e240]: + - generic [ref=f20e241]: Shipping address + - generic [ref=f20e242]: Jane Doe 123 Main St Berlin 10115 DE + - generic [ref=f20e243]: + - generic [ref=f20e244]: Billing address + - generic [ref=f20e245]: Jane Doe 123 Main St Berlin 10115 DE \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-48-39-175Z.yml b/.playwright-mcp/page-2026-07-26T08-48-39-175Z.yml new file mode 100644 index 00000000..c8c32486 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-48-39-175Z.yml @@ -0,0 +1,165 @@ +- generic [active] [ref=f20e1]: + - link "Skip to main content" [ref=f20e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f20e3]: + - complementary "Admin navigation" [ref=f20e4]: + - generic [ref=f20e5]: + - link "Acme Fashion" [ref=f20e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f20e12]: + - navigation [ref=f20e13]: + - link "Dashboard" [ref=f20e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f20e19]: Products + - navigation [ref=f20e20]: + - link "Products" [ref=f20e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f20e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f20e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f20e36]: Orders + - navigation [ref=f20e37]: + - link "Orders" [ref=f20e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f20e43]: Customers + - navigation [ref=f20e44]: + - link "Customers" [ref=f20e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f20e50]: Discounts + - navigation [ref=f20e51]: + - link "Discounts" [ref=f20e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f20e58]: Content + - navigation [ref=f20e59]: + - link "Pages" [ref=f20e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f20e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f20e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f20e75]: + - link "Analytics" [ref=f20e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f20e82]: Settings + - navigation [ref=f20e83]: + - link "Settings" [ref=f20e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f20e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f20e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f20e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f20e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f20e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f20e115]: + - banner [ref=f20e116]: + - button "Acme Fashion" [ref=f20e118] + - button "Notifications" [ref=f20e123] + - button "AU Admin User" [ref=f20e127]: + - generic [ref=f20e128]: AU + - generic [ref=f20e131]: Admin User + - main [ref=f20e135]: + - generic [ref=f20e136]: + - link "Home" [ref=f20e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - link "Orders" [ref=f20e142] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - generic [ref=f20e145]: "#1016" + - generic [ref=f20e147]: + - generic [ref=f20e148]: + - generic [ref=f20e149]: "#1016" + - generic [ref=f20e150]: Paid + - generic [ref=f20e151]: Unfulfilled + - paragraph [ref=f20e152]: Jul 26, 2026 8:34 AM + - generic [ref=f20e153]: + - button "Create fulfillment" [ref=f20e154] + - button "Refund" [ref=f20e160] + - button "Cancel order" [ref=f20e166] + - generic [ref=f20e172]: + - generic [ref=f20e173]: + - generic [ref=f20e174]: + - generic [ref=f20e175]: Timeline + - list [ref=f20e176]: + - listitem [ref=f20e177]: + - paragraph [ref=f20e179]: Order placed + - paragraph [ref=f20e180]: Jul 26, 2026 8:34 AM + - listitem [ref=f20e181]: + - paragraph [ref=f20e183]: Payment received + - paragraph [ref=f20e184]: Jul 26, 2026 8:34 AM + - generic [ref=f20e185]: + - generic [ref=f20e186]: Order lines + - table [ref=f20e188]: + - rowgroup [ref=f20e189]: + - row [ref=f20e190]: + - columnheader "Image" [ref=f20e191] + - columnheader "Product" [ref=f20e193] + - columnheader "Qty" [ref=f20e194] + - columnheader "Unit price" [ref=f20e195] + - columnheader "Total" [ref=f20e196] + - rowgroup [ref=f20e197]: + - row [ref=f20e198]: + - cell [ref=f20e199] + - 'cell "Classic Cotton T-Shirt - S / White SKU: ACME-CTSH-S-WHT" [ref=f20e203]': + - generic [ref=f20e204]: Classic Cotton T-Shirt - S / White + - generic [ref=f20e205]: "SKU: ACME-CTSH-S-WHT" + - cell "1" [ref=f20e206] + - cell "24.99 EUR" [ref=f20e207] + - cell "22.50 EUR" [ref=f20e208] + - generic [ref=f20e209]: + - generic [ref=f20e210]: + - generic [ref=f20e211]: Subtotal + - generic [ref=f20e212]: 24.99 EUR + - generic [ref=f20e213]: + - generic [ref=f20e214]: Discount + - generic [ref=f20e215]: "-2.49 EUR" + - generic [ref=f20e216]: + - generic [ref=f20e217]: Shipping + - generic [ref=f20e218]: 4.99 EUR + - generic [ref=f20e219]: + - generic [ref=f20e220]: Tax + - generic [ref=f20e221]: 4.40 EUR + - generic [ref=f20e222]: + - generic [ref=f20e223]: Total + - generic [ref=f20e224]: 27.49 EUR + - generic [ref=f20e225]: + - generic [ref=f20e226]: Payment details + - generic [ref=f20e228]: + - generic [ref=f20e229]: + - paragraph [ref=f20e230]: Credit Card + - paragraph [ref=f20e231]: "27.49 EUR · Ref: mock_RuUajoEnFLV6EEVn · Jul 26, 2026 8:34 AM" + - generic [ref=f20e232]: Captured + - generic [ref=f20e233]: + - generic [ref=f20e234]: + - generic [ref=f20e235]: Customer + - paragraph [ref=f20e236]: Jane Smith + - paragraph [ref=f20e237]: jane@example.com + - link "View customer" [ref=f20e239] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/2 + - generic [ref=f20e240]: + - generic [ref=f20e241]: Shipping address + - generic [ref=f20e242]: Jane Doe 123 Main St Berlin 10115 DE + - generic [ref=f20e243]: + - generic [ref=f20e244]: Billing address + - generic [ref=f20e245]: Jane Doe 123 Main St Berlin 10115 DE + - dialog [ref=f20e246]: + - generic [ref=f20e247]: + - generic [ref=f20e248]: Refund order + - generic [ref=f20e249]: + - generic [ref=f20e250]: Amount (cents) + - spinbutton "Amount (cents)" [ref=f20e252]: "2749" + - generic [ref=f20e253]: "Refundable: 27.49 EUR" + - generic [ref=f20e254]: + - generic [ref=f20e255]: Reason + - textbox "Reason" [ref=f20e256]: + - /placeholder: Reason for refund... + - generic [ref=f20e257]: + - checkbox "Restock returned items" [ref=f20e258] + - generic [ref=f20e260]: Restock returned items + - generic [ref=f20e261]: + - button "Cancel" [ref=f20e262] + - button "Create refund" [ref=f20e268] + - button "Close modal" [ref=f20e276] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-49-38-377Z.yml b/.playwright-mcp/page-2026-07-26T08-49-38-377Z.yml new file mode 100644 index 00000000..25a301a1 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-49-38-377Z.yml @@ -0,0 +1,160 @@ +- generic [ref=f20e1]: + - link "Skip to main content" [ref=f20e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f20e3]: + - complementary "Admin navigation" [ref=f20e4]: + - generic [ref=f20e5]: + - link "Acme Fashion" [ref=f20e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f20e12]: + - navigation [ref=f20e13]: + - link "Dashboard" [ref=f20e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f20e19]: Products + - navigation [ref=f20e20]: + - link "Products" [ref=f20e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f20e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f20e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f20e36]: Orders + - navigation [ref=f20e37]: + - link "Orders" [ref=f20e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f20e43]: Customers + - navigation [ref=f20e44]: + - link "Customers" [ref=f20e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f20e50]: Discounts + - navigation [ref=f20e51]: + - link "Discounts" [ref=f20e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f20e58]: Content + - navigation [ref=f20e59]: + - link "Pages" [ref=f20e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f20e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f20e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f20e75]: + - link "Analytics" [ref=f20e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f20e82]: Settings + - navigation [ref=f20e83]: + - link "Settings" [ref=f20e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f20e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f20e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f20e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f20e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f20e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f20e115]: + - banner [ref=f20e116]: + - button "Acme Fashion" [ref=f20e118] + - button "Notifications" [ref=f20e123] + - button "AU Admin User" [ref=f20e127]: + - generic [ref=f20e128]: AU + - generic [ref=f20e131]: Admin User + - main [ref=f20e135]: + - generic [ref=f20e136]: + - link "Home" [ref=f20e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - link "Orders" [ref=f20e142] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - generic [ref=f20e145]: "#1016" + - generic [ref=f20e147]: + - generic [ref=f20e148]: + - generic [ref=f20e149]: "#1016" + - generic [ref=f20e150]: Partially Refunded + - generic [ref=f20e151]: Unfulfilled + - paragraph [ref=f20e152]: Jul 26, 2026 8:34 AM + - generic [ref=f20e153]: + - button "Create fulfillment" [ref=f20e154] + - button "Refund" [active] [ref=f20e160] + - button "Cancel order" [ref=f20e166] + - generic [ref=f20e172]: + - generic [ref=f20e173]: + - generic [ref=f20e174]: + - generic [ref=f20e175]: Timeline + - list [ref=f20e176]: + - listitem [ref=f20e177]: + - paragraph [ref=f20e179]: Order placed + - paragraph [ref=f20e180]: Jul 26, 2026 8:34 AM + - listitem [ref=f20e181]: + - paragraph [ref=f20e183]: Payment received + - paragraph [ref=f20e184]: Jul 26, 2026 8:34 AM + - listitem [ref=f20e279]: + - paragraph [ref=f20e281]: Refund issued (10.00 EUR) + - paragraph [ref=f20e282]: Jul 26, 2026 8:49 AM + - generic [ref=f20e185]: + - generic [ref=f20e186]: Order lines + - table [ref=f20e188]: + - rowgroup [ref=f20e189]: + - row [ref=f20e190]: + - columnheader "Image" [ref=f20e191] + - columnheader "Product" [ref=f20e193] + - columnheader "Qty" [ref=f20e194] + - columnheader "Unit price" [ref=f20e195] + - columnheader "Total" [ref=f20e196] + - rowgroup [ref=f20e197]: + - row [ref=f20e198]: + - cell [ref=f20e199] + - 'cell "Classic Cotton T-Shirt - S / White SKU: ACME-CTSH-S-WHT" [ref=f20e203]': + - generic [ref=f20e204]: Classic Cotton T-Shirt - S / White + - generic [ref=f20e205]: "SKU: ACME-CTSH-S-WHT" + - cell "1" [ref=f20e206] + - cell "24.99 EUR" [ref=f20e207] + - cell "22.50 EUR" [ref=f20e208] + - generic [ref=f20e209]: + - generic [ref=f20e210]: + - generic [ref=f20e211]: Subtotal + - generic [ref=f20e212]: 24.99 EUR + - generic [ref=f20e213]: + - generic [ref=f20e214]: Discount + - generic [ref=f20e215]: "-2.49 EUR" + - generic [ref=f20e216]: + - generic [ref=f20e217]: Shipping + - generic [ref=f20e218]: 4.99 EUR + - generic [ref=f20e219]: + - generic [ref=f20e220]: Tax + - generic [ref=f20e221]: 4.40 EUR + - generic [ref=f20e222]: + - generic [ref=f20e223]: Total + - generic [ref=f20e224]: 27.49 EUR + - generic [ref=f20e225]: + - generic [ref=f20e226]: Payment details + - generic [ref=f20e228]: + - generic [ref=f20e229]: + - paragraph [ref=f20e230]: Credit Card + - paragraph [ref=f20e231]: "27.49 EUR · Ref: mock_RuUajoEnFLV6EEVn · Jul 26, 2026 8:34 AM" + - generic [ref=f20e232]: Captured + - generic [ref=f20e283]: + - generic [ref=f20e284]: Refunds + - generic [ref=f20e286]: + - generic [ref=f20e287]: + - paragraph [ref=f20e288]: 10.00 EUR + - paragraph [ref=f20e289]: Jul 26, 2026 8:49 AM · Partial goodwill refund + - generic [ref=f20e290]: Processed + - generic [ref=f20e233]: + - generic [ref=f20e234]: + - generic [ref=f20e235]: Customer + - paragraph [ref=f20e236]: Jane Smith + - paragraph [ref=f20e237]: jane@example.com + - link "View customer" [ref=f20e239] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/2 + - generic [ref=f20e240]: + - generic [ref=f20e241]: Shipping address + - generic [ref=f20e242]: Jane Doe 123 Main St Berlin 10115 DE + - generic [ref=f20e243]: + - generic [ref=f20e244]: Billing address + - generic [ref=f20e245]: Jane Doe 123 Main St Berlin 10115 DE + - alert [ref=f20e291]: + - paragraph [ref=f20e294]: Refund issued + - button "Dismiss" [ref=f20e295] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-50-01-888Z.yml b/.playwright-mcp/page-2026-07-26T08-50-01-888Z.yml new file mode 100644 index 00000000..988d1b39 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-50-01-888Z.yml @@ -0,0 +1,311 @@ +- generic [active] [ref=f21e1]: + - link "Skip to main content" [ref=f21e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f21e3]: + - complementary "Admin navigation" [ref=f21e4]: + - generic [ref=f21e5]: + - link "Acme Fashion" [ref=f21e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f21e12]: + - navigation [ref=f21e13]: + - link "Dashboard" [ref=f21e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f21e19]: Products + - navigation [ref=f21e20]: + - link "Products" [ref=f21e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f21e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f21e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f21e36]: Orders + - navigation [ref=f21e37]: + - link "Orders" [ref=f21e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f21e43]: Customers + - navigation [ref=f21e44]: + - link "Customers" [ref=f21e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f21e50]: Discounts + - navigation [ref=f21e51]: + - link "Discounts" [ref=f21e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f21e58]: Content + - navigation [ref=f21e59]: + - link "Pages" [ref=f21e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f21e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f21e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f21e75]: + - link "Analytics" [ref=f21e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f21e82]: Settings + - navigation [ref=f21e83]: + - link "Settings" [ref=f21e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f21e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f21e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f21e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f21e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f21e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f21e115]: + - banner [ref=f21e116]: + - button "Acme Fashion" [ref=f21e118] + - button "Notifications" [ref=f21e123] + - button "AU Admin User" [ref=f21e127]: + - generic [ref=f21e128]: AU + - generic [ref=f21e131]: Admin User + - main [ref=f21e135]: + - generic [ref=f21e136]: + - link "Home" [ref=f21e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f21e141]: Products + - generic [ref=f21e143]: + - generic [ref=f21e144]: + - generic [ref=f21e145]: Products + - link "Add product" [ref=f21e146] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/create + - generic [ref=f21e150]: + - textbox "Search products" [ref=f21e152]: + - /placeholder: Search products... + - tablist "Status filter" [ref=f21e154]: + - tab "All" [selected] [ref=f21e155] + - tab "Draft" [ref=f21e156] + - tab "Active" [ref=f21e157] + - tab "Archived" [ref=f21e158] + - combobox "Product type filter" [ref=f21e159]: + - option "All types" [selected] + - option "Accessories" + - option "Gift Cards" + - option "Hoodies" + - option "Jackets" + - option "Pants" + - option "Shoes" + - option "T-Shirts" + - table [ref=f21e161]: + - rowgroup [ref=f21e162]: + - row [ref=f21e163]: + - columnheader [ref=f21e164]: + - checkbox "Select all products" [ref=f21e165] + - columnheader "Image" [ref=f21e167] + - columnheader [ref=f21e169]: + - button "Title" [ref=f21e170] + - columnheader "Status" [ref=f21e171] + - columnheader [ref=f21e172]: + - button "Inventory" [ref=f21e173] + - columnheader "Variants" [ref=f21e174] + - columnheader "Type" [ref=f21e175] + - columnheader "Vendor" [ref=f21e176] + - columnheader [ref=f21e177]: + - button "Updated" [ref=f21e178] + - rowgroup [ref=f21e181]: + - row [ref=f21e182]: + - cell [ref=f21e183]: + - checkbox "Select Leather Belt" [ref=f21e184] + - cell [ref=f21e186] + - cell [ref=f21e190]: + - link "Leather Belt" [ref=f21e191] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/4/edit + - cell "Active" [ref=f21e192] + - cell "100" [ref=f21e194] + - cell "4" [ref=f21e195] + - cell "Accessories" [ref=f21e196] + - cell "Acme Accessories" [ref=f21e197] + - cell "35 minutes ago" [ref=f21e198] + - row [ref=f21e199]: + - cell [ref=f21e200]: + - checkbox "Select Wool Scarf" [ref=f21e201] + - cell [ref=f21e203] + - cell [ref=f21e207]: + - link "Wool Scarf" [ref=f21e208] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/12/edit + - cell "Active" [ref=f21e209] + - cell "90" [ref=f21e211] + - cell "3" [ref=f21e212] + - cell "Accessories" [ref=f21e213] + - cell "Acme Accessories" [ref=f21e214] + - cell "35 minutes ago" [ref=f21e215] + - row [ref=f21e216]: + - cell [ref=f21e217]: + - checkbox "Select Canvas Tote Bag" [ref=f21e218] + - cell [ref=f21e220] + - cell [ref=f21e224]: + - link "Canvas Tote Bag" [ref=f21e225] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/13/edit + - cell "Active" [ref=f21e226] + - cell "80" [ref=f21e228] + - cell "2" [ref=f21e229] + - cell "Accessories" [ref=f21e230] + - cell "Acme Accessories" [ref=f21e231] + - cell "35 minutes ago" [ref=f21e232] + - row [ref=f21e233]: + - cell [ref=f21e234]: + - checkbox "Select Bucket Hat" [ref=f21e235] + - cell [ref=f21e237] + - cell [ref=f21e241]: + - link "Bucket Hat" [ref=f21e242] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/14/edit + - cell "Active" [ref=f21e243] + - cell "132" [ref=f21e245] + - cell "6" [ref=f21e246] + - cell "Accessories" [ref=f21e247] + - cell "Acme Accessories" [ref=f21e248] + - cell "35 minutes ago" [ref=f21e249] + - row [ref=f21e250]: + - cell [ref=f21e251]: + - checkbox "Select Gift Card" [ref=f21e252] + - cell [ref=f21e254] + - cell [ref=f21e258]: + - link "Gift Card" [ref=f21e259] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/19/edit + - cell "Active" [ref=f21e260] + - cell "29997" [ref=f21e262] + - cell "3" [ref=f21e263] + - cell "Gift Cards" [ref=f21e264] + - cell "Acme Fashion" [ref=f21e265] + - cell "35 minutes ago" [ref=f21e266] + - row [ref=f21e267]: + - cell [ref=f21e268]: + - checkbox "Select Organic Hoodie" [ref=f21e269] + - cell [ref=f21e271] + - cell [ref=f21e275]: + - link "Organic Hoodie" [ref=f21e276] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/3/edit + - cell "Active" [ref=f21e277] + - cell "80" [ref=f21e279] + - cell "4" [ref=f21e280] + - cell "Hoodies" [ref=f21e281] + - cell "Acme Basics" [ref=f21e282] + - cell "35 minutes ago" [ref=f21e283] + - row [ref=f21e284]: + - cell [ref=f21e285]: + - checkbox "Select Unreleased Winter Jacket" [ref=f21e286] + - cell [ref=f21e288] + - cell [ref=f21e292]: + - link "Unreleased Winter Jacket" [ref=f21e293] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/15/edit + - cell "Draft" [ref=f21e294] + - cell "0" [ref=f21e296] + - cell "4" [ref=f21e297] + - cell "Jackets" [ref=f21e298] + - cell "Acme Outerwear" [ref=f21e299] + - cell "35 minutes ago" [ref=f21e300] + - row [ref=f21e301]: + - cell [ref=f21e302]: + - checkbox "Select Discontinued Raincoat" [ref=f21e303] + - cell [ref=f21e305] + - cell [ref=f21e309]: + - link "Discontinued Raincoat" [ref=f21e310] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/16/edit + - cell "Archived" [ref=f21e311] + - cell "6" [ref=f21e313] + - cell "2" [ref=f21e314] + - cell "Jackets" [ref=f21e315] + - cell "Acme Outerwear" [ref=f21e316] + - cell "35 minutes ago" [ref=f21e317] + - row [ref=f21e318]: + - cell [ref=f21e319]: + - checkbox "Select Backorder Denim Jacket" [ref=f21e320] + - cell [ref=f21e322] + - cell [ref=f21e326]: + - link "Backorder Denim Jacket" [ref=f21e327] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/18/edit + - cell "Active" [ref=f21e328] + - cell "0" [ref=f21e330] + - cell "4" [ref=f21e331] + - cell "Jackets" [ref=f21e332] + - cell "Acme Denim" [ref=f21e333] + - cell "35 minutes ago" [ref=f21e334] + - row [ref=f21e335]: + - cell [ref=f21e336]: + - checkbox "Select Cashmere Overcoat" [ref=f21e337] + - cell [ref=f21e339] + - cell [ref=f21e343]: + - link "Cashmere Overcoat" [ref=f21e344] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/20/edit + - cell "Active" [ref=f21e345] + - cell "18" [ref=f21e347] + - cell "6" [ref=f21e348] + - cell "Jackets" [ref=f21e349] + - cell "Acme Premium" [ref=f21e350] + - cell "35 minutes ago" [ref=f21e351] + - row [ref=f21e352]: + - cell [ref=f21e353]: + - checkbox "Select Premium Slim Fit Jeans" [ref=f21e354] + - cell [ref=f21e356] + - cell [ref=f21e360]: + - link "Premium Slim Fit Jeans" [ref=f21e361] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/2/edit + - cell "Active" [ref=f21e362] + - cell "79" [ref=f21e364] + - cell "10" [ref=f21e365] + - cell "Pants" [ref=f21e366] + - cell "Acme Denim" [ref=f21e367] + - cell "35 minutes ago" [ref=f21e368] + - row [ref=f21e369]: + - cell [ref=f21e370]: + - checkbox "Select Cargo Pants" [ref=f21e371] + - cell [ref=f21e373] + - cell [ref=f21e377]: + - link "Cargo Pants" [ref=f21e378] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/9/edit + - cell "Active" [ref=f21e379] + - cell "168" [ref=f21e381] + - cell "12" [ref=f21e382] + - cell "Pants" [ref=f21e383] + - cell "Acme Workwear" [ref=f21e384] + - cell "35 minutes ago" [ref=f21e385] + - row [ref=f21e386]: + - cell [ref=f21e387]: + - checkbox "Select Chino Shorts" [ref=f21e388] + - cell [ref=f21e390] + - cell [ref=f21e394]: + - link "Chino Shorts" [ref=f21e395] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/10/edit + - cell "Active" [ref=f21e396] + - cell "128" [ref=f21e398] + - cell "8" [ref=f21e399] + - cell "Pants" [ref=f21e400] + - cell "Acme Basics" [ref=f21e401] + - cell "35 minutes ago" [ref=f21e402] + - row [ref=f21e403]: + - cell [ref=f21e404]: + - checkbox "Select Wide Leg Trousers" [ref=f21e405] + - cell [ref=f21e407] + - cell [ref=f21e411]: + - link "Wide Leg Trousers" [ref=f21e412] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/11/edit + - cell "Active" [ref=f21e413] + - cell "21" [ref=f21e415] + - cell "3" [ref=f21e416] + - cell "Pants" [ref=f21e417] + - cell "Acme Denim" [ref=f21e418] + - cell "35 minutes ago" [ref=f21e419] + - row [ref=f21e420]: + - cell [ref=f21e421]: + - checkbox "Select Running Sneakers" [ref=f21e422] + - cell [ref=f21e424] + - cell [ref=f21e428]: + - link "Running Sneakers" [ref=f21e429] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/5/edit + - cell "Active" [ref=f21e430] + - cell "70" [ref=f21e432] + - cell "14" [ref=f21e433] + - cell "Shoes" [ref=f21e434] + - cell "Acme Sport" [ref=f21e435] + - cell "35 minutes ago" [ref=f21e436] + - navigation "Pagination Navigation" [ref=f21e438]: + - generic [ref=f21e439]: + - paragraph [ref=f21e441]: Showing 1 to 15 of 20 results + - generic [ref=f21e443]: + - generic "« Previous" [ref=f21e445] + - generic [ref=f21e449]: "1" + - button "Go to page 2" [ref=f21e453]: "2" + - button "Next »" [ref=f21e455] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-50-49-156Z.yml b/.playwright-mcp/page-2026-07-26T08-50-49-156Z.yml new file mode 100644 index 00000000..9324ef51 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-50-49-156Z.yml @@ -0,0 +1,424 @@ +- generic [active] [ref=f21e475]: + - link "Skip to main content" [ref=f21e476] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f21e477]: + - complementary "Admin navigation" [ref=f21e478]: + - generic [ref=f21e479]: + - link "Acme Fashion" [ref=f21e481] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f21e486]: + - navigation [ref=f21e487]: + - link "Dashboard" [ref=f21e488] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f21e493]: Products + - navigation [ref=f21e494]: + - link "Products" [ref=f21e495] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f21e500] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f21e505] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f21e510]: Orders + - navigation [ref=f21e511]: + - link "Orders" [ref=f21e512] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f21e517]: Customers + - navigation [ref=f21e518]: + - link "Customers" [ref=f21e519] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f21e524]: Discounts + - navigation [ref=f21e525]: + - link "Discounts" [ref=f21e526] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f21e532]: Content + - navigation [ref=f21e533]: + - link "Pages" [ref=f21e534] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f21e539] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f21e544] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f21e549]: + - link "Analytics" [ref=f21e550] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f21e556]: Settings + - navigation [ref=f21e557]: + - link "Settings" [ref=f21e558] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f21e564] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f21e569] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f21e574] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f21e579] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f21e584] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f21e589]: + - banner [ref=f21e590]: + - button "Acme Fashion" [ref=f21e592] + - button "Notifications" [ref=f21e597] + - button "AU Admin User" [ref=f21e601]: + - generic [ref=f21e602]: AU + - generic [ref=f21e605]: Admin User + - main [ref=f21e609]: + - generic [ref=f21e610]: + - link "Home" [ref=f21e612] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - link "Products" [ref=f21e616] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - generic [ref=f21e619]: Classic Cotton T-Shirt + - generic [ref=f21e621]: + - generic [ref=f21e622]: + - generic [ref=f21e623]: Classic Cotton T-Shirt + - button "Delete" [ref=f21e624] + - generic [ref=f21e632]: + - generic [ref=f21e633]: + - generic [ref=f21e634]: + - generic [ref=f21e635]: + - generic [ref=f21e636]: Title + - textbox "Title" [ref=f21e638]: + - /placeholder: Short Sleeve T-Shirt + - text: Classic Cotton T-Shirt + - generic [ref=f21e639]: + - generic [ref=f21e640]: Description + - textbox "Description" [ref=f21e641]: + - /placeholder: Describe your product... + - text:

A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.

+ - generic [ref=f21e642]: + - generic [ref=f21e643]: Media + - generic [ref=f21e644] [cursor=pointer]: + - paragraph [ref=f21e647]: Drag and drop images or click to upload + - button "Drag and drop images or click to upload" [ref=f21e648] + - generic [ref=f21e649]: + - generic [ref=f21e650]: Variants + - generic [ref=f21e651]: + - generic [ref=f21e652]: + - generic [ref=f21e653]: + - generic [ref=f21e654]: Option name + - textbox "Option name" [ref=f21e656]: + - /placeholder: Size + - text: Size + - generic [ref=f21e657]: + - generic [ref=f21e658]: Values (comma-separated) + - textbox "Values (comma-separated)" [ref=f21e660]: + - /placeholder: S, M, L, XL + - text: S, M, L, XL + - button "Remove option" [ref=f21e661] + - generic [ref=f21e668]: + - generic [ref=f21e669]: + - generic [ref=f21e670]: Option name + - textbox "Option name" [ref=f21e672]: + - /placeholder: Size + - text: Color + - generic [ref=f21e673]: + - generic [ref=f21e674]: Values (comma-separated) + - textbox "Values (comma-separated)" [ref=f21e676]: + - /placeholder: S, M, L, XL + - text: White, Black, Navy + - button "Remove option" [ref=f21e677] + - button "Add another option" [ref=f21e684] + - table [ref=f21e693]: + - rowgroup [ref=f21e694]: + - row [ref=f21e695]: + - columnheader "Variant" [ref=f21e696] + - columnheader "SKU" [ref=f21e697] + - columnheader "Barcode" [ref=f21e698] + - columnheader "Price (cents)" [ref=f21e699] + - columnheader "Compare at" [ref=f21e700] + - columnheader "Weight (g)" [ref=f21e701] + - columnheader "Qty" [ref=f21e702] + - columnheader "Policy" [ref=f21e703] + - columnheader "Ship" [ref=f21e704] + - rowgroup [ref=f21e705]: + - row [ref=f21e706]: + - cell "S / White Default" [ref=f21e707]: + - text: S / White + - generic [ref=f21e708]: Default + - cell [ref=f21e709]: + - textbox "SKU" [ref=f21e710]: ACME-CTSH-S-WHT + - cell [ref=f21e711]: + - textbox "Barcode" [ref=f21e712] + - cell [ref=f21e713]: + - spinbutton "Price in cents" [ref=f21e714]: "2499" + - cell [ref=f21e715]: + - spinbutton "Compare at price in cents" [ref=f21e716] + - cell [ref=f21e717]: + - spinbutton "Weight in grams" [ref=f21e718]: "200" + - cell [ref=f21e719]: + - spinbutton "Quantity on hand" [ref=f21e720]: "14" + - cell "Deny" [ref=f21e721]: + - combobox "Inventory policy" [ref=f21e722]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e723]: + - checkbox "Requires shipping" [checked] [ref=f21e724] + - row [ref=f21e728]: + - cell "S / Black" [ref=f21e729] + - cell [ref=f21e730]: + - textbox "SKU" [ref=f21e731]: ACME-CTSH-S-BLK + - cell [ref=f21e732]: + - textbox "Barcode" [ref=f21e733] + - cell [ref=f21e734]: + - spinbutton "Price in cents" [ref=f21e735]: "2499" + - cell [ref=f21e736]: + - spinbutton "Compare at price in cents" [ref=f21e737] + - cell [ref=f21e738]: + - spinbutton "Weight in grams" [ref=f21e739]: "200" + - cell [ref=f21e740]: + - spinbutton "Quantity on hand" [ref=f21e741]: "15" + - cell "Deny" [ref=f21e742]: + - combobox "Inventory policy" [ref=f21e743]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e744]: + - checkbox "Requires shipping" [checked] [ref=f21e745] + - row [ref=f21e749]: + - cell "S / Navy" [ref=f21e750] + - cell [ref=f21e751]: + - textbox "SKU" [ref=f21e752]: ACME-CTSH-S-NAV + - cell [ref=f21e753]: + - textbox "Barcode" [ref=f21e754] + - cell [ref=f21e755]: + - spinbutton "Price in cents" [ref=f21e756]: "2499" + - cell [ref=f21e757]: + - spinbutton "Compare at price in cents" [ref=f21e758] + - cell [ref=f21e759]: + - spinbutton "Weight in grams" [ref=f21e760]: "200" + - cell [ref=f21e761]: + - spinbutton "Quantity on hand" [ref=f21e762]: "15" + - cell "Deny" [ref=f21e763]: + - combobox "Inventory policy" [ref=f21e764]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e765]: + - checkbox "Requires shipping" [checked] [ref=f21e766] + - row [ref=f21e770]: + - cell "M / White" [ref=f21e771] + - cell [ref=f21e772]: + - textbox "SKU" [ref=f21e773]: ACME-CTSH-M-WHT + - cell [ref=f21e774]: + - textbox "Barcode" [ref=f21e775] + - cell [ref=f21e776]: + - spinbutton "Price in cents" [ref=f21e777]: "2499" + - cell [ref=f21e778]: + - spinbutton "Compare at price in cents" [ref=f21e779] + - cell [ref=f21e780]: + - spinbutton "Weight in grams" [ref=f21e781]: "200" + - cell [ref=f21e782]: + - spinbutton "Quantity on hand" [ref=f21e783]: "15" + - cell "Deny" [ref=f21e784]: + - combobox "Inventory policy" [ref=f21e785]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e786]: + - checkbox "Requires shipping" [checked] [ref=f21e787] + - row [ref=f21e791]: + - cell "M / Black" [ref=f21e792] + - cell [ref=f21e793]: + - textbox "SKU" [ref=f21e794]: ACME-CTSH-M-BLK + - cell [ref=f21e795]: + - textbox "Barcode" [ref=f21e796] + - cell [ref=f21e797]: + - spinbutton "Price in cents" [ref=f21e798]: "2499" + - cell [ref=f21e799]: + - spinbutton "Compare at price in cents" [ref=f21e800] + - cell [ref=f21e801]: + - spinbutton "Weight in grams" [ref=f21e802]: "200" + - cell [ref=f21e803]: + - spinbutton "Quantity on hand" [ref=f21e804]: "15" + - cell "Deny" [ref=f21e805]: + - combobox "Inventory policy" [ref=f21e806]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e807]: + - checkbox "Requires shipping" [checked] [ref=f21e808] + - row [ref=f21e812]: + - cell "M / Navy" [ref=f21e813] + - cell [ref=f21e814]: + - textbox "SKU" [ref=f21e815]: ACME-CTSH-M-NAV + - cell [ref=f21e816]: + - textbox "Barcode" [ref=f21e817] + - cell [ref=f21e818]: + - spinbutton "Price in cents" [ref=f21e819]: "2499" + - cell [ref=f21e820]: + - spinbutton "Compare at price in cents" [ref=f21e821] + - cell [ref=f21e822]: + - spinbutton "Weight in grams" [ref=f21e823]: "200" + - cell [ref=f21e824]: + - spinbutton "Quantity on hand" [ref=f21e825]: "15" + - cell "Deny" [ref=f21e826]: + - combobox "Inventory policy" [ref=f21e827]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e828]: + - checkbox "Requires shipping" [checked] [ref=f21e829] + - row [ref=f21e833]: + - cell "L / White" [ref=f21e834] + - cell [ref=f21e835]: + - textbox "SKU" [ref=f21e836]: ACME-CTSH-L-WHT + - cell [ref=f21e837]: + - textbox "Barcode" [ref=f21e838] + - cell [ref=f21e839]: + - spinbutton "Price in cents" [ref=f21e840]: "2499" + - cell [ref=f21e841]: + - spinbutton "Compare at price in cents" [ref=f21e842] + - cell [ref=f21e843]: + - spinbutton "Weight in grams" [ref=f21e844]: "200" + - cell [ref=f21e845]: + - spinbutton "Quantity on hand" [ref=f21e846]: "15" + - cell "Deny" [ref=f21e847]: + - combobox "Inventory policy" [ref=f21e848]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e849]: + - checkbox "Requires shipping" [checked] [ref=f21e850] + - row [ref=f21e854]: + - cell "L / Black" [ref=f21e855] + - cell [ref=f21e856]: + - textbox "SKU" [ref=f21e857]: ACME-CTSH-L-BLK + - cell [ref=f21e858]: + - textbox "Barcode" [ref=f21e859] + - cell [ref=f21e860]: + - spinbutton "Price in cents" [ref=f21e861]: "2499" + - cell [ref=f21e862]: + - spinbutton "Compare at price in cents" [ref=f21e863] + - cell [ref=f21e864]: + - spinbutton "Weight in grams" [ref=f21e865]: "200" + - cell [ref=f21e866]: + - spinbutton "Quantity on hand" [ref=f21e867]: "15" + - cell "Deny" [ref=f21e868]: + - combobox "Inventory policy" [ref=f21e869]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e870]: + - checkbox "Requires shipping" [checked] [ref=f21e871] + - row [ref=f21e875]: + - cell "L / Navy" [ref=f21e876] + - cell [ref=f21e877]: + - textbox "SKU" [ref=f21e878]: ACME-CTSH-L-NAV + - cell [ref=f21e879]: + - textbox "Barcode" [ref=f21e880] + - cell [ref=f21e881]: + - spinbutton "Price in cents" [ref=f21e882]: "2499" + - cell [ref=f21e883]: + - spinbutton "Compare at price in cents" [ref=f21e884] + - cell [ref=f21e885]: + - spinbutton "Weight in grams" [ref=f21e886]: "200" + - cell [ref=f21e887]: + - spinbutton "Quantity on hand" [ref=f21e888]: "15" + - cell "Deny" [ref=f21e889]: + - combobox "Inventory policy" [ref=f21e890]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e891]: + - checkbox "Requires shipping" [checked] [ref=f21e892] + - row [ref=f21e896]: + - cell "XL / White" [ref=f21e897] + - cell [ref=f21e898]: + - textbox "SKU" [ref=f21e899]: ACME-CTSH-XL-WHT + - cell [ref=f21e900]: + - textbox "Barcode" [ref=f21e901] + - cell [ref=f21e902]: + - spinbutton "Price in cents" [ref=f21e903]: "2499" + - cell [ref=f21e904]: + - spinbutton "Compare at price in cents" [ref=f21e905] + - cell [ref=f21e906]: + - spinbutton "Weight in grams" [ref=f21e907]: "200" + - cell [ref=f21e908]: + - spinbutton "Quantity on hand" [ref=f21e909]: "15" + - cell "Deny" [ref=f21e910]: + - combobox "Inventory policy" [ref=f21e911]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e912]: + - checkbox "Requires shipping" [checked] [ref=f21e913] + - row [ref=f21e917]: + - cell "XL / Black" [ref=f21e918] + - cell [ref=f21e919]: + - textbox "SKU" [ref=f21e920]: ACME-CTSH-XL-BLK + - cell [ref=f21e921]: + - textbox "Barcode" [ref=f21e922] + - cell [ref=f21e923]: + - spinbutton "Price in cents" [ref=f21e924]: "2499" + - cell [ref=f21e925]: + - spinbutton "Compare at price in cents" [ref=f21e926] + - cell [ref=f21e927]: + - spinbutton "Weight in grams" [ref=f21e928]: "200" + - cell [ref=f21e929]: + - spinbutton "Quantity on hand" [ref=f21e930]: "15" + - cell "Deny" [ref=f21e931]: + - combobox "Inventory policy" [ref=f21e932]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e933]: + - checkbox "Requires shipping" [checked] [ref=f21e934] + - row [ref=f21e938]: + - cell "XL / Navy" [ref=f21e939] + - cell [ref=f21e940]: + - textbox "SKU" [ref=f21e941]: ACME-CTSH-XL-NAV + - cell [ref=f21e942]: + - textbox "Barcode" [ref=f21e943] + - cell [ref=f21e944]: + - spinbutton "Price in cents" [ref=f21e945]: "2499" + - cell [ref=f21e946]: + - spinbutton "Compare at price in cents" [ref=f21e947] + - cell [ref=f21e948]: + - spinbutton "Weight in grams" [ref=f21e949]: "200" + - cell [ref=f21e950]: + - spinbutton "Quantity on hand" [ref=f21e951]: "15" + - cell "Deny" [ref=f21e952]: + - combobox "Inventory policy" [ref=f21e953]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e954]: + - checkbox "Requires shipping" [checked] [ref=f21e955] + - button "Search engine listing" [ref=f21e960] + - generic [ref=f21e963]: + - generic [ref=f21e965]: + - generic [ref=f21e966]: Status + - combobox "Status" [ref=f21e967]: + - option "Draft" + - option "Active" [selected] + - option "Archived" + - generic [ref=f21e969]: + - generic [ref=f21e970]: Published at + - textbox "Published at" [ref=f21e972]: 2026-07-26T08:14 + - generic [ref=f21e973]: + - generic [ref=f21e974]: Organization + - generic [ref=f21e975]: + - generic [ref=f21e976]: Vendor + - textbox "Vendor" [ref=f21e978]: + - /placeholder: Nike + - text: Acme Basics + - generic [ref=f21e979]: + - generic [ref=f21e980]: Product type + - textbox "Product type" [ref=f21e982]: + - /placeholder: T-Shirts + - text: T-Shirts + - generic [ref=f21e983]: + - generic [ref=f21e984]: Tags + - textbox "Tags" [ref=f21e986]: + - /placeholder: summer, cotton, sale + - text: new, popular + - generic [ref=f21e987]: Separate tags with commas + - generic [ref=f21e988]: + - generic [ref=f21e989]: Collections + - generic [ref=f21e990]: + - generic [ref=f21e991]: + - checkbox "New Arrivals" [checked] [ref=f21e992] + - generic [ref=f21e996]: New Arrivals + - generic [ref=f21e997]: + - checkbox "Pants & Jeans" [ref=f21e998] + - generic [ref=f21e1000]: Pants & Jeans + - generic [ref=f21e1001]: + - checkbox "Sale" [ref=f21e1002] + - generic [ref=f21e1004]: Sale + - generic [ref=f21e1005]: + - checkbox "T-Shirts" [checked] [ref=f21e1006] + - generic [ref=f21e1010]: T-Shirts + - generic [ref=f21e1012]: + - link "Discard" [ref=f21e1013] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - button "Save" [ref=f21e1014] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-51-29-293Z.yml b/.playwright-mcp/page-2026-07-26T08-51-29-293Z.yml new file mode 100644 index 00000000..bece74d9 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-51-29-293Z.yml @@ -0,0 +1,427 @@ +- generic [active] [ref=f21e475]: + - link "Skip to main content" [ref=f21e476] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f21e477]: + - complementary "Admin navigation" [ref=f21e478]: + - generic [ref=f21e479]: + - link "Acme Fashion" [ref=f21e481] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f21e486]: + - navigation [ref=f21e487]: + - link "Dashboard" [ref=f21e488] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f21e493]: Products + - navigation [ref=f21e494]: + - link "Products" [ref=f21e495] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f21e500] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f21e505] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f21e510]: Orders + - navigation [ref=f21e511]: + - link "Orders" [ref=f21e512] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f21e517]: Customers + - navigation [ref=f21e518]: + - link "Customers" [ref=f21e519] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f21e524]: Discounts + - navigation [ref=f21e525]: + - link "Discounts" [ref=f21e526] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f21e532]: Content + - navigation [ref=f21e533]: + - link "Pages" [ref=f21e534] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f21e539] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f21e544] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f21e549]: + - link "Analytics" [ref=f21e550] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f21e556]: Settings + - navigation [ref=f21e557]: + - link "Settings" [ref=f21e558] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f21e564] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f21e569] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f21e574] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f21e579] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f21e584] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f21e589]: + - banner [ref=f21e590]: + - button "Acme Fashion" [ref=f21e592] + - button "Notifications" [ref=f21e597] + - button "AU Admin User" [ref=f21e601]: + - generic [ref=f21e602]: AU + - generic [ref=f21e605]: Admin User + - main [ref=f21e609]: + - generic [ref=f21e610]: + - link "Home" [ref=f21e612] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - link "Products" [ref=f21e616] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - generic [ref=f21e619]: Classic Cotton T-Shirt + - generic [ref=f21e621]: + - generic [ref=f21e622]: + - generic [ref=f21e623]: Classic Cotton T-Shirt + - button "Delete" [ref=f21e624] + - generic [ref=f21e632]: + - generic [ref=f21e633]: + - generic [ref=f21e634]: + - generic [ref=f21e635]: + - generic [ref=f21e636]: Title + - textbox "Title" [ref=f21e638]: + - /placeholder: Short Sleeve T-Shirt + - text: Classic Cotton T-Shirt + - generic [ref=f21e639]: + - generic [ref=f21e640]: Description + - textbox "Description" [ref=f21e641]: + - /placeholder: Describe your product... + - text:

A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.

+ - generic [ref=f21e642]: + - generic [ref=f21e643]: Media + - generic [ref=f21e644] [cursor=pointer]: + - paragraph [ref=f21e647]: Drag and drop images or click to upload + - button "Drag and drop images or click to upload" [ref=f21e648] + - generic [ref=f21e649]: + - generic [ref=f21e650]: Variants + - generic [ref=f21e651]: + - generic [ref=f21e652]: + - generic [ref=f21e653]: + - generic [ref=f21e654]: Option name + - textbox "Option name" [ref=f21e656]: + - /placeholder: Size + - text: Size + - generic [ref=f21e657]: + - generic [ref=f21e658]: Values (comma-separated) + - textbox "Values (comma-separated)" [ref=f21e660]: + - /placeholder: S, M, L, XL + - text: S, M, L, XL + - button "Remove option" [ref=f21e661] + - generic [ref=f21e668]: + - generic [ref=f21e669]: + - generic [ref=f21e670]: Option name + - textbox "Option name" [ref=f21e672]: + - /placeholder: Size + - text: Color + - generic [ref=f21e673]: + - generic [ref=f21e674]: Values (comma-separated) + - textbox "Values (comma-separated)" [ref=f21e676]: + - /placeholder: S, M, L, XL + - text: White, Black, Navy + - button "Remove option" [ref=f21e677] + - button "Add another option" [ref=f21e684] + - table [ref=f21e693]: + - rowgroup [ref=f21e694]: + - row [ref=f21e695]: + - columnheader "Variant" [ref=f21e696] + - columnheader "SKU" [ref=f21e697] + - columnheader "Barcode" [ref=f21e698] + - columnheader "Price (cents)" [ref=f21e699] + - columnheader "Compare at" [ref=f21e700] + - columnheader "Weight (g)" [ref=f21e701] + - columnheader "Qty" [ref=f21e702] + - columnheader "Policy" [ref=f21e703] + - columnheader "Ship" [ref=f21e704] + - rowgroup [ref=f21e705]: + - row [ref=f21e706]: + - cell "S / White Default" [ref=f21e707]: + - text: S / White + - generic [ref=f21e708]: Default + - cell [ref=f21e709]: + - textbox "SKU" [ref=f21e710]: ACME-CTSH-S-WHT + - cell [ref=f21e711]: + - textbox "Barcode" [ref=f21e712] + - cell [ref=f21e713]: + - spinbutton "Price in cents" [ref=f21e714]: "2499" + - cell [ref=f21e715]: + - spinbutton "Compare at price in cents" [ref=f21e716] + - cell [ref=f21e717]: + - spinbutton "Weight in grams" [ref=f21e718]: "200" + - cell [ref=f21e719]: + - spinbutton "Quantity on hand" [ref=f21e720]: "14" + - cell "Deny" [ref=f21e721]: + - combobox "Inventory policy" [ref=f21e722]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e723]: + - checkbox "Requires shipping" [checked] [ref=f21e724] + - row [ref=f21e728]: + - cell "S / Black" [ref=f21e729] + - cell [ref=f21e730]: + - textbox "SKU" [ref=f21e731]: ACME-CTSH-S-BLK + - cell [ref=f21e732]: + - textbox "Barcode" [ref=f21e733] + - cell [ref=f21e734]: + - spinbutton "Price in cents" [ref=f21e735]: "2499" + - cell [ref=f21e736]: + - spinbutton "Compare at price in cents" [ref=f21e737] + - cell [ref=f21e738]: + - spinbutton "Weight in grams" [ref=f21e739]: "200" + - cell [ref=f21e740]: + - spinbutton "Quantity on hand" [ref=f21e741]: "15" + - cell "Deny" [ref=f21e742]: + - combobox "Inventory policy" [ref=f21e743]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e744]: + - checkbox "Requires shipping" [checked] [ref=f21e745] + - row [ref=f21e749]: + - cell "S / Navy" [ref=f21e750] + - cell [ref=f21e751]: + - textbox "SKU" [ref=f21e752]: ACME-CTSH-S-NAV + - cell [ref=f21e753]: + - textbox "Barcode" [ref=f21e754] + - cell [ref=f21e755]: + - spinbutton "Price in cents" [ref=f21e756]: "2499" + - cell [ref=f21e757]: + - spinbutton "Compare at price in cents" [ref=f21e758] + - cell [ref=f21e759]: + - spinbutton "Weight in grams" [ref=f21e760]: "200" + - cell [ref=f21e761]: + - spinbutton "Quantity on hand" [ref=f21e762]: "15" + - cell "Deny" [ref=f21e763]: + - combobox "Inventory policy" [ref=f21e764]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e765]: + - checkbox "Requires shipping" [checked] [ref=f21e766] + - row [ref=f21e770]: + - cell "M / White" [ref=f21e771] + - cell [ref=f21e772]: + - textbox "SKU" [ref=f21e773]: ACME-CTSH-M-WHT + - cell [ref=f21e774]: + - textbox "Barcode" [ref=f21e775] + - cell [ref=f21e776]: + - spinbutton "Price in cents" [ref=f21e777]: "2499" + - cell [ref=f21e778]: + - spinbutton "Compare at price in cents" [ref=f21e779] + - cell [ref=f21e780]: + - spinbutton "Weight in grams" [ref=f21e781]: "200" + - cell [ref=f21e782]: + - spinbutton "Quantity on hand" [ref=f21e783]: "15" + - cell "Deny" [ref=f21e784]: + - combobox "Inventory policy" [ref=f21e785]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e786]: + - checkbox "Requires shipping" [checked] [ref=f21e787] + - row [ref=f21e791]: + - cell "M / Black" [ref=f21e792] + - cell [ref=f21e793]: + - textbox "SKU" [ref=f21e794]: ACME-CTSH-M-BLK + - cell [ref=f21e795]: + - textbox "Barcode" [ref=f21e796] + - cell [ref=f21e797]: + - spinbutton "Price in cents" [ref=f21e798]: "2499" + - cell [ref=f21e799]: + - spinbutton "Compare at price in cents" [ref=f21e800] + - cell [ref=f21e801]: + - spinbutton "Weight in grams" [ref=f21e802]: "200" + - cell [ref=f21e803]: + - spinbutton "Quantity on hand" [ref=f21e804]: "15" + - cell "Deny" [ref=f21e805]: + - combobox "Inventory policy" [ref=f21e806]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e807]: + - checkbox "Requires shipping" [checked] [ref=f21e808] + - row [ref=f21e812]: + - cell "M / Navy" [ref=f21e813] + - cell [ref=f21e814]: + - textbox "SKU" [ref=f21e815]: ACME-CTSH-M-NAV + - cell [ref=f21e816]: + - textbox "Barcode" [ref=f21e817] + - cell [ref=f21e818]: + - spinbutton "Price in cents" [ref=f21e819]: "2499" + - cell [ref=f21e820]: + - spinbutton "Compare at price in cents" [ref=f21e821] + - cell [ref=f21e822]: + - spinbutton "Weight in grams" [ref=f21e823]: "200" + - cell [ref=f21e824]: + - spinbutton "Quantity on hand" [ref=f21e825]: "15" + - cell "Deny" [ref=f21e826]: + - combobox "Inventory policy" [ref=f21e827]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e828]: + - checkbox "Requires shipping" [checked] [ref=f21e829] + - row [ref=f21e833]: + - cell "L / White" [ref=f21e834] + - cell [ref=f21e835]: + - textbox "SKU" [ref=f21e836]: ACME-CTSH-L-WHT + - cell [ref=f21e837]: + - textbox "Barcode" [ref=f21e838] + - cell [ref=f21e839]: + - spinbutton "Price in cents" [ref=f21e840]: "2499" + - cell [ref=f21e841]: + - spinbutton "Compare at price in cents" [ref=f21e842] + - cell [ref=f21e843]: + - spinbutton "Weight in grams" [ref=f21e844]: "200" + - cell [ref=f21e845]: + - spinbutton "Quantity on hand" [ref=f21e846]: "15" + - cell "Deny" [ref=f21e847]: + - combobox "Inventory policy" [ref=f21e848]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e849]: + - checkbox "Requires shipping" [checked] [ref=f21e850] + - row [ref=f21e854]: + - cell "L / Black" [ref=f21e855] + - cell [ref=f21e856]: + - textbox "SKU" [ref=f21e857]: ACME-CTSH-L-BLK + - cell [ref=f21e858]: + - textbox "Barcode" [ref=f21e859] + - cell [ref=f21e860]: + - spinbutton "Price in cents" [ref=f21e861]: "2499" + - cell [ref=f21e862]: + - spinbutton "Compare at price in cents" [ref=f21e863] + - cell [ref=f21e864]: + - spinbutton "Weight in grams" [ref=f21e865]: "200" + - cell [ref=f21e866]: + - spinbutton "Quantity on hand" [ref=f21e867]: "15" + - cell "Deny" [ref=f21e868]: + - combobox "Inventory policy" [ref=f21e869]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e870]: + - checkbox "Requires shipping" [checked] [ref=f21e871] + - row [ref=f21e875]: + - cell "L / Navy" [ref=f21e876] + - cell [ref=f21e877]: + - textbox "SKU" [ref=f21e878]: ACME-CTSH-L-NAV + - cell [ref=f21e879]: + - textbox "Barcode" [ref=f21e880] + - cell [ref=f21e881]: + - spinbutton "Price in cents" [ref=f21e882]: "2499" + - cell [ref=f21e883]: + - spinbutton "Compare at price in cents" [ref=f21e884] + - cell [ref=f21e885]: + - spinbutton "Weight in grams" [ref=f21e886]: "200" + - cell [ref=f21e887]: + - spinbutton "Quantity on hand" [ref=f21e888]: "15" + - cell "Deny" [ref=f21e889]: + - combobox "Inventory policy" [ref=f21e890]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e891]: + - checkbox "Requires shipping" [checked] [ref=f21e892] + - row [ref=f21e896]: + - cell "XL / White" [ref=f21e897] + - cell [ref=f21e898]: + - textbox "SKU" [ref=f21e899]: ACME-CTSH-XL-WHT + - cell [ref=f21e900]: + - textbox "Barcode" [ref=f21e901] + - cell [ref=f21e902]: + - spinbutton "Price in cents" [ref=f21e903]: "2499" + - cell [ref=f21e904]: + - spinbutton "Compare at price in cents" [ref=f21e905] + - cell [ref=f21e906]: + - spinbutton "Weight in grams" [ref=f21e907]: "200" + - cell [ref=f21e908]: + - spinbutton "Quantity on hand" [ref=f21e909]: "15" + - cell "Deny" [ref=f21e910]: + - combobox "Inventory policy" [ref=f21e911]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e912]: + - checkbox "Requires shipping" [checked] [ref=f21e913] + - row [ref=f21e917]: + - cell "XL / Black" [ref=f21e918] + - cell [ref=f21e919]: + - textbox "SKU" [ref=f21e920]: ACME-CTSH-XL-BLK + - cell [ref=f21e921]: + - textbox "Barcode" [ref=f21e922] + - cell [ref=f21e923]: + - spinbutton "Price in cents" [ref=f21e924]: "2499" + - cell [ref=f21e925]: + - spinbutton "Compare at price in cents" [ref=f21e926] + - cell [ref=f21e927]: + - spinbutton "Weight in grams" [ref=f21e928]: "200" + - cell [ref=f21e929]: + - spinbutton "Quantity on hand" [ref=f21e930]: "15" + - cell "Deny" [ref=f21e931]: + - combobox "Inventory policy" [ref=f21e932]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e933]: + - checkbox "Requires shipping" [checked] [ref=f21e934] + - row [ref=f21e938]: + - cell "XL / Navy" [ref=f21e939] + - cell [ref=f21e940]: + - textbox "SKU" [ref=f21e941]: ACME-CTSH-XL-NAV + - cell [ref=f21e942]: + - textbox "Barcode" [ref=f21e943] + - cell [ref=f21e944]: + - spinbutton "Price in cents" [ref=f21e945]: "2499" + - cell [ref=f21e946]: + - spinbutton "Compare at price in cents" [ref=f21e947] + - cell [ref=f21e948]: + - spinbutton "Weight in grams" [ref=f21e949]: "200" + - cell [ref=f21e950]: + - spinbutton "Quantity on hand" [ref=f21e951]: "15" + - cell "Deny" [ref=f21e952]: + - combobox "Inventory policy" [ref=f21e953]: + - option "Deny" [selected] + - option "Continue" + - cell [ref=f21e954]: + - checkbox "Requires shipping" [checked] [ref=f21e955] + - button "Search engine listing" [ref=f21e960] + - generic [ref=f21e963]: + - generic [ref=f21e965]: + - generic [ref=f21e966]: Status + - combobox "Status" [ref=f21e967]: + - option "Draft" + - option "Active" [selected] + - option "Archived" + - generic [ref=f21e969]: + - generic [ref=f21e970]: Published at + - textbox "Published at" [ref=f21e972]: 2026-07-26T08:14 + - generic [ref=f21e973]: + - generic [ref=f21e974]: Organization + - generic [ref=f21e975]: + - generic [ref=f21e976]: Vendor + - textbox "Vendor" [ref=f21e978]: + - /placeholder: Nike + - text: Acme Basics + - generic [ref=f21e979]: + - generic [ref=f21e980]: Product type + - textbox "Product type" [ref=f21e982]: + - /placeholder: T-Shirts + - text: T-Shirts + - generic [ref=f21e983]: + - generic [ref=f21e984]: Tags + - textbox "Tags" [ref=f21e986]: + - /placeholder: summer, cotton, sale + - text: new, popular + - generic [ref=f21e987]: Separate tags with commas + - generic [ref=f21e988]: + - generic [ref=f21e989]: Collections + - generic [ref=f21e990]: + - generic [ref=f21e991]: + - checkbox "New Arrivals" [checked] [ref=f21e992] + - generic [ref=f21e996]: New Arrivals + - generic [ref=f21e997]: + - checkbox "Pants & Jeans" [ref=f21e998] + - generic [ref=f21e1000]: Pants & Jeans + - generic [ref=f21e1001]: + - checkbox "Sale" [ref=f21e1002] + - generic [ref=f21e1004]: Sale + - generic [ref=f21e1005]: + - checkbox "T-Shirts" [checked] [ref=f21e1006] + - generic [ref=f21e1010]: T-Shirts + - generic [ref=f21e1012]: + - link "Discard" [ref=f21e1013] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - button "Save" [ref=f21e1014] + - alert [ref=f21e1020]: + - paragraph [ref=f21e1023]: Product saved + - button "Dismiss" [ref=f21e1024] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-54-48-712Z.yml b/.playwright-mcp/page-2026-07-26T08-54-48-712Z.yml new file mode 100644 index 00000000..003cff74 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-54-48-712Z.yml @@ -0,0 +1,173 @@ +- generic [active] [ref=f30e1]: + - link "Skip to main content" [ref=f30e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f30e3]: + - complementary "Admin navigation" [ref=f30e4]: + - generic [ref=f30e5]: + - link "Acme Fashion" [ref=f30e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f30e12]: + - navigation [ref=f30e13]: + - link "Dashboard" [ref=f30e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f30e19]: Products + - navigation [ref=f30e20]: + - link "Products" [ref=f30e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f30e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f30e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f30e36]: Orders + - navigation [ref=f30e37]: + - link "Orders" [ref=f30e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f30e43]: Customers + - navigation [ref=f30e44]: + - link "Customers" [ref=f30e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f30e50]: Discounts + - navigation [ref=f30e51]: + - link "Discounts" [ref=f30e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f30e58]: Content + - navigation [ref=f30e59]: + - link "Pages" [ref=f30e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f30e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f30e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f30e75]: + - link "Analytics" [ref=f30e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f30e82]: Settings + - navigation [ref=f30e83]: + - link "Settings" [ref=f30e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f30e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f30e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f30e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f30e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f30e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f30e115]: + - banner [ref=f30e116]: + - button "Acme Fashion" [ref=f30e118] + - button "Notifications" [ref=f30e123] + - button "AU Admin User" [ref=f30e127]: + - generic [ref=f30e128]: AU + - generic [ref=f30e131]: Admin User + - main [ref=f30e135]: + - generic [ref=f30e136]: + - link "Home" [ref=f30e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f30e141]: Discounts + - generic [ref=f30e143]: + - generic [ref=f30e144]: + - generic [ref=f30e145]: Discounts + - link "Create discount" [ref=f30e146] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts/create + - generic [ref=f30e150]: + - textbox "Search discounts" [ref=f30e152]: + - /placeholder: Search by code... + - combobox "Status filter" [ref=f30e154]: + - option "All statuses" [selected] + - option "Draft" + - option "Active" + - option "Scheduled" + - option "Expired" + - option "Disabled" + - combobox "Type filter" [ref=f30e155]: + - option "All types" [selected] + - option "Code" + - option "Automatic" + - table [ref=f30e157]: + - rowgroup [ref=f30e158]: + - row [ref=f30e159]: + - columnheader "Code" [ref=f30e160] + - columnheader "Type" [ref=f30e161] + - columnheader "Value" [ref=f30e162] + - columnheader "Usage" [ref=f30e163] + - columnheader "Dates" [ref=f30e164] + - columnheader "Status" [ref=f30e165] + - columnheader "Actions" [ref=f30e166] + - rowgroup [ref=f30e168]: + - row [ref=f30e169]: + - cell [ref=f30e170]: + - link "WELCOME10" [ref=f30e171] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts/1/edit + - cell "Code" [ref=f30e172] + - cell "10%" [ref=f30e174] + - cell "4 / Unlimited" [ref=f30e175] + - cell "Jan 1, 2025 → Dec 31, 2027" [ref=f30e176] + - cell "Active" [ref=f30e177] + - cell [ref=f30e179]: + - generic [ref=f30e180]: + - link "Edit WELCOME10" [ref=f30e181] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts/1/edit + - button "Disable" [ref=f30e184] + - button "Delete WELCOME10" [ref=f30e190] + - row [ref=f30e197]: + - cell [ref=f30e198]: + - link "FLAT5" [ref=f30e199] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts/2/edit + - cell "Code" [ref=f30e200] + - cell "5.00 EUR" [ref=f30e202] + - cell "0 / Unlimited" [ref=f30e203] + - cell "Jan 1, 2025 → Dec 31, 2027" [ref=f30e204] + - cell "Active" [ref=f30e205] + - cell [ref=f30e207]: + - generic [ref=f30e208]: + - link "Edit FLAT5" [ref=f30e209] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts/2/edit + - button "Disable" [ref=f30e212] + - button "Delete FLAT5" [ref=f30e218] + - row [ref=f30e225]: + - cell [ref=f30e226]: + - link "FREESHIP" [ref=f30e227] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts/3/edit + - cell "Code" [ref=f30e228] + - cell "Free shipping" [ref=f30e230] + - cell "1 / Unlimited" [ref=f30e231] + - cell "Jan 1, 2025 → Dec 31, 2027" [ref=f30e232] + - cell "Active" [ref=f30e233] + - cell [ref=f30e235]: + - generic [ref=f30e236]: + - link "Edit FREESHIP" [ref=f30e237] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts/3/edit + - button "Disable" [ref=f30e240] + - button "Delete FREESHIP" [ref=f30e246] + - row [ref=f30e253]: + - cell [ref=f30e254]: + - link "EXPIRED20" [ref=f30e255] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts/4/edit + - cell "Code" [ref=f30e256] + - cell "20%" [ref=f30e258] + - cell "0 / Unlimited" [ref=f30e259] + - cell "Jan 1, 2024 → Dec 31, 2024" [ref=f30e260] + - cell "Expired" [ref=f30e261] + - cell [ref=f30e263]: + - generic [ref=f30e264]: + - link "Edit EXPIRED20" [ref=f30e265] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts/4/edit + - button "Delete EXPIRED20" [ref=f30e268] + - row [ref=f30e275]: + - cell [ref=f30e276]: + - link "MAXED" [ref=f30e277] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts/5/edit + - cell "Code" [ref=f30e278] + - cell "10%" [ref=f30e280] + - cell "5 / 5" [ref=f30e281] + - cell "Jan 1, 2025 → Dec 31, 2027" [ref=f30e282] + - cell "Active" [ref=f30e283] + - cell [ref=f30e285]: + - generic [ref=f30e286]: + - link "Edit MAXED" [ref=f30e287] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts/5/edit + - button "Disable" [ref=f30e290] + - button "Delete MAXED" [ref=f30e296] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-55-12-643Z.yml b/.playwright-mcp/page-2026-07-26T08-55-12-643Z.yml new file mode 100644 index 00000000..d042f0c0 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-55-12-643Z.yml @@ -0,0 +1,144 @@ +- generic [active] [ref=f33e1]: + - link "Skip to main content" [ref=f33e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f33e3]: + - complementary "Admin navigation" [ref=f33e4]: + - generic [ref=f33e5]: + - link "Acme Fashion" [ref=f33e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f33e12]: + - navigation [ref=f33e13]: + - link "Dashboard" [ref=f33e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f33e19]: Products + - navigation [ref=f33e20]: + - link "Products" [ref=f33e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f33e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f33e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f33e36]: Orders + - navigation [ref=f33e37]: + - link "Orders" [ref=f33e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f33e43]: Customers + - navigation [ref=f33e44]: + - link "Customers" [ref=f33e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f33e50]: Discounts + - navigation [ref=f33e51]: + - link "Discounts" [ref=f33e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f33e58]: Content + - navigation [ref=f33e59]: + - link "Pages" [ref=f33e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f33e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f33e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f33e75]: + - link "Analytics" [ref=f33e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f33e82]: Settings + - navigation [ref=f33e83]: + - link "Settings" [ref=f33e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f33e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f33e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f33e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f33e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f33e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f33e115]: + - banner [ref=f33e116]: + - button "Acme Fashion" [ref=f33e118] + - button "Notifications" [ref=f33e123] + - button "AU Admin User" [ref=f33e127]: + - generic [ref=f33e128]: AU + - generic [ref=f33e131]: Admin User + - main [ref=f33e135]: + - generic [ref=f33e136]: + - link "Home" [ref=f33e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f33e141]: Collections + - generic [ref=f33e143]: + - generic [ref=f33e144]: + - generic [ref=f33e145]: Collections + - link "Add collection" [ref=f33e146] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections/create + - generic [ref=f33e150]: + - textbox "Search collections" [ref=f33e152]: + - /placeholder: Search collections... + - combobox "Status filter" [ref=f33e154]: + - option "All statuses" [selected] + - option "Draft" + - option "Active" + - option "Archived" + - table [ref=f33e156]: + - rowgroup [ref=f33e157]: + - row [ref=f33e158]: + - columnheader "Title" [ref=f33e159] + - columnheader "Type" [ref=f33e160] + - columnheader "Products" [ref=f33e161] + - columnheader "Status" [ref=f33e162] + - columnheader "Updated" [ref=f33e163] + - columnheader "Actions" [ref=f33e164] + - rowgroup [ref=f33e166]: + - row [ref=f33e167]: + - cell [ref=f33e168]: + - link "New Arrivals" [ref=f33e169] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections/1/edit + - cell "Manual" [ref=f33e170] + - cell "7" [ref=f33e172] + - cell "Active" [ref=f33e173] + - cell "40 minutes ago" [ref=f33e175] + - cell [ref=f33e176]: + - generic [ref=f33e177]: + - link "Edit New Arrivals" [ref=f33e178] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections/1/edit + - button "Delete New Arrivals" [ref=f33e181] + - row [ref=f33e188]: + - cell [ref=f33e189]: + - link "T-Shirts" [ref=f33e190] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections/2/edit + - cell "Manual" [ref=f33e191] + - cell "4" [ref=f33e193] + - cell "Active" [ref=f33e194] + - cell "40 minutes ago" [ref=f33e196] + - cell [ref=f33e197]: + - generic [ref=f33e198]: + - link "Edit T-Shirts" [ref=f33e199] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections/2/edit + - button "Delete T-Shirts" [ref=f33e202] + - row [ref=f33e209]: + - cell [ref=f33e210]: + - link "Pants & Jeans" [ref=f33e211] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections/3/edit + - cell "Manual" [ref=f33e212] + - cell "4" [ref=f33e214] + - cell "Active" [ref=f33e215] + - cell "40 minutes ago" [ref=f33e217] + - cell [ref=f33e218]: + - generic [ref=f33e219]: + - link "Edit Pants & Jeans" [ref=f33e220] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections/3/edit + - button "Delete Pants & Jeans" [ref=f33e223] + - row [ref=f33e230]: + - cell [ref=f33e231]: + - link "Sale" [ref=f33e232] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections/4/edit + - cell "Manual" [ref=f33e233] + - cell "3" [ref=f33e235] + - cell "Active" [ref=f33e236] + - cell "40 minutes ago" [ref=f33e238] + - cell [ref=f33e239]: + - generic [ref=f33e240]: + - link "Edit Sale" [ref=f33e241] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections/4/edit + - button "Delete Sale" [ref=f33e244] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-55-23-115Z.yml b/.playwright-mcp/page-2026-07-26T08-55-23-115Z.yml new file mode 100644 index 00000000..9463da96 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-55-23-115Z.yml @@ -0,0 +1,173 @@ +- generic [active] [ref=f35e1]: + - link "Skip to main content" [ref=f35e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f35e3]: + - complementary "Admin navigation" [ref=f35e4]: + - generic [ref=f35e5]: + - link "Acme Fashion" [ref=f35e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f35e12]: + - navigation [ref=f35e13]: + - link "Dashboard" [ref=f35e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f35e19]: Products + - navigation [ref=f35e20]: + - link "Products" [ref=f35e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f35e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f35e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f35e36]: Orders + - navigation [ref=f35e37]: + - link "Orders" [ref=f35e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f35e43]: Customers + - navigation [ref=f35e44]: + - link "Customers" [ref=f35e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f35e50]: Discounts + - navigation [ref=f35e51]: + - link "Discounts" [ref=f35e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f35e58]: Content + - navigation [ref=f35e59]: + - link "Pages" [ref=f35e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f35e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f35e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f35e75]: + - link "Analytics" [ref=f35e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f35e82]: Settings + - navigation [ref=f35e83]: + - link "Settings" [ref=f35e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f35e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f35e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f35e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f35e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f35e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f35e115]: + - banner [ref=f35e116]: + - button "Acme Fashion" [ref=f35e118] + - button "Notifications" [ref=f35e123] + - button "AU Admin User" [ref=f35e127]: + - generic [ref=f35e128]: AU + - generic [ref=f35e131]: Admin User + - main [ref=f35e135]: + - generic [ref=f35e136]: + - link "Home" [ref=f35e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f35e141]: Customers + - generic [ref=f35e143]: + - generic [ref=f35e144]: Customers + - textbox "Search customers" [ref=f35e147]: + - /placeholder: Search by name or email... + - table [ref=f35e150]: + - rowgroup [ref=f35e151]: + - row [ref=f35e152]: + - columnheader "Name" [ref=f35e153] + - columnheader "Email" [ref=f35e154] + - columnheader "Orders" [ref=f35e155] + - columnheader "Total spent" [ref=f35e156] + - columnheader "Marketing" [ref=f35e157] + - columnheader "Created" [ref=f35e158] + - rowgroup [ref=f35e159]: + - row [ref=f35e160]: + - cell [ref=f35e161]: + - link "John Doe" [ref=f35e162] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/1 + - cell "customer@acme.test" [ref=f35e163] + - cell "5" [ref=f35e164] + - cell "734.37 EUR" [ref=f35e165] + - cell "Opted in" [ref=f35e166] + - cell "Jul 26, 2026" [ref=f35e168] + - row [ref=f35e169]: + - cell [ref=f35e170]: + - link "Jane Smith" [ref=f35e171] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/2 + - cell "jane@example.com" [ref=f35e172] + - cell "4" [ref=f35e173] + - cell "272.42 EUR" [ref=f35e174] + - cell "Opted out" [ref=f35e175] + - cell "Jul 26, 2026" [ref=f35e177] + - row [ref=f35e178]: + - cell [ref=f35e179]: + - link "Michael Brown" [ref=f35e180] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/3 + - cell "michael@example.com" [ref=f35e181] + - cell "1" [ref=f35e182] + - cell "124.98 EUR" [ref=f35e183] + - cell "Opted in" [ref=f35e184] + - cell "Jul 26, 2026" [ref=f35e186] + - row [ref=f35e187]: + - cell [ref=f35e188]: + - link "Sarah Wilson" [ref=f35e189] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/4 + - cell "sarah@example.com" [ref=f35e190] + - cell "1" [ref=f35e191] + - cell "104.96 EUR" [ref=f35e192] + - cell "Opted out" [ref=f35e193] + - cell "Jul 26, 2026" [ref=f35e195] + - row [ref=f35e196]: + - cell [ref=f35e197]: + - link "David Lee" [ref=f35e198] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/5 + - cell "david@example.com" [ref=f35e199] + - cell "1" [ref=f35e200] + - cell "89.97 EUR" [ref=f35e201] + - cell "Opted in" [ref=f35e202] + - cell "Jul 26, 2026" [ref=f35e204] + - row [ref=f35e205]: + - cell [ref=f35e206]: + - link "Emma Garcia" [ref=f35e207] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/6 + - cell "emma@example.com" [ref=f35e208] + - cell "1" [ref=f35e209] + - cell "49.97 EUR" [ref=f35e210] + - cell "Opted out" [ref=f35e211] + - cell "Jul 26, 2026" [ref=f35e213] + - row [ref=f35e214]: + - cell [ref=f35e215]: + - link "James Taylor" [ref=f35e216] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/7 + - cell "james@example.com" [ref=f35e217] + - cell "1" [ref=f35e218] + - cell "32.98 EUR" [ref=f35e219] + - cell "Opted out" [ref=f35e220] + - cell "Jul 26, 2026" [ref=f35e222] + - row [ref=f35e223]: + - cell [ref=f35e224]: + - link "Lisa Anderson" [ref=f35e225] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/8 + - cell "lisa@example.com" [ref=f35e226] + - cell "1" [ref=f35e227] + - cell "84.97 EUR" [ref=f35e228] + - cell "Opted in" [ref=f35e229] + - cell "Jul 26, 2026" [ref=f35e231] + - row [ref=f35e232]: + - cell [ref=f35e233]: + - link "Robert Martinez" [ref=f35e234] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/9 + - cell "robert@example.com" [ref=f35e235] + - cell "1" [ref=f35e236] + - cell "84.97 EUR" [ref=f35e237] + - cell "Opted out" [ref=f35e238] + - cell "Jul 26, 2026" [ref=f35e240] + - row [ref=f35e241]: + - cell [ref=f35e242]: + - link "Anna Thomas" [ref=f35e243] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/10 + - cell "anna@example.com" [ref=f35e244] + - cell "1" [ref=f35e245] + - cell "50.00 EUR" [ref=f35e246] + - cell "Opted in" [ref=f35e247] + - cell "Jul 26, 2026" [ref=f35e249] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-55-44-134Z.yml b/.playwright-mcp/page-2026-07-26T08-55-44-134Z.yml new file mode 100644 index 00000000..58ed7b52 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-55-44-134Z.yml @@ -0,0 +1,537 @@ +- generic [active] [ref=f36e1]: + - link "Skip to main content" [ref=f36e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f36e3]: + - complementary "Admin navigation" [ref=f36e4]: + - generic [ref=f36e5]: + - link "Acme Fashion" [ref=f36e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f36e12]: + - navigation [ref=f36e13]: + - link "Dashboard" [ref=f36e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f36e19]: Products + - navigation [ref=f36e20]: + - link "Products" [ref=f36e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f36e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f36e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f36e36]: Orders + - navigation [ref=f36e37]: + - link "Orders" [ref=f36e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f36e43]: Customers + - navigation [ref=f36e44]: + - link "Customers" [ref=f36e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f36e50]: Discounts + - navigation [ref=f36e51]: + - link "Discounts" [ref=f36e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f36e58]: Content + - navigation [ref=f36e59]: + - link "Pages" [ref=f36e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f36e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f36e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f36e75]: + - link "Analytics" [ref=f36e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f36e82]: Settings + - navigation [ref=f36e83]: + - link "Settings" [ref=f36e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f36e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f36e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f36e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f36e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f36e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f36e115]: + - banner [ref=f36e116]: + - button "Acme Fashion" [ref=f36e118] + - button "Notifications" [ref=f36e123] + - button "AU Admin User" [ref=f36e127]: + - generic [ref=f36e128]: AU + - generic [ref=f36e131]: Admin User + - main [ref=f36e135]: + - generic [ref=f36e136]: + - link "Home" [ref=f36e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f36e141]: Settings + - generic [ref=f36e143]: + - generic [ref=f36e144]: Settings + - tablist [ref=f36e145]: + - tab "General" [selected] [ref=f36e146] + - tab "Domains" [ref=f36e147] + - tab "Checkout" [ref=f36e148] + - tab "Notifications" [ref=f36e149] + - link "Shipping" [ref=f36e150] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f36e151] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - generic [ref=f36e152]: + - generic [ref=f36e153]: + - generic [ref=f36e154]: + - generic [ref=f36e155]: Store details + - paragraph [ref=f36e156]: Basic information about your store. + - generic [ref=f36e157]: + - generic [ref=f36e158]: + - generic [ref=f36e159]: Store name + - textbox "Store name" [ref=f36e161]: Acme Fashion + - generic [ref=f36e162]: + - generic [ref=f36e163]: Contact email + - textbox "Contact email" [ref=f36e165]: + - /placeholder: hello@example.com + - text: hello@acme-fashion.test + - generic [ref=f36e166]: + - generic [ref=f36e167]: + - generic [ref=f36e168]: Defaults + - paragraph [ref=f36e169]: Currency, language, and timezone settings. + - generic [ref=f36e170]: + - generic [ref=f36e171]: + - generic [ref=f36e172]: Default currency + - combobox "Default currency" [ref=f36e173]: + - option "EUR" [selected] + - option "USD" + - option "GBP" + - option "CHF" + - option "SEK" + - option "PLN" + - generic [ref=f36e174]: + - generic [ref=f36e175]: Default locale + - combobox "Default locale" [ref=f36e176]: + - option "English" [selected] + - option "German" + - option "French" + - generic [ref=f36e177]: + - generic [ref=f36e178]: Timezone + - combobox "Timezone" [ref=f36e179]: + - option "Africa/Abidjan" + - option "Africa/Accra" + - option "Africa/Addis_Ababa" + - option "Africa/Algiers" + - option "Africa/Asmara" + - option "Africa/Bamako" + - option "Africa/Bangui" + - option "Africa/Banjul" + - option "Africa/Bissau" + - option "Africa/Blantyre" + - option "Africa/Brazzaville" + - option "Africa/Bujumbura" + - option "Africa/Cairo" + - option "Africa/Casablanca" + - option "Africa/Ceuta" + - option "Africa/Conakry" + - option "Africa/Dakar" + - option "Africa/Dar_es_Salaam" + - option "Africa/Djibouti" + - option "Africa/Douala" + - option "Africa/El_Aaiun" + - option "Africa/Freetown" + - option "Africa/Gaborone" + - option "Africa/Harare" + - option "Africa/Johannesburg" + - option "Africa/Juba" + - option "Africa/Kampala" + - option "Africa/Khartoum" + - option "Africa/Kigali" + - option "Africa/Kinshasa" + - option "Africa/Lagos" + - option "Africa/Libreville" + - option "Africa/Lome" + - option "Africa/Luanda" + - option "Africa/Lubumbashi" + - option "Africa/Lusaka" + - option "Africa/Malabo" + - option "Africa/Maputo" + - option "Africa/Maseru" + - option "Africa/Mbabane" + - option "Africa/Mogadishu" + - option "Africa/Monrovia" + - option "Africa/Nairobi" + - option "Africa/Ndjamena" + - option "Africa/Niamey" + - option "Africa/Nouakchott" + - option "Africa/Ouagadougou" + - option "Africa/Porto-Novo" + - option "Africa/Sao_Tome" + - option "Africa/Tripoli" + - option "Africa/Tunis" + - option "Africa/Windhoek" + - option "America/Adak" + - option "America/Anchorage" + - option "America/Anguilla" + - option "America/Antigua" + - option "America/Araguaina" + - option "America/Argentina/Buenos_Aires" + - option "America/Argentina/Catamarca" + - option "America/Argentina/Cordoba" + - option "America/Argentina/Jujuy" + - option "America/Argentina/La_Rioja" + - option "America/Argentina/Mendoza" + - option "America/Argentina/Rio_Gallegos" + - option "America/Argentina/Salta" + - option "America/Argentina/San_Juan" + - option "America/Argentina/San_Luis" + - option "America/Argentina/Tucuman" + - option "America/Argentina/Ushuaia" + - option "America/Aruba" + - option "America/Asuncion" + - option "America/Atikokan" + - option "America/Bahia" + - option "America/Bahia_Banderas" + - option "America/Barbados" + - option "America/Belem" + - option "America/Belize" + - option "America/Blanc-Sablon" + - option "America/Boa_Vista" + - option "America/Bogota" + - option "America/Boise" + - option "America/Cambridge_Bay" + - option "America/Campo_Grande" + - option "America/Cancun" + - option "America/Caracas" + - option "America/Cayenne" + - option "America/Cayman" + - option "America/Chicago" + - option "America/Chihuahua" + - option "America/Ciudad_Juarez" + - option "America/Costa_Rica" + - option "America/Coyhaique" + - option "America/Creston" + - option "America/Cuiaba" + - option "America/Curacao" + - option "America/Danmarkshavn" + - option "America/Dawson" + - option "America/Dawson_Creek" + - option "America/Denver" + - option "America/Detroit" + - option "America/Dominica" + - option "America/Edmonton" + - option "America/Eirunepe" + - option "America/El_Salvador" + - option "America/Fort_Nelson" + - option "America/Fortaleza" + - option "America/Glace_Bay" + - option "America/Goose_Bay" + - option "America/Grand_Turk" + - option "America/Grenada" + - option "America/Guadeloupe" + - option "America/Guatemala" + - option "America/Guayaquil" + - option "America/Guyana" + - option "America/Halifax" + - option "America/Havana" + - option "America/Hermosillo" + - option "America/Indiana/Indianapolis" + - option "America/Indiana/Knox" + - option "America/Indiana/Marengo" + - option "America/Indiana/Petersburg" + - option "America/Indiana/Tell_City" + - option "America/Indiana/Vevay" + - option "America/Indiana/Vincennes" + - option "America/Indiana/Winamac" + - option "America/Inuvik" + - option "America/Iqaluit" + - option "America/Jamaica" + - option "America/Juneau" + - option "America/Kentucky/Louisville" + - option "America/Kentucky/Monticello" + - option "America/Kralendijk" + - option "America/La_Paz" + - option "America/Lima" + - option "America/Los_Angeles" + - option "America/Lower_Princes" + - option "America/Maceio" + - option "America/Managua" + - option "America/Manaus" + - option "America/Marigot" + - option "America/Martinique" + - option "America/Matamoros" + - option "America/Mazatlan" + - option "America/Menominee" + - option "America/Merida" + - option "America/Metlakatla" + - option "America/Mexico_City" + - option "America/Miquelon" + - option "America/Moncton" + - option "America/Monterrey" + - option "America/Montevideo" + - option "America/Montserrat" + - option "America/Nassau" + - option "America/New_York" + - option "America/Nome" + - option "America/Noronha" + - option "America/North_Dakota/Beulah" + - option "America/North_Dakota/Center" + - option "America/North_Dakota/New_Salem" + - option "America/Nuuk" + - option "America/Ojinaga" + - option "America/Panama" + - option "America/Paramaribo" + - option "America/Phoenix" + - option "America/Port-au-Prince" + - option "America/Port_of_Spain" + - option "America/Porto_Velho" + - option "America/Puerto_Rico" + - option "America/Punta_Arenas" + - option "America/Rankin_Inlet" + - option "America/Recife" + - option "America/Regina" + - option "America/Resolute" + - option "America/Rio_Branco" + - option "America/Santarem" + - option "America/Santiago" + - option "America/Santo_Domingo" + - option "America/Sao_Paulo" + - option "America/Scoresbysund" + - option "America/Sitka" + - option "America/St_Barthelemy" + - option "America/St_Johns" + - option "America/St_Kitts" + - option "America/St_Lucia" + - option "America/St_Thomas" + - option "America/St_Vincent" + - option "America/Swift_Current" + - option "America/Tegucigalpa" + - option "America/Thule" + - option "America/Tijuana" + - option "America/Toronto" + - option "America/Tortola" + - option "America/Vancouver" + - option "America/Whitehorse" + - option "America/Winnipeg" + - option "America/Yakutat" + - option "Antarctica/Casey" + - option "Antarctica/Davis" + - option "Antarctica/DumontDUrville" + - option "Antarctica/Macquarie" + - option "Antarctica/Mawson" + - option "Antarctica/McMurdo" + - option "Antarctica/Palmer" + - option "Antarctica/Rothera" + - option "Antarctica/Syowa" + - option "Antarctica/Troll" + - option "Antarctica/Vostok" + - option "Arctic/Longyearbyen" + - option "Asia/Aden" + - option "Asia/Almaty" + - option "Asia/Amman" + - option "Asia/Anadyr" + - option "Asia/Aqtau" + - option "Asia/Aqtobe" + - option "Asia/Ashgabat" + - option "Asia/Atyrau" + - option "Asia/Baghdad" + - option "Asia/Bahrain" + - option "Asia/Baku" + - option "Asia/Bangkok" + - option "Asia/Barnaul" + - option "Asia/Beirut" + - option "Asia/Bishkek" + - option "Asia/Brunei" + - option "Asia/Chita" + - option "Asia/Colombo" + - option "Asia/Damascus" + - option "Asia/Dhaka" + - option "Asia/Dili" + - option "Asia/Dubai" + - option "Asia/Dushanbe" + - option "Asia/Famagusta" + - option "Asia/Gaza" + - option "Asia/Hebron" + - option "Asia/Ho_Chi_Minh" + - option "Asia/Hong_Kong" + - option "Asia/Hovd" + - option "Asia/Irkutsk" + - option "Asia/Jakarta" + - option "Asia/Jayapura" + - option "Asia/Jerusalem" + - option "Asia/Kabul" + - option "Asia/Kamchatka" + - option "Asia/Karachi" + - option "Asia/Kathmandu" + - option "Asia/Khandyga" + - option "Asia/Kolkata" + - option "Asia/Krasnoyarsk" + - option "Asia/Kuala_Lumpur" + - option "Asia/Kuching" + - option "Asia/Kuwait" + - option "Asia/Macau" + - option "Asia/Magadan" + - option "Asia/Makassar" + - option "Asia/Manila" + - option "Asia/Muscat" + - option "Asia/Nicosia" + - option "Asia/Novokuznetsk" + - option "Asia/Novosibirsk" + - option "Asia/Omsk" + - option "Asia/Oral" + - option "Asia/Phnom_Penh" + - option "Asia/Pontianak" + - option "Asia/Pyongyang" + - option "Asia/Qatar" + - option "Asia/Qostanay" + - option "Asia/Qyzylorda" + - option "Asia/Riyadh" + - option "Asia/Sakhalin" + - option "Asia/Samarkand" + - option "Asia/Seoul" + - option "Asia/Shanghai" + - option "Asia/Singapore" + - option "Asia/Srednekolymsk" + - option "Asia/Taipei" + - option "Asia/Tashkent" + - option "Asia/Tbilisi" + - option "Asia/Tehran" + - option "Asia/Thimphu" + - option "Asia/Tokyo" + - option "Asia/Tomsk" + - option "Asia/Ulaanbaatar" + - option "Asia/Urumqi" + - option "Asia/Ust-Nera" + - option "Asia/Vientiane" + - option "Asia/Vladivostok" + - option "Asia/Yakutsk" + - option "Asia/Yangon" + - option "Asia/Yekaterinburg" + - option "Asia/Yerevan" + - option "Atlantic/Azores" + - option "Atlantic/Bermuda" + - option "Atlantic/Canary" + - option "Atlantic/Cape_Verde" + - option "Atlantic/Faroe" + - option "Atlantic/Madeira" + - option "Atlantic/Reykjavik" + - option "Atlantic/South_Georgia" + - option "Atlantic/St_Helena" + - option "Atlantic/Stanley" + - option "Australia/Adelaide" + - option "Australia/Brisbane" + - option "Australia/Broken_Hill" + - option "Australia/Darwin" + - option "Australia/Eucla" + - option "Australia/Hobart" + - option "Australia/Lindeman" + - option "Australia/Lord_Howe" + - option "Australia/Melbourne" + - option "Australia/Perth" + - option "Australia/Sydney" + - option "Europe/Amsterdam" + - option "Europe/Andorra" + - option "Europe/Astrakhan" + - option "Europe/Athens" + - option "Europe/Belgrade" + - option "Europe/Berlin" [selected] + - option "Europe/Bratislava" + - option "Europe/Brussels" + - option "Europe/Bucharest" + - option "Europe/Budapest" + - option "Europe/Busingen" + - option "Europe/Chisinau" + - option "Europe/Copenhagen" + - option "Europe/Dublin" + - option "Europe/Gibraltar" + - option "Europe/Guernsey" + - option "Europe/Helsinki" + - option "Europe/Isle_of_Man" + - option "Europe/Istanbul" + - option "Europe/Jersey" + - option "Europe/Kaliningrad" + - option "Europe/Kirov" + - option "Europe/Kyiv" + - option "Europe/Lisbon" + - option "Europe/Ljubljana" + - option "Europe/London" + - option "Europe/Luxembourg" + - option "Europe/Madrid" + - option "Europe/Malta" + - option "Europe/Mariehamn" + - option "Europe/Minsk" + - option "Europe/Monaco" + - option "Europe/Moscow" + - option "Europe/Oslo" + - option "Europe/Paris" + - option "Europe/Podgorica" + - option "Europe/Prague" + - option "Europe/Riga" + - option "Europe/Rome" + - option "Europe/Samara" + - option "Europe/San_Marino" + - option "Europe/Sarajevo" + - option "Europe/Saratov" + - option "Europe/Simferopol" + - option "Europe/Skopje" + - option "Europe/Sofia" + - option "Europe/Stockholm" + - option "Europe/Tallinn" + - option "Europe/Tirane" + - option "Europe/Ulyanovsk" + - option "Europe/Vaduz" + - option "Europe/Vatican" + - option "Europe/Vienna" + - option "Europe/Vilnius" + - option "Europe/Volgograd" + - option "Europe/Warsaw" + - option "Europe/Zagreb" + - option "Europe/Zurich" + - option "Indian/Antananarivo" + - option "Indian/Chagos" + - option "Indian/Christmas" + - option "Indian/Cocos" + - option "Indian/Comoro" + - option "Indian/Kerguelen" + - option "Indian/Mahe" + - option "Indian/Maldives" + - option "Indian/Mauritius" + - option "Indian/Mayotte" + - option "Indian/Reunion" + - option "Pacific/Apia" + - option "Pacific/Auckland" + - option "Pacific/Bougainville" + - option "Pacific/Chatham" + - option "Pacific/Chuuk" + - option "Pacific/Easter" + - option "Pacific/Efate" + - option "Pacific/Fakaofo" + - option "Pacific/Fiji" + - option "Pacific/Funafuti" + - option "Pacific/Galapagos" + - option "Pacific/Gambier" + - option "Pacific/Guadalcanal" + - option "Pacific/Guam" + - option "Pacific/Honolulu" + - option "Pacific/Kanton" + - option "Pacific/Kiritimati" + - option "Pacific/Kosrae" + - option "Pacific/Kwajalein" + - option "Pacific/Majuro" + - option "Pacific/Marquesas" + - option "Pacific/Midway" + - option "Pacific/Nauru" + - option "Pacific/Niue" + - option "Pacific/Norfolk" + - option "Pacific/Noumea" + - option "Pacific/Pago_Pago" + - option "Pacific/Palau" + - option "Pacific/Pitcairn" + - option "Pacific/Pohnpei" + - option "Pacific/Port_Moresby" + - option "Pacific/Rarotonga" + - option "Pacific/Saipan" + - option "Pacific/Tahiti" + - option "Pacific/Tarawa" + - option "Pacific/Tongatapu" + - option "Pacific/Wake" + - option "Pacific/Wallis" + - option "UTC" + - button "Save" [ref=f36e181] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-55-55-091Z.yml b/.playwright-mcp/page-2026-07-26T08-55-55-091Z.yml new file mode 100644 index 00000000..dafb7e11 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-55-55-091Z.yml @@ -0,0 +1,170 @@ +- generic [active] [ref=f38e1]: + - link "Skip to main content" [ref=f38e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f38e3]: + - complementary "Admin navigation" [ref=f38e4]: + - generic [ref=f38e5]: + - link "Acme Fashion" [ref=f38e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f38e12]: + - navigation [ref=f38e13]: + - link "Dashboard" [ref=f38e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f38e19]: Products + - navigation [ref=f38e20]: + - link "Products" [ref=f38e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f38e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f38e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f38e36]: Orders + - navigation [ref=f38e37]: + - link "Orders" [ref=f38e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f38e43]: Customers + - navigation [ref=f38e44]: + - link "Customers" [ref=f38e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f38e50]: Discounts + - navigation [ref=f38e51]: + - link "Discounts" [ref=f38e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f38e58]: Content + - navigation [ref=f38e59]: + - link "Pages" [ref=f38e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f38e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f38e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f38e75]: + - link "Analytics" [ref=f38e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f38e82]: Settings + - navigation [ref=f38e83]: + - link "Settings" [ref=f38e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f38e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f38e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f38e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f38e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f38e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f38e115]: + - banner [ref=f38e116]: + - button "Acme Fashion" [ref=f38e118] + - button "Notifications" [ref=f38e123] + - button "AU Admin User" [ref=f38e127]: + - generic [ref=f38e128]: AU + - generic [ref=f38e131]: Admin User + - main [ref=f38e135]: + - generic [ref=f38e136]: + - link "Home" [ref=f38e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - link "Settings" [ref=f38e142] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - generic [ref=f38e145]: Shipping + - generic [ref=f38e147]: + - generic [ref=f38e148]: + - generic [ref=f38e149]: Shipping + - button "Add zone" [ref=f38e150] + - generic [ref=f38e158]: + - generic [ref=f38e159]: + - generic [ref=f38e160]: + - generic [ref=f38e161]: + - generic [ref=f38e162]: Domestic + - paragraph [ref=f38e163]: "Countries: DE" + - generic [ref=f38e164]: + - button "Edit Domestic" [ref=f38e165] + - button "Delete Domestic" [ref=f38e172] + - table [ref=f38e180]: + - rowgroup [ref=f38e181]: + - row [ref=f38e182]: + - columnheader "Name" [ref=f38e183] + - columnheader "Type" [ref=f38e184] + - columnheader "Config" [ref=f38e185] + - columnheader "Active" [ref=f38e186] + - columnheader "Actions" [ref=f38e187] + - rowgroup [ref=f38e189]: + - row [ref=f38e190]: + - cell "Standard Shipping" [ref=f38e191] + - cell "flat" [ref=f38e192] + - cell "4.99" [ref=f38e194] + - cell [ref=f38e195]: + - switch "Toggle Standard Shipping" [checked] [ref=f38e196] + - cell [ref=f38e198]: + - generic [ref=f38e199]: + - button "Edit Standard Shipping" [ref=f38e200] + - button "Delete Standard Shipping" [ref=f38e207] + - row [ref=f38e214]: + - cell "Express Shipping" [ref=f38e215] + - cell "flat" [ref=f38e216] + - cell "9.99" [ref=f38e218] + - cell [ref=f38e219]: + - switch "Toggle Express Shipping" [checked] [ref=f38e220] + - cell [ref=f38e222]: + - generic [ref=f38e223]: + - button "Edit Express Shipping" [ref=f38e224] + - button "Delete Express Shipping" [ref=f38e231] + - button "Add rate" [ref=f38e239] + - generic [ref=f38e247]: + - generic [ref=f38e248]: + - generic [ref=f38e249]: + - generic [ref=f38e250]: EU + - paragraph [ref=f38e251]: "Countries: AT, FR, IT, ES, NL, BE, PL" + - generic [ref=f38e252]: + - button "Edit EU" [ref=f38e253] + - button "Delete EU" [ref=f38e260] + - table [ref=f38e268]: + - rowgroup [ref=f38e269]: + - row [ref=f38e270]: + - columnheader "Name" [ref=f38e271] + - columnheader "Type" [ref=f38e272] + - columnheader "Config" [ref=f38e273] + - columnheader "Active" [ref=f38e274] + - columnheader "Actions" [ref=f38e275] + - rowgroup [ref=f38e277]: + - row [ref=f38e278]: + - cell "EU Standard" [ref=f38e279] + - cell "flat" [ref=f38e280] + - cell "8.99" [ref=f38e282] + - cell [ref=f38e283]: + - switch "Toggle EU Standard" [checked] [ref=f38e284] + - cell [ref=f38e286]: + - generic [ref=f38e287]: + - button "Edit EU Standard" [ref=f38e288] + - button "Delete EU Standard" [ref=f38e295] + - button "Add rate" [ref=f38e303] + - generic [ref=f38e311]: + - generic [ref=f38e312]: + - generic [ref=f38e313]: + - generic [ref=f38e314]: Rest of World + - paragraph [ref=f38e315]: "Countries: US, GB, CA, AU" + - generic [ref=f38e316]: + - button "Edit Rest of World" [ref=f38e317] + - button "Delete Rest of World" [ref=f38e324] + - table [ref=f38e332]: + - rowgroup [ref=f38e333]: + - row [ref=f38e334]: + - columnheader "Name" [ref=f38e335] + - columnheader "Type" [ref=f38e336] + - columnheader "Config" [ref=f38e337] + - columnheader "Active" [ref=f38e338] + - columnheader "Actions" [ref=f38e339] + - rowgroup [ref=f38e341]: + - row [ref=f38e342]: + - cell "International" [ref=f38e343] + - cell "flat" [ref=f38e344] + - cell "14.99" [ref=f38e346] + - cell [ref=f38e347]: + - switch "Toggle International" [checked] [ref=f38e348] + - cell [ref=f38e350]: + - generic [ref=f38e351]: + - button "Edit International" [ref=f38e352] + - button "Delete International" [ref=f38e359] + - button "Add rate" [ref=f38e367] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-56-06-976Z.yml b/.playwright-mcp/page-2026-07-26T08-56-06-976Z.yml new file mode 100644 index 00000000..110063fb --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-56-06-976Z.yml @@ -0,0 +1,107 @@ +- generic [active] [ref=f39e1]: + - link "Skip to main content" [ref=f39e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f39e3]: + - complementary "Admin navigation" [ref=f39e4]: + - generic [ref=f39e5]: + - link "Acme Fashion" [ref=f39e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f39e12]: + - navigation [ref=f39e13]: + - link "Dashboard" [ref=f39e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f39e19]: Products + - navigation [ref=f39e20]: + - link "Products" [ref=f39e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f39e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f39e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f39e36]: Orders + - navigation [ref=f39e37]: + - link "Orders" [ref=f39e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f39e43]: Customers + - navigation [ref=f39e44]: + - link "Customers" [ref=f39e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f39e50]: Discounts + - navigation [ref=f39e51]: + - link "Discounts" [ref=f39e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f39e58]: Content + - navigation [ref=f39e59]: + - link "Pages" [ref=f39e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f39e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f39e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f39e75]: + - link "Analytics" [ref=f39e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f39e82]: Settings + - navigation [ref=f39e83]: + - link "Settings" [ref=f39e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f39e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f39e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f39e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f39e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f39e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f39e115]: + - banner [ref=f39e116]: + - button "Acme Fashion" [ref=f39e118] + - button "Notifications" [ref=f39e123] + - button "AU Admin User" [ref=f39e127]: + - generic [ref=f39e128]: AU + - generic [ref=f39e131]: Admin User + - main [ref=f39e135]: + - generic [ref=f39e136]: + - link "Home" [ref=f39e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - link "Settings" [ref=f39e142] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - generic [ref=f39e145]: Taxes + - generic [ref=f39e147]: + - generic [ref=f39e148]: Taxes + - generic [ref=f39e149]: + - generic [ref=f39e150]: Tax mode + - generic [ref=f39e151]: + - generic [ref=f39e152]: + - radio "Manual tax rates" [ref=f39e153] + - generic [ref=f39e155]: Manual tax rates + - generic [ref=f39e156]: Define tax rates per zone manually. + - generic [ref=f39e157]: + - radio "Tax provider" [ref=f39e158] + - generic [ref=f39e160]: Tax provider + - generic [ref=f39e161]: Use an automated tax calculation service. + - generic [ref=f39e162]: + - generic [ref=f39e163]: Rates + - generic [ref=f39e164]: + - generic [ref=f39e165]: Default rate (basis points) + - spinbutton "Default rate (basis points)" [ref=f39e167] + - generic [ref=f39e168]: 1900 = 19.00%. Applied when no zone override matches. + - paragraph [ref=f39e169]: Zone overrides + - paragraph [ref=f39e170]: Optional per-zone rates. Leave empty to use the default rate. + - generic [ref=f39e171]: + - generic [ref=f39e172]: + - generic [ref=f39e173]: Domestic + - spinbutton "Domestic" [ref=f39e175] + - generic [ref=f39e176]: + - generic [ref=f39e177]: EU + - spinbutton "EU" [ref=f39e179] + - generic [ref=f39e180]: + - generic [ref=f39e181]: Rest of World + - spinbutton "Rest of World" [ref=f39e183] + - generic [ref=f39e185]: + - generic [ref=f39e186]: Prices include tax + - generic [ref=f39e187]: When enabled, the listed price includes tax. Tax is calculated backwards from the price. + - switch "Prices include tax" [ref=f39e188] + - button "Save" [ref=f39e191] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-57-34-782Z.yml b/.playwright-mcp/page-2026-07-26T08-57-34-782Z.yml new file mode 100644 index 00000000..056ab0a6 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-57-34-782Z.yml @@ -0,0 +1,107 @@ +- generic [active] [ref=f43e1]: + - link "Skip to main content" [ref=f43e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f43e3]: + - complementary "Admin navigation" [ref=f43e4]: + - generic [ref=f43e5]: + - link "Acme Fashion" [ref=f43e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f43e12]: + - navigation [ref=f43e13]: + - link "Dashboard" [ref=f43e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f43e19]: Products + - navigation [ref=f43e20]: + - link "Products" [ref=f43e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f43e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f43e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f43e36]: Orders + - navigation [ref=f43e37]: + - link "Orders" [ref=f43e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f43e43]: Customers + - navigation [ref=f43e44]: + - link "Customers" [ref=f43e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f43e50]: Discounts + - navigation [ref=f43e51]: + - link "Discounts" [ref=f43e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f43e58]: Content + - navigation [ref=f43e59]: + - link "Pages" [ref=f43e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f43e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f43e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f43e75]: + - link "Analytics" [ref=f43e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f43e82]: Settings + - navigation [ref=f43e83]: + - link "Settings" [ref=f43e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f43e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f43e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f43e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f43e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f43e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f43e115]: + - banner [ref=f43e116]: + - button "Acme Fashion" [ref=f43e118] + - button "Notifications" [ref=f43e123] + - button "AU Admin User" [ref=f43e127]: + - generic [ref=f43e128]: AU + - generic [ref=f43e131]: Admin User + - main [ref=f43e135]: + - generic [ref=f43e136]: + - link "Home" [ref=f43e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - link "Settings" [ref=f43e142] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - generic [ref=f43e145]: Taxes + - generic [ref=f43e147]: + - generic [ref=f43e148]: Taxes + - generic [ref=f43e149]: + - generic [ref=f43e150]: Tax mode + - radiogroup [ref=f43e152]: + - generic [ref=f43e153]: + - radio "Manual tax rates" [checked] [ref=f43e154] + - generic [ref=f43e157]: Manual tax rates + - generic [ref=f43e158]: Define tax rates per zone manually. + - generic [ref=f43e159]: + - radio "Tax provider" [ref=f43e160] + - generic [ref=f43e162]: Tax provider + - generic [ref=f43e163]: Use an automated tax calculation service. + - generic [ref=f43e164]: + - generic [ref=f43e165]: Rates + - generic [ref=f43e166]: + - generic [ref=f43e167]: Default rate (basis points) + - spinbutton "Default rate (basis points)" [ref=f43e169]: "1900" + - generic [ref=f43e170]: 1900 = 19.00%. Applied when no zone override matches. + - paragraph [ref=f43e171]: Zone overrides + - paragraph [ref=f43e172]: Optional per-zone rates. Leave empty to use the default rate. + - generic [ref=f43e173]: + - generic [ref=f43e174]: + - generic [ref=f43e175]: Domestic + - spinbutton "Domestic" [ref=f43e177] + - generic [ref=f43e178]: + - generic [ref=f43e179]: EU + - spinbutton "EU" [ref=f43e181] + - generic [ref=f43e182]: + - generic [ref=f43e183]: Rest of World + - spinbutton "Rest of World" [ref=f43e185] + - generic [ref=f43e187]: + - generic [ref=f43e188]: Prices include tax + - generic [ref=f43e189]: When enabled, the listed price includes tax. Tax is calculated backwards from the price. + - switch "Prices include tax" [checked] [ref=f43e190] + - button "Save" [ref=f43e193] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-57-46-804Z.yml b/.playwright-mcp/page-2026-07-26T08-57-46-804Z.yml new file mode 100644 index 00000000..ad8e0686 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-57-46-804Z.yml @@ -0,0 +1,84 @@ +- generic [active] [ref=f44e1]: + - link "Skip to main content" [ref=f44e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f44e3]: + - complementary "Admin navigation" [ref=f44e4]: + - generic [ref=f44e5]: + - link "Acme Fashion" [ref=f44e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f44e12]: + - navigation [ref=f44e13]: + - link "Dashboard" [ref=f44e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f44e19]: Products + - navigation [ref=f44e20]: + - link "Products" [ref=f44e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f44e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f44e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f44e36]: Orders + - navigation [ref=f44e37]: + - link "Orders" [ref=f44e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f44e43]: Customers + - navigation [ref=f44e44]: + - link "Customers" [ref=f44e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f44e50]: Discounts + - navigation [ref=f44e51]: + - link "Discounts" [ref=f44e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f44e58]: Content + - navigation [ref=f44e59]: + - link "Pages" [ref=f44e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f44e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f44e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f44e75]: + - link "Analytics" [ref=f44e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f44e82]: Settings + - navigation [ref=f44e83]: + - link "Settings" [ref=f44e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f44e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f44e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f44e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f44e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f44e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f44e115]: + - banner [ref=f44e116]: + - button "Acme Fashion" [ref=f44e118] + - button "Notifications" [ref=f44e123] + - button "AU Admin User" [ref=f44e127]: + - generic [ref=f44e128]: AU + - generic [ref=f44e131]: Admin User + - main [ref=f44e135]: + - generic [ref=f44e136]: + - link "Home" [ref=f44e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f44e141]: Themes + - generic [ref=f44e143]: + - generic [ref=f44e144]: + - generic [ref=f44e145]: Themes + - button "Add theme" [ref=f44e146] + - generic [ref=f44e159]: + - generic [ref=f44e160]: + - generic [ref=f44e161]: Default Theme + - paragraph [ref=f44e162]: v1.0.0 + - generic [ref=f44e163]: + - generic [ref=f44e164]: Published + - paragraph [ref=f44e165]: 43 minutes ago + - generic [ref=f44e166]: + - link "Customize" [ref=f44e167] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes/1/editor + - button "More actions for Default Theme" [ref=f44e169] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-57-56-521Z.yml b/.playwright-mcp/page-2026-07-26T08-57-56-521Z.yml new file mode 100644 index 00000000..98e72d5d --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-57-56-521Z.yml @@ -0,0 +1,145 @@ +- generic [active] [ref=f46e1]: + - link "Skip to main content" [ref=f46e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f46e3]: + - complementary "Admin navigation" [ref=f46e4]: + - generic [ref=f46e5]: + - link "Acme Fashion" [ref=f46e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f46e12]: + - navigation [ref=f46e13]: + - link "Dashboard" [ref=f46e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f46e19]: Products + - navigation [ref=f46e20]: + - link "Products" [ref=f46e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f46e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f46e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f46e36]: Orders + - navigation [ref=f46e37]: + - link "Orders" [ref=f46e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f46e43]: Customers + - navigation [ref=f46e44]: + - link "Customers" [ref=f46e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f46e50]: Discounts + - navigation [ref=f46e51]: + - link "Discounts" [ref=f46e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f46e58]: Content + - navigation [ref=f46e59]: + - link "Pages" [ref=f46e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f46e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f46e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f46e75]: + - link "Analytics" [ref=f46e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f46e82]: Settings + - navigation [ref=f46e83]: + - link "Settings" [ref=f46e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f46e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f46e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f46e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f46e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f46e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f46e115]: + - banner [ref=f46e116]: + - button "Acme Fashion" [ref=f46e118] + - button "Notifications" [ref=f46e123] + - button "AU Admin User" [ref=f46e127]: + - generic [ref=f46e128]: AU + - generic [ref=f46e131]: Admin User + - main [ref=f46e135]: + - generic [ref=f46e136]: + - link "Home" [ref=f46e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f46e141]: Pages + - generic [ref=f46e143]: + - generic [ref=f46e144]: + - generic [ref=f46e145]: Pages + - link "Add page" [ref=f46e146] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages/create + - textbox "Search pages" [ref=f46e152]: + - /placeholder: Search pages... + - table [ref=f46e155]: + - rowgroup [ref=f46e156]: + - row [ref=f46e157]: + - columnheader "Title" [ref=f46e158] + - columnheader "Handle" [ref=f46e159] + - columnheader "Status" [ref=f46e160] + - columnheader "Updated" [ref=f46e161] + - columnheader "Actions" [ref=f46e162] + - rowgroup [ref=f46e164]: + - row [ref=f46e165]: + - cell [ref=f46e166]: + - link "About Us" [ref=f46e167] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages/1/edit + - cell "about" [ref=f46e168] + - cell "Published" [ref=f46e169] + - cell "43 minutes ago" [ref=f46e171] + - cell [ref=f46e172]: + - generic [ref=f46e173]: + - link "Edit About Us" [ref=f46e174] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages/1/edit + - button "Delete About Us" [ref=f46e177] + - row [ref=f46e184]: + - cell [ref=f46e185]: + - link "FAQ" [ref=f46e186] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages/2/edit + - cell "faq" [ref=f46e187] + - cell "Published" [ref=f46e188] + - cell "43 minutes ago" [ref=f46e190] + - cell [ref=f46e191]: + - generic [ref=f46e192]: + - link "Edit FAQ" [ref=f46e193] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages/2/edit + - button "Delete FAQ" [ref=f46e196] + - row [ref=f46e203]: + - cell [ref=f46e204]: + - link "Shipping & Returns" [ref=f46e205] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages/3/edit + - cell "shipping-returns" [ref=f46e206] + - cell "Published" [ref=f46e207] + - cell "43 minutes ago" [ref=f46e209] + - cell [ref=f46e210]: + - generic [ref=f46e211]: + - link "Edit Shipping & Returns" [ref=f46e212] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages/3/edit + - button "Delete Shipping & Returns" [ref=f46e215] + - row [ref=f46e222]: + - cell [ref=f46e223]: + - link "Privacy Policy" [ref=f46e224] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages/4/edit + - cell "privacy-policy" [ref=f46e225] + - cell "Published" [ref=f46e226] + - cell "43 minutes ago" [ref=f46e228] + - cell [ref=f46e229]: + - generic [ref=f46e230]: + - link "Edit Privacy Policy" [ref=f46e231] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages/4/edit + - button "Delete Privacy Policy" [ref=f46e234] + - row [ref=f46e241]: + - cell [ref=f46e242]: + - link "Terms of Service" [ref=f46e243] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages/5/edit + - cell "terms" [ref=f46e244] + - cell "Published" [ref=f46e245] + - cell "43 minutes ago" [ref=f46e247] + - cell [ref=f46e248]: + - generic [ref=f46e249]: + - link "Edit Terms of Service" [ref=f46e250] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages/5/edit + - button "Delete Terms of Service" [ref=f46e253] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-58-07-680Z.yml b/.playwright-mcp/page-2026-07-26T08-58-07-680Z.yml new file mode 100644 index 00000000..0679fc26 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-58-07-680Z.yml @@ -0,0 +1,125 @@ +- generic [active] [ref=f47e1]: + - link "Skip to main content" [ref=f47e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f47e3]: + - complementary "Admin navigation" [ref=f47e4]: + - generic [ref=f47e5]: + - link "Acme Fashion" [ref=f47e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f47e12]: + - navigation [ref=f47e13]: + - link "Dashboard" [ref=f47e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f47e19]: Products + - navigation [ref=f47e20]: + - link "Products" [ref=f47e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f47e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f47e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f47e36]: Orders + - navigation [ref=f47e37]: + - link "Orders" [ref=f47e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f47e43]: Customers + - navigation [ref=f47e44]: + - link "Customers" [ref=f47e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f47e50]: Discounts + - navigation [ref=f47e51]: + - link "Discounts" [ref=f47e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f47e58]: Content + - navigation [ref=f47e59]: + - link "Pages" [ref=f47e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f47e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f47e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f47e75]: + - link "Analytics" [ref=f47e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f47e82]: Settings + - navigation [ref=f47e83]: + - link "Settings" [ref=f47e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f47e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f47e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f47e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f47e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f47e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f47e115]: + - banner [ref=f47e116]: + - button "Acme Fashion" [ref=f47e118] + - button "Notifications" [ref=f47e123] + - button "AU Admin User" [ref=f47e127]: + - generic [ref=f47e128]: AU + - generic [ref=f47e131]: Admin User + - main [ref=f47e135]: + - generic [ref=f47e136]: + - link "Home" [ref=f47e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f47e141]: Navigation + - generic [ref=f47e143]: + - generic [ref=f47e144]: + - generic [ref=f47e145]: Navigation + - button "Create menu" [ref=f47e146] + - generic [ref=f47e154]: + - button "Main Menu main-menu · 5 items" [ref=f47e155]: + - generic [ref=f47e156]: Main Menu + - generic [ref=f47e157]: main-menu · 5 items + - button "Footer Menu footer-menu · 5 items" [ref=f47e158]: + - generic [ref=f47e159]: Footer Menu + - generic [ref=f47e160]: footer-menu · 5 items + - generic [ref=f47e161]: + - generic [ref=f47e162]: + - generic [ref=f47e163]: Main Menu + - button "Add item" [ref=f47e164] + - list [ref=f47e172]: + - listitem [ref=f47e173]: + - generic [ref=f47e174]: + - generic [ref=f47e175]: Home + - generic [ref=f47e176]: "link: /" + - button "Move Home up" [disabled] + - button "Move Home down" [ref=f47e177] + - button "Edit Home" [ref=f47e184] + - button "Remove Home" [ref=f47e191] + - listitem [ref=f47e198]: + - generic [ref=f47e199]: + - generic [ref=f47e200]: New Arrivals + - generic [ref=f47e201]: "collection: New Arrivals" + - button "Move New Arrivals up" [ref=f47e202] + - button "Move New Arrivals down" [ref=f47e209] + - button "Edit New Arrivals" [ref=f47e216] + - button "Remove New Arrivals" [ref=f47e223] + - listitem [ref=f47e230]: + - generic [ref=f47e231]: + - generic [ref=f47e232]: T-Shirts + - generic [ref=f47e233]: "collection: T-Shirts" + - button "Move T-Shirts up" [ref=f47e234] + - button "Move T-Shirts down" [ref=f47e241] + - button "Edit T-Shirts" [ref=f47e248] + - button "Remove T-Shirts" [ref=f47e255] + - listitem [ref=f47e262]: + - generic [ref=f47e263]: + - generic [ref=f47e264]: Pants & Jeans + - generic [ref=f47e265]: "collection: Pants & Jeans" + - button "Move Pants & Jeans up" [ref=f47e266] + - button "Move Pants & Jeans down" [ref=f47e273] + - button "Edit Pants & Jeans" [ref=f47e280] + - button "Remove Pants & Jeans" [ref=f47e287] + - listitem [ref=f47e294]: + - generic [ref=f47e295]: + - generic [ref=f47e296]: Sale + - generic [ref=f47e297]: "collection: Sale" + - button "Move Sale up" [ref=f47e298] + - button "Move Sale down" [disabled] + - button "Edit Sale" [ref=f47e305] + - button "Remove Sale" [ref=f47e312] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-58-18-576Z.yml b/.playwright-mcp/page-2026-07-26T08-58-18-576Z.yml new file mode 100644 index 00000000..0778ae2e --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-58-18-576Z.yml @@ -0,0 +1,201 @@ +- generic [active] [ref=f48e1]: + - link "Skip to main content" [ref=f48e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f48e3]: + - complementary "Admin navigation" [ref=f48e4]: + - generic [ref=f48e5]: + - link "Acme Fashion" [ref=f48e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f48e12]: + - navigation [ref=f48e13]: + - link "Dashboard" [ref=f48e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f48e19]: Products + - navigation [ref=f48e20]: + - link "Products" [ref=f48e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f48e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f48e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f48e36]: Orders + - navigation [ref=f48e37]: + - link "Orders" [ref=f48e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f48e43]: Customers + - navigation [ref=f48e44]: + - link "Customers" [ref=f48e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f48e50]: Discounts + - navigation [ref=f48e51]: + - link "Discounts" [ref=f48e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f48e58]: Content + - navigation [ref=f48e59]: + - link "Pages" [ref=f48e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f48e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f48e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f48e75]: + - link "Analytics" [ref=f48e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f48e82]: Settings + - navigation [ref=f48e83]: + - link "Settings" [ref=f48e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f48e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f48e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f48e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f48e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f48e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f48e115]: + - banner [ref=f48e116]: + - button "Acme Fashion" [ref=f48e118] + - button "Notifications" [ref=f48e123] + - button "AU Admin User" [ref=f48e127]: + - generic [ref=f48e128]: AU + - generic [ref=f48e131]: Admin User + - main [ref=f48e135]: + - generic [ref=f48e136]: + - link "Home" [ref=f48e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f48e141]: Analytics + - generic [ref=f48e143]: + - generic [ref=f48e144]: + - generic [ref=f48e145]: Analytics + - combobox "Date range" [ref=f48e146]: + - option "Last 7 days" + - option "Last 30 days" [selected] + - option "Last 90 days" + - generic [ref=f48e147]: + - generic [ref=f48e148]: + - paragraph [ref=f48e149]: Total Sales + - generic [ref=f48e150]: 9,120.58 EUR + - generic [ref=f48e151]: + - paragraph [ref=f48e152]: Orders + - generic [ref=f48e153]: "147" + - generic [ref=f48e154]: + - paragraph [ref=f48e155]: Avg. Order Value + - generic [ref=f48e156]: 62.04 EUR + - generic [ref=f48e157]: + - paragraph [ref=f48e158]: Conversion Rate + - generic [ref=f48e159]: 4.3% + - generic [ref=f48e160]: + - generic [ref=f48e161]: Sales over time + - generic [ref=f48e162]: + - img "Daily revenue for the selected period" [ref=f48e163] + - generic [ref=f48e165]: + - generic [ref=f48e166]: 2026-06-27 + - generic [ref=f48e167]: 2026-07-26 + - generic [ref=f48e168]: + - generic [ref=f48e169]: + - generic [ref=f48e170]: Visits over time + - generic [ref=f48e171]: + - img "Daily visits for the selected period" [ref=f48e172] + - generic [ref=f48e174]: + - generic [ref=f48e175]: 2026-06-27 + - generic [ref=f48e176]: 2026-07-26 + - generic [ref=f48e177]: + - generic [ref=f48e178]: Conversion funnel + - generic [ref=f48e179]: + - generic [ref=f48e180]: + - generic [ref=f48e181]: + - paragraph [ref=f48e182]: Visits + - paragraph [ref=f48e183]: 3,387 (100%) + - 'img "Visits: 3387" [ref=f48e184]' + - generic [ref=f48e186]: + - generic [ref=f48e187]: + - paragraph [ref=f48e188]: Added to cart + - paragraph [ref=f48e189]: 749 (22.1%) + - 'img "Added to cart: 749" [ref=f48e190]' + - generic [ref=f48e192]: + - generic [ref=f48e193]: + - paragraph [ref=f48e194]: Checkout started + - paragraph [ref=f48e195]: 352 (10.4%) + - 'img "Checkout started: 352" [ref=f48e196]' + - generic [ref=f48e198]: + - generic [ref=f48e199]: + - paragraph [ref=f48e200]: Checkout completed + - paragraph [ref=f48e201]: 147 (4.3%) + - 'img "Checkout completed: 147" [ref=f48e202]' + - generic [ref=f48e204]: + - generic [ref=f48e205]: Top products + - table [ref=f48e207]: + - rowgroup [ref=f48e208]: + - row [ref=f48e209]: + - columnheader "Rank" [ref=f48e210] + - columnheader "Product" [ref=f48e211] + - columnheader "Units Sold" [ref=f48e212] + - columnheader "Revenue" [ref=f48e213] + - columnheader "% of Total" [ref=f48e214] + - rowgroup [ref=f48e215]: + - row [ref=f48e216]: + - cell "1" [ref=f48e217] + - cell "Cashmere Overcoat" [ref=f48e218] + - cell "1" [ref=f48e219] + - cell "499.99 EUR" [ref=f48e220] + - cell "32.1%" [ref=f48e221] + - row [ref=f48e222]: + - cell "2" [ref=f48e223] + - cell "Classic Cotton T-Shirt" [ref=f48e224] + - cell "5" [ref=f48e225] + - cell "124.95 EUR" [ref=f48e226] + - cell "8%" [ref=f48e227] + - row [ref=f48e228]: + - cell "3" [ref=f48e229] + - cell "Running Sneakers" [ref=f48e230] + - cell "1" [ref=f48e231] + - cell "119.99 EUR" [ref=f48e232] + - cell "7.7%" [ref=f48e233] + - row [ref=f48e234]: + - cell "4" [ref=f48e235] + - cell "Premium Slim Fit Jeans" [ref=f48e236] + - cell "1" [ref=f48e237] + - cell "79.99 EUR" [ref=f48e238] + - cell "5.1%" [ref=f48e239] + - row [ref=f48e240]: + - cell "5" [ref=f48e241] + - cell "Premium Slim Fit Jeans - 28 / Blue" [ref=f48e242] + - cell "1" [ref=f48e243] + - cell "79.99 EUR" [ref=f48e244] + - cell "5.1%" [ref=f48e245] + - row [ref=f48e246]: + - cell "6" [ref=f48e247] + - cell "Chino Shorts" [ref=f48e248] + - cell "2" [ref=f48e249] + - cell "79.98 EUR" [ref=f48e250] + - cell "5.1%" [ref=f48e251] + - row [ref=f48e252]: + - cell "7" [ref=f48e253] + - cell "Leather Belt" [ref=f48e254] + - cell "2" [ref=f48e255] + - cell "69.98 EUR" [ref=f48e256] + - cell "4.5%" [ref=f48e257] + - row [ref=f48e258]: + - cell "8" [ref=f48e259] + - cell "V-Neck Linen Tee" [ref=f48e260] + - cell "2" [ref=f48e261] + - cell "69.98 EUR" [ref=f48e262] + - cell "4.5%" [ref=f48e263] + - row [ref=f48e264]: + - cell "9" [ref=f48e265] + - cell "Organic Hoodie" [ref=f48e266] + - cell "1" [ref=f48e267] + - cell "59.99 EUR" [ref=f48e268] + - cell "3.9%" [ref=f48e269] + - row [ref=f48e270]: + - cell "10" [ref=f48e271] + - cell "Graphic Print Tee" [ref=f48e272] + - cell "2" [ref=f48e273] + - cell "59.98 EUR" [ref=f48e274] + - cell "3.9%" [ref=f48e275] + - generic [ref=f48e276]: + - generic [ref=f48e277]: Recent search queries + - paragraph [ref=f48e278]: No search queries yet. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-58-42-913Z.yml b/.playwright-mcp/page-2026-07-26T08-58-42-913Z.yml new file mode 100644 index 00000000..226e5bd6 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-58-42-913Z.yml @@ -0,0 +1,105 @@ +- generic [active] [ref=f50e1]: + - link "Skip to main content" [ref=f50e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f50e3]: + - complementary "Admin navigation" [ref=f50e4]: + - generic [ref=f50e5]: + - link "Acme Fashion" [ref=f50e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f50e12]: + - navigation [ref=f50e13]: + - link "Dashboard" [ref=f50e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f50e19]: Products + - navigation [ref=f50e20]: + - link "Products" [ref=f50e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f50e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f50e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f50e36]: Orders + - navigation [ref=f50e37]: + - link "Orders" [ref=f50e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f50e43]: Customers + - navigation [ref=f50e44]: + - link "Customers" [ref=f50e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f50e50]: Discounts + - navigation [ref=f50e51]: + - link "Discounts" [ref=f50e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f50e58]: Content + - navigation [ref=f50e59]: + - link "Pages" [ref=f50e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f50e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f50e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f50e75]: + - link "Analytics" [ref=f50e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f50e82]: Settings + - navigation [ref=f50e83]: + - link "Settings" [ref=f50e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f50e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f50e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f50e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f50e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f50e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f50e115]: + - banner [ref=f50e116]: + - button "Acme Fashion" [ref=f50e118] + - button "Notifications" [ref=f50e123] + - button "AU Admin User" [ref=f50e127]: + - generic [ref=f50e128]: AU + - generic [ref=f50e131]: Admin User + - main [ref=f50e135]: + - generic [ref=f50e136]: + - link "Home" [ref=f50e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - link "Search" [ref=f50e142] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - generic [ref=f50e145]: Settings + - generic [ref=f50e147]: + - generic [ref=f50e148]: Search Settings + - generic [ref=f50e149]: + - generic [ref=f50e150]: Synonyms + - paragraph [ref=f50e151]: Define groups of words that should be treated as equivalent. + - generic [ref=f50e152]: + - generic [ref=f50e153]: + - textbox "t-shirt, tee, tshirt" [ref=f50e156]: tee, t-shirt, tshirt + - button "Remove synonym group" [ref=f50e157] + - generic [ref=f50e164]: + - textbox "t-shirt, tee, tshirt" [ref=f50e167]: pants, trousers, jeans + - button "Remove synonym group" [ref=f50e168] + - generic [ref=f50e175]: + - textbox "t-shirt, tee, tshirt" [ref=f50e178]: sneakers, trainers, shoes + - button "Remove synonym group" [ref=f50e179] + - generic [ref=f50e186]: + - textbox "t-shirt, tee, tshirt" [ref=f50e189]: hoodie, sweatshirt + - button "Remove synonym group" [ref=f50e190] + - button "Add synonym group" [ref=f50e197] + - generic [ref=f50e205]: + - generic [ref=f50e206]: Stop words + - paragraph [ref=f50e207]: Words that are excluded from search. + - generic [ref=f50e208]: + - textbox "the, a, an, is, are..." [ref=f50e209]: the, a, an, and, or, but, in, on, at, to, for, of, is + - generic [ref=f50e210]: Separate words with commas. + - generic [ref=f50e211]: + - generic [ref=f50e212]: Search index + - generic [ref=f50e213]: + - button "Reindex now" [ref=f50e214] + - paragraph [ref=f50e220]: "Last indexed: Sun, Jul 26, 2026 8:14 AM" + - generic [ref=f50e221]: + - generic [ref=f50e222]: Recent search queries + - paragraph [ref=f50e223]: No searches recorded yet. + - button "Save" [ref=f50e225] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T08-58-53-186Z.yml b/.playwright-mcp/page-2026-07-26T08-58-53-186Z.yml new file mode 100644 index 00000000..ef85ed19 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T08-58-53-186Z.yml @@ -0,0 +1,93 @@ +- generic [active] [ref=f51e1]: + - link "Skip to main content" [ref=f51e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f51e3]: + - complementary "Admin navigation" [ref=f51e4]: + - generic [ref=f51e5]: + - link "Acme Fashion" [ref=f51e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f51e12]: + - navigation [ref=f51e13]: + - link "Dashboard" [ref=f51e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f51e19]: Products + - navigation [ref=f51e20]: + - link "Products" [ref=f51e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f51e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f51e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f51e36]: Orders + - navigation [ref=f51e37]: + - link "Orders" [ref=f51e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f51e43]: Customers + - navigation [ref=f51e44]: + - link "Customers" [ref=f51e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f51e50]: Discounts + - navigation [ref=f51e51]: + - link "Discounts" [ref=f51e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f51e58]: Content + - navigation [ref=f51e59]: + - link "Pages" [ref=f51e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f51e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f51e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f51e75]: + - link "Analytics" [ref=f51e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f51e82]: Settings + - navigation [ref=f51e83]: + - link "Settings" [ref=f51e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f51e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f51e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f51e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f51e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f51e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f51e115]: + - banner [ref=f51e116]: + - button "Acme Fashion" [ref=f51e118] + - button "Notifications" [ref=f51e123] + - button "AU Admin User" [ref=f51e127]: + - generic [ref=f51e128]: AU + - generic [ref=f51e131]: Admin User + - main [ref=f51e135]: + - generic [ref=f51e136]: + - link "Home" [ref=f51e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f51e141]: Apps + - generic [ref=f51e143]: + - generic [ref=f51e144]: Apps + - generic [ref=f51e146]: + - generic [ref=f51e147]: No apps installed + - paragraph [ref=f51e148]: Installed apps will appear here. Install one from the catalog below to extend your store. + - generic [ref=f51e149]: + - generic [ref=f51e150]: + - generic [ref=f51e151]: Available apps + - paragraph [ref=f51e152]: Apps that can be installed on this store. + - generic [ref=f51e153]: + - generic [ref=f51e157]: + - generic [ref=f51e158]: My Integration App + - paragraph [ref=f51e159]: Syncs products and orders with your external systems. + - button "Install" [ref=f51e160] + - generic [ref=f51e166]: + - generic [ref=f51e170]: + - generic [ref=f51e171]: Analytics Plugin + - paragraph [ref=f51e172]: Sends storefront and order events to your analytics warehouse. + - button "Install" [ref=f51e173] + - generic [ref=f51e179]: + - generic [ref=f51e183]: + - generic [ref=f51e184]: Review Connector + - paragraph [ref=f51e185]: Imports product reviews from your review provider. + - button "Install" [ref=f51e186] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-00-54-347Z.yml b/.playwright-mcp/page-2026-07-26T09-00-54-347Z.yml new file mode 100644 index 00000000..8b39efd6 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-00-54-347Z.yml @@ -0,0 +1,93 @@ +- generic [active] [ref=f53e1]: + - link "Skip to main content" [ref=f53e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f53e3]: + - complementary "Admin navigation" [ref=f53e4]: + - generic [ref=f53e5]: + - link "Acme Fashion" [ref=f53e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f53e12]: + - navigation [ref=f53e13]: + - link "Dashboard" [ref=f53e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f53e19]: Products + - navigation [ref=f53e20]: + - link "Products" [ref=f53e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f53e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f53e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f53e36]: Orders + - navigation [ref=f53e37]: + - link "Orders" [ref=f53e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f53e43]: Customers + - navigation [ref=f53e44]: + - link "Customers" [ref=f53e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f53e50]: Discounts + - navigation [ref=f53e51]: + - link "Discounts" [ref=f53e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f53e58]: Content + - navigation [ref=f53e59]: + - link "Pages" [ref=f53e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f53e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f53e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f53e75]: + - link "Analytics" [ref=f53e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f53e82]: Settings + - navigation [ref=f53e83]: + - link "Settings" [ref=f53e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f53e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f53e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f53e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f53e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f53e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f53e115]: + - banner [ref=f53e116]: + - button "Acme Fashion" [ref=f53e118] + - button "Notifications" [ref=f53e123] + - button "AU Admin User" [ref=f53e127]: + - generic [ref=f53e128]: AU + - generic [ref=f53e131]: Admin User + - main [ref=f53e135]: + - generic [ref=f53e136]: + - link "Home" [ref=f53e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f53e141]: Apps + - generic [ref=f53e143]: + - generic [ref=f53e144]: Apps + - generic [ref=f53e146]: + - generic [ref=f53e147]: No apps installed + - paragraph [ref=f53e148]: Installed apps will appear here. Install one from the catalog below to extend your store. + - generic [ref=f53e149]: + - generic [ref=f53e150]: + - generic [ref=f53e151]: Available apps + - paragraph [ref=f53e152]: Apps that can be installed on this store. + - generic [ref=f53e153]: + - generic [ref=f53e157]: + - generic [ref=f53e158]: My Integration App + - paragraph [ref=f53e159]: Syncs products and orders with your external systems. + - button "Install" [ref=f53e160] + - generic [ref=f53e166]: + - generic [ref=f53e170]: + - generic [ref=f53e171]: Analytics Plugin + - paragraph [ref=f53e172]: Sends storefront and order events to your analytics warehouse. + - button "Install" [ref=f53e173] + - generic [ref=f53e179]: + - generic [ref=f53e183]: + - generic [ref=f53e184]: Review Connector + - paragraph [ref=f53e185]: Imports product reviews from your review provider. + - button "Install" [ref=f53e186] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-01-20-904Z.yml b/.playwright-mcp/page-2026-07-26T09-01-20-904Z.yml new file mode 100644 index 00000000..078d453e --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-01-20-904Z.yml @@ -0,0 +1,94 @@ +- generic [ref=f53e1]: + - link "Skip to main content" [ref=f53e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f53e3]: + - complementary "Admin navigation" [ref=f53e4]: + - generic [ref=f53e5]: + - link "Acme Fashion" [ref=f53e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f53e12]: + - navigation [ref=f53e13]: + - link "Dashboard" [ref=f53e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f53e19]: Products + - navigation [ref=f53e20]: + - link "Products" [ref=f53e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f53e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f53e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f53e36]: Orders + - navigation [ref=f53e37]: + - link "Orders" [ref=f53e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f53e43]: Customers + - navigation [ref=f53e44]: + - link "Customers" [ref=f53e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f53e50]: Discounts + - navigation [ref=f53e51]: + - link "Discounts" [ref=f53e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f53e58]: Content + - navigation [ref=f53e59]: + - link "Pages" [ref=f53e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f53e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f53e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f53e75]: + - link "Analytics" [ref=f53e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f53e82]: Settings + - navigation [ref=f53e83]: + - link "Settings" [ref=f53e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f53e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f53e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f53e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f53e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f53e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f53e115]: + - banner [ref=f53e116]: + - button "Acme Fashion" [ref=f53e118] + - button "Notifications" [ref=f53e123] + - button "AU Admin User" [ref=f53e127]: + - generic [ref=f53e128]: AU + - generic [ref=f53e131]: Admin User + - main [ref=f53e135]: + - generic [ref=f53e136]: + - link "Home" [ref=f53e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f53e141]: Apps + - generic [ref=f53e143]: + - generic [ref=f53e144]: Apps + - link "My Integration App Installed 0 seconds ago Active" [ref=f53e192] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps/1 + - generic [ref=f53e196]: + - generic [ref=f53e197]: My Integration App + - paragraph [ref=f53e198]: Installed 0 seconds ago + - generic [ref=f53e199]: Active + - generic [ref=f53e149]: + - generic [ref=f53e150]: + - generic [ref=f53e151]: Available apps + - paragraph [ref=f53e152]: Apps that can be installed on this store. + - generic [ref=f53e153]: + - generic [ref=f53e157]: + - generic [ref=f53e158]: Analytics Plugin + - paragraph [ref=f53e159]: Sends storefront and order events to your analytics warehouse. + - button "Install" [active] [ref=f53e160] + - generic [ref=f53e166]: + - generic [ref=f53e170]: + - generic [ref=f53e171]: Review Connector + - paragraph [ref=f53e172]: Imports product reviews from your review provider. + - button "Install" [ref=f53e173] + - alert [ref=f53e200]: + - paragraph [ref=f53e203]: My Integration App installed + - button "Dismiss" [ref=f53e204] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-01-44-615Z.yml b/.playwright-mcp/page-2026-07-26T09-01-44-615Z.yml new file mode 100644 index 00000000..4b2ed700 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-01-44-615Z.yml @@ -0,0 +1,101 @@ +- generic [active] [ref=f54e1]: + - link "Skip to main content" [ref=f54e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f54e3]: + - complementary "Admin navigation" [ref=f54e4]: + - generic [ref=f54e5]: + - link "Acme Fashion" [ref=f54e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f54e12]: + - navigation [ref=f54e13]: + - link "Dashboard" [ref=f54e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f54e19]: Products + - navigation [ref=f54e20]: + - link "Products" [ref=f54e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f54e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f54e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f54e36]: Orders + - navigation [ref=f54e37]: + - link "Orders" [ref=f54e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f54e43]: Customers + - navigation [ref=f54e44]: + - link "Customers" [ref=f54e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f54e50]: Discounts + - navigation [ref=f54e51]: + - link "Discounts" [ref=f54e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f54e58]: Content + - navigation [ref=f54e59]: + - link "Pages" [ref=f54e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f54e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f54e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f54e75]: + - link "Analytics" [ref=f54e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f54e82]: Settings + - navigation [ref=f54e83]: + - link "Settings" [ref=f54e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f54e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f54e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f54e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f54e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f54e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f54e115]: + - banner [ref=f54e116]: + - button "Acme Fashion" [ref=f54e118] + - button "Notifications" [ref=f54e123] + - button "AU Admin User" [ref=f54e127]: + - generic [ref=f54e128]: AU + - generic [ref=f54e131]: Admin User + - main [ref=f54e135]: + - generic [ref=f54e136]: + - link "Home" [ref=f54e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f54e141]: Developers + - generic [ref=f54e143]: + - generic [ref=f54e144]: Developers + - generic [ref=f54e145]: + - generic [ref=f54e146]: API tokens + - paragraph [ref=f54e147]: Manage personal access tokens for the Admin API. + - table [ref=f54e149]: + - rowgroup [ref=f54e150]: + - row [ref=f54e151]: + - columnheader "Name" [ref=f54e152] + - columnheader "Abilities" [ref=f54e153] + - columnheader "Last used" [ref=f54e154] + - columnheader "Expires" [ref=f54e155] + - columnheader "Created" [ref=f54e156] + - columnheader "Actions" [ref=f54e157] + - rowgroup [ref=f54e158]: + - row [ref=f54e159]: + - cell "No API tokens yet." [ref=f54e160] + - button "Generate new token" [ref=f54e162] + - generic [ref=f54e168]: + - generic [ref=f54e169]: Webhooks + - paragraph [ref=f54e170]: Manage webhook subscriptions for real-time event notifications. + - table [ref=f54e172]: + - rowgroup [ref=f54e173]: + - row [ref=f54e174]: + - columnheader "Event type" [ref=f54e175] + - columnheader "URL" [ref=f54e176] + - columnheader "Status" [ref=f54e177] + - columnheader "Actions" [ref=f54e178] + - rowgroup [ref=f54e179]: + - row [ref=f54e180]: + - cell "No webhooks configured." [ref=f54e181] + - button "Add webhook" [ref=f54e183] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-02-14-367Z.yml b/.playwright-mcp/page-2026-07-26T09-02-14-367Z.yml new file mode 100644 index 00000000..2e93ed90 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-02-14-367Z.yml @@ -0,0 +1,173 @@ +- generic [active] [ref=f54e1]: + - link "Skip to main content" [ref=f54e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f54e3]: + - complementary "Admin navigation" [ref=f54e4]: + - generic [ref=f54e5]: + - link "Acme Fashion" [ref=f54e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f54e12]: + - navigation [ref=f54e13]: + - link "Dashboard" [ref=f54e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f54e19]: Products + - navigation [ref=f54e20]: + - link "Products" [ref=f54e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f54e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f54e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f54e36]: Orders + - navigation [ref=f54e37]: + - link "Orders" [ref=f54e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f54e43]: Customers + - navigation [ref=f54e44]: + - link "Customers" [ref=f54e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f54e50]: Discounts + - navigation [ref=f54e51]: + - link "Discounts" [ref=f54e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f54e58]: Content + - navigation [ref=f54e59]: + - link "Pages" [ref=f54e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f54e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f54e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f54e75]: + - link "Analytics" [ref=f54e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f54e82]: Settings + - navigation [ref=f54e83]: + - link "Settings" [ref=f54e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f54e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f54e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f54e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f54e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f54e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f54e115]: + - banner [ref=f54e116]: + - button "Acme Fashion" [ref=f54e118] + - button "Notifications" [ref=f54e123] + - button "AU Admin User" [ref=f54e127]: + - generic [ref=f54e128]: AU + - generic [ref=f54e131]: Admin User + - main [ref=f54e135]: + - generic [ref=f54e136]: + - link "Home" [ref=f54e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f54e141]: Developers + - generic [ref=f54e143]: + - generic [ref=f54e144]: Developers + - generic [ref=f54e145]: + - generic [ref=f54e146]: API tokens + - paragraph [ref=f54e147]: Manage personal access tokens for the Admin API. + - table [ref=f54e149]: + - rowgroup [ref=f54e150]: + - row [ref=f54e151]: + - columnheader "Name" [ref=f54e152] + - columnheader "Abilities" [ref=f54e153] + - columnheader "Last used" [ref=f54e154] + - columnheader "Expires" [ref=f54e155] + - columnheader "Created" [ref=f54e156] + - columnheader "Actions" [ref=f54e157] + - rowgroup [ref=f54e158]: + - row [ref=f54e159]: + - cell "No API tokens yet." [ref=f54e160] + - button "Generate new token" [ref=f54e162] + - generic [ref=f54e168]: + - generic [ref=f54e169]: Webhooks + - paragraph [ref=f54e170]: Manage webhook subscriptions for real-time event notifications. + - table [ref=f54e172]: + - rowgroup [ref=f54e173]: + - row [ref=f54e174]: + - columnheader "Event type" [ref=f54e175] + - columnheader "URL" [ref=f54e176] + - columnheader "Status" [ref=f54e177] + - columnheader "Actions" [ref=f54e178] + - rowgroup [ref=f54e179]: + - row [ref=f54e180]: + - cell "No webhooks configured." [ref=f54e181] + - button "Add webhook" [ref=f54e183] + - dialog [ref=f54e191]: + - generic [ref=f54e192]: + - generic [ref=f54e193]: Generate API token + - generic [ref=f54e194]: + - generic [ref=f54e195]: Token name + - textbox "Token name" [ref=f54e197]: + - /placeholder: My integration + - generic [ref=f54e198]: + - generic [ref=f54e199]: Abilities + - generic [ref=f54e200]: + - generic [ref=f54e201]: + - checkbox "Read products" [ref=f54e202] + - generic [ref=f54e204]: Read products + - generic [ref=f54e205]: + - checkbox "Write products" [ref=f54e206] + - generic [ref=f54e208]: Write products + - generic [ref=f54e209]: + - checkbox "Read orders" [ref=f54e210] + - generic [ref=f54e212]: Read orders + - generic [ref=f54e213]: + - checkbox "Write orders" [ref=f54e214] + - generic [ref=f54e216]: Write orders + - generic [ref=f54e217]: + - checkbox "Read customers" [ref=f54e218] + - generic [ref=f54e220]: Read customers + - generic [ref=f54e221]: + - checkbox "Write customers" [ref=f54e222] + - generic [ref=f54e224]: Write customers + - generic [ref=f54e225]: + - checkbox "Read collections" [ref=f54e226] + - generic [ref=f54e228]: Read collections + - generic [ref=f54e229]: + - checkbox "Write collections" [ref=f54e230] + - generic [ref=f54e232]: Write collections + - generic [ref=f54e233]: + - checkbox "Read discounts" [ref=f54e234] + - generic [ref=f54e236]: Read discounts + - generic [ref=f54e237]: + - checkbox "Write discounts" [ref=f54e238] + - generic [ref=f54e240]: Write discounts + - generic [ref=f54e241]: + - checkbox "Read analytics" [ref=f54e242] + - generic [ref=f54e244]: Read analytics + - generic [ref=f54e245]: + - checkbox "Read settings" [ref=f54e246] + - generic [ref=f54e248]: Read settings + - generic [ref=f54e249]: + - checkbox "Write settings" [ref=f54e250] + - generic [ref=f54e252]: Write settings + - generic [ref=f54e253]: + - checkbox "Read themes" [ref=f54e254] + - generic [ref=f54e256]: Read themes + - generic [ref=f54e257]: + - checkbox "Write themes" [ref=f54e258] + - generic [ref=f54e260]: Write themes + - generic [ref=f54e261]: + - checkbox "Read content" [ref=f54e262] + - generic [ref=f54e264]: Read content + - generic [ref=f54e265]: + - checkbox "Write content" [ref=f54e266] + - generic [ref=f54e268]: Write content + - generic [ref=f54e269]: + - checkbox "Manage platform" [ref=f54e270] + - generic [ref=f54e272]: Manage platform + - generic [ref=f54e273]: + - generic [ref=f54e274]: Expires at (optional) + - textbox "Expires at (optional)" [ref=f54e276] + - generic [ref=f54e277]: Defaults to one year from now. + - generic [ref=f54e278]: + - button "Cancel" [ref=f54e279] + - button "Generate" [ref=f54e285] + - button "Close modal" [ref=f54e293] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-02-58-344Z.yml b/.playwright-mcp/page-2026-07-26T09-02-58-344Z.yml new file mode 100644 index 00000000..cfcd03c4 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-02-58-344Z.yml @@ -0,0 +1,193 @@ +- generic [ref=f54e1]: + - link "Skip to main content" [ref=f54e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f54e3]: + - complementary "Admin navigation" [ref=f54e4]: + - generic [ref=f54e5]: + - link "Acme Fashion" [ref=f54e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f54e12]: + - navigation [ref=f54e13]: + - link "Dashboard" [ref=f54e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f54e19]: Products + - navigation [ref=f54e20]: + - link "Products" [ref=f54e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f54e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f54e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f54e36]: Orders + - navigation [ref=f54e37]: + - link "Orders" [ref=f54e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f54e43]: Customers + - navigation [ref=f54e44]: + - link "Customers" [ref=f54e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f54e50]: Discounts + - navigation [ref=f54e51]: + - link "Discounts" [ref=f54e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f54e58]: Content + - navigation [ref=f54e59]: + - link "Pages" [ref=f54e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f54e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f54e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f54e75]: + - link "Analytics" [ref=f54e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f54e82]: Settings + - navigation [ref=f54e83]: + - link "Settings" [ref=f54e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f54e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f54e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f54e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f54e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f54e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f54e115]: + - banner [ref=f54e116]: + - button "Acme Fashion" [ref=f54e118] + - button "Notifications" [ref=f54e123] + - button "AU Admin User" [ref=f54e127]: + - generic [ref=f54e128]: AU + - generic [ref=f54e131]: Admin User + - main [ref=f54e135]: + - generic [ref=f54e136]: + - link "Home" [ref=f54e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f54e141]: Developers + - generic [ref=f54e143]: + - generic [ref=f54e144]: Developers + - generic [ref=f54e145]: + - generic [ref=f54e146]: API tokens + - paragraph [ref=f54e147]: Manage personal access tokens for the Admin API. + - table [ref=f54e149]: + - rowgroup [ref=f54e150]: + - row [ref=f54e151]: + - columnheader "Name" [ref=f54e152] + - columnheader "Abilities" [ref=f54e153] + - columnheader "Last used" [ref=f54e154] + - columnheader "Expires" [ref=f54e155] + - columnheader "Created" [ref=f54e156] + - columnheader "Actions" [ref=f54e157] + - rowgroup [ref=f54e158]: + - row [ref=f54e159]: + - cell "No API tokens yet." [ref=f54e160] + - button "Generate new token" [ref=f54e162] + - generic [ref=f54e168]: + - generic [ref=f54e169]: Webhooks + - paragraph [ref=f54e170]: Manage webhook subscriptions for real-time event notifications. + - table [ref=f54e172]: + - rowgroup [ref=f54e173]: + - row [ref=f54e174]: + - columnheader "Event type" [ref=f54e175] + - columnheader "URL" [ref=f54e176] + - columnheader "Status" [ref=f54e177] + - columnheader "Actions" [ref=f54e178] + - rowgroup [ref=f54e179]: + - row [ref=f54e180]: + - cell "No webhooks configured." [ref=f54e181] + - button "Add webhook" [ref=f54e183] + - dialog [ref=f54e191]: + - generic [ref=f54e192]: + - generic [ref=f54e193]: Generate API token + - generic [ref=f54e194]: + - generic [ref=f54e195]: Token name + - textbox "Token name" [ref=f54e197]: + - /placeholder: My integration + - text: E2E Test Token + - generic [ref=f54e198]: + - generic [ref=f54e199]: Abilities + - generic [ref=f54e200]: + - generic [ref=f54e201]: + - checkbox "Read products" [ref=f54e202] + - generic [ref=f54e204]: Read products + - alert [ref=f54e296]: The new token abilities field is required. + - generic [ref=f54e205]: + - checkbox "Write products" [ref=f54e206] + - generic [ref=f54e208]: Write products + - alert [ref=f54e299]: The new token abilities field is required. + - generic [ref=f54e209]: + - checkbox "Read orders" [ref=f54e210] + - generic [ref=f54e212]: Read orders + - alert [ref=f54e302]: The new token abilities field is required. + - generic [ref=f54e213]: + - checkbox "Write orders" [ref=f54e214] + - generic [ref=f54e216]: Write orders + - alert [ref=f54e305]: The new token abilities field is required. + - generic [ref=f54e217]: + - checkbox "Read customers" [ref=f54e218] + - generic [ref=f54e220]: Read customers + - alert [ref=f54e308]: The new token abilities field is required. + - generic [ref=f54e221]: + - checkbox "Write customers" [ref=f54e222] + - generic [ref=f54e224]: Write customers + - alert [ref=f54e311]: The new token abilities field is required. + - generic [ref=f54e225]: + - checkbox "Read collections" [ref=f54e226] + - generic [ref=f54e228]: Read collections + - alert [ref=f54e314]: The new token abilities field is required. + - generic [ref=f54e229]: + - checkbox "Write collections" [ref=f54e230] + - generic [ref=f54e232]: Write collections + - alert [ref=f54e317]: The new token abilities field is required. + - generic [ref=f54e233]: + - checkbox "Read discounts" [ref=f54e234] + - generic [ref=f54e236]: Read discounts + - alert [ref=f54e320]: The new token abilities field is required. + - generic [ref=f54e237]: + - checkbox "Write discounts" [ref=f54e238] + - generic [ref=f54e240]: Write discounts + - alert [ref=f54e323]: The new token abilities field is required. + - generic [ref=f54e241]: + - checkbox "Read analytics" [ref=f54e242] + - generic [ref=f54e244]: Read analytics + - alert [ref=f54e326]: The new token abilities field is required. + - generic [ref=f54e245]: + - checkbox "Read settings" [ref=f54e246] + - generic [ref=f54e248]: Read settings + - alert [ref=f54e329]: The new token abilities field is required. + - generic [ref=f54e249]: + - checkbox "Write settings" [ref=f54e250] + - generic [ref=f54e252]: Write settings + - alert [ref=f54e332]: The new token abilities field is required. + - generic [ref=f54e253]: + - checkbox "Read themes" [ref=f54e254] + - generic [ref=f54e256]: Read themes + - alert [ref=f54e335]: The new token abilities field is required. + - generic [ref=f54e257]: + - checkbox "Write themes" [ref=f54e258] + - generic [ref=f54e260]: Write themes + - alert [ref=f54e338]: The new token abilities field is required. + - generic [ref=f54e261]: + - checkbox "Read content" [ref=f54e262] + - generic [ref=f54e264]: Read content + - alert [ref=f54e341]: The new token abilities field is required. + - generic [ref=f54e265]: + - checkbox "Write content" [ref=f54e266] + - generic [ref=f54e268]: Write content + - alert [ref=f54e344]: The new token abilities field is required. + - generic [ref=f54e269]: + - checkbox "Manage platform" [ref=f54e270] + - generic [ref=f54e272]: Manage platform + - alert [ref=f54e347]: The new token abilities field is required. + - alert [ref=f54e350]: The new token abilities field is required. + - generic [ref=f54e273]: + - generic [ref=f54e274]: Expires at (optional) + - textbox "Expires at (optional)" [ref=f54e276] + - generic [ref=f54e277]: Defaults to one year from now. + - generic [ref=f54e278]: + - button "Cancel" [ref=f54e279] + - button "Generate" [active] [ref=f54e285] + - button "Close modal" [ref=f54e293] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-03-30-149Z.yml b/.playwright-mcp/page-2026-07-26T09-03-30-149Z.yml new file mode 100644 index 00000000..21d18c2d --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-03-30-149Z.yml @@ -0,0 +1,193 @@ +- generic [ref=f54e1]: + - link "Skip to main content" [ref=f54e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f54e3]: + - complementary "Admin navigation" [ref=f54e4]: + - generic [ref=f54e5]: + - link "Acme Fashion" [ref=f54e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f54e12]: + - navigation [ref=f54e13]: + - link "Dashboard" [ref=f54e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f54e19]: Products + - navigation [ref=f54e20]: + - link "Products" [ref=f54e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f54e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f54e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f54e36]: Orders + - navigation [ref=f54e37]: + - link "Orders" [ref=f54e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f54e43]: Customers + - navigation [ref=f54e44]: + - link "Customers" [ref=f54e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f54e50]: Discounts + - navigation [ref=f54e51]: + - link "Discounts" [ref=f54e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f54e58]: Content + - navigation [ref=f54e59]: + - link "Pages" [ref=f54e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f54e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f54e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f54e75]: + - link "Analytics" [ref=f54e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f54e82]: Settings + - navigation [ref=f54e83]: + - link "Settings" [ref=f54e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f54e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f54e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f54e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f54e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f54e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f54e115]: + - banner [ref=f54e116]: + - button "Acme Fashion" [ref=f54e118] + - button "Notifications" [ref=f54e123] + - button "AU Admin User" [ref=f54e127]: + - generic [ref=f54e128]: AU + - generic [ref=f54e131]: Admin User + - main [ref=f54e135]: + - generic [ref=f54e136]: + - link "Home" [ref=f54e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f54e141]: Developers + - generic [ref=f54e143]: + - generic [ref=f54e144]: Developers + - generic [ref=f54e145]: + - generic [ref=f54e146]: API tokens + - paragraph [ref=f54e147]: Manage personal access tokens for the Admin API. + - table [ref=f54e149]: + - rowgroup [ref=f54e150]: + - row [ref=f54e151]: + - columnheader "Name" [ref=f54e152] + - columnheader "Abilities" [ref=f54e153] + - columnheader "Last used" [ref=f54e154] + - columnheader "Expires" [ref=f54e155] + - columnheader "Created" [ref=f54e156] + - columnheader "Actions" [ref=f54e157] + - rowgroup [ref=f54e158]: + - row [ref=f54e159]: + - cell "No API tokens yet." [ref=f54e160] + - button "Generate new token" [ref=f54e162] + - generic [ref=f54e168]: + - generic [ref=f54e169]: Webhooks + - paragraph [ref=f54e170]: Manage webhook subscriptions for real-time event notifications. + - table [ref=f54e172]: + - rowgroup [ref=f54e173]: + - row [ref=f54e174]: + - columnheader "Event type" [ref=f54e175] + - columnheader "URL" [ref=f54e176] + - columnheader "Status" [ref=f54e177] + - columnheader "Actions" [ref=f54e178] + - rowgroup [ref=f54e179]: + - row [ref=f54e180]: + - cell "No webhooks configured." [ref=f54e181] + - button "Add webhook" [ref=f54e183] + - dialog [ref=f54e191]: + - generic [ref=f54e192]: + - generic [ref=f54e193]: Generate API token + - generic [ref=f54e194]: + - generic [ref=f54e195]: Token name + - textbox "Token name" [ref=f54e197]: + - /placeholder: My integration + - text: E2E Test Token + - generic [ref=f54e198]: + - generic [ref=f54e199]: Abilities + - generic [ref=f54e200]: + - generic [ref=f54e201]: + - checkbox "Read products" [checked] [active] [ref=f54e202] + - generic [ref=f54e204]: Read products + - alert [ref=f54e296]: The new token abilities field is required. + - generic [ref=f54e205]: + - checkbox "Write products" [ref=f54e206] + - generic [ref=f54e208]: Write products + - alert [ref=f54e299]: The new token abilities field is required. + - generic [ref=f54e209]: + - checkbox "Read orders" [ref=f54e210] + - generic [ref=f54e212]: Read orders + - alert [ref=f54e302]: The new token abilities field is required. + - generic [ref=f54e213]: + - checkbox "Write orders" [ref=f54e214] + - generic [ref=f54e216]: Write orders + - alert [ref=f54e305]: The new token abilities field is required. + - generic [ref=f54e217]: + - checkbox "Read customers" [ref=f54e218] + - generic [ref=f54e220]: Read customers + - alert [ref=f54e308]: The new token abilities field is required. + - generic [ref=f54e221]: + - checkbox "Write customers" [ref=f54e222] + - generic [ref=f54e224]: Write customers + - alert [ref=f54e311]: The new token abilities field is required. + - generic [ref=f54e225]: + - checkbox "Read collections" [ref=f54e226] + - generic [ref=f54e228]: Read collections + - alert [ref=f54e314]: The new token abilities field is required. + - generic [ref=f54e229]: + - checkbox "Write collections" [ref=f54e230] + - generic [ref=f54e232]: Write collections + - alert [ref=f54e317]: The new token abilities field is required. + - generic [ref=f54e233]: + - checkbox "Read discounts" [ref=f54e234] + - generic [ref=f54e236]: Read discounts + - alert [ref=f54e320]: The new token abilities field is required. + - generic [ref=f54e237]: + - checkbox "Write discounts" [ref=f54e238] + - generic [ref=f54e240]: Write discounts + - alert [ref=f54e323]: The new token abilities field is required. + - generic [ref=f54e241]: + - checkbox "Read analytics" [ref=f54e242] + - generic [ref=f54e244]: Read analytics + - alert [ref=f54e326]: The new token abilities field is required. + - generic [ref=f54e245]: + - checkbox "Read settings" [ref=f54e246] + - generic [ref=f54e248]: Read settings + - alert [ref=f54e329]: The new token abilities field is required. + - generic [ref=f54e249]: + - checkbox "Write settings" [ref=f54e250] + - generic [ref=f54e252]: Write settings + - alert [ref=f54e332]: The new token abilities field is required. + - generic [ref=f54e253]: + - checkbox "Read themes" [ref=f54e254] + - generic [ref=f54e256]: Read themes + - alert [ref=f54e335]: The new token abilities field is required. + - generic [ref=f54e257]: + - checkbox "Write themes" [ref=f54e258] + - generic [ref=f54e260]: Write themes + - alert [ref=f54e338]: The new token abilities field is required. + - generic [ref=f54e261]: + - checkbox "Read content" [ref=f54e262] + - generic [ref=f54e264]: Read content + - alert [ref=f54e341]: The new token abilities field is required. + - generic [ref=f54e265]: + - checkbox "Write content" [ref=f54e266] + - generic [ref=f54e268]: Write content + - alert [ref=f54e344]: The new token abilities field is required. + - generic [ref=f54e269]: + - checkbox "Manage platform" [ref=f54e270] + - generic [ref=f54e272]: Manage platform + - alert [ref=f54e347]: The new token abilities field is required. + - alert [ref=f54e350]: The new token abilities field is required. + - generic [ref=f54e273]: + - generic [ref=f54e274]: Expires at (optional) + - textbox "Expires at (optional)" [ref=f54e276] + - generic [ref=f54e277]: Defaults to one year from now. + - generic [ref=f54e278]: + - button "Cancel" [ref=f54e279] + - button "Generate" [ref=f54e285] + - button "Close modal" [ref=f54e293] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-03-41-517Z.yml b/.playwright-mcp/page-2026-07-26T09-03-41-517Z.yml new file mode 100644 index 00000000..9f666d28 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-03-41-517Z.yml @@ -0,0 +1,193 @@ +- generic [ref=f54e1]: + - link "Skip to main content" [ref=f54e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f54e3]: + - complementary "Admin navigation" [ref=f54e4]: + - generic [ref=f54e5]: + - link "Acme Fashion" [ref=f54e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f54e12]: + - navigation [ref=f54e13]: + - link "Dashboard" [ref=f54e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f54e19]: Products + - navigation [ref=f54e20]: + - link "Products" [ref=f54e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f54e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f54e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f54e36]: Orders + - navigation [ref=f54e37]: + - link "Orders" [ref=f54e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f54e43]: Customers + - navigation [ref=f54e44]: + - link "Customers" [ref=f54e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f54e50]: Discounts + - navigation [ref=f54e51]: + - link "Discounts" [ref=f54e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f54e58]: Content + - navigation [ref=f54e59]: + - link "Pages" [ref=f54e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f54e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f54e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f54e75]: + - link "Analytics" [ref=f54e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f54e82]: Settings + - navigation [ref=f54e83]: + - link "Settings" [ref=f54e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f54e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f54e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f54e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f54e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f54e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f54e115]: + - banner [ref=f54e116]: + - button "Acme Fashion" [ref=f54e118] + - button "Notifications" [ref=f54e123] + - button "AU Admin User" [ref=f54e127]: + - generic [ref=f54e128]: AU + - generic [ref=f54e131]: Admin User + - main [ref=f54e135]: + - generic [ref=f54e136]: + - link "Home" [ref=f54e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f54e141]: Developers + - generic [ref=f54e143]: + - generic [ref=f54e144]: Developers + - generic [ref=f54e145]: + - generic [ref=f54e146]: API tokens + - paragraph [ref=f54e147]: Manage personal access tokens for the Admin API. + - table [ref=f54e149]: + - rowgroup [ref=f54e150]: + - row [ref=f54e151]: + - columnheader "Name" [ref=f54e152] + - columnheader "Abilities" [ref=f54e153] + - columnheader "Last used" [ref=f54e154] + - columnheader "Expires" [ref=f54e155] + - columnheader "Created" [ref=f54e156] + - columnheader "Actions" [ref=f54e157] + - rowgroup [ref=f54e158]: + - row [ref=f54e159]: + - cell "No API tokens yet." [ref=f54e160] + - button "Generate new token" [ref=f54e162] + - generic [ref=f54e168]: + - generic [ref=f54e169]: Webhooks + - paragraph [ref=f54e170]: Manage webhook subscriptions for real-time event notifications. + - table [ref=f54e172]: + - rowgroup [ref=f54e173]: + - row [ref=f54e174]: + - columnheader "Event type" [ref=f54e175] + - columnheader "URL" [ref=f54e176] + - columnheader "Status" [ref=f54e177] + - columnheader "Actions" [ref=f54e178] + - rowgroup [ref=f54e179]: + - row [ref=f54e180]: + - cell "No webhooks configured." [ref=f54e181] + - button "Add webhook" [ref=f54e183] + - dialog [ref=f54e191]: + - generic [ref=f54e192]: + - generic [ref=f54e193]: Generate API token + - generic [ref=f54e194]: + - generic [ref=f54e195]: Token name + - textbox "Token name" [ref=f54e197]: + - /placeholder: My integration + - text: E2E Test Token + - generic [ref=f54e198]: + - generic [ref=f54e199]: Abilities + - generic [ref=f54e200]: + - generic [ref=f54e201]: + - checkbox "Read products" [checked] [ref=f54e202] + - generic [ref=f54e204]: Read products + - alert [ref=f54e296]: The new token abilities field is required. + - generic [ref=f54e205]: + - checkbox "Write products" [ref=f54e206] + - generic [ref=f54e208]: Write products + - alert [ref=f54e299]: The new token abilities field is required. + - generic [ref=f54e209]: + - checkbox "Read orders" [checked] [active] [ref=f54e210] + - generic [ref=f54e212]: Read orders + - alert [ref=f54e302]: The new token abilities field is required. + - generic [ref=f54e213]: + - checkbox "Write orders" [ref=f54e214] + - generic [ref=f54e216]: Write orders + - alert [ref=f54e305]: The new token abilities field is required. + - generic [ref=f54e217]: + - checkbox "Read customers" [ref=f54e218] + - generic [ref=f54e220]: Read customers + - alert [ref=f54e308]: The new token abilities field is required. + - generic [ref=f54e221]: + - checkbox "Write customers" [ref=f54e222] + - generic [ref=f54e224]: Write customers + - alert [ref=f54e311]: The new token abilities field is required. + - generic [ref=f54e225]: + - checkbox "Read collections" [ref=f54e226] + - generic [ref=f54e228]: Read collections + - alert [ref=f54e314]: The new token abilities field is required. + - generic [ref=f54e229]: + - checkbox "Write collections" [ref=f54e230] + - generic [ref=f54e232]: Write collections + - alert [ref=f54e317]: The new token abilities field is required. + - generic [ref=f54e233]: + - checkbox "Read discounts" [ref=f54e234] + - generic [ref=f54e236]: Read discounts + - alert [ref=f54e320]: The new token abilities field is required. + - generic [ref=f54e237]: + - checkbox "Write discounts" [ref=f54e238] + - generic [ref=f54e240]: Write discounts + - alert [ref=f54e323]: The new token abilities field is required. + - generic [ref=f54e241]: + - checkbox "Read analytics" [ref=f54e242] + - generic [ref=f54e244]: Read analytics + - alert [ref=f54e326]: The new token abilities field is required. + - generic [ref=f54e245]: + - checkbox "Read settings" [ref=f54e246] + - generic [ref=f54e248]: Read settings + - alert [ref=f54e329]: The new token abilities field is required. + - generic [ref=f54e249]: + - checkbox "Write settings" [ref=f54e250] + - generic [ref=f54e252]: Write settings + - alert [ref=f54e332]: The new token abilities field is required. + - generic [ref=f54e253]: + - checkbox "Read themes" [ref=f54e254] + - generic [ref=f54e256]: Read themes + - alert [ref=f54e335]: The new token abilities field is required. + - generic [ref=f54e257]: + - checkbox "Write themes" [ref=f54e258] + - generic [ref=f54e260]: Write themes + - alert [ref=f54e338]: The new token abilities field is required. + - generic [ref=f54e261]: + - checkbox "Read content" [ref=f54e262] + - generic [ref=f54e264]: Read content + - alert [ref=f54e341]: The new token abilities field is required. + - generic [ref=f54e265]: + - checkbox "Write content" [ref=f54e266] + - generic [ref=f54e268]: Write content + - alert [ref=f54e344]: The new token abilities field is required. + - generic [ref=f54e269]: + - checkbox "Manage platform" [ref=f54e270] + - generic [ref=f54e272]: Manage platform + - alert [ref=f54e347]: The new token abilities field is required. + - alert [ref=f54e350]: The new token abilities field is required. + - generic [ref=f54e273]: + - generic [ref=f54e274]: Expires at (optional) + - textbox "Expires at (optional)" [ref=f54e276] + - generic [ref=f54e277]: Defaults to one year from now. + - generic [ref=f54e278]: + - button "Cancel" [ref=f54e279] + - button "Generate" [ref=f54e285] + - button "Close modal" [ref=f54e293] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-04-13-865Z.yml b/.playwright-mcp/page-2026-07-26T09-04-13-865Z.yml new file mode 100644 index 00000000..78a9f338 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-04-13-865Z.yml @@ -0,0 +1,118 @@ +- generic [ref=f54e1]: + - link "Skip to main content" [ref=f54e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f54e3]: + - complementary "Admin navigation" [ref=f54e4]: + - generic [ref=f54e5]: + - link "Acme Fashion" [ref=f54e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f54e12]: + - navigation [ref=f54e13]: + - link "Dashboard" [ref=f54e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f54e19]: Products + - navigation [ref=f54e20]: + - link "Products" [ref=f54e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f54e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f54e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f54e36]: Orders + - navigation [ref=f54e37]: + - link "Orders" [ref=f54e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f54e43]: Customers + - navigation [ref=f54e44]: + - link "Customers" [ref=f54e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f54e50]: Discounts + - navigation [ref=f54e51]: + - link "Discounts" [ref=f54e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f54e58]: Content + - navigation [ref=f54e59]: + - link "Pages" [ref=f54e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f54e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f54e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f54e75]: + - link "Analytics" [ref=f54e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f54e82]: Settings + - navigation [ref=f54e83]: + - link "Settings" [ref=f54e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f54e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f54e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f54e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f54e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f54e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f54e115]: + - banner [ref=f54e116]: + - button "Acme Fashion" [ref=f54e118] + - button "Notifications" [ref=f54e123] + - button "AU Admin User" [ref=f54e127]: + - generic [ref=f54e128]: AU + - generic [ref=f54e131]: Admin User + - main [ref=f54e135]: + - generic [ref=f54e136]: + - link "Home" [ref=f54e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f54e141]: Developers + - generic [ref=f54e143]: + - generic [ref=f54e144]: Developers + - generic [ref=f54e145]: + - generic [ref=f54e146]: API tokens + - paragraph [ref=f54e147]: Manage personal access tokens for the Admin API. + - generic [ref=f54e362]: + - generic [ref=f54e363]: Copy this token now. It will not be shown again. + - generic [ref=f54e365]: + - code [ref=f54e366]: shop_0LZoxgtB0epf6FUXdZSRkMaXl405b7nhrZy85XUB + - button "Copy" [ref=f54e367] + - table [ref=f54e149]: + - rowgroup [ref=f54e150]: + - row [ref=f54e151]: + - columnheader "Name" [ref=f54e152] + - columnheader "Abilities" [ref=f54e153] + - columnheader "Last used" [ref=f54e154] + - columnheader "Expires" [ref=f54e155] + - columnheader "Created" [ref=f54e156] + - columnheader "Actions" [ref=f54e157] + - rowgroup [ref=f54e158]: + - row [ref=f54e371]: + - cell "E2E Test Token" [ref=f54e372] + - cell "read-products read-orders" [ref=f54e373]: + - generic [ref=f54e374]: + - generic [ref=f54e375]: read-products + - generic [ref=f54e376]: read-orders + - cell "Never" [ref=f54e377] + - cell "Jul 26, 2027" [ref=f54e378] + - cell "Jul 26, 2026" [ref=f54e379] + - cell [ref=f54e380]: + - button "Revoke" [ref=f54e381] + - button "Generate new token" [active] [ref=f54e162] + - generic [ref=f54e168]: + - generic [ref=f54e169]: Webhooks + - paragraph [ref=f54e170]: Manage webhook subscriptions for real-time event notifications. + - table [ref=f54e172]: + - rowgroup [ref=f54e173]: + - row [ref=f54e174]: + - columnheader "Event type" [ref=f54e175] + - columnheader "URL" [ref=f54e176] + - columnheader "Status" [ref=f54e177] + - columnheader "Actions" [ref=f54e178] + - rowgroup [ref=f54e179]: + - row [ref=f54e180]: + - cell "No webhooks configured." [ref=f54e181] + - button "Add webhook" [ref=f54e183] + - alert [ref=f54e387]: + - paragraph [ref=f54e390]: API token created + - button "Dismiss" [ref=f54e391] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-05-58-744Z.yml b/.playwright-mcp/page-2026-07-26T09-05-58-744Z.yml new file mode 100644 index 00000000..e7e5ab21 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-05-58-744Z.yml @@ -0,0 +1,110 @@ +- generic [active] [ref=f55e1]: + - link "Skip to main content" [ref=f55e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f55e3]: + - complementary "Admin navigation" [ref=f55e4]: + - generic [ref=f55e5]: + - link "Acme Fashion" [ref=f55e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f55e12]: + - navigation [ref=f55e13]: + - link "Dashboard" [ref=f55e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f55e19]: Products + - navigation [ref=f55e20]: + - link "Products" [ref=f55e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f55e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f55e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f55e36]: Orders + - navigation [ref=f55e37]: + - link "Orders" [ref=f55e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f55e43]: Customers + - navigation [ref=f55e44]: + - link "Customers" [ref=f55e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f55e50]: Discounts + - navigation [ref=f55e51]: + - link "Discounts" [ref=f55e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f55e58]: Content + - navigation [ref=f55e59]: + - link "Pages" [ref=f55e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f55e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f55e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f55e75]: + - link "Analytics" [ref=f55e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f55e82]: Settings + - navigation [ref=f55e83]: + - link "Settings" [ref=f55e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f55e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f55e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f55e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f55e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f55e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f55e115]: + - banner [ref=f55e116]: + - button "Acme Fashion" [ref=f55e118] + - button "Notifications" [ref=f55e123] + - button "AU Admin User" [ref=f55e127]: + - generic [ref=f55e128]: AU + - generic [ref=f55e131]: Admin User + - main [ref=f55e135]: + - generic [ref=f55e136]: + - link "Home" [ref=f55e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f55e141]: Developers + - generic [ref=f55e143]: + - generic [ref=f55e144]: Developers + - generic [ref=f55e145]: + - generic [ref=f55e146]: API tokens + - paragraph [ref=f55e147]: Manage personal access tokens for the Admin API. + - table [ref=f55e149]: + - rowgroup [ref=f55e150]: + - row [ref=f55e151]: + - columnheader "Name" [ref=f55e152] + - columnheader "Abilities" [ref=f55e153] + - columnheader "Last used" [ref=f55e154] + - columnheader "Expires" [ref=f55e155] + - columnheader "Created" [ref=f55e156] + - columnheader "Actions" [ref=f55e157] + - rowgroup [ref=f55e158]: + - row [ref=f55e159]: + - cell "E2E Test Token" [ref=f55e160] + - cell "read-products read-orders" [ref=f55e161]: + - generic [ref=f55e162]: + - generic [ref=f55e163]: read-products + - generic [ref=f55e164]: read-orders + - cell "Never" [ref=f55e165] + - cell "Jul 26, 2027" [ref=f55e166] + - cell "Jul 26, 2026" [ref=f55e167] + - cell [ref=f55e168]: + - button "Revoke" [ref=f55e169] + - button "Generate new token" [ref=f55e176] + - generic [ref=f55e182]: + - generic [ref=f55e183]: Webhooks + - paragraph [ref=f55e184]: Manage webhook subscriptions for real-time event notifications. + - table [ref=f55e186]: + - rowgroup [ref=f55e187]: + - row [ref=f55e188]: + - columnheader "Event type" [ref=f55e189] + - columnheader "URL" [ref=f55e190] + - columnheader "Status" [ref=f55e191] + - columnheader "Actions" [ref=f55e192] + - rowgroup [ref=f55e193]: + - row [ref=f55e194]: + - cell "No webhooks configured." [ref=f55e195] + - button "Add webhook" [ref=f55e197] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-06-13-197Z.yml b/.playwright-mcp/page-2026-07-26T09-06-13-197Z.yml new file mode 100644 index 00000000..f73fa8ad --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-06-13-197Z.yml @@ -0,0 +1,182 @@ +- generic [active] [ref=f55e1]: + - link "Skip to main content" [ref=f55e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f55e3]: + - complementary "Admin navigation" [ref=f55e4]: + - generic [ref=f55e5]: + - link "Acme Fashion" [ref=f55e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f55e12]: + - navigation [ref=f55e13]: + - link "Dashboard" [ref=f55e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f55e19]: Products + - navigation [ref=f55e20]: + - link "Products" [ref=f55e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f55e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f55e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f55e36]: Orders + - navigation [ref=f55e37]: + - link "Orders" [ref=f55e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f55e43]: Customers + - navigation [ref=f55e44]: + - link "Customers" [ref=f55e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f55e50]: Discounts + - navigation [ref=f55e51]: + - link "Discounts" [ref=f55e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f55e58]: Content + - navigation [ref=f55e59]: + - link "Pages" [ref=f55e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f55e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f55e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f55e75]: + - link "Analytics" [ref=f55e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f55e82]: Settings + - navigation [ref=f55e83]: + - link "Settings" [ref=f55e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f55e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f55e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f55e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f55e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f55e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f55e115]: + - banner [ref=f55e116]: + - button "Acme Fashion" [ref=f55e118] + - button "Notifications" [ref=f55e123] + - button "AU Admin User" [ref=f55e127]: + - generic [ref=f55e128]: AU + - generic [ref=f55e131]: Admin User + - main [ref=f55e135]: + - generic [ref=f55e136]: + - link "Home" [ref=f55e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f55e141]: Developers + - generic [ref=f55e143]: + - generic [ref=f55e144]: Developers + - generic [ref=f55e145]: + - generic [ref=f55e146]: API tokens + - paragraph [ref=f55e147]: Manage personal access tokens for the Admin API. + - table [ref=f55e149]: + - rowgroup [ref=f55e150]: + - row [ref=f55e151]: + - columnheader "Name" [ref=f55e152] + - columnheader "Abilities" [ref=f55e153] + - columnheader "Last used" [ref=f55e154] + - columnheader "Expires" [ref=f55e155] + - columnheader "Created" [ref=f55e156] + - columnheader "Actions" [ref=f55e157] + - rowgroup [ref=f55e158]: + - row [ref=f55e159]: + - cell "E2E Test Token" [ref=f55e160] + - cell "read-products read-orders" [ref=f55e161]: + - generic [ref=f55e162]: + - generic [ref=f55e163]: read-products + - generic [ref=f55e164]: read-orders + - cell "Never" [ref=f55e165] + - cell "Jul 26, 2027" [ref=f55e166] + - cell "Jul 26, 2026" [ref=f55e167] + - cell [ref=f55e168]: + - button "Revoke" [ref=f55e169] + - button "Generate new token" [ref=f55e176] + - generic [ref=f55e182]: + - generic [ref=f55e183]: Webhooks + - paragraph [ref=f55e184]: Manage webhook subscriptions for real-time event notifications. + - table [ref=f55e186]: + - rowgroup [ref=f55e187]: + - row [ref=f55e188]: + - columnheader "Event type" [ref=f55e189] + - columnheader "URL" [ref=f55e190] + - columnheader "Status" [ref=f55e191] + - columnheader "Actions" [ref=f55e192] + - rowgroup [ref=f55e193]: + - row [ref=f55e194]: + - cell "No webhooks configured." [ref=f55e195] + - button "Add webhook" [ref=f55e197] + - dialog [ref=f55e205]: + - generic [ref=f55e206]: + - generic [ref=f55e207]: Generate API token + - generic [ref=f55e208]: + - generic [ref=f55e209]: Token name + - textbox "Token name" [ref=f55e211]: + - /placeholder: My integration + - generic [ref=f55e212]: + - generic [ref=f55e213]: Abilities + - generic [ref=f55e214]: + - generic [ref=f55e215]: + - checkbox "Read products" [ref=f55e216] + - generic [ref=f55e218]: Read products + - generic [ref=f55e219]: + - checkbox "Write products" [ref=f55e220] + - generic [ref=f55e222]: Write products + - generic [ref=f55e223]: + - checkbox "Read orders" [ref=f55e224] + - generic [ref=f55e226]: Read orders + - generic [ref=f55e227]: + - checkbox "Write orders" [ref=f55e228] + - generic [ref=f55e230]: Write orders + - generic [ref=f55e231]: + - checkbox "Read customers" [ref=f55e232] + - generic [ref=f55e234]: Read customers + - generic [ref=f55e235]: + - checkbox "Write customers" [ref=f55e236] + - generic [ref=f55e238]: Write customers + - generic [ref=f55e239]: + - checkbox "Read collections" [ref=f55e240] + - generic [ref=f55e242]: Read collections + - generic [ref=f55e243]: + - checkbox "Write collections" [ref=f55e244] + - generic [ref=f55e246]: Write collections + - generic [ref=f55e247]: + - checkbox "Read discounts" [ref=f55e248] + - generic [ref=f55e250]: Read discounts + - generic [ref=f55e251]: + - checkbox "Write discounts" [ref=f55e252] + - generic [ref=f55e254]: Write discounts + - generic [ref=f55e255]: + - checkbox "Read analytics" [ref=f55e256] + - generic [ref=f55e258]: Read analytics + - generic [ref=f55e259]: + - checkbox "Read settings" [ref=f55e260] + - generic [ref=f55e262]: Read settings + - generic [ref=f55e263]: + - checkbox "Write settings" [ref=f55e264] + - generic [ref=f55e266]: Write settings + - generic [ref=f55e267]: + - checkbox "Read themes" [ref=f55e268] + - generic [ref=f55e270]: Read themes + - generic [ref=f55e271]: + - checkbox "Write themes" [ref=f55e272] + - generic [ref=f55e274]: Write themes + - generic [ref=f55e275]: + - checkbox "Read content" [ref=f55e276] + - generic [ref=f55e278]: Read content + - generic [ref=f55e279]: + - checkbox "Write content" [ref=f55e280] + - generic [ref=f55e282]: Write content + - generic [ref=f55e283]: + - checkbox "Manage platform" [ref=f55e284] + - generic [ref=f55e286]: Manage platform + - generic [ref=f55e287]: + - generic [ref=f55e288]: Expires at (optional) + - textbox "Expires at (optional)" [ref=f55e290] + - generic [ref=f55e291]: Defaults to one year from now. + - generic [ref=f55e292]: + - button "Cancel" [ref=f55e293] + - button "Generate" [ref=f55e299] + - button "Close modal" [ref=f55e307] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-06-36-245Z.yml b/.playwright-mcp/page-2026-07-26T09-06-36-245Z.yml new file mode 100644 index 00000000..48e0bd43 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-06-36-245Z.yml @@ -0,0 +1,183 @@ +- generic [ref=f55e1]: + - link "Skip to main content" [ref=f55e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f55e3]: + - complementary "Admin navigation" [ref=f55e4]: + - generic [ref=f55e5]: + - link "Acme Fashion" [ref=f55e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f55e12]: + - navigation [ref=f55e13]: + - link "Dashboard" [ref=f55e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f55e19]: Products + - navigation [ref=f55e20]: + - link "Products" [ref=f55e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f55e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f55e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f55e36]: Orders + - navigation [ref=f55e37]: + - link "Orders" [ref=f55e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f55e43]: Customers + - navigation [ref=f55e44]: + - link "Customers" [ref=f55e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f55e50]: Discounts + - navigation [ref=f55e51]: + - link "Discounts" [ref=f55e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f55e58]: Content + - navigation [ref=f55e59]: + - link "Pages" [ref=f55e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f55e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f55e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f55e75]: + - link "Analytics" [ref=f55e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f55e82]: Settings + - navigation [ref=f55e83]: + - link "Settings" [ref=f55e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f55e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f55e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f55e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f55e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f55e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f55e115]: + - banner [ref=f55e116]: + - button "Acme Fashion" [ref=f55e118] + - button "Notifications" [ref=f55e123] + - button "AU Admin User" [ref=f55e127]: + - generic [ref=f55e128]: AU + - generic [ref=f55e131]: Admin User + - main [ref=f55e135]: + - generic [ref=f55e136]: + - link "Home" [ref=f55e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f55e141]: Developers + - generic [ref=f55e143]: + - generic [ref=f55e144]: Developers + - generic [ref=f55e145]: + - generic [ref=f55e146]: API tokens + - paragraph [ref=f55e147]: Manage personal access tokens for the Admin API. + - table [ref=f55e149]: + - rowgroup [ref=f55e150]: + - row [ref=f55e151]: + - columnheader "Name" [ref=f55e152] + - columnheader "Abilities" [ref=f55e153] + - columnheader "Last used" [ref=f55e154] + - columnheader "Expires" [ref=f55e155] + - columnheader "Created" [ref=f55e156] + - columnheader "Actions" [ref=f55e157] + - rowgroup [ref=f55e158]: + - row [ref=f55e159]: + - cell "E2E Test Token" [ref=f55e160] + - cell "read-products read-orders" [ref=f55e161]: + - generic [ref=f55e162]: + - generic [ref=f55e163]: read-products + - generic [ref=f55e164]: read-orders + - cell "Never" [ref=f55e165] + - cell "Jul 26, 2027" [ref=f55e166] + - cell "Jul 26, 2026" [ref=f55e167] + - cell [ref=f55e168]: + - button "Revoke" [ref=f55e169] + - button "Generate new token" [ref=f55e176] + - generic [ref=f55e182]: + - generic [ref=f55e183]: Webhooks + - paragraph [ref=f55e184]: Manage webhook subscriptions for real-time event notifications. + - table [ref=f55e186]: + - rowgroup [ref=f55e187]: + - row [ref=f55e188]: + - columnheader "Event type" [ref=f55e189] + - columnheader "URL" [ref=f55e190] + - columnheader "Status" [ref=f55e191] + - columnheader "Actions" [ref=f55e192] + - rowgroup [ref=f55e193]: + - row [ref=f55e194]: + - cell "No webhooks configured." [ref=f55e195] + - button "Add webhook" [ref=f55e197] + - dialog [ref=f55e205]: + - generic [ref=f55e206]: + - generic [ref=f55e207]: Generate API token + - generic [ref=f55e208]: + - generic [ref=f55e209]: Token name + - textbox "Token name" [ref=f55e211]: + - /placeholder: My integration + - text: E2E API Token + - generic [ref=f55e212]: + - generic [ref=f55e213]: Abilities + - generic [ref=f55e214]: + - generic [ref=f55e215]: + - checkbox "Read products" [checked] [active] [ref=f55e216] + - generic [ref=f55e218]: Read products + - generic [ref=f55e219]: + - checkbox "Write products" [ref=f55e220] + - generic [ref=f55e222]: Write products + - generic [ref=f55e223]: + - checkbox "Read orders" [ref=f55e224] + - generic [ref=f55e226]: Read orders + - generic [ref=f55e227]: + - checkbox "Write orders" [ref=f55e228] + - generic [ref=f55e230]: Write orders + - generic [ref=f55e231]: + - checkbox "Read customers" [ref=f55e232] + - generic [ref=f55e234]: Read customers + - generic [ref=f55e235]: + - checkbox "Write customers" [ref=f55e236] + - generic [ref=f55e238]: Write customers + - generic [ref=f55e239]: + - checkbox "Read collections" [ref=f55e240] + - generic [ref=f55e242]: Read collections + - generic [ref=f55e243]: + - checkbox "Write collections" [ref=f55e244] + - generic [ref=f55e246]: Write collections + - generic [ref=f55e247]: + - checkbox "Read discounts" [ref=f55e248] + - generic [ref=f55e250]: Read discounts + - generic [ref=f55e251]: + - checkbox "Write discounts" [ref=f55e252] + - generic [ref=f55e254]: Write discounts + - generic [ref=f55e255]: + - checkbox "Read analytics" [ref=f55e256] + - generic [ref=f55e258]: Read analytics + - generic [ref=f55e259]: + - checkbox "Read settings" [ref=f55e260] + - generic [ref=f55e262]: Read settings + - generic [ref=f55e263]: + - checkbox "Write settings" [ref=f55e264] + - generic [ref=f55e266]: Write settings + - generic [ref=f55e267]: + - checkbox "Read themes" [ref=f55e268] + - generic [ref=f55e270]: Read themes + - generic [ref=f55e271]: + - checkbox "Write themes" [ref=f55e272] + - generic [ref=f55e274]: Write themes + - generic [ref=f55e275]: + - checkbox "Read content" [ref=f55e276] + - generic [ref=f55e278]: Read content + - generic [ref=f55e279]: + - checkbox "Write content" [ref=f55e280] + - generic [ref=f55e282]: Write content + - generic [ref=f55e283]: + - checkbox "Manage platform" [ref=f55e284] + - generic [ref=f55e286]: Manage platform + - generic [ref=f55e287]: + - generic [ref=f55e288]: Expires at (optional) + - textbox "Expires at (optional)" [ref=f55e290] + - generic [ref=f55e291]: Defaults to one year from now. + - generic [ref=f55e292]: + - button "Cancel" [ref=f55e293] + - button "Generate" [ref=f55e299] + - button "Close modal" [ref=f55e307] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-06-47-294Z.yml b/.playwright-mcp/page-2026-07-26T09-06-47-294Z.yml new file mode 100644 index 00000000..ba6251b8 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-06-47-294Z.yml @@ -0,0 +1,183 @@ +- generic [ref=f55e1]: + - link "Skip to main content" [ref=f55e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f55e3]: + - complementary "Admin navigation" [ref=f55e4]: + - generic [ref=f55e5]: + - link "Acme Fashion" [ref=f55e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f55e12]: + - navigation [ref=f55e13]: + - link "Dashboard" [ref=f55e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f55e19]: Products + - navigation [ref=f55e20]: + - link "Products" [ref=f55e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f55e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f55e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f55e36]: Orders + - navigation [ref=f55e37]: + - link "Orders" [ref=f55e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f55e43]: Customers + - navigation [ref=f55e44]: + - link "Customers" [ref=f55e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f55e50]: Discounts + - navigation [ref=f55e51]: + - link "Discounts" [ref=f55e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f55e58]: Content + - navigation [ref=f55e59]: + - link "Pages" [ref=f55e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f55e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f55e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f55e75]: + - link "Analytics" [ref=f55e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f55e82]: Settings + - navigation [ref=f55e83]: + - link "Settings" [ref=f55e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f55e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f55e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f55e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f55e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f55e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f55e115]: + - banner [ref=f55e116]: + - button "Acme Fashion" [ref=f55e118] + - button "Notifications" [ref=f55e123] + - button "AU Admin User" [ref=f55e127]: + - generic [ref=f55e128]: AU + - generic [ref=f55e131]: Admin User + - main [ref=f55e135]: + - generic [ref=f55e136]: + - link "Home" [ref=f55e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f55e141]: Developers + - generic [ref=f55e143]: + - generic [ref=f55e144]: Developers + - generic [ref=f55e145]: + - generic [ref=f55e146]: API tokens + - paragraph [ref=f55e147]: Manage personal access tokens for the Admin API. + - table [ref=f55e149]: + - rowgroup [ref=f55e150]: + - row [ref=f55e151]: + - columnheader "Name" [ref=f55e152] + - columnheader "Abilities" [ref=f55e153] + - columnheader "Last used" [ref=f55e154] + - columnheader "Expires" [ref=f55e155] + - columnheader "Created" [ref=f55e156] + - columnheader "Actions" [ref=f55e157] + - rowgroup [ref=f55e158]: + - row [ref=f55e159]: + - cell "E2E Test Token" [ref=f55e160] + - cell "read-products read-orders" [ref=f55e161]: + - generic [ref=f55e162]: + - generic [ref=f55e163]: read-products + - generic [ref=f55e164]: read-orders + - cell "Never" [ref=f55e165] + - cell "Jul 26, 2027" [ref=f55e166] + - cell "Jul 26, 2026" [ref=f55e167] + - cell [ref=f55e168]: + - button "Revoke" [ref=f55e169] + - button "Generate new token" [ref=f55e176] + - generic [ref=f55e182]: + - generic [ref=f55e183]: Webhooks + - paragraph [ref=f55e184]: Manage webhook subscriptions for real-time event notifications. + - table [ref=f55e186]: + - rowgroup [ref=f55e187]: + - row [ref=f55e188]: + - columnheader "Event type" [ref=f55e189] + - columnheader "URL" [ref=f55e190] + - columnheader "Status" [ref=f55e191] + - columnheader "Actions" [ref=f55e192] + - rowgroup [ref=f55e193]: + - row [ref=f55e194]: + - cell "No webhooks configured." [ref=f55e195] + - button "Add webhook" [ref=f55e197] + - dialog [ref=f55e205]: + - generic [ref=f55e206]: + - generic [ref=f55e207]: Generate API token + - generic [ref=f55e208]: + - generic [ref=f55e209]: Token name + - textbox "Token name" [ref=f55e211]: + - /placeholder: My integration + - text: E2E API Token + - generic [ref=f55e212]: + - generic [ref=f55e213]: Abilities + - generic [ref=f55e214]: + - generic [ref=f55e215]: + - checkbox "Read products" [checked] [ref=f55e216] + - generic [ref=f55e218]: Read products + - generic [ref=f55e219]: + - checkbox "Write products" [ref=f55e220] + - generic [ref=f55e222]: Write products + - generic [ref=f55e223]: + - checkbox "Read orders" [checked] [active] [ref=f55e224] + - generic [ref=f55e226]: Read orders + - generic [ref=f55e227]: + - checkbox "Write orders" [ref=f55e228] + - generic [ref=f55e230]: Write orders + - generic [ref=f55e231]: + - checkbox "Read customers" [ref=f55e232] + - generic [ref=f55e234]: Read customers + - generic [ref=f55e235]: + - checkbox "Write customers" [ref=f55e236] + - generic [ref=f55e238]: Write customers + - generic [ref=f55e239]: + - checkbox "Read collections" [ref=f55e240] + - generic [ref=f55e242]: Read collections + - generic [ref=f55e243]: + - checkbox "Write collections" [ref=f55e244] + - generic [ref=f55e246]: Write collections + - generic [ref=f55e247]: + - checkbox "Read discounts" [ref=f55e248] + - generic [ref=f55e250]: Read discounts + - generic [ref=f55e251]: + - checkbox "Write discounts" [ref=f55e252] + - generic [ref=f55e254]: Write discounts + - generic [ref=f55e255]: + - checkbox "Read analytics" [ref=f55e256] + - generic [ref=f55e258]: Read analytics + - generic [ref=f55e259]: + - checkbox "Read settings" [ref=f55e260] + - generic [ref=f55e262]: Read settings + - generic [ref=f55e263]: + - checkbox "Write settings" [ref=f55e264] + - generic [ref=f55e266]: Write settings + - generic [ref=f55e267]: + - checkbox "Read themes" [ref=f55e268] + - generic [ref=f55e270]: Read themes + - generic [ref=f55e271]: + - checkbox "Write themes" [ref=f55e272] + - generic [ref=f55e274]: Write themes + - generic [ref=f55e275]: + - checkbox "Read content" [ref=f55e276] + - generic [ref=f55e278]: Read content + - generic [ref=f55e279]: + - checkbox "Write content" [ref=f55e280] + - generic [ref=f55e282]: Write content + - generic [ref=f55e283]: + - checkbox "Manage platform" [ref=f55e284] + - generic [ref=f55e286]: Manage platform + - generic [ref=f55e287]: + - generic [ref=f55e288]: Expires at (optional) + - textbox "Expires at (optional)" [ref=f55e290] + - generic [ref=f55e291]: Defaults to one year from now. + - generic [ref=f55e292]: + - button "Cancel" [ref=f55e293] + - button "Generate" [ref=f55e299] + - button "Close modal" [ref=f55e307] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-06-59-060Z.yml b/.playwright-mcp/page-2026-07-26T09-06-59-060Z.yml new file mode 100644 index 00000000..58d57d94 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-06-59-060Z.yml @@ -0,0 +1,129 @@ +- generic [ref=f55e1]: + - link "Skip to main content" [ref=f55e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f55e3]: + - complementary "Admin navigation" [ref=f55e4]: + - generic [ref=f55e5]: + - link "Acme Fashion" [ref=f55e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f55e12]: + - navigation [ref=f55e13]: + - link "Dashboard" [ref=f55e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f55e19]: Products + - navigation [ref=f55e20]: + - link "Products" [ref=f55e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f55e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f55e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f55e36]: Orders + - navigation [ref=f55e37]: + - link "Orders" [ref=f55e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f55e43]: Customers + - navigation [ref=f55e44]: + - link "Customers" [ref=f55e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f55e50]: Discounts + - navigation [ref=f55e51]: + - link "Discounts" [ref=f55e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f55e58]: Content + - navigation [ref=f55e59]: + - link "Pages" [ref=f55e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f55e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f55e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f55e75]: + - link "Analytics" [ref=f55e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f55e82]: Settings + - navigation [ref=f55e83]: + - link "Settings" [ref=f55e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f55e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f55e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f55e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f55e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f55e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f55e115]: + - banner [ref=f55e116]: + - button "Acme Fashion" [ref=f55e118] + - button "Notifications" [ref=f55e123] + - button "AU Admin User" [ref=f55e127]: + - generic [ref=f55e128]: AU + - generic [ref=f55e131]: Admin User + - main [ref=f55e135]: + - generic [ref=f55e136]: + - link "Home" [ref=f55e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f55e141]: Developers + - generic [ref=f55e143]: + - generic [ref=f55e144]: Developers + - generic [ref=f55e145]: + - generic [ref=f55e146]: API tokens + - paragraph [ref=f55e147]: Manage personal access tokens for the Admin API. + - generic [ref=f55e319]: + - generic [ref=f55e320]: Copy this token now. It will not be shown again. + - generic [ref=f55e322]: + - code [ref=f55e323]: shop_cy9iOFjaRZKWqiNyuUZFCISorCXWcKwc26ShPK6C + - button "Copy" [ref=f55e324] + - table [ref=f55e149]: + - rowgroup [ref=f55e150]: + - row [ref=f55e151]: + - columnheader "Name" [ref=f55e152] + - columnheader "Abilities" [ref=f55e153] + - columnheader "Last used" [ref=f55e154] + - columnheader "Expires" [ref=f55e155] + - columnheader "Created" [ref=f55e156] + - columnheader "Actions" [ref=f55e157] + - rowgroup [ref=f55e158]: + - row [ref=f55e328]: + - cell "E2E API Token" [ref=f55e329] + - cell "read-products read-orders" [ref=f55e330]: + - generic [ref=f55e331]: + - generic [ref=f55e332]: read-products + - generic [ref=f55e333]: read-orders + - cell "Never" [ref=f55e334] + - cell "Jul 26, 2027" [ref=f55e335] + - cell "Jul 26, 2026" [ref=f55e336] + - cell [ref=f55e337]: + - button "Revoke" [ref=f55e338] + - row [ref=f55e159]: + - cell "E2E Test Token" [ref=f55e160] + - cell "read-products read-orders" [ref=f55e161]: + - generic [ref=f55e162]: + - generic [ref=f55e163]: read-products + - generic [ref=f55e164]: read-orders + - cell "Never" [ref=f55e165] + - cell "Jul 26, 2027" [ref=f55e166] + - cell "Jul 26, 2026" [ref=f55e167] + - cell [ref=f55e168]: + - button "Revoke" [ref=f55e169] + - button "Generate new token" [active] [ref=f55e176] + - generic [ref=f55e182]: + - generic [ref=f55e183]: Webhooks + - paragraph [ref=f55e184]: Manage webhook subscriptions for real-time event notifications. + - table [ref=f55e186]: + - rowgroup [ref=f55e187]: + - row [ref=f55e188]: + - columnheader "Event type" [ref=f55e189] + - columnheader "URL" [ref=f55e190] + - columnheader "Status" [ref=f55e191] + - columnheader "Actions" [ref=f55e192] + - rowgroup [ref=f55e193]: + - row [ref=f55e194]: + - cell "No webhooks configured." [ref=f55e195] + - button "Add webhook" [ref=f55e197] + - alert [ref=f55e344]: + - paragraph [ref=f55e347]: API token created + - button "Dismiss" [ref=f55e348] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-08-05-300Z.yml b/.playwright-mcp/page-2026-07-26T09-08-05-300Z.yml new file mode 100644 index 00000000..7390415d --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-08-05-300Z.yml @@ -0,0 +1,81 @@ +- generic [ref=f56e1]: + - link "Skip to main content" [ref=f56e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f56e4]: + - paragraph [ref=f56e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f56e6] + - banner [ref=f56e9]: + - generic [ref=f56e10]: + - link "Acme Fashion" [ref=f56e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f56e13]: + - link "Home" [ref=f56e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f56e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f56e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f56e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f56e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f56e19]: + - button "Search" [ref=f56e20] + - link "Account" [ref=f56e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f56e26] + - main [ref=f56e29]: + - generic [ref=f56e30]: + - heading "Create an account" [level=1] [ref=f56e31] + - generic [ref=f56e32]: + - generic [ref=f56e33]: + - generic [ref=f56e34]: Name + - textbox "Name" [active] [ref=f56e36] + - generic [ref=f56e37]: + - generic [ref=f56e38]: Email + - textbox "Email" [ref=f56e40] + - generic [ref=f56e41]: + - generic [ref=f56e42]: Password + - textbox "Password" [ref=f56e44] + - generic [ref=f56e45]: + - generic [ref=f56e46]: Confirm password + - textbox "Confirm password" [ref=f56e48] + - generic [ref=f56e49]: + - checkbox "Subscribe to marketing emails" [ref=f56e50] + - generic [ref=f56e52]: Subscribe to marketing emails + - button "Create account" [ref=f56e53] + - paragraph [ref=f56e59]: + - text: Already have an account? + - link "Log in" [ref=f56e60] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - contentinfo [ref=f56e61]: + - generic [ref=f56e62]: + - generic [ref=f56e63]: + - generic [ref=f56e64]: + - heading "Shop" [level=2] [ref=f56e65] + - list [ref=f56e66]: + - listitem [ref=f56e67]: + - link "About Us" [ref=f56e68] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f56e69]: + - link "FAQ" [ref=f56e70] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f56e71]: + - link "Shipping & Returns" [ref=f56e72] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f56e73]: + - link "Privacy Policy" [ref=f56e74] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f56e75]: + - link "Terms of Service" [ref=f56e76] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f56e77]: + - heading "Acme Fashion" [level=2] [ref=f56e78] + - paragraph [ref=f56e79]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f56e80]: + - paragraph [ref=f56e81]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f56e82]: + - generic [ref=f56e83]: VISA + - generic [ref=f56e84]: MASTERCARD + - generic [ref=f56e85]: AMEX + - generic [ref=f56e86]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-08-43-308Z.yml b/.playwright-mcp/page-2026-07-26T09-08-43-308Z.yml new file mode 100644 index 00000000..102923b8 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-08-43-308Z.yml @@ -0,0 +1,89 @@ +- generic [active] [ref=f57e1]: + - link "Skip to main content" [ref=f57e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f57e4]: + - paragraph [ref=f57e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f57e6] + - banner [ref=f57e9]: + - generic [ref=f57e10]: + - link "Acme Fashion" [ref=f57e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f57e13]: + - link "Home" [ref=f57e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f57e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f57e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f57e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f57e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f57e19]: + - button "Search" [ref=f57e20] + - link "Account" [ref=f57e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f57e26] + - main [ref=f57e29]: + - generic [ref=f57e30]: + - heading "Welcome back, Max Mustermann!" [level=1] [ref=f57e31] + - paragraph [ref=f57e32]: max@example.com + - generic [ref=f57e33]: + - link [ref=f57e34] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders + - paragraph [ref=f57e37]: Order history + - paragraph [ref=f57e38]: View all your orders + - link [ref=f57e39] [cursor=pointer]: + - /url: http://acme-fashion.test/account/addresses + - paragraph [ref=f57e43]: Addresses + - paragraph [ref=f57e44]: Manage your addresses + - button "Log out Sign out of your account" [ref=f57e46]: + - generic [ref=f57e49]: Log out + - generic [ref=f57e50]: Sign out of your account + - generic [ref=f57e51]: + - generic [ref=f57e52]: + - heading "Recent orders" [level=2] [ref=f57e53] + - link "View all" [ref=f57e54] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders + - paragraph [ref=f57e55]: You haven't placed any orders yet. + - generic [ref=f57e56]: + - heading "Profile" [level=2] [ref=f57e57] + - generic [ref=f57e58]: + - generic [ref=f57e59]: + - generic [ref=f57e60]: Name + - textbox "Name" [ref=f57e62]: Max Mustermann + - generic [ref=f57e63]: + - checkbox "Subscribe to marketing emails" [ref=f57e64] + - generic [ref=f57e66]: Subscribe to marketing emails + - button "Save" [ref=f57e67] + - contentinfo [ref=f57e73]: + - generic [ref=f57e74]: + - generic [ref=f57e75]: + - generic [ref=f57e76]: + - heading "Shop" [level=2] [ref=f57e77] + - list [ref=f57e78]: + - listitem [ref=f57e79]: + - link "About Us" [ref=f57e80] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f57e81]: + - link "FAQ" [ref=f57e82] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f57e83]: + - link "Shipping & Returns" [ref=f57e84] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f57e85]: + - link "Privacy Policy" [ref=f57e86] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f57e87]: + - link "Terms of Service" [ref=f57e88] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f57e89]: + - heading "Acme Fashion" [level=2] [ref=f57e90] + - paragraph [ref=f57e91]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f57e92]: + - paragraph [ref=f57e93]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f57e94]: + - generic [ref=f57e95]: VISA + - generic [ref=f57e96]: MASTERCARD + - generic [ref=f57e97]: AMEX + - generic [ref=f57e98]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-09-06-242Z.yml b/.playwright-mcp/page-2026-07-26T09-09-06-242Z.yml new file mode 100644 index 00000000..20eeffb4 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-09-06-242Z.yml @@ -0,0 +1,63 @@ +- generic [active] [ref=f58e1]: + - link "Skip to main content" [ref=f58e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f58e4]: + - paragraph [ref=f58e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f58e6] + - banner [ref=f58e9]: + - generic [ref=f58e10]: + - link "Acme Fashion" [ref=f58e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f58e13]: + - link "Home" [ref=f58e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f58e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f58e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f58e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f58e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f58e19]: + - button "Search" [ref=f58e20] + - link "Account" [ref=f58e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f58e26] + - main [ref=f58e29]: + - generic [ref=f58e30]: + - generic [ref=f58e31]: + - heading "Your addresses" [level=1] [ref=f58e32] + - button "Add new address" [ref=f58e33] + - paragraph [ref=f58e41]: You have no saved addresses yet. + - contentinfo [ref=f58e42]: + - generic [ref=f58e43]: + - generic [ref=f58e44]: + - generic [ref=f58e45]: + - heading "Shop" [level=2] [ref=f58e46] + - list [ref=f58e47]: + - listitem [ref=f58e48]: + - link "About Us" [ref=f58e49] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f58e50]: + - link "FAQ" [ref=f58e51] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f58e52]: + - link "Shipping & Returns" [ref=f58e53] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f58e54]: + - link "Privacy Policy" [ref=f58e55] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f58e56]: + - link "Terms of Service" [ref=f58e57] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f58e58]: + - heading "Acme Fashion" [level=2] [ref=f58e59] + - paragraph [ref=f58e60]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f58e61]: + - paragraph [ref=f58e62]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f58e63]: + - generic [ref=f58e64]: VISA + - generic [ref=f58e65]: MASTERCARD + - generic [ref=f58e66]: AMEX + - generic [ref=f58e67]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-09-29-517Z.yml b/.playwright-mcp/page-2026-07-26T09-09-29-517Z.yml new file mode 100644 index 00000000..5a3e574e --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-09-29-517Z.yml @@ -0,0 +1,119 @@ +- generic [active] [ref=f58e1]: + - link "Skip to main content" [ref=f58e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f58e4]: + - paragraph [ref=f58e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f58e6] + - banner [ref=f58e9]: + - generic [ref=f58e10]: + - link "Acme Fashion" [ref=f58e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f58e13]: + - link "Home" [ref=f58e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f58e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f58e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f58e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f58e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f58e19]: + - button "Search" [ref=f58e20] + - link "Account" [ref=f58e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f58e26] + - main [ref=f58e29]: + - generic [ref=f58e30]: + - generic [ref=f58e31]: + - heading "Your addresses" [level=1] [ref=f58e32] + - button "Add new address" [ref=f58e33] + - paragraph [ref=f58e41]: You have no saved addresses yet. + - dialog [ref=f58e68]: + - generic [ref=f58e69]: + - generic [ref=f58e70]: Add address + - generic [ref=f58e71]: + - generic [ref=f58e72]: + - generic [ref=f58e73]: Label (optional) + - textbox "Label (optional)" [ref=f58e75]: + - /placeholder: Home, Work, ... + - generic [ref=f58e76]: + - generic [ref=f58e77]: + - generic [ref=f58e78]: First name + - textbox "First name" [ref=f58e80] + - generic [ref=f58e81]: + - generic [ref=f58e82]: Last name + - textbox "Last name" [ref=f58e84] + - generic [ref=f58e85]: + - generic [ref=f58e86]: Company (optional) + - textbox "Company (optional)" [ref=f58e88] + - generic [ref=f58e89]: + - generic [ref=f58e90]: Address + - textbox "Address" [ref=f58e92] + - generic [ref=f58e93]: + - generic [ref=f58e94]: Apartment, suite, etc. (optional) + - textbox "Apartment, suite, etc. (optional)" [ref=f58e96] + - generic [ref=f58e97]: + - generic [ref=f58e98]: + - generic [ref=f58e99]: City + - textbox "City" [ref=f58e101] + - generic [ref=f58e102]: + - generic [ref=f58e103]: Postal code + - textbox "Postal code" [ref=f58e105] + - generic [ref=f58e106]: + - generic [ref=f58e107]: + - generic [ref=f58e108]: Province / state (optional) + - textbox "Province / state (optional)" [ref=f58e110] + - generic [ref=f58e111]: + - generic [ref=f58e112]: Province code (optional) + - textbox "Province code (optional)" [ref=f58e114] + - generic [ref=f58e115]: + - generic [ref=f58e116]: + - generic [ref=f58e117]: Country + - textbox "Country" [ref=f58e119] + - generic [ref=f58e120]: + - generic [ref=f58e121]: Country code + - textbox "Country code" [ref=f58e123]: + - /placeholder: DE + - generic [ref=f58e124]: + - generic [ref=f58e125]: Phone (optional) + - textbox "Phone (optional)" [ref=f58e127] + - generic [ref=f58e128]: + - checkbox "Set as default address" [ref=f58e129] + - generic [ref=f58e131]: Set as default address + - generic [ref=f58e132]: + - button "Cancel" [ref=f58e134] + - button "Save address" [ref=f58e135] + - button "Close modal" [ref=f58e143] + - contentinfo [ref=f58e42]: + - generic [ref=f58e43]: + - generic [ref=f58e44]: + - generic [ref=f58e45]: + - heading "Shop" [level=2] [ref=f58e46] + - list [ref=f58e47]: + - listitem [ref=f58e48]: + - link "About Us" [ref=f58e49] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f58e50]: + - link "FAQ" [ref=f58e51] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f58e52]: + - link "Shipping & Returns" [ref=f58e53] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f58e54]: + - link "Privacy Policy" [ref=f58e55] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f58e56]: + - link "Terms of Service" [ref=f58e57] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f58e58]: + - heading "Acme Fashion" [level=2] [ref=f58e59] + - paragraph [ref=f58e60]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f58e61]: + - paragraph [ref=f58e62]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f58e63]: + - generic [ref=f58e64]: VISA + - generic [ref=f58e65]: MASTERCARD + - generic [ref=f58e66]: AMEX + - generic [ref=f58e67]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-10-20-194Z.yml b/.playwright-mcp/page-2026-07-26T09-10-20-194Z.yml new file mode 100644 index 00000000..e8df64df --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-10-20-194Z.yml @@ -0,0 +1,70 @@ +- generic [ref=f58e1]: + - link "Skip to main content" [ref=f58e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f58e4]: + - paragraph [ref=f58e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f58e6] + - banner [ref=f58e9]: + - generic [ref=f58e10]: + - link "Acme Fashion" [ref=f58e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f58e13]: + - link "Home" [ref=f58e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f58e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f58e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f58e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f58e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f58e19]: + - button "Search" [ref=f58e20] + - link "Account" [ref=f58e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f58e26] + - main [ref=f58e29]: + - generic [ref=f58e30]: + - generic [ref=f58e31]: + - heading "Your addresses" [level=1] [ref=f58e32] + - button "Add new address" [active] [ref=f58e33] + - generic [ref=f58e147]: + - generic [ref=f58e148]: + - paragraph [ref=f58e149]: Home + - generic [ref=f58e150]: Default + - generic [ref=f58e151]: Max Mustermann Hauptstrasse 1 Berlin 10115 Germany + - generic [ref=f58e152]: + - button "Edit" [ref=f58e153] + - button "Delete" [ref=f58e154] + - contentinfo [ref=f58e42]: + - generic [ref=f58e43]: + - generic [ref=f58e44]: + - generic [ref=f58e45]: + - heading "Shop" [level=2] [ref=f58e46] + - list [ref=f58e47]: + - listitem [ref=f58e48]: + - link "About Us" [ref=f58e49] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f58e50]: + - link "FAQ" [ref=f58e51] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f58e52]: + - link "Shipping & Returns" [ref=f58e53] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f58e54]: + - link "Privacy Policy" [ref=f58e55] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f58e56]: + - link "Terms of Service" [ref=f58e57] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f58e58]: + - heading "Acme Fashion" [level=2] [ref=f58e59] + - paragraph [ref=f58e60]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f58e61]: + - paragraph [ref=f58e62]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f58e63]: + - generic [ref=f58e64]: VISA + - generic [ref=f58e65]: MASTERCARD + - generic [ref=f58e66]: AMEX + - generic [ref=f58e67]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-10-44-439Z.yml b/.playwright-mcp/page-2026-07-26T09-10-44-439Z.yml new file mode 100644 index 00000000..b6d8c691 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-10-44-439Z.yml @@ -0,0 +1,89 @@ +- generic [ref=f59e2]: + - generic [ref=f59e4]: + - generic [ref=f59e5]: Method Not Allowed + - button "Copy as Markdown" [ref=f59e11] [cursor=pointer] + - generic [ref=f59e18]: + - generic [ref=f59e19]: + - heading "Symfony\\Component\\HttpKernel\\Exception\\MethodNotAllowedHttpException" [level=1] [ref=f59e20] + - generic [ref=f59e21]: vendor/laravel/framework/src/Illuminate/Routing/AbstractRouteCollection.php:131 + - paragraph [ref=f59e23]: "The GET method is not supported for route account/logout. Supported methods: POST." + - generic [ref=f59e24]: + - generic [ref=f59e25]: + - generic [ref=f59e26]: + - generic [ref=f59e27]: LARAVEL + - generic [ref=f59e28]: 12.51.0 + - generic [ref=f59e29]: + - generic [ref=f59e30]: PHP + - generic [ref=f59e31]: 8.4.17 + - generic [ref=f59e32]: UNHANDLED + - generic [ref=f59e36]: CODE 0 + - generic [ref=f59e38]: + - generic [ref=f59e39]: "405" + - generic [ref=f59e43]: GET + - generic [ref=f59e47]: http://acme-fashion.test/account/logout + - button [ref=f59e48] [cursor=pointer] + - generic [ref=f59e53]: + - generic [ref=f59e54]: + - heading "Exception trace" [level=3] [ref=f59e60] + - generic [ref=f59e61]: + - generic [ref=f59e63] [cursor=pointer]: + - generic [ref=f59e68]: 33 vendor frames + - button [ref=f59e69] + - generic [ref=f59e74]: + - generic [ref=f59e75] [cursor=pointer]: + - generic [ref=f59e78]: + - code [ref=f59e82]: + - generic [ref=f59e83]: public/index.php + - generic [ref=f59e84]: public/index.php:20 + - button [ref=f59e87] + - code [ref=f59e96]: + - generic [ref=f59e97]: "15" + - generic [ref=f59e98]: 16// Bootstrap Laravel and handle the request... + - generic [ref=f59e99]: 17/** @var Application $app */ + - generic [ref=f59e100]: 18$app = require_once __DIR__.'/../bootstrap/app.php'; + - generic [ref=f59e101]: "19" + - generic [ref=f59e102]: 20$app->handleRequest(Request::capture()); + - generic [ref=f59e103]: "21" + - generic [ref=f59e105] [cursor=pointer]: + - generic [ref=f59e110]: 1 vendor frame + - button [ref=f59e111] + - generic [ref=f59e116]: + - heading "Queries" [level=3] [ref=f59e122] + - generic [ref=f59e123]: // No queries executed + - generic [ref=f59e126]: + - generic [ref=f59e127]: + - heading "Headers" [level=2] [ref=f59e128] + - generic [ref=f59e129]: + - generic [ref=f59e130]: + - generic [ref=f59e131]: cookie + - generic [ref=f59e133]: XSRF-TOKEN=eyJpdiI6InNESnptY25sQU5GTExSb1NZbElEQ3c9PSIsInZhbHVlIjoiMFhNUWt6RVcxTmtsMkQrYmphdDhGWWxDejhWYjZGRmhBVVlNNFlIMGE3VEZYNnpFOHN0cC9rc2k0OTBwSVJETm9DbGRyRStyNVVwMFYwODVsZ1gxSTZtV2pmNG5vMHd0T3ozb0hKUlFFaSt5MGkzVTRtWFdzRFU2U1dqRDhWaFkiLCJtYWMiOiI2OTRkODAxODY5YWRmYjA5Y2M4NzkwMjliZDM1YjBmYzEzNTBhMzk1NTdlZTg5YTE0ZTZiMTc0NDIzYmE3Nzc0IiwidGFnIjoiIn0%3D; shop_session=eyJpdiI6InFEazg0eVVGRVBsNkZFR21FaWxWQWc9PSIsInZhbHVlIjoiVXhaRWU2WGFMRmQ4U1JSUGdVZG1wdDRXSzA3VVN3blprZU9iSXIyK3lLaDhqSDFLdWdSd3M1SGJ2UTE1eW13NWNpRUNMcG5tWE40L0pGSFJYYitIQ3laNFFiYWNBSHc4MGdyVTFIeDdZdElPMFFkNi9tVXBycWxXRlJpbzRocUciLCJtYWMiOiJjZTI2ZDAyODM4YzRmODVmZGNmNjQyNzI0OGI4ODZmMmIwYWIzNDBkYzdjYTAzZGY2MjY4NTg2MDdkZTcyOTliIiwidGFnIjoiIn0%3D + - generic [ref=f59e134]: + - generic [ref=f59e135]: accept-language + - generic [ref=f59e137]: en-GB,en-US;q=0.9,en;q=0.8 + - generic [ref=f59e138]: + - generic [ref=f59e139]: accept-encoding + - generic [ref=f59e141]: gzip, deflate + - generic [ref=f59e142]: + - generic [ref=f59e143]: accept + - generic [ref=f59e145]: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 + - generic [ref=f59e146]: + - generic [ref=f59e147]: user-agent + - generic [ref=f59e149]: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36 + - generic [ref=f59e150]: + - generic [ref=f59e151]: upgrade-insecure-requests + - generic [ref=f59e153]: "1" + - generic [ref=f59e154]: + - generic [ref=f59e155]: connection + - generic [ref=f59e157]: keep-alive + - generic [ref=f59e158]: + - generic [ref=f59e159]: host + - generic [ref=f59e161]: acme-fashion.test + - generic [ref=f59e162]: + - heading "Body" [level=2] [ref=f59e163] + - generic [ref=f59e164]: // No request body + - generic [ref=f59e165]: + - heading "Routing" [level=2] [ref=f59e166] + - generic [ref=f59e167]: // No routing context + - generic [ref=f59e169]: + - heading "Routing parameters" [level=2] [ref=f59e170] + - generic [ref=f59e171]: // No routing parameters \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-10-58-011Z.yml b/.playwright-mcp/page-2026-07-26T09-10-58-011Z.yml new file mode 100644 index 00000000..777f799e --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-10-58-011Z.yml @@ -0,0 +1,89 @@ +- generic [active] [ref=f60e1]: + - link "Skip to main content" [ref=f60e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f60e4]: + - paragraph [ref=f60e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f60e6] + - banner [ref=f60e9]: + - generic [ref=f60e10]: + - link "Acme Fashion" [ref=f60e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f60e13]: + - link "Home" [ref=f60e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f60e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f60e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f60e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f60e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f60e19]: + - button "Search" [ref=f60e20] + - link "Account" [ref=f60e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f60e26] + - main [ref=f60e29]: + - generic [ref=f60e30]: + - heading "Welcome back, Max Mustermann!" [level=1] [ref=f60e31] + - paragraph [ref=f60e32]: max@example.com + - generic [ref=f60e33]: + - link [ref=f60e34] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders + - paragraph [ref=f60e37]: Order history + - paragraph [ref=f60e38]: View all your orders + - link [ref=f60e39] [cursor=pointer]: + - /url: http://acme-fashion.test/account/addresses + - paragraph [ref=f60e43]: Addresses + - paragraph [ref=f60e44]: Manage your addresses + - button "Log out Sign out of your account" [ref=f60e46]: + - generic [ref=f60e49]: Log out + - generic [ref=f60e50]: Sign out of your account + - generic [ref=f60e51]: + - generic [ref=f60e52]: + - heading "Recent orders" [level=2] [ref=f60e53] + - link "View all" [ref=f60e54] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders + - paragraph [ref=f60e55]: You haven't placed any orders yet. + - generic [ref=f60e56]: + - heading "Profile" [level=2] [ref=f60e57] + - generic [ref=f60e58]: + - generic [ref=f60e59]: + - generic [ref=f60e60]: Name + - textbox "Name" [ref=f60e62]: Max Mustermann + - generic [ref=f60e63]: + - checkbox "Subscribe to marketing emails" [ref=f60e64] + - generic [ref=f60e66]: Subscribe to marketing emails + - button "Save" [ref=f60e67] + - contentinfo [ref=f60e73]: + - generic [ref=f60e74]: + - generic [ref=f60e75]: + - generic [ref=f60e76]: + - heading "Shop" [level=2] [ref=f60e77] + - list [ref=f60e78]: + - listitem [ref=f60e79]: + - link "About Us" [ref=f60e80] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f60e81]: + - link "FAQ" [ref=f60e82] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f60e83]: + - link "Shipping & Returns" [ref=f60e84] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f60e85]: + - link "Privacy Policy" [ref=f60e86] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f60e87]: + - link "Terms of Service" [ref=f60e88] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f60e89]: + - heading "Acme Fashion" [level=2] [ref=f60e90] + - paragraph [ref=f60e91]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f60e92]: + - paragraph [ref=f60e93]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f60e94]: + - generic [ref=f60e95]: VISA + - generic [ref=f60e96]: MASTERCARD + - generic [ref=f60e97]: AMEX + - generic [ref=f60e98]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-11-19-496Z.yml b/.playwright-mcp/page-2026-07-26T09-11-19-496Z.yml new file mode 100644 index 00000000..bad84ad2 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-11-19-496Z.yml @@ -0,0 +1,78 @@ +- generic [ref=f61e1]: + - link "Skip to main content" [ref=f61e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f61e4]: + - paragraph [ref=f61e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f61e6] + - banner [ref=f61e9]: + - generic [ref=f61e10]: + - link "Acme Fashion" [ref=f61e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f61e13]: + - link "Home" [ref=f61e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f61e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f61e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f61e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f61e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f61e19]: + - button "Search" [ref=f61e20] + - link "Account" [ref=f61e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f61e26] + - main [ref=f61e29]: + - generic [ref=f61e30]: + - heading "Log in to your account" [level=1] [ref=f61e31] + - generic [ref=f61e32]: + - generic [ref=f61e33]: + - generic [ref=f61e34]: Email + - textbox "Email" [active] [ref=f61e36] + - generic [ref=f61e37]: + - generic [ref=f61e38]: Password + - textbox "Password" [ref=f61e40] + - generic [ref=f61e41]: + - generic [ref=f61e42]: + - checkbox "Remember me" [ref=f61e43] + - generic [ref=f61e45]: Remember me + - link "Forgot password?" [ref=f61e46] [cursor=pointer]: + - /url: http://acme-fashion.test/forgot-password + - button "Log in" [ref=f61e47] + - paragraph [ref=f61e53]: + - text: Don't have an account? + - link "Create one" [ref=f61e54] [cursor=pointer]: + - /url: http://acme-fashion.test/account/register + - contentinfo [ref=f61e55]: + - generic [ref=f61e56]: + - generic [ref=f61e57]: + - generic [ref=f61e58]: + - heading "Shop" [level=2] [ref=f61e59] + - list [ref=f61e60]: + - listitem [ref=f61e61]: + - link "About Us" [ref=f61e62] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f61e63]: + - link "FAQ" [ref=f61e64] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f61e65]: + - link "Shipping & Returns" [ref=f61e66] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f61e67]: + - link "Privacy Policy" [ref=f61e68] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f61e69]: + - link "Terms of Service" [ref=f61e70] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f61e71]: + - heading "Acme Fashion" [level=2] [ref=f61e72] + - paragraph [ref=f61e73]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f61e74]: + - paragraph [ref=f61e75]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f61e76]: + - generic [ref=f61e77]: VISA + - generic [ref=f61e78]: MASTERCARD + - generic [ref=f61e79]: AMEX + - generic [ref=f61e80]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-11-55-808Z.yml b/.playwright-mcp/page-2026-07-26T09-11-55-808Z.yml new file mode 100644 index 00000000..61d4ab60 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-11-55-808Z.yml @@ -0,0 +1,137 @@ +- generic [active] [ref=f62e1]: + - link "Skip to main content" [ref=f62e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f62e4]: + - paragraph [ref=f62e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f62e6] + - banner [ref=f62e9]: + - generic [ref=f62e10]: + - link "Acme Fashion" [ref=f62e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f62e13]: + - link "Home" [ref=f62e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f62e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f62e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f62e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f62e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f62e19]: + - button "Search" [ref=f62e20] + - link "Account" [ref=f62e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f62e26] + - main [ref=f62e29]: + - generic [ref=f62e30]: + - heading "Welcome back, John Doe!" [level=1] [ref=f62e31] + - paragraph [ref=f62e32]: customer@acme.test + - generic [ref=f62e33]: + - link [ref=f62e34] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders + - paragraph [ref=f62e37]: Order history + - paragraph [ref=f62e38]: View all your orders + - link [ref=f62e39] [cursor=pointer]: + - /url: http://acme-fashion.test/account/addresses + - paragraph [ref=f62e43]: Addresses + - paragraph [ref=f62e44]: Manage your addresses + - button "Log out Sign out of your account" [ref=f62e46]: + - generic [ref=f62e49]: Log out + - generic [ref=f62e50]: Sign out of your account + - generic [ref=f62e51]: + - generic [ref=f62e52]: + - heading "Recent orders" [level=2] [ref=f62e53] + - link "View all" [ref=f62e54] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders + - table [ref=f62e56]: + - rowgroup [ref=f62e57]: + - row [ref=f62e58]: + - columnheader "Order" [ref=f62e59] + - columnheader "Date" [ref=f62e60] + - columnheader "Status" [ref=f62e61] + - columnheader "Total" [ref=f62e62] + - columnheader "View" [ref=f62e63] + - rowgroup [ref=f62e65]: + - row [ref=f62e66]: + - cell "#1015" [ref=f62e67] + - cell "Jul 26, 2026" [ref=f62e68] + - cell "paid" [ref=f62e69] + - cell "54.47 EUR" [ref=f62e71] + - cell [ref=f62e72]: + - link "View" [ref=f62e73] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/1015 + - row [ref=f62e74]: + - cell "#1010" [ref=f62e75] + - cell "Jul 25, 2026" [ref=f62e76] + - cell "paid" [ref=f62e77] + - cell "504.98 EUR" [ref=f62e79] + - cell [ref=f62e80]: + - link "View" [ref=f62e81] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/1010 + - row [ref=f62e82]: + - cell "#1001" [ref=f62e83] + - cell "Jul 24, 2026" [ref=f62e84] + - cell "paid" [ref=f62e85] + - cell "54.97 EUR" [ref=f62e87] + - cell [ref=f62e88]: + - link "View" [ref=f62e89] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/1001 + - row [ref=f62e90]: + - cell "#1002" [ref=f62e91] + - cell "Jul 16, 2026" [ref=f62e92] + - cell "fulfilled" [ref=f62e93] + - cell "89.97 EUR" [ref=f62e95] + - cell [ref=f62e96]: + - link "View" [ref=f62e97] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/1002 + - row [ref=f62e98]: + - cell "#1004" [ref=f62e99] + - cell "Jul 11, 2026" [ref=f62e100] + - cell "cancelled" [ref=f62e101] + - cell "29.98 EUR" [ref=f62e103] + - cell [ref=f62e104]: + - link "View" [ref=f62e105] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/1004 + - generic [ref=f62e106]: + - heading "Profile" [level=2] [ref=f62e107] + - generic [ref=f62e108]: + - generic [ref=f62e109]: + - generic [ref=f62e110]: Name + - textbox "Name" [ref=f62e112]: John Doe + - generic [ref=f62e113]: + - checkbox "Subscribe to marketing emails" [checked] [ref=f62e114] + - generic [ref=f62e118]: Subscribe to marketing emails + - button "Save" [ref=f62e119] + - contentinfo [ref=f62e125]: + - generic [ref=f62e126]: + - generic [ref=f62e127]: + - generic [ref=f62e128]: + - heading "Shop" [level=2] [ref=f62e129] + - list [ref=f62e130]: + - listitem [ref=f62e131]: + - link "About Us" [ref=f62e132] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f62e133]: + - link "FAQ" [ref=f62e134] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f62e135]: + - link "Shipping & Returns" [ref=f62e136] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f62e137]: + - link "Privacy Policy" [ref=f62e138] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f62e139]: + - link "Terms of Service" [ref=f62e140] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f62e141]: + - heading "Acme Fashion" [level=2] [ref=f62e142] + - paragraph [ref=f62e143]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f62e144]: + - paragraph [ref=f62e145]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f62e146]: + - generic [ref=f62e147]: VISA + - generic [ref=f62e148]: MASTERCARD + - generic [ref=f62e149]: AMEX + - generic [ref=f62e150]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-12-07-190Z.yml b/.playwright-mcp/page-2026-07-26T09-12-07-190Z.yml new file mode 100644 index 00000000..6a40396a --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-12-07-190Z.yml @@ -0,0 +1,147 @@ +- generic [active] [ref=f63e1]: + - link "Skip to main content" [ref=f63e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f63e4]: + - paragraph [ref=f63e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f63e6] + - banner [ref=f63e9]: + - generic [ref=f63e10]: + - link "Acme Fashion" [ref=f63e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f63e13]: + - link "Home" [ref=f63e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f63e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f63e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f63e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f63e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f63e19]: + - button "Search" [ref=f63e20] + - link "Account" [ref=f63e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f63e26] + - main [ref=f63e29]: + - generic [ref=f63e30]: + - navigation "Breadcrumb" [ref=f63e31]: + - list [ref=f63e32]: + - listitem [ref=f63e33]: + - link "Account" [ref=f63e34] [cursor=pointer]: + - /url: http://acme-fashion.test/account + - listitem [ref=f63e35]: + - generic [ref=f63e36]: / + - generic [ref=f63e37]: Orders + - heading "Order history" [level=1] [ref=f63e38] + - table [ref=f63e39]: + - rowgroup [ref=f63e40]: + - row [ref=f63e41]: + - columnheader "Order" [ref=f63e42] + - columnheader "Date" [ref=f63e43] + - columnheader "Status" [ref=f63e44] + - columnheader "Total" [ref=f63e45] + - columnheader "View" [ref=f63e46] + - rowgroup [ref=f63e48]: + - row [ref=f63e49]: + - cell [ref=f63e50]: + - link "#1015" [ref=f63e51] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/1015 + - cell "Jul 26, 2026" [ref=f63e52] + - cell "paid paid unfulfilled" [ref=f63e53]: + - generic [ref=f63e54]: + - generic [ref=f63e55]: paid + - generic [ref=f63e56]: paid + - generic [ref=f63e57]: unfulfilled + - cell "54.47 EUR" [ref=f63e58] + - cell [ref=f63e59]: + - link "View" [ref=f63e60] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/1015 + - row [ref=f63e61]: + - cell [ref=f63e62]: + - link "#1010" [ref=f63e63] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/1010 + - cell "Jul 25, 2026" [ref=f63e64] + - cell "paid paid unfulfilled" [ref=f63e65]: + - generic [ref=f63e66]: + - generic [ref=f63e67]: paid + - generic [ref=f63e68]: paid + - generic [ref=f63e69]: unfulfilled + - cell "504.98 EUR" [ref=f63e70] + - cell [ref=f63e71]: + - link "View" [ref=f63e72] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/1010 + - row [ref=f63e73]: + - cell [ref=f63e74]: + - link "#1001" [ref=f63e75] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/1001 + - cell "Jul 24, 2026" [ref=f63e76] + - cell "paid paid unfulfilled" [ref=f63e77]: + - generic [ref=f63e78]: + - generic [ref=f63e79]: paid + - generic [ref=f63e80]: paid + - generic [ref=f63e81]: unfulfilled + - cell "54.97 EUR" [ref=f63e82] + - cell [ref=f63e83]: + - link "View" [ref=f63e84] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/1001 + - row [ref=f63e85]: + - cell [ref=f63e86]: + - link "#1002" [ref=f63e87] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/1002 + - cell "Jul 16, 2026" [ref=f63e88] + - cell "fulfilled paid fulfilled" [ref=f63e89]: + - generic [ref=f63e90]: + - generic [ref=f63e91]: fulfilled + - generic [ref=f63e92]: paid + - generic [ref=f63e93]: fulfilled + - cell "89.97 EUR" [ref=f63e94] + - cell [ref=f63e95]: + - link "View" [ref=f63e96] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/1002 + - row [ref=f63e97]: + - cell [ref=f63e98]: + - link "#1004" [ref=f63e99] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/1004 + - cell "Jul 11, 2026" [ref=f63e100] + - cell "cancelled refunded unfulfilled" [ref=f63e101]: + - generic [ref=f63e102]: + - generic [ref=f63e103]: cancelled + - generic [ref=f63e104]: refunded + - generic [ref=f63e105]: unfulfilled + - cell "29.98 EUR" [ref=f63e106] + - cell [ref=f63e107]: + - link "View" [ref=f63e108] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/1004 + - contentinfo [ref=f63e109]: + - generic [ref=f63e110]: + - generic [ref=f63e111]: + - generic [ref=f63e112]: + - heading "Shop" [level=2] [ref=f63e113] + - list [ref=f63e114]: + - listitem [ref=f63e115]: + - link "About Us" [ref=f63e116] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f63e117]: + - link "FAQ" [ref=f63e118] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f63e119]: + - link "Shipping & Returns" [ref=f63e120] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f63e121]: + - link "Privacy Policy" [ref=f63e122] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f63e123]: + - link "Terms of Service" [ref=f63e124] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f63e125]: + - heading "Acme Fashion" [level=2] [ref=f63e126] + - paragraph [ref=f63e127]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f63e128]: + - paragraph [ref=f63e129]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f63e130]: + - generic [ref=f63e131]: VISA + - generic [ref=f63e132]: MASTERCARD + - generic [ref=f63e133]: AMEX + - generic [ref=f63e134]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-12-32-159Z.yml b/.playwright-mcp/page-2026-07-26T09-12-32-159Z.yml new file mode 100644 index 00000000..dd70d206 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-12-32-159Z.yml @@ -0,0 +1,129 @@ +- generic [active] [ref=f64e1]: + - link "Skip to main content" [ref=f64e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f64e4]: + - paragraph [ref=f64e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f64e6] + - banner [ref=f64e9]: + - generic [ref=f64e10]: + - link "Acme Fashion" [ref=f64e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f64e13]: + - link "Home" [ref=f64e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f64e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f64e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f64e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f64e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f64e19]: + - button "Search" [ref=f64e20] + - link "Account" [ref=f64e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f64e26] + - main [ref=f64e29]: + - generic [ref=f64e30]: + - navigation "Breadcrumb" [ref=f64e31]: + - list [ref=f64e32]: + - listitem [ref=f64e33]: + - link "Account" [ref=f64e34] [cursor=pointer]: + - /url: http://acme-fashion.test/account + - listitem [ref=f64e35]: + - generic [ref=f64e36]: / + - link "Orders" [ref=f64e37] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders + - listitem [ref=f64e38]: + - generic [ref=f64e39]: / + - generic [ref=f64e40]: "#1001" + - generic [ref=f64e41]: + - 'heading "Order #1001" [level=1] [ref=f64e42]' + - generic [ref=f64e43]: + - generic [ref=f64e44]: paid + - generic [ref=f64e45]: unfulfilled + - paragraph [ref=f64e46]: Placed on July 24, 2026 + - list "Order progress" [ref=f64e47]: + - listitem [ref=f64e48]: + - generic [ref=f64e52]: + - text: Placed + - generic [ref=f64e53]: Jul 24, 2026 + - listitem [ref=f64e54]: + - generic [ref=f64e59]: + - text: Paid + - generic [ref=f64e60]: Jul 26, 2026 + - listitem [ref=f64e61]: + - generic [ref=f64e63]: Shipped + - listitem [ref=f64e67]: + - generic [ref=f64e69]: Delivered + - generic [ref=f64e73]: + - heading "Items" [level=2] [ref=f64e74] + - table [ref=f64e75]: + - rowgroup [ref=f64e76]: + - row [ref=f64e77]: + - cell [ref=f64e78]: + - generic [ref=f64e83]: + - paragraph [ref=f64e84]: Classic Cotton T-Shirt + - paragraph [ref=f64e85]: "SKU: ACME-CTSH-S-WHT" + - cell "×2" [ref=f64e86] + - cell "49.98 EUR" [ref=f64e87] + - generic [ref=f64e88]: + - generic [ref=f64e89]: + - heading "Shipping address" [level=2] [ref=f64e90] + - generic [ref=f64e91]: John Doe Hauptstrasse 1 Berlin 10115 Germany +49 30 12345678 + - generic [ref=f64e92]: + - heading "Billing address" [level=2] [ref=f64e93] + - generic [ref=f64e94]: Same as shipping + - generic [ref=f64e95]: + - heading "Payment" [level=2] [ref=f64e96] + - paragraph [ref=f64e97]: + - text: Credit card + - generic [ref=f64e98]: 54.97 EUR · captured + - generic [ref=f64e99]: + - generic [ref=f64e100]: + - term [ref=f64e101]: Subtotal + - definition [ref=f64e102]: 49.98 EUR + - generic [ref=f64e103]: + - term [ref=f64e104]: Shipping + - definition [ref=f64e105]: 4.99 EUR + - generic [ref=f64e106]: + - term [ref=f64e107]: Tax + - definition [ref=f64e108]: 7.98 EUR + - generic [ref=f64e109]: + - term [ref=f64e110]: Discount + - definition [ref=f64e111]: "-0.00 EUR" + - generic [ref=f64e112]: + - term [ref=f64e113]: Total + - definition [ref=f64e114]: 54.97 EUR + - contentinfo [ref=f64e115]: + - generic [ref=f64e116]: + - generic [ref=f64e117]: + - generic [ref=f64e118]: + - heading "Shop" [level=2] [ref=f64e119] + - list [ref=f64e120]: + - listitem [ref=f64e121]: + - link "About Us" [ref=f64e122] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f64e123]: + - link "FAQ" [ref=f64e124] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f64e125]: + - link "Shipping & Returns" [ref=f64e126] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f64e127]: + - link "Privacy Policy" [ref=f64e128] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f64e129]: + - link "Terms of Service" [ref=f64e130] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f64e131]: + - heading "Acme Fashion" [level=2] [ref=f64e132] + - paragraph [ref=f64e133]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f64e134]: + - paragraph [ref=f64e135]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f64e136]: + - generic [ref=f64e137]: VISA + - generic [ref=f64e138]: MASTERCARD + - generic [ref=f64e139]: AMEX + - generic [ref=f64e140]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-13-09-652Z.yml b/.playwright-mcp/page-2026-07-26T09-13-09-652Z.yml new file mode 100644 index 00000000..3a2b4a9d --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-13-09-652Z.yml @@ -0,0 +1,83 @@ +- generic [active] [ref=f65e1]: + - link "Skip to main content" [ref=f65e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f65e4]: + - paragraph [ref=f65e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f65e6] + - banner [ref=f65e9]: + - generic [ref=f65e10]: + - link "Acme Fashion" [ref=f65e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f65e13]: + - link "Home" [ref=f65e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f65e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f65e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f65e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f65e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f65e19]: + - button "Search" [ref=f65e20] + - link "Account" [ref=f65e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f65e26] + - main [ref=f65e29]: + - generic [ref=f65e30]: + - navigation "Breadcrumb" [ref=f65e31]: + - list [ref=f65e32]: + - listitem [ref=f65e33]: + - link "Home" [ref=f65e34] [cursor=pointer]: + - /url: http://acme-fashion.test + - listitem [ref=f65e35]: + - generic [ref=f65e36]: / + - generic [ref=f65e37]: Limited Edition Sneakers + - generic [ref=f65e38]: + - region "Product images" [ref=f65e39] + - generic [ref=f65e45]: + - heading "Limited Edition Sneakers" [level=1] [ref=f65e46] + - paragraph [ref=f65e47]: Acme Sport + - generic [ref=f65e48]: 159.99 EUR + - group "SizeEU 40" [ref=f65e50]: + - generic [ref=f65e52]: + - button "EU 40" [disabled] [pressed] [ref=f65e53] + - button "EU 42" [disabled] [ref=f65e54] + - button "EU 44" [disabled] [ref=f65e55] + - paragraph [ref=f65e56]: Out of stock + - button "Sold out" [disabled] [ref=f65e60] + - separator [ref=f65e61] + - paragraph [ref=f65e63]: Limited edition collaboration sneakers. Once they are gone, they are gone. + - generic [ref=f65e64]: limited + - contentinfo [ref=f65e66]: + - generic [ref=f65e67]: + - generic [ref=f65e68]: + - generic [ref=f65e69]: + - heading "Shop" [level=2] [ref=f65e70] + - list [ref=f65e71]: + - listitem [ref=f65e72]: + - link "About Us" [ref=f65e73] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f65e74]: + - link "FAQ" [ref=f65e75] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f65e76]: + - link "Shipping & Returns" [ref=f65e77] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f65e78]: + - link "Privacy Policy" [ref=f65e79] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f65e80]: + - link "Terms of Service" [ref=f65e81] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f65e82]: + - heading "Acme Fashion" [level=2] [ref=f65e83] + - paragraph [ref=f65e84]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f65e85]: + - paragraph [ref=f65e86]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f65e87]: + - generic [ref=f65e88]: VISA + - generic [ref=f65e89]: MASTERCARD + - generic [ref=f65e90]: AMEX + - generic [ref=f65e91]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-13-30-071Z.yml b/.playwright-mcp/page-2026-07-26T09-13-30-071Z.yml new file mode 100644 index 00000000..452f01e6 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-13-30-071Z.yml @@ -0,0 +1,90 @@ +- generic [active] [ref=f66e1]: + - link "Skip to main content" [ref=f66e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f66e4]: + - paragraph [ref=f66e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f66e6] + - banner [ref=f66e9]: + - generic [ref=f66e10]: + - link "Acme Fashion" [ref=f66e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f66e13]: + - link "Home" [ref=f66e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f66e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f66e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f66e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f66e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f66e19]: + - button "Search" [ref=f66e20] + - link "Account" [ref=f66e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f66e26] + - main [ref=f66e29]: + - generic [ref=f66e30]: + - navigation "Breadcrumb" [ref=f66e31]: + - list [ref=f66e32]: + - listitem [ref=f66e33]: + - link "Home" [ref=f66e34] [cursor=pointer]: + - /url: http://acme-fashion.test + - listitem [ref=f66e35]: + - generic [ref=f66e36]: / + - generic [ref=f66e37]: Backorder Denim Jacket + - generic [ref=f66e38]: + - region "Product images" [ref=f66e39] + - generic [ref=f66e45]: + - heading "Backorder Denim Jacket" [level=1] [ref=f66e46] + - paragraph [ref=f66e47]: Acme Denim + - generic [ref=f66e48]: 99.99 EUR + - group "SizeS" [ref=f66e50]: + - generic [ref=f66e52]: + - button "S" [pressed] [ref=f66e53] + - button "M" [ref=f66e54] + - button "L" [ref=f66e55] + - button "XL" [ref=f66e56] + - paragraph [ref=f66e57]: Available on backorder + - generic [ref=f66e60]: + - generic [ref=f66e61]: + - button "Decrease quantity" [disabled] [ref=f66e62] + - generic [ref=f66e64]: Quantity + - spinbutton "Quantity" [ref=f66e65]: "1" + - button "Increase quantity" [ref=f66e66] + - button "Add to cart" [ref=f66e69] + - separator [ref=f66e70] + - paragraph [ref=f66e72]: Classic denim jacket. Currently on backorder - ships within 2-3 weeks. + - generic [ref=f66e73]: popular + - contentinfo [ref=f66e75]: + - generic [ref=f66e76]: + - generic [ref=f66e77]: + - generic [ref=f66e78]: + - heading "Shop" [level=2] [ref=f66e79] + - list [ref=f66e80]: + - listitem [ref=f66e81]: + - link "About Us" [ref=f66e82] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f66e83]: + - link "FAQ" [ref=f66e84] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f66e85]: + - link "Shipping & Returns" [ref=f66e86] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f66e87]: + - link "Privacy Policy" [ref=f66e88] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f66e89]: + - link "Terms of Service" [ref=f66e90] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f66e91]: + - heading "Acme Fashion" [level=2] [ref=f66e92] + - paragraph [ref=f66e93]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f66e94]: + - paragraph [ref=f66e95]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f66e96]: + - generic [ref=f66e97]: VISA + - generic [ref=f66e98]: MASTERCARD + - generic [ref=f66e99]: AMEX + - generic [ref=f66e100]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-14-11-043Z.yml b/.playwright-mcp/page-2026-07-26T09-14-11-043Z.yml new file mode 100644 index 00000000..50f9d6d8 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-14-11-043Z.yml @@ -0,0 +1,184 @@ +- generic [active] [ref=f67e1]: + - link "Skip to main content" [ref=f67e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f67e4]: + - paragraph [ref=f67e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f67e6] + - banner [ref=f67e9]: + - generic [ref=f67e10]: + - link "Acme Fashion" [ref=f67e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f67e13]: + - link "Home" [ref=f67e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f67e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f67e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f67e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f67e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f67e19]: + - button "Search" [ref=f67e20] + - link "Account" [ref=f67e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f67e26] + - main [ref=f67e29]: + - generic [ref=f67e30]: + - generic [ref=f67e34]: + - heading "Welcome to Acme Fashion" [level=1] [ref=f67e35] + - paragraph [ref=f67e36]: Discover our curated collection of modern essentials + - link "Shop New Arrivals" [ref=f67e37] [cursor=pointer]: + - /url: /collections/new-arrivals + - region [ref=f67e38]: + - heading "Featured collections" [level=2] [ref=f67e39] + - generic [ref=f67e40]: + - link "New Arrivals" [ref=f67e41] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - generic [ref=f67e43]: + - generic [ref=f67e44]: New Arrivals + - generic [ref=f67e45]: Shop now + - link "T-Shirts" [ref=f67e46] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - generic [ref=f67e48]: + - generic [ref=f67e49]: T-Shirts + - generic [ref=f67e50]: Shop now + - link "Sale" [ref=f67e51] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/sale + - generic [ref=f67e53]: + - generic [ref=f67e54]: Sale + - generic [ref=f67e55]: Shop now + - region [ref=f67e56]: + - heading "Featured products" [level=2] [ref=f67e57] + - generic [ref=f67e58]: + - generic [ref=f67e59]: + - link [ref=f67e61] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f67e65] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f67e66] + - generic [ref=f67e67]: 24.99 EUR + - link "Choose options" [ref=f67e71] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f67e72]: + - generic [ref=f67e73]: + - link [ref=f67e74] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f67e79]: + - generic [ref=f67e80]: "On sale:" + - text: Sale + - 'link "Premium Slim Fit Jeans 79.99 EUR 99.99 EUR On sale: Sale" [ref=f67e81] [cursor=pointer]': + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - heading "Premium Slim Fit Jeans" [level=3] [ref=f67e82] + - generic [ref=f67e84]: + - generic [ref=f67e85]: 79.99 EUR + - generic [ref=f67e86]: 99.99 EUR + - generic [ref=f67e87]: + - generic [ref=f67e88]: "On sale:" + - text: Sale + - link "Choose options" [ref=f67e90] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f67e91]: + - link [ref=f67e93] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - link "Organic Hoodie 59.99 EUR" [ref=f67e97] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - heading "Organic Hoodie" [level=3] [ref=f67e98] + - generic [ref=f67e99]: 59.99 EUR + - link "Choose options" [ref=f67e103] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - generic [ref=f67e104]: + - link [ref=f67e106] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - link "Leather Belt 34.99 EUR" [ref=f67e110] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - heading "Leather Belt" [level=3] [ref=f67e111] + - generic [ref=f67e112]: 34.99 EUR + - link "Choose options" [ref=f67e116] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - generic [ref=f67e117]: + - link [ref=f67e119] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - link "Running Sneakers 119.99 EUR" [ref=f67e123] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - heading "Running Sneakers" [level=3] [ref=f67e124] + - generic [ref=f67e125]: 119.99 EUR + - link "Choose options" [ref=f67e129] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - generic [ref=f67e130]: + - link [ref=f67e132] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f67e136] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f67e137] + - generic [ref=f67e138]: 29.99 EUR + - link "Choose options" [ref=f67e142] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - generic [ref=f67e143]: + - link [ref=f67e145] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - link "V-Neck Linen Tee 34.99 EUR" [ref=f67e149] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - heading "V-Neck Linen Tee" [level=3] [ref=f67e150] + - generic [ref=f67e151]: 34.99 EUR + - link "Choose options" [ref=f67e155] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - generic [ref=f67e156]: + - generic [ref=f67e157]: + - link [ref=f67e158] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - generic [ref=f67e163]: + - generic [ref=f67e164]: "On sale:" + - text: Sale + - 'link "Striped Polo Shirt 27.99 EUR 39.99 EUR On sale: Sale" [ref=f67e165] [cursor=pointer]': + - /url: http://acme-fashion.test/products/striped-polo-shirt + - heading "Striped Polo Shirt" [level=3] [ref=f67e166] + - generic [ref=f67e168]: + - generic [ref=f67e169]: 27.99 EUR + - generic [ref=f67e170]: 39.99 EUR + - generic [ref=f67e171]: + - generic [ref=f67e172]: "On sale:" + - text: Sale + - link "Choose options" [ref=f67e174] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - region [ref=f67e175]: + - generic [ref=f67e176]: + - heading "Stay in the loop" [level=2] [ref=f67e177] + - paragraph [ref=f67e178]: Subscribe for exclusive offers and updates. + - generic [ref=f67e180]: + - generic [ref=f67e181]: Email address + - textbox "Email address" [ref=f67e182]: + - /placeholder: Enter your email + - button "Subscribe" [ref=f67e183] + - contentinfo [ref=f67e184]: + - generic [ref=f67e185]: + - generic [ref=f67e186]: + - generic [ref=f67e187]: + - heading "Shop" [level=2] [ref=f67e188] + - list [ref=f67e189]: + - listitem [ref=f67e190]: + - link "About Us" [ref=f67e191] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f67e192]: + - link "FAQ" [ref=f67e193] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f67e194]: + - link "Shipping & Returns" [ref=f67e195] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f67e196]: + - link "Privacy Policy" [ref=f67e197] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f67e198]: + - link "Terms of Service" [ref=f67e199] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f67e200]: + - heading "Acme Fashion" [level=2] [ref=f67e201] + - paragraph [ref=f67e202]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f67e203]: + - paragraph [ref=f67e204]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f67e205]: + - generic [ref=f67e206]: VISA + - generic [ref=f67e207]: MASTERCARD + - generic [ref=f67e208]: AMEX + - generic [ref=f67e209]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-14-48-560Z.yml b/.playwright-mcp/page-2026-07-26T09-14-48-560Z.yml new file mode 100644 index 00000000..be0d1fb2 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-14-48-560Z.yml @@ -0,0 +1,191 @@ +- generic [ref=f67e1]: + - link "Skip to main content" [ref=f67e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f67e4]: + - paragraph [ref=f67e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f67e6] + - banner [ref=f67e9]: + - generic [ref=f67e10]: + - link "Acme Fashion" [ref=f67e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f67e13]: + - link "Home" [ref=f67e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f67e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f67e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f67e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f67e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f67e19]: + - button "Search" [ref=f67e20] + - link "Account" [ref=f67e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f67e26] + - main [ref=f67e29]: + - generic [ref=f67e30]: + - generic [ref=f67e34]: + - heading "Welcome to Acme Fashion" [level=1] [ref=f67e35] + - paragraph [ref=f67e36]: Discover our curated collection of modern essentials + - link "Shop New Arrivals" [ref=f67e37] [cursor=pointer]: + - /url: /collections/new-arrivals + - region [ref=f67e38]: + - heading "Featured collections" [level=2] [ref=f67e39] + - generic [ref=f67e40]: + - link "New Arrivals" [ref=f67e41] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - generic [ref=f67e43]: + - generic [ref=f67e44]: New Arrivals + - generic [ref=f67e45]: Shop now + - link "T-Shirts" [ref=f67e46] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - generic [ref=f67e48]: + - generic [ref=f67e49]: T-Shirts + - generic [ref=f67e50]: Shop now + - link "Sale" [ref=f67e51] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/sale + - generic [ref=f67e53]: + - generic [ref=f67e54]: Sale + - generic [ref=f67e55]: Shop now + - region [ref=f67e56]: + - heading "Featured products" [level=2] [ref=f67e57] + - generic [ref=f67e58]: + - generic [ref=f67e59]: + - link [ref=f67e61] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f67e65] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f67e66] + - generic [ref=f67e67]: 24.99 EUR + - link "Choose options" [ref=f67e71] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f67e72]: + - generic [ref=f67e73]: + - link [ref=f67e74] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f67e79]: + - generic [ref=f67e80]: "On sale:" + - text: Sale + - 'link "Premium Slim Fit Jeans 79.99 EUR 99.99 EUR On sale: Sale" [ref=f67e81] [cursor=pointer]': + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - heading "Premium Slim Fit Jeans" [level=3] [ref=f67e82] + - generic [ref=f67e84]: + - generic [ref=f67e85]: 79.99 EUR + - generic [ref=f67e86]: 99.99 EUR + - generic [ref=f67e87]: + - generic [ref=f67e88]: "On sale:" + - text: Sale + - link "Choose options" [ref=f67e90] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f67e91]: + - link [ref=f67e93] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - link "Organic Hoodie 59.99 EUR" [ref=f67e97] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - heading "Organic Hoodie" [level=3] [ref=f67e98] + - generic [ref=f67e99]: 59.99 EUR + - link "Choose options" [ref=f67e103] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - generic [ref=f67e104]: + - link [ref=f67e106] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - link "Leather Belt 34.99 EUR" [ref=f67e110] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - heading "Leather Belt" [level=3] [ref=f67e111] + - generic [ref=f67e112]: 34.99 EUR + - link "Choose options" [ref=f67e116] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - generic [ref=f67e117]: + - link [ref=f67e119] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - link "Running Sneakers 119.99 EUR" [ref=f67e123] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - heading "Running Sneakers" [level=3] [ref=f67e124] + - generic [ref=f67e125]: 119.99 EUR + - link "Choose options" [ref=f67e129] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - generic [ref=f67e130]: + - link [ref=f67e132] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f67e136] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f67e137] + - generic [ref=f67e138]: 29.99 EUR + - link "Choose options" [ref=f67e142] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - generic [ref=f67e143]: + - link [ref=f67e145] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - link "V-Neck Linen Tee 34.99 EUR" [ref=f67e149] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - heading "V-Neck Linen Tee" [level=3] [ref=f67e150] + - generic [ref=f67e151]: 34.99 EUR + - link "Choose options" [ref=f67e155] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - generic [ref=f67e156]: + - generic [ref=f67e157]: + - link [ref=f67e158] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - generic [ref=f67e163]: + - generic [ref=f67e164]: "On sale:" + - text: Sale + - 'link "Striped Polo Shirt 27.99 EUR 39.99 EUR On sale: Sale" [ref=f67e165] [cursor=pointer]': + - /url: http://acme-fashion.test/products/striped-polo-shirt + - heading "Striped Polo Shirt" [level=3] [ref=f67e166] + - generic [ref=f67e168]: + - generic [ref=f67e169]: 27.99 EUR + - generic [ref=f67e170]: 39.99 EUR + - generic [ref=f67e171]: + - generic [ref=f67e172]: "On sale:" + - text: Sale + - link "Choose options" [ref=f67e174] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - region [ref=f67e175]: + - generic [ref=f67e176]: + - heading "Stay in the loop" [level=2] [ref=f67e177] + - paragraph [ref=f67e178]: Subscribe for exclusive offers and updates. + - generic [ref=f67e180]: + - generic [ref=f67e181]: Email address + - textbox "Email address" [ref=f67e182]: + - /placeholder: Enter your email + - button "Subscribe" [ref=f67e183] + - contentinfo [ref=f67e184]: + - generic [ref=f67e185]: + - generic [ref=f67e186]: + - generic [ref=f67e187]: + - heading "Shop" [level=2] [ref=f67e188] + - list [ref=f67e189]: + - listitem [ref=f67e190]: + - link "About Us" [ref=f67e191] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f67e192]: + - link "FAQ" [ref=f67e193] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f67e194]: + - link "Shipping & Returns" [ref=f67e195] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f67e196]: + - link "Privacy Policy" [ref=f67e197] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f67e198]: + - link "Terms of Service" [ref=f67e199] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f67e200]: + - heading "Acme Fashion" [level=2] [ref=f67e201] + - paragraph [ref=f67e202]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f67e203]: + - paragraph [ref=f67e204]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f67e205]: + - generic [ref=f67e206]: VISA + - generic [ref=f67e207]: MASTERCARD + - generic [ref=f67e208]: AMEX + - generic [ref=f67e209]: PAYPAL + - generic: + - dialog "Search": + - search [ref=f67e213]: + - generic [ref=f67e216]: Search products + - combobox "Search products" [active] [ref=f67e217] + - button "Search" [ref=f67e218] + - button "Close search" [ref=f67e219] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-15-16-061Z.yml b/.playwright-mcp/page-2026-07-26T09-15-16-061Z.yml new file mode 100644 index 00000000..9203d289 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-15-16-061Z.yml @@ -0,0 +1,222 @@ +- generic [ref=f67e1]: + - link "Skip to main content" [ref=f67e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f67e4]: + - paragraph [ref=f67e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f67e6] + - banner [ref=f67e9]: + - generic [ref=f67e10]: + - link "Acme Fashion" [ref=f67e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f67e13]: + - link "Home" [ref=f67e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f67e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f67e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f67e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f67e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f67e19]: + - button "Search" [ref=f67e20] + - link "Account" [ref=f67e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f67e26] + - main [ref=f67e29]: + - generic [ref=f67e30]: + - generic [ref=f67e34]: + - heading "Welcome to Acme Fashion" [level=1] [ref=f67e35] + - paragraph [ref=f67e36]: Discover our curated collection of modern essentials + - link "Shop New Arrivals" [ref=f67e37] [cursor=pointer]: + - /url: /collections/new-arrivals + - region [ref=f67e38]: + - heading "Featured collections" [level=2] [ref=f67e39] + - generic [ref=f67e40]: + - link "New Arrivals" [ref=f67e41] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - generic [ref=f67e43]: + - generic [ref=f67e44]: New Arrivals + - generic [ref=f67e45]: Shop now + - link "T-Shirts" [ref=f67e46] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - generic [ref=f67e48]: + - generic [ref=f67e49]: T-Shirts + - generic [ref=f67e50]: Shop now + - link "Sale" [ref=f67e51] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/sale + - generic [ref=f67e53]: + - generic [ref=f67e54]: Sale + - generic [ref=f67e55]: Shop now + - region [ref=f67e56]: + - heading "Featured products" [level=2] [ref=f67e57] + - generic [ref=f67e58]: + - generic [ref=f67e59]: + - link [ref=f67e61] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f67e65] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f67e66] + - generic [ref=f67e67]: 24.99 EUR + - link "Choose options" [ref=f67e71] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f67e72]: + - generic [ref=f67e73]: + - link [ref=f67e74] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f67e79]: + - generic [ref=f67e80]: "On sale:" + - text: Sale + - 'link "Premium Slim Fit Jeans 79.99 EUR 99.99 EUR On sale: Sale" [ref=f67e81] [cursor=pointer]': + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - heading "Premium Slim Fit Jeans" [level=3] [ref=f67e82] + - generic [ref=f67e84]: + - generic [ref=f67e85]: 79.99 EUR + - generic [ref=f67e86]: 99.99 EUR + - generic [ref=f67e87]: + - generic [ref=f67e88]: "On sale:" + - text: Sale + - link "Choose options" [ref=f67e90] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f67e91]: + - link [ref=f67e93] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - link "Organic Hoodie 59.99 EUR" [ref=f67e97] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - heading "Organic Hoodie" [level=3] [ref=f67e98] + - generic [ref=f67e99]: 59.99 EUR + - link "Choose options" [ref=f67e103] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - generic [ref=f67e104]: + - link [ref=f67e106] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - link "Leather Belt 34.99 EUR" [ref=f67e110] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - heading "Leather Belt" [level=3] [ref=f67e111] + - generic [ref=f67e112]: 34.99 EUR + - link "Choose options" [ref=f67e116] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - generic [ref=f67e117]: + - link [ref=f67e119] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - link "Running Sneakers 119.99 EUR" [ref=f67e123] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - heading "Running Sneakers" [level=3] [ref=f67e124] + - generic [ref=f67e125]: 119.99 EUR + - link "Choose options" [ref=f67e129] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - generic [ref=f67e130]: + - link [ref=f67e132] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f67e136] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f67e137] + - generic [ref=f67e138]: 29.99 EUR + - link "Choose options" [ref=f67e142] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - generic [ref=f67e143]: + - link [ref=f67e145] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - link "V-Neck Linen Tee 34.99 EUR" [ref=f67e149] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - heading "V-Neck Linen Tee" [level=3] [ref=f67e150] + - generic [ref=f67e151]: 34.99 EUR + - link "Choose options" [ref=f67e155] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - generic [ref=f67e156]: + - generic [ref=f67e157]: + - link [ref=f67e158] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - generic [ref=f67e163]: + - generic [ref=f67e164]: "On sale:" + - text: Sale + - 'link "Striped Polo Shirt 27.99 EUR 39.99 EUR On sale: Sale" [ref=f67e165] [cursor=pointer]': + - /url: http://acme-fashion.test/products/striped-polo-shirt + - heading "Striped Polo Shirt" [level=3] [ref=f67e166] + - generic [ref=f67e168]: + - generic [ref=f67e169]: 27.99 EUR + - generic [ref=f67e170]: 39.99 EUR + - generic [ref=f67e171]: + - generic [ref=f67e172]: "On sale:" + - text: Sale + - link "Choose options" [ref=f67e174] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - region [ref=f67e175]: + - generic [ref=f67e176]: + - heading "Stay in the loop" [level=2] [ref=f67e177] + - paragraph [ref=f67e178]: Subscribe for exclusive offers and updates. + - generic [ref=f67e180]: + - generic [ref=f67e181]: Email address + - textbox "Email address" [ref=f67e182]: + - /placeholder: Enter your email + - button "Subscribe" [ref=f67e183] + - contentinfo [ref=f67e184]: + - generic [ref=f67e185]: + - generic [ref=f67e186]: + - generic [ref=f67e187]: + - heading "Shop" [level=2] [ref=f67e188] + - list [ref=f67e189]: + - listitem [ref=f67e190]: + - link "About Us" [ref=f67e191] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f67e192]: + - link "FAQ" [ref=f67e193] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f67e194]: + - link "Shipping & Returns" [ref=f67e195] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f67e196]: + - link "Privacy Policy" [ref=f67e197] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f67e198]: + - link "Terms of Service" [ref=f67e199] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f67e200]: + - heading "Acme Fashion" [level=2] [ref=f67e201] + - paragraph [ref=f67e202]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f67e203]: + - paragraph [ref=f67e204]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f67e205]: + - generic [ref=f67e206]: VISA + - generic [ref=f67e207]: MASTERCARD + - generic [ref=f67e208]: AMEX + - generic [ref=f67e209]: PAYPAL + - generic: + - dialog "Search": + - generic [ref=f67e212]: + - search [ref=f67e213]: + - generic [ref=f67e216]: Search products + - combobox "Search products" [expanded] [active] [ref=f67e217]: cotton + - button "Search" [ref=f67e218] + - button "Close search" [ref=f67e219] + - generic [ref=f67e222]: + - listbox "Search suggestions" [ref=f67e223]: + - listitem [ref=f67e224]: Products + - option [ref=f67e225]: + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f67e226] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f67e230]: Classic Cotton T-Shirt + - generic [ref=f67e231]: 24.99 EUR + - option [ref=f67e234]: + - link "Cargo Pants 54.99 EUR" [ref=f67e235] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cargo-pants + - generic [ref=f67e239]: Cargo Pants + - generic [ref=f67e240]: 54.99 EUR + - option [ref=f67e243]: + - link "Organic Hoodie 59.99 EUR" [ref=f67e244] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - generic [ref=f67e248]: Organic Hoodie + - generic [ref=f67e249]: 59.99 EUR + - option [ref=f67e252]: + - link "Bucket Hat 24.99 EUR" [ref=f67e253] [cursor=pointer]: + - /url: http://acme-fashion.test/products/bucket-hat + - generic [ref=f67e257]: Bucket Hat + - generic [ref=f67e258]: 24.99 EUR + - option [ref=f67e261]: + - link "Graphic Print Tee 29.99 EUR" [ref=f67e262] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - generic [ref=f67e266]: Graphic Print Tee + - generic [ref=f67e267]: 29.99 EUR + - link "View all results for “cotton” →" [ref=f67e270] [cursor=pointer]: + - /url: http://acme-fashion.test/search?q=cotton \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-15-25-845Z.yml b/.playwright-mcp/page-2026-07-26T09-15-25-845Z.yml new file mode 100644 index 00000000..9203d289 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-15-25-845Z.yml @@ -0,0 +1,222 @@ +- generic [ref=f67e1]: + - link "Skip to main content" [ref=f67e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f67e4]: + - paragraph [ref=f67e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f67e6] + - banner [ref=f67e9]: + - generic [ref=f67e10]: + - link "Acme Fashion" [ref=f67e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f67e13]: + - link "Home" [ref=f67e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f67e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f67e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f67e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f67e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f67e19]: + - button "Search" [ref=f67e20] + - link "Account" [ref=f67e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f67e26] + - main [ref=f67e29]: + - generic [ref=f67e30]: + - generic [ref=f67e34]: + - heading "Welcome to Acme Fashion" [level=1] [ref=f67e35] + - paragraph [ref=f67e36]: Discover our curated collection of modern essentials + - link "Shop New Arrivals" [ref=f67e37] [cursor=pointer]: + - /url: /collections/new-arrivals + - region [ref=f67e38]: + - heading "Featured collections" [level=2] [ref=f67e39] + - generic [ref=f67e40]: + - link "New Arrivals" [ref=f67e41] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - generic [ref=f67e43]: + - generic [ref=f67e44]: New Arrivals + - generic [ref=f67e45]: Shop now + - link "T-Shirts" [ref=f67e46] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - generic [ref=f67e48]: + - generic [ref=f67e49]: T-Shirts + - generic [ref=f67e50]: Shop now + - link "Sale" [ref=f67e51] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/sale + - generic [ref=f67e53]: + - generic [ref=f67e54]: Sale + - generic [ref=f67e55]: Shop now + - region [ref=f67e56]: + - heading "Featured products" [level=2] [ref=f67e57] + - generic [ref=f67e58]: + - generic [ref=f67e59]: + - link [ref=f67e61] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f67e65] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f67e66] + - generic [ref=f67e67]: 24.99 EUR + - link "Choose options" [ref=f67e71] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f67e72]: + - generic [ref=f67e73]: + - link [ref=f67e74] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f67e79]: + - generic [ref=f67e80]: "On sale:" + - text: Sale + - 'link "Premium Slim Fit Jeans 79.99 EUR 99.99 EUR On sale: Sale" [ref=f67e81] [cursor=pointer]': + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - heading "Premium Slim Fit Jeans" [level=3] [ref=f67e82] + - generic [ref=f67e84]: + - generic [ref=f67e85]: 79.99 EUR + - generic [ref=f67e86]: 99.99 EUR + - generic [ref=f67e87]: + - generic [ref=f67e88]: "On sale:" + - text: Sale + - link "Choose options" [ref=f67e90] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f67e91]: + - link [ref=f67e93] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - link "Organic Hoodie 59.99 EUR" [ref=f67e97] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - heading "Organic Hoodie" [level=3] [ref=f67e98] + - generic [ref=f67e99]: 59.99 EUR + - link "Choose options" [ref=f67e103] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - generic [ref=f67e104]: + - link [ref=f67e106] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - link "Leather Belt 34.99 EUR" [ref=f67e110] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - heading "Leather Belt" [level=3] [ref=f67e111] + - generic [ref=f67e112]: 34.99 EUR + - link "Choose options" [ref=f67e116] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - generic [ref=f67e117]: + - link [ref=f67e119] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - link "Running Sneakers 119.99 EUR" [ref=f67e123] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - heading "Running Sneakers" [level=3] [ref=f67e124] + - generic [ref=f67e125]: 119.99 EUR + - link "Choose options" [ref=f67e129] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - generic [ref=f67e130]: + - link [ref=f67e132] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f67e136] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f67e137] + - generic [ref=f67e138]: 29.99 EUR + - link "Choose options" [ref=f67e142] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - generic [ref=f67e143]: + - link [ref=f67e145] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - link "V-Neck Linen Tee 34.99 EUR" [ref=f67e149] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - heading "V-Neck Linen Tee" [level=3] [ref=f67e150] + - generic [ref=f67e151]: 34.99 EUR + - link "Choose options" [ref=f67e155] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - generic [ref=f67e156]: + - generic [ref=f67e157]: + - link [ref=f67e158] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - generic [ref=f67e163]: + - generic [ref=f67e164]: "On sale:" + - text: Sale + - 'link "Striped Polo Shirt 27.99 EUR 39.99 EUR On sale: Sale" [ref=f67e165] [cursor=pointer]': + - /url: http://acme-fashion.test/products/striped-polo-shirt + - heading "Striped Polo Shirt" [level=3] [ref=f67e166] + - generic [ref=f67e168]: + - generic [ref=f67e169]: 27.99 EUR + - generic [ref=f67e170]: 39.99 EUR + - generic [ref=f67e171]: + - generic [ref=f67e172]: "On sale:" + - text: Sale + - link "Choose options" [ref=f67e174] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - region [ref=f67e175]: + - generic [ref=f67e176]: + - heading "Stay in the loop" [level=2] [ref=f67e177] + - paragraph [ref=f67e178]: Subscribe for exclusive offers and updates. + - generic [ref=f67e180]: + - generic [ref=f67e181]: Email address + - textbox "Email address" [ref=f67e182]: + - /placeholder: Enter your email + - button "Subscribe" [ref=f67e183] + - contentinfo [ref=f67e184]: + - generic [ref=f67e185]: + - generic [ref=f67e186]: + - generic [ref=f67e187]: + - heading "Shop" [level=2] [ref=f67e188] + - list [ref=f67e189]: + - listitem [ref=f67e190]: + - link "About Us" [ref=f67e191] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f67e192]: + - link "FAQ" [ref=f67e193] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f67e194]: + - link "Shipping & Returns" [ref=f67e195] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f67e196]: + - link "Privacy Policy" [ref=f67e197] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f67e198]: + - link "Terms of Service" [ref=f67e199] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f67e200]: + - heading "Acme Fashion" [level=2] [ref=f67e201] + - paragraph [ref=f67e202]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f67e203]: + - paragraph [ref=f67e204]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f67e205]: + - generic [ref=f67e206]: VISA + - generic [ref=f67e207]: MASTERCARD + - generic [ref=f67e208]: AMEX + - generic [ref=f67e209]: PAYPAL + - generic: + - dialog "Search": + - generic [ref=f67e212]: + - search [ref=f67e213]: + - generic [ref=f67e216]: Search products + - combobox "Search products" [expanded] [active] [ref=f67e217]: cotton + - button "Search" [ref=f67e218] + - button "Close search" [ref=f67e219] + - generic [ref=f67e222]: + - listbox "Search suggestions" [ref=f67e223]: + - listitem [ref=f67e224]: Products + - option [ref=f67e225]: + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f67e226] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f67e230]: Classic Cotton T-Shirt + - generic [ref=f67e231]: 24.99 EUR + - option [ref=f67e234]: + - link "Cargo Pants 54.99 EUR" [ref=f67e235] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cargo-pants + - generic [ref=f67e239]: Cargo Pants + - generic [ref=f67e240]: 54.99 EUR + - option [ref=f67e243]: + - link "Organic Hoodie 59.99 EUR" [ref=f67e244] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - generic [ref=f67e248]: Organic Hoodie + - generic [ref=f67e249]: 59.99 EUR + - option [ref=f67e252]: + - link "Bucket Hat 24.99 EUR" [ref=f67e253] [cursor=pointer]: + - /url: http://acme-fashion.test/products/bucket-hat + - generic [ref=f67e257]: Bucket Hat + - generic [ref=f67e258]: 24.99 EUR + - option [ref=f67e261]: + - link "Graphic Print Tee 29.99 EUR" [ref=f67e262] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - generic [ref=f67e266]: Graphic Print Tee + - generic [ref=f67e267]: 29.99 EUR + - link "View all results for “cotton” →" [ref=f67e270] [cursor=pointer]: + - /url: http://acme-fashion.test/search?q=cotton \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-15-48-639Z.yml b/.playwright-mcp/page-2026-07-26T09-15-48-639Z.yml new file mode 100644 index 00000000..8af7f1a3 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-15-48-639Z.yml @@ -0,0 +1,165 @@ +- generic [active] [ref=f68e1]: + - link "Skip to main content" [ref=f68e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f68e4]: + - paragraph [ref=f68e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f68e6] + - banner [ref=f68e9]: + - generic [ref=f68e10]: + - link "Acme Fashion" [ref=f68e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f68e13]: + - link "Home" [ref=f68e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f68e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f68e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f68e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f68e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f68e19]: + - button "Search" [ref=f68e20] + - link "Account" [ref=f68e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f68e26] + - main [ref=f68e29]: + - generic [ref=f68e30]: + - navigation "Breadcrumb" [ref=f68e31]: + - list [ref=f68e32]: + - listitem [ref=f68e33]: + - link "Home" [ref=f68e34] [cursor=pointer]: + - /url: http://acme-fashion.test + - listitem [ref=f68e35]: + - generic [ref=f68e36]: / + - generic [ref=f68e37]: Search results + - generic [ref=f68e38]: + - heading "5 results for “cotton”" [level=1] [ref=f68e39] + - search [ref=f68e40]: + - generic [ref=f68e41]: Search products + - searchbox "Search products" [ref=f68e43]: cotton + - generic [ref=f68e44]: + - paragraph [ref=f68e45]: 5 results + - generic [ref=f68e46]: + - generic [ref=f68e47]: Sort by + - combobox "Sort by" [ref=f68e48]: + - option "Relevance" [selected] + - 'option "Price: Low to High"' + - 'option "Price: High to Low"' + - option "Newest" + - option "Best Selling" + - generic [ref=f68e49]: + - complementary "Search filters" [ref=f68e50]: + - generic [ref=f68e51]: + - group "Availability" [ref=f68e52]: + - generic [ref=f68e55]: + - checkbox "In stock" [ref=f68e56] + - text: In stock + - group "Price" [ref=f68e57]: + - generic [ref=f68e59]: + - generic [ref=f68e60]: Minimum price + - spinbutton "Minimum price Minimum price" [ref=f68e61] + - generic [ref=f68e62]: "-" + - generic [ref=f68e63]: Maximum price + - spinbutton "Maximum price Maximum price" [ref=f68e64] + - group "Collection" [ref=f68e65]: + - generic [ref=f68e67]: + - generic [ref=f68e68]: Collection + - combobox "Collection Collection" [ref=f68e69]: + - option "All collections" [selected] + - option "New Arrivals" + - option "Pants & Jeans" + - option "Sale" + - option "T-Shirts" + - group "Vendor" [ref=f68e70]: + - generic [ref=f68e72]: + - generic [ref=f68e73]: + - checkbox "Acme Accessories (1)" [ref=f68e74] + - text: Acme Accessories + - generic [ref=f68e75]: (1) + - generic [ref=f68e76]: + - checkbox "Acme Basics (3)" [ref=f68e77] + - text: Acme Basics + - generic [ref=f68e78]: (3) + - generic [ref=f68e79]: + - checkbox "Acme Workwear (1)" [ref=f68e80] + - text: Acme Workwear + - generic [ref=f68e81]: (1) + - generic [ref=f68e83]: + - generic [ref=f68e84]: + - link [ref=f68e86] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f68e90] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f68e91] + - generic [ref=f68e92]: 24.99 EUR + - link "Choose options" [ref=f68e96] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f68e97]: + - link [ref=f68e99] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cargo-pants + - link "Cargo Pants 54.99 EUR" [ref=f68e103] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cargo-pants + - heading "Cargo Pants" [level=3] [ref=f68e104] + - generic [ref=f68e105]: 54.99 EUR + - link "Choose options" [ref=f68e109] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cargo-pants + - generic [ref=f68e110]: + - link [ref=f68e112] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - link "Organic Hoodie 59.99 EUR" [ref=f68e116] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - heading "Organic Hoodie" [level=3] [ref=f68e117] + - generic [ref=f68e118]: 59.99 EUR + - link "Choose options" [ref=f68e122] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - generic [ref=f68e123]: + - link [ref=f68e125] [cursor=pointer]: + - /url: http://acme-fashion.test/products/bucket-hat + - link "Bucket Hat 24.99 EUR" [ref=f68e129] [cursor=pointer]: + - /url: http://acme-fashion.test/products/bucket-hat + - heading "Bucket Hat" [level=3] [ref=f68e130] + - generic [ref=f68e131]: 24.99 EUR + - link "Choose options" [ref=f68e135] [cursor=pointer]: + - /url: http://acme-fashion.test/products/bucket-hat + - generic [ref=f68e136]: + - link [ref=f68e138] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f68e142] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f68e143] + - generic [ref=f68e144]: 29.99 EUR + - link "Choose options" [ref=f68e148] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - contentinfo [ref=f68e149]: + - generic [ref=f68e150]: + - generic [ref=f68e151]: + - generic [ref=f68e152]: + - heading "Shop" [level=2] [ref=f68e153] + - list [ref=f68e154]: + - listitem [ref=f68e155]: + - link "About Us" [ref=f68e156] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f68e157]: + - link "FAQ" [ref=f68e158] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f68e159]: + - link "Shipping & Returns" [ref=f68e160] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f68e161]: + - link "Privacy Policy" [ref=f68e162] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f68e163]: + - link "Terms of Service" [ref=f68e164] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f68e165]: + - heading "Acme Fashion" [level=2] [ref=f68e166] + - paragraph [ref=f68e167]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f68e168]: + - paragraph [ref=f68e169]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f68e170]: + - generic [ref=f68e171]: VISA + - generic [ref=f68e172]: MASTERCARD + - generic [ref=f68e173]: AMEX + - generic [ref=f68e174]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-16-14-985Z.yml b/.playwright-mcp/page-2026-07-26T09-16-14-985Z.yml new file mode 100644 index 00000000..273dc475 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-16-14-985Z.yml @@ -0,0 +1,152 @@ +- generic [active] [ref=f69e1]: + - link "Skip to main content" [ref=f69e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f69e4]: + - paragraph [ref=f69e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f69e6] + - banner [ref=f69e9]: + - generic [ref=f69e10]: + - link "Acme Fashion" [ref=f69e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f69e13]: + - link "Home" [ref=f69e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f69e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f69e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f69e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f69e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f69e19]: + - button "Search" [ref=f69e20] + - link "Account" [ref=f69e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f69e26] + - main [ref=f69e29]: + - generic [ref=f69e30]: + - navigation "Breadcrumb" [ref=f69e31]: + - list [ref=f69e32]: + - listitem [ref=f69e33]: + - link "Home" [ref=f69e34] [cursor=pointer]: + - /url: http://acme-fashion.test + - listitem [ref=f69e35]: + - generic [ref=f69e36]: / + - link "Collections" [ref=f69e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - listitem [ref=f69e38]: + - generic [ref=f69e39]: / + - generic [ref=f69e40]: T-Shirts + - generic [ref=f69e41]: + - heading "T-Shirts" [level=1] [ref=f69e42] + - paragraph [ref=f69e44]: Premium cotton tees for every occasion. + - generic [ref=f69e45]: + - paragraph [ref=f69e46]: 4 products + - generic [ref=f69e47]: + - generic [ref=f69e48]: Sort by + - combobox "Sort by" [ref=f69e49]: + - option "Featured" [selected] + - 'option "Price: Low to High"' + - 'option "Price: High to Low"' + - option "Newest" + - option "Best Selling" + - generic [ref=f69e50]: + - complementary "Product filters" [ref=f69e51]: + - generic [ref=f69e52]: + - group "Availability" [ref=f69e53]: + - generic [ref=f69e56]: + - checkbox "In stock" [ref=f69e57] + - text: In stock + - group "Price" [ref=f69e58]: + - generic [ref=f69e60]: + - generic [ref=f69e61]: Minimum price + - spinbutton "Minimum price Minimum price" [ref=f69e62] + - generic [ref=f69e63]: "-" + - generic [ref=f69e64]: Maximum price + - spinbutton "Maximum price Maximum price" [ref=f69e65] + - group "Product type" [ref=f69e66]: + - generic [ref=f69e69]: + - checkbox "T-Shirts" [ref=f69e70] + - text: T-Shirts + - group "Vendor" [ref=f69e71]: + - generic [ref=f69e74]: + - checkbox "Acme Basics" [ref=f69e75] + - text: Acme Basics + - generic [ref=f69e77]: + - generic [ref=f69e78]: + - link [ref=f69e80] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f69e84] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f69e85] + - generic [ref=f69e86]: 24.99 EUR + - link "Choose options" [ref=f69e90] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f69e91]: + - link [ref=f69e93] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f69e97] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f69e98] + - generic [ref=f69e99]: 29.99 EUR + - link "Choose options" [ref=f69e103] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - generic [ref=f69e104]: + - link [ref=f69e106] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - link "V-Neck Linen Tee 34.99 EUR" [ref=f69e110] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - heading "V-Neck Linen Tee" [level=3] [ref=f69e111] + - generic [ref=f69e112]: 34.99 EUR + - link "Choose options" [ref=f69e116] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - generic [ref=f69e117]: + - generic [ref=f69e118]: + - link [ref=f69e119] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - generic [ref=f69e124]: + - generic [ref=f69e125]: "On sale:" + - text: Sale + - 'link "Striped Polo Shirt 27.99 EUR 39.99 EUR On sale: Sale" [ref=f69e126] [cursor=pointer]': + - /url: http://acme-fashion.test/products/striped-polo-shirt + - heading "Striped Polo Shirt" [level=3] [ref=f69e127] + - generic [ref=f69e129]: + - generic [ref=f69e130]: 27.99 EUR + - generic [ref=f69e131]: 39.99 EUR + - generic [ref=f69e132]: + - generic [ref=f69e133]: "On sale:" + - text: Sale + - link "Choose options" [ref=f69e135] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - contentinfo [ref=f69e136]: + - generic [ref=f69e137]: + - generic [ref=f69e138]: + - generic [ref=f69e139]: + - heading "Shop" [level=2] [ref=f69e140] + - list [ref=f69e141]: + - listitem [ref=f69e142]: + - link "About Us" [ref=f69e143] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f69e144]: + - link "FAQ" [ref=f69e145] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f69e146]: + - link "Shipping & Returns" [ref=f69e147] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f69e148]: + - link "Privacy Policy" [ref=f69e149] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f69e150]: + - link "Terms of Service" [ref=f69e151] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f69e152]: + - heading "Acme Fashion" [level=2] [ref=f69e153] + - paragraph [ref=f69e154]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f69e155]: + - paragraph [ref=f69e156]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f69e157]: + - generic [ref=f69e158]: VISA + - generic [ref=f69e159]: MASTERCARD + - generic [ref=f69e160]: AMEX + - generic [ref=f69e161]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-17-10-024Z.yml b/.playwright-mcp/page-2026-07-26T09-17-10-024Z.yml new file mode 100644 index 00000000..05dc002e --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-17-10-024Z.yml @@ -0,0 +1,152 @@ +- generic [active] [ref=f69e1]: + - link "Skip to main content" [ref=f69e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f69e4]: + - paragraph [ref=f69e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f69e6] + - banner [ref=f69e9]: + - generic [ref=f69e10]: + - link "Acme Fashion" [ref=f69e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f69e13]: + - link "Home" [ref=f69e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f69e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f69e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f69e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f69e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f69e19]: + - button "Search" [ref=f69e20] + - link "Account" [ref=f69e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f69e26] + - main [ref=f69e29]: + - generic [ref=f69e30]: + - navigation "Breadcrumb" [ref=f69e31]: + - list [ref=f69e32]: + - listitem [ref=f69e33]: + - link "Home" [ref=f69e34] [cursor=pointer]: + - /url: http://acme-fashion.test + - listitem [ref=f69e35]: + - generic [ref=f69e36]: / + - link "Collections" [ref=f69e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - listitem [ref=f69e38]: + - generic [ref=f69e39]: / + - generic [ref=f69e40]: T-Shirts + - generic [ref=f69e41]: + - heading "T-Shirts" [level=1] [ref=f69e42] + - paragraph [ref=f69e44]: Premium cotton tees for every occasion. + - generic [ref=f69e45]: + - paragraph [ref=f69e46]: 4 products + - generic [ref=f69e47]: + - generic [ref=f69e48]: Sort by + - combobox "Sort by" [ref=f69e49]: + - option "Featured" + - 'option "Price: Low to High" [selected]' + - 'option "Price: High to Low"' + - option "Newest" + - option "Best Selling" + - generic [ref=f69e50]: + - complementary "Product filters" [ref=f69e51]: + - generic [ref=f69e52]: + - group "Availability" [ref=f69e53]: + - generic [ref=f69e56]: + - checkbox "In stock" [ref=f69e57] + - text: In stock + - group "Price" [ref=f69e58]: + - generic [ref=f69e60]: + - generic [ref=f69e61]: Minimum price + - spinbutton "Minimum price Minimum price" [ref=f69e62] + - generic [ref=f69e63]: "-" + - generic [ref=f69e64]: Maximum price + - spinbutton "Maximum price Maximum price" [ref=f69e65] + - group "Product type" [ref=f69e66]: + - generic [ref=f69e69]: + - checkbox "T-Shirts" [ref=f69e70] + - text: T-Shirts + - group "Vendor" [ref=f69e71]: + - generic [ref=f69e74]: + - checkbox "Acme Basics" [ref=f69e75] + - text: Acme Basics + - generic [ref=f69e77]: + - generic [ref=f69e78]: + - link [ref=f69e80] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f69e84] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f69e85] + - generic [ref=f69e86]: 24.99 EUR + - link "Choose options" [ref=f69e90] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f69e91]: + - link [ref=f69e93] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f69e97] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f69e98] + - generic [ref=f69e99]: 29.99 EUR + - link "Choose options" [ref=f69e103] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - generic [ref=f69e104]: + - link [ref=f69e106] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - link "V-Neck Linen Tee 34.99 EUR" [ref=f69e110] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - heading "V-Neck Linen Tee" [level=3] [ref=f69e111] + - generic [ref=f69e112]: 34.99 EUR + - link "Choose options" [ref=f69e116] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - generic [ref=f69e117]: + - generic [ref=f69e118]: + - link [ref=f69e119] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - generic [ref=f69e124]: + - generic [ref=f69e125]: "On sale:" + - text: Sale + - 'link "Striped Polo Shirt 27.99 EUR 39.99 EUR On sale: Sale" [ref=f69e126] [cursor=pointer]': + - /url: http://acme-fashion.test/products/striped-polo-shirt + - heading "Striped Polo Shirt" [level=3] [ref=f69e127] + - generic [ref=f69e129]: + - generic [ref=f69e130]: 27.99 EUR + - generic [ref=f69e131]: 39.99 EUR + - generic [ref=f69e132]: + - generic [ref=f69e133]: "On sale:" + - text: Sale + - link "Choose options" [ref=f69e135] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - contentinfo [ref=f69e136]: + - generic [ref=f69e137]: + - generic [ref=f69e138]: + - generic [ref=f69e139]: + - heading "Shop" [level=2] [ref=f69e140] + - list [ref=f69e141]: + - listitem [ref=f69e142]: + - link "About Us" [ref=f69e143] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f69e144]: + - link "FAQ" [ref=f69e145] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f69e146]: + - link "Shipping & Returns" [ref=f69e147] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f69e148]: + - link "Privacy Policy" [ref=f69e149] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f69e150]: + - link "Terms of Service" [ref=f69e151] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f69e152]: + - heading "Acme Fashion" [level=2] [ref=f69e153] + - paragraph [ref=f69e154]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f69e155]: + - paragraph [ref=f69e156]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f69e157]: + - generic [ref=f69e158]: VISA + - generic [ref=f69e159]: MASTERCARD + - generic [ref=f69e160]: AMEX + - generic [ref=f69e161]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-17-43-335Z.yml b/.playwright-mcp/page-2026-07-26T09-17-43-335Z.yml new file mode 100644 index 00000000..05dc002e --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-17-43-335Z.yml @@ -0,0 +1,152 @@ +- generic [active] [ref=f69e1]: + - link "Skip to main content" [ref=f69e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f69e4]: + - paragraph [ref=f69e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f69e6] + - banner [ref=f69e9]: + - generic [ref=f69e10]: + - link "Acme Fashion" [ref=f69e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f69e13]: + - link "Home" [ref=f69e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f69e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f69e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f69e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f69e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f69e19]: + - button "Search" [ref=f69e20] + - link "Account" [ref=f69e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f69e26] + - main [ref=f69e29]: + - generic [ref=f69e30]: + - navigation "Breadcrumb" [ref=f69e31]: + - list [ref=f69e32]: + - listitem [ref=f69e33]: + - link "Home" [ref=f69e34] [cursor=pointer]: + - /url: http://acme-fashion.test + - listitem [ref=f69e35]: + - generic [ref=f69e36]: / + - link "Collections" [ref=f69e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - listitem [ref=f69e38]: + - generic [ref=f69e39]: / + - generic [ref=f69e40]: T-Shirts + - generic [ref=f69e41]: + - heading "T-Shirts" [level=1] [ref=f69e42] + - paragraph [ref=f69e44]: Premium cotton tees for every occasion. + - generic [ref=f69e45]: + - paragraph [ref=f69e46]: 4 products + - generic [ref=f69e47]: + - generic [ref=f69e48]: Sort by + - combobox "Sort by" [ref=f69e49]: + - option "Featured" + - 'option "Price: Low to High" [selected]' + - 'option "Price: High to Low"' + - option "Newest" + - option "Best Selling" + - generic [ref=f69e50]: + - complementary "Product filters" [ref=f69e51]: + - generic [ref=f69e52]: + - group "Availability" [ref=f69e53]: + - generic [ref=f69e56]: + - checkbox "In stock" [ref=f69e57] + - text: In stock + - group "Price" [ref=f69e58]: + - generic [ref=f69e60]: + - generic [ref=f69e61]: Minimum price + - spinbutton "Minimum price Minimum price" [ref=f69e62] + - generic [ref=f69e63]: "-" + - generic [ref=f69e64]: Maximum price + - spinbutton "Maximum price Maximum price" [ref=f69e65] + - group "Product type" [ref=f69e66]: + - generic [ref=f69e69]: + - checkbox "T-Shirts" [ref=f69e70] + - text: T-Shirts + - group "Vendor" [ref=f69e71]: + - generic [ref=f69e74]: + - checkbox "Acme Basics" [ref=f69e75] + - text: Acme Basics + - generic [ref=f69e77]: + - generic [ref=f69e78]: + - link [ref=f69e80] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f69e84] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f69e85] + - generic [ref=f69e86]: 24.99 EUR + - link "Choose options" [ref=f69e90] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f69e91]: + - link [ref=f69e93] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f69e97] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f69e98] + - generic [ref=f69e99]: 29.99 EUR + - link "Choose options" [ref=f69e103] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - generic [ref=f69e104]: + - link [ref=f69e106] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - link "V-Neck Linen Tee 34.99 EUR" [ref=f69e110] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - heading "V-Neck Linen Tee" [level=3] [ref=f69e111] + - generic [ref=f69e112]: 34.99 EUR + - link "Choose options" [ref=f69e116] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - generic [ref=f69e117]: + - generic [ref=f69e118]: + - link [ref=f69e119] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - generic [ref=f69e124]: + - generic [ref=f69e125]: "On sale:" + - text: Sale + - 'link "Striped Polo Shirt 27.99 EUR 39.99 EUR On sale: Sale" [ref=f69e126] [cursor=pointer]': + - /url: http://acme-fashion.test/products/striped-polo-shirt + - heading "Striped Polo Shirt" [level=3] [ref=f69e127] + - generic [ref=f69e129]: + - generic [ref=f69e130]: 27.99 EUR + - generic [ref=f69e131]: 39.99 EUR + - generic [ref=f69e132]: + - generic [ref=f69e133]: "On sale:" + - text: Sale + - link "Choose options" [ref=f69e135] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - contentinfo [ref=f69e136]: + - generic [ref=f69e137]: + - generic [ref=f69e138]: + - generic [ref=f69e139]: + - heading "Shop" [level=2] [ref=f69e140] + - list [ref=f69e141]: + - listitem [ref=f69e142]: + - link "About Us" [ref=f69e143] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f69e144]: + - link "FAQ" [ref=f69e145] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f69e146]: + - link "Shipping & Returns" [ref=f69e147] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f69e148]: + - link "Privacy Policy" [ref=f69e149] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f69e150]: + - link "Terms of Service" [ref=f69e151] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f69e152]: + - heading "Acme Fashion" [level=2] [ref=f69e153] + - paragraph [ref=f69e154]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f69e155]: + - paragraph [ref=f69e156]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f69e157]: + - generic [ref=f69e158]: VISA + - generic [ref=f69e159]: MASTERCARD + - generic [ref=f69e160]: AMEX + - generic [ref=f69e161]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-19-27-517Z.yml b/.playwright-mcp/page-2026-07-26T09-19-27-517Z.yml new file mode 100644 index 00000000..ab1da69f --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-19-27-517Z.yml @@ -0,0 +1,152 @@ +- generic [active] [ref=f69e1]: + - link "Skip to main content" [ref=f69e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f69e4]: + - paragraph [ref=f69e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f69e6] + - banner [ref=f69e9]: + - generic [ref=f69e10]: + - link "Acme Fashion" [ref=f69e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f69e13]: + - link "Home" [ref=f69e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f69e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f69e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f69e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f69e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f69e19]: + - button "Search" [ref=f69e20] + - link "Account" [ref=f69e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f69e26] + - main [ref=f69e29]: + - generic [ref=f69e30]: + - navigation "Breadcrumb" [ref=f69e31]: + - list [ref=f69e32]: + - listitem [ref=f69e33]: + - link "Home" [ref=f69e34] [cursor=pointer]: + - /url: http://acme-fashion.test + - listitem [ref=f69e35]: + - generic [ref=f69e36]: / + - link "Collections" [ref=f69e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - listitem [ref=f69e38]: + - generic [ref=f69e39]: / + - generic [ref=f69e40]: T-Shirts + - generic [ref=f69e41]: + - heading "T-Shirts" [level=1] [ref=f69e42] + - paragraph [ref=f69e44]: Premium cotton tees for every occasion. + - generic [ref=f69e45]: + - paragraph [ref=f69e46]: 4 products + - generic [ref=f69e47]: + - generic [ref=f69e48]: Sort by + - combobox "Sort by" [ref=f69e49]: + - option "Featured" + - 'option "Price: Low to High"' + - 'option "Price: High to Low" [selected]' + - option "Newest" + - option "Best Selling" + - generic [ref=f69e50]: + - complementary "Product filters" [ref=f69e51]: + - generic [ref=f69e52]: + - group "Availability" [ref=f69e53]: + - generic [ref=f69e56]: + - checkbox "In stock" [ref=f69e57] + - text: In stock + - group "Price" [ref=f69e58]: + - generic [ref=f69e60]: + - generic [ref=f69e61]: Minimum price + - spinbutton "Minimum price Minimum price" [ref=f69e62] + - generic [ref=f69e63]: "-" + - generic [ref=f69e64]: Maximum price + - spinbutton "Maximum price Maximum price" [ref=f69e65] + - group "Product type" [ref=f69e66]: + - generic [ref=f69e69]: + - checkbox "T-Shirts" [ref=f69e70] + - text: T-Shirts + - group "Vendor" [ref=f69e71]: + - generic [ref=f69e74]: + - checkbox "Acme Basics" [ref=f69e75] + - text: Acme Basics + - generic [ref=f69e77]: + - generic [ref=f69e78]: + - link [ref=f69e80] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f69e84] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f69e85] + - generic [ref=f69e86]: 24.99 EUR + - link "Choose options" [ref=f69e90] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f69e91]: + - link [ref=f69e93] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f69e97] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f69e98] + - generic [ref=f69e99]: 29.99 EUR + - link "Choose options" [ref=f69e103] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - generic [ref=f69e104]: + - link [ref=f69e106] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - link "V-Neck Linen Tee 34.99 EUR" [ref=f69e110] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - heading "V-Neck Linen Tee" [level=3] [ref=f69e111] + - generic [ref=f69e112]: 34.99 EUR + - link "Choose options" [ref=f69e116] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - generic [ref=f69e117]: + - generic [ref=f69e118]: + - link [ref=f69e119] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - generic [ref=f69e124]: + - generic [ref=f69e125]: "On sale:" + - text: Sale + - 'link "Striped Polo Shirt 27.99 EUR 39.99 EUR On sale: Sale" [ref=f69e126] [cursor=pointer]': + - /url: http://acme-fashion.test/products/striped-polo-shirt + - heading "Striped Polo Shirt" [level=3] [ref=f69e127] + - generic [ref=f69e129]: + - generic [ref=f69e130]: 27.99 EUR + - generic [ref=f69e131]: 39.99 EUR + - generic [ref=f69e132]: + - generic [ref=f69e133]: "On sale:" + - text: Sale + - link "Choose options" [ref=f69e135] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - contentinfo [ref=f69e136]: + - generic [ref=f69e137]: + - generic [ref=f69e138]: + - generic [ref=f69e139]: + - heading "Shop" [level=2] [ref=f69e140] + - list [ref=f69e141]: + - listitem [ref=f69e142]: + - link "About Us" [ref=f69e143] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f69e144]: + - link "FAQ" [ref=f69e145] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f69e146]: + - link "Shipping & Returns" [ref=f69e147] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f69e148]: + - link "Privacy Policy" [ref=f69e149] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f69e150]: + - link "Terms of Service" [ref=f69e151] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f69e152]: + - heading "Acme Fashion" [level=2] [ref=f69e153] + - paragraph [ref=f69e154]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f69e155]: + - paragraph [ref=f69e156]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f69e157]: + - generic [ref=f69e158]: VISA + - generic [ref=f69e159]: MASTERCARD + - generic [ref=f69e160]: AMEX + - generic [ref=f69e161]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-19-38-689Z.yml b/.playwright-mcp/page-2026-07-26T09-19-38-689Z.yml new file mode 100644 index 00000000..0034addf --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-19-38-689Z.yml @@ -0,0 +1,152 @@ +- generic [active] [ref=f69e1]: + - link "Skip to main content" [ref=f69e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f69e4]: + - paragraph [ref=f69e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f69e6] + - banner [ref=f69e9]: + - generic [ref=f69e10]: + - link "Acme Fashion" [ref=f69e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f69e13]: + - link "Home" [ref=f69e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f69e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f69e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f69e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f69e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f69e19]: + - button "Search" [ref=f69e20] + - link "Account" [ref=f69e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f69e26] + - main [ref=f69e29]: + - generic [ref=f69e30]: + - navigation "Breadcrumb" [ref=f69e31]: + - list [ref=f69e32]: + - listitem [ref=f69e33]: + - link "Home" [ref=f69e34] [cursor=pointer]: + - /url: http://acme-fashion.test + - listitem [ref=f69e35]: + - generic [ref=f69e36]: / + - link "Collections" [ref=f69e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - listitem [ref=f69e38]: + - generic [ref=f69e39]: / + - generic [ref=f69e40]: T-Shirts + - generic [ref=f69e41]: + - heading "T-Shirts" [level=1] [ref=f69e42] + - paragraph [ref=f69e44]: Premium cotton tees for every occasion. + - generic [ref=f69e45]: + - paragraph [ref=f69e46]: 4 products + - generic [ref=f69e47]: + - generic [ref=f69e48]: Sort by + - combobox "Sort by" [ref=f69e49]: + - option "Featured" + - 'option "Price: Low to High"' + - 'option "Price: High to Low" [selected]' + - option "Newest" + - option "Best Selling" + - generic [ref=f69e50]: + - complementary "Product filters" [ref=f69e51]: + - generic [ref=f69e52]: + - group "Availability" [ref=f69e53]: + - generic [ref=f69e56]: + - checkbox "In stock" [ref=f69e57] + - text: In stock + - group "Price" [ref=f69e58]: + - generic [ref=f69e60]: + - generic [ref=f69e61]: Minimum price + - spinbutton "Minimum price Minimum price" [ref=f69e62] + - generic [ref=f69e63]: "-" + - generic [ref=f69e64]: Maximum price + - spinbutton "Maximum price Maximum price" [ref=f69e65] + - group "Product type" [ref=f69e66]: + - generic [ref=f69e69]: + - checkbox "T-Shirts" [ref=f69e70] + - text: T-Shirts + - group "Vendor" [ref=f69e71]: + - generic [ref=f69e74]: + - checkbox "Acme Basics" [ref=f69e75] + - text: Acme Basics + - generic [ref=f69e77]: + - generic [ref=f69e91]: + - link [ref=f69e93] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f69e97] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f69e98] + - generic [ref=f69e99]: 29.99 EUR + - link "Choose options" [ref=f69e103] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - generic [ref=f69e78]: + - link [ref=f69e80] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f69e84] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f69e85] + - generic [ref=f69e86]: 24.99 EUR + - link "Choose options" [ref=f69e90] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f69e104]: + - link [ref=f69e106] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - link "V-Neck Linen Tee 34.99 EUR" [ref=f69e110] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - heading "V-Neck Linen Tee" [level=3] [ref=f69e111] + - generic [ref=f69e112]: 34.99 EUR + - link "Choose options" [ref=f69e116] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - generic [ref=f69e117]: + - generic [ref=f69e118]: + - link [ref=f69e119] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - generic [ref=f69e124]: + - generic [ref=f69e125]: "On sale:" + - text: Sale + - 'link "Striped Polo Shirt 27.99 EUR 39.99 EUR On sale: Sale" [ref=f69e126] [cursor=pointer]': + - /url: http://acme-fashion.test/products/striped-polo-shirt + - heading "Striped Polo Shirt" [level=3] [ref=f69e127] + - generic [ref=f69e129]: + - generic [ref=f69e130]: 27.99 EUR + - generic [ref=f69e131]: 39.99 EUR + - generic [ref=f69e132]: + - generic [ref=f69e133]: "On sale:" + - text: Sale + - link "Choose options" [ref=f69e135] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - contentinfo [ref=f69e136]: + - generic [ref=f69e137]: + - generic [ref=f69e138]: + - generic [ref=f69e139]: + - heading "Shop" [level=2] [ref=f69e140] + - list [ref=f69e141]: + - listitem [ref=f69e142]: + - link "About Us" [ref=f69e143] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f69e144]: + - link "FAQ" [ref=f69e145] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f69e146]: + - link "Shipping & Returns" [ref=f69e147] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f69e148]: + - link "Privacy Policy" [ref=f69e149] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f69e150]: + - link "Terms of Service" [ref=f69e151] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f69e152]: + - heading "Acme Fashion" [level=2] [ref=f69e153] + - paragraph [ref=f69e154]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f69e155]: + - paragraph [ref=f69e156]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f69e157]: + - generic [ref=f69e158]: VISA + - generic [ref=f69e159]: MASTERCARD + - generic [ref=f69e160]: AMEX + - generic [ref=f69e161]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-23-40-722Z.yml b/.playwright-mcp/page-2026-07-26T09-23-40-722Z.yml new file mode 100644 index 00000000..e56663ad --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-23-40-722Z.yml @@ -0,0 +1,152 @@ +- generic [active] [ref=f70e1]: + - link "Skip to main content" [ref=f70e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f70e4]: + - paragraph [ref=f70e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f70e6] + - banner [ref=f70e9]: + - generic [ref=f70e10]: + - link "Acme Fashion" [ref=f70e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f70e13]: + - link "Home" [ref=f70e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f70e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f70e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f70e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f70e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f70e19]: + - button "Search" [ref=f70e20] + - link "Account" [ref=f70e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f70e26] + - main [ref=f70e29]: + - generic [ref=f70e30]: + - navigation "Breadcrumb" [ref=f70e31]: + - list [ref=f70e32]: + - listitem [ref=f70e33]: + - link "Home" [ref=f70e34] [cursor=pointer]: + - /url: http://acme-fashion.test + - listitem [ref=f70e35]: + - generic [ref=f70e36]: / + - link "Collections" [ref=f70e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - listitem [ref=f70e38]: + - generic [ref=f70e39]: / + - generic [ref=f70e40]: T-Shirts + - generic [ref=f70e41]: + - heading "T-Shirts" [level=1] [ref=f70e42] + - paragraph [ref=f70e44]: Premium cotton tees for every occasion. + - generic [ref=f70e45]: + - paragraph [ref=f70e46]: 4 products + - generic [ref=f70e47]: + - generic [ref=f70e48]: Sort by + - combobox "Sort by" [ref=f70e49]: + - option "Featured" [selected] + - 'option "Price: Low to High"' + - 'option "Price: High to Low"' + - option "Newest" + - option "Best Selling" + - generic [ref=f70e50]: + - complementary "Product filters" [ref=f70e51]: + - generic [ref=f70e52]: + - group "Availability" [ref=f70e53]: + - generic [ref=f70e56]: + - checkbox "In stock" [ref=f70e57] + - text: In stock + - group "Price" [ref=f70e58]: + - generic [ref=f70e60]: + - generic [ref=f70e61]: Minimum price + - spinbutton "Minimum price Minimum price" [ref=f70e62] + - generic [ref=f70e63]: "-" + - generic [ref=f70e64]: Maximum price + - spinbutton "Maximum price Maximum price" [ref=f70e65] + - group "Product type" [ref=f70e66]: + - generic [ref=f70e69]: + - checkbox "T-Shirts" [ref=f70e70] + - text: T-Shirts + - group "Vendor" [ref=f70e71]: + - generic [ref=f70e74]: + - checkbox "Acme Basics" [ref=f70e75] + - text: Acme Basics + - generic [ref=f70e77]: + - generic [ref=f70e78]: + - link [ref=f70e80] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f70e84] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f70e85] + - generic [ref=f70e86]: 24.99 EUR + - link "Choose options" [ref=f70e90] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f70e91]: + - link [ref=f70e93] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f70e97] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f70e98] + - generic [ref=f70e99]: 29.99 EUR + - link "Choose options" [ref=f70e103] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - generic [ref=f70e104]: + - link [ref=f70e106] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - link "V-Neck Linen Tee 34.99 EUR" [ref=f70e110] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - heading "V-Neck Linen Tee" [level=3] [ref=f70e111] + - generic [ref=f70e112]: 34.99 EUR + - link "Choose options" [ref=f70e116] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - generic [ref=f70e117]: + - generic [ref=f70e118]: + - link [ref=f70e119] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - generic [ref=f70e124]: + - generic [ref=f70e125]: "On sale:" + - text: Sale + - 'link "Striped Polo Shirt 27.99 EUR 39.99 EUR On sale: Sale" [ref=f70e126] [cursor=pointer]': + - /url: http://acme-fashion.test/products/striped-polo-shirt + - heading "Striped Polo Shirt" [level=3] [ref=f70e127] + - generic [ref=f70e129]: + - generic [ref=f70e130]: 27.99 EUR + - generic [ref=f70e131]: 39.99 EUR + - generic [ref=f70e132]: + - generic [ref=f70e133]: "On sale:" + - text: Sale + - link "Choose options" [ref=f70e135] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - contentinfo [ref=f70e136]: + - generic [ref=f70e137]: + - generic [ref=f70e138]: + - generic [ref=f70e139]: + - heading "Shop" [level=2] [ref=f70e140] + - list [ref=f70e141]: + - listitem [ref=f70e142]: + - link "About Us" [ref=f70e143] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f70e144]: + - link "FAQ" [ref=f70e145] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f70e146]: + - link "Shipping & Returns" [ref=f70e147] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f70e148]: + - link "Privacy Policy" [ref=f70e149] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f70e150]: + - link "Terms of Service" [ref=f70e151] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f70e152]: + - heading "Acme Fashion" [level=2] [ref=f70e153] + - paragraph [ref=f70e154]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f70e155]: + - paragraph [ref=f70e156]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f70e157]: + - generic [ref=f70e158]: VISA + - generic [ref=f70e159]: MASTERCARD + - generic [ref=f70e160]: AMEX + - generic [ref=f70e161]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-23-53-227Z.yml b/.playwright-mcp/page-2026-07-26T09-23-53-227Z.yml new file mode 100644 index 00000000..7cf14b0c --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-23-53-227Z.yml @@ -0,0 +1,152 @@ +- generic [active] [ref=f70e1]: + - link "Skip to main content" [ref=f70e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f70e4]: + - paragraph [ref=f70e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f70e6] + - banner [ref=f70e9]: + - generic [ref=f70e10]: + - link "Acme Fashion" [ref=f70e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f70e13]: + - link "Home" [ref=f70e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f70e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f70e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f70e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f70e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f70e19]: + - button "Search" [ref=f70e20] + - link "Account" [ref=f70e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f70e26] + - main [ref=f70e29]: + - generic [ref=f70e30]: + - navigation "Breadcrumb" [ref=f70e31]: + - list [ref=f70e32]: + - listitem [ref=f70e33]: + - link "Home" [ref=f70e34] [cursor=pointer]: + - /url: http://acme-fashion.test + - listitem [ref=f70e35]: + - generic [ref=f70e36]: / + - link "Collections" [ref=f70e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - listitem [ref=f70e38]: + - generic [ref=f70e39]: / + - generic [ref=f70e40]: T-Shirts + - generic [ref=f70e41]: + - heading "T-Shirts" [level=1] [ref=f70e42] + - paragraph [ref=f70e44]: Premium cotton tees for every occasion. + - generic [ref=f70e45]: + - paragraph [ref=f70e46]: 4 products + - generic [ref=f70e47]: + - generic [ref=f70e48]: Sort by + - combobox "Sort by" [ref=f70e49]: + - option "Featured" + - 'option "Price: Low to High" [selected]' + - 'option "Price: High to Low"' + - option "Newest" + - option "Best Selling" + - generic [ref=f70e50]: + - complementary "Product filters" [ref=f70e51]: + - generic [ref=f70e52]: + - group "Availability" [ref=f70e53]: + - generic [ref=f70e56]: + - checkbox "In stock" [ref=f70e57] + - text: In stock + - group "Price" [ref=f70e58]: + - generic [ref=f70e60]: + - generic [ref=f70e61]: Minimum price + - spinbutton "Minimum price Minimum price" [ref=f70e62] + - generic [ref=f70e63]: "-" + - generic [ref=f70e64]: Maximum price + - spinbutton "Maximum price Maximum price" [ref=f70e65] + - group "Product type" [ref=f70e66]: + - generic [ref=f70e69]: + - checkbox "T-Shirts" [ref=f70e70] + - text: T-Shirts + - group "Vendor" [ref=f70e71]: + - generic [ref=f70e74]: + - checkbox "Acme Basics" [ref=f70e75] + - text: Acme Basics + - generic [ref=f70e77]: + - generic [ref=f70e78]: + - link [ref=f70e80] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f70e84] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f70e85] + - generic [ref=f70e86]: 24.99 EUR + - link "Choose options" [ref=f70e90] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f70e91]: + - link [ref=f70e93] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f70e97] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f70e98] + - generic [ref=f70e99]: 29.99 EUR + - link "Choose options" [ref=f70e103] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - generic [ref=f70e104]: + - link [ref=f70e106] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - link "V-Neck Linen Tee 34.99 EUR" [ref=f70e110] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - heading "V-Neck Linen Tee" [level=3] [ref=f70e111] + - generic [ref=f70e112]: 34.99 EUR + - link "Choose options" [ref=f70e116] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - generic [ref=f70e117]: + - generic [ref=f70e118]: + - link [ref=f70e119] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - generic [ref=f70e124]: + - generic [ref=f70e125]: "On sale:" + - text: Sale + - 'link "Striped Polo Shirt 27.99 EUR 39.99 EUR On sale: Sale" [ref=f70e126] [cursor=pointer]': + - /url: http://acme-fashion.test/products/striped-polo-shirt + - heading "Striped Polo Shirt" [level=3] [ref=f70e127] + - generic [ref=f70e129]: + - generic [ref=f70e130]: 27.99 EUR + - generic [ref=f70e131]: 39.99 EUR + - generic [ref=f70e132]: + - generic [ref=f70e133]: "On sale:" + - text: Sale + - link "Choose options" [ref=f70e135] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - contentinfo [ref=f70e136]: + - generic [ref=f70e137]: + - generic [ref=f70e138]: + - generic [ref=f70e139]: + - heading "Shop" [level=2] [ref=f70e140] + - list [ref=f70e141]: + - listitem [ref=f70e142]: + - link "About Us" [ref=f70e143] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f70e144]: + - link "FAQ" [ref=f70e145] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f70e146]: + - link "Shipping & Returns" [ref=f70e147] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f70e148]: + - link "Privacy Policy" [ref=f70e149] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f70e150]: + - link "Terms of Service" [ref=f70e151] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f70e152]: + - heading "Acme Fashion" [level=2] [ref=f70e153] + - paragraph [ref=f70e154]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f70e155]: + - paragraph [ref=f70e156]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f70e157]: + - generic [ref=f70e158]: VISA + - generic [ref=f70e159]: MASTERCARD + - generic [ref=f70e160]: AMEX + - generic [ref=f70e161]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-24-04-309Z.yml b/.playwright-mcp/page-2026-07-26T09-24-04-309Z.yml new file mode 100644 index 00000000..85993a12 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-24-04-309Z.yml @@ -0,0 +1,152 @@ +- generic [active] [ref=f70e1]: + - link "Skip to main content" [ref=f70e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f70e4]: + - paragraph [ref=f70e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f70e6] + - banner [ref=f70e9]: + - generic [ref=f70e10]: + - link "Acme Fashion" [ref=f70e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f70e13]: + - link "Home" [ref=f70e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f70e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f70e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f70e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f70e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f70e19]: + - button "Search" [ref=f70e20] + - link "Account" [ref=f70e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f70e26] + - main [ref=f70e29]: + - generic [ref=f70e30]: + - navigation "Breadcrumb" [ref=f70e31]: + - list [ref=f70e32]: + - listitem [ref=f70e33]: + - link "Home" [ref=f70e34] [cursor=pointer]: + - /url: http://acme-fashion.test + - listitem [ref=f70e35]: + - generic [ref=f70e36]: / + - link "Collections" [ref=f70e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - listitem [ref=f70e38]: + - generic [ref=f70e39]: / + - generic [ref=f70e40]: T-Shirts + - generic [ref=f70e41]: + - heading "T-Shirts" [level=1] [ref=f70e42] + - paragraph [ref=f70e44]: Premium cotton tees for every occasion. + - generic [ref=f70e45]: + - paragraph [ref=f70e46]: 4 products + - generic [ref=f70e47]: + - generic [ref=f70e48]: Sort by + - combobox "Sort by" [ref=f70e49]: + - option "Featured" + - 'option "Price: Low to High" [selected]' + - 'option "Price: High to Low"' + - option "Newest" + - option "Best Selling" + - generic [ref=f70e50]: + - complementary "Product filters" [ref=f70e51]: + - generic [ref=f70e52]: + - group "Availability" [ref=f70e53]: + - generic [ref=f70e56]: + - checkbox "In stock" [ref=f70e57] + - text: In stock + - group "Price" [ref=f70e58]: + - generic [ref=f70e60]: + - generic [ref=f70e61]: Minimum price + - spinbutton "Minimum price Minimum price" [ref=f70e62] + - generic [ref=f70e63]: "-" + - generic [ref=f70e64]: Maximum price + - spinbutton "Maximum price Maximum price" [ref=f70e65] + - group "Product type" [ref=f70e66]: + - generic [ref=f70e69]: + - checkbox "T-Shirts" [ref=f70e70] + - text: T-Shirts + - group "Vendor" [ref=f70e71]: + - generic [ref=f70e74]: + - checkbox "Acme Basics" [ref=f70e75] + - text: Acme Basics + - generic [ref=f70e77]: + - generic [ref=f70e78]: + - link [ref=f70e80] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f70e84] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f70e85] + - generic [ref=f70e86]: 24.99 EUR + - link "Choose options" [ref=f70e90] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f70e117]: + - generic [ref=f70e118]: + - link [ref=f70e119] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - generic [ref=f70e124]: + - generic [ref=f70e125]: "On sale:" + - text: Sale + - 'link "Striped Polo Shirt 27.99 EUR 39.99 EUR On sale: Sale" [ref=f70e126] [cursor=pointer]': + - /url: http://acme-fashion.test/products/striped-polo-shirt + - heading "Striped Polo Shirt" [level=3] [ref=f70e127] + - generic [ref=f70e129]: + - generic [ref=f70e130]: 27.99 EUR + - generic [ref=f70e131]: 39.99 EUR + - generic [ref=f70e132]: + - generic [ref=f70e133]: "On sale:" + - text: Sale + - link "Choose options" [ref=f70e135] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - generic [ref=f70e91]: + - link [ref=f70e93] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f70e97] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f70e98] + - generic [ref=f70e99]: 29.99 EUR + - link "Choose options" [ref=f70e103] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - generic [ref=f70e104]: + - link [ref=f70e106] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - link "V-Neck Linen Tee 34.99 EUR" [ref=f70e110] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - heading "V-Neck Linen Tee" [level=3] [ref=f70e111] + - generic [ref=f70e112]: 34.99 EUR + - link "Choose options" [ref=f70e116] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - contentinfo [ref=f70e136]: + - generic [ref=f70e137]: + - generic [ref=f70e138]: + - generic [ref=f70e139]: + - heading "Shop" [level=2] [ref=f70e140] + - list [ref=f70e141]: + - listitem [ref=f70e142]: + - link "About Us" [ref=f70e143] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f70e144]: + - link "FAQ" [ref=f70e145] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f70e146]: + - link "Shipping & Returns" [ref=f70e147] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f70e148]: + - link "Privacy Policy" [ref=f70e149] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f70e150]: + - link "Terms of Service" [ref=f70e151] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f70e152]: + - heading "Acme Fashion" [level=2] [ref=f70e153] + - paragraph [ref=f70e154]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f70e155]: + - paragraph [ref=f70e156]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f70e157]: + - generic [ref=f70e158]: VISA + - generic [ref=f70e159]: MASTERCARD + - generic [ref=f70e160]: AMEX + - generic [ref=f70e161]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-25-23-147Z.yml b/.playwright-mcp/page-2026-07-26T09-25-23-147Z.yml new file mode 100644 index 00000000..030d02a0 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-25-23-147Z.yml @@ -0,0 +1,96 @@ +- generic [active] [ref=f71e1]: + - link "Skip to main content" [ref=f71e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f71e4]: + - paragraph [ref=f71e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f71e6] + - banner [ref=f71e9]: + - generic [ref=f71e10]: + - link "Acme Fashion" [ref=f71e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f71e13]: + - link "Home" [ref=f71e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f71e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f71e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f71e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f71e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f71e19]: + - button "Search" [ref=f71e20] + - link "Account" [ref=f71e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f71e26] + - main [ref=f71e29]: + - generic [ref=f71e30]: + - navigation "Breadcrumb" [ref=f71e31]: + - list [ref=f71e32]: + - listitem [ref=f71e33]: + - link "Home" [ref=f71e34] [cursor=pointer]: + - /url: http://acme-fashion.test + - listitem [ref=f71e35]: + - generic [ref=f71e36]: / + - link "New Arrivals" [ref=f71e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - listitem [ref=f71e38]: + - generic [ref=f71e39]: / + - generic [ref=f71e40]: Organic Hoodie + - generic [ref=f71e41]: + - region "Product images" [ref=f71e42] + - generic [ref=f71e48]: + - heading "Organic Hoodie" [level=1] [ref=f71e49] + - paragraph [ref=f71e50]: Acme Basics + - generic [ref=f71e51]: 59.99 EUR + - group "SizeS" [ref=f71e53]: + - generic [ref=f71e55]: + - button "S" [pressed] [ref=f71e56] + - button "M" [ref=f71e57] + - button "L" [ref=f71e58] + - button "XL" [ref=f71e59] + - paragraph [ref=f71e60]: In stock + - generic [ref=f71e63]: + - generic [ref=f71e64]: + - button "Decrease quantity" [disabled] [ref=f71e65] + - generic [ref=f71e67]: Quantity + - spinbutton "Quantity" [ref=f71e68]: "1" + - button "Increase quantity" [ref=f71e69] + - button "Add to cart" [ref=f71e72] + - separator [ref=f71e73] + - paragraph [ref=f71e75]: Made from 100% organic cotton. Warm, soft, and sustainably produced. + - generic [ref=f71e76]: + - generic [ref=f71e77]: new + - generic [ref=f71e78]: trending + - contentinfo [ref=f71e79]: + - generic [ref=f71e80]: + - generic [ref=f71e81]: + - generic [ref=f71e82]: + - heading "Shop" [level=2] [ref=f71e83] + - list [ref=f71e84]: + - listitem [ref=f71e85]: + - link "About Us" [ref=f71e86] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f71e87]: + - link "FAQ" [ref=f71e88] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f71e89]: + - link "Shipping & Returns" [ref=f71e90] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f71e91]: + - link "Privacy Policy" [ref=f71e92] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f71e93]: + - link "Terms of Service" [ref=f71e94] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f71e95]: + - heading "Acme Fashion" [level=2] [ref=f71e96] + - paragraph [ref=f71e97]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f71e98]: + - paragraph [ref=f71e99]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f71e100]: + - generic [ref=f71e101]: VISA + - generic [ref=f71e102]: MASTERCARD + - generic [ref=f71e103]: AMEX + - generic [ref=f71e104]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-25-35-732Z.yml b/.playwright-mcp/page-2026-07-26T09-25-35-732Z.yml new file mode 100644 index 00000000..0e05e384 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-25-35-732Z.yml @@ -0,0 +1,130 @@ +- generic [ref=f71e1]: + - link "Skip to main content" [ref=f71e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f71e4]: + - paragraph [ref=f71e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f71e6] + - banner [ref=f71e9]: + - generic [ref=f71e10]: + - link "Acme Fashion" [ref=f71e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f71e13]: + - link "Home" [ref=f71e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f71e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f71e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f71e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f71e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f71e19]: + - button "Search" [ref=f71e20] + - link "Account" [ref=f71e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f71e26]: + - generic [ref=f71e105]: "1" + - main [ref=f71e29]: + - generic [ref=f71e30]: + - navigation "Breadcrumb" [ref=f71e31]: + - list [ref=f71e32]: + - listitem [ref=f71e33]: + - link "Home" [ref=f71e34] [cursor=pointer]: + - /url: http://acme-fashion.test + - listitem [ref=f71e35]: + - generic [ref=f71e36]: / + - link "New Arrivals" [ref=f71e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - listitem [ref=f71e38]: + - generic [ref=f71e39]: / + - generic [ref=f71e40]: Organic Hoodie + - generic [ref=f71e41]: + - region "Product images" [ref=f71e42] + - generic [ref=f71e48]: + - heading "Organic Hoodie" [level=1] [ref=f71e49] + - paragraph [ref=f71e50]: Acme Basics + - generic [ref=f71e51]: 59.99 EUR + - group "SizeS" [ref=f71e53]: + - generic [ref=f71e55]: + - button "S" [pressed] [ref=f71e56] + - button "M" [ref=f71e57] + - button "L" [ref=f71e58] + - button "XL" [ref=f71e59] + - paragraph [ref=f71e60]: In stock + - generic [ref=f71e63]: + - generic [ref=f71e64]: + - button "Decrease quantity" [disabled] [ref=f71e65] + - generic [ref=f71e67]: Quantity + - spinbutton "Quantity" [ref=f71e68]: "1" + - button "Increase quantity" [ref=f71e69] + - button "Add to cart" [ref=f71e72] + - separator [ref=f71e73] + - paragraph [ref=f71e75]: Made from 100% organic cotton. Warm, soft, and sustainably produced. + - generic [ref=f71e76]: + - generic [ref=f71e77]: new + - generic [ref=f71e78]: trending + - contentinfo [ref=f71e79]: + - generic [ref=f71e80]: + - generic [ref=f71e81]: + - generic [ref=f71e82]: + - heading "Shop" [level=2] [ref=f71e83] + - list [ref=f71e84]: + - listitem [ref=f71e85]: + - link "About Us" [ref=f71e86] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f71e87]: + - link "FAQ" [ref=f71e88] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f71e89]: + - link "Shipping & Returns" [ref=f71e90] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f71e91]: + - link "Privacy Policy" [ref=f71e92] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f71e93]: + - link "Terms of Service" [ref=f71e94] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f71e95]: + - heading "Acme Fashion" [level=2] [ref=f71e96] + - paragraph [ref=f71e97]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f71e98]: + - paragraph [ref=f71e99]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f71e100]: + - generic [ref=f71e101]: VISA + - generic [ref=f71e102]: MASTERCARD + - generic [ref=f71e103]: AMEX + - generic [ref=f71e104]: PAYPAL + - generic: + - dialog "Your Cart (1)": + - generic [ref=f71e108]: + - generic [ref=f71e109]: + - heading "Your Cart (1)" [level=2] [ref=f71e110] + - button "Close cart" [active] [ref=f71e111] + - list [ref=f71e114]: + - listitem [ref=f71e115]: + - generic [ref=f71e119]: + - paragraph [ref=f71e120]: Organic Hoodie + - paragraph [ref=f71e121]: S + - generic [ref=f71e122]: + - generic [ref=f71e123]: + - button "Decrease quantity of Organic Hoodie" [ref=f71e124] + - generic [ref=f71e126]: "1" + - button "Increase quantity of Organic Hoodie" [ref=f71e127] + - paragraph [ref=f71e130]: 59.99 EUR + - button "Remove Organic Hoodie from cart" [ref=f71e132] + - generic [ref=f71e136]: + - generic [ref=f71e137]: Discount code + - textbox "Discount code" [ref=f71e138] + - button "Apply" [ref=f71e139] + - generic [ref=f71e140]: + - generic [ref=f71e141]: + - generic [ref=f71e142]: + - term [ref=f71e143]: Subtotal + - definition [ref=f71e144]: 59.99 EUR + - generic [ref=f71e145]: + - term [ref=f71e146]: Estimated total + - definition [ref=f71e147]: 59.99 EUR + - paragraph [ref=f71e148]: Shipping and taxes calculated at checkout + - button "Checkout" [ref=f71e149] + - button "Continue shopping" [ref=f71e151] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-25-46-399Z.yml b/.playwright-mcp/page-2026-07-26T09-25-46-399Z.yml new file mode 100644 index 00000000..badc0a85 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-25-46-399Z.yml @@ -0,0 +1,101 @@ +- generic [active] [ref=f72e1]: + - link "Skip to main content" [ref=f72e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f72e4]: + - paragraph [ref=f72e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f72e6] + - banner [ref=f72e9]: + - generic [ref=f72e10]: + - link "Acme Fashion" [ref=f72e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f72e13]: + - link "Home" [ref=f72e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f72e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f72e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f72e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f72e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f72e19]: + - button "Search" [ref=f72e20] + - link "Account" [ref=f72e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f72e26] + - main [ref=f72e29]: + - generic [ref=f72e30]: + - heading "Your Cart" [level=1] [ref=f72e31] + - generic [ref=f72e32]: + - table [ref=f72e34]: + - rowgroup [ref=f72e35]: + - row [ref=f72e36]: + - columnheader "Product" [ref=f72e37] + - columnheader "Price" [ref=f72e38] + - columnheader "Quantity" [ref=f72e39] + - columnheader "Total" [ref=f72e40] + - columnheader "Remove" [ref=f72e41] + - rowgroup [ref=f72e43]: + - row [ref=f72e44]: + - cell [ref=f72e45]: + - generic [ref=f72e50]: + - paragraph [ref=f72e51]: Organic Hoodie + - paragraph [ref=f72e52]: S + - cell "59.99 EUR" [ref=f72e53] + - cell "Decrease quantity of Organic Hoodie 1 Increase quantity of Organic Hoodie" [ref=f72e54]: + - generic [ref=f72e55]: + - button "Decrease quantity of Organic Hoodie" [ref=f72e56] + - generic [ref=f72e58]: "1" + - button "Increase quantity of Organic Hoodie" [ref=f72e59] + - cell "59.99 EUR" [ref=f72e62] + - cell [ref=f72e63]: + - button "Remove Organic Hoodie from cart" [ref=f72e64] + - generic [ref=f72e68]: + - heading "Order summary" [level=2] [ref=f72e69] + - generic [ref=f72e71]: + - generic [ref=f72e72]: Discount code + - textbox "Discount code" [ref=f72e73] + - button "Apply" [ref=f72e74] + - generic [ref=f72e75]: + - generic [ref=f72e76]: + - term [ref=f72e77]: Subtotal + - definition [ref=f72e78]: 59.99 EUR + - generic [ref=f72e79]: + - term [ref=f72e80]: Total + - definition [ref=f72e81]: 59.99 EUR + - paragraph [ref=f72e82]: Shipping estimated at checkout. Taxes calculated at checkout. + - button "Checkout" [ref=f72e83] + - link "Continue shopping" [ref=f72e85] [cursor=pointer]: + - /url: http://acme-fashion.test + - contentinfo [ref=f72e86]: + - generic [ref=f72e87]: + - generic [ref=f72e88]: + - generic [ref=f72e89]: + - heading "Shop" [level=2] [ref=f72e90] + - list [ref=f72e91]: + - listitem [ref=f72e92]: + - link "About Us" [ref=f72e93] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f72e94]: + - link "FAQ" [ref=f72e95] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f72e96]: + - link "Shipping & Returns" [ref=f72e97] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f72e98]: + - link "Privacy Policy" [ref=f72e99] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f72e100]: + - link "Terms of Service" [ref=f72e101] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f72e102]: + - heading "Acme Fashion" [level=2] [ref=f72e103] + - paragraph [ref=f72e104]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f72e105]: + - paragraph [ref=f72e106]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f72e107]: + - generic [ref=f72e108]: VISA + - generic [ref=f72e109]: MASTERCARD + - generic [ref=f72e110]: AMEX + - generic [ref=f72e111]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-26-22-276Z.yml b/.playwright-mcp/page-2026-07-26T09-26-22-276Z.yml new file mode 100644 index 00000000..1dfc6cf0 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-26-22-276Z.yml @@ -0,0 +1,103 @@ +- generic [active] [ref=f72e1]: + - link "Skip to main content" [ref=f72e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f72e4]: + - paragraph [ref=f72e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f72e6] + - banner [ref=f72e9]: + - generic [ref=f72e10]: + - link "Acme Fashion" [ref=f72e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f72e13]: + - link "Home" [ref=f72e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f72e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f72e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f72e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f72e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f72e19]: + - button "Search" [ref=f72e20] + - link "Account" [ref=f72e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f72e26] + - main [ref=f72e29]: + - generic [ref=f72e30]: + - heading "Your Cart" [level=1] [ref=f72e31] + - generic [ref=f72e32]: + - table [ref=f72e34]: + - rowgroup [ref=f72e35]: + - row [ref=f72e36]: + - columnheader "Product" [ref=f72e37] + - columnheader "Price" [ref=f72e38] + - columnheader "Quantity" [ref=f72e39] + - columnheader "Total" [ref=f72e40] + - columnheader "Remove" [ref=f72e41] + - rowgroup [ref=f72e43]: + - row [ref=f72e44]: + - cell [ref=f72e45]: + - generic [ref=f72e50]: + - paragraph [ref=f72e51]: Organic Hoodie + - paragraph [ref=f72e52]: S + - cell "59.99 EUR" [ref=f72e53] + - cell "Decrease quantity of Organic Hoodie 1 Increase quantity of Organic Hoodie" [ref=f72e54]: + - generic [ref=f72e55]: + - button "Decrease quantity of Organic Hoodie" [ref=f72e56] + - generic [ref=f72e58]: "1" + - button "Increase quantity of Organic Hoodie" [ref=f72e59] + - cell "59.99 EUR" [ref=f72e62] + - cell [ref=f72e63]: + - button "Remove Organic Hoodie from cart" [ref=f72e64] + - generic [ref=f72e68]: + - heading "Order summary" [level=2] [ref=f72e69] + - generic [ref=f72e70]: + - generic [ref=f72e71]: + - generic [ref=f72e72]: Discount code + - textbox "Discount code" [ref=f72e73]: EXPIRED20 + - button "Apply" [ref=f72e74] + - paragraph [ref=f72e112]: This discount code has expired. + - generic [ref=f72e75]: + - generic [ref=f72e76]: + - term [ref=f72e77]: Subtotal + - definition [ref=f72e78]: 59.99 EUR + - generic [ref=f72e79]: + - term [ref=f72e80]: Total + - definition [ref=f72e81]: 59.99 EUR + - paragraph [ref=f72e82]: Shipping estimated at checkout. Taxes calculated at checkout. + - button "Checkout" [ref=f72e83] + - link "Continue shopping" [ref=f72e85] [cursor=pointer]: + - /url: http://acme-fashion.test + - contentinfo [ref=f72e86]: + - generic [ref=f72e87]: + - generic [ref=f72e88]: + - generic [ref=f72e89]: + - heading "Shop" [level=2] [ref=f72e90] + - list [ref=f72e91]: + - listitem [ref=f72e92]: + - link "About Us" [ref=f72e93] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f72e94]: + - link "FAQ" [ref=f72e95] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f72e96]: + - link "Shipping & Returns" [ref=f72e97] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f72e98]: + - link "Privacy Policy" [ref=f72e99] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f72e100]: + - link "Terms of Service" [ref=f72e101] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f72e102]: + - heading "Acme Fashion" [level=2] [ref=f72e103] + - paragraph [ref=f72e104]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f72e105]: + - paragraph [ref=f72e106]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f72e107]: + - generic [ref=f72e108]: VISA + - generic [ref=f72e109]: MASTERCARD + - generic [ref=f72e110]: AMEX + - generic [ref=f72e111]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-26-55-532Z.yml b/.playwright-mcp/page-2026-07-26T09-26-55-532Z.yml new file mode 100644 index 00000000..83cafdbf --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-26-55-532Z.yml @@ -0,0 +1,103 @@ +- generic [active] [ref=f72e1]: + - link "Skip to main content" [ref=f72e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f72e4]: + - paragraph [ref=f72e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f72e6] + - banner [ref=f72e9]: + - generic [ref=f72e10]: + - link "Acme Fashion" [ref=f72e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f72e13]: + - link "Home" [ref=f72e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f72e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f72e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f72e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f72e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f72e19]: + - button "Search" [ref=f72e20] + - link "Account" [ref=f72e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f72e26] + - main [ref=f72e29]: + - generic [ref=f72e30]: + - heading "Your Cart" [level=1] [ref=f72e31] + - generic [ref=f72e32]: + - table [ref=f72e34]: + - rowgroup [ref=f72e35]: + - row [ref=f72e36]: + - columnheader "Product" [ref=f72e37] + - columnheader "Price" [ref=f72e38] + - columnheader "Quantity" [ref=f72e39] + - columnheader "Total" [ref=f72e40] + - columnheader "Remove" [ref=f72e41] + - rowgroup [ref=f72e43]: + - row [ref=f72e44]: + - cell [ref=f72e45]: + - generic [ref=f72e50]: + - paragraph [ref=f72e51]: Organic Hoodie + - paragraph [ref=f72e52]: S + - cell "59.99 EUR" [ref=f72e53] + - cell "Decrease quantity of Organic Hoodie 1 Increase quantity of Organic Hoodie" [ref=f72e54]: + - generic [ref=f72e55]: + - button "Decrease quantity of Organic Hoodie" [ref=f72e56] + - generic [ref=f72e58]: "1" + - button "Increase quantity of Organic Hoodie" [ref=f72e59] + - cell "59.99 EUR" [ref=f72e62] + - cell [ref=f72e63]: + - button "Remove Organic Hoodie from cart" [ref=f72e64] + - generic [ref=f72e68]: + - heading "Order summary" [level=2] [ref=f72e69] + - generic [ref=f72e70]: + - generic [ref=f72e71]: + - generic [ref=f72e72]: Discount code + - textbox "Discount code" [ref=f72e73]: MAXED + - button "Apply" [ref=f72e74] + - paragraph [ref=f72e112]: This discount code has reached its usage limit. + - generic [ref=f72e75]: + - generic [ref=f72e76]: + - term [ref=f72e77]: Subtotal + - definition [ref=f72e78]: 59.99 EUR + - generic [ref=f72e79]: + - term [ref=f72e80]: Total + - definition [ref=f72e81]: 59.99 EUR + - paragraph [ref=f72e82]: Shipping estimated at checkout. Taxes calculated at checkout. + - button "Checkout" [ref=f72e83] + - link "Continue shopping" [ref=f72e85] [cursor=pointer]: + - /url: http://acme-fashion.test + - contentinfo [ref=f72e86]: + - generic [ref=f72e87]: + - generic [ref=f72e88]: + - generic [ref=f72e89]: + - heading "Shop" [level=2] [ref=f72e90] + - list [ref=f72e91]: + - listitem [ref=f72e92]: + - link "About Us" [ref=f72e93] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f72e94]: + - link "FAQ" [ref=f72e95] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f72e96]: + - link "Shipping & Returns" [ref=f72e97] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f72e98]: + - link "Privacy Policy" [ref=f72e99] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f72e100]: + - link "Terms of Service" [ref=f72e101] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f72e102]: + - heading "Acme Fashion" [level=2] [ref=f72e103] + - paragraph [ref=f72e104]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f72e105]: + - paragraph [ref=f72e106]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f72e107]: + - generic [ref=f72e108]: VISA + - generic [ref=f72e109]: MASTERCARD + - generic [ref=f72e110]: AMEX + - generic [ref=f72e111]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-27-35-543Z.yml b/.playwright-mcp/page-2026-07-26T09-27-35-543Z.yml new file mode 100644 index 00000000..f9d4733f --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-27-35-543Z.yml @@ -0,0 +1,100 @@ +- generic [active] [ref=f72e1]: + - link "Skip to main content" [ref=f72e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f72e4]: + - paragraph [ref=f72e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f72e6] + - banner [ref=f72e9]: + - generic [ref=f72e10]: + - link "Acme Fashion" [ref=f72e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f72e13]: + - link "Home" [ref=f72e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f72e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f72e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f72e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f72e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f72e19]: + - button "Search" [ref=f72e20] + - link "Account" [ref=f72e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f72e26] + - main [ref=f72e29]: + - generic [ref=f72e30]: + - heading "Your Cart" [level=1] [ref=f72e31] + - generic [ref=f72e32]: + - table [ref=f72e34]: + - rowgroup [ref=f72e35]: + - row [ref=f72e36]: + - columnheader "Product" [ref=f72e37] + - columnheader "Price" [ref=f72e38] + - columnheader "Quantity" [ref=f72e39] + - columnheader "Total" [ref=f72e40] + - columnheader "Remove" [ref=f72e41] + - rowgroup [ref=f72e43]: + - row [ref=f72e44]: + - cell [ref=f72e45]: + - generic [ref=f72e50]: + - paragraph [ref=f72e51]: Organic Hoodie + - paragraph [ref=f72e52]: S + - cell "59.99 EUR" [ref=f72e53] + - cell "Decrease quantity of Organic Hoodie 1 Increase quantity of Organic Hoodie" [ref=f72e54]: + - generic [ref=f72e55]: + - button "Decrease quantity of Organic Hoodie" [ref=f72e56] + - generic [ref=f72e58]: "1" + - button "Increase quantity of Organic Hoodie" [ref=f72e59] + - cell "59.99 EUR" [ref=f72e62] + - cell [ref=f72e63]: + - button "Remove Organic Hoodie from cart" [ref=f72e64] + - generic [ref=f72e68]: + - heading "Order summary" [level=2] [ref=f72e69] + - generic [ref=f72e113]: + - paragraph [ref=f72e114]: FREESHIP (Free shipping) + - button "Remove" [ref=f72e115] + - generic [ref=f72e75]: + - generic [ref=f72e76]: + - term [ref=f72e77]: Subtotal + - definition [ref=f72e78]: 59.99 EUR + - generic [ref=f72e79]: + - term [ref=f72e80]: Total + - definition [ref=f72e81]: 59.99 EUR + - paragraph [ref=f72e82]: Shipping estimated at checkout. Taxes calculated at checkout. + - button "Checkout" [ref=f72e83] + - link "Continue shopping" [ref=f72e85] [cursor=pointer]: + - /url: http://acme-fashion.test + - contentinfo [ref=f72e86]: + - generic [ref=f72e87]: + - generic [ref=f72e88]: + - generic [ref=f72e89]: + - heading "Shop" [level=2] [ref=f72e90] + - list [ref=f72e91]: + - listitem [ref=f72e92]: + - link "About Us" [ref=f72e93] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f72e94]: + - link "FAQ" [ref=f72e95] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f72e96]: + - link "Shipping & Returns" [ref=f72e97] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f72e98]: + - link "Privacy Policy" [ref=f72e99] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f72e100]: + - link "Terms of Service" [ref=f72e101] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f72e102]: + - heading "Acme Fashion" [level=2] [ref=f72e103] + - paragraph [ref=f72e104]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f72e105]: + - paragraph [ref=f72e106]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f72e107]: + - generic [ref=f72e108]: VISA + - generic [ref=f72e109]: MASTERCARD + - generic [ref=f72e110]: AMEX + - generic [ref=f72e111]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-28-10-362Z.yml b/.playwright-mcp/page-2026-07-26T09-28-10-362Z.yml new file mode 100644 index 00000000..c578a593 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-28-10-362Z.yml @@ -0,0 +1,126 @@ +- generic [active] [ref=f73e1]: + - link "Skip to main content" [ref=f73e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f73e4]: + - paragraph [ref=f73e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f73e6] + - banner [ref=f73e9]: + - generic [ref=f73e10]: + - link "Acme Fashion" [ref=f73e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f73e13]: + - link "Home" [ref=f73e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f73e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f73e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f73e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f73e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f73e19]: + - button "Search" [ref=f73e20] + - link "Account" [ref=f73e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f73e26] + - main [ref=f73e29]: + - generic [ref=f73e30]: + - heading "Checkout" [level=1] [ref=f73e31] + - generic [ref=f73e32]: + - generic [ref=f73e33]: + - region [ref=f73e34]: + - heading "1. Contact & shipping address" [level=2] [ref=f73e35] + - generic [ref=f73e37]: + - generic [ref=f73e38]: + - generic [ref=f73e39]: Email * + - textbox "Email" [ref=f73e40] + - generic [ref=f73e41]: + - generic [ref=f73e42]: + - generic [ref=f73e43]: First name * + - textbox "First name" [ref=f73e44] + - generic [ref=f73e45]: + - generic [ref=f73e46]: Last name * + - textbox "Last name" [ref=f73e47] + - generic [ref=f73e48]: + - generic [ref=f73e49]: Address line 1 * + - textbox "Address line 1" [ref=f73e50] + - generic [ref=f73e51]: + - generic [ref=f73e52]: Address line 2 (optional) + - textbox "Address line 2 (optional)" [ref=f73e53] + - generic [ref=f73e54]: + - generic [ref=f73e55]: City * + - textbox "City" [ref=f73e56] + - generic [ref=f73e57]: + - generic [ref=f73e58]: State / Province (optional) + - textbox "State / Province (optional)" [ref=f73e59] + - generic [ref=f73e60]: + - generic [ref=f73e61]: Postal code * + - textbox "Postal code" [ref=f73e62] + - generic [ref=f73e63]: + - generic [ref=f73e64]: Country code (e.g. DE) * + - textbox "Country code (e.g. DE)" [ref=f73e65] + - generic [ref=f73e66]: + - generic [ref=f73e67]: Phone (optional) + - textbox "Phone (optional)" [ref=f73e68] + - generic [ref=f73e69]: + - checkbox "Billing address same as shipping" [checked] [ref=f73e70] + - text: Billing address same as shipping + - button "Continue to shipping" [ref=f73e71] + - region [ref=f73e72]: + - heading "2. Shipping method" [level=2] [ref=f73e73] + - region [ref=f73e74]: + - heading "3. Payment" [level=2] [ref=f73e75] + - complementary "Order summary" [ref=f73e76]: + - generic [ref=f73e77]: + - heading "Order Summary" [level=2] [ref=f73e78] + - list [ref=f73e79]: + - listitem [ref=f73e80]: + - generic [ref=f73e84]: + - paragraph [ref=f73e85]: Organic Hoodie ×1 + - paragraph [ref=f73e86]: S + - paragraph [ref=f73e87]: 59.99 EUR + - generic [ref=f73e88]: + - generic [ref=f73e89]: + - term [ref=f73e90]: Subtotal + - definition [ref=f73e91]: 59.99 EUR + - generic [ref=f73e92]: + - term [ref=f73e93]: Shipping + - definition [ref=f73e94]: Calculated at next step + - generic [ref=f73e95]: + - term [ref=f73e96]: Tax + - definition [ref=f73e97]: 0.00 EUR + - generic [ref=f73e98]: + - term [ref=f73e99]: Total + - definition [ref=f73e100]: 59.99 EUR + - contentinfo [ref=f73e101]: + - generic [ref=f73e102]: + - generic [ref=f73e103]: + - generic [ref=f73e104]: + - heading "Shop" [level=2] [ref=f73e105] + - list [ref=f73e106]: + - listitem [ref=f73e107]: + - link "About Us" [ref=f73e108] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f73e109]: + - link "FAQ" [ref=f73e110] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f73e111]: + - link "Shipping & Returns" [ref=f73e112] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f73e113]: + - link "Privacy Policy" [ref=f73e114] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f73e115]: + - link "Terms of Service" [ref=f73e116] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f73e117]: + - heading "Acme Fashion" [level=2] [ref=f73e118] + - paragraph [ref=f73e119]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f73e120]: + - paragraph [ref=f73e121]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f73e122]: + - generic [ref=f73e123]: VISA + - generic [ref=f73e124]: MASTERCARD + - generic [ref=f73e125]: AMEX + - generic [ref=f73e126]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-28-51-952Z.yml b/.playwright-mcp/page-2026-07-26T09-28-51-952Z.yml new file mode 100644 index 00000000..fe68a95c --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-28-51-952Z.yml @@ -0,0 +1,103 @@ +- generic [active] [ref=f74e1]: + - link "Skip to main content" [ref=f74e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f74e4]: + - paragraph [ref=f74e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f74e6] + - banner [ref=f74e9]: + - generic [ref=f74e10]: + - link "Acme Fashion" [ref=f74e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f74e13]: + - link "Home" [ref=f74e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f74e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f74e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f74e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f74e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f74e19]: + - button "Search" [ref=f74e20] + - link "Account" [ref=f74e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f74e26] + - main [ref=f74e29]: + - generic [ref=f74e30]: + - heading "Checkout" [level=1] [ref=f74e31] + - generic [ref=f74e32]: + - generic [ref=f74e33]: + - region [ref=f74e34]: + - heading "1. Contact & shipping address jane@example.com" [level=2] [ref=f74e35]: + - generic [ref=f74e36]: 1. Contact & shipping address + - generic [ref=f74e37]: jane@example.com + - generic [ref=f74e38]: Jane Doe, 123 Main St, 10115 Berlin, DE + - region [ref=f74e39]: + - heading "2. Shipping method" [level=2] [ref=f74e40] + - group "Available shipping methods" [ref=f74e41]: + - button "Standard Shipping 4.99 EUR" [ref=f74e43]: + - generic [ref=f74e44]: Standard Shipping + - generic [ref=f74e45]: 4.99 EUR + - button "Express Shipping 9.99 EUR" [ref=f74e46]: + - generic [ref=f74e47]: Express Shipping + - generic [ref=f74e48]: 9.99 EUR + - region [ref=f74e49]: + - heading "3. Payment" [level=2] [ref=f74e50] + - complementary "Order summary" [ref=f74e51]: + - generic [ref=f74e52]: + - heading "Order Summary" [level=2] [ref=f74e53] + - list [ref=f74e54]: + - listitem [ref=f74e55]: + - generic [ref=f74e59]: + - paragraph [ref=f74e60]: Organic Hoodie ×1 + - paragraph [ref=f74e61]: S + - paragraph [ref=f74e62]: 59.99 EUR + - generic [ref=f74e64]: + - paragraph [ref=f74e65]: FREESHIP + - button "Remove" [ref=f74e66] + - generic [ref=f74e67]: + - generic [ref=f74e68]: + - term [ref=f74e69]: Subtotal + - definition [ref=f74e70]: 59.99 EUR + - generic [ref=f74e71]: + - term [ref=f74e72]: Shipping + - definition [ref=f74e73]: 0.00 EUR + - generic [ref=f74e74]: + - term [ref=f74e75]: Tax + - definition [ref=f74e76]: 9.58 EUR + - generic [ref=f74e77]: + - term [ref=f74e78]: Total + - definition [ref=f74e79]: 59.99 EUR + - contentinfo [ref=f74e80]: + - generic [ref=f74e81]: + - generic [ref=f74e82]: + - generic [ref=f74e83]: + - heading "Shop" [level=2] [ref=f74e84] + - list [ref=f74e85]: + - listitem [ref=f74e86]: + - link "About Us" [ref=f74e87] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f74e88]: + - link "FAQ" [ref=f74e89] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f74e90]: + - link "Shipping & Returns" [ref=f74e91] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f74e92]: + - link "Privacy Policy" [ref=f74e93] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f74e94]: + - link "Terms of Service" [ref=f74e95] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f74e96]: + - heading "Acme Fashion" [level=2] [ref=f74e97] + - paragraph [ref=f74e98]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f74e99]: + - paragraph [ref=f74e100]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f74e101]: + - generic [ref=f74e102]: VISA + - generic [ref=f74e103]: MASTERCARD + - generic [ref=f74e104]: AMEX + - generic [ref=f74e105]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-29-03-699Z.yml b/.playwright-mcp/page-2026-07-26T09-29-03-699Z.yml new file mode 100644 index 00000000..8030db7b --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-29-03-699Z.yml @@ -0,0 +1,109 @@ +- generic [active] [ref=f74e1]: + - link "Skip to main content" [ref=f74e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f74e4]: + - paragraph [ref=f74e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f74e6] + - banner [ref=f74e9]: + - generic [ref=f74e10]: + - link "Acme Fashion" [ref=f74e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f74e13]: + - link "Home" [ref=f74e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f74e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f74e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f74e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f74e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f74e19]: + - button "Search" [ref=f74e20] + - link "Account" [ref=f74e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f74e26] + - main [ref=f74e29]: + - generic [ref=f74e30]: + - heading "Checkout" [level=1] [ref=f74e31] + - generic [ref=f74e32]: + - generic [ref=f74e33]: + - region [ref=f74e34]: + - heading "1. Contact & shipping address jane@example.com" [level=2] [ref=f74e35]: + - generic [ref=f74e36]: 1. Contact & shipping address + - generic [ref=f74e37]: jane@example.com + - generic [ref=f74e38]: Jane Doe, 123 Main St, 10115 Berlin, DE + - region [ref=f74e39]: + - heading "2. Shipping method" [level=2] [ref=f74e40] + - generic [ref=f74e106]: Shipping method selected + - region [ref=f74e49]: + - heading "3. Payment" [level=2] [ref=f74e50] + - generic [ref=f74e107]: + - group "Select a payment method" [ref=f74e108]: + - generic [ref=f74e110] [cursor=pointer]: + - radio "Credit Card" [checked] [ref=f74e111] + - generic [ref=f74e112]: Credit Card + - generic [ref=f74e113] [cursor=pointer]: + - radio "PayPal" [ref=f74e114] + - generic [ref=f74e115]: PayPal + - generic [ref=f74e116] [cursor=pointer]: + - radio "Bank Transfer" [ref=f74e117] + - generic [ref=f74e118]: Bank Transfer + - button "Continue" [ref=f74e119] + - complementary "Order summary" [ref=f74e51]: + - generic [ref=f74e52]: + - heading "Order Summary" [level=2] [ref=f74e53] + - list [ref=f74e54]: + - listitem [ref=f74e55]: + - generic [ref=f74e59]: + - paragraph [ref=f74e60]: Organic Hoodie ×1 + - paragraph [ref=f74e61]: S + - paragraph [ref=f74e62]: 59.99 EUR + - generic [ref=f74e64]: + - paragraph [ref=f74e65]: FREESHIP + - button "Remove" [ref=f74e66] + - generic [ref=f74e67]: + - generic [ref=f74e68]: + - term [ref=f74e69]: Subtotal + - definition [ref=f74e70]: 59.99 EUR + - generic [ref=f74e71]: + - term [ref=f74e72]: Shipping + - definition [ref=f74e73]: 0.00 EUR + - generic [ref=f74e74]: + - term [ref=f74e75]: Tax + - definition [ref=f74e76]: 9.58 EUR + - generic [ref=f74e77]: + - term [ref=f74e78]: Total + - definition [ref=f74e79]: 59.99 EUR + - contentinfo [ref=f74e80]: + - generic [ref=f74e81]: + - generic [ref=f74e82]: + - generic [ref=f74e83]: + - heading "Shop" [level=2] [ref=f74e84] + - list [ref=f74e85]: + - listitem [ref=f74e86]: + - link "About Us" [ref=f74e87] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f74e88]: + - link "FAQ" [ref=f74e89] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f74e90]: + - link "Shipping & Returns" [ref=f74e91] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f74e92]: + - link "Privacy Policy" [ref=f74e93] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f74e94]: + - link "Terms of Service" [ref=f74e95] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f74e96]: + - heading "Acme Fashion" [level=2] [ref=f74e97] + - paragraph [ref=f74e98]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f74e99]: + - paragraph [ref=f74e100]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f74e101]: + - generic [ref=f74e102]: VISA + - generic [ref=f74e103]: MASTERCARD + - generic [ref=f74e104]: AMEX + - generic [ref=f74e105]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-29-43-070Z.yml b/.playwright-mcp/page-2026-07-26T09-29-43-070Z.yml new file mode 100644 index 00000000..1864393e --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-29-43-070Z.yml @@ -0,0 +1,109 @@ +- generic [ref=f74e1]: + - link "Skip to main content" [ref=f74e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f74e4]: + - paragraph [ref=f74e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f74e6] + - banner [ref=f74e9]: + - generic [ref=f74e10]: + - link "Acme Fashion" [ref=f74e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f74e13]: + - link "Home" [ref=f74e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f74e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f74e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f74e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f74e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f74e19]: + - button "Search" [ref=f74e20] + - link "Account" [ref=f74e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f74e26] + - main [ref=f74e29]: + - generic [ref=f74e30]: + - heading "Checkout" [level=1] [ref=f74e31] + - generic [ref=f74e32]: + - generic [ref=f74e33]: + - region [ref=f74e34]: + - heading "1. Contact & shipping address jane@example.com" [level=2] [ref=f74e35]: + - generic [ref=f74e36]: 1. Contact & shipping address + - generic [ref=f74e37]: jane@example.com + - generic [ref=f74e38]: Jane Doe, 123 Main St, 10115 Berlin, DE + - region [ref=f74e39]: + - heading "2. Shipping method" [level=2] [ref=f74e40] + - generic [ref=f74e106]: Shipping method selected + - region [ref=f74e49]: + - heading "3. Payment" [level=2] [ref=f74e50] + - generic [ref=f74e107]: + - group "Select a payment method" [ref=f74e108]: + - generic [ref=f74e110] [cursor=pointer]: + - radio "Credit Card" [ref=f74e111] + - generic [ref=f74e112]: Credit Card + - generic [ref=f74e113] [cursor=pointer]: + - radio "PayPal" [checked] [active] [ref=f74e114] + - generic [ref=f74e115]: PayPal + - generic [ref=f74e116] [cursor=pointer]: + - radio "Bank Transfer" [ref=f74e117] + - generic [ref=f74e118]: Bank Transfer + - button "Continue" [ref=f74e119] + - complementary "Order summary" [ref=f74e51]: + - generic [ref=f74e52]: + - heading "Order Summary" [level=2] [ref=f74e53] + - list [ref=f74e54]: + - listitem [ref=f74e55]: + - generic [ref=f74e59]: + - paragraph [ref=f74e60]: Organic Hoodie ×1 + - paragraph [ref=f74e61]: S + - paragraph [ref=f74e62]: 59.99 EUR + - generic [ref=f74e64]: + - paragraph [ref=f74e65]: FREESHIP + - button "Remove" [ref=f74e66] + - generic [ref=f74e67]: + - generic [ref=f74e68]: + - term [ref=f74e69]: Subtotal + - definition [ref=f74e70]: 59.99 EUR + - generic [ref=f74e71]: + - term [ref=f74e72]: Shipping + - definition [ref=f74e73]: 0.00 EUR + - generic [ref=f74e74]: + - term [ref=f74e75]: Tax + - definition [ref=f74e76]: 9.58 EUR + - generic [ref=f74e77]: + - term [ref=f74e78]: Total + - definition [ref=f74e79]: 59.99 EUR + - contentinfo [ref=f74e80]: + - generic [ref=f74e81]: + - generic [ref=f74e82]: + - generic [ref=f74e83]: + - heading "Shop" [level=2] [ref=f74e84] + - list [ref=f74e85]: + - listitem [ref=f74e86]: + - link "About Us" [ref=f74e87] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f74e88]: + - link "FAQ" [ref=f74e89] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f74e90]: + - link "Shipping & Returns" [ref=f74e91] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f74e92]: + - link "Privacy Policy" [ref=f74e93] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f74e94]: + - link "Terms of Service" [ref=f74e95] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f74e96]: + - heading "Acme Fashion" [level=2] [ref=f74e97] + - paragraph [ref=f74e98]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f74e99]: + - paragraph [ref=f74e100]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f74e101]: + - generic [ref=f74e102]: VISA + - generic [ref=f74e103]: MASTERCARD + - generic [ref=f74e104]: AMEX + - generic [ref=f74e105]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-29-59-237Z.yml b/.playwright-mcp/page-2026-07-26T09-29-59-237Z.yml new file mode 100644 index 00000000..0235b6d8 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-29-59-237Z.yml @@ -0,0 +1,111 @@ +- generic [active] [ref=f74e1]: + - link "Skip to main content" [ref=f74e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f74e4]: + - paragraph [ref=f74e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f74e6] + - banner [ref=f74e9]: + - generic [ref=f74e10]: + - link "Acme Fashion" [ref=f74e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f74e13]: + - link "Home" [ref=f74e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f74e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f74e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f74e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f74e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f74e19]: + - button "Search" [ref=f74e20] + - link "Account" [ref=f74e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f74e26] + - main [ref=f74e29]: + - generic [ref=f74e30]: + - heading "Checkout" [level=1] [ref=f74e31] + - generic [ref=f74e32]: + - generic [ref=f74e33]: + - region [ref=f74e34]: + - heading "1. Contact & shipping address jane@example.com" [level=2] [ref=f74e35]: + - generic [ref=f74e36]: 1. Contact & shipping address + - generic [ref=f74e37]: jane@example.com + - generic [ref=f74e38]: Jane Doe, 123 Main St, 10115 Berlin, DE + - region [ref=f74e39]: + - heading "2. Shipping method" [level=2] [ref=f74e40] + - generic [ref=f74e106]: Shipping method selected + - region [ref=f74e49]: + - heading "3. Payment" [level=2] [ref=f74e50] + - generic [ref=f74e107]: + - group "Select a payment method" [ref=f74e108]: + - generic [ref=f74e110] [cursor=pointer]: + - radio "Credit Card" [disabled] [ref=f74e111] + - generic [ref=f74e112]: Credit Card + - generic [ref=f74e113] [cursor=pointer]: + - radio "PayPal" [checked] [disabled] [ref=f74e114] + - generic [ref=f74e115]: PayPal + - generic [ref=f74e116] [cursor=pointer]: + - radio "Bank Transfer" [disabled] [ref=f74e117] + - generic [ref=f74e118]: Bank Transfer + - generic [ref=f74e120]: + - paragraph [ref=f74e121]: Your PayPal payment will be processed securely. + - button "Pay with PayPal - 59.99 EUR" [ref=f74e122] + - complementary "Order summary" [ref=f74e51]: + - generic [ref=f74e52]: + - heading "Order Summary" [level=2] [ref=f74e53] + - list [ref=f74e54]: + - listitem [ref=f74e55]: + - generic [ref=f74e59]: + - paragraph [ref=f74e60]: Organic Hoodie ×1 + - paragraph [ref=f74e61]: S + - paragraph [ref=f74e62]: 59.99 EUR + - generic [ref=f74e64]: + - paragraph [ref=f74e65]: FREESHIP + - button "Remove" [ref=f74e66] + - generic [ref=f74e67]: + - generic [ref=f74e68]: + - term [ref=f74e69]: Subtotal + - definition [ref=f74e70]: 59.99 EUR + - generic [ref=f74e71]: + - term [ref=f74e72]: Shipping + - definition [ref=f74e73]: 0.00 EUR + - generic [ref=f74e74]: + - term [ref=f74e75]: Tax + - definition [ref=f74e76]: 9.58 EUR + - generic [ref=f74e77]: + - term [ref=f74e78]: Total + - definition [ref=f74e79]: 59.99 EUR + - contentinfo [ref=f74e80]: + - generic [ref=f74e81]: + - generic [ref=f74e82]: + - generic [ref=f74e83]: + - heading "Shop" [level=2] [ref=f74e84] + - list [ref=f74e85]: + - listitem [ref=f74e86]: + - link "About Us" [ref=f74e87] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f74e88]: + - link "FAQ" [ref=f74e89] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f74e90]: + - link "Shipping & Returns" [ref=f74e91] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f74e92]: + - link "Privacy Policy" [ref=f74e93] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f74e94]: + - link "Terms of Service" [ref=f74e95] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f74e96]: + - heading "Acme Fashion" [level=2] [ref=f74e97] + - paragraph [ref=f74e98]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f74e99]: + - paragraph [ref=f74e100]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f74e101]: + - generic [ref=f74e102]: VISA + - generic [ref=f74e103]: MASTERCARD + - generic [ref=f74e104]: AMEX + - generic [ref=f74e105]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-30-22-577Z.yml b/.playwright-mcp/page-2026-07-26T09-30-22-577Z.yml new file mode 100644 index 00000000..a45a4e50 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-30-22-577Z.yml @@ -0,0 +1,97 @@ +- generic [active] [ref=f75e1]: + - link "Skip to main content" [ref=f75e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f75e4]: + - paragraph [ref=f75e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f75e6] + - banner [ref=f75e9]: + - generic [ref=f75e10]: + - link "Acme Fashion" [ref=f75e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f75e13]: + - link "Home" [ref=f75e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f75e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f75e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f75e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f75e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f75e19]: + - button "Search" [ref=f75e20] + - link "Account" [ref=f75e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f75e26] + - main [ref=f75e29]: + - generic [ref=f75e30]: + - generic [ref=f75e31]: + - heading "Thank you for your order!" [level=1] [ref=f75e35] + - paragraph [ref=f75e36]: "Order #1018" + - paragraph [ref=f75e37]: We've sent a confirmation to jane@example.com + - region [ref=f75e38]: + - heading "Order Summary" [level=2] [ref=f75e39] + - list [ref=f75e40]: + - listitem [ref=f75e41]: + - generic [ref=f75e42]: + - paragraph [ref=f75e43]: Organic Hoodie - S + - paragraph [ref=f75e44]: "SKU: ACME-OHOOD-S" + - paragraph [ref=f75e45]: ×1 + - paragraph [ref=f75e46]: 59.99 EUR + - generic [ref=f75e47]: + - region [ref=f75e48]: + - heading "Shipping Address" [level=2] [ref=f75e49] + - generic [ref=f75e50]: Jane Doe 123 Main St 10115 Berlin DE + - region [ref=f75e51]: + - heading "Payment Method" [level=2] [ref=f75e52] + - paragraph [ref=f75e53]: PayPal + - generic [ref=f75e54]: + - generic [ref=f75e55]: + - term [ref=f75e56]: Subtotal + - definition [ref=f75e57]: 59.99 EUR + - generic [ref=f75e58]: + - term [ref=f75e59]: Shipping + - definition [ref=f75e60]: 0.00 EUR + - generic [ref=f75e61]: + - term [ref=f75e62]: Tax + - definition [ref=f75e63]: 9.58 EUR + - generic [ref=f75e64]: + - term [ref=f75e65]: Total + - definition [ref=f75e66]: 59.99 EUR + - generic [ref=f75e67]: + - link "Continue shopping" [ref=f75e68] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "View order status" [ref=f75e69] [cursor=pointer]: + - /url: /api/storefront/v1/orders/%231018?token=d38a20e770c5b017d8163154bec2754a3f55be8c73a8fa9a1ae1565f57d25fdc + - contentinfo [ref=f75e70]: + - generic [ref=f75e71]: + - generic [ref=f75e72]: + - generic [ref=f75e73]: + - heading "Shop" [level=2] [ref=f75e74] + - list [ref=f75e75]: + - listitem [ref=f75e76]: + - link "About Us" [ref=f75e77] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f75e78]: + - link "FAQ" [ref=f75e79] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f75e80]: + - link "Shipping & Returns" [ref=f75e81] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f75e82]: + - link "Privacy Policy" [ref=f75e83] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f75e84]: + - link "Terms of Service" [ref=f75e85] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f75e86]: + - heading "Acme Fashion" [level=2] [ref=f75e87] + - paragraph [ref=f75e88]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f75e89]: + - paragraph [ref=f75e90]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f75e91]: + - generic [ref=f75e92]: VISA + - generic [ref=f75e93]: MASTERCARD + - generic [ref=f75e94]: AMEX + - generic [ref=f75e95]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-30-38-857Z.yml b/.playwright-mcp/page-2026-07-26T09-30-38-857Z.yml new file mode 100644 index 00000000..15bd9c17 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-30-38-857Z.yml @@ -0,0 +1,177 @@ +- generic [active] [ref=f76e1]: + - link "Skip to main content" [ref=f76e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f76e3]: + - complementary "Admin navigation" [ref=f76e4]: + - generic [ref=f76e5]: + - link "Acme Fashion" [ref=f76e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f76e12]: + - navigation [ref=f76e13]: + - link "Dashboard" [ref=f76e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f76e19]: Products + - navigation [ref=f76e20]: + - link "Products" [ref=f76e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f76e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f76e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f76e36]: Orders + - navigation [ref=f76e37]: + - link "Orders" [ref=f76e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f76e43]: Customers + - navigation [ref=f76e44]: + - link "Customers" [ref=f76e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f76e50]: Discounts + - navigation [ref=f76e51]: + - link "Discounts" [ref=f76e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f76e58]: Content + - navigation [ref=f76e59]: + - link "Pages" [ref=f76e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f76e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f76e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f76e75]: + - link "Analytics" [ref=f76e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f76e82]: Settings + - navigation [ref=f76e83]: + - link "Settings" [ref=f76e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f76e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f76e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f76e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f76e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f76e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f76e115]: + - banner [ref=f76e116]: + - button "Acme Fashion" [ref=f76e118] + - button "Notifications" [ref=f76e123] + - button "AU Admin User" [ref=f76e127]: + - generic [ref=f76e128]: AU + - generic [ref=f76e131]: Admin User + - main [ref=f76e135]: + - generic [ref=f76e136]: + - generic [ref=f76e137]: Home + - generic [ref=f76e141]: Dashboard + - generic [ref=f76e143]: + - generic [ref=f76e144]: + - heading "Dashboard" [level=1] [ref=f76e145] + - combobox "Date range" [ref=f76e146]: + - option "Last 7 days" + - option "Last 30 days" [selected] + - option "Last 90 days" + - generic [ref=f76e147]: + - generic [ref=f76e148]: + - paragraph [ref=f76e149]: Total Sales + - generic [ref=f76e150]: 1,689.58 EUR + - generic [ref=f76e151]: + - paragraph [ref=f76e152]: Orders + - generic [ref=f76e153]: "18" + - generic [ref=f76e154]: + - paragraph [ref=f76e155]: Avg. Order Value + - generic [ref=f76e156]: 93.86 EUR + - generic [ref=f76e157]: + - paragraph [ref=f76e158]: Conversion Rate + - generic [ref=f76e159]: 48.6% + - generic [ref=f76e160]: + - heading "Orders over time" [level=2] [ref=f76e161] + - generic [ref=f76e162]: + - img "Daily order counts for the selected period" [ref=f76e163] + - generic [ref=f76e165]: + - generic [ref=f76e166]: 2026-06-27 + - generic [ref=f76e167]: 2026-07-26 + - generic [ref=f76e168]: + - heading "Recent orders" [level=2] [ref=f76e169] + - table [ref=f76e171]: + - rowgroup [ref=f76e172]: + - row [ref=f76e173]: + - columnheader "Order" [ref=f76e174] + - columnheader "Date" [ref=f76e175] + - columnheader "Customer" [ref=f76e176] + - columnheader "Payment" [ref=f76e177] + - columnheader "Fulfillment" [ref=f76e178] + - columnheader "Total" [ref=f76e179] + - rowgroup [ref=f76e180]: + - row [ref=f76e181]: + - cell "#1018" [ref=f76e182] + - cell "Jul 26, 2026" [ref=f76e183] + - cell "John Doe" [ref=f76e184] + - cell "Paid" [ref=f76e185] + - cell "Unfulfilled" [ref=f76e187] + - cell "59.99 EUR" [ref=f76e189] + - row [ref=f76e190]: + - cell "#1017" [ref=f76e191] + - cell "Jul 26, 2026" [ref=f76e192] + - cell "Jane Smith" [ref=f76e193] + - cell "Paid" [ref=f76e194] + - cell "Fulfilled" [ref=f76e196] + - cell "84.98 EUR" [ref=f76e198] + - row [ref=f76e199]: + - cell "#1016" [ref=f76e200] + - cell "Jul 26, 2026" [ref=f76e201] + - cell "Jane Smith" [ref=f76e202] + - cell "Partially refunded" [ref=f76e203] + - cell "Unfulfilled" [ref=f76e205] + - cell "27.49 EUR" [ref=f76e207] + - row [ref=f76e208]: + - cell "#1015" [ref=f76e209] + - cell "Jul 26, 2026" [ref=f76e210] + - cell "John Doe" [ref=f76e211] + - cell "Paid" [ref=f76e212] + - cell "Unfulfilled" [ref=f76e214] + - cell "54.47 EUR" [ref=f76e216] + - row [ref=f76e217]: + - cell "#1005" [ref=f76e218] + - cell "Jul 26, 2026" [ref=f76e219] + - cell "Jane Smith" [ref=f76e220] + - cell "Pending" [ref=f76e221] + - cell "Unfulfilled" [ref=f76e223] + - cell "39.98 EUR" [ref=f76e225] + - row [ref=f76e226]: + - cell "#1013" [ref=f76e227] + - cell "Jul 25, 2026" [ref=f76e228] + - cell "Robert Martinez" [ref=f76e229] + - cell "Paid" [ref=f76e230] + - cell "Unfulfilled" [ref=f76e232] + - cell "84.97 EUR" [ref=f76e234] + - row [ref=f76e235]: + - cell "#1010" [ref=f76e236] + - cell "Jul 25, 2026" [ref=f76e237] + - cell "John Doe" [ref=f76e238] + - cell "Paid" [ref=f76e239] + - cell "Unfulfilled" [ref=f76e241] + - cell "504.98 EUR" [ref=f76e243] + - row [ref=f76e244]: + - cell "#1006" [ref=f76e245] + - cell "Jul 25, 2026" [ref=f76e246] + - cell "Michael Brown" [ref=f76e247] + - cell "Paid" [ref=f76e248] + - cell "Unfulfilled" [ref=f76e250] + - cell "124.98 EUR" [ref=f76e252] + - row [ref=f76e253]: + - cell "#1001" [ref=f76e254] + - cell "Jul 24, 2026" [ref=f76e255] + - cell "John Doe" [ref=f76e256] + - cell "Paid" [ref=f76e257] + - cell "Unfulfilled" [ref=f76e259] + - cell "54.97 EUR" [ref=f76e261] + - row [ref=f76e262]: + - cell "#1009" [ref=f76e263] + - cell "Jul 23, 2026" [ref=f76e264] + - cell "Emma Garcia" [ref=f76e265] + - cell "Paid" [ref=f76e266] + - cell "Unfulfilled" [ref=f76e268] + - cell "49.97 EUR" [ref=f76e270] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-30-50-718Z.yml b/.playwright-mcp/page-2026-07-26T09-30-50-718Z.yml new file mode 100644 index 00000000..c00a08c9 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-30-50-718Z.yml @@ -0,0 +1,189 @@ +- generic: + - link "Skip to main content": + - /url: "#main-content" + - generic: + - complementary "Admin navigation": + - generic: + - generic: + - link "Acme Fashion": + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin": + - navigation: + - link "Dashboard": + - /url: http://admin.acme-fashion.test/admin + - paragraph: Products + - navigation: + - link "Products": + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections": + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory": + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph: Orders + - navigation: + - link "Orders": + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph: Customers + - navigation: + - link "Customers": + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph: Discounts + - navigation: + - link "Discounts": + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph: Content + - navigation: + - link "Pages": + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation": + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes": + - /url: http://admin.acme-fashion.test/admin/themes + - navigation: + - link "Analytics": + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph: Settings + - navigation: + - link "Settings": + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping": + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes": + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search": + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps": + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers": + - /url: http://admin.acme-fashion.test/admin/developers + - generic: + - banner: + - generic: + - button "Acme Fashion" + - generic: + - button "Notifications" + - generic: + - button "AU Admin User" [expanded]: + - generic: AU + - generic: Admin User + - menu [active] [ref=f76e271]: + - generic [ref=f76e272]: + - paragraph [ref=f76e273]: Admin User + - paragraph [ref=f76e274]: admin@acme.test + - generic [ref=f76e275]: owner + - menuitem "Settings" [ref=f76e277] [cursor=pointer] + - menuitem "Log out" [ref=f76e282] + - main: + - generic: + - generic: Home + - generic: Dashboard + - generic: + - generic: + - heading "Dashboard" [level=1] + - combobox "Date range": + - option "Last 7 days" + - option "Last 30 days" [selected] + - option "Last 90 days" + - generic: + - generic: + - paragraph: Total Sales + - generic: 1,689.58 EUR + - generic: + - paragraph: Orders + - generic: "18" + - generic: + - paragraph: Avg. Order Value + - generic: 93.86 EUR + - generic: + - paragraph: Conversion Rate + - generic: 48.6% + - generic: + - heading "Orders over time" [level=2] + - generic: + - img "Daily order counts for the selected period" + - generic: + - generic: 2026-06-27 + - generic: 2026-07-26 + - generic: + - heading "Recent orders" [level=2] + - generic: + - table: + - rowgroup: + - row "Order Date Customer Payment Fulfillment Total": + - columnheader "Order" + - columnheader "Date" + - columnheader "Customer" + - columnheader "Payment" + - columnheader "Fulfillment" + - columnheader "Total" + - rowgroup: + - row "#1018 Jul 26, 2026 John Doe Paid Unfulfilled 59.99 EUR": + - cell "#1018" + - cell "Jul 26, 2026" + - cell "John Doe" + - cell "Paid" + - cell "Unfulfilled" + - cell "59.99 EUR" + - row "#1017 Jul 26, 2026 Jane Smith Paid Fulfilled 84.98 EUR": + - cell "#1017" + - cell "Jul 26, 2026" + - cell "Jane Smith" + - cell "Paid" + - cell "Fulfilled" + - cell "84.98 EUR" + - row "#1016 Jul 26, 2026 Jane Smith Partially refunded Unfulfilled 27.49 EUR": + - cell "#1016" + - cell "Jul 26, 2026" + - cell "Jane Smith" + - cell "Partially refunded" + - cell "Unfulfilled" + - cell "27.49 EUR" + - row "#1015 Jul 26, 2026 John Doe Paid Unfulfilled 54.47 EUR": + - cell "#1015" + - cell "Jul 26, 2026" + - cell "John Doe" + - cell "Paid" + - cell "Unfulfilled" + - cell "54.47 EUR" + - row "#1005 Jul 26, 2026 Jane Smith Pending Unfulfilled 39.98 EUR": + - cell "#1005" + - cell "Jul 26, 2026" + - cell "Jane Smith" + - cell "Pending" + - cell "Unfulfilled" + - cell "39.98 EUR" + - row "#1013 Jul 25, 2026 Robert Martinez Paid Unfulfilled 84.97 EUR": + - cell "#1013" + - cell "Jul 25, 2026" + - cell "Robert Martinez" + - cell "Paid" + - cell "Unfulfilled" + - cell "84.97 EUR" + - row "#1010 Jul 25, 2026 John Doe Paid Unfulfilled 504.98 EUR": + - cell "#1010" + - cell "Jul 25, 2026" + - cell "John Doe" + - cell "Paid" + - cell "Unfulfilled" + - cell "504.98 EUR" + - row "#1006 Jul 25, 2026 Michael Brown Paid Unfulfilled 124.98 EUR": + - cell "#1006" + - cell "Jul 25, 2026" + - cell "Michael Brown" + - cell "Paid" + - cell "Unfulfilled" + - cell "124.98 EUR" + - row "#1001 Jul 24, 2026 John Doe Paid Unfulfilled 54.97 EUR": + - cell "#1001" + - cell "Jul 24, 2026" + - cell "John Doe" + - cell "Paid" + - cell "Unfulfilled" + - cell "54.97 EUR" + - row "#1009 Jul 23, 2026 Emma Garcia Paid Unfulfilled 49.97 EUR": + - cell "#1009" + - cell "Jul 23, 2026" + - cell "Emma Garcia" + - cell "Paid" + - cell "Unfulfilled" + - cell "49.97 EUR" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-31-14-818Z.yml b/.playwright-mcp/page-2026-07-26T09-31-14-818Z.yml new file mode 100644 index 00000000..4bbaf523 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-31-14-818Z.yml @@ -0,0 +1,22 @@ +- generic [ref=f77e1]: + - link "Skip to main content" [ref=f77e2] [cursor=pointer]: + - /url: "#main-content" + - main [ref=f77e3]: + - generic [ref=f77e5]: + - generic [ref=f77e6]: + - heading "Log in" [level=1] [ref=f77e7] + - paragraph [ref=f77e8]: Sign in to your admin account + - generic [ref=f77e9]: + - generic [ref=f77e10]: + - generic [ref=f77e11]: Email + - textbox "Email" [active] [ref=f77e13] + - generic [ref=f77e14]: + - generic [ref=f77e15]: Password + - textbox "Password" [ref=f77e17] + - generic [ref=f77e18]: + - generic [ref=f77e19]: + - checkbox "Remember me" [ref=f77e20] + - generic [ref=f77e22]: Remember me + - link "Forgot password?" [ref=f77e23] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/forgot-password + - button "Log in" [ref=f77e24] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-31-45-101Z.yml b/.playwright-mcp/page-2026-07-26T09-31-45-101Z.yml new file mode 100644 index 00000000..1beed4d5 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-31-45-101Z.yml @@ -0,0 +1,159 @@ +- generic [active] [ref=f78e1]: + - link "Skip to main content" [ref=f78e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f78e3]: + - complementary "Admin navigation" [ref=f78e4]: + - generic [ref=f78e5]: + - link "Acme Fashion" [ref=f78e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f78e12]: + - navigation [ref=f78e13]: + - link "Dashboard" [ref=f78e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f78e19]: Products + - navigation [ref=f78e20]: + - link "Products" [ref=f78e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f78e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f78e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f78e36]: Orders + - navigation [ref=f78e37]: + - link "Orders" [ref=f78e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f78e43]: Customers + - navigation [ref=f78e44]: + - link "Customers" [ref=f78e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f78e50]: Discounts + - navigation [ref=f78e51]: + - link "Discounts" [ref=f78e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f78e58]: Content + - navigation [ref=f78e59]: + - link "Pages" [ref=f78e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - navigation [ref=f78e65]: + - link "Analytics" [ref=f78e66] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - generic [ref=f78e72]: + - banner [ref=f78e73]: + - button "Acme Fashion" [ref=f78e75] + - button "Notifications" [ref=f78e80] + - button "SU Staff User" [ref=f78e84]: + - generic [ref=f78e85]: SU + - generic [ref=f78e88]: Staff User + - main [ref=f78e92]: + - generic [ref=f78e93]: + - generic [ref=f78e94]: Home + - generic [ref=f78e98]: Dashboard + - generic [ref=f78e100]: + - generic [ref=f78e101]: + - heading "Dashboard" [level=1] [ref=f78e102] + - combobox "Date range" [ref=f78e103]: + - option "Last 7 days" + - option "Last 30 days" [selected] + - option "Last 90 days" + - generic [ref=f78e104]: + - generic [ref=f78e105]: + - paragraph [ref=f78e106]: Total Sales + - generic [ref=f78e107]: 1,689.58 EUR + - generic [ref=f78e108]: + - paragraph [ref=f78e109]: Orders + - generic [ref=f78e110]: "18" + - generic [ref=f78e111]: + - paragraph [ref=f78e112]: Avg. Order Value + - generic [ref=f78e113]: 93.86 EUR + - generic [ref=f78e114]: + - paragraph [ref=f78e115]: Conversion Rate + - generic [ref=f78e116]: 48.6% + - generic [ref=f78e117]: + - heading "Orders over time" [level=2] [ref=f78e118] + - generic [ref=f78e119]: + - img "Daily order counts for the selected period" [ref=f78e120] + - generic [ref=f78e122]: + - generic [ref=f78e123]: 2026-06-27 + - generic [ref=f78e124]: 2026-07-26 + - generic [ref=f78e125]: + - heading "Recent orders" [level=2] [ref=f78e126] + - table [ref=f78e128]: + - rowgroup [ref=f78e129]: + - row [ref=f78e130]: + - columnheader "Order" [ref=f78e131] + - columnheader "Date" [ref=f78e132] + - columnheader "Customer" [ref=f78e133] + - columnheader "Payment" [ref=f78e134] + - columnheader "Fulfillment" [ref=f78e135] + - columnheader "Total" [ref=f78e136] + - rowgroup [ref=f78e137]: + - row [ref=f78e138]: + - cell "#1018" [ref=f78e139] + - cell "Jul 26, 2026" [ref=f78e140] + - cell "John Doe" [ref=f78e141] + - cell "Paid" [ref=f78e142] + - cell "Unfulfilled" [ref=f78e144] + - cell "59.99 EUR" [ref=f78e146] + - row [ref=f78e147]: + - cell "#1017" [ref=f78e148] + - cell "Jul 26, 2026" [ref=f78e149] + - cell "Jane Smith" [ref=f78e150] + - cell "Paid" [ref=f78e151] + - cell "Fulfilled" [ref=f78e153] + - cell "84.98 EUR" [ref=f78e155] + - row [ref=f78e156]: + - cell "#1016" [ref=f78e157] + - cell "Jul 26, 2026" [ref=f78e158] + - cell "Jane Smith" [ref=f78e159] + - cell "Partially refunded" [ref=f78e160] + - cell "Unfulfilled" [ref=f78e162] + - cell "27.49 EUR" [ref=f78e164] + - row [ref=f78e165]: + - cell "#1015" [ref=f78e166] + - cell "Jul 26, 2026" [ref=f78e167] + - cell "John Doe" [ref=f78e168] + - cell "Paid" [ref=f78e169] + - cell "Unfulfilled" [ref=f78e171] + - cell "54.47 EUR" [ref=f78e173] + - row [ref=f78e174]: + - cell "#1005" [ref=f78e175] + - cell "Jul 26, 2026" [ref=f78e176] + - cell "Jane Smith" [ref=f78e177] + - cell "Pending" [ref=f78e178] + - cell "Unfulfilled" [ref=f78e180] + - cell "39.98 EUR" [ref=f78e182] + - row [ref=f78e183]: + - cell "#1013" [ref=f78e184] + - cell "Jul 25, 2026" [ref=f78e185] + - cell "Robert Martinez" [ref=f78e186] + - cell "Paid" [ref=f78e187] + - cell "Unfulfilled" [ref=f78e189] + - cell "84.97 EUR" [ref=f78e191] + - row [ref=f78e192]: + - cell "#1010" [ref=f78e193] + - cell "Jul 25, 2026" [ref=f78e194] + - cell "John Doe" [ref=f78e195] + - cell "Paid" [ref=f78e196] + - cell "Unfulfilled" [ref=f78e198] + - cell "504.98 EUR" [ref=f78e200] + - row [ref=f78e201]: + - cell "#1006" [ref=f78e202] + - cell "Jul 25, 2026" [ref=f78e203] + - cell "Michael Brown" [ref=f78e204] + - cell "Paid" [ref=f78e205] + - cell "Unfulfilled" [ref=f78e207] + - cell "124.98 EUR" [ref=f78e209] + - row [ref=f78e210]: + - cell "#1001" [ref=f78e211] + - cell "Jul 24, 2026" [ref=f78e212] + - cell "John Doe" [ref=f78e213] + - cell "Paid" [ref=f78e214] + - cell "Unfulfilled" [ref=f78e216] + - cell "54.97 EUR" [ref=f78e218] + - row [ref=f78e219]: + - cell "#1009" [ref=f78e220] + - cell "Jul 23, 2026" [ref=f78e221] + - cell "Emma Garcia" [ref=f78e222] + - cell "Paid" [ref=f78e223] + - cell "Unfulfilled" [ref=f78e225] + - cell "49.97 EUR" [ref=f78e227] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-32-11-612Z.yml b/.playwright-mcp/page-2026-07-26T09-32-11-612Z.yml new file mode 100644 index 00000000..4a3962e1 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-32-11-612Z.yml @@ -0,0 +1,22 @@ +- generic [active] [ref=f79e1]: + - link "Skip to main content" [ref=f79e2] [cursor=pointer]: + - /url: "#main-content" + - banner [ref=f79e3]: + - link "Acme Fashion" [ref=f79e5] [cursor=pointer]: + - /url: / + - main [ref=f79e6]: + - generic [ref=f79e7]: + - paragraph: "403" + - generic [ref=f79e8]: + - heading "Access denied" [level=1] [ref=f79e9] + - paragraph [ref=f79e10]: This action is unauthorized. + - link "Go to home page" [ref=f79e11] [cursor=pointer]: + - /url: / + - contentinfo [ref=f79e12]: + - navigation "Helpful links" [ref=f79e13]: + - link "Home" [ref=f79e14] [cursor=pointer]: + - /url: / + - link "Collections" [ref=f79e15] [cursor=pointer]: + - /url: /collections + - link "Search" [ref=f79e16] [cursor=pointer]: + - /url: /search \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-32-29-014Z.yml b/.playwright-mcp/page-2026-07-26T09-32-29-014Z.yml new file mode 100644 index 00000000..947fcda4 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-32-29-014Z.yml @@ -0,0 +1,159 @@ +- generic [active] [ref=f80e1]: + - link "Skip to main content" [ref=f80e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f80e3]: + - complementary "Admin navigation" [ref=f80e4]: + - generic [ref=f80e5]: + - link "Acme Fashion" [ref=f80e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f80e12]: + - navigation [ref=f80e13]: + - link "Dashboard" [ref=f80e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f80e19]: Products + - navigation [ref=f80e20]: + - link "Products" [ref=f80e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f80e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f80e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f80e36]: Orders + - navigation [ref=f80e37]: + - link "Orders" [ref=f80e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f80e43]: Customers + - navigation [ref=f80e44]: + - link "Customers" [ref=f80e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f80e50]: Discounts + - navigation [ref=f80e51]: + - link "Discounts" [ref=f80e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f80e58]: Content + - navigation [ref=f80e59]: + - link "Pages" [ref=f80e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - navigation [ref=f80e65]: + - link "Analytics" [ref=f80e66] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - generic [ref=f80e72]: + - banner [ref=f80e73]: + - button "Acme Fashion" [ref=f80e75] + - button "Notifications" [ref=f80e80] + - button "SU Staff User" [ref=f80e84]: + - generic [ref=f80e85]: SU + - generic [ref=f80e88]: Staff User + - main [ref=f80e92]: + - generic [ref=f80e93]: + - generic [ref=f80e94]: Home + - generic [ref=f80e98]: Dashboard + - generic [ref=f80e100]: + - generic [ref=f80e101]: + - heading "Dashboard" [level=1] [ref=f80e102] + - combobox "Date range" [ref=f80e103]: + - option "Last 7 days" + - option "Last 30 days" [selected] + - option "Last 90 days" + - generic [ref=f80e104]: + - generic [ref=f80e105]: + - paragraph [ref=f80e106]: Total Sales + - generic [ref=f80e107]: 1,689.58 EUR + - generic [ref=f80e108]: + - paragraph [ref=f80e109]: Orders + - generic [ref=f80e110]: "18" + - generic [ref=f80e111]: + - paragraph [ref=f80e112]: Avg. Order Value + - generic [ref=f80e113]: 93.86 EUR + - generic [ref=f80e114]: + - paragraph [ref=f80e115]: Conversion Rate + - generic [ref=f80e116]: 48.6% + - generic [ref=f80e117]: + - heading "Orders over time" [level=2] [ref=f80e118] + - generic [ref=f80e119]: + - img "Daily order counts for the selected period" [ref=f80e120] + - generic [ref=f80e122]: + - generic [ref=f80e123]: 2026-06-27 + - generic [ref=f80e124]: 2026-07-26 + - generic [ref=f80e125]: + - heading "Recent orders" [level=2] [ref=f80e126] + - table [ref=f80e128]: + - rowgroup [ref=f80e129]: + - row [ref=f80e130]: + - columnheader "Order" [ref=f80e131] + - columnheader "Date" [ref=f80e132] + - columnheader "Customer" [ref=f80e133] + - columnheader "Payment" [ref=f80e134] + - columnheader "Fulfillment" [ref=f80e135] + - columnheader "Total" [ref=f80e136] + - rowgroup [ref=f80e137]: + - row [ref=f80e138]: + - cell "#1018" [ref=f80e139] + - cell "Jul 26, 2026" [ref=f80e140] + - cell "John Doe" [ref=f80e141] + - cell "Paid" [ref=f80e142] + - cell "Unfulfilled" [ref=f80e144] + - cell "59.99 EUR" [ref=f80e146] + - row [ref=f80e147]: + - cell "#1017" [ref=f80e148] + - cell "Jul 26, 2026" [ref=f80e149] + - cell "Jane Smith" [ref=f80e150] + - cell "Paid" [ref=f80e151] + - cell "Fulfilled" [ref=f80e153] + - cell "84.98 EUR" [ref=f80e155] + - row [ref=f80e156]: + - cell "#1016" [ref=f80e157] + - cell "Jul 26, 2026" [ref=f80e158] + - cell "Jane Smith" [ref=f80e159] + - cell "Partially refunded" [ref=f80e160] + - cell "Unfulfilled" [ref=f80e162] + - cell "27.49 EUR" [ref=f80e164] + - row [ref=f80e165]: + - cell "#1015" [ref=f80e166] + - cell "Jul 26, 2026" [ref=f80e167] + - cell "John Doe" [ref=f80e168] + - cell "Paid" [ref=f80e169] + - cell "Unfulfilled" [ref=f80e171] + - cell "54.47 EUR" [ref=f80e173] + - row [ref=f80e174]: + - cell "#1005" [ref=f80e175] + - cell "Jul 26, 2026" [ref=f80e176] + - cell "Jane Smith" [ref=f80e177] + - cell "Pending" [ref=f80e178] + - cell "Unfulfilled" [ref=f80e180] + - cell "39.98 EUR" [ref=f80e182] + - row [ref=f80e183]: + - cell "#1013" [ref=f80e184] + - cell "Jul 25, 2026" [ref=f80e185] + - cell "Robert Martinez" [ref=f80e186] + - cell "Paid" [ref=f80e187] + - cell "Unfulfilled" [ref=f80e189] + - cell "84.97 EUR" [ref=f80e191] + - row [ref=f80e192]: + - cell "#1010" [ref=f80e193] + - cell "Jul 25, 2026" [ref=f80e194] + - cell "John Doe" [ref=f80e195] + - cell "Paid" [ref=f80e196] + - cell "Unfulfilled" [ref=f80e198] + - cell "504.98 EUR" [ref=f80e200] + - row [ref=f80e201]: + - cell "#1006" [ref=f80e202] + - cell "Jul 25, 2026" [ref=f80e203] + - cell "Michael Brown" [ref=f80e204] + - cell "Paid" [ref=f80e205] + - cell "Unfulfilled" [ref=f80e207] + - cell "124.98 EUR" [ref=f80e209] + - row [ref=f80e210]: + - cell "#1001" [ref=f80e211] + - cell "Jul 24, 2026" [ref=f80e212] + - cell "John Doe" [ref=f80e213] + - cell "Paid" [ref=f80e214] + - cell "Unfulfilled" [ref=f80e216] + - cell "54.97 EUR" [ref=f80e218] + - row [ref=f80e219]: + - cell "#1009" [ref=f80e220] + - cell "Jul 23, 2026" [ref=f80e221] + - cell "Emma Garcia" [ref=f80e222] + - cell "Paid" [ref=f80e223] + - cell "Unfulfilled" [ref=f80e225] + - cell "49.97 EUR" [ref=f80e227] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-32-40-875Z.yml b/.playwright-mcp/page-2026-07-26T09-32-40-875Z.yml new file mode 100644 index 00000000..947fcda4 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-32-40-875Z.yml @@ -0,0 +1,159 @@ +- generic [active] [ref=f80e1]: + - link "Skip to main content" [ref=f80e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f80e3]: + - complementary "Admin navigation" [ref=f80e4]: + - generic [ref=f80e5]: + - link "Acme Fashion" [ref=f80e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f80e12]: + - navigation [ref=f80e13]: + - link "Dashboard" [ref=f80e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f80e19]: Products + - navigation [ref=f80e20]: + - link "Products" [ref=f80e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f80e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f80e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f80e36]: Orders + - navigation [ref=f80e37]: + - link "Orders" [ref=f80e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f80e43]: Customers + - navigation [ref=f80e44]: + - link "Customers" [ref=f80e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f80e50]: Discounts + - navigation [ref=f80e51]: + - link "Discounts" [ref=f80e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f80e58]: Content + - navigation [ref=f80e59]: + - link "Pages" [ref=f80e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - navigation [ref=f80e65]: + - link "Analytics" [ref=f80e66] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - generic [ref=f80e72]: + - banner [ref=f80e73]: + - button "Acme Fashion" [ref=f80e75] + - button "Notifications" [ref=f80e80] + - button "SU Staff User" [ref=f80e84]: + - generic [ref=f80e85]: SU + - generic [ref=f80e88]: Staff User + - main [ref=f80e92]: + - generic [ref=f80e93]: + - generic [ref=f80e94]: Home + - generic [ref=f80e98]: Dashboard + - generic [ref=f80e100]: + - generic [ref=f80e101]: + - heading "Dashboard" [level=1] [ref=f80e102] + - combobox "Date range" [ref=f80e103]: + - option "Last 7 days" + - option "Last 30 days" [selected] + - option "Last 90 days" + - generic [ref=f80e104]: + - generic [ref=f80e105]: + - paragraph [ref=f80e106]: Total Sales + - generic [ref=f80e107]: 1,689.58 EUR + - generic [ref=f80e108]: + - paragraph [ref=f80e109]: Orders + - generic [ref=f80e110]: "18" + - generic [ref=f80e111]: + - paragraph [ref=f80e112]: Avg. Order Value + - generic [ref=f80e113]: 93.86 EUR + - generic [ref=f80e114]: + - paragraph [ref=f80e115]: Conversion Rate + - generic [ref=f80e116]: 48.6% + - generic [ref=f80e117]: + - heading "Orders over time" [level=2] [ref=f80e118] + - generic [ref=f80e119]: + - img "Daily order counts for the selected period" [ref=f80e120] + - generic [ref=f80e122]: + - generic [ref=f80e123]: 2026-06-27 + - generic [ref=f80e124]: 2026-07-26 + - generic [ref=f80e125]: + - heading "Recent orders" [level=2] [ref=f80e126] + - table [ref=f80e128]: + - rowgroup [ref=f80e129]: + - row [ref=f80e130]: + - columnheader "Order" [ref=f80e131] + - columnheader "Date" [ref=f80e132] + - columnheader "Customer" [ref=f80e133] + - columnheader "Payment" [ref=f80e134] + - columnheader "Fulfillment" [ref=f80e135] + - columnheader "Total" [ref=f80e136] + - rowgroup [ref=f80e137]: + - row [ref=f80e138]: + - cell "#1018" [ref=f80e139] + - cell "Jul 26, 2026" [ref=f80e140] + - cell "John Doe" [ref=f80e141] + - cell "Paid" [ref=f80e142] + - cell "Unfulfilled" [ref=f80e144] + - cell "59.99 EUR" [ref=f80e146] + - row [ref=f80e147]: + - cell "#1017" [ref=f80e148] + - cell "Jul 26, 2026" [ref=f80e149] + - cell "Jane Smith" [ref=f80e150] + - cell "Paid" [ref=f80e151] + - cell "Fulfilled" [ref=f80e153] + - cell "84.98 EUR" [ref=f80e155] + - row [ref=f80e156]: + - cell "#1016" [ref=f80e157] + - cell "Jul 26, 2026" [ref=f80e158] + - cell "Jane Smith" [ref=f80e159] + - cell "Partially refunded" [ref=f80e160] + - cell "Unfulfilled" [ref=f80e162] + - cell "27.49 EUR" [ref=f80e164] + - row [ref=f80e165]: + - cell "#1015" [ref=f80e166] + - cell "Jul 26, 2026" [ref=f80e167] + - cell "John Doe" [ref=f80e168] + - cell "Paid" [ref=f80e169] + - cell "Unfulfilled" [ref=f80e171] + - cell "54.47 EUR" [ref=f80e173] + - row [ref=f80e174]: + - cell "#1005" [ref=f80e175] + - cell "Jul 26, 2026" [ref=f80e176] + - cell "Jane Smith" [ref=f80e177] + - cell "Pending" [ref=f80e178] + - cell "Unfulfilled" [ref=f80e180] + - cell "39.98 EUR" [ref=f80e182] + - row [ref=f80e183]: + - cell "#1013" [ref=f80e184] + - cell "Jul 25, 2026" [ref=f80e185] + - cell "Robert Martinez" [ref=f80e186] + - cell "Paid" [ref=f80e187] + - cell "Unfulfilled" [ref=f80e189] + - cell "84.97 EUR" [ref=f80e191] + - row [ref=f80e192]: + - cell "#1010" [ref=f80e193] + - cell "Jul 25, 2026" [ref=f80e194] + - cell "John Doe" [ref=f80e195] + - cell "Paid" [ref=f80e196] + - cell "Unfulfilled" [ref=f80e198] + - cell "504.98 EUR" [ref=f80e200] + - row [ref=f80e201]: + - cell "#1006" [ref=f80e202] + - cell "Jul 25, 2026" [ref=f80e203] + - cell "Michael Brown" [ref=f80e204] + - cell "Paid" [ref=f80e205] + - cell "Unfulfilled" [ref=f80e207] + - cell "124.98 EUR" [ref=f80e209] + - row [ref=f80e210]: + - cell "#1001" [ref=f80e211] + - cell "Jul 24, 2026" [ref=f80e212] + - cell "John Doe" [ref=f80e213] + - cell "Paid" [ref=f80e214] + - cell "Unfulfilled" [ref=f80e216] + - cell "54.97 EUR" [ref=f80e218] + - row [ref=f80e219]: + - cell "#1009" [ref=f80e220] + - cell "Jul 23, 2026" [ref=f80e221] + - cell "Emma Garcia" [ref=f80e222] + - cell "Paid" [ref=f80e223] + - cell "Unfulfilled" [ref=f80e225] + - cell "49.97 EUR" [ref=f80e227] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-33-57-157Z.yml b/.playwright-mcp/page-2026-07-26T09-33-57-157Z.yml new file mode 100644 index 00000000..a4e17c70 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-33-57-157Z.yml @@ -0,0 +1,159 @@ +- generic [active] [ref=f81e1]: + - link "Skip to main content" [ref=f81e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f81e3]: + - complementary "Admin navigation" [ref=f81e4]: + - generic [ref=f81e5]: + - link "Acme Fashion" [ref=f81e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f81e12]: + - navigation [ref=f81e13]: + - link "Dashboard" [ref=f81e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f81e19]: Products + - navigation [ref=f81e20]: + - link "Products" [ref=f81e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f81e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f81e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f81e36]: Orders + - navigation [ref=f81e37]: + - link "Orders" [ref=f81e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f81e43]: Customers + - navigation [ref=f81e44]: + - link "Customers" [ref=f81e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f81e50]: Discounts + - navigation [ref=f81e51]: + - link "Discounts" [ref=f81e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f81e58]: Content + - navigation [ref=f81e59]: + - link "Pages" [ref=f81e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - navigation [ref=f81e65]: + - link "Analytics" [ref=f81e66] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - generic [ref=f81e72]: + - banner [ref=f81e73]: + - button "Acme Fashion" [ref=f81e75] + - button "Notifications" [ref=f81e80] + - button "SU Staff User" [ref=f81e84]: + - generic [ref=f81e85]: SU + - generic [ref=f81e88]: Staff User + - main [ref=f81e92]: + - generic [ref=f81e93]: + - generic [ref=f81e94]: Home + - generic [ref=f81e98]: Dashboard + - generic [ref=f81e100]: + - generic [ref=f81e101]: + - heading "Dashboard" [level=1] [ref=f81e102] + - combobox "Date range" [ref=f81e103]: + - option "Last 7 days" + - option "Last 30 days" [selected] + - option "Last 90 days" + - generic [ref=f81e104]: + - generic [ref=f81e105]: + - paragraph [ref=f81e106]: Total Sales + - generic [ref=f81e107]: 1,689.58 EUR + - generic [ref=f81e108]: + - paragraph [ref=f81e109]: Orders + - generic [ref=f81e110]: "18" + - generic [ref=f81e111]: + - paragraph [ref=f81e112]: Avg. Order Value + - generic [ref=f81e113]: 93.86 EUR + - generic [ref=f81e114]: + - paragraph [ref=f81e115]: Conversion Rate + - generic [ref=f81e116]: 48.6% + - generic [ref=f81e117]: + - heading "Orders over time" [level=2] [ref=f81e118] + - generic [ref=f81e119]: + - img "Daily order counts for the selected period" [ref=f81e120] + - generic [ref=f81e122]: + - generic [ref=f81e123]: 2026-06-27 + - generic [ref=f81e124]: 2026-07-26 + - generic [ref=f81e125]: + - heading "Recent orders" [level=2] [ref=f81e126] + - table [ref=f81e128]: + - rowgroup [ref=f81e129]: + - row [ref=f81e130]: + - columnheader "Order" [ref=f81e131] + - columnheader "Date" [ref=f81e132] + - columnheader "Customer" [ref=f81e133] + - columnheader "Payment" [ref=f81e134] + - columnheader "Fulfillment" [ref=f81e135] + - columnheader "Total" [ref=f81e136] + - rowgroup [ref=f81e137]: + - row [ref=f81e138]: + - cell "#1018" [ref=f81e139] + - cell "Jul 26, 2026" [ref=f81e140] + - cell "John Doe" [ref=f81e141] + - cell "Paid" [ref=f81e142] + - cell "Unfulfilled" [ref=f81e144] + - cell "59.99 EUR" [ref=f81e146] + - row [ref=f81e147]: + - cell "#1017" [ref=f81e148] + - cell "Jul 26, 2026" [ref=f81e149] + - cell "Jane Smith" [ref=f81e150] + - cell "Paid" [ref=f81e151] + - cell "Fulfilled" [ref=f81e153] + - cell "84.98 EUR" [ref=f81e155] + - row [ref=f81e156]: + - cell "#1016" [ref=f81e157] + - cell "Jul 26, 2026" [ref=f81e158] + - cell "Jane Smith" [ref=f81e159] + - cell "Partially refunded" [ref=f81e160] + - cell "Unfulfilled" [ref=f81e162] + - cell "27.49 EUR" [ref=f81e164] + - row [ref=f81e165]: + - cell "#1015" [ref=f81e166] + - cell "Jul 26, 2026" [ref=f81e167] + - cell "John Doe" [ref=f81e168] + - cell "Paid" [ref=f81e169] + - cell "Unfulfilled" [ref=f81e171] + - cell "54.47 EUR" [ref=f81e173] + - row [ref=f81e174]: + - cell "#1005" [ref=f81e175] + - cell "Jul 26, 2026" [ref=f81e176] + - cell "Jane Smith" [ref=f81e177] + - cell "Pending" [ref=f81e178] + - cell "Unfulfilled" [ref=f81e180] + - cell "39.98 EUR" [ref=f81e182] + - row [ref=f81e183]: + - cell "#1013" [ref=f81e184] + - cell "Jul 25, 2026" [ref=f81e185] + - cell "Robert Martinez" [ref=f81e186] + - cell "Paid" [ref=f81e187] + - cell "Unfulfilled" [ref=f81e189] + - cell "84.97 EUR" [ref=f81e191] + - row [ref=f81e192]: + - cell "#1010" [ref=f81e193] + - cell "Jul 25, 2026" [ref=f81e194] + - cell "John Doe" [ref=f81e195] + - cell "Paid" [ref=f81e196] + - cell "Unfulfilled" [ref=f81e198] + - cell "504.98 EUR" [ref=f81e200] + - row [ref=f81e201]: + - cell "#1006" [ref=f81e202] + - cell "Jul 25, 2026" [ref=f81e203] + - cell "Michael Brown" [ref=f81e204] + - cell "Paid" [ref=f81e205] + - cell "Unfulfilled" [ref=f81e207] + - cell "124.98 EUR" [ref=f81e209] + - row [ref=f81e210]: + - cell "#1001" [ref=f81e211] + - cell "Jul 24, 2026" [ref=f81e212] + - cell "John Doe" [ref=f81e213] + - cell "Paid" [ref=f81e214] + - cell "Unfulfilled" [ref=f81e216] + - cell "54.97 EUR" [ref=f81e218] + - row [ref=f81e219]: + - cell "#1009" [ref=f81e220] + - cell "Jul 23, 2026" [ref=f81e221] + - cell "Emma Garcia" [ref=f81e222] + - cell "Paid" [ref=f81e223] + - cell "Unfulfilled" [ref=f81e225] + - cell "49.97 EUR" [ref=f81e227] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-34-27-673Z.yml b/.playwright-mcp/page-2026-07-26T09-34-27-673Z.yml new file mode 100644 index 00000000..a4e17c70 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-34-27-673Z.yml @@ -0,0 +1,159 @@ +- generic [active] [ref=f81e1]: + - link "Skip to main content" [ref=f81e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f81e3]: + - complementary "Admin navigation" [ref=f81e4]: + - generic [ref=f81e5]: + - link "Acme Fashion" [ref=f81e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f81e12]: + - navigation [ref=f81e13]: + - link "Dashboard" [ref=f81e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f81e19]: Products + - navigation [ref=f81e20]: + - link "Products" [ref=f81e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f81e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f81e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f81e36]: Orders + - navigation [ref=f81e37]: + - link "Orders" [ref=f81e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f81e43]: Customers + - navigation [ref=f81e44]: + - link "Customers" [ref=f81e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f81e50]: Discounts + - navigation [ref=f81e51]: + - link "Discounts" [ref=f81e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f81e58]: Content + - navigation [ref=f81e59]: + - link "Pages" [ref=f81e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - navigation [ref=f81e65]: + - link "Analytics" [ref=f81e66] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - generic [ref=f81e72]: + - banner [ref=f81e73]: + - button "Acme Fashion" [ref=f81e75] + - button "Notifications" [ref=f81e80] + - button "SU Staff User" [ref=f81e84]: + - generic [ref=f81e85]: SU + - generic [ref=f81e88]: Staff User + - main [ref=f81e92]: + - generic [ref=f81e93]: + - generic [ref=f81e94]: Home + - generic [ref=f81e98]: Dashboard + - generic [ref=f81e100]: + - generic [ref=f81e101]: + - heading "Dashboard" [level=1] [ref=f81e102] + - combobox "Date range" [ref=f81e103]: + - option "Last 7 days" + - option "Last 30 days" [selected] + - option "Last 90 days" + - generic [ref=f81e104]: + - generic [ref=f81e105]: + - paragraph [ref=f81e106]: Total Sales + - generic [ref=f81e107]: 1,689.58 EUR + - generic [ref=f81e108]: + - paragraph [ref=f81e109]: Orders + - generic [ref=f81e110]: "18" + - generic [ref=f81e111]: + - paragraph [ref=f81e112]: Avg. Order Value + - generic [ref=f81e113]: 93.86 EUR + - generic [ref=f81e114]: + - paragraph [ref=f81e115]: Conversion Rate + - generic [ref=f81e116]: 48.6% + - generic [ref=f81e117]: + - heading "Orders over time" [level=2] [ref=f81e118] + - generic [ref=f81e119]: + - img "Daily order counts for the selected period" [ref=f81e120] + - generic [ref=f81e122]: + - generic [ref=f81e123]: 2026-06-27 + - generic [ref=f81e124]: 2026-07-26 + - generic [ref=f81e125]: + - heading "Recent orders" [level=2] [ref=f81e126] + - table [ref=f81e128]: + - rowgroup [ref=f81e129]: + - row [ref=f81e130]: + - columnheader "Order" [ref=f81e131] + - columnheader "Date" [ref=f81e132] + - columnheader "Customer" [ref=f81e133] + - columnheader "Payment" [ref=f81e134] + - columnheader "Fulfillment" [ref=f81e135] + - columnheader "Total" [ref=f81e136] + - rowgroup [ref=f81e137]: + - row [ref=f81e138]: + - cell "#1018" [ref=f81e139] + - cell "Jul 26, 2026" [ref=f81e140] + - cell "John Doe" [ref=f81e141] + - cell "Paid" [ref=f81e142] + - cell "Unfulfilled" [ref=f81e144] + - cell "59.99 EUR" [ref=f81e146] + - row [ref=f81e147]: + - cell "#1017" [ref=f81e148] + - cell "Jul 26, 2026" [ref=f81e149] + - cell "Jane Smith" [ref=f81e150] + - cell "Paid" [ref=f81e151] + - cell "Fulfilled" [ref=f81e153] + - cell "84.98 EUR" [ref=f81e155] + - row [ref=f81e156]: + - cell "#1016" [ref=f81e157] + - cell "Jul 26, 2026" [ref=f81e158] + - cell "Jane Smith" [ref=f81e159] + - cell "Partially refunded" [ref=f81e160] + - cell "Unfulfilled" [ref=f81e162] + - cell "27.49 EUR" [ref=f81e164] + - row [ref=f81e165]: + - cell "#1015" [ref=f81e166] + - cell "Jul 26, 2026" [ref=f81e167] + - cell "John Doe" [ref=f81e168] + - cell "Paid" [ref=f81e169] + - cell "Unfulfilled" [ref=f81e171] + - cell "54.47 EUR" [ref=f81e173] + - row [ref=f81e174]: + - cell "#1005" [ref=f81e175] + - cell "Jul 26, 2026" [ref=f81e176] + - cell "Jane Smith" [ref=f81e177] + - cell "Pending" [ref=f81e178] + - cell "Unfulfilled" [ref=f81e180] + - cell "39.98 EUR" [ref=f81e182] + - row [ref=f81e183]: + - cell "#1013" [ref=f81e184] + - cell "Jul 25, 2026" [ref=f81e185] + - cell "Robert Martinez" [ref=f81e186] + - cell "Paid" [ref=f81e187] + - cell "Unfulfilled" [ref=f81e189] + - cell "84.97 EUR" [ref=f81e191] + - row [ref=f81e192]: + - cell "#1010" [ref=f81e193] + - cell "Jul 25, 2026" [ref=f81e194] + - cell "John Doe" [ref=f81e195] + - cell "Paid" [ref=f81e196] + - cell "Unfulfilled" [ref=f81e198] + - cell "504.98 EUR" [ref=f81e200] + - row [ref=f81e201]: + - cell "#1006" [ref=f81e202] + - cell "Jul 25, 2026" [ref=f81e203] + - cell "Michael Brown" [ref=f81e204] + - cell "Paid" [ref=f81e205] + - cell "Unfulfilled" [ref=f81e207] + - cell "124.98 EUR" [ref=f81e209] + - row [ref=f81e210]: + - cell "#1001" [ref=f81e211] + - cell "Jul 24, 2026" [ref=f81e212] + - cell "John Doe" [ref=f81e213] + - cell "Paid" [ref=f81e214] + - cell "Unfulfilled" [ref=f81e216] + - cell "54.97 EUR" [ref=f81e218] + - row [ref=f81e219]: + - cell "#1009" [ref=f81e220] + - cell "Jul 23, 2026" [ref=f81e221] + - cell "Emma Garcia" [ref=f81e222] + - cell "Paid" [ref=f81e223] + - cell "Unfulfilled" [ref=f81e225] + - cell "49.97 EUR" [ref=f81e227] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-36-01-949Z.yml b/.playwright-mcp/page-2026-07-26T09-36-01-949Z.yml new file mode 100644 index 00000000..a4e17c70 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-36-01-949Z.yml @@ -0,0 +1,159 @@ +- generic [active] [ref=f81e1]: + - link "Skip to main content" [ref=f81e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f81e3]: + - complementary "Admin navigation" [ref=f81e4]: + - generic [ref=f81e5]: + - link "Acme Fashion" [ref=f81e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f81e12]: + - navigation [ref=f81e13]: + - link "Dashboard" [ref=f81e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f81e19]: Products + - navigation [ref=f81e20]: + - link "Products" [ref=f81e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f81e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f81e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f81e36]: Orders + - navigation [ref=f81e37]: + - link "Orders" [ref=f81e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f81e43]: Customers + - navigation [ref=f81e44]: + - link "Customers" [ref=f81e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f81e50]: Discounts + - navigation [ref=f81e51]: + - link "Discounts" [ref=f81e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f81e58]: Content + - navigation [ref=f81e59]: + - link "Pages" [ref=f81e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - navigation [ref=f81e65]: + - link "Analytics" [ref=f81e66] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - generic [ref=f81e72]: + - banner [ref=f81e73]: + - button "Acme Fashion" [ref=f81e75] + - button "Notifications" [ref=f81e80] + - button "SU Staff User" [ref=f81e84]: + - generic [ref=f81e85]: SU + - generic [ref=f81e88]: Staff User + - main [ref=f81e92]: + - generic [ref=f81e93]: + - generic [ref=f81e94]: Home + - generic [ref=f81e98]: Dashboard + - generic [ref=f81e100]: + - generic [ref=f81e101]: + - heading "Dashboard" [level=1] [ref=f81e102] + - combobox "Date range" [ref=f81e103]: + - option "Last 7 days" + - option "Last 30 days" [selected] + - option "Last 90 days" + - generic [ref=f81e104]: + - generic [ref=f81e105]: + - paragraph [ref=f81e106]: Total Sales + - generic [ref=f81e107]: 1,689.58 EUR + - generic [ref=f81e108]: + - paragraph [ref=f81e109]: Orders + - generic [ref=f81e110]: "18" + - generic [ref=f81e111]: + - paragraph [ref=f81e112]: Avg. Order Value + - generic [ref=f81e113]: 93.86 EUR + - generic [ref=f81e114]: + - paragraph [ref=f81e115]: Conversion Rate + - generic [ref=f81e116]: 48.6% + - generic [ref=f81e117]: + - heading "Orders over time" [level=2] [ref=f81e118] + - generic [ref=f81e119]: + - img "Daily order counts for the selected period" [ref=f81e120] + - generic [ref=f81e122]: + - generic [ref=f81e123]: 2026-06-27 + - generic [ref=f81e124]: 2026-07-26 + - generic [ref=f81e125]: + - heading "Recent orders" [level=2] [ref=f81e126] + - table [ref=f81e128]: + - rowgroup [ref=f81e129]: + - row [ref=f81e130]: + - columnheader "Order" [ref=f81e131] + - columnheader "Date" [ref=f81e132] + - columnheader "Customer" [ref=f81e133] + - columnheader "Payment" [ref=f81e134] + - columnheader "Fulfillment" [ref=f81e135] + - columnheader "Total" [ref=f81e136] + - rowgroup [ref=f81e137]: + - row [ref=f81e138]: + - cell "#1018" [ref=f81e139] + - cell "Jul 26, 2026" [ref=f81e140] + - cell "John Doe" [ref=f81e141] + - cell "Paid" [ref=f81e142] + - cell "Unfulfilled" [ref=f81e144] + - cell "59.99 EUR" [ref=f81e146] + - row [ref=f81e147]: + - cell "#1017" [ref=f81e148] + - cell "Jul 26, 2026" [ref=f81e149] + - cell "Jane Smith" [ref=f81e150] + - cell "Paid" [ref=f81e151] + - cell "Fulfilled" [ref=f81e153] + - cell "84.98 EUR" [ref=f81e155] + - row [ref=f81e156]: + - cell "#1016" [ref=f81e157] + - cell "Jul 26, 2026" [ref=f81e158] + - cell "Jane Smith" [ref=f81e159] + - cell "Partially refunded" [ref=f81e160] + - cell "Unfulfilled" [ref=f81e162] + - cell "27.49 EUR" [ref=f81e164] + - row [ref=f81e165]: + - cell "#1015" [ref=f81e166] + - cell "Jul 26, 2026" [ref=f81e167] + - cell "John Doe" [ref=f81e168] + - cell "Paid" [ref=f81e169] + - cell "Unfulfilled" [ref=f81e171] + - cell "54.47 EUR" [ref=f81e173] + - row [ref=f81e174]: + - cell "#1005" [ref=f81e175] + - cell "Jul 26, 2026" [ref=f81e176] + - cell "Jane Smith" [ref=f81e177] + - cell "Pending" [ref=f81e178] + - cell "Unfulfilled" [ref=f81e180] + - cell "39.98 EUR" [ref=f81e182] + - row [ref=f81e183]: + - cell "#1013" [ref=f81e184] + - cell "Jul 25, 2026" [ref=f81e185] + - cell "Robert Martinez" [ref=f81e186] + - cell "Paid" [ref=f81e187] + - cell "Unfulfilled" [ref=f81e189] + - cell "84.97 EUR" [ref=f81e191] + - row [ref=f81e192]: + - cell "#1010" [ref=f81e193] + - cell "Jul 25, 2026" [ref=f81e194] + - cell "John Doe" [ref=f81e195] + - cell "Paid" [ref=f81e196] + - cell "Unfulfilled" [ref=f81e198] + - cell "504.98 EUR" [ref=f81e200] + - row [ref=f81e201]: + - cell "#1006" [ref=f81e202] + - cell "Jul 25, 2026" [ref=f81e203] + - cell "Michael Brown" [ref=f81e204] + - cell "Paid" [ref=f81e205] + - cell "Unfulfilled" [ref=f81e207] + - cell "124.98 EUR" [ref=f81e209] + - row [ref=f81e210]: + - cell "#1001" [ref=f81e211] + - cell "Jul 24, 2026" [ref=f81e212] + - cell "John Doe" [ref=f81e213] + - cell "Paid" [ref=f81e214] + - cell "Unfulfilled" [ref=f81e216] + - cell "54.97 EUR" [ref=f81e218] + - row [ref=f81e219]: + - cell "#1009" [ref=f81e220] + - cell "Jul 23, 2026" [ref=f81e221] + - cell "Emma Garcia" [ref=f81e222] + - cell "Paid" [ref=f81e223] + - cell "Unfulfilled" [ref=f81e225] + - cell "49.97 EUR" [ref=f81e227] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-37-25-452Z.yml b/.playwright-mcp/page-2026-07-26T09-37-25-452Z.yml new file mode 100644 index 00000000..cf5c173d --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-37-25-452Z.yml @@ -0,0 +1,22 @@ +- generic [ref=f82e1]: + - link "Skip to main content" [ref=f82e2] [cursor=pointer]: + - /url: "#main-content" + - main [ref=f82e3]: + - generic [ref=f82e5]: + - generic [ref=f82e6]: + - heading "Log in" [level=1] [ref=f82e7] + - paragraph [ref=f82e8]: Sign in to your admin account + - generic [ref=f82e9]: + - generic [ref=f82e10]: + - generic [ref=f82e11]: Email + - textbox "Email" [ref=f82e13]: support@acme.test + - generic [ref=f82e14]: + - generic [ref=f82e15]: Password + - textbox "Password" [active] [ref=f82e17]: password + - generic [ref=f82e18]: + - generic [ref=f82e19]: + - checkbox "Remember me" [ref=f82e20] + - generic [ref=f82e22]: Remember me + - link "Forgot password?" [ref=f82e23] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/forgot-password + - button "Log in" [ref=f82e24] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-37-36-289Z.yml b/.playwright-mcp/page-2026-07-26T09-37-36-289Z.yml new file mode 100644 index 00000000..cf5c173d --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-37-36-289Z.yml @@ -0,0 +1,22 @@ +- generic [ref=f82e1]: + - link "Skip to main content" [ref=f82e2] [cursor=pointer]: + - /url: "#main-content" + - main [ref=f82e3]: + - generic [ref=f82e5]: + - generic [ref=f82e6]: + - heading "Log in" [level=1] [ref=f82e7] + - paragraph [ref=f82e8]: Sign in to your admin account + - generic [ref=f82e9]: + - generic [ref=f82e10]: + - generic [ref=f82e11]: Email + - textbox "Email" [ref=f82e13]: support@acme.test + - generic [ref=f82e14]: + - generic [ref=f82e15]: Password + - textbox "Password" [active] [ref=f82e17]: password + - generic [ref=f82e18]: + - generic [ref=f82e19]: + - checkbox "Remember me" [ref=f82e20] + - generic [ref=f82e22]: Remember me + - link "Forgot password?" [ref=f82e23] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/forgot-password + - button "Log in" [ref=f82e24] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-38-16-404Z.yml b/.playwright-mcp/page-2026-07-26T09-38-16-404Z.yml new file mode 100644 index 00000000..cf5c173d --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-38-16-404Z.yml @@ -0,0 +1,22 @@ +- generic [ref=f82e1]: + - link "Skip to main content" [ref=f82e2] [cursor=pointer]: + - /url: "#main-content" + - main [ref=f82e3]: + - generic [ref=f82e5]: + - generic [ref=f82e6]: + - heading "Log in" [level=1] [ref=f82e7] + - paragraph [ref=f82e8]: Sign in to your admin account + - generic [ref=f82e9]: + - generic [ref=f82e10]: + - generic [ref=f82e11]: Email + - textbox "Email" [ref=f82e13]: support@acme.test + - generic [ref=f82e14]: + - generic [ref=f82e15]: Password + - textbox "Password" [active] [ref=f82e17]: password + - generic [ref=f82e18]: + - generic [ref=f82e19]: + - checkbox "Remember me" [ref=f82e20] + - generic [ref=f82e22]: Remember me + - link "Forgot password?" [ref=f82e23] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/forgot-password + - button "Log in" [ref=f82e24] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-39-30-355Z.yml b/.playwright-mcp/page-2026-07-26T09-39-30-355Z.yml new file mode 100644 index 00000000..cf5c173d --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-39-30-355Z.yml @@ -0,0 +1,22 @@ +- generic [ref=f82e1]: + - link "Skip to main content" [ref=f82e2] [cursor=pointer]: + - /url: "#main-content" + - main [ref=f82e3]: + - generic [ref=f82e5]: + - generic [ref=f82e6]: + - heading "Log in" [level=1] [ref=f82e7] + - paragraph [ref=f82e8]: Sign in to your admin account + - generic [ref=f82e9]: + - generic [ref=f82e10]: + - generic [ref=f82e11]: Email + - textbox "Email" [ref=f82e13]: support@acme.test + - generic [ref=f82e14]: + - generic [ref=f82e15]: Password + - textbox "Password" [active] [ref=f82e17]: password + - generic [ref=f82e18]: + - generic [ref=f82e19]: + - checkbox "Remember me" [ref=f82e20] + - generic [ref=f82e22]: Remember me + - link "Forgot password?" [ref=f82e23] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/forgot-password + - button "Log in" [ref=f82e24] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-40-10-875Z.yml b/.playwright-mcp/page-2026-07-26T09-40-10-875Z.yml new file mode 100644 index 00000000..3c9e1a2d --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-40-10-875Z.yml @@ -0,0 +1,22 @@ +- generic [ref=f83e1]: + - link "Skip to main content" [ref=f83e2] [cursor=pointer]: + - /url: "#main-content" + - main [ref=f83e3]: + - generic [ref=f83e5]: + - generic [ref=f83e6]: + - heading "Log in" [level=1] [ref=f83e7] + - paragraph [ref=f83e8]: Sign in to your admin account + - generic [ref=f83e9]: + - generic [ref=f83e10]: + - generic [ref=f83e11]: Email + - textbox "Email" [active] [ref=f83e13] + - generic [ref=f83e14]: + - generic [ref=f83e15]: Password + - textbox "Password" [ref=f83e17] + - generic [ref=f83e18]: + - generic [ref=f83e19]: + - checkbox "Remember me" [ref=f83e20] + - generic [ref=f83e22]: Remember me + - link "Forgot password?" [ref=f83e23] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/forgot-password + - button "Log in" [ref=f83e24] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-40-35-938Z.yml b/.playwright-mcp/page-2026-07-26T09-40-35-938Z.yml new file mode 100644 index 00000000..7a9f2b32 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-40-35-938Z.yml @@ -0,0 +1,22 @@ +- generic [ref=f83e1]: + - link "Skip to main content" [ref=f83e2] [cursor=pointer]: + - /url: "#main-content" + - main [ref=f83e3]: + - generic [ref=f83e5]: + - generic [ref=f83e6]: + - heading "Log in" [level=1] [ref=f83e7] + - paragraph [ref=f83e8]: Sign in to your admin account + - generic [ref=f83e9]: + - generic [ref=f83e10]: + - generic [ref=f83e11]: Email + - textbox "Email" [ref=f83e13]: support@acme.test + - generic [ref=f83e14]: + - generic [ref=f83e15]: Password + - textbox "Password" [active] [ref=f83e17]: password + - generic [ref=f83e18]: + - generic [ref=f83e19]: + - checkbox "Remember me" [ref=f83e20] + - generic [ref=f83e22]: Remember me + - link "Forgot password?" [ref=f83e23] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/forgot-password + - button "Log in" [ref=f83e24] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-40-48-545Z.yml b/.playwright-mcp/page-2026-07-26T09-40-48-545Z.yml new file mode 100644 index 00000000..7a9f2b32 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-40-48-545Z.yml @@ -0,0 +1,22 @@ +- generic [ref=f83e1]: + - link "Skip to main content" [ref=f83e2] [cursor=pointer]: + - /url: "#main-content" + - main [ref=f83e3]: + - generic [ref=f83e5]: + - generic [ref=f83e6]: + - heading "Log in" [level=1] [ref=f83e7] + - paragraph [ref=f83e8]: Sign in to your admin account + - generic [ref=f83e9]: + - generic [ref=f83e10]: + - generic [ref=f83e11]: Email + - textbox "Email" [ref=f83e13]: support@acme.test + - generic [ref=f83e14]: + - generic [ref=f83e15]: Password + - textbox "Password" [active] [ref=f83e17]: password + - generic [ref=f83e18]: + - generic [ref=f83e19]: + - checkbox "Remember me" [ref=f83e20] + - generic [ref=f83e22]: Remember me + - link "Forgot password?" [ref=f83e23] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/forgot-password + - button "Log in" [ref=f83e24] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-42-32-258Z.yml b/.playwright-mcp/page-2026-07-26T09-42-32-258Z.yml new file mode 100644 index 00000000..7859894c --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-42-32-258Z.yml @@ -0,0 +1,124 @@ +- generic [active] [ref=f85e1]: + - link "Skip to main content" [ref=f85e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f85e3]: + - complementary "Admin navigation" [ref=f85e4]: + - generic [ref=f85e5]: + - link "Acme Fashion" [ref=f85e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f85e12]: + - navigation [ref=f85e13]: + - link "Dashboard" [ref=f85e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f85e19]: Products + - navigation [ref=f85e20]: + - link "Products" [ref=f85e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f85e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f85e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f85e36]: Orders + - navigation [ref=f85e37]: + - link "Orders" [ref=f85e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f85e43]: Customers + - navigation [ref=f85e44]: + - link "Customers" [ref=f85e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - generic [ref=f85e50]: + - banner [ref=f85e51]: + - button "Acme Fashion" [ref=f85e53] + - button "Notifications" [ref=f85e58] + - button "SU Support User" [ref=f85e62]: + - generic [ref=f85e63]: SU + - generic [ref=f85e66]: Support User + - main [ref=f85e70]: + - generic [ref=f85e71]: + - link "Home" [ref=f85e73] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - link "Orders" [ref=f85e77] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - generic [ref=f85e80]: "#1016" + - generic [ref=f85e82]: + - generic [ref=f85e83]: + - generic [ref=f85e84]: "#1016" + - generic [ref=f85e85]: Partially Refunded + - generic [ref=f85e86]: Unfulfilled + - paragraph [ref=f85e87]: Jul 26, 2026 8:34 AM + - generic [ref=f85e88]: + - generic [ref=f85e89]: + - generic [ref=f85e90]: + - generic [ref=f85e91]: Timeline + - list [ref=f85e92]: + - listitem [ref=f85e93]: + - paragraph [ref=f85e95]: Order placed + - paragraph [ref=f85e96]: Jul 26, 2026 8:34 AM + - listitem [ref=f85e97]: + - paragraph [ref=f85e99]: Payment received + - paragraph [ref=f85e100]: Jul 26, 2026 8:34 AM + - listitem [ref=f85e101]: + - paragraph [ref=f85e103]: Refund issued (10.00 EUR) + - paragraph [ref=f85e104]: Jul 26, 2026 8:49 AM + - generic [ref=f85e105]: + - generic [ref=f85e106]: Order lines + - table [ref=f85e108]: + - rowgroup [ref=f85e109]: + - row [ref=f85e110]: + - columnheader "Image" [ref=f85e111] + - columnheader "Product" [ref=f85e113] + - columnheader "Qty" [ref=f85e114] + - columnheader "Unit price" [ref=f85e115] + - columnheader "Total" [ref=f85e116] + - rowgroup [ref=f85e117]: + - row [ref=f85e118]: + - cell [ref=f85e119] + - 'cell "Classic Cotton T-Shirt - S / White SKU: ACME-CTSH-S-WHT" [ref=f85e123]': + - generic [ref=f85e124]: Classic Cotton T-Shirt - S / White + - generic [ref=f85e125]: "SKU: ACME-CTSH-S-WHT" + - cell "1" [ref=f85e126] + - cell "24.99 EUR" [ref=f85e127] + - cell "22.50 EUR" [ref=f85e128] + - generic [ref=f85e129]: + - generic [ref=f85e130]: + - generic [ref=f85e131]: Subtotal + - generic [ref=f85e132]: 24.99 EUR + - generic [ref=f85e133]: + - generic [ref=f85e134]: Discount + - generic [ref=f85e135]: "-2.49 EUR" + - generic [ref=f85e136]: + - generic [ref=f85e137]: Shipping + - generic [ref=f85e138]: 4.99 EUR + - generic [ref=f85e139]: + - generic [ref=f85e140]: Tax + - generic [ref=f85e141]: 4.40 EUR + - generic [ref=f85e142]: + - generic [ref=f85e143]: Total + - generic [ref=f85e144]: 27.49 EUR + - generic [ref=f85e145]: + - generic [ref=f85e146]: Payment details + - generic [ref=f85e148]: + - generic [ref=f85e149]: + - paragraph [ref=f85e150]: Credit Card + - paragraph [ref=f85e151]: "27.49 EUR · Ref: mock_RuUajoEnFLV6EEVn · Jul 26, 2026 8:34 AM" + - generic [ref=f85e152]: Captured + - generic [ref=f85e153]: + - generic [ref=f85e154]: Refunds + - generic [ref=f85e156]: + - generic [ref=f85e157]: + - paragraph [ref=f85e158]: 10.00 EUR + - paragraph [ref=f85e159]: Jul 26, 2026 8:49 AM · Partial goodwill refund + - generic [ref=f85e160]: Processed + - generic [ref=f85e161]: + - generic [ref=f85e162]: + - generic [ref=f85e163]: Customer + - paragraph [ref=f85e164]: Jane Smith + - paragraph [ref=f85e165]: jane@example.com + - link "View customer" [ref=f85e167] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/2 + - generic [ref=f85e168]: + - generic [ref=f85e169]: Shipping address + - generic [ref=f85e170]: Jane Doe 123 Main St Berlin 10115 DE + - generic [ref=f85e171]: + - generic [ref=f85e172]: Billing address + - generic [ref=f85e173]: Jane Doe 123 Main St Berlin 10115 DE \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-43-08-849Z.yml b/.playwright-mcp/page-2026-07-26T09-43-08-849Z.yml new file mode 100644 index 00000000..250c570b --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-43-08-849Z.yml @@ -0,0 +1,170 @@ +- generic [active] [ref=f86e1]: + - link "Skip to main content" [ref=f86e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f86e4]: + - paragraph [ref=f86e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f86e6] + - banner [ref=f86e9]: + - generic [ref=f86e10]: + - button "Open navigation menu" [ref=f86e11] + - link "Acme Fashion" [ref=f86e14] [cursor=pointer]: + - /url: http://acme-fashion.test + - button "Open cart" [ref=f86e17] + - main [ref=f86e20]: + - generic [ref=f86e21]: + - generic [ref=f86e25]: + - heading "Welcome to Acme Fashion" [level=1] [ref=f86e26] + - paragraph [ref=f86e27]: Discover our curated collection of modern essentials + - link "Shop New Arrivals" [ref=f86e28] [cursor=pointer]: + - /url: /collections/new-arrivals + - region [ref=f86e29]: + - heading "Featured collections" [level=2] [ref=f86e30] + - generic [ref=f86e31]: + - link "New Arrivals" [ref=f86e32] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - generic [ref=f86e34]: + - generic [ref=f86e35]: New Arrivals + - generic [ref=f86e36]: Shop now + - link "T-Shirts" [ref=f86e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - generic [ref=f86e39]: + - generic [ref=f86e40]: T-Shirts + - generic [ref=f86e41]: Shop now + - link "Sale" [ref=f86e42] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/sale + - generic [ref=f86e44]: + - generic [ref=f86e45]: Sale + - generic [ref=f86e46]: Shop now + - region [ref=f86e47]: + - heading "Featured products" [level=2] [ref=f86e48] + - generic [ref=f86e49]: + - generic [ref=f86e50]: + - link [ref=f86e52] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f86e56] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f86e57] + - generic [ref=f86e58]: 24.99 EUR + - link "Choose options" [ref=f86e62] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f86e63]: + - generic [ref=f86e64]: + - link [ref=f86e65] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f86e70]: + - generic [ref=f86e71]: "On sale:" + - text: Sale + - 'link "Premium Slim Fit Jeans 79.99 EUR 99.99 EUR On sale: Sale" [ref=f86e72] [cursor=pointer]': + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - heading "Premium Slim Fit Jeans" [level=3] [ref=f86e73] + - generic [ref=f86e75]: + - generic [ref=f86e76]: 79.99 EUR + - generic [ref=f86e77]: 99.99 EUR + - generic [ref=f86e78]: + - generic [ref=f86e79]: "On sale:" + - text: Sale + - link "Choose options" [ref=f86e81] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f86e82]: + - link [ref=f86e84] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - link "Organic Hoodie 59.99 EUR" [ref=f86e88] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - heading "Organic Hoodie" [level=3] [ref=f86e89] + - generic [ref=f86e90]: 59.99 EUR + - link "Choose options" [ref=f86e94] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - generic [ref=f86e95]: + - link [ref=f86e97] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - link "Leather Belt 34.99 EUR" [ref=f86e101] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - heading "Leather Belt" [level=3] [ref=f86e102] + - generic [ref=f86e103]: 34.99 EUR + - link "Choose options" [ref=f86e107] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - generic [ref=f86e108]: + - link [ref=f86e110] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - link "Running Sneakers 119.99 EUR" [ref=f86e114] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - heading "Running Sneakers" [level=3] [ref=f86e115] + - generic [ref=f86e116]: 119.99 EUR + - link "Choose options" [ref=f86e120] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - generic [ref=f86e121]: + - link [ref=f86e123] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f86e127] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f86e128] + - generic [ref=f86e129]: 29.99 EUR + - link "Choose options" [ref=f86e133] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - generic [ref=f86e134]: + - link [ref=f86e136] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - link "V-Neck Linen Tee 34.99 EUR" [ref=f86e140] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - heading "V-Neck Linen Tee" [level=3] [ref=f86e141] + - generic [ref=f86e142]: 34.99 EUR + - link "Choose options" [ref=f86e146] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - generic [ref=f86e147]: + - generic [ref=f86e148]: + - link [ref=f86e149] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - generic [ref=f86e154]: + - generic [ref=f86e155]: "On sale:" + - text: Sale + - 'link "Striped Polo Shirt 27.99 EUR 39.99 EUR On sale: Sale" [ref=f86e156] [cursor=pointer]': + - /url: http://acme-fashion.test/products/striped-polo-shirt + - heading "Striped Polo Shirt" [level=3] [ref=f86e157] + - generic [ref=f86e159]: + - generic [ref=f86e160]: 27.99 EUR + - generic [ref=f86e161]: 39.99 EUR + - generic [ref=f86e162]: + - generic [ref=f86e163]: "On sale:" + - text: Sale + - link "Choose options" [ref=f86e165] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - region [ref=f86e166]: + - generic [ref=f86e167]: + - heading "Stay in the loop" [level=2] [ref=f86e168] + - paragraph [ref=f86e169]: Subscribe for exclusive offers and updates. + - generic [ref=f86e171]: + - generic [ref=f86e172]: Email address + - textbox "Email address" [ref=f86e173]: + - /placeholder: Enter your email + - button "Subscribe" [ref=f86e174] + - contentinfo [ref=f86e175]: + - generic [ref=f86e176]: + - generic [ref=f86e177]: + - generic [ref=f86e178]: + - heading "Shop" [level=2] [ref=f86e179] + - list [ref=f86e180]: + - listitem [ref=f86e181]: + - link "About Us" [ref=f86e182] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f86e183]: + - link "FAQ" [ref=f86e184] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f86e185]: + - link "Shipping & Returns" [ref=f86e186] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f86e187]: + - link "Privacy Policy" [ref=f86e188] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f86e189]: + - link "Terms of Service" [ref=f86e190] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f86e191]: + - heading "Acme Fashion" [level=2] [ref=f86e192] + - paragraph [ref=f86e193]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f86e194]: + - paragraph [ref=f86e195]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f86e196]: + - generic [ref=f86e197]: VISA + - generic [ref=f86e198]: MASTERCARD + - generic [ref=f86e199]: AMEX + - generic [ref=f86e200]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-43-52-251Z.yml b/.playwright-mcp/page-2026-07-26T09-43-52-251Z.yml new file mode 100644 index 00000000..250c570b --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-43-52-251Z.yml @@ -0,0 +1,170 @@ +- generic [active] [ref=f86e1]: + - link "Skip to main content" [ref=f86e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f86e4]: + - paragraph [ref=f86e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f86e6] + - banner [ref=f86e9]: + - generic [ref=f86e10]: + - button "Open navigation menu" [ref=f86e11] + - link "Acme Fashion" [ref=f86e14] [cursor=pointer]: + - /url: http://acme-fashion.test + - button "Open cart" [ref=f86e17] + - main [ref=f86e20]: + - generic [ref=f86e21]: + - generic [ref=f86e25]: + - heading "Welcome to Acme Fashion" [level=1] [ref=f86e26] + - paragraph [ref=f86e27]: Discover our curated collection of modern essentials + - link "Shop New Arrivals" [ref=f86e28] [cursor=pointer]: + - /url: /collections/new-arrivals + - region [ref=f86e29]: + - heading "Featured collections" [level=2] [ref=f86e30] + - generic [ref=f86e31]: + - link "New Arrivals" [ref=f86e32] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - generic [ref=f86e34]: + - generic [ref=f86e35]: New Arrivals + - generic [ref=f86e36]: Shop now + - link "T-Shirts" [ref=f86e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - generic [ref=f86e39]: + - generic [ref=f86e40]: T-Shirts + - generic [ref=f86e41]: Shop now + - link "Sale" [ref=f86e42] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/sale + - generic [ref=f86e44]: + - generic [ref=f86e45]: Sale + - generic [ref=f86e46]: Shop now + - region [ref=f86e47]: + - heading "Featured products" [level=2] [ref=f86e48] + - generic [ref=f86e49]: + - generic [ref=f86e50]: + - link [ref=f86e52] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f86e56] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f86e57] + - generic [ref=f86e58]: 24.99 EUR + - link "Choose options" [ref=f86e62] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f86e63]: + - generic [ref=f86e64]: + - link [ref=f86e65] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f86e70]: + - generic [ref=f86e71]: "On sale:" + - text: Sale + - 'link "Premium Slim Fit Jeans 79.99 EUR 99.99 EUR On sale: Sale" [ref=f86e72] [cursor=pointer]': + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - heading "Premium Slim Fit Jeans" [level=3] [ref=f86e73] + - generic [ref=f86e75]: + - generic [ref=f86e76]: 79.99 EUR + - generic [ref=f86e77]: 99.99 EUR + - generic [ref=f86e78]: + - generic [ref=f86e79]: "On sale:" + - text: Sale + - link "Choose options" [ref=f86e81] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f86e82]: + - link [ref=f86e84] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - link "Organic Hoodie 59.99 EUR" [ref=f86e88] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - heading "Organic Hoodie" [level=3] [ref=f86e89] + - generic [ref=f86e90]: 59.99 EUR + - link "Choose options" [ref=f86e94] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - generic [ref=f86e95]: + - link [ref=f86e97] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - link "Leather Belt 34.99 EUR" [ref=f86e101] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - heading "Leather Belt" [level=3] [ref=f86e102] + - generic [ref=f86e103]: 34.99 EUR + - link "Choose options" [ref=f86e107] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - generic [ref=f86e108]: + - link [ref=f86e110] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - link "Running Sneakers 119.99 EUR" [ref=f86e114] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - heading "Running Sneakers" [level=3] [ref=f86e115] + - generic [ref=f86e116]: 119.99 EUR + - link "Choose options" [ref=f86e120] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - generic [ref=f86e121]: + - link [ref=f86e123] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f86e127] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f86e128] + - generic [ref=f86e129]: 29.99 EUR + - link "Choose options" [ref=f86e133] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - generic [ref=f86e134]: + - link [ref=f86e136] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - link "V-Neck Linen Tee 34.99 EUR" [ref=f86e140] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - heading "V-Neck Linen Tee" [level=3] [ref=f86e141] + - generic [ref=f86e142]: 34.99 EUR + - link "Choose options" [ref=f86e146] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - generic [ref=f86e147]: + - generic [ref=f86e148]: + - link [ref=f86e149] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - generic [ref=f86e154]: + - generic [ref=f86e155]: "On sale:" + - text: Sale + - 'link "Striped Polo Shirt 27.99 EUR 39.99 EUR On sale: Sale" [ref=f86e156] [cursor=pointer]': + - /url: http://acme-fashion.test/products/striped-polo-shirt + - heading "Striped Polo Shirt" [level=3] [ref=f86e157] + - generic [ref=f86e159]: + - generic [ref=f86e160]: 27.99 EUR + - generic [ref=f86e161]: 39.99 EUR + - generic [ref=f86e162]: + - generic [ref=f86e163]: "On sale:" + - text: Sale + - link "Choose options" [ref=f86e165] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - region [ref=f86e166]: + - generic [ref=f86e167]: + - heading "Stay in the loop" [level=2] [ref=f86e168] + - paragraph [ref=f86e169]: Subscribe for exclusive offers and updates. + - generic [ref=f86e171]: + - generic [ref=f86e172]: Email address + - textbox "Email address" [ref=f86e173]: + - /placeholder: Enter your email + - button "Subscribe" [ref=f86e174] + - contentinfo [ref=f86e175]: + - generic [ref=f86e176]: + - generic [ref=f86e177]: + - generic [ref=f86e178]: + - heading "Shop" [level=2] [ref=f86e179] + - list [ref=f86e180]: + - listitem [ref=f86e181]: + - link "About Us" [ref=f86e182] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f86e183]: + - link "FAQ" [ref=f86e184] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f86e185]: + - link "Shipping & Returns" [ref=f86e186] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f86e187]: + - link "Privacy Policy" [ref=f86e188] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f86e189]: + - link "Terms of Service" [ref=f86e190] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f86e191]: + - heading "Acme Fashion" [level=2] [ref=f86e192] + - paragraph [ref=f86e193]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f86e194]: + - paragraph [ref=f86e195]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f86e196]: + - generic [ref=f86e197]: VISA + - generic [ref=f86e198]: MASTERCARD + - generic [ref=f86e199]: AMEX + - generic [ref=f86e200]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-44-15-850Z.yml b/.playwright-mcp/page-2026-07-26T09-44-15-850Z.yml new file mode 100644 index 00000000..66aed4aa --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-44-15-850Z.yml @@ -0,0 +1,276 @@ +- generic [active] [ref=f87e1]: + - link "Skip to main content" [ref=f87e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f87e3]: + - complementary "Admin navigation" [ref=f87e4]: + - generic [ref=f87e5]: + - link "Acme Fashion" [ref=f87e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f87e12]: + - navigation [ref=f87e13]: + - link "Dashboard" [ref=f87e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f87e19]: Products + - navigation [ref=f87e20]: + - link "Products" [ref=f87e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f87e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f87e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f87e36]: Orders + - navigation [ref=f87e37]: + - link "Orders" [ref=f87e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f87e43]: Customers + - navigation [ref=f87e44]: + - link "Customers" [ref=f87e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - generic [ref=f87e50]: + - banner [ref=f87e51]: + - button "Open navigation menu" [ref=f87e52] + - button "Acme Fashion" [ref=f87e56] + - button "Notifications" [ref=f87e61] + - button "SU Support User" [ref=f87e65]: + - generic [ref=f87e66]: SU + - generic [ref=f87e69]: Support User + - main [ref=f87e73]: + - generic [ref=f87e74]: + - link "Home" [ref=f87e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f87e79]: Products + - generic [ref=f87e81]: + - generic [ref=f87e82]: Products + - generic [ref=f87e84]: + - textbox "Search products" [ref=f87e86]: + - /placeholder: Search products... + - tablist "Status filter" [ref=f87e88]: + - tab "All" [selected] [ref=f87e89] + - tab "Draft" [ref=f87e90] + - tab "Active" [ref=f87e91] + - tab "Archived" [ref=f87e92] + - combobox "Product type filter" [ref=f87e93]: + - option "All types" [selected] + - option "Accessories" + - option "Gift Cards" + - option "Hoodies" + - option "Jackets" + - option "Pants" + - option "Shoes" + - option "T-Shirts" + - table [ref=f87e95]: + - rowgroup [ref=f87e96]: + - row [ref=f87e97]: + - columnheader [ref=f87e98]: + - checkbox "Select all products" [ref=f87e99] + - columnheader "Image" [ref=f87e101] + - columnheader [ref=f87e103]: + - button "Title" [ref=f87e104] + - columnheader "Status" [ref=f87e105] + - columnheader [ref=f87e106]: + - button "Inventory" [ref=f87e107] + - columnheader "Variants" [ref=f87e108] + - columnheader "Type" [ref=f87e109] + - columnheader "Vendor" [ref=f87e110] + - columnheader [ref=f87e111]: + - button "Updated" [ref=f87e112] + - rowgroup [ref=f87e115]: + - row [ref=f87e116]: + - cell [ref=f87e117]: + - checkbox "Select Classic Cotton T-Shirt" [ref=f87e118] + - cell [ref=f87e120] + - cell [ref=f87e124]: + - link "Classic Cotton T-Shirt" [ref=f87e125] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/1/edit + - cell "Active" [ref=f87e126] + - cell "179" [ref=f87e128] + - cell "12" [ref=f87e129] + - cell "T-Shirts" [ref=f87e130] + - cell "Acme Basics" [ref=f87e131] + - cell "52 minutes ago" [ref=f87e132] + - row [ref=f87e133]: + - cell [ref=f87e134]: + - checkbox "Select Leather Belt" [ref=f87e135] + - cell [ref=f87e137] + - cell [ref=f87e141]: + - link "Leather Belt" [ref=f87e142] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/4/edit + - cell "Active" [ref=f87e143] + - cell "100" [ref=f87e145] + - cell "4" [ref=f87e146] + - cell "Accessories" [ref=f87e147] + - cell "Acme Accessories" [ref=f87e148] + - cell "1 hour ago" [ref=f87e149] + - row [ref=f87e150]: + - cell [ref=f87e151]: + - checkbox "Select Wool Scarf" [ref=f87e152] + - cell [ref=f87e154] + - cell [ref=f87e158]: + - link "Wool Scarf" [ref=f87e159] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/12/edit + - cell "Active" [ref=f87e160] + - cell "90" [ref=f87e162] + - cell "3" [ref=f87e163] + - cell "Accessories" [ref=f87e164] + - cell "Acme Accessories" [ref=f87e165] + - cell "1 hour ago" [ref=f87e166] + - row [ref=f87e167]: + - cell [ref=f87e168]: + - checkbox "Select Canvas Tote Bag" [ref=f87e169] + - cell [ref=f87e171] + - cell [ref=f87e175]: + - link "Canvas Tote Bag" [ref=f87e176] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/13/edit + - cell "Active" [ref=f87e177] + - cell "80" [ref=f87e179] + - cell "2" [ref=f87e180] + - cell "Accessories" [ref=f87e181] + - cell "Acme Accessories" [ref=f87e182] + - cell "1 hour ago" [ref=f87e183] + - row [ref=f87e184]: + - cell [ref=f87e185]: + - checkbox "Select Bucket Hat" [ref=f87e186] + - cell [ref=f87e188] + - cell [ref=f87e192]: + - link "Bucket Hat" [ref=f87e193] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/14/edit + - cell "Active" [ref=f87e194] + - cell "132" [ref=f87e196] + - cell "6" [ref=f87e197] + - cell "Accessories" [ref=f87e198] + - cell "Acme Accessories" [ref=f87e199] + - cell "1 hour ago" [ref=f87e200] + - row [ref=f87e201]: + - cell [ref=f87e202]: + - checkbox "Select Gift Card" [ref=f87e203] + - cell [ref=f87e205] + - cell [ref=f87e209]: + - link "Gift Card" [ref=f87e210] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/19/edit + - cell "Active" [ref=f87e211] + - cell "29997" [ref=f87e213] + - cell "3" [ref=f87e214] + - cell "Gift Cards" [ref=f87e215] + - cell "Acme Fashion" [ref=f87e216] + - cell "1 hour ago" [ref=f87e217] + - row [ref=f87e218]: + - cell [ref=f87e219]: + - checkbox "Select Organic Hoodie" [ref=f87e220] + - cell [ref=f87e222] + - cell [ref=f87e226]: + - link "Organic Hoodie" [ref=f87e227] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/3/edit + - cell "Active" [ref=f87e228] + - cell "79" [ref=f87e230] + - cell "4" [ref=f87e231] + - cell "Hoodies" [ref=f87e232] + - cell "Acme Basics" [ref=f87e233] + - cell "1 hour ago" [ref=f87e234] + - row [ref=f87e235]: + - cell [ref=f87e236]: + - checkbox "Select Unreleased Winter Jacket" [ref=f87e237] + - cell [ref=f87e239] + - cell [ref=f87e243]: + - link "Unreleased Winter Jacket" [ref=f87e244] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/15/edit + - cell "Draft" [ref=f87e245] + - cell "0" [ref=f87e247] + - cell "4" [ref=f87e248] + - cell "Jackets" [ref=f87e249] + - cell "Acme Outerwear" [ref=f87e250] + - cell "1 hour ago" [ref=f87e251] + - row [ref=f87e252]: + - cell [ref=f87e253]: + - checkbox "Select Discontinued Raincoat" [ref=f87e254] + - cell [ref=f87e256] + - cell [ref=f87e260]: + - link "Discontinued Raincoat" [ref=f87e261] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/16/edit + - cell "Archived" [ref=f87e262] + - cell "6" [ref=f87e264] + - cell "2" [ref=f87e265] + - cell "Jackets" [ref=f87e266] + - cell "Acme Outerwear" [ref=f87e267] + - cell "1 hour ago" [ref=f87e268] + - row [ref=f87e269]: + - cell [ref=f87e270]: + - checkbox "Select Backorder Denim Jacket" [ref=f87e271] + - cell [ref=f87e273] + - cell [ref=f87e277]: + - link "Backorder Denim Jacket" [ref=f87e278] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/18/edit + - cell "Active" [ref=f87e279] + - cell "0" [ref=f87e281] + - cell "4" [ref=f87e282] + - cell "Jackets" [ref=f87e283] + - cell "Acme Denim" [ref=f87e284] + - cell "1 hour ago" [ref=f87e285] + - row [ref=f87e286]: + - cell [ref=f87e287]: + - checkbox "Select Cashmere Overcoat" [ref=f87e288] + - cell [ref=f87e290] + - cell [ref=f87e294]: + - link "Cashmere Overcoat" [ref=f87e295] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/20/edit + - cell "Active" [ref=f87e296] + - cell "18" [ref=f87e298] + - cell "6" [ref=f87e299] + - cell "Jackets" [ref=f87e300] + - cell "Acme Premium" [ref=f87e301] + - cell "1 hour ago" [ref=f87e302] + - row [ref=f87e303]: + - cell [ref=f87e304]: + - checkbox "Select Premium Slim Fit Jeans" [ref=f87e305] + - cell [ref=f87e307] + - cell [ref=f87e311]: + - link "Premium Slim Fit Jeans" [ref=f87e312] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/2/edit + - cell "Active" [ref=f87e313] + - cell "79" [ref=f87e315] + - cell "10" [ref=f87e316] + - cell "Pants" [ref=f87e317] + - cell "Acme Denim" [ref=f87e318] + - cell "1 hour ago" [ref=f87e319] + - row [ref=f87e320]: + - cell [ref=f87e321]: + - checkbox "Select Cargo Pants" [ref=f87e322] + - cell [ref=f87e324] + - cell [ref=f87e328]: + - link "Cargo Pants" [ref=f87e329] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/9/edit + - cell "Active" [ref=f87e330] + - cell "168" [ref=f87e332] + - cell "12" [ref=f87e333] + - cell "Pants" [ref=f87e334] + - cell "Acme Workwear" [ref=f87e335] + - cell "1 hour ago" [ref=f87e336] + - row [ref=f87e337]: + - cell [ref=f87e338]: + - checkbox "Select Chino Shorts" [ref=f87e339] + - cell [ref=f87e341] + - cell [ref=f87e345]: + - link "Chino Shorts" [ref=f87e346] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/10/edit + - cell "Active" [ref=f87e347] + - cell "128" [ref=f87e349] + - cell "8" [ref=f87e350] + - cell "Pants" [ref=f87e351] + - cell "Acme Basics" [ref=f87e352] + - cell "1 hour ago" [ref=f87e353] + - row [ref=f87e354]: + - cell [ref=f87e355]: + - checkbox "Select Wide Leg Trousers" [ref=f87e356] + - cell [ref=f87e358] + - cell [ref=f87e362]: + - link "Wide Leg Trousers" [ref=f87e363] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/11/edit + - cell "Active" [ref=f87e364] + - cell "21" [ref=f87e366] + - cell "3" [ref=f87e367] + - cell "Pants" [ref=f87e368] + - cell "Acme Denim" [ref=f87e369] + - cell "1 hour ago" [ref=f87e370] + - navigation "Pagination Navigation" [ref=f87e372]: + - generic [ref=f87e373]: + - generic [ref=f87e374]: « Previous + - button "Next »" [ref=f87e377] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-47-48-384Z.yml b/.playwright-mcp/page-2026-07-26T09-47-48-384Z.yml new file mode 100644 index 00000000..f6849e10 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-47-48-384Z.yml @@ -0,0 +1,276 @@ +- generic [active] [ref=f88e1]: + - link "Skip to main content" [ref=f88e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f88e3]: + - complementary "Admin navigation" [ref=f88e4]: + - generic [ref=f88e5]: + - link "Acme Fashion" [ref=f88e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f88e12]: + - navigation [ref=f88e13]: + - link "Dashboard" [ref=f88e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f88e19]: Products + - navigation [ref=f88e20]: + - link "Products" [ref=f88e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f88e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f88e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f88e36]: Orders + - navigation [ref=f88e37]: + - link "Orders" [ref=f88e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f88e43]: Customers + - navigation [ref=f88e44]: + - link "Customers" [ref=f88e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - generic [ref=f88e50]: + - banner [ref=f88e51]: + - button "Open navigation menu" [ref=f88e52] + - button "Acme Fashion" [ref=f88e56] + - button "Notifications" [ref=f88e61] + - button "SU Support User" [ref=f88e65]: + - generic [ref=f88e66]: SU + - generic [ref=f88e69]: Support User + - main [ref=f88e73]: + - generic [ref=f88e74]: + - link "Home" [ref=f88e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f88e79]: Products + - generic [ref=f88e81]: + - generic [ref=f88e82]: Products + - generic [ref=f88e84]: + - textbox "Search products" [ref=f88e86]: + - /placeholder: Search products... + - tablist "Status filter" [ref=f88e88]: + - tab "All" [selected] [ref=f88e89] + - tab "Draft" [ref=f88e90] + - tab "Active" [ref=f88e91] + - tab "Archived" [ref=f88e92] + - combobox "Product type filter" [ref=f88e93]: + - option "All types" [selected] + - option "Accessories" + - option "Gift Cards" + - option "Hoodies" + - option "Jackets" + - option "Pants" + - option "Shoes" + - option "T-Shirts" + - table [ref=f88e95]: + - rowgroup [ref=f88e96]: + - row [ref=f88e97]: + - columnheader [ref=f88e98]: + - checkbox "Select all products" [ref=f88e99] + - columnheader "Image" [ref=f88e101] + - columnheader [ref=f88e103]: + - button "Title" [ref=f88e104] + - columnheader "Status" [ref=f88e105] + - columnheader [ref=f88e106]: + - button "Inventory" [ref=f88e107] + - columnheader "Variants" [ref=f88e108] + - columnheader "Type" [ref=f88e109] + - columnheader "Vendor" [ref=f88e110] + - columnheader [ref=f88e111]: + - button "Updated" [ref=f88e112] + - rowgroup [ref=f88e115]: + - row [ref=f88e116]: + - cell [ref=f88e117]: + - checkbox "Select Classic Cotton T-Shirt" [ref=f88e118] + - cell [ref=f88e120] + - cell [ref=f88e124]: + - link "Classic Cotton T-Shirt" [ref=f88e125] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/1/edit + - cell "Active" [ref=f88e126] + - cell "179" [ref=f88e128] + - cell "12" [ref=f88e129] + - cell "T-Shirts" [ref=f88e130] + - cell "Acme Basics" [ref=f88e131] + - cell "56 minutes ago" [ref=f88e132] + - row [ref=f88e133]: + - cell [ref=f88e134]: + - checkbox "Select Leather Belt" [ref=f88e135] + - cell [ref=f88e137] + - cell [ref=f88e141]: + - link "Leather Belt" [ref=f88e142] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/4/edit + - cell "Active" [ref=f88e143] + - cell "100" [ref=f88e145] + - cell "4" [ref=f88e146] + - cell "Accessories" [ref=f88e147] + - cell "Acme Accessories" [ref=f88e148] + - cell "1 hour ago" [ref=f88e149] + - row [ref=f88e150]: + - cell [ref=f88e151]: + - checkbox "Select Wool Scarf" [ref=f88e152] + - cell [ref=f88e154] + - cell [ref=f88e158]: + - link "Wool Scarf" [ref=f88e159] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/12/edit + - cell "Active" [ref=f88e160] + - cell "90" [ref=f88e162] + - cell "3" [ref=f88e163] + - cell "Accessories" [ref=f88e164] + - cell "Acme Accessories" [ref=f88e165] + - cell "1 hour ago" [ref=f88e166] + - row [ref=f88e167]: + - cell [ref=f88e168]: + - checkbox "Select Canvas Tote Bag" [ref=f88e169] + - cell [ref=f88e171] + - cell [ref=f88e175]: + - link "Canvas Tote Bag" [ref=f88e176] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/13/edit + - cell "Active" [ref=f88e177] + - cell "80" [ref=f88e179] + - cell "2" [ref=f88e180] + - cell "Accessories" [ref=f88e181] + - cell "Acme Accessories" [ref=f88e182] + - cell "1 hour ago" [ref=f88e183] + - row [ref=f88e184]: + - cell [ref=f88e185]: + - checkbox "Select Bucket Hat" [ref=f88e186] + - cell [ref=f88e188] + - cell [ref=f88e192]: + - link "Bucket Hat" [ref=f88e193] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/14/edit + - cell "Active" [ref=f88e194] + - cell "132" [ref=f88e196] + - cell "6" [ref=f88e197] + - cell "Accessories" [ref=f88e198] + - cell "Acme Accessories" [ref=f88e199] + - cell "1 hour ago" [ref=f88e200] + - row [ref=f88e201]: + - cell [ref=f88e202]: + - checkbox "Select Gift Card" [ref=f88e203] + - cell [ref=f88e205] + - cell [ref=f88e209]: + - link "Gift Card" [ref=f88e210] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/19/edit + - cell "Active" [ref=f88e211] + - cell "29997" [ref=f88e213] + - cell "3" [ref=f88e214] + - cell "Gift Cards" [ref=f88e215] + - cell "Acme Fashion" [ref=f88e216] + - cell "1 hour ago" [ref=f88e217] + - row [ref=f88e218]: + - cell [ref=f88e219]: + - checkbox "Select Organic Hoodie" [ref=f88e220] + - cell [ref=f88e222] + - cell [ref=f88e226]: + - link "Organic Hoodie" [ref=f88e227] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/3/edit + - cell "Active" [ref=f88e228] + - cell "79" [ref=f88e230] + - cell "4" [ref=f88e231] + - cell "Hoodies" [ref=f88e232] + - cell "Acme Basics" [ref=f88e233] + - cell "1 hour ago" [ref=f88e234] + - row [ref=f88e235]: + - cell [ref=f88e236]: + - checkbox "Select Unreleased Winter Jacket" [ref=f88e237] + - cell [ref=f88e239] + - cell [ref=f88e243]: + - link "Unreleased Winter Jacket" [ref=f88e244] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/15/edit + - cell "Draft" [ref=f88e245] + - cell "0" [ref=f88e247] + - cell "4" [ref=f88e248] + - cell "Jackets" [ref=f88e249] + - cell "Acme Outerwear" [ref=f88e250] + - cell "1 hour ago" [ref=f88e251] + - row [ref=f88e252]: + - cell [ref=f88e253]: + - checkbox "Select Discontinued Raincoat" [ref=f88e254] + - cell [ref=f88e256] + - cell [ref=f88e260]: + - link "Discontinued Raincoat" [ref=f88e261] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/16/edit + - cell "Archived" [ref=f88e262] + - cell "6" [ref=f88e264] + - cell "2" [ref=f88e265] + - cell "Jackets" [ref=f88e266] + - cell "Acme Outerwear" [ref=f88e267] + - cell "1 hour ago" [ref=f88e268] + - row [ref=f88e269]: + - cell [ref=f88e270]: + - checkbox "Select Backorder Denim Jacket" [ref=f88e271] + - cell [ref=f88e273] + - cell [ref=f88e277]: + - link "Backorder Denim Jacket" [ref=f88e278] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/18/edit + - cell "Active" [ref=f88e279] + - cell "0" [ref=f88e281] + - cell "4" [ref=f88e282] + - cell "Jackets" [ref=f88e283] + - cell "Acme Denim" [ref=f88e284] + - cell "1 hour ago" [ref=f88e285] + - row [ref=f88e286]: + - cell [ref=f88e287]: + - checkbox "Select Cashmere Overcoat" [ref=f88e288] + - cell [ref=f88e290] + - cell [ref=f88e294]: + - link "Cashmere Overcoat" [ref=f88e295] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/20/edit + - cell "Active" [ref=f88e296] + - cell "18" [ref=f88e298] + - cell "6" [ref=f88e299] + - cell "Jackets" [ref=f88e300] + - cell "Acme Premium" [ref=f88e301] + - cell "1 hour ago" [ref=f88e302] + - row [ref=f88e303]: + - cell [ref=f88e304]: + - checkbox "Select Premium Slim Fit Jeans" [ref=f88e305] + - cell [ref=f88e307] + - cell [ref=f88e311]: + - link "Premium Slim Fit Jeans" [ref=f88e312] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/2/edit + - cell "Active" [ref=f88e313] + - cell "79" [ref=f88e315] + - cell "10" [ref=f88e316] + - cell "Pants" [ref=f88e317] + - cell "Acme Denim" [ref=f88e318] + - cell "1 hour ago" [ref=f88e319] + - row [ref=f88e320]: + - cell [ref=f88e321]: + - checkbox "Select Cargo Pants" [ref=f88e322] + - cell [ref=f88e324] + - cell [ref=f88e328]: + - link "Cargo Pants" [ref=f88e329] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/9/edit + - cell "Active" [ref=f88e330] + - cell "168" [ref=f88e332] + - cell "12" [ref=f88e333] + - cell "Pants" [ref=f88e334] + - cell "Acme Workwear" [ref=f88e335] + - cell "1 hour ago" [ref=f88e336] + - row [ref=f88e337]: + - cell [ref=f88e338]: + - checkbox "Select Chino Shorts" [ref=f88e339] + - cell [ref=f88e341] + - cell [ref=f88e345]: + - link "Chino Shorts" [ref=f88e346] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/10/edit + - cell "Active" [ref=f88e347] + - cell "128" [ref=f88e349] + - cell "8" [ref=f88e350] + - cell "Pants" [ref=f88e351] + - cell "Acme Basics" [ref=f88e352] + - cell "1 hour ago" [ref=f88e353] + - row [ref=f88e354]: + - cell [ref=f88e355]: + - checkbox "Select Wide Leg Trousers" [ref=f88e356] + - cell [ref=f88e358] + - cell [ref=f88e362]: + - link "Wide Leg Trousers" [ref=f88e363] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/11/edit + - cell "Active" [ref=f88e364] + - cell "21" [ref=f88e366] + - cell "3" [ref=f88e367] + - cell "Pants" [ref=f88e368] + - cell "Acme Denim" [ref=f88e369] + - cell "1 hour ago" [ref=f88e370] + - navigation "Pagination Navigation" [ref=f88e372]: + - generic [ref=f88e373]: + - generic [ref=f88e374]: « Previous + - button "Next »" [ref=f88e377] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-48-26-039Z.yml b/.playwright-mcp/page-2026-07-26T09-48-26-039Z.yml new file mode 100644 index 00000000..abc75f63 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-48-26-039Z.yml @@ -0,0 +1,274 @@ +- generic [active] [ref=f89e1]: + - link "Skip to main content" [ref=f89e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f89e3]: + - complementary "Admin navigation" [ref=f89e4]: + - generic [ref=f89e5]: + - link "Acme Fashion" [ref=f89e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f89e12]: + - navigation [ref=f89e13]: + - link "Dashboard" [ref=f89e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f89e19]: Products + - navigation [ref=f89e20]: + - link "Products" [ref=f89e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f89e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f89e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f89e36]: Orders + - navigation [ref=f89e37]: + - link "Orders" [ref=f89e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f89e43]: Customers + - navigation [ref=f89e44]: + - link "Customers" [ref=f89e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - generic [ref=f89e50]: + - banner [ref=f89e51]: + - button "Open navigation menu" [ref=f89e52] + - button "Acme Fashion" [ref=f89e56] + - button "Notifications" [ref=f89e61] + - button "SU" [ref=f89e65] + - main [ref=f89e72]: + - generic [ref=f89e73]: + - link "Home" [ref=f89e75] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f89e78]: Products + - generic [ref=f89e80]: + - generic [ref=f89e81]: Products + - generic [ref=f89e83]: + - textbox "Search products" [ref=f89e85]: + - /placeholder: Search products... + - tablist "Status filter" [ref=f89e87]: + - tab "All" [selected] [ref=f89e88] + - tab "Draft" [ref=f89e89] + - tab "Active" [ref=f89e90] + - tab "Archived" [ref=f89e91] + - combobox "Product type filter" [ref=f89e92]: + - option "All types" [selected] + - option "Accessories" + - option "Gift Cards" + - option "Hoodies" + - option "Jackets" + - option "Pants" + - option "Shoes" + - option "T-Shirts" + - table [ref=f89e94]: + - rowgroup [ref=f89e95]: + - row [ref=f89e96]: + - columnheader [ref=f89e97]: + - checkbox "Select all products" [ref=f89e98] + - columnheader "Image" [ref=f89e100] + - columnheader [ref=f89e102]: + - button "Title" [ref=f89e103] + - columnheader "Status" [ref=f89e104] + - columnheader [ref=f89e105]: + - button "Inventory" [ref=f89e106] + - columnheader "Variants" [ref=f89e107] + - columnheader "Type" [ref=f89e108] + - columnheader "Vendor" [ref=f89e109] + - columnheader [ref=f89e110]: + - button "Updated" [ref=f89e111] + - rowgroup [ref=f89e114]: + - row [ref=f89e115]: + - cell [ref=f89e116]: + - checkbox "Select Classic Cotton T-Shirt" [ref=f89e117] + - cell [ref=f89e119] + - cell [ref=f89e123]: + - link "Classic Cotton T-Shirt" [ref=f89e124] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/1/edit + - cell "Active" [ref=f89e125] + - cell "179" [ref=f89e127] + - cell "12" [ref=f89e128] + - cell "T-Shirts" [ref=f89e129] + - cell "Acme Basics" [ref=f89e130] + - cell "56 minutes ago" [ref=f89e131] + - row [ref=f89e132]: + - cell [ref=f89e133]: + - checkbox "Select Leather Belt" [ref=f89e134] + - cell [ref=f89e136] + - cell [ref=f89e140]: + - link "Leather Belt" [ref=f89e141] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/4/edit + - cell "Active" [ref=f89e142] + - cell "100" [ref=f89e144] + - cell "4" [ref=f89e145] + - cell "Accessories" [ref=f89e146] + - cell "Acme Accessories" [ref=f89e147] + - cell "1 hour ago" [ref=f89e148] + - row [ref=f89e149]: + - cell [ref=f89e150]: + - checkbox "Select Wool Scarf" [ref=f89e151] + - cell [ref=f89e153] + - cell [ref=f89e157]: + - link "Wool Scarf" [ref=f89e158] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/12/edit + - cell "Active" [ref=f89e159] + - cell "90" [ref=f89e161] + - cell "3" [ref=f89e162] + - cell "Accessories" [ref=f89e163] + - cell "Acme Accessories" [ref=f89e164] + - cell "1 hour ago" [ref=f89e165] + - row [ref=f89e166]: + - cell [ref=f89e167]: + - checkbox "Select Canvas Tote Bag" [ref=f89e168] + - cell [ref=f89e170] + - cell [ref=f89e174]: + - link "Canvas Tote Bag" [ref=f89e175] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/13/edit + - cell "Active" [ref=f89e176] + - cell "80" [ref=f89e178] + - cell "2" [ref=f89e179] + - cell "Accessories" [ref=f89e180] + - cell "Acme Accessories" [ref=f89e181] + - cell "1 hour ago" [ref=f89e182] + - row [ref=f89e183]: + - cell [ref=f89e184]: + - checkbox "Select Bucket Hat" [ref=f89e185] + - cell [ref=f89e187] + - cell [ref=f89e191]: + - link "Bucket Hat" [ref=f89e192] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/14/edit + - cell "Active" [ref=f89e193] + - cell "132" [ref=f89e195] + - cell "6" [ref=f89e196] + - cell "Accessories" [ref=f89e197] + - cell "Acme Accessories" [ref=f89e198] + - cell "1 hour ago" [ref=f89e199] + - row [ref=f89e200]: + - cell [ref=f89e201]: + - checkbox "Select Gift Card" [ref=f89e202] + - cell [ref=f89e204] + - cell [ref=f89e208]: + - link "Gift Card" [ref=f89e209] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/19/edit + - cell "Active" [ref=f89e210] + - cell "29997" [ref=f89e212] + - cell "3" [ref=f89e213] + - cell "Gift Cards" [ref=f89e214] + - cell "Acme Fashion" [ref=f89e215] + - cell "1 hour ago" [ref=f89e216] + - row [ref=f89e217]: + - cell [ref=f89e218]: + - checkbox "Select Organic Hoodie" [ref=f89e219] + - cell [ref=f89e221] + - cell [ref=f89e225]: + - link "Organic Hoodie" [ref=f89e226] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/3/edit + - cell "Active" [ref=f89e227] + - cell "79" [ref=f89e229] + - cell "4" [ref=f89e230] + - cell "Hoodies" [ref=f89e231] + - cell "Acme Basics" [ref=f89e232] + - cell "1 hour ago" [ref=f89e233] + - row [ref=f89e234]: + - cell [ref=f89e235]: + - checkbox "Select Unreleased Winter Jacket" [ref=f89e236] + - cell [ref=f89e238] + - cell [ref=f89e242]: + - link "Unreleased Winter Jacket" [ref=f89e243] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/15/edit + - cell "Draft" [ref=f89e244] + - cell "0" [ref=f89e246] + - cell "4" [ref=f89e247] + - cell "Jackets" [ref=f89e248] + - cell "Acme Outerwear" [ref=f89e249] + - cell "1 hour ago" [ref=f89e250] + - row [ref=f89e251]: + - cell [ref=f89e252]: + - checkbox "Select Discontinued Raincoat" [ref=f89e253] + - cell [ref=f89e255] + - cell [ref=f89e259]: + - link "Discontinued Raincoat" [ref=f89e260] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/16/edit + - cell "Archived" [ref=f89e261] + - cell "6" [ref=f89e263] + - cell "2" [ref=f89e264] + - cell "Jackets" [ref=f89e265] + - cell "Acme Outerwear" [ref=f89e266] + - cell "1 hour ago" [ref=f89e267] + - row [ref=f89e268]: + - cell [ref=f89e269]: + - checkbox "Select Backorder Denim Jacket" [ref=f89e270] + - cell [ref=f89e272] + - cell [ref=f89e276]: + - link "Backorder Denim Jacket" [ref=f89e277] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/18/edit + - cell "Active" [ref=f89e278] + - cell "0" [ref=f89e280] + - cell "4" [ref=f89e281] + - cell "Jackets" [ref=f89e282] + - cell "Acme Denim" [ref=f89e283] + - cell "1 hour ago" [ref=f89e284] + - row [ref=f89e285]: + - cell [ref=f89e286]: + - checkbox "Select Cashmere Overcoat" [ref=f89e287] + - cell [ref=f89e289] + - cell [ref=f89e293]: + - link "Cashmere Overcoat" [ref=f89e294] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/20/edit + - cell "Active" [ref=f89e295] + - cell "18" [ref=f89e297] + - cell "6" [ref=f89e298] + - cell "Jackets" [ref=f89e299] + - cell "Acme Premium" [ref=f89e300] + - cell "1 hour ago" [ref=f89e301] + - row [ref=f89e302]: + - cell [ref=f89e303]: + - checkbox "Select Premium Slim Fit Jeans" [ref=f89e304] + - cell [ref=f89e306] + - cell [ref=f89e310]: + - link "Premium Slim Fit Jeans" [ref=f89e311] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/2/edit + - cell "Active" [ref=f89e312] + - cell "79" [ref=f89e314] + - cell "10" [ref=f89e315] + - cell "Pants" [ref=f89e316] + - cell "Acme Denim" [ref=f89e317] + - cell "1 hour ago" [ref=f89e318] + - row [ref=f89e319]: + - cell [ref=f89e320]: + - checkbox "Select Cargo Pants" [ref=f89e321] + - cell [ref=f89e323] + - cell [ref=f89e327]: + - link "Cargo Pants" [ref=f89e328] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/9/edit + - cell "Active" [ref=f89e329] + - cell "168" [ref=f89e331] + - cell "12" [ref=f89e332] + - cell "Pants" [ref=f89e333] + - cell "Acme Workwear" [ref=f89e334] + - cell "1 hour ago" [ref=f89e335] + - row [ref=f89e336]: + - cell [ref=f89e337]: + - checkbox "Select Chino Shorts" [ref=f89e338] + - cell [ref=f89e340] + - cell [ref=f89e344]: + - link "Chino Shorts" [ref=f89e345] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/10/edit + - cell "Active" [ref=f89e346] + - cell "128" [ref=f89e348] + - cell "8" [ref=f89e349] + - cell "Pants" [ref=f89e350] + - cell "Acme Basics" [ref=f89e351] + - cell "1 hour ago" [ref=f89e352] + - row [ref=f89e353]: + - cell [ref=f89e354]: + - checkbox "Select Wide Leg Trousers" [ref=f89e355] + - cell [ref=f89e357] + - cell [ref=f89e361]: + - link "Wide Leg Trousers" [ref=f89e362] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/11/edit + - cell "Active" [ref=f89e363] + - cell "21" [ref=f89e365] + - cell "3" [ref=f89e366] + - cell "Pants" [ref=f89e367] + - cell "Acme Denim" [ref=f89e368] + - cell "1 hour ago" [ref=f89e369] + - navigation "Pagination Navigation" [ref=f89e371]: + - generic [ref=f89e372]: + - generic [ref=f89e373]: « Previous + - button "Next »" [ref=f89e376] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-48-50-297Z.yml b/.playwright-mcp/page-2026-07-26T09-48-50-297Z.yml new file mode 100644 index 00000000..56b4c753 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-48-50-297Z.yml @@ -0,0 +1,125 @@ +- generic [active] [ref=f90e1]: + - link "Skip to main content" [ref=f90e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f90e3]: + - complementary "Admin navigation" [ref=f90e4]: + - generic [ref=f90e5]: + - link "Acme Fashion" [ref=f90e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f90e12]: + - navigation [ref=f90e13]: + - link "Dashboard" [ref=f90e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f90e19]: Products + - navigation [ref=f90e20]: + - link "Products" [ref=f90e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f90e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f90e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f90e36]: Orders + - navigation [ref=f90e37]: + - link "Orders" [ref=f90e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f90e43]: Customers + - navigation [ref=f90e44]: + - link "Customers" [ref=f90e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - generic [ref=f90e50]: + - banner [ref=f90e51]: + - button "Open navigation menu" [ref=f90e52] + - button "Acme Fashion" [ref=f90e56] + - button "Notifications" [ref=f90e61] + - button "SU" [ref=f90e65] + - main [ref=f90e72]: + - generic [ref=f90e73]: + - link "Home" [ref=f90e75] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - link "Orders" [ref=f90e79] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - generic [ref=f90e82]: "#1017" + - generic [ref=f90e84]: + - generic [ref=f90e85]: + - generic [ref=f90e86]: "#1017" + - generic [ref=f90e87]: Paid + - generic [ref=f90e88]: Fulfilled + - paragraph [ref=f90e89]: Jul 26, 2026 8:41 AM + - generic [ref=f90e90]: + - generic [ref=f90e91]: + - generic [ref=f90e92]: + - generic [ref=f90e93]: Timeline + - list [ref=f90e94]: + - listitem [ref=f90e95]: + - paragraph [ref=f90e97]: Order placed + - paragraph [ref=f90e98]: Jul 26, 2026 8:41 AM + - listitem [ref=f90e99]: + - paragraph [ref=f90e101]: Payment received + - paragraph [ref=f90e102]: Jul 26, 2026 8:41 AM + - listitem [ref=f90e103]: + - paragraph [ref=f90e105]: Fulfillment created + - paragraph [ref=f90e106]: Jul 26, 2026 8:46 AM + - listitem [ref=f90e107]: + - paragraph [ref=f90e109]: Fulfillment shipped + - paragraph [ref=f90e110]: Jul 26, 2026 8:47 AM + - generic [ref=f90e111]: + - generic [ref=f90e112]: Order lines + - table [ref=f90e114]: + - rowgroup [ref=f90e115]: + - row [ref=f90e116]: + - columnheader "Image" [ref=f90e117] + - columnheader "Product" [ref=f90e119] + - columnheader "Qty" [ref=f90e120] + - columnheader "Unit price" [ref=f90e121] + - columnheader "Total" [ref=f90e122] + - rowgroup [ref=f90e123]: + - row [ref=f90e124]: + - cell [ref=f90e125] + - 'cell "Premium Slim Fit Jeans - 28 / Blue SKU: ACME-PSFJ-28-BLUE" [ref=f90e129]': + - generic [ref=f90e130]: Premium Slim Fit Jeans - 28 / Blue + - generic [ref=f90e131]: "SKU: ACME-PSFJ-28-BLUE" + - cell "1" [ref=f90e132] + - cell "79.99 EUR" [ref=f90e133] + - cell "79.99 EUR" [ref=f90e134] + - generic [ref=f90e135]: + - generic [ref=f90e136]: + - generic [ref=f90e137]: Subtotal + - generic [ref=f90e138]: 79.99 EUR + - generic [ref=f90e139]: + - generic [ref=f90e140]: Shipping + - generic [ref=f90e141]: 4.99 EUR + - generic [ref=f90e142]: + - generic [ref=f90e143]: Tax + - generic [ref=f90e144]: 13.58 EUR + - generic [ref=f90e145]: + - generic [ref=f90e146]: Total + - generic [ref=f90e147]: 84.98 EUR + - generic [ref=f90e148]: + - generic [ref=f90e149]: Payment details + - generic [ref=f90e151]: + - generic [ref=f90e152]: + - paragraph [ref=f90e153]: Bank Transfer + - paragraph [ref=f90e154]: "84.98 EUR · Ref: mock_xFqqZ3TJnXOxLjhZ · Jul 26, 2026 8:41 AM" + - generic [ref=f90e155]: Captured + - generic [ref=f90e157]: + - generic [ref=f90e159]: + - generic [ref=f90e160]: "Fulfillment #8" + - generic [ref=f90e161]: Delivered + - paragraph [ref=f90e162]: "Tracking: DHL 1234567890" + - list [ref=f90e163]: + - listitem [ref=f90e164]: + - generic [ref=f90e165]: Premium Slim Fit Jeans - 28 / Blue + - generic [ref=f90e166]: × 1 + - generic [ref=f90e167]: + - generic [ref=f90e168]: + - generic [ref=f90e169]: Customer + - paragraph [ref=f90e170]: Jane Smith + - paragraph [ref=f90e171]: jane@example.com + - link "View customer" [ref=f90e173] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/2 + - generic [ref=f90e174]: + - generic [ref=f90e175]: Shipping address + - generic [ref=f90e176]: Jane Doe 123 Main St Berlin 10115 DE + - generic [ref=f90e177]: + - generic [ref=f90e178]: Billing address + - generic [ref=f90e179]: Jane Doe 123 Main St Berlin 10115 DE \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-51-01-633Z.yml b/.playwright-mcp/page-2026-07-26T09-51-01-633Z.yml new file mode 100644 index 00000000..55f94aef --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-51-01-633Z.yml @@ -0,0 +1,125 @@ +- generic [active] [ref=f91e1]: + - link "Skip to main content" [ref=f91e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f91e3]: + - complementary "Admin navigation" [ref=f91e4]: + - generic [ref=f91e5]: + - link "Acme Fashion" [ref=f91e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f91e12]: + - navigation [ref=f91e13]: + - link "Dashboard" [ref=f91e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f91e19]: Products + - navigation [ref=f91e20]: + - link "Products" [ref=f91e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f91e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f91e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f91e36]: Orders + - navigation [ref=f91e37]: + - link "Orders" [ref=f91e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f91e43]: Customers + - navigation [ref=f91e44]: + - link "Customers" [ref=f91e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - generic [ref=f91e50]: + - banner [ref=f91e51]: + - button "Open navigation menu" [ref=f91e52] + - button "Acme Fashion" [ref=f91e56] + - button "Notifications" [ref=f91e61] + - button "SU" [ref=f91e65] + - main [ref=f91e72]: + - generic [ref=f91e73]: + - link "Home" [ref=f91e75] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - link "Orders" [ref=f91e79] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - generic [ref=f91e82]: "#1017" + - generic [ref=f91e84]: + - generic [ref=f91e85]: + - generic [ref=f91e86]: "#1017" + - generic [ref=f91e87]: Paid + - generic [ref=f91e88]: Fulfilled + - paragraph [ref=f91e89]: Jul 26, 2026 8:41 AM + - generic [ref=f91e90]: + - generic [ref=f91e91]: + - generic [ref=f91e92]: + - generic [ref=f91e93]: Timeline + - list [ref=f91e94]: + - listitem [ref=f91e95]: + - paragraph [ref=f91e97]: Order placed + - paragraph [ref=f91e98]: Jul 26, 2026 8:41 AM + - listitem [ref=f91e99]: + - paragraph [ref=f91e101]: Payment received + - paragraph [ref=f91e102]: Jul 26, 2026 8:41 AM + - listitem [ref=f91e103]: + - paragraph [ref=f91e105]: Fulfillment created + - paragraph [ref=f91e106]: Jul 26, 2026 8:46 AM + - listitem [ref=f91e107]: + - paragraph [ref=f91e109]: Fulfillment shipped + - paragraph [ref=f91e110]: Jul 26, 2026 8:47 AM + - generic [ref=f91e111]: + - generic [ref=f91e112]: Order lines + - table [ref=f91e114]: + - rowgroup [ref=f91e115]: + - row [ref=f91e116]: + - columnheader "Image" [ref=f91e117] + - columnheader "Product" [ref=f91e119] + - columnheader "Qty" [ref=f91e120] + - columnheader "Unit price" [ref=f91e121] + - columnheader "Total" [ref=f91e122] + - rowgroup [ref=f91e123]: + - row [ref=f91e124]: + - cell [ref=f91e125] + - 'cell "Premium Slim Fit Jeans - 28 / Blue SKU: ACME-PSFJ-28-BLUE" [ref=f91e129]': + - generic [ref=f91e130]: Premium Slim Fit Jeans - 28 / Blue + - generic [ref=f91e131]: "SKU: ACME-PSFJ-28-BLUE" + - cell "1" [ref=f91e132] + - cell "79.99 EUR" [ref=f91e133] + - cell "79.99 EUR" [ref=f91e134] + - generic [ref=f91e135]: + - generic [ref=f91e136]: + - generic [ref=f91e137]: Subtotal + - generic [ref=f91e138]: 79.99 EUR + - generic [ref=f91e139]: + - generic [ref=f91e140]: Shipping + - generic [ref=f91e141]: 4.99 EUR + - generic [ref=f91e142]: + - generic [ref=f91e143]: Tax + - generic [ref=f91e144]: 13.58 EUR + - generic [ref=f91e145]: + - generic [ref=f91e146]: Total + - generic [ref=f91e147]: 84.98 EUR + - generic [ref=f91e148]: + - generic [ref=f91e149]: Payment details + - generic [ref=f91e151]: + - generic [ref=f91e152]: + - paragraph [ref=f91e153]: Bank Transfer + - paragraph [ref=f91e154]: "84.98 EUR · Ref: mock_xFqqZ3TJnXOxLjhZ · Jul 26, 2026 8:41 AM" + - generic [ref=f91e155]: Captured + - generic [ref=f91e157]: + - generic [ref=f91e159]: + - generic [ref=f91e160]: "Fulfillment #8" + - generic [ref=f91e161]: Delivered + - paragraph [ref=f91e162]: "Tracking: DHL 1234567890" + - list [ref=f91e163]: + - listitem [ref=f91e164]: + - generic [ref=f91e165]: Premium Slim Fit Jeans - 28 / Blue + - generic [ref=f91e166]: × 1 + - generic [ref=f91e167]: + - generic [ref=f91e168]: + - generic [ref=f91e169]: Customer + - paragraph [ref=f91e170]: Jane Smith + - paragraph [ref=f91e171]: jane@example.com + - link "View customer" [ref=f91e173] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/2 + - generic [ref=f91e174]: + - generic [ref=f91e175]: Shipping address + - generic [ref=f91e176]: Jane Doe 123 Main St Berlin 10115 DE + - generic [ref=f91e177]: + - generic [ref=f91e178]: Billing address + - generic [ref=f91e179]: Jane Doe 123 Main St Berlin 10115 DE \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-51-25-351Z.yml b/.playwright-mcp/page-2026-07-26T09-51-25-351Z.yml new file mode 100644 index 00000000..8a8eaa53 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-51-25-351Z.yml @@ -0,0 +1,170 @@ +- generic [active] [ref=f92e1]: + - link "Skip to main content" [ref=f92e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f92e4]: + - paragraph [ref=f92e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f92e6] + - banner [ref=f92e9]: + - generic [ref=f92e10]: + - button "Open navigation menu" [ref=f92e11] + - link "Acme Fashion" [ref=f92e14] [cursor=pointer]: + - /url: http://acme-fashion.test + - button "Open cart" [ref=f92e17] + - main [ref=f92e20]: + - generic [ref=f92e21]: + - generic [ref=f92e25]: + - heading "Welcome to Acme Fashion" [level=1] [ref=f92e26] + - paragraph [ref=f92e27]: Discover our curated collection of modern essentials + - link "Shop New Arrivals" [ref=f92e28] [cursor=pointer]: + - /url: /collections/new-arrivals + - region [ref=f92e29]: + - heading "Featured collections" [level=2] [ref=f92e30] + - generic [ref=f92e31]: + - link "New Arrivals" [ref=f92e32] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - generic [ref=f92e34]: + - generic [ref=f92e35]: New Arrivals + - generic [ref=f92e36]: Shop now + - link "T-Shirts" [ref=f92e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - generic [ref=f92e39]: + - generic [ref=f92e40]: T-Shirts + - generic [ref=f92e41]: Shop now + - link "Sale" [ref=f92e42] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/sale + - generic [ref=f92e44]: + - generic [ref=f92e45]: Sale + - generic [ref=f92e46]: Shop now + - region [ref=f92e47]: + - heading "Featured products" [level=2] [ref=f92e48] + - generic [ref=f92e49]: + - generic [ref=f92e50]: + - link [ref=f92e52] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f92e56] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f92e57] + - generic [ref=f92e58]: 24.99 EUR + - link "Choose options" [ref=f92e62] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f92e63]: + - generic [ref=f92e64]: + - link [ref=f92e65] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f92e70]: + - generic [ref=f92e71]: "On sale:" + - text: Sale + - 'link "Premium Slim Fit Jeans 79.99 EUR 99.99 EUR On sale: Sale" [ref=f92e72] [cursor=pointer]': + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - heading "Premium Slim Fit Jeans" [level=3] [ref=f92e73] + - generic [ref=f92e75]: + - generic [ref=f92e76]: 79.99 EUR + - generic [ref=f92e77]: 99.99 EUR + - generic [ref=f92e78]: + - generic [ref=f92e79]: "On sale:" + - text: Sale + - link "Choose options" [ref=f92e81] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f92e82]: + - link [ref=f92e84] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - link "Organic Hoodie 59.99 EUR" [ref=f92e88] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - heading "Organic Hoodie" [level=3] [ref=f92e89] + - generic [ref=f92e90]: 59.99 EUR + - link "Choose options" [ref=f92e94] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - generic [ref=f92e95]: + - link [ref=f92e97] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - link "Leather Belt 34.99 EUR" [ref=f92e101] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - heading "Leather Belt" [level=3] [ref=f92e102] + - generic [ref=f92e103]: 34.99 EUR + - link "Choose options" [ref=f92e107] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - generic [ref=f92e108]: + - link [ref=f92e110] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - link "Running Sneakers 119.99 EUR" [ref=f92e114] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - heading "Running Sneakers" [level=3] [ref=f92e115] + - generic [ref=f92e116]: 119.99 EUR + - link "Choose options" [ref=f92e120] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - generic [ref=f92e121]: + - link [ref=f92e123] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f92e127] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f92e128] + - generic [ref=f92e129]: 29.99 EUR + - link "Choose options" [ref=f92e133] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - generic [ref=f92e134]: + - link [ref=f92e136] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - link "V-Neck Linen Tee 34.99 EUR" [ref=f92e140] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - heading "V-Neck Linen Tee" [level=3] [ref=f92e141] + - generic [ref=f92e142]: 34.99 EUR + - link "Choose options" [ref=f92e146] [cursor=pointer]: + - /url: http://acme-fashion.test/products/v-neck-linen-tee + - generic [ref=f92e147]: + - generic [ref=f92e148]: + - link [ref=f92e149] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - generic [ref=f92e154]: + - generic [ref=f92e155]: "On sale:" + - text: Sale + - 'link "Striped Polo Shirt 27.99 EUR 39.99 EUR On sale: Sale" [ref=f92e156] [cursor=pointer]': + - /url: http://acme-fashion.test/products/striped-polo-shirt + - heading "Striped Polo Shirt" [level=3] [ref=f92e157] + - generic [ref=f92e159]: + - generic [ref=f92e160]: 27.99 EUR + - generic [ref=f92e161]: 39.99 EUR + - generic [ref=f92e162]: + - generic [ref=f92e163]: "On sale:" + - text: Sale + - link "Choose options" [ref=f92e165] [cursor=pointer]: + - /url: http://acme-fashion.test/products/striped-polo-shirt + - region [ref=f92e166]: + - generic [ref=f92e167]: + - heading "Stay in the loop" [level=2] [ref=f92e168] + - paragraph [ref=f92e169]: Subscribe for exclusive offers and updates. + - generic [ref=f92e171]: + - generic [ref=f92e172]: Email address + - textbox "Email address" [ref=f92e173]: + - /placeholder: Enter your email + - button "Subscribe" [ref=f92e174] + - contentinfo [ref=f92e175]: + - generic [ref=f92e176]: + - generic [ref=f92e177]: + - generic [ref=f92e178]: + - heading "Shop" [level=2] [ref=f92e179] + - list [ref=f92e180]: + - listitem [ref=f92e181]: + - link "About Us" [ref=f92e182] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f92e183]: + - link "FAQ" [ref=f92e184] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f92e185]: + - link "Shipping & Returns" [ref=f92e186] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f92e187]: + - link "Privacy Policy" [ref=f92e188] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f92e189]: + - link "Terms of Service" [ref=f92e190] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f92e191]: + - heading "Acme Fashion" [level=2] [ref=f92e192] + - paragraph [ref=f92e193]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f92e194]: + - paragraph [ref=f92e195]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f92e196]: + - generic [ref=f92e197]: VISA + - generic [ref=f92e198]: MASTERCARD + - generic [ref=f92e199]: AMEX + - generic [ref=f92e200]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-51-53-652Z.yml b/.playwright-mcp/page-2026-07-26T09-51-53-652Z.yml new file mode 100644 index 00000000..51b9f03c --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-51-53-652Z.yml @@ -0,0 +1,147 @@ +- generic [active] [ref=f93e1]: + - link "Skip to main content" [ref=f93e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f93e3]: + - complementary "Admin navigation" [ref=f93e4]: + - generic [ref=f93e5]: + - link "Acme Fashion" [ref=f93e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f93e12]: + - navigation [ref=f93e13]: + - link "Dashboard" [ref=f93e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f93e19]: Products + - navigation [ref=f93e20]: + - link "Products" [ref=f93e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f93e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f93e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f93e36]: Orders + - navigation [ref=f93e37]: + - link "Orders" [ref=f93e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f93e43]: Customers + - navigation [ref=f93e44]: + - link "Customers" [ref=f93e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - generic [ref=f93e50]: + - banner [ref=f93e51]: + - button "Open navigation menu" [ref=f93e52] + - button "Acme Fashion" [ref=f93e56] + - button "Notifications" [ref=f93e61] + - button "SU" [ref=f93e65] + - main [ref=f93e72]: + - generic [ref=f93e73]: + - generic [ref=f93e74]: Home + - generic [ref=f93e78]: Dashboard + - generic [ref=f93e80]: + - generic [ref=f93e81]: + - heading "Dashboard" [level=1] [ref=f93e82] + - combobox "Date range" [ref=f93e83]: + - option "Last 7 days" + - option "Last 30 days" [selected] + - option "Last 90 days" + - generic [ref=f93e84]: + - generic [ref=f93e85]: + - paragraph [ref=f93e86]: Total Sales + - generic [ref=f93e87]: 1,689.58 EUR + - generic [ref=f93e88]: + - paragraph [ref=f93e89]: Orders + - generic [ref=f93e90]: "18" + - generic [ref=f93e91]: + - paragraph [ref=f93e92]: Avg. Order Value + - generic [ref=f93e93]: 93.86 EUR + - generic [ref=f93e94]: + - paragraph [ref=f93e95]: Conversion Rate + - generic [ref=f93e96]: 48.6% + - generic [ref=f93e97]: + - heading "Orders over time" [level=2] [ref=f93e98] + - generic [ref=f93e99]: + - img "Daily order counts for the selected period" [ref=f93e100] + - generic [ref=f93e102]: + - generic [ref=f93e103]: 2026-06-27 + - generic [ref=f93e104]: 2026-07-26 + - generic [ref=f93e105]: + - heading "Recent orders" [level=2] [ref=f93e106] + - table [ref=f93e108]: + - rowgroup [ref=f93e109]: + - row [ref=f93e110]: + - columnheader "Order" [ref=f93e111] + - columnheader "Date" [ref=f93e112] + - columnheader "Customer" [ref=f93e113] + - columnheader "Payment" [ref=f93e114] + - columnheader "Fulfillment" [ref=f93e115] + - columnheader "Total" [ref=f93e116] + - rowgroup [ref=f93e117]: + - row [ref=f93e118]: + - cell "#1018" [ref=f93e119] + - cell "Jul 26, 2026" [ref=f93e120] + - cell "John Doe" [ref=f93e121] + - cell "Paid" [ref=f93e122] + - cell "Unfulfilled" [ref=f93e124] + - cell "59.99 EUR" [ref=f93e126] + - row [ref=f93e127]: + - cell "#1017" [ref=f93e128] + - cell "Jul 26, 2026" [ref=f93e129] + - cell "Jane Smith" [ref=f93e130] + - cell "Paid" [ref=f93e131] + - cell "Fulfilled" [ref=f93e133] + - cell "84.98 EUR" [ref=f93e135] + - row [ref=f93e136]: + - cell "#1016" [ref=f93e137] + - cell "Jul 26, 2026" [ref=f93e138] + - cell "Jane Smith" [ref=f93e139] + - cell "Partially refunded" [ref=f93e140] + - cell "Unfulfilled" [ref=f93e142] + - cell "27.49 EUR" [ref=f93e144] + - row [ref=f93e145]: + - cell "#1015" [ref=f93e146] + - cell "Jul 26, 2026" [ref=f93e147] + - cell "John Doe" [ref=f93e148] + - cell "Paid" [ref=f93e149] + - cell "Unfulfilled" [ref=f93e151] + - cell "54.47 EUR" [ref=f93e153] + - row [ref=f93e154]: + - cell "#1005" [ref=f93e155] + - cell "Jul 26, 2026" [ref=f93e156] + - cell "Jane Smith" [ref=f93e157] + - cell "Pending" [ref=f93e158] + - cell "Unfulfilled" [ref=f93e160] + - cell "39.98 EUR" [ref=f93e162] + - row [ref=f93e163]: + - cell "#1013" [ref=f93e164] + - cell "Jul 25, 2026" [ref=f93e165] + - cell "Robert Martinez" [ref=f93e166] + - cell "Paid" [ref=f93e167] + - cell "Unfulfilled" [ref=f93e169] + - cell "84.97 EUR" [ref=f93e171] + - row [ref=f93e172]: + - cell "#1010" [ref=f93e173] + - cell "Jul 25, 2026" [ref=f93e174] + - cell "John Doe" [ref=f93e175] + - cell "Paid" [ref=f93e176] + - cell "Unfulfilled" [ref=f93e178] + - cell "504.98 EUR" [ref=f93e180] + - row [ref=f93e181]: + - cell "#1006" [ref=f93e182] + - cell "Jul 25, 2026" [ref=f93e183] + - cell "Michael Brown" [ref=f93e184] + - cell "Paid" [ref=f93e185] + - cell "Unfulfilled" [ref=f93e187] + - cell "124.98 EUR" [ref=f93e189] + - row [ref=f93e190]: + - cell "#1001" [ref=f93e191] + - cell "Jul 24, 2026" [ref=f93e192] + - cell "John Doe" [ref=f93e193] + - cell "Paid" [ref=f93e194] + - cell "Unfulfilled" [ref=f93e196] + - cell "54.97 EUR" [ref=f93e198] + - row [ref=f93e199]: + - cell "#1009" [ref=f93e200] + - cell "Jul 23, 2026" [ref=f93e201] + - cell "Emma Garcia" [ref=f93e202] + - cell "Paid" [ref=f93e203] + - cell "Unfulfilled" [ref=f93e205] + - cell "49.97 EUR" [ref=f93e207] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T09-52-44-448Z.yml b/.playwright-mcp/page-2026-07-26T09-52-44-448Z.yml new file mode 100644 index 00000000..340c3071 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T09-52-44-448Z.yml @@ -0,0 +1,170 @@ +- generic [active] [ref=f96e1]: + - link "Skip to main content" [ref=f96e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f96e3]: + - complementary "Admin navigation" [ref=f96e4]: + - generic [ref=f96e5]: + - link "Acme Electronics" [ref=f96e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f96e12]: + - navigation [ref=f96e13]: + - link "Dashboard" [ref=f96e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f96e19]: Products + - navigation [ref=f96e20]: + - link "Products" [ref=f96e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f96e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f96e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f96e36]: Orders + - navigation [ref=f96e37]: + - link "Orders" [ref=f96e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f96e43]: Customers + - navigation [ref=f96e44]: + - link "Customers" [ref=f96e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f96e50]: Discounts + - navigation [ref=f96e51]: + - link "Discounts" [ref=f96e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f96e58]: Content + - navigation [ref=f96e59]: + - link "Pages" [ref=f96e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f96e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f96e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f96e75]: + - link "Analytics" [ref=f96e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f96e82]: Settings + - navigation [ref=f96e83]: + - link "Settings" [ref=f96e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f96e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f96e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f96e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f96e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f96e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f96e115]: + - banner [ref=f96e116]: + - button "Open navigation menu" [ref=f96e117] + - button "Acme Electronics" [ref=f96e121] + - button "Notifications" [ref=f96e126] + - button "AT" [ref=f96e130] + - main [ref=f96e137]: + - generic [ref=f96e138]: + - link "Home" [ref=f96e140] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f96e143]: Products + - generic [ref=f96e145]: + - generic [ref=f96e146]: + - generic [ref=f96e147]: Products + - link "Add product" [ref=f96e148] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/create + - generic [ref=f96e152]: + - textbox "Search products" [ref=f96e154]: + - /placeholder: Search products... + - tablist "Status filter" [ref=f96e156]: + - tab "All" [selected] [ref=f96e157] + - tab "Draft" [ref=f96e158] + - tab "Active" [ref=f96e159] + - tab "Archived" [ref=f96e160] + - combobox "Product type filter" [ref=f96e161]: + - option "All types" [selected] + - option "Accessories" + - option "Audio" + - option "Cables" + - option "Laptops" + - option "Peripherals" + - table [ref=f96e163]: + - rowgroup [ref=f96e164]: + - row [ref=f96e165]: + - columnheader [ref=f96e166]: + - checkbox "Select all products" [ref=f96e167] + - columnheader "Image" [ref=f96e169] + - columnheader [ref=f96e171]: + - button "Title" [ref=f96e172] + - columnheader "Status" [ref=f96e173] + - columnheader [ref=f96e174]: + - button "Inventory" [ref=f96e175] + - columnheader "Variants" [ref=f96e176] + - columnheader "Type" [ref=f96e177] + - columnheader "Vendor" [ref=f96e178] + - columnheader [ref=f96e179]: + - button "Updated" [ref=f96e180] + - rowgroup [ref=f96e183]: + - row [ref=f96e184]: + - cell [ref=f96e185]: + - checkbox "Select Monitor Stand" [ref=f96e186] + - cell [ref=f96e188] + - cell [ref=f96e192]: + - link "Monitor Stand" [ref=f96e193] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/25/edit + - cell "Active" [ref=f96e194] + - cell "30" [ref=f96e196] + - cell "1" [ref=f96e197] + - cell "Accessories" [ref=f96e198] + - cell "DeskGear" [ref=f96e199] + - cell "1 hour ago" [ref=f96e200] + - row [ref=f96e201]: + - cell [ref=f96e202]: + - checkbox "Select Wireless Headphones" [ref=f96e203] + - cell [ref=f96e205] + - cell [ref=f96e209]: + - link "Wireless Headphones" [ref=f96e210] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/22/edit + - cell "Active" [ref=f96e211] + - cell "50" [ref=f96e213] + - cell "2" [ref=f96e214] + - cell "Audio" [ref=f96e215] + - cell "AudioMax" [ref=f96e216] + - cell "1 hour ago" [ref=f96e217] + - row [ref=f96e218]: + - cell [ref=f96e219]: + - checkbox "Select USB-C Cable 2m" [ref=f96e220] + - cell [ref=f96e222] + - cell [ref=f96e226]: + - link "USB-C Cable 2m" [ref=f96e227] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/23/edit + - cell "Active" [ref=f96e228] + - cell "200" [ref=f96e230] + - cell "1" [ref=f96e231] + - cell "Cables" [ref=f96e232] + - cell "CablePro" [ref=f96e233] + - cell "1 hour ago" [ref=f96e234] + - row [ref=f96e235]: + - cell [ref=f96e236]: + - checkbox "Select Pro Laptop 15" [ref=f96e237] + - cell [ref=f96e239] + - cell [ref=f96e243]: + - link "Pro Laptop 15" [ref=f96e244] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/21/edit + - cell "Active" [ref=f96e245] + - cell "30" [ref=f96e247] + - cell "3" [ref=f96e248] + - cell "Laptops" [ref=f96e249] + - cell "TechCorp" [ref=f96e250] + - cell "1 hour ago" [ref=f96e251] + - row [ref=f96e252]: + - cell [ref=f96e253]: + - checkbox "Select Mechanical Keyboard" [ref=f96e254] + - cell [ref=f96e256] + - cell [ref=f96e260]: + - link "Mechanical Keyboard" [ref=f96e261] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/24/edit + - cell "Active" [ref=f96e262] + - cell "45" [ref=f96e264] + - cell "3" [ref=f96e265] + - cell "Peripherals" [ref=f96e266] + - cell "KeyTech" [ref=f96e267] + - cell "1 hour ago" [ref=f96e268] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T21-00-43-856Z.yml b/.playwright-mcp/page-2026-07-26T21-00-43-856Z.yml new file mode 100644 index 00000000..1ae24d0a --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T21-00-43-856Z.yml @@ -0,0 +1,26 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - banner [ref=e3]: + - link "Shop" [ref=e5] [cursor=pointer]: + - /url: / + - main [ref=e6]: + - generic [ref=e7]: + - paragraph: "404" + - generic [ref=e8]: + - heading "Page not found" [level=1] [ref=e9] + - paragraph [ref=e10]: The page you're looking for doesn't exist or has been moved. + - search [ref=e11]: + - generic [ref=e12]: Search products + - searchbox "Search products" [ref=e13] + - button "Search" [ref=e14] + - link "Go to home page" [ref=e15] [cursor=pointer]: + - /url: / + - contentinfo [ref=e16]: + - navigation "Helpful links" [ref=e17]: + - link "Home" [ref=e18] [cursor=pointer]: + - /url: / + - link "Collections" [ref=e19] [cursor=pointer]: + - /url: /collections + - link "Search" [ref=e20] [cursor=pointer]: + - /url: /search \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T21-00-51-564Z.yml b/.playwright-mcp/page-2026-07-26T21-00-51-564Z.yml new file mode 100644 index 00000000..f2991376 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T21-00-51-564Z.yml @@ -0,0 +1,78 @@ +- generic [ref=f1e1]: + - link "Skip to main content" [ref=f1e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f1e4]: + - paragraph [ref=f1e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f1e6] + - banner [ref=f1e9]: + - generic [ref=f1e10]: + - link "Acme Fashion" [ref=f1e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f1e13]: + - link "Home" [ref=f1e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f1e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f1e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f1e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f1e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f1e19]: + - button "Search" [ref=f1e20] + - link "Account" [ref=f1e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f1e26] + - main [ref=f1e29]: + - generic [ref=f1e30]: + - heading "Log in to your account" [level=1] [ref=f1e31] + - generic [ref=f1e32]: + - generic [ref=f1e33]: + - generic [ref=f1e34]: Email + - textbox "Email" [active] [ref=f1e36] + - generic [ref=f1e37]: + - generic [ref=f1e38]: Password + - textbox "Password" [ref=f1e40] + - generic [ref=f1e41]: + - generic [ref=f1e42]: + - checkbox "Remember me" [ref=f1e43] + - generic [ref=f1e45]: Remember me + - link "Forgot password?" [ref=f1e46] [cursor=pointer]: + - /url: http://acme-fashion.test/forgot-password + - button "Log in" [ref=f1e47] + - paragraph [ref=f1e53]: + - text: Don't have an account? + - link "Create one" [ref=f1e54] [cursor=pointer]: + - /url: http://acme-fashion.test/account/register + - contentinfo [ref=f1e55]: + - generic [ref=f1e56]: + - generic [ref=f1e57]: + - generic [ref=f1e58]: + - heading "Shop" [level=2] [ref=f1e59] + - list [ref=f1e60]: + - listitem [ref=f1e61]: + - link "About Us" [ref=f1e62] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f1e63]: + - link "FAQ" [ref=f1e64] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f1e65]: + - link "Shipping & Returns" [ref=f1e66] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f1e67]: + - link "Privacy Policy" [ref=f1e68] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f1e69]: + - link "Terms of Service" [ref=f1e70] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f1e71]: + - heading "Acme Fashion" [level=2] [ref=f1e72] + - paragraph [ref=f1e73]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f1e74]: + - paragraph [ref=f1e75]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f1e76]: + - generic [ref=f1e77]: VISA + - generic [ref=f1e78]: MASTERCARD + - generic [ref=f1e79]: AMEX + - generic [ref=f1e80]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T21-17-41-187Z.yml b/.playwright-mcp/page-2026-07-26T21-17-41-187Z.yml new file mode 100644 index 00000000..ac389a83 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T21-17-41-187Z.yml @@ -0,0 +1,22 @@ +- generic [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - main [ref=e3]: + - generic [ref=e5]: + - generic [ref=e6]: + - heading "Log in" [level=1] [ref=e7] + - paragraph [ref=e8]: Sign in to your admin account + - generic [ref=e9]: + - generic [ref=e10]: + - generic [ref=e11]: Email + - textbox "Email" [active] [ref=e13] + - generic [ref=e14]: + - generic [ref=e15]: Password + - textbox "Password" [ref=e17] + - generic [ref=e18]: + - generic [ref=e19]: + - checkbox "Remember me" [ref=e20] + - generic [ref=e22]: Remember me + - link "Forgot password?" [ref=e23] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/forgot-password + - button "Log in" [ref=e24] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T21-36-48-249Z.yml b/.playwright-mcp/page-2026-07-26T21-36-48-249Z.yml new file mode 100644 index 00000000..ac34eb0b --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T21-36-48-249Z.yml @@ -0,0 +1,175 @@ +- generic [active] [ref=f3e1]: + - link "Skip to main content" [ref=f3e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f3e4]: + - paragraph [ref=f3e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f3e6] + - banner [ref=f3e9]: + - generic [ref=f3e10]: + - link "Acme Fashion" [ref=f3e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f3e13]: + - link "Home" [ref=f3e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f3e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f3e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f3e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f3e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f3e19]: + - button "Search" [ref=f3e20] + - link "Account" [ref=f3e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f3e26] + - main [ref=f3e29]: + - generic [ref=f3e30]: + - generic [ref=f3e34]: + - heading "Welcome to Acme Fashion" [level=1] [ref=f3e35] + - paragraph [ref=f3e36]: Discover our curated collection of modern essentials + - link "Shop New Arrivals" [ref=f3e37] [cursor=pointer]: + - /url: /collections/new-arrivals + - region [ref=f3e38]: + - heading "Featured collections" [level=2] [ref=f3e39] + - generic [ref=f3e40]: + - link "New Arrivals" [ref=f3e41] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - generic [ref=f3e43]: + - generic [ref=f3e44]: New Arrivals + - generic [ref=f3e45]: Shop now + - link "T-Shirts" [ref=f3e46] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - generic [ref=f3e48]: + - generic [ref=f3e49]: T-Shirts + - generic [ref=f3e50]: Shop now + - link "Sale" [ref=f3e51] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/sale + - generic [ref=f3e53]: + - generic [ref=f3e54]: Sale + - generic [ref=f3e55]: Shop now + - region [ref=f3e56]: + - heading "Featured products" [level=2] [ref=f3e57] + - generic [ref=f3e58]: + - generic [ref=f3e59]: + - link [ref=f3e61] [cursor=pointer]: + - /url: http://acme-fashion.test/products/gift-card + - link "Gift Card 25.00 EUR" [ref=f3e65] [cursor=pointer]: + - /url: http://acme-fashion.test/products/gift-card + - heading "Gift Card" [level=3] [ref=f3e66] + - generic [ref=f3e67]: 25.00 EUR + - link "Choose options" [ref=f3e71] [cursor=pointer]: + - /url: http://acme-fashion.test/products/gift-card + - generic [ref=f3e72]: + - link [ref=f3e74] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cashmere-overcoat + - link "Cashmere Overcoat 499.99 EUR" [ref=f3e78] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cashmere-overcoat + - heading "Cashmere Overcoat" [level=3] [ref=f3e79] + - generic [ref=f3e80]: 499.99 EUR + - link "Choose options" [ref=f3e84] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cashmere-overcoat + - generic [ref=f3e85]: + - link [ref=f3e87] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f3e91] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f3e92] + - generic [ref=f3e93]: 24.99 EUR + - link "Choose options" [ref=f3e97] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f3e98]: + - generic [ref=f3e99]: + - link [ref=f3e100] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f3e105]: + - generic [ref=f3e106]: "On sale:" + - text: Sale + - 'link "Premium Slim Fit Jeans 79.99 EUR 99.99 EUR On sale: Sale" [ref=f3e107] [cursor=pointer]': + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - heading "Premium Slim Fit Jeans" [level=3] [ref=f3e108] + - generic [ref=f3e110]: + - generic [ref=f3e111]: 79.99 EUR + - generic [ref=f3e112]: 99.99 EUR + - generic [ref=f3e113]: + - generic [ref=f3e114]: "On sale:" + - text: Sale + - link "Choose options" [ref=f3e116] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f3e117]: + - link [ref=f3e119] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - link "Organic Hoodie 59.99 EUR" [ref=f3e123] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - heading "Organic Hoodie" [level=3] [ref=f3e124] + - generic [ref=f3e125]: 59.99 EUR + - link "Choose options" [ref=f3e129] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - generic [ref=f3e130]: + - link [ref=f3e132] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - link "Leather Belt 34.99 EUR" [ref=f3e136] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - heading "Leather Belt" [level=3] [ref=f3e137] + - generic [ref=f3e138]: 34.99 EUR + - link "Choose options" [ref=f3e142] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - generic [ref=f3e143]: + - link [ref=f3e145] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - link "Running Sneakers 119.99 EUR" [ref=f3e149] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - heading "Running Sneakers" [level=3] [ref=f3e150] + - generic [ref=f3e151]: 119.99 EUR + - link "Choose options" [ref=f3e155] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - generic [ref=f3e156]: + - link [ref=f3e158] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f3e162] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f3e163] + - generic [ref=f3e164]: 29.99 EUR + - link "Choose options" [ref=f3e168] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - region [ref=f3e169]: + - generic [ref=f3e170]: + - heading "Stay in the loop" [level=2] [ref=f3e171] + - paragraph [ref=f3e172]: Subscribe for exclusive offers and updates. + - generic [ref=f3e174]: + - generic [ref=f3e175]: Email address + - textbox "Email address" [ref=f3e176]: + - /placeholder: Enter your email + - button "Subscribe" [ref=f3e177] + - contentinfo [ref=f3e178]: + - generic [ref=f3e179]: + - generic [ref=f3e180]: + - generic [ref=f3e181]: + - heading "Shop" [level=2] [ref=f3e182] + - list [ref=f3e183]: + - listitem [ref=f3e184]: + - link "About Us" [ref=f3e185] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f3e186]: + - link "FAQ" [ref=f3e187] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f3e188]: + - link "Shipping & Returns" [ref=f3e189] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f3e190]: + - link "Privacy Policy" [ref=f3e191] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f3e192]: + - link "Terms of Service" [ref=f3e193] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f3e194]: + - heading "Acme Fashion" [level=2] [ref=f3e195] + - paragraph [ref=f3e196]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f3e197]: + - paragraph [ref=f3e198]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f3e199]: + - generic [ref=f3e200]: VISA + - generic [ref=f3e201]: MASTERCARD + - generic [ref=f3e202]: AMEX + - generic [ref=f3e203]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T21-37-34-173Z.yml b/.playwright-mcp/page-2026-07-26T21-37-34-173Z.yml new file mode 100644 index 00000000..8717e375 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T21-37-34-173Z.yml @@ -0,0 +1,101 @@ +- generic [active] [ref=f4e1]: + - link "Skip to main content" [ref=f4e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f4e4]: + - paragraph [ref=f4e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f4e6] + - banner [ref=f4e9]: + - generic [ref=f4e10]: + - link "Acme Fashion" [ref=f4e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f4e13]: + - link "Home" [ref=f4e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f4e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f4e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f4e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f4e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f4e19]: + - button "Search" [ref=f4e20] + - link "Account" [ref=f4e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f4e26] + - main [ref=f4e29]: + - generic [ref=f4e30]: + - navigation "Breadcrumb" [ref=f4e31]: + - list [ref=f4e32]: + - listitem [ref=f4e33]: + - link "Home" [ref=f4e34] [cursor=pointer]: + - /url: http://acme-fashion.test + - listitem [ref=f4e35]: + - generic [ref=f4e36]: / + - link "New Arrivals" [ref=f4e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - listitem [ref=f4e38]: + - generic [ref=f4e39]: / + - generic [ref=f4e40]: Classic Cotton T-Shirt + - generic [ref=f4e41]: + - region "Product images" [ref=f4e42] + - generic [ref=f4e48]: + - heading "Classic Cotton T-Shirt" [level=1] [ref=f4e49] + - paragraph [ref=f4e50]: Acme Basics + - generic [ref=f4e51]: 24.99 EUR + - group "SizeS" [ref=f4e53]: + - generic [ref=f4e55]: + - button "S" [pressed] [ref=f4e56] + - button "M" [ref=f4e57] + - button "L" [ref=f4e58] + - button "XL" [ref=f4e59] + - group "ColorWhite" [ref=f4e60]: + - generic [ref=f4e62]: + - button "White" [pressed] [ref=f4e63] + - button "Black" [ref=f4e64] + - button "Navy" [ref=f4e65] + - paragraph [ref=f4e66]: In stock + - generic [ref=f4e69]: + - generic [ref=f4e70]: + - button "Decrease quantity" [disabled] [ref=f4e71] + - generic [ref=f4e73]: Quantity + - spinbutton "Quantity" [ref=f4e74]: "1" + - button "Increase quantity" [ref=f4e75] + - button "Add to cart" [ref=f4e78] + - separator [ref=f4e79] + - paragraph [ref=f4e81]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - generic [ref=f4e82]: + - generic [ref=f4e83]: new + - generic [ref=f4e84]: popular + - contentinfo [ref=f4e85]: + - generic [ref=f4e86]: + - generic [ref=f4e87]: + - generic [ref=f4e88]: + - heading "Shop" [level=2] [ref=f4e89] + - list [ref=f4e90]: + - listitem [ref=f4e91]: + - link "About Us" [ref=f4e92] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f4e93]: + - link "FAQ" [ref=f4e94] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f4e95]: + - link "Shipping & Returns" [ref=f4e96] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f4e97]: + - link "Privacy Policy" [ref=f4e98] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f4e99]: + - link "Terms of Service" [ref=f4e100] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f4e101]: + - heading "Acme Fashion" [level=2] [ref=f4e102] + - paragraph [ref=f4e103]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f4e104]: + - paragraph [ref=f4e105]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f4e106]: + - generic [ref=f4e107]: VISA + - generic [ref=f4e108]: MASTERCARD + - generic [ref=f4e109]: AMEX + - generic [ref=f4e110]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T21-38-07-519Z.yml b/.playwright-mcp/page-2026-07-26T21-38-07-519Z.yml new file mode 100644 index 00000000..0817344b --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T21-38-07-519Z.yml @@ -0,0 +1,135 @@ +- generic [ref=f4e1]: + - link "Skip to main content" [ref=f4e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f4e4]: + - paragraph [ref=f4e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f4e6] + - banner [ref=f4e9]: + - generic [ref=f4e10]: + - link "Acme Fashion" [ref=f4e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f4e13]: + - link "Home" [ref=f4e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f4e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f4e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f4e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f4e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f4e19]: + - button "Search" [ref=f4e20] + - link "Account" [ref=f4e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f4e26]: + - generic [ref=f4e111]: "1" + - main [ref=f4e29]: + - generic [ref=f4e30]: + - navigation "Breadcrumb" [ref=f4e31]: + - list [ref=f4e32]: + - listitem [ref=f4e33]: + - link "Home" [ref=f4e34] [cursor=pointer]: + - /url: http://acme-fashion.test + - listitem [ref=f4e35]: + - generic [ref=f4e36]: / + - link "New Arrivals" [ref=f4e37] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - listitem [ref=f4e38]: + - generic [ref=f4e39]: / + - generic [ref=f4e40]: Classic Cotton T-Shirt + - generic [ref=f4e41]: + - region "Product images" [ref=f4e42] + - generic [ref=f4e48]: + - heading "Classic Cotton T-Shirt" [level=1] [ref=f4e49] + - paragraph [ref=f4e50]: Acme Basics + - generic [ref=f4e51]: 24.99 EUR + - group "SizeS" [ref=f4e53]: + - generic [ref=f4e55]: + - button "S" [pressed] [ref=f4e56] + - button "M" [ref=f4e57] + - button "L" [ref=f4e58] + - button "XL" [ref=f4e59] + - group "ColorWhite" [ref=f4e60]: + - generic [ref=f4e62]: + - button "White" [pressed] [ref=f4e63] + - button "Black" [ref=f4e64] + - button "Navy" [ref=f4e65] + - paragraph [ref=f4e66]: In stock + - generic [ref=f4e69]: + - generic [ref=f4e70]: + - button "Decrease quantity" [disabled] [ref=f4e71] + - generic [ref=f4e73]: Quantity + - spinbutton "Quantity" [ref=f4e74]: "1" + - button "Increase quantity" [ref=f4e75] + - button "Add to cart" [ref=f4e78] + - separator [ref=f4e79] + - paragraph [ref=f4e81]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - generic [ref=f4e82]: + - generic [ref=f4e83]: new + - generic [ref=f4e84]: popular + - contentinfo [ref=f4e85]: + - generic [ref=f4e86]: + - generic [ref=f4e87]: + - generic [ref=f4e88]: + - heading "Shop" [level=2] [ref=f4e89] + - list [ref=f4e90]: + - listitem [ref=f4e91]: + - link "About Us" [ref=f4e92] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f4e93]: + - link "FAQ" [ref=f4e94] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f4e95]: + - link "Shipping & Returns" [ref=f4e96] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f4e97]: + - link "Privacy Policy" [ref=f4e98] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f4e99]: + - link "Terms of Service" [ref=f4e100] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f4e101]: + - heading "Acme Fashion" [level=2] [ref=f4e102] + - paragraph [ref=f4e103]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f4e104]: + - paragraph [ref=f4e105]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f4e106]: + - generic [ref=f4e107]: VISA + - generic [ref=f4e108]: MASTERCARD + - generic [ref=f4e109]: AMEX + - generic [ref=f4e110]: PAYPAL + - generic: + - dialog "Your Cart (1)": + - generic [ref=f4e114]: + - generic [ref=f4e115]: + - heading "Your Cart (1)" [level=2] [ref=f4e116] + - button "Close cart" [active] [ref=f4e117] + - list [ref=f4e120]: + - listitem [ref=f4e121]: + - generic [ref=f4e125]: + - paragraph [ref=f4e126]: Classic Cotton T-Shirt + - paragraph [ref=f4e127]: S / White + - generic [ref=f4e128]: + - generic [ref=f4e129]: + - button "Decrease quantity of Classic Cotton T-Shirt" [ref=f4e130] + - generic [ref=f4e132]: "1" + - button "Increase quantity of Classic Cotton T-Shirt" [ref=f4e133] + - paragraph [ref=f4e136]: 24.99 EUR + - button "Remove Classic Cotton T-Shirt from cart" [ref=f4e138] + - generic [ref=f4e142]: + - generic [ref=f4e143]: Discount code + - textbox "Discount code" [ref=f4e144] + - button "Apply" [ref=f4e145] + - generic [ref=f4e146]: + - generic [ref=f4e147]: + - generic [ref=f4e148]: + - term [ref=f4e149]: Subtotal + - definition [ref=f4e150]: 24.99 EUR + - generic [ref=f4e151]: + - term [ref=f4e152]: Estimated total + - definition [ref=f4e153]: 24.99 EUR + - paragraph [ref=f4e154]: Shipping and taxes calculated at checkout + - button "Checkout" [ref=f4e155] + - button "Continue shopping" [ref=f4e157] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T21-38-40-915Z.yml b/.playwright-mcp/page-2026-07-26T21-38-40-915Z.yml new file mode 100644 index 00000000..78964134 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T21-38-40-915Z.yml @@ -0,0 +1,126 @@ +- generic [active] [ref=f5e1]: + - link "Skip to main content" [ref=f5e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f5e4]: + - paragraph [ref=f5e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f5e6] + - banner [ref=f5e9]: + - generic [ref=f5e10]: + - link "Acme Fashion" [ref=f5e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f5e13]: + - link "Home" [ref=f5e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f5e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f5e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f5e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f5e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f5e19]: + - button "Search" [ref=f5e20] + - link "Account" [ref=f5e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f5e26] + - main [ref=f5e29]: + - generic [ref=f5e30]: + - heading "Checkout" [level=1] [ref=f5e31] + - generic [ref=f5e32]: + - generic [ref=f5e33]: + - region [ref=f5e34]: + - heading "1. Contact & shipping address" [level=2] [ref=f5e35] + - generic [ref=f5e37]: + - generic [ref=f5e38]: + - generic [ref=f5e39]: Email * + - textbox "Email" [ref=f5e40] + - generic [ref=f5e41]: + - generic [ref=f5e42]: + - generic [ref=f5e43]: First name * + - textbox "First name" [ref=f5e44] + - generic [ref=f5e45]: + - generic [ref=f5e46]: Last name * + - textbox "Last name" [ref=f5e47] + - generic [ref=f5e48]: + - generic [ref=f5e49]: Address line 1 * + - textbox "Address line 1" [ref=f5e50] + - generic [ref=f5e51]: + - generic [ref=f5e52]: Address line 2 (optional) + - textbox "Address line 2 (optional)" [ref=f5e53] + - generic [ref=f5e54]: + - generic [ref=f5e55]: City * + - textbox "City" [ref=f5e56] + - generic [ref=f5e57]: + - generic [ref=f5e58]: State / Province (optional) + - textbox "State / Province (optional)" [ref=f5e59] + - generic [ref=f5e60]: + - generic [ref=f5e61]: Postal code * + - textbox "Postal code" [ref=f5e62] + - generic [ref=f5e63]: + - generic [ref=f5e64]: Country code (e.g. DE) * + - textbox "Country code (e.g. DE)" [ref=f5e65] + - generic [ref=f5e66]: + - generic [ref=f5e67]: Phone (optional) + - textbox "Phone (optional)" [ref=f5e68] + - generic [ref=f5e69]: + - checkbox "Billing address same as shipping" [checked] [ref=f5e70] + - text: Billing address same as shipping + - button "Continue to shipping" [ref=f5e71] + - region [ref=f5e72]: + - heading "2. Shipping method" [level=2] [ref=f5e73] + - region [ref=f5e74]: + - heading "3. Payment" [level=2] [ref=f5e75] + - complementary "Order summary" [ref=f5e76]: + - generic [ref=f5e77]: + - heading "Order Summary" [level=2] [ref=f5e78] + - list [ref=f5e79]: + - listitem [ref=f5e80]: + - generic [ref=f5e84]: + - paragraph [ref=f5e85]: Classic Cotton T-Shirt ×1 + - paragraph [ref=f5e86]: S / White + - paragraph [ref=f5e87]: 24.99 EUR + - generic [ref=f5e88]: + - generic [ref=f5e89]: + - term [ref=f5e90]: Subtotal + - definition [ref=f5e91]: 24.99 EUR + - generic [ref=f5e92]: + - term [ref=f5e93]: Shipping + - definition [ref=f5e94]: Calculated at next step + - generic [ref=f5e95]: + - term [ref=f5e96]: Tax + - definition [ref=f5e97]: 0.00 EUR + - generic [ref=f5e98]: + - term [ref=f5e99]: Total + - definition [ref=f5e100]: 24.99 EUR + - contentinfo [ref=f5e101]: + - generic [ref=f5e102]: + - generic [ref=f5e103]: + - generic [ref=f5e104]: + - heading "Shop" [level=2] [ref=f5e105] + - list [ref=f5e106]: + - listitem [ref=f5e107]: + - link "About Us" [ref=f5e108] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f5e109]: + - link "FAQ" [ref=f5e110] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f5e111]: + - link "Shipping & Returns" [ref=f5e112] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f5e113]: + - link "Privacy Policy" [ref=f5e114] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f5e115]: + - link "Terms of Service" [ref=f5e116] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f5e117]: + - heading "Acme Fashion" [level=2] [ref=f5e118] + - paragraph [ref=f5e119]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f5e120]: + - paragraph [ref=f5e121]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f5e122]: + - generic [ref=f5e123]: VISA + - generic [ref=f5e124]: MASTERCARD + - generic [ref=f5e125]: AMEX + - generic [ref=f5e126]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T21-39-33-681Z.yml b/.playwright-mcp/page-2026-07-26T21-39-33-681Z.yml new file mode 100644 index 00000000..eb68a283 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T21-39-33-681Z.yml @@ -0,0 +1,104 @@ +- generic [active] [ref=f6e1]: + - link "Skip to main content" [ref=f6e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f6e4]: + - paragraph [ref=f6e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f6e6] + - banner [ref=f6e9]: + - generic [ref=f6e10]: + - link "Acme Fashion" [ref=f6e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f6e13]: + - link "Home" [ref=f6e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f6e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f6e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f6e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f6e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f6e19]: + - button "Search" [ref=f6e20] + - link "Account" [ref=f6e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f6e26] + - main [ref=f6e29]: + - generic [ref=f6e30]: + - heading "Checkout" [level=1] [ref=f6e31] + - generic [ref=f6e32]: + - generic [ref=f6e33]: + - region [ref=f6e34]: + - heading "1. Contact & shipping address jane@example.com" [level=2] [ref=f6e35]: + - generic [ref=f6e36]: 1. Contact & shipping address + - generic [ref=f6e37]: jane@example.com + - generic [ref=f6e38]: Jane Doe, 123 Main St, 10115 Berlin, DE + - region [ref=f6e39]: + - heading "2. Shipping method" [level=2] [ref=f6e40] + - group "Available shipping methods" [ref=f6e41]: + - button "Standard Shipping 4.99 EUR" [ref=f6e43]: + - generic [ref=f6e44]: Standard Shipping + - generic [ref=f6e45]: 4.99 EUR + - button "Express Shipping 9.99 EUR" [ref=f6e46]: + - generic [ref=f6e47]: Express Shipping + - generic [ref=f6e48]: 9.99 EUR + - region [ref=f6e49]: + - heading "3. Payment" [level=2] [ref=f6e50] + - complementary "Order summary" [ref=f6e51]: + - generic [ref=f6e52]: + - heading "Order Summary" [level=2] [ref=f6e53] + - list [ref=f6e54]: + - listitem [ref=f6e55]: + - generic [ref=f6e59]: + - paragraph [ref=f6e60]: Classic Cotton T-Shirt ×1 + - paragraph [ref=f6e61]: S / White + - paragraph [ref=f6e62]: 24.99 EUR + - generic [ref=f6e64]: + - generic [ref=f6e65]: Discount code + - textbox "Discount code" [ref=f6e66] + - button "Apply" [ref=f6e67] + - generic [ref=f6e68]: + - generic [ref=f6e69]: + - term [ref=f6e70]: Subtotal + - definition [ref=f6e71]: 24.99 EUR + - generic [ref=f6e72]: + - term [ref=f6e73]: Shipping + - definition [ref=f6e74]: 0.00 EUR + - generic [ref=f6e75]: + - term [ref=f6e76]: Tax + - definition [ref=f6e77]: 3.99 EUR + - generic [ref=f6e78]: + - term [ref=f6e79]: Total + - definition [ref=f6e80]: 24.99 EUR + - contentinfo [ref=f6e81]: + - generic [ref=f6e82]: + - generic [ref=f6e83]: + - generic [ref=f6e84]: + - heading "Shop" [level=2] [ref=f6e85] + - list [ref=f6e86]: + - listitem [ref=f6e87]: + - link "About Us" [ref=f6e88] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f6e89]: + - link "FAQ" [ref=f6e90] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f6e91]: + - link "Shipping & Returns" [ref=f6e92] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f6e93]: + - link "Privacy Policy" [ref=f6e94] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f6e95]: + - link "Terms of Service" [ref=f6e96] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f6e97]: + - heading "Acme Fashion" [level=2] [ref=f6e98] + - paragraph [ref=f6e99]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f6e100]: + - paragraph [ref=f6e101]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f6e102]: + - generic [ref=f6e103]: VISA + - generic [ref=f6e104]: MASTERCARD + - generic [ref=f6e105]: AMEX + - generic [ref=f6e106]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T21-40-06-296Z.yml b/.playwright-mcp/page-2026-07-26T21-40-06-296Z.yml new file mode 100644 index 00000000..b519690c --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T21-40-06-296Z.yml @@ -0,0 +1,110 @@ +- generic [active] [ref=f6e1]: + - link "Skip to main content" [ref=f6e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f6e4]: + - paragraph [ref=f6e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f6e6] + - banner [ref=f6e9]: + - generic [ref=f6e10]: + - link "Acme Fashion" [ref=f6e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f6e13]: + - link "Home" [ref=f6e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f6e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f6e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f6e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f6e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f6e19]: + - button "Search" [ref=f6e20] + - link "Account" [ref=f6e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f6e26] + - main [ref=f6e29]: + - generic [ref=f6e30]: + - heading "Checkout" [level=1] [ref=f6e31] + - generic [ref=f6e32]: + - generic [ref=f6e33]: + - region [ref=f6e34]: + - heading "1. Contact & shipping address jane@example.com" [level=2] [ref=f6e35]: + - generic [ref=f6e36]: 1. Contact & shipping address + - generic [ref=f6e37]: jane@example.com + - generic [ref=f6e38]: Jane Doe, 123 Main St, 10115 Berlin, DE + - region [ref=f6e39]: + - heading "2. Shipping method" [level=2] [ref=f6e40] + - generic [ref=f6e107]: Shipping method selected + - region [ref=f6e49]: + - heading "3. Payment" [level=2] [ref=f6e50] + - generic [ref=f6e108]: + - group "Select a payment method" [ref=f6e109]: + - generic [ref=f6e111] [cursor=pointer]: + - radio "Credit Card" [checked] [ref=f6e112] + - generic [ref=f6e113]: Credit Card + - generic [ref=f6e114] [cursor=pointer]: + - radio "PayPal" [ref=f6e115] + - generic [ref=f6e116]: PayPal + - generic [ref=f6e117] [cursor=pointer]: + - radio "Bank Transfer" [ref=f6e118] + - generic [ref=f6e119]: Bank Transfer + - button "Continue" [ref=f6e120] + - complementary "Order summary" [ref=f6e51]: + - generic [ref=f6e52]: + - heading "Order Summary" [level=2] [ref=f6e53] + - list [ref=f6e54]: + - listitem [ref=f6e55]: + - generic [ref=f6e59]: + - paragraph [ref=f6e60]: Classic Cotton T-Shirt ×1 + - paragraph [ref=f6e61]: S / White + - paragraph [ref=f6e62]: 24.99 EUR + - generic [ref=f6e64]: + - generic [ref=f6e65]: Discount code + - textbox "Discount code" [ref=f6e66] + - button "Apply" [ref=f6e67] + - generic [ref=f6e68]: + - generic [ref=f6e69]: + - term [ref=f6e70]: Subtotal + - definition [ref=f6e71]: 24.99 EUR + - generic [ref=f6e72]: + - term [ref=f6e73]: Shipping + - definition [ref=f6e74]: 4.99 EUR + - generic [ref=f6e75]: + - term [ref=f6e76]: Tax + - definition [ref=f6e77]: 4.79 EUR + - generic [ref=f6e78]: + - term [ref=f6e79]: Total + - definition [ref=f6e80]: 29.98 EUR + - contentinfo [ref=f6e81]: + - generic [ref=f6e82]: + - generic [ref=f6e83]: + - generic [ref=f6e84]: + - heading "Shop" [level=2] [ref=f6e85] + - list [ref=f6e86]: + - listitem [ref=f6e87]: + - link "About Us" [ref=f6e88] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f6e89]: + - link "FAQ" [ref=f6e90] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f6e91]: + - link "Shipping & Returns" [ref=f6e92] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f6e93]: + - link "Privacy Policy" [ref=f6e94] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f6e95]: + - link "Terms of Service" [ref=f6e96] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f6e97]: + - heading "Acme Fashion" [level=2] [ref=f6e98] + - paragraph [ref=f6e99]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f6e100]: + - paragraph [ref=f6e101]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f6e102]: + - generic [ref=f6e103]: VISA + - generic [ref=f6e104]: MASTERCARD + - generic [ref=f6e105]: AMEX + - generic [ref=f6e106]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T21-40-37-047Z.yml b/.playwright-mcp/page-2026-07-26T21-40-37-047Z.yml new file mode 100644 index 00000000..9c6efbe3 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T21-40-37-047Z.yml @@ -0,0 +1,127 @@ +- generic [active] [ref=f6e1]: + - link "Skip to main content" [ref=f6e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f6e4]: + - paragraph [ref=f6e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f6e6] + - banner [ref=f6e9]: + - generic [ref=f6e10]: + - link "Acme Fashion" [ref=f6e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f6e13]: + - link "Home" [ref=f6e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f6e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f6e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f6e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f6e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f6e19]: + - button "Search" [ref=f6e20] + - link "Account" [ref=f6e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f6e26] + - main [ref=f6e29]: + - generic [ref=f6e30]: + - heading "Checkout" [level=1] [ref=f6e31] + - generic [ref=f6e32]: + - generic [ref=f6e33]: + - region [ref=f6e34]: + - heading "1. Contact & shipping address jane@example.com" [level=2] [ref=f6e35]: + - generic [ref=f6e36]: 1. Contact & shipping address + - generic [ref=f6e37]: jane@example.com + - generic [ref=f6e38]: Jane Doe, 123 Main St, 10115 Berlin, DE + - region [ref=f6e39]: + - heading "2. Shipping method" [level=2] [ref=f6e40] + - generic [ref=f6e107]: Shipping method selected + - region [ref=f6e49]: + - heading "3. Payment" [level=2] [ref=f6e50] + - generic [ref=f6e108]: + - group "Select a payment method" [ref=f6e109]: + - generic [ref=f6e111] [cursor=pointer]: + - radio "Credit Card" [checked] [disabled] [ref=f6e112] + - generic [ref=f6e113]: Credit Card + - generic [ref=f6e114] [cursor=pointer]: + - radio "PayPal" [disabled] [ref=f6e115] + - generic [ref=f6e116]: PayPal + - generic [ref=f6e117] [cursor=pointer]: + - radio "Bank Transfer" [disabled] [ref=f6e118] + - generic [ref=f6e119]: Bank Transfer + - generic [ref=f6e121]: + - generic [ref=f6e122]: + - generic [ref=f6e123]: Card number * + - textbox "Card number" [ref=f6e124]: + - /placeholder: 4242 4242 4242 4242 + - generic [ref=f6e125]: + - generic [ref=f6e126]: Cardholder name * + - textbox "Cardholder name" [ref=f6e127] + - generic [ref=f6e128]: + - generic [ref=f6e129]: + - generic [ref=f6e130]: Expiry (MM/YY) * + - textbox "Expiry (MM/YY)" [ref=f6e131]: + - /placeholder: 12/28 + - generic [ref=f6e132]: + - generic [ref=f6e133]: CVC * + - textbox "CVC" [ref=f6e134]: + - /placeholder: "123" + - button "Pay now - 29.98 EUR" [ref=f6e135] + - complementary "Order summary" [ref=f6e51]: + - generic [ref=f6e52]: + - heading "Order Summary" [level=2] [ref=f6e53] + - list [ref=f6e54]: + - listitem [ref=f6e55]: + - generic [ref=f6e59]: + - paragraph [ref=f6e60]: Classic Cotton T-Shirt ×1 + - paragraph [ref=f6e61]: S / White + - paragraph [ref=f6e62]: 24.99 EUR + - generic [ref=f6e64]: + - generic [ref=f6e65]: Discount code + - textbox "Discount code" [ref=f6e66] + - button "Apply" [ref=f6e67] + - generic [ref=f6e68]: + - generic [ref=f6e69]: + - term [ref=f6e70]: Subtotal + - definition [ref=f6e71]: 24.99 EUR + - generic [ref=f6e72]: + - term [ref=f6e73]: Shipping + - definition [ref=f6e74]: 4.99 EUR + - generic [ref=f6e75]: + - term [ref=f6e76]: Tax + - definition [ref=f6e77]: 4.79 EUR + - generic [ref=f6e78]: + - term [ref=f6e79]: Total + - definition [ref=f6e80]: 29.98 EUR + - contentinfo [ref=f6e81]: + - generic [ref=f6e82]: + - generic [ref=f6e83]: + - generic [ref=f6e84]: + - heading "Shop" [level=2] [ref=f6e85] + - list [ref=f6e86]: + - listitem [ref=f6e87]: + - link "About Us" [ref=f6e88] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f6e89]: + - link "FAQ" [ref=f6e90] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f6e91]: + - link "Shipping & Returns" [ref=f6e92] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f6e93]: + - link "Privacy Policy" [ref=f6e94] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f6e95]: + - link "Terms of Service" [ref=f6e96] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f6e97]: + - heading "Acme Fashion" [level=2] [ref=f6e98] + - paragraph [ref=f6e99]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f6e100]: + - paragraph [ref=f6e101]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f6e102]: + - generic [ref=f6e103]: VISA + - generic [ref=f6e104]: MASTERCARD + - generic [ref=f6e105]: AMEX + - generic [ref=f6e106]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T21-41-22-721Z.yml b/.playwright-mcp/page-2026-07-26T21-41-22-721Z.yml new file mode 100644 index 00000000..4a7c73c5 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T21-41-22-721Z.yml @@ -0,0 +1,97 @@ +- generic [active] [ref=f7e1]: + - link "Skip to main content" [ref=f7e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f7e4]: + - paragraph [ref=f7e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f7e6] + - banner [ref=f7e9]: + - generic [ref=f7e10]: + - link "Acme Fashion" [ref=f7e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f7e13]: + - link "Home" [ref=f7e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f7e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f7e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f7e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f7e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f7e19]: + - button "Search" [ref=f7e20] + - link "Account" [ref=f7e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f7e26] + - main [ref=f7e29]: + - generic [ref=f7e30]: + - generic [ref=f7e31]: + - heading "Thank you for your order!" [level=1] [ref=f7e35] + - paragraph [ref=f7e36]: "Order #1016" + - paragraph [ref=f7e37]: We've sent a confirmation to jane@example.com + - region [ref=f7e38]: + - heading "Order Summary" [level=2] [ref=f7e39] + - list [ref=f7e40]: + - listitem [ref=f7e41]: + - generic [ref=f7e42]: + - paragraph [ref=f7e43]: Classic Cotton T-Shirt - S / White + - paragraph [ref=f7e44]: "SKU: ACME-CTSH-S-WHT" + - paragraph [ref=f7e45]: ×1 + - paragraph [ref=f7e46]: 24.99 EUR + - generic [ref=f7e47]: + - region [ref=f7e48]: + - heading "Shipping Address" [level=2] [ref=f7e49] + - generic [ref=f7e50]: Jane Doe 123 Main St 10115 Berlin DE + - region [ref=f7e51]: + - heading "Payment Method" [level=2] [ref=f7e52] + - paragraph [ref=f7e53]: Credit Card ending in 4242 + - generic [ref=f7e54]: + - generic [ref=f7e55]: + - term [ref=f7e56]: Subtotal + - definition [ref=f7e57]: 24.99 EUR + - generic [ref=f7e58]: + - term [ref=f7e59]: Shipping + - definition [ref=f7e60]: 4.99 EUR + - generic [ref=f7e61]: + - term [ref=f7e62]: Tax + - definition [ref=f7e63]: 4.79 EUR + - generic [ref=f7e64]: + - term [ref=f7e65]: Total + - definition [ref=f7e66]: 29.98 EUR + - generic [ref=f7e67]: + - link "Continue shopping" [ref=f7e68] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "View order status" [ref=f7e69] [cursor=pointer]: + - /url: /api/storefront/v1/orders/%231016?token=bc7fc02b48bc4b0591260b51eaddfbb23a69e5db1858694c5e0a7f0b30c92c3f + - contentinfo [ref=f7e70]: + - generic [ref=f7e71]: + - generic [ref=f7e72]: + - generic [ref=f7e73]: + - heading "Shop" [level=2] [ref=f7e74] + - list [ref=f7e75]: + - listitem [ref=f7e76]: + - link "About Us" [ref=f7e77] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f7e78]: + - link "FAQ" [ref=f7e79] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f7e80]: + - link "Shipping & Returns" [ref=f7e81] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f7e82]: + - link "Privacy Policy" [ref=f7e83] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f7e84]: + - link "Terms of Service" [ref=f7e85] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f7e86]: + - heading "Acme Fashion" [level=2] [ref=f7e87] + - paragraph [ref=f7e88]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f7e89]: + - paragraph [ref=f7e90]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f7e91]: + - generic [ref=f7e92]: VISA + - generic [ref=f7e93]: MASTERCARD + - generic [ref=f7e94]: AMEX + - generic [ref=f7e95]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T21-41-59-723Z.yml b/.playwright-mcp/page-2026-07-26T21-41-59-723Z.yml new file mode 100644 index 00000000..8e69b5b8 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T21-41-59-723Z.yml @@ -0,0 +1,177 @@ +- generic [active] [ref=f8e1]: + - link "Skip to main content" [ref=f8e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f8e3]: + - complementary "Admin navigation" [ref=f8e4]: + - generic [ref=f8e5]: + - link "Acme Fashion" [ref=f8e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f8e12]: + - navigation [ref=f8e13]: + - link "Dashboard" [ref=f8e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f8e19]: Products + - navigation [ref=f8e20]: + - link "Products" [ref=f8e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f8e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f8e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f8e36]: Orders + - navigation [ref=f8e37]: + - link "Orders" [ref=f8e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f8e43]: Customers + - navigation [ref=f8e44]: + - link "Customers" [ref=f8e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f8e50]: Discounts + - navigation [ref=f8e51]: + - link "Discounts" [ref=f8e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f8e58]: Content + - navigation [ref=f8e59]: + - link "Pages" [ref=f8e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f8e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f8e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f8e75]: + - link "Analytics" [ref=f8e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f8e82]: Settings + - navigation [ref=f8e83]: + - link "Settings" [ref=f8e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f8e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f8e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f8e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f8e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f8e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f8e115]: + - banner [ref=f8e116]: + - button "Acme Fashion" [ref=f8e118] + - button "Notifications" [ref=f8e123] + - button "AU Admin User" [ref=f8e127]: + - generic [ref=f8e128]: AU + - generic [ref=f8e131]: Admin User + - main [ref=f8e135]: + - generic [ref=f8e136]: + - generic [ref=f8e137]: Home + - generic [ref=f8e141]: Dashboard + - generic [ref=f8e143]: + - generic [ref=f8e144]: + - heading "Dashboard" [level=1] [ref=f8e145] + - combobox "Date range" [ref=f8e146]: + - option "Last 7 days" + - option "Last 30 days" [selected] + - option "Last 90 days" + - generic [ref=f8e147]: + - generic [ref=f8e148]: + - paragraph [ref=f8e149]: Total Sales + - generic [ref=f8e150]: 1,547.10 EUR + - generic [ref=f8e151]: + - paragraph [ref=f8e152]: Orders + - generic [ref=f8e153]: "16" + - generic [ref=f8e154]: + - paragraph [ref=f8e155]: Avg. Order Value + - generic [ref=f8e156]: 96.69 EUR + - generic [ref=f8e157]: + - paragraph [ref=f8e158]: Conversion Rate + - generic [ref=f8e159]: 44.4% + - generic [ref=f8e160]: + - heading "Orders over time" [level=2] [ref=f8e161] + - generic [ref=f8e162]: + - img "Daily order counts for the selected period" [ref=f8e163] + - generic [ref=f8e165]: + - generic [ref=f8e166]: 2026-06-27 + - generic [ref=f8e167]: 2026-07-26 + - generic [ref=f8e168]: + - heading "Recent orders" [level=2] [ref=f8e169] + - table [ref=f8e171]: + - rowgroup [ref=f8e172]: + - row [ref=f8e173]: + - columnheader "Order" [ref=f8e174] + - columnheader "Date" [ref=f8e175] + - columnheader "Customer" [ref=f8e176] + - columnheader "Payment" [ref=f8e177] + - columnheader "Fulfillment" [ref=f8e178] + - columnheader "Total" [ref=f8e179] + - rowgroup [ref=f8e180]: + - row [ref=f8e181]: + - cell "#1016" [ref=f8e182] + - cell "Jul 26, 2026" [ref=f8e183] + - cell "Jane Smith" [ref=f8e184] + - cell "Paid" [ref=f8e185] + - cell "Unfulfilled" [ref=f8e187] + - cell "29.98 EUR" [ref=f8e189] + - row [ref=f8e190]: + - cell "#1015" [ref=f8e191] + - cell "Jul 26, 2026" [ref=f8e192] + - cell "John Doe" [ref=f8e193] + - cell "Paid" [ref=f8e194] + - cell "Unfulfilled" [ref=f8e196] + - cell "54.47 EUR" [ref=f8e198] + - row [ref=f8e199]: + - cell "#1005" [ref=f8e200] + - cell "Jul 26, 2026" [ref=f8e201] + - cell "Jane Smith" [ref=f8e202] + - cell "Pending" [ref=f8e203] + - cell "Unfulfilled" [ref=f8e205] + - cell "39.98 EUR" [ref=f8e207] + - row [ref=f8e208]: + - cell "#1013" [ref=f8e209] + - cell "Jul 25, 2026" [ref=f8e210] + - cell "Robert Martinez" [ref=f8e211] + - cell "Paid" [ref=f8e212] + - cell "Unfulfilled" [ref=f8e214] + - cell "84.97 EUR" [ref=f8e216] + - row [ref=f8e217]: + - cell "#1010" [ref=f8e218] + - cell "Jul 25, 2026" [ref=f8e219] + - cell "John Doe" [ref=f8e220] + - cell "Paid" [ref=f8e221] + - cell "Unfulfilled" [ref=f8e223] + - cell "504.98 EUR" [ref=f8e225] + - row [ref=f8e226]: + - cell "#1006" [ref=f8e227] + - cell "Jul 25, 2026" [ref=f8e228] + - cell "Michael Brown" [ref=f8e229] + - cell "Paid" [ref=f8e230] + - cell "Unfulfilled" [ref=f8e232] + - cell "124.98 EUR" [ref=f8e234] + - row [ref=f8e235]: + - cell "#1001" [ref=f8e236] + - cell "Jul 24, 2026" [ref=f8e237] + - cell "John Doe" [ref=f8e238] + - cell "Paid" [ref=f8e239] + - cell "Unfulfilled" [ref=f8e241] + - cell "54.97 EUR" [ref=f8e243] + - row [ref=f8e244]: + - cell "#1009" [ref=f8e245] + - cell "Jul 23, 2026" [ref=f8e246] + - cell "Emma Garcia" [ref=f8e247] + - cell "Paid" [ref=f8e248] + - cell "Unfulfilled" [ref=f8e250] + - cell "49.97 EUR" [ref=f8e252] + - row [ref=f8e253]: + - cell "#1012" [ref=f8e254] + - cell "Jul 22, 2026" [ref=f8e255] + - cell "Lisa Anderson" [ref=f8e256] + - cell "Paid" [ref=f8e257] + - cell "Unfulfilled" [ref=f8e259] + - cell "84.97 EUR" [ref=f8e261] + - row [ref=f8e262]: + - cell "#1003" [ref=f8e263] + - cell "Jul 21, 2026" [ref=f8e264] + - cell "Jane Smith" [ref=f8e265] + - cell "Paid" [ref=f8e266] + - cell "Partial" [ref=f8e268] + - cell "119.97 EUR" [ref=f8e270] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T21-42-34-646Z.yml b/.playwright-mcp/page-2026-07-26T21-42-34-646Z.yml new file mode 100644 index 00000000..4a111764 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T21-42-34-646Z.yml @@ -0,0 +1,311 @@ +- generic [active] [ref=f9e1]: + - link "Skip to main content" [ref=f9e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f9e3]: + - complementary "Admin navigation" [ref=f9e4]: + - generic [ref=f9e5]: + - link "Acme Fashion" [ref=f9e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f9e12]: + - navigation [ref=f9e13]: + - link "Dashboard" [ref=f9e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f9e19]: Products + - navigation [ref=f9e20]: + - link "Products" [ref=f9e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f9e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f9e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f9e36]: Orders + - navigation [ref=f9e37]: + - link "Orders" [ref=f9e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f9e43]: Customers + - navigation [ref=f9e44]: + - link "Customers" [ref=f9e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f9e50]: Discounts + - navigation [ref=f9e51]: + - link "Discounts" [ref=f9e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f9e58]: Content + - navigation [ref=f9e59]: + - link "Pages" [ref=f9e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f9e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f9e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f9e75]: + - link "Analytics" [ref=f9e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f9e82]: Settings + - navigation [ref=f9e83]: + - link "Settings" [ref=f9e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f9e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f9e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f9e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f9e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f9e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f9e115]: + - banner [ref=f9e116]: + - button "Acme Fashion" [ref=f9e118] + - button "Notifications" [ref=f9e123] + - button "AU Admin User" [ref=f9e127]: + - generic [ref=f9e128]: AU + - generic [ref=f9e131]: Admin User + - main [ref=f9e135]: + - generic [ref=f9e136]: + - link "Home" [ref=f9e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f9e141]: Products + - generic [ref=f9e143]: + - generic [ref=f9e144]: + - generic [ref=f9e145]: Products + - link "Add product" [ref=f9e146] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/create + - generic [ref=f9e150]: + - textbox "Search products" [ref=f9e152]: + - /placeholder: Search products... + - tablist "Status filter" [ref=f9e154]: + - tab "All" [selected] [ref=f9e155] + - tab "Draft" [ref=f9e156] + - tab "Active" [ref=f9e157] + - tab "Archived" [ref=f9e158] + - combobox "Product type filter" [ref=f9e159]: + - option "All types" [selected] + - option "Accessories" + - option "Gift Cards" + - option "Hoodies" + - option "Jackets" + - option "Pants" + - option "Shoes" + - option "T-Shirts" + - table [ref=f9e161]: + - rowgroup [ref=f9e162]: + - row [ref=f9e163]: + - columnheader [ref=f9e164]: + - checkbox "Select all products" [ref=f9e165] + - columnheader "Image" [ref=f9e167] + - columnheader [ref=f9e169]: + - button "Title" [ref=f9e170] + - columnheader "Status" [ref=f9e171] + - columnheader [ref=f9e172]: + - button "Inventory" [ref=f9e173] + - columnheader "Variants" [ref=f9e174] + - columnheader "Type" [ref=f9e175] + - columnheader "Vendor" [ref=f9e176] + - columnheader [ref=f9e177]: + - button "Updated" [ref=f9e178] + - rowgroup [ref=f9e181]: + - row [ref=f9e182]: + - cell [ref=f9e183]: + - checkbox "Select Gift Card" [ref=f9e184] + - cell [ref=f9e186] + - cell [ref=f9e190]: + - link "Gift Card" [ref=f9e191] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/19/edit + - cell "Active" [ref=f9e192] + - cell "29997" [ref=f9e194] + - cell "3" [ref=f9e195] + - cell "Gift Cards" [ref=f9e196] + - cell "Acme Fashion" [ref=f9e197] + - cell "7 minutes ago" [ref=f9e198] + - row [ref=f9e199]: + - cell [ref=f9e200]: + - checkbox "Select Cashmere Overcoat" [ref=f9e201] + - cell [ref=f9e203] + - cell [ref=f9e207]: + - link "Cashmere Overcoat" [ref=f9e208] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/20/edit + - cell "Active" [ref=f9e209] + - cell "18" [ref=f9e211] + - cell "6" [ref=f9e212] + - cell "Jackets" [ref=f9e213] + - cell "Acme Premium" [ref=f9e214] + - cell "7 minutes ago" [ref=f9e215] + - row [ref=f9e216]: + - cell [ref=f9e217]: + - checkbox "Select Leather Belt" [ref=f9e218] + - cell [ref=f9e220] + - cell [ref=f9e224]: + - link "Leather Belt" [ref=f9e225] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/4/edit + - cell "Active" [ref=f9e226] + - cell "100" [ref=f9e228] + - cell "4" [ref=f9e229] + - cell "Accessories" [ref=f9e230] + - cell "Acme Accessories" [ref=f9e231] + - cell "7 minutes ago" [ref=f9e232] + - row [ref=f9e233]: + - cell [ref=f9e234]: + - checkbox "Select Wool Scarf" [ref=f9e235] + - cell [ref=f9e237] + - cell [ref=f9e241]: + - link "Wool Scarf" [ref=f9e242] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/12/edit + - cell "Active" [ref=f9e243] + - cell "90" [ref=f9e245] + - cell "3" [ref=f9e246] + - cell "Accessories" [ref=f9e247] + - cell "Acme Accessories" [ref=f9e248] + - cell "7 minutes ago" [ref=f9e249] + - row [ref=f9e250]: + - cell [ref=f9e251]: + - checkbox "Select Canvas Tote Bag" [ref=f9e252] + - cell [ref=f9e254] + - cell [ref=f9e258]: + - link "Canvas Tote Bag" [ref=f9e259] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/13/edit + - cell "Active" [ref=f9e260] + - cell "80" [ref=f9e262] + - cell "2" [ref=f9e263] + - cell "Accessories" [ref=f9e264] + - cell "Acme Accessories" [ref=f9e265] + - cell "7 minutes ago" [ref=f9e266] + - row [ref=f9e267]: + - cell [ref=f9e268]: + - checkbox "Select Bucket Hat" [ref=f9e269] + - cell [ref=f9e271] + - cell [ref=f9e275]: + - link "Bucket Hat" [ref=f9e276] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/14/edit + - cell "Active" [ref=f9e277] + - cell "132" [ref=f9e279] + - cell "6" [ref=f9e280] + - cell "Accessories" [ref=f9e281] + - cell "Acme Accessories" [ref=f9e282] + - cell "7 minutes ago" [ref=f9e283] + - row [ref=f9e284]: + - cell [ref=f9e285]: + - checkbox "Select Organic Hoodie" [ref=f9e286] + - cell [ref=f9e288] + - cell [ref=f9e292]: + - link "Organic Hoodie" [ref=f9e293] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/3/edit + - cell "Active" [ref=f9e294] + - cell "80" [ref=f9e296] + - cell "4" [ref=f9e297] + - cell "Hoodies" [ref=f9e298] + - cell "Acme Basics" [ref=f9e299] + - cell "7 minutes ago" [ref=f9e300] + - row [ref=f9e301]: + - cell [ref=f9e302]: + - checkbox "Select Unreleased Winter Jacket" [ref=f9e303] + - cell [ref=f9e305] + - cell [ref=f9e309]: + - link "Unreleased Winter Jacket" [ref=f9e310] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/15/edit + - cell "Draft" [ref=f9e311] + - cell "0" [ref=f9e313] + - cell "4" [ref=f9e314] + - cell "Jackets" [ref=f9e315] + - cell "Acme Outerwear" [ref=f9e316] + - cell "7 minutes ago" [ref=f9e317] + - row [ref=f9e318]: + - cell [ref=f9e319]: + - checkbox "Select Discontinued Raincoat" [ref=f9e320] + - cell [ref=f9e322] + - cell [ref=f9e326]: + - link "Discontinued Raincoat" [ref=f9e327] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/16/edit + - cell "Archived" [ref=f9e328] + - cell "6" [ref=f9e330] + - cell "2" [ref=f9e331] + - cell "Jackets" [ref=f9e332] + - cell "Acme Outerwear" [ref=f9e333] + - cell "7 minutes ago" [ref=f9e334] + - row [ref=f9e335]: + - cell [ref=f9e336]: + - checkbox "Select Backorder Denim Jacket" [ref=f9e337] + - cell [ref=f9e339] + - cell [ref=f9e343]: + - link "Backorder Denim Jacket" [ref=f9e344] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/18/edit + - cell "Active" [ref=f9e345] + - cell "0" [ref=f9e347] + - cell "4" [ref=f9e348] + - cell "Jackets" [ref=f9e349] + - cell "Acme Denim" [ref=f9e350] + - cell "7 minutes ago" [ref=f9e351] + - row [ref=f9e352]: + - cell [ref=f9e353]: + - checkbox "Select Premium Slim Fit Jeans" [ref=f9e354] + - cell [ref=f9e356] + - cell [ref=f9e360]: + - link "Premium Slim Fit Jeans" [ref=f9e361] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/2/edit + - cell "Active" [ref=f9e362] + - cell "80" [ref=f9e364] + - cell "10" [ref=f9e365] + - cell "Pants" [ref=f9e366] + - cell "Acme Denim" [ref=f9e367] + - cell "7 minutes ago" [ref=f9e368] + - row [ref=f9e369]: + - cell [ref=f9e370]: + - checkbox "Select Cargo Pants" [ref=f9e371] + - cell [ref=f9e373] + - cell [ref=f9e377]: + - link "Cargo Pants" [ref=f9e378] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/9/edit + - cell "Active" [ref=f9e379] + - cell "168" [ref=f9e381] + - cell "12" [ref=f9e382] + - cell "Pants" [ref=f9e383] + - cell "Acme Workwear" [ref=f9e384] + - cell "7 minutes ago" [ref=f9e385] + - row [ref=f9e386]: + - cell [ref=f9e387]: + - checkbox "Select Chino Shorts" [ref=f9e388] + - cell [ref=f9e390] + - cell [ref=f9e394]: + - link "Chino Shorts" [ref=f9e395] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/10/edit + - cell "Active" [ref=f9e396] + - cell "128" [ref=f9e398] + - cell "8" [ref=f9e399] + - cell "Pants" [ref=f9e400] + - cell "Acme Basics" [ref=f9e401] + - cell "7 minutes ago" [ref=f9e402] + - row [ref=f9e403]: + - cell [ref=f9e404]: + - checkbox "Select Wide Leg Trousers" [ref=f9e405] + - cell [ref=f9e407] + - cell [ref=f9e411]: + - link "Wide Leg Trousers" [ref=f9e412] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/11/edit + - cell "Active" [ref=f9e413] + - cell "21" [ref=f9e415] + - cell "3" [ref=f9e416] + - cell "Pants" [ref=f9e417] + - cell "Acme Denim" [ref=f9e418] + - cell "7 minutes ago" [ref=f9e419] + - row [ref=f9e420]: + - cell [ref=f9e421]: + - checkbox "Select Running Sneakers" [ref=f9e422] + - cell [ref=f9e424] + - cell [ref=f9e428]: + - link "Running Sneakers" [ref=f9e429] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products/5/edit + - cell "Active" [ref=f9e430] + - cell "70" [ref=f9e432] + - cell "14" [ref=f9e433] + - cell "Shoes" [ref=f9e434] + - cell "Acme Sport" [ref=f9e435] + - cell "7 minutes ago" [ref=f9e436] + - navigation "Pagination Navigation" [ref=f9e438]: + - generic [ref=f9e439]: + - paragraph [ref=f9e441]: Showing 1 to 15 of 20 results + - generic [ref=f9e443]: + - generic "« Previous" [ref=f9e445] + - generic [ref=f9e449]: "1" + - button "Go to page 2" [ref=f9e453]: "2" + - button "Next »" [ref=f9e455] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T21-43-08-380Z.yml b/.playwright-mcp/page-2026-07-26T21-43-08-380Z.yml new file mode 100644 index 00000000..117523fb --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T21-43-08-380Z.yml @@ -0,0 +1,22 @@ +- generic [active] [ref=f10e1]: + - link "Skip to main content" [ref=f10e2] [cursor=pointer]: + - /url: "#main-content" + - banner [ref=f10e3]: + - link "Acme Fashion" [ref=f10e5] [cursor=pointer]: + - /url: / + - main [ref=f10e6]: + - generic [ref=f10e7]: + - paragraph: "403" + - generic [ref=f10e8]: + - heading "Access denied" [level=1] [ref=f10e9] + - paragraph [ref=f10e10]: This action is unauthorized. + - link "Go to home page" [ref=f10e11] [cursor=pointer]: + - /url: / + - contentinfo [ref=f10e12]: + - navigation "Helpful links" [ref=f10e13]: + - link "Home" [ref=f10e14] [cursor=pointer]: + - /url: / + - link "Collections" [ref=f10e15] [cursor=pointer]: + - /url: /collections + - link "Search" [ref=f10e16] [cursor=pointer]: + - /url: /search \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T21-43-28-251Z.yml b/.playwright-mcp/page-2026-07-26T21-43-28-251Z.yml new file mode 100644 index 00000000..542a53a7 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T21-43-28-251Z.yml @@ -0,0 +1,144 @@ +- generic [active] [ref=f11e1]: + - link "Skip to main content" [ref=f11e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f11e3]: + - complementary "Admin navigation" [ref=f11e4]: + - generic [ref=f11e5]: + - link "Acme Fashion" [ref=f11e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f11e12]: + - navigation [ref=f11e13]: + - link "Dashboard" [ref=f11e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f11e19]: Products + - navigation [ref=f11e20]: + - link "Products" [ref=f11e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f11e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f11e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f11e36]: Orders + - navigation [ref=f11e37]: + - link "Orders" [ref=f11e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f11e43]: Customers + - navigation [ref=f11e44]: + - link "Customers" [ref=f11e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f11e50]: Discounts + - navigation [ref=f11e51]: + - link "Discounts" [ref=f11e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f11e58]: Content + - navigation [ref=f11e59]: + - link "Pages" [ref=f11e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f11e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f11e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f11e75]: + - link "Analytics" [ref=f11e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f11e82]: Settings + - navigation [ref=f11e83]: + - link "Settings" [ref=f11e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f11e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f11e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f11e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f11e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f11e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f11e115]: + - banner [ref=f11e116]: + - button "Acme Fashion" [ref=f11e118] + - button "Notifications" [ref=f11e123] + - button "AU Admin User" [ref=f11e127]: + - generic [ref=f11e128]: AU + - generic [ref=f11e131]: Admin User + - main [ref=f11e135]: + - generic [ref=f11e136]: + - link "Home" [ref=f11e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - link "Orders" [ref=f11e142] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - generic [ref=f11e145]: "#1016" + - generic [ref=f11e147]: + - generic [ref=f11e148]: + - generic [ref=f11e149]: "#1016" + - generic [ref=f11e150]: Paid + - generic [ref=f11e151]: Unfulfilled + - paragraph [ref=f11e152]: Jul 26, 2026 9:41 PM + - generic [ref=f11e153]: + - button "Create fulfillment" [ref=f11e154] + - button "Refund" [ref=f11e160] + - button "Cancel order" [ref=f11e166] + - generic [ref=f11e172]: + - generic [ref=f11e173]: + - generic [ref=f11e174]: + - generic [ref=f11e175]: Timeline + - list [ref=f11e176]: + - listitem [ref=f11e177]: + - paragraph [ref=f11e179]: Order placed + - paragraph [ref=f11e180]: Jul 26, 2026 9:41 PM + - listitem [ref=f11e181]: + - paragraph [ref=f11e183]: Payment received + - paragraph [ref=f11e184]: Jul 26, 2026 9:41 PM + - generic [ref=f11e185]: + - generic [ref=f11e186]: Order lines + - table [ref=f11e188]: + - rowgroup [ref=f11e189]: + - row [ref=f11e190]: + - columnheader "Image" [ref=f11e191] + - columnheader "Product" [ref=f11e193] + - columnheader "Qty" [ref=f11e194] + - columnheader "Unit price" [ref=f11e195] + - columnheader "Total" [ref=f11e196] + - rowgroup [ref=f11e197]: + - row [ref=f11e198]: + - cell [ref=f11e199] + - 'cell "Classic Cotton T-Shirt - S / White SKU: ACME-CTSH-S-WHT" [ref=f11e203]': + - generic [ref=f11e204]: Classic Cotton T-Shirt - S / White + - generic [ref=f11e205]: "SKU: ACME-CTSH-S-WHT" + - cell "1" [ref=f11e206] + - cell "24.99 EUR" [ref=f11e207] + - cell "24.99 EUR" [ref=f11e208] + - generic [ref=f11e209]: + - generic [ref=f11e210]: + - generic [ref=f11e211]: Subtotal + - generic [ref=f11e212]: 24.99 EUR + - generic [ref=f11e213]: + - generic [ref=f11e214]: Shipping + - generic [ref=f11e215]: 4.99 EUR + - generic [ref=f11e216]: + - generic [ref=f11e217]: Tax + - generic [ref=f11e218]: 4.79 EUR + - generic [ref=f11e219]: + - generic [ref=f11e220]: Total + - generic [ref=f11e221]: 29.98 EUR + - generic [ref=f11e222]: + - generic [ref=f11e223]: Payment details + - generic [ref=f11e225]: + - generic [ref=f11e226]: + - paragraph [ref=f11e227]: Credit Card + - paragraph [ref=f11e228]: "29.98 EUR · Ref: mock_kGFTit2meXG1Zddw · Jul 26, 2026 9:41 PM" + - generic [ref=f11e229]: Captured + - generic [ref=f11e230]: + - generic [ref=f11e231]: + - generic [ref=f11e232]: Customer + - paragraph [ref=f11e233]: Jane Smith + - paragraph [ref=f11e234]: jane@example.com + - link "View customer" [ref=f11e236] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers/2 + - generic [ref=f11e237]: + - generic [ref=f11e238]: Shipping address + - generic [ref=f11e239]: Jane Doe 123 Main St Berlin 10115 DE + - generic [ref=f11e240]: + - generic [ref=f11e241]: Billing address + - generic [ref=f11e242]: Jane Doe 123 Main St Berlin 10115 DE \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T21-44-09-949Z.yml b/.playwright-mcp/page-2026-07-26T21-44-09-949Z.yml new file mode 100644 index 00000000..a8566cd5 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T21-44-09-949Z.yml @@ -0,0 +1,201 @@ +- generic [active] [ref=f12e1]: + - link "Skip to main content" [ref=f12e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f12e3]: + - complementary "Admin navigation" [ref=f12e4]: + - generic [ref=f12e5]: + - link "Acme Fashion" [ref=f12e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f12e12]: + - navigation [ref=f12e13]: + - link "Dashboard" [ref=f12e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f12e19]: Products + - navigation [ref=f12e20]: + - link "Products" [ref=f12e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f12e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f12e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f12e36]: Orders + - navigation [ref=f12e37]: + - link "Orders" [ref=f12e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f12e43]: Customers + - navigation [ref=f12e44]: + - link "Customers" [ref=f12e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f12e50]: Discounts + - navigation [ref=f12e51]: + - link "Discounts" [ref=f12e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f12e58]: Content + - navigation [ref=f12e59]: + - link "Pages" [ref=f12e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f12e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f12e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f12e75]: + - link "Analytics" [ref=f12e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f12e82]: Settings + - navigation [ref=f12e83]: + - link "Settings" [ref=f12e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f12e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f12e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f12e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f12e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f12e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f12e115]: + - banner [ref=f12e116]: + - button "Acme Fashion" [ref=f12e118] + - button "Notifications" [ref=f12e123] + - button "AU Admin User" [ref=f12e127]: + - generic [ref=f12e128]: AU + - generic [ref=f12e131]: Admin User + - main [ref=f12e135]: + - generic [ref=f12e136]: + - link "Home" [ref=f12e138] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - generic [ref=f12e141]: Analytics + - generic [ref=f12e143]: + - generic [ref=f12e144]: + - generic [ref=f12e145]: Analytics + - combobox "Date range" [ref=f12e146]: + - option "Last 7 days" + - option "Last 30 days" [selected] + - option "Last 90 days" + - generic [ref=f12e147]: + - generic [ref=f12e148]: + - paragraph [ref=f12e149]: Total Sales + - generic [ref=f12e150]: 9,120.58 EUR + - generic [ref=f12e151]: + - paragraph [ref=f12e152]: Orders + - generic [ref=f12e153]: "147" + - generic [ref=f12e154]: + - paragraph [ref=f12e155]: Avg. Order Value + - generic [ref=f12e156]: 62.04 EUR + - generic [ref=f12e157]: + - paragraph [ref=f12e158]: Conversion Rate + - generic [ref=f12e159]: 4.3% + - generic [ref=f12e160]: + - generic [ref=f12e161]: Sales over time + - generic [ref=f12e162]: + - img "Daily revenue for the selected period" [ref=f12e163] + - generic [ref=f12e165]: + - generic [ref=f12e166]: 2026-06-27 + - generic [ref=f12e167]: 2026-07-26 + - generic [ref=f12e168]: + - generic [ref=f12e169]: + - generic [ref=f12e170]: Visits over time + - generic [ref=f12e171]: + - img "Daily visits for the selected period" [ref=f12e172] + - generic [ref=f12e174]: + - generic [ref=f12e175]: 2026-06-27 + - generic [ref=f12e176]: 2026-07-26 + - generic [ref=f12e177]: + - generic [ref=f12e178]: Conversion funnel + - generic [ref=f12e179]: + - generic [ref=f12e180]: + - generic [ref=f12e181]: + - paragraph [ref=f12e182]: Visits + - paragraph [ref=f12e183]: 3,387 (100%) + - 'img "Visits: 3387" [ref=f12e184]' + - generic [ref=f12e186]: + - generic [ref=f12e187]: + - paragraph [ref=f12e188]: Added to cart + - paragraph [ref=f12e189]: 749 (22.1%) + - 'img "Added to cart: 749" [ref=f12e190]' + - generic [ref=f12e192]: + - generic [ref=f12e193]: + - paragraph [ref=f12e194]: Checkout started + - paragraph [ref=f12e195]: 352 (10.4%) + - 'img "Checkout started: 352" [ref=f12e196]' + - generic [ref=f12e198]: + - generic [ref=f12e199]: + - paragraph [ref=f12e200]: Checkout completed + - paragraph [ref=f12e201]: 147 (4.3%) + - 'img "Checkout completed: 147" [ref=f12e202]' + - generic [ref=f12e204]: + - generic [ref=f12e205]: Top products + - table [ref=f12e207]: + - rowgroup [ref=f12e208]: + - row [ref=f12e209]: + - columnheader "Rank" [ref=f12e210] + - columnheader "Product" [ref=f12e211] + - columnheader "Units Sold" [ref=f12e212] + - columnheader "Revenue" [ref=f12e213] + - columnheader "% of Total" [ref=f12e214] + - rowgroup [ref=f12e215]: + - row [ref=f12e216]: + - cell "1" [ref=f12e217] + - cell "Cashmere Overcoat" [ref=f12e218] + - cell "1" [ref=f12e219] + - cell "499.99 EUR" [ref=f12e220] + - cell "33.8%" [ref=f12e221] + - row [ref=f12e222]: + - cell "2" [ref=f12e223] + - cell "Classic Cotton T-Shirt" [ref=f12e224] + - cell "5" [ref=f12e225] + - cell "124.95 EUR" [ref=f12e226] + - cell "8.5%" [ref=f12e227] + - row [ref=f12e228]: + - cell "3" [ref=f12e229] + - cell "Running Sneakers" [ref=f12e230] + - cell "1" [ref=f12e231] + - cell "119.99 EUR" [ref=f12e232] + - cell "8.1%" [ref=f12e233] + - row [ref=f12e234]: + - cell "4" [ref=f12e235] + - cell "Premium Slim Fit Jeans" [ref=f12e236] + - cell "1" [ref=f12e237] + - cell "79.99 EUR" [ref=f12e238] + - cell "5.4%" [ref=f12e239] + - row [ref=f12e240]: + - cell "5" [ref=f12e241] + - cell "Chino Shorts" [ref=f12e242] + - cell "2" [ref=f12e243] + - cell "79.98 EUR" [ref=f12e244] + - cell "5.4%" [ref=f12e245] + - row [ref=f12e246]: + - cell "6" [ref=f12e247] + - cell "Leather Belt" [ref=f12e248] + - cell "2" [ref=f12e249] + - cell "69.98 EUR" [ref=f12e250] + - cell "4.7%" [ref=f12e251] + - row [ref=f12e252]: + - cell "7" [ref=f12e253] + - cell "V-Neck Linen Tee" [ref=f12e254] + - cell "2" [ref=f12e255] + - cell "69.98 EUR" [ref=f12e256] + - cell "4.7%" [ref=f12e257] + - row [ref=f12e258]: + - cell "8" [ref=f12e259] + - cell "Organic Hoodie" [ref=f12e260] + - cell "1" [ref=f12e261] + - cell "59.99 EUR" [ref=f12e262] + - cell "4.1%" [ref=f12e263] + - row [ref=f12e264]: + - cell "9" [ref=f12e265] + - cell "Graphic Print Tee" [ref=f12e266] + - cell "2" [ref=f12e267] + - cell "59.98 EUR" [ref=f12e268] + - cell "4.1%" [ref=f12e269] + - row [ref=f12e270]: + - cell "10" [ref=f12e271] + - cell "Wool Scarf" [ref=f12e272] + - cell "2" [ref=f12e273] + - cell "59.98 EUR" [ref=f12e274] + - cell "4.1%" [ref=f12e275] + - generic [ref=f12e276]: + - generic [ref=f12e277]: Recent search queries + - paragraph [ref=f12e278]: No search queries yet. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T21-44-44-382Z.yml b/.playwright-mcp/page-2026-07-26T21-44-44-382Z.yml new file mode 100644 index 00000000..3628a164 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T21-44-44-382Z.yml @@ -0,0 +1,175 @@ +- generic [active] [ref=f13e1]: + - link "Skip to main content" [ref=f13e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f13e4]: + - paragraph [ref=f13e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f13e6] + - banner [ref=f13e9]: + - generic [ref=f13e10]: + - link "Acme Fashion" [ref=f13e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f13e13]: + - link "Home" [ref=f13e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f13e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f13e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f13e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f13e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f13e19]: + - button "Search" [ref=f13e20] + - link "Account" [ref=f13e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f13e26] + - main [ref=f13e29]: + - generic [ref=f13e30]: + - generic [ref=f13e34]: + - heading "Welcome to Acme Fashion" [level=1] [ref=f13e35] + - paragraph [ref=f13e36]: Discover our curated collection of modern essentials + - link "Shop New Arrivals" [ref=f13e37] [cursor=pointer]: + - /url: /collections/new-arrivals + - region [ref=f13e38]: + - heading "Featured collections" [level=2] [ref=f13e39] + - generic [ref=f13e40]: + - link "New Arrivals" [ref=f13e41] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - generic [ref=f13e43]: + - generic [ref=f13e44]: New Arrivals + - generic [ref=f13e45]: Shop now + - link "T-Shirts" [ref=f13e46] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - generic [ref=f13e48]: + - generic [ref=f13e49]: T-Shirts + - generic [ref=f13e50]: Shop now + - link "Sale" [ref=f13e51] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/sale + - generic [ref=f13e53]: + - generic [ref=f13e54]: Sale + - generic [ref=f13e55]: Shop now + - region [ref=f13e56]: + - heading "Featured products" [level=2] [ref=f13e57] + - generic [ref=f13e58]: + - generic [ref=f13e59]: + - link [ref=f13e61] [cursor=pointer]: + - /url: http://acme-fashion.test/products/gift-card + - link "Gift Card 25.00 EUR" [ref=f13e65] [cursor=pointer]: + - /url: http://acme-fashion.test/products/gift-card + - heading "Gift Card" [level=3] [ref=f13e66] + - generic [ref=f13e67]: 25.00 EUR + - link "Choose options" [ref=f13e71] [cursor=pointer]: + - /url: http://acme-fashion.test/products/gift-card + - generic [ref=f13e72]: + - link [ref=f13e74] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cashmere-overcoat + - link "Cashmere Overcoat 499.99 EUR" [ref=f13e78] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cashmere-overcoat + - heading "Cashmere Overcoat" [level=3] [ref=f13e79] + - generic [ref=f13e80]: 499.99 EUR + - link "Choose options" [ref=f13e84] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cashmere-overcoat + - generic [ref=f13e85]: + - link [ref=f13e87] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f13e91] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f13e92] + - generic [ref=f13e93]: 24.99 EUR + - link "Choose options" [ref=f13e97] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f13e98]: + - generic [ref=f13e99]: + - link [ref=f13e100] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f13e105]: + - generic [ref=f13e106]: "On sale:" + - text: Sale + - 'link "Premium Slim Fit Jeans 79.99 EUR 99.99 EUR On sale: Sale" [ref=f13e107] [cursor=pointer]': + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - heading "Premium Slim Fit Jeans" [level=3] [ref=f13e108] + - generic [ref=f13e110]: + - generic [ref=f13e111]: 79.99 EUR + - generic [ref=f13e112]: 99.99 EUR + - generic [ref=f13e113]: + - generic [ref=f13e114]: "On sale:" + - text: Sale + - link "Choose options" [ref=f13e116] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f13e117]: + - link [ref=f13e119] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - link "Organic Hoodie 59.99 EUR" [ref=f13e123] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - heading "Organic Hoodie" [level=3] [ref=f13e124] + - generic [ref=f13e125]: 59.99 EUR + - link "Choose options" [ref=f13e129] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - generic [ref=f13e130]: + - link [ref=f13e132] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - link "Leather Belt 34.99 EUR" [ref=f13e136] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - heading "Leather Belt" [level=3] [ref=f13e137] + - generic [ref=f13e138]: 34.99 EUR + - link "Choose options" [ref=f13e142] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - generic [ref=f13e143]: + - link [ref=f13e145] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - link "Running Sneakers 119.99 EUR" [ref=f13e149] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - heading "Running Sneakers" [level=3] [ref=f13e150] + - generic [ref=f13e151]: 119.99 EUR + - link "Choose options" [ref=f13e155] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - generic [ref=f13e156]: + - link [ref=f13e158] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f13e162] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f13e163] + - generic [ref=f13e164]: 29.99 EUR + - link "Choose options" [ref=f13e168] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - region [ref=f13e169]: + - generic [ref=f13e170]: + - heading "Stay in the loop" [level=2] [ref=f13e171] + - paragraph [ref=f13e172]: Subscribe for exclusive offers and updates. + - generic [ref=f13e174]: + - generic [ref=f13e175]: Email address + - textbox "Email address" [ref=f13e176]: + - /placeholder: Enter your email + - button "Subscribe" [ref=f13e177] + - contentinfo [ref=f13e178]: + - generic [ref=f13e179]: + - generic [ref=f13e180]: + - generic [ref=f13e181]: + - heading "Shop" [level=2] [ref=f13e182] + - list [ref=f13e183]: + - listitem [ref=f13e184]: + - link "About Us" [ref=f13e185] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f13e186]: + - link "FAQ" [ref=f13e187] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f13e188]: + - link "Shipping & Returns" [ref=f13e189] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f13e190]: + - link "Privacy Policy" [ref=f13e191] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f13e192]: + - link "Terms of Service" [ref=f13e193] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f13e194]: + - heading "Acme Fashion" [level=2] [ref=f13e195] + - paragraph [ref=f13e196]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f13e197]: + - paragraph [ref=f13e198]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f13e199]: + - generic [ref=f13e200]: VISA + - generic [ref=f13e201]: MASTERCARD + - generic [ref=f13e202]: AMEX + - generic [ref=f13e203]: PAYPAL \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T21-44-53-028Z.yml b/.playwright-mcp/page-2026-07-26T21-44-53-028Z.yml new file mode 100644 index 00000000..d223312e --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T21-44-53-028Z.yml @@ -0,0 +1,182 @@ +- generic [ref=f13e1]: + - link "Skip to main content" [ref=f13e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f13e4]: + - paragraph [ref=f13e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f13e6] + - banner [ref=f13e9]: + - generic [ref=f13e10]: + - link "Acme Fashion" [ref=f13e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f13e13]: + - link "Home" [ref=f13e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f13e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f13e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f13e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f13e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f13e19]: + - button "Search" [ref=f13e20] + - link "Account" [ref=f13e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f13e26] + - main [ref=f13e29]: + - generic [ref=f13e30]: + - generic [ref=f13e34]: + - heading "Welcome to Acme Fashion" [level=1] [ref=f13e35] + - paragraph [ref=f13e36]: Discover our curated collection of modern essentials + - link "Shop New Arrivals" [ref=f13e37] [cursor=pointer]: + - /url: /collections/new-arrivals + - region [ref=f13e38]: + - heading "Featured collections" [level=2] [ref=f13e39] + - generic [ref=f13e40]: + - link "New Arrivals" [ref=f13e41] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - generic [ref=f13e43]: + - generic [ref=f13e44]: New Arrivals + - generic [ref=f13e45]: Shop now + - link "T-Shirts" [ref=f13e46] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - generic [ref=f13e48]: + - generic [ref=f13e49]: T-Shirts + - generic [ref=f13e50]: Shop now + - link "Sale" [ref=f13e51] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/sale + - generic [ref=f13e53]: + - generic [ref=f13e54]: Sale + - generic [ref=f13e55]: Shop now + - region [ref=f13e56]: + - heading "Featured products" [level=2] [ref=f13e57] + - generic [ref=f13e58]: + - generic [ref=f13e59]: + - link [ref=f13e61] [cursor=pointer]: + - /url: http://acme-fashion.test/products/gift-card + - link "Gift Card 25.00 EUR" [ref=f13e65] [cursor=pointer]: + - /url: http://acme-fashion.test/products/gift-card + - heading "Gift Card" [level=3] [ref=f13e66] + - generic [ref=f13e67]: 25.00 EUR + - link "Choose options" [ref=f13e71] [cursor=pointer]: + - /url: http://acme-fashion.test/products/gift-card + - generic [ref=f13e72]: + - link [ref=f13e74] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cashmere-overcoat + - link "Cashmere Overcoat 499.99 EUR" [ref=f13e78] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cashmere-overcoat + - heading "Cashmere Overcoat" [level=3] [ref=f13e79] + - generic [ref=f13e80]: 499.99 EUR + - link "Choose options" [ref=f13e84] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cashmere-overcoat + - generic [ref=f13e85]: + - link [ref=f13e87] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f13e91] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f13e92] + - generic [ref=f13e93]: 24.99 EUR + - link "Choose options" [ref=f13e97] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f13e98]: + - generic [ref=f13e99]: + - link [ref=f13e100] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f13e105]: + - generic [ref=f13e106]: "On sale:" + - text: Sale + - 'link "Premium Slim Fit Jeans 79.99 EUR 99.99 EUR On sale: Sale" [ref=f13e107] [cursor=pointer]': + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - heading "Premium Slim Fit Jeans" [level=3] [ref=f13e108] + - generic [ref=f13e110]: + - generic [ref=f13e111]: 79.99 EUR + - generic [ref=f13e112]: 99.99 EUR + - generic [ref=f13e113]: + - generic [ref=f13e114]: "On sale:" + - text: Sale + - link "Choose options" [ref=f13e116] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f13e117]: + - link [ref=f13e119] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - link "Organic Hoodie 59.99 EUR" [ref=f13e123] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - heading "Organic Hoodie" [level=3] [ref=f13e124] + - generic [ref=f13e125]: 59.99 EUR + - link "Choose options" [ref=f13e129] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - generic [ref=f13e130]: + - link [ref=f13e132] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - link "Leather Belt 34.99 EUR" [ref=f13e136] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - heading "Leather Belt" [level=3] [ref=f13e137] + - generic [ref=f13e138]: 34.99 EUR + - link "Choose options" [ref=f13e142] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - generic [ref=f13e143]: + - link [ref=f13e145] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - link "Running Sneakers 119.99 EUR" [ref=f13e149] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - heading "Running Sneakers" [level=3] [ref=f13e150] + - generic [ref=f13e151]: 119.99 EUR + - link "Choose options" [ref=f13e155] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - generic [ref=f13e156]: + - link [ref=f13e158] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f13e162] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f13e163] + - generic [ref=f13e164]: 29.99 EUR + - link "Choose options" [ref=f13e168] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - region [ref=f13e169]: + - generic [ref=f13e170]: + - heading "Stay in the loop" [level=2] [ref=f13e171] + - paragraph [ref=f13e172]: Subscribe for exclusive offers and updates. + - generic [ref=f13e174]: + - generic [ref=f13e175]: Email address + - textbox "Email address" [ref=f13e176]: + - /placeholder: Enter your email + - button "Subscribe" [ref=f13e177] + - contentinfo [ref=f13e178]: + - generic [ref=f13e179]: + - generic [ref=f13e180]: + - generic [ref=f13e181]: + - heading "Shop" [level=2] [ref=f13e182] + - list [ref=f13e183]: + - listitem [ref=f13e184]: + - link "About Us" [ref=f13e185] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f13e186]: + - link "FAQ" [ref=f13e187] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f13e188]: + - link "Shipping & Returns" [ref=f13e189] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f13e190]: + - link "Privacy Policy" [ref=f13e191] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f13e192]: + - link "Terms of Service" [ref=f13e193] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f13e194]: + - heading "Acme Fashion" [level=2] [ref=f13e195] + - paragraph [ref=f13e196]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f13e197]: + - paragraph [ref=f13e198]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f13e199]: + - generic [ref=f13e200]: VISA + - generic [ref=f13e201]: MASTERCARD + - generic [ref=f13e202]: AMEX + - generic [ref=f13e203]: PAYPAL + - generic: + - dialog "Search": + - search [ref=f13e207]: + - generic [ref=f13e210]: Search products + - combobox "Search products" [active] [ref=f13e211] + - button "Search" [ref=f13e212] + - button "Close search" [ref=f13e213] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T21-45-02-607Z.yml b/.playwright-mcp/page-2026-07-26T21-45-02-607Z.yml new file mode 100644 index 00000000..f89855a4 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T21-45-02-607Z.yml @@ -0,0 +1,213 @@ +- generic [ref=f13e1]: + - link "Skip to main content" [ref=f13e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f13e4]: + - paragraph [ref=f13e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f13e6] + - banner [ref=f13e9]: + - generic [ref=f13e10]: + - link "Acme Fashion" [ref=f13e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f13e13]: + - link "Home" [ref=f13e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f13e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f13e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f13e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f13e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f13e19]: + - button "Search" [ref=f13e20] + - link "Account" [ref=f13e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f13e26] + - main [ref=f13e29]: + - generic [ref=f13e30]: + - generic [ref=f13e34]: + - heading "Welcome to Acme Fashion" [level=1] [ref=f13e35] + - paragraph [ref=f13e36]: Discover our curated collection of modern essentials + - link "Shop New Arrivals" [ref=f13e37] [cursor=pointer]: + - /url: /collections/new-arrivals + - region [ref=f13e38]: + - heading "Featured collections" [level=2] [ref=f13e39] + - generic [ref=f13e40]: + - link "New Arrivals" [ref=f13e41] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - generic [ref=f13e43]: + - generic [ref=f13e44]: New Arrivals + - generic [ref=f13e45]: Shop now + - link "T-Shirts" [ref=f13e46] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - generic [ref=f13e48]: + - generic [ref=f13e49]: T-Shirts + - generic [ref=f13e50]: Shop now + - link "Sale" [ref=f13e51] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/sale + - generic [ref=f13e53]: + - generic [ref=f13e54]: Sale + - generic [ref=f13e55]: Shop now + - region [ref=f13e56]: + - heading "Featured products" [level=2] [ref=f13e57] + - generic [ref=f13e58]: + - generic [ref=f13e59]: + - link [ref=f13e61] [cursor=pointer]: + - /url: http://acme-fashion.test/products/gift-card + - link "Gift Card 25.00 EUR" [ref=f13e65] [cursor=pointer]: + - /url: http://acme-fashion.test/products/gift-card + - heading "Gift Card" [level=3] [ref=f13e66] + - generic [ref=f13e67]: 25.00 EUR + - link "Choose options" [ref=f13e71] [cursor=pointer]: + - /url: http://acme-fashion.test/products/gift-card + - generic [ref=f13e72]: + - link [ref=f13e74] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cashmere-overcoat + - link "Cashmere Overcoat 499.99 EUR" [ref=f13e78] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cashmere-overcoat + - heading "Cashmere Overcoat" [level=3] [ref=f13e79] + - generic [ref=f13e80]: 499.99 EUR + - link "Choose options" [ref=f13e84] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cashmere-overcoat + - generic [ref=f13e85]: + - link [ref=f13e87] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f13e91] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f13e92] + - generic [ref=f13e93]: 24.99 EUR + - link "Choose options" [ref=f13e97] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f13e98]: + - generic [ref=f13e99]: + - link [ref=f13e100] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f13e105]: + - generic [ref=f13e106]: "On sale:" + - text: Sale + - 'link "Premium Slim Fit Jeans 79.99 EUR 99.99 EUR On sale: Sale" [ref=f13e107] [cursor=pointer]': + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - heading "Premium Slim Fit Jeans" [level=3] [ref=f13e108] + - generic [ref=f13e110]: + - generic [ref=f13e111]: 79.99 EUR + - generic [ref=f13e112]: 99.99 EUR + - generic [ref=f13e113]: + - generic [ref=f13e114]: "On sale:" + - text: Sale + - link "Choose options" [ref=f13e116] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f13e117]: + - link [ref=f13e119] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - link "Organic Hoodie 59.99 EUR" [ref=f13e123] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - heading "Organic Hoodie" [level=3] [ref=f13e124] + - generic [ref=f13e125]: 59.99 EUR + - link "Choose options" [ref=f13e129] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - generic [ref=f13e130]: + - link [ref=f13e132] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - link "Leather Belt 34.99 EUR" [ref=f13e136] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - heading "Leather Belt" [level=3] [ref=f13e137] + - generic [ref=f13e138]: 34.99 EUR + - link "Choose options" [ref=f13e142] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - generic [ref=f13e143]: + - link [ref=f13e145] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - link "Running Sneakers 119.99 EUR" [ref=f13e149] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - heading "Running Sneakers" [level=3] [ref=f13e150] + - generic [ref=f13e151]: 119.99 EUR + - link "Choose options" [ref=f13e155] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - generic [ref=f13e156]: + - link [ref=f13e158] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f13e162] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f13e163] + - generic [ref=f13e164]: 29.99 EUR + - link "Choose options" [ref=f13e168] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - region [ref=f13e169]: + - generic [ref=f13e170]: + - heading "Stay in the loop" [level=2] [ref=f13e171] + - paragraph [ref=f13e172]: Subscribe for exclusive offers and updates. + - generic [ref=f13e174]: + - generic [ref=f13e175]: Email address + - textbox "Email address" [ref=f13e176]: + - /placeholder: Enter your email + - button "Subscribe" [ref=f13e177] + - contentinfo [ref=f13e178]: + - generic [ref=f13e179]: + - generic [ref=f13e180]: + - generic [ref=f13e181]: + - heading "Shop" [level=2] [ref=f13e182] + - list [ref=f13e183]: + - listitem [ref=f13e184]: + - link "About Us" [ref=f13e185] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f13e186]: + - link "FAQ" [ref=f13e187] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f13e188]: + - link "Shipping & Returns" [ref=f13e189] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f13e190]: + - link "Privacy Policy" [ref=f13e191] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f13e192]: + - link "Terms of Service" [ref=f13e193] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f13e194]: + - heading "Acme Fashion" [level=2] [ref=f13e195] + - paragraph [ref=f13e196]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f13e197]: + - paragraph [ref=f13e198]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f13e199]: + - generic [ref=f13e200]: VISA + - generic [ref=f13e201]: MASTERCARD + - generic [ref=f13e202]: AMEX + - generic [ref=f13e203]: PAYPAL + - generic: + - dialog "Search": + - generic [ref=f13e206]: + - search [ref=f13e207]: + - generic [ref=f13e210]: Search products + - combobox "Search products" [expanded] [active] [ref=f13e211]: cotton + - button "Search" [ref=f13e212] + - button "Close search" [ref=f13e213] + - generic [ref=f13e216]: + - listbox "Search suggestions" [ref=f13e217]: + - listitem [ref=f13e218]: Products + - option [ref=f13e219]: + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f13e220] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f13e224]: Classic Cotton T-Shirt + - generic [ref=f13e225]: 24.99 EUR + - option [ref=f13e228]: + - link "Cargo Pants 54.99 EUR" [ref=f13e229] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cargo-pants + - generic [ref=f13e233]: Cargo Pants + - generic [ref=f13e234]: 54.99 EUR + - option [ref=f13e237]: + - link "Organic Hoodie 59.99 EUR" [ref=f13e238] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - generic [ref=f13e242]: Organic Hoodie + - generic [ref=f13e243]: 59.99 EUR + - option [ref=f13e246]: + - link "Bucket Hat 24.99 EUR" [ref=f13e247] [cursor=pointer]: + - /url: http://acme-fashion.test/products/bucket-hat + - generic [ref=f13e251]: Bucket Hat + - generic [ref=f13e252]: 24.99 EUR + - option [ref=f13e255]: + - link "Graphic Print Tee 29.99 EUR" [ref=f13e256] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - generic [ref=f13e260]: Graphic Print Tee + - generic [ref=f13e261]: 29.99 EUR + - link "View all results for “cotton” →" [ref=f13e264] [cursor=pointer]: + - /url: http://acme-fashion.test/search?q=cotton \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T21-45-11-137Z.yml b/.playwright-mcp/page-2026-07-26T21-45-11-137Z.yml new file mode 100644 index 00000000..f89855a4 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T21-45-11-137Z.yml @@ -0,0 +1,213 @@ +- generic [ref=f13e1]: + - link "Skip to main content" [ref=f13e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f13e4]: + - paragraph [ref=f13e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=f13e6] + - banner [ref=f13e9]: + - generic [ref=f13e10]: + - link "Acme Fashion" [ref=f13e11] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f13e13]: + - link "Home" [ref=f13e14] [cursor=pointer]: + - /url: / + - link "New Arrivals" [ref=f13e15] [cursor=pointer]: + - /url: /collections/new-arrivals + - link "T-Shirts" [ref=f13e16] [cursor=pointer]: + - /url: /collections/t-shirts + - link "Pants & Jeans" [ref=f13e17] [cursor=pointer]: + - /url: /collections/pants-jeans + - link "Sale" [ref=f13e18] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f13e19]: + - button "Search" [ref=f13e20] + - link "Account" [ref=f13e23] [cursor=pointer]: + - /url: /account + - button "Open cart" [ref=f13e26] + - main [ref=f13e29]: + - generic [ref=f13e30]: + - generic [ref=f13e34]: + - heading "Welcome to Acme Fashion" [level=1] [ref=f13e35] + - paragraph [ref=f13e36]: Discover our curated collection of modern essentials + - link "Shop New Arrivals" [ref=f13e37] [cursor=pointer]: + - /url: /collections/new-arrivals + - region [ref=f13e38]: + - heading "Featured collections" [level=2] [ref=f13e39] + - generic [ref=f13e40]: + - link "New Arrivals" [ref=f13e41] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - generic [ref=f13e43]: + - generic [ref=f13e44]: New Arrivals + - generic [ref=f13e45]: Shop now + - link "T-Shirts" [ref=f13e46] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - generic [ref=f13e48]: + - generic [ref=f13e49]: T-Shirts + - generic [ref=f13e50]: Shop now + - link "Sale" [ref=f13e51] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/sale + - generic [ref=f13e53]: + - generic [ref=f13e54]: Sale + - generic [ref=f13e55]: Shop now + - region [ref=f13e56]: + - heading "Featured products" [level=2] [ref=f13e57] + - generic [ref=f13e58]: + - generic [ref=f13e59]: + - link [ref=f13e61] [cursor=pointer]: + - /url: http://acme-fashion.test/products/gift-card + - link "Gift Card 25.00 EUR" [ref=f13e65] [cursor=pointer]: + - /url: http://acme-fashion.test/products/gift-card + - heading "Gift Card" [level=3] [ref=f13e66] + - generic [ref=f13e67]: 25.00 EUR + - link "Choose options" [ref=f13e71] [cursor=pointer]: + - /url: http://acme-fashion.test/products/gift-card + - generic [ref=f13e72]: + - link [ref=f13e74] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cashmere-overcoat + - link "Cashmere Overcoat 499.99 EUR" [ref=f13e78] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cashmere-overcoat + - heading "Cashmere Overcoat" [level=3] [ref=f13e79] + - generic [ref=f13e80]: 499.99 EUR + - link "Choose options" [ref=f13e84] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cashmere-overcoat + - generic [ref=f13e85]: + - link [ref=f13e87] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f13e91] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - heading "Classic Cotton T-Shirt" [level=3] [ref=f13e92] + - generic [ref=f13e93]: 24.99 EUR + - link "Choose options" [ref=f13e97] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f13e98]: + - generic [ref=f13e99]: + - link [ref=f13e100] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f13e105]: + - generic [ref=f13e106]: "On sale:" + - text: Sale + - 'link "Premium Slim Fit Jeans 79.99 EUR 99.99 EUR On sale: Sale" [ref=f13e107] [cursor=pointer]': + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - heading "Premium Slim Fit Jeans" [level=3] [ref=f13e108] + - generic [ref=f13e110]: + - generic [ref=f13e111]: 79.99 EUR + - generic [ref=f13e112]: 99.99 EUR + - generic [ref=f13e113]: + - generic [ref=f13e114]: "On sale:" + - text: Sale + - link "Choose options" [ref=f13e116] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f13e117]: + - link [ref=f13e119] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - link "Organic Hoodie 59.99 EUR" [ref=f13e123] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - heading "Organic Hoodie" [level=3] [ref=f13e124] + - generic [ref=f13e125]: 59.99 EUR + - link "Choose options" [ref=f13e129] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - generic [ref=f13e130]: + - link [ref=f13e132] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - link "Leather Belt 34.99 EUR" [ref=f13e136] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - heading "Leather Belt" [level=3] [ref=f13e137] + - generic [ref=f13e138]: 34.99 EUR + - link "Choose options" [ref=f13e142] [cursor=pointer]: + - /url: http://acme-fashion.test/products/leather-belt + - generic [ref=f13e143]: + - link [ref=f13e145] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - link "Running Sneakers 119.99 EUR" [ref=f13e149] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - heading "Running Sneakers" [level=3] [ref=f13e150] + - generic [ref=f13e151]: 119.99 EUR + - link "Choose options" [ref=f13e155] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - generic [ref=f13e156]: + - link [ref=f13e158] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - link "Graphic Print Tee 29.99 EUR" [ref=f13e162] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - heading "Graphic Print Tee" [level=3] [ref=f13e163] + - generic [ref=f13e164]: 29.99 EUR + - link "Choose options" [ref=f13e168] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - region [ref=f13e169]: + - generic [ref=f13e170]: + - heading "Stay in the loop" [level=2] [ref=f13e171] + - paragraph [ref=f13e172]: Subscribe for exclusive offers and updates. + - generic [ref=f13e174]: + - generic [ref=f13e175]: Email address + - textbox "Email address" [ref=f13e176]: + - /placeholder: Enter your email + - button "Subscribe" [ref=f13e177] + - contentinfo [ref=f13e178]: + - generic [ref=f13e179]: + - generic [ref=f13e180]: + - generic [ref=f13e181]: + - heading "Shop" [level=2] [ref=f13e182] + - list [ref=f13e183]: + - listitem [ref=f13e184]: + - link "About Us" [ref=f13e185] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f13e186]: + - link "FAQ" [ref=f13e187] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f13e188]: + - link "Shipping & Returns" [ref=f13e189] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f13e190]: + - link "Privacy Policy" [ref=f13e191] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f13e192]: + - link "Terms of Service" [ref=f13e193] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f13e194]: + - heading "Acme Fashion" [level=2] [ref=f13e195] + - paragraph [ref=f13e196]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f13e197]: + - paragraph [ref=f13e198]: © 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=f13e199]: + - generic [ref=f13e200]: VISA + - generic [ref=f13e201]: MASTERCARD + - generic [ref=f13e202]: AMEX + - generic [ref=f13e203]: PAYPAL + - generic: + - dialog "Search": + - generic [ref=f13e206]: + - search [ref=f13e207]: + - generic [ref=f13e210]: Search products + - combobox "Search products" [expanded] [active] [ref=f13e211]: cotton + - button "Search" [ref=f13e212] + - button "Close search" [ref=f13e213] + - generic [ref=f13e216]: + - listbox "Search suggestions" [ref=f13e217]: + - listitem [ref=f13e218]: Products + - option [ref=f13e219]: + - link "Classic Cotton T-Shirt 24.99 EUR" [ref=f13e220] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f13e224]: Classic Cotton T-Shirt + - generic [ref=f13e225]: 24.99 EUR + - option [ref=f13e228]: + - link "Cargo Pants 54.99 EUR" [ref=f13e229] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cargo-pants + - generic [ref=f13e233]: Cargo Pants + - generic [ref=f13e234]: 54.99 EUR + - option [ref=f13e237]: + - link "Organic Hoodie 59.99 EUR" [ref=f13e238] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - generic [ref=f13e242]: Organic Hoodie + - generic [ref=f13e243]: 59.99 EUR + - option [ref=f13e246]: + - link "Bucket Hat 24.99 EUR" [ref=f13e247] [cursor=pointer]: + - /url: http://acme-fashion.test/products/bucket-hat + - generic [ref=f13e251]: Bucket Hat + - generic [ref=f13e252]: 24.99 EUR + - option [ref=f13e255]: + - link "Graphic Print Tee 29.99 EUR" [ref=f13e256] [cursor=pointer]: + - /url: http://acme-fashion.test/products/graphic-print-tee + - generic [ref=f13e260]: Graphic Print Tee + - generic [ref=f13e261]: 29.99 EUR + - link "View all results for “cotton” →" [ref=f13e264] [cursor=pointer]: + - /url: http://acme-fashion.test/search?q=cotton \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-26T21-45-47-093Z.yml b/.playwright-mcp/page-2026-07-26T21-45-47-093Z.yml new file mode 100644 index 00000000..5e487341 --- /dev/null +++ b/.playwright-mcp/page-2026-07-26T21-45-47-093Z.yml @@ -0,0 +1,177 @@ +- generic [active] [ref=f14e1]: + - link "Skip to main content" [ref=f14e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f14e3]: + - complementary "Admin navigation" [ref=f14e4]: + - generic [ref=f14e5]: + - link "Acme Fashion" [ref=f14e7] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - navigation "Admin" [ref=f14e12]: + - navigation [ref=f14e13]: + - link "Dashboard" [ref=f14e14] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin + - paragraph [ref=f14e19]: Products + - navigation [ref=f14e20]: + - link "Products" [ref=f14e21] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/products + - link "Collections" [ref=f14e26] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/collections + - link "Inventory" [ref=f14e31] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/inventory + - paragraph [ref=f14e36]: Orders + - navigation [ref=f14e37]: + - link "Orders" [ref=f14e38] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/orders + - paragraph [ref=f14e43]: Customers + - navigation [ref=f14e44]: + - link "Customers" [ref=f14e45] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/customers + - paragraph [ref=f14e50]: Discounts + - navigation [ref=f14e51]: + - link "Discounts" [ref=f14e52] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/discounts + - paragraph [ref=f14e58]: Content + - navigation [ref=f14e59]: + - link "Pages" [ref=f14e60] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/pages + - link "Navigation" [ref=f14e65] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/navigation + - link "Themes" [ref=f14e70] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/themes + - navigation [ref=f14e75]: + - link "Analytics" [ref=f14e76] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/analytics + - paragraph [ref=f14e82]: Settings + - navigation [ref=f14e83]: + - link "Settings" [ref=f14e84] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings + - link "Shipping" [ref=f14e90] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/shipping + - link "Taxes" [ref=f14e95] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/settings/taxes + - link "Search" [ref=f14e100] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/search/settings + - link "Apps" [ref=f14e105] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/apps + - link "Developers" [ref=f14e110] [cursor=pointer]: + - /url: http://admin.acme-fashion.test/admin/developers + - generic [ref=f14e115]: + - banner [ref=f14e116]: + - button "Acme Fashion" [ref=f14e118] + - button "Notifications" [ref=f14e123] + - button "AU Admin User" [ref=f14e127]: + - generic [ref=f14e128]: AU + - generic [ref=f14e131]: Admin User + - main [ref=f14e135]: + - generic [ref=f14e136]: + - generic [ref=f14e137]: Home + - generic [ref=f14e141]: Dashboard + - generic [ref=f14e143]: + - generic [ref=f14e144]: + - heading "Dashboard" [level=1] [ref=f14e145] + - combobox "Date range" [ref=f14e146]: + - option "Last 7 days" + - option "Last 30 days" [selected] + - option "Last 90 days" + - generic [ref=f14e147]: + - generic [ref=f14e148]: + - paragraph [ref=f14e149]: Total Sales + - generic [ref=f14e150]: 1,547.10 EUR + - generic [ref=f14e151]: + - paragraph [ref=f14e152]: Orders + - generic [ref=f14e153]: "16" + - generic [ref=f14e154]: + - paragraph [ref=f14e155]: Avg. Order Value + - generic [ref=f14e156]: 96.69 EUR + - generic [ref=f14e157]: + - paragraph [ref=f14e158]: Conversion Rate + - generic [ref=f14e159]: 44.4% + - generic [ref=f14e160]: + - heading "Orders over time" [level=2] [ref=f14e161] + - generic [ref=f14e162]: + - img "Daily order counts for the selected period" [ref=f14e163] + - generic [ref=f14e165]: + - generic [ref=f14e166]: 2026-06-27 + - generic [ref=f14e167]: 2026-07-26 + - generic [ref=f14e168]: + - heading "Recent orders" [level=2] [ref=f14e169] + - table [ref=f14e171]: + - rowgroup [ref=f14e172]: + - row [ref=f14e173]: + - columnheader "Order" [ref=f14e174] + - columnheader "Date" [ref=f14e175] + - columnheader "Customer" [ref=f14e176] + - columnheader "Payment" [ref=f14e177] + - columnheader "Fulfillment" [ref=f14e178] + - columnheader "Total" [ref=f14e179] + - rowgroup [ref=f14e180]: + - row [ref=f14e181]: + - cell "#1016" [ref=f14e182] + - cell "Jul 26, 2026" [ref=f14e183] + - cell "Jane Smith" [ref=f14e184] + - cell "Paid" [ref=f14e185] + - cell "Unfulfilled" [ref=f14e187] + - cell "29.98 EUR" [ref=f14e189] + - row [ref=f14e190]: + - cell "#1015" [ref=f14e191] + - cell "Jul 26, 2026" [ref=f14e192] + - cell "John Doe" [ref=f14e193] + - cell "Paid" [ref=f14e194] + - cell "Unfulfilled" [ref=f14e196] + - cell "54.47 EUR" [ref=f14e198] + - row [ref=f14e199]: + - cell "#1005" [ref=f14e200] + - cell "Jul 26, 2026" [ref=f14e201] + - cell "Jane Smith" [ref=f14e202] + - cell "Pending" [ref=f14e203] + - cell "Unfulfilled" [ref=f14e205] + - cell "39.98 EUR" [ref=f14e207] + - row [ref=f14e208]: + - cell "#1013" [ref=f14e209] + - cell "Jul 25, 2026" [ref=f14e210] + - cell "Robert Martinez" [ref=f14e211] + - cell "Paid" [ref=f14e212] + - cell "Unfulfilled" [ref=f14e214] + - cell "84.97 EUR" [ref=f14e216] + - row [ref=f14e217]: + - cell "#1010" [ref=f14e218] + - cell "Jul 25, 2026" [ref=f14e219] + - cell "John Doe" [ref=f14e220] + - cell "Paid" [ref=f14e221] + - cell "Unfulfilled" [ref=f14e223] + - cell "504.98 EUR" [ref=f14e225] + - row [ref=f14e226]: + - cell "#1006" [ref=f14e227] + - cell "Jul 25, 2026" [ref=f14e228] + - cell "Michael Brown" [ref=f14e229] + - cell "Paid" [ref=f14e230] + - cell "Unfulfilled" [ref=f14e232] + - cell "124.98 EUR" [ref=f14e234] + - row [ref=f14e235]: + - cell "#1001" [ref=f14e236] + - cell "Jul 24, 2026" [ref=f14e237] + - cell "John Doe" [ref=f14e238] + - cell "Paid" [ref=f14e239] + - cell "Unfulfilled" [ref=f14e241] + - cell "54.97 EUR" [ref=f14e243] + - row [ref=f14e244]: + - cell "#1009" [ref=f14e245] + - cell "Jul 23, 2026" [ref=f14e246] + - cell "Emma Garcia" [ref=f14e247] + - cell "Paid" [ref=f14e248] + - cell "Unfulfilled" [ref=f14e250] + - cell "49.97 EUR" [ref=f14e252] + - row [ref=f14e253]: + - cell "#1012" [ref=f14e254] + - cell "Jul 22, 2026" [ref=f14e255] + - cell "Lisa Anderson" [ref=f14e256] + - cell "Paid" [ref=f14e257] + - cell "Unfulfilled" [ref=f14e259] + - cell "84.97 EUR" [ref=f14e261] + - row [ref=f14e262]: + - cell "#1003" [ref=f14e263] + - cell "Jul 21, 2026" [ref=f14e264] + - cell "Jane Smith" [ref=f14e265] + - cell "Paid" [ref=f14e266] + - cell "Partial" [ref=f14e268] + - cell "119.97 EUR" [ref=f14e270] \ No newline at end of file diff --git a/.playwright-mcp/review/review-01-storefront-home.jpeg b/.playwright-mcp/review/review-01-storefront-home.jpeg new file mode 100644 index 00000000..604adea7 Binary files /dev/null and b/.playwright-mcp/review/review-01-storefront-home.jpeg differ diff --git a/.playwright-mcp/review/review-02-product.jpeg b/.playwright-mcp/review/review-02-product.jpeg new file mode 100644 index 00000000..504427c0 Binary files /dev/null and b/.playwright-mcp/review/review-02-product.jpeg differ diff --git a/.playwright-mcp/review/review-03-cart-drawer.jpeg b/.playwright-mcp/review/review-03-cart-drawer.jpeg new file mode 100644 index 00000000..4aa6d9b3 Binary files /dev/null and b/.playwright-mcp/review/review-03-cart-drawer.jpeg differ diff --git a/.playwright-mcp/review/review-04-checkout-step1.jpeg b/.playwright-mcp/review/review-04-checkout-step1.jpeg new file mode 100644 index 00000000..0b86c667 Binary files /dev/null and b/.playwright-mcp/review/review-04-checkout-step1.jpeg differ diff --git a/.playwright-mcp/review/review-05-checkout-shipping.jpeg b/.playwright-mcp/review/review-05-checkout-shipping.jpeg new file mode 100644 index 00000000..671d6634 Binary files /dev/null and b/.playwright-mcp/review/review-05-checkout-shipping.jpeg differ diff --git a/.playwright-mcp/review/review-06-checkout-payment.jpeg b/.playwright-mcp/review/review-06-checkout-payment.jpeg new file mode 100644 index 00000000..24ca89b6 Binary files /dev/null and b/.playwright-mcp/review/review-06-checkout-payment.jpeg differ diff --git a/.playwright-mcp/review/review-07-checkout-card.jpeg b/.playwright-mcp/review/review-07-checkout-card.jpeg new file mode 100644 index 00000000..61c4b83d Binary files /dev/null and b/.playwright-mcp/review/review-07-checkout-card.jpeg differ diff --git a/.playwright-mcp/review/review-08-confirmation.jpeg b/.playwright-mcp/review/review-08-confirmation.jpeg new file mode 100644 index 00000000..1b85331b Binary files /dev/null and b/.playwright-mcp/review/review-08-confirmation.jpeg differ diff --git a/.playwright-mcp/review/review-09-admin-dashboard.jpeg b/.playwright-mcp/review/review-09-admin-dashboard.jpeg new file mode 100644 index 00000000..53820127 Binary files /dev/null and b/.playwright-mcp/review/review-09-admin-dashboard.jpeg differ diff --git a/.playwright-mcp/review/review-10-admin-products.jpeg b/.playwright-mcp/review/review-10-admin-products.jpeg new file mode 100644 index 00000000..6b6bc1d6 Binary files /dev/null and b/.playwright-mcp/review/review-10-admin-products.jpeg differ diff --git a/.playwright-mcp/review/review-11-admin-order.jpeg b/.playwright-mcp/review/review-11-admin-order.jpeg new file mode 100644 index 00000000..28b32042 Binary files /dev/null and b/.playwright-mcp/review/review-11-admin-order.jpeg differ diff --git a/.playwright-mcp/review/review-12-admin-analytics.jpeg b/.playwright-mcp/review/review-12-admin-analytics.jpeg new file mode 100644 index 00000000..8c966e00 Binary files /dev/null and b/.playwright-mcp/review/review-12-admin-analytics.jpeg differ diff --git a/.playwright-mcp/review/review-13-search-modal.jpeg b/.playwright-mcp/review/review-13-search-modal.jpeg new file mode 100644 index 00000000..c4cfd001 Binary files /dev/null and b/.playwright-mcp/review/review-13-search-modal.jpeg differ diff --git a/.playwright-mcp/review/review-14-admin-dark.jpeg b/.playwright-mcp/review/review-14-admin-dark.jpeg new file mode 100644 index 00000000..981fe49e Binary files /dev/null and b/.playwright-mcp/review/review-14-admin-dark.jpeg differ diff --git a/AGENTS.md b/AGENTS.md index 296f2af0..66899279 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,3 +23,221 @@ The complete specification is in `specs/`. Start with `specs/09-IMPLEMENTATION-R - `specs/07-SEEDERS-AND-TEST-DATA.md` - Seeders and test data - `specs/08-PLAYWRIGHT-E2E-PLAN.md` - E2E browser tests - `specs/09-IMPLEMENTATION-ROADMAP.md` - Implementation roadmap + +=== + + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications. + +## Foundational Context + +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.4 +- laravel/fortify (FORTIFY) - v1 +- laravel/framework (LARAVEL) - v12 +- laravel/prompts (PROMPTS) - v0 +- laravel/sanctum (SANCTUM) - v4 +- livewire/flux (FLUXUI_FREE) - v2 +- livewire/livewire (LIVEWIRE) - v4 +- laravel/boost (BOOST) - v2 +- laravel/mcp (MCP) - v0 +- laravel/pail (PAIL) - v1 +- laravel/pint (PINT) - v1 +- laravel/sail (SAIL) - v1 +- pestphp/pest (PEST) - v4 +- phpunit/phpunit (PHPUNIT) - v12 +- tailwindcss (TAILWINDCSS) - v4 + +## Skills Activation + +This project has domain-specific skills available in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. + +## Conventions + +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts + +- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important. + +## Application Structure & Architecture + +- Stick to existing directory structure; don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling + +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Documentation Files + +- You must only create documentation files if explicitly requested by the user. + +## Replies + +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +=== boost rules === + +# Laravel Boost + +## Tools + +- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads. +- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker. +- Use `database-schema` to inspect table structure before writing migrations or models. +- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user. +- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries. + +## Searching Documentation (IMPORTANT) + +- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically. +- Pass a `packages` array to scope results when you know which packages are relevant. +- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first. +- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`. + +### Search Syntax + +1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit". +2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order. +3. Combine words and phrases for mixed queries: `middleware "rate limit"`. +4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`. + +## Artisan + +- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. +- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`. +- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory. + +## Tinker + +- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code. +- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'` + - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` + +=== php rules === + +# PHP + +- Always use curly braces for control structures, even for single-line bodies. +- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private. +- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool` +- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`. +- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic. +- Use array shape type definitions in PHPDoc blocks. + +=== deployments rules === + +# Deployment + +- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications. + +=== herd rules === + +# Laravel Herd + +- The application is served by Laravel Herd at `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs. Never run commands to serve the site. It is always available. +- Use the `herd` CLI to manage services, PHP versions, and sites (e.g. `herd sites`, `herd services:start `, `herd php:list`). Run `herd list` to discover all available commands. + +=== tests rules === + +# 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. + +=== fortify/core rules === + +# Laravel Fortify + +- Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications. +- IMPORTANT: Always use the `search-docs` tool for detailed Laravel Fortify patterns and documentation. +- IMPORTANT: Activate `developing-with-fortify` skill when working with Fortify authentication features. + +=== laravel/core rules === + +# Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`. +- If you're creating a generic PHP class, use `php artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Model Creation + +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options. + +## APIs & Eloquent Resources + +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +## URL Generation + +- When generating links to other pages, prefer named routes and the `route()` function. + +## Testing + +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +## Vite Error + +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + +=== laravel/v12 rules === + +# Laravel 12 + +- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +## Laravel 12 Structure + +- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`. +- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- The `app/Console/Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. +- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration. + +## Database + +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models + +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + +=== livewire/core rules === + +# Livewire + +- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript. +- You can use Alpine.js for client-side interactions instead of JavaScript frameworks. +- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests. + +=== pint/core rules === + +# Laravel Pint Code Formatter + +- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues. + +=== pest/core rules === + +## Pest + +- 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`. +- Do NOT delete tests without approval. + + diff --git a/CLAUDE.md b/CLAUDE.md index 7b0f1e95..46203118 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,432 +29,212 @@ The complete specification is in `specs/`. Start with `specs/09-IMPLEMENTATION-R # Laravel Boost Guidelines -The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications. ## Foundational Context + This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. -- php - 8.4.17 +- php - 8.4 +- laravel/fortify (FORTIFY) - v1 - laravel/framework (LARAVEL) - v12 - laravel/prompts (PROMPTS) - v0 - livewire/flux (FLUXUI_FREE) - v2 - livewire/livewire (LIVEWIRE) - v4 +- laravel/boost (BOOST) - v2 +- laravel/mcp (MCP) - v0 +- laravel/pail (PAIL) - v1 - laravel/pint (PINT) - v1 +- laravel/sail (SAIL) - v1 - pestphp/pest (PEST) - v4 +- phpunit/phpunit (PHPUNIT) - v12 - tailwindcss (TAILWINDCSS) - v4 +## Skills Activation + +This project has domain-specific skills available in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. ## Conventions -- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. + +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming. - Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. - Check for existing components to reuse before writing a new one. ## Verification Scripts -- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important. ## Application Structure & Architecture -- Stick to existing directory structure - don't create new base folders without approval. + +- Stick to existing directory structure; don't create new base folders without approval. - Do not change the application's dependencies without approval. ## Frontend Bundling -- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. -## Replies -- Be concise in your explanations - focus on what's important rather than explaining obvious details. +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. ## Documentation Files + - You must only create documentation files if explicitly requested by the user. +## Replies + +- Be concise in your explanations - focus on what's important rather than explaining obvious details. === boost rules === -## Laravel Boost -- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. +# Laravel Boost -## Artisan -- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. +## Tools + +- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads. +- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker. +- Use `database-schema` to inspect table structure before writing migrations or models. +- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user. +- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries. -## URLs -- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. +## Searching Documentation (IMPORTANT) -## Tinker / Debugging -- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. -- Use the `database-query` tool when you only need to read from the database. +- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically. +- Pass a `packages` array to scope results when you know which packages are relevant. +- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first. +- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`. -## Reading Browser Logs With the `browser-logs` Tool -- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. -- Only recent browser logs will be useful - ignore old logs. +### Search Syntax -## Searching Documentation (Critically Important) -- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. -- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. -- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. -- Search the documentation before making code changes to ensure we are taking the correct approach. -- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. -- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. +1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit". +2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order. +3. Combine words and phrases for mixed queries: `middleware "rate limit"`. +4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`. -### Available Search Syntax -- You can and should pass multiple queries at once. The most relevant results will be returned first. +## Artisan + +- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. +- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`. +- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory. -1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' -2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" -3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order -4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" -5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms +## Tinker +- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code. +- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'` + - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` === php rules === -## PHP +# PHP -- Always use curly braces for control structures, even if it has one line. +- Always use curly braces for control structures, even for single-line bodies. +- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private. +- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool` +- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`. +- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic. +- Use array shape type definitions in PHPDoc blocks. -### Constructors -- Use PHP 8 constructor property promotion in `__construct()`. - - public function __construct(public GitHub $github) { } -- Do not allow empty `__construct()` methods with zero parameters. +=== deployments rules === -### Type Declarations -- Always use explicit return type declarations for methods and functions. -- Use appropriate PHP type hints for method parameters. +# Deployment - -protected function isAccessible(User $user, ?string $path = null): bool -{ - ... -} - +- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications. -## Comments -- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. +=== herd rules === -## PHPDoc Blocks -- Add useful array shape type definitions for arrays when appropriate. +# Laravel Herd -## Enums -- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. +- The application is served by Laravel Herd at `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs. Never run commands to serve the site. It is always available. +- Use the `herd` CLI to manage services, PHP versions, and sites (e.g. `herd sites`, `herd services:start `, `herd php:list`). Run `herd list` to discover all available commands. +=== tests rules === -=== herd rules === +# Test Enforcement -## Laravel Herd +- 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. -- The application is served by Laravel Herd and will be available at: https?://[kebab-case-project-dir].test. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs. -- You must not run any commands to make the site available via HTTP(s). It is _always_ available through Laravel Herd. +=== fortify/core rules === +# Laravel Fortify + +- Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications. +- IMPORTANT: Always use the `search-docs` tool for detailed Laravel Fortify patterns and documentation. +- IMPORTANT: Activate `developing-with-fortify` skill when working with Fortify authentication features. === laravel/core rules === -## Do Things the Laravel Way +# Do Things the Laravel Way -- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. -- If you're creating a generic PHP class, use `artisan make:class`. +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`. +- If you're creating a generic PHP class, use `php artisan make:class`. - Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. -### Database -- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. -- Use Eloquent models and relationships before suggesting raw database queries -- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. -- Generate code that prevents N+1 query problems by using eager loading. -- Use Laravel's query builder for very complex database operations. - ### Model Creation -- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. -### APIs & Eloquent Resources -- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options. -### Controllers & Validation -- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. -- Check sibling Form Requests to see if the application uses array or string based validation rules. +## APIs & Eloquent Resources -### Queues -- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. -### Authentication & Authorization -- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). +## URL Generation -### URL Generation - When generating links to other pages, prefer named routes and the `route()` function. -### Configuration -- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. +## Testing -### Testing - When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. - Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. -- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. +- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. -### Vite Error -- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. +## Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. === laravel/v12 rules === -## Laravel 12 +# Laravel 12 -- Use the `search-docs` tool to get version specific documentation. +- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples. - Since Laravel 11, Laravel has a new streamlined file structure which this project uses. -### Laravel 12 Structure -- No middleware files in `app/Http/Middleware/`. +## Laravel 12 Structure + +- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`. +- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`. - `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. - `bootstrap/providers.php` contains application specific service providers. -- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. -- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. +- The `app/Console/Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. +- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration. + +## Database -### Database - When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. -- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. +- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. ### Models -- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. - - -=== fluxui-free/core rules === - -## Flux UI Free - -- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components. -- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize. -- You should use Flux UI components when available. -- Fallback to standard Blade components if Flux is unavailable. -- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project. -- Flux UI components look like this: - - - - - - -### Available Components -This is correct as of Boost installation, but there may be additional components within the codebase. - - -avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip - +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. === livewire/core rules === -## Livewire Core -- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. -- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components -- State should live on the server, with the UI reflecting it. -- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. - -## Livewire Best Practices -- Livewire components require a single root element. -- Use `wire:loading` and `wire:dirty` for delightful loading states. -- Add `wire:key` in loops: - - ```blade - @foreach ($items as $item) -
- {{ $item->name }} -
- @endforeach - ``` - -- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects: - - - public function mount(User $user) { $this->user = $user; } - public function updatedSearch() { $this->resetPage(); } - - - -## Testing Livewire - - - Livewire::test(Counter::class) - ->assertSet('count', 0) - ->call('increment') - ->assertSet('count', 1) - ->assertSee(1) - ->assertStatus(200); - - - - - $this->get('/posts/create') - ->assertSeeLivewire(CreatePost::class); - +# Livewire +- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript. +- You can use Alpine.js for client-side interactions instead of JavaScript frameworks. +- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests. === pint/core rules === -## Laravel Pint Code Formatter - -- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. -- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. +# Laravel Pint Code Formatter +- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues. === pest/core rules === ## Pest -### Testing -- If you need to verify a feature is working, write or update a Unit / Feature test. - -### Pest Tests -- All tests must be written using Pest. Use `php artisan make:test --pest `. -- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. -- Tests should test all of the happy paths, failure paths, and weird paths. -- Tests live in the `tests/Feature` and `tests/Unit` directories. -- Pest tests look and behave like this: - -it('is true', function () { - expect(true)->toBeTrue(); -}); - - -### Running Tests -- Run the minimal number of tests using an appropriate filter before finalizing code edits. -- To run all tests: `php artisan test`. -- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. -- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). -- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. - -### Pest Assertions -- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: - -it('returns all', function () { - $response = $this->postJson('/api/docs', []); - - $response->assertSuccessful(); -}); - - -### Mocking -- Mocking can be very helpful when appropriate. -- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. -- You can also create partial mocks using the same import or self method. - -### Datasets -- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. - - -it('has emails', function (string $email) { - expect($email)->not->toBeEmpty(); -})->with([ - 'james' => 'james@laravel.com', - 'taylor' => 'taylor@laravel.com', -]); - - - -=== pest/v4 rules === - -## Pest 4 - -- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage. -- Browser testing is incredibly powerful and useful for this project. -- Browser tests should live in `tests/Browser/`. -- Use the `search-docs` tool for detailed guidance on utilizing these features. - -### Browser Testing -- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test. -- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test. -- If requested, test on multiple browsers (Chrome, Firefox, Safari). -- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints). -- Switch color schemes (light/dark mode) when appropriate. -- Take screenshots or pause tests for debugging when appropriate. - -### Example Tests - - -it('may reset the password', function () { - Notification::fake(); - - $this->actingAs(User::factory()->create()); - - $page = visit('/sign-in'); // Visit on a real browser... - - $page->assertSee('Sign In') - ->assertNoJavascriptErrors() // or ->assertNoConsoleLogs() - ->click('Forgot Password?') - ->fill('email', 'nuno@laravel.com') - ->click('Send Reset Link') - ->assertSee('We have emailed your password reset link!') - - Notification::assertSent(ResetPassword::class); -}); - - - +- 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`. +- Do NOT delete tests without approval. - -$pages = visit(['/', '/about', '/contact']); - -$pages->assertNoJavascriptErrors()->assertNoConsoleLogs(); - - - -=== tailwindcss/core rules === - -## Tailwind Core - -- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. -- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) -- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically -- You can use the `search-docs` tool to get exact examples from the official documentation when needed. - -### Spacing -- When listing items, use gap utilities for spacing, don't use margins. - - -
-
Superior
-
Michigan
-
Erie
-
-
- - -### Dark Mode -- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. - - -=== tailwindcss/v4 rules === - -## Tailwind 4 - -- Always use Tailwind CSS v4 - do not use the deprecated utilities. -- `corePlugins` is not supported in Tailwind v4. -- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: - - - - -### Replaced Utilities -- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. -- Opacity values are still numeric. - -| Deprecated | Replacement | -|------------+--------------| -| bg-opacity-* | bg-black/* | -| text-opacity-* | text-black/* | -| border-opacity-* | border-black/* | -| divide-opacity-* | divide-black/* | -| ring-opacity-* | ring-black/* | -| placeholder-opacity-* | placeholder-black/* | -| flex-shrink-* | shrink-* | -| flex-grow-* | grow-* | -| overflow-ellipsis | text-ellipsis | -| decoration-slice | box-decoration-slice | -| decoration-clone | box-decoration-clone | - - -=== tests rules === - -## 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` with a specific filename or filter. diff --git a/README.md b/README.md new file mode 100644 index 00000000..0c14741c --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +Your mission is to implement an entire shop system based on the specifications im specs/*. You must do in one go without stopping. You might use sub-agents or team mode! You must test everything via Pest (unit, and functional tests). You must also additional simulate user behaviour using the Playwright MPC and confirm that all acceptance criterias are met. If you find bugs, you must fix them. The result is a perfect shop system. All requirements are perfectly implemented. All acceptance criterias are met, tested and confirmed by you. + +Continuously keep track of the progress in specs/progress.md Commit your progress after every relevant iteration with a meaningful message. + +When implementation is fully done, then make a full review meeting and showcase all features (customer- and admin-side) to me. In case bugs appear, you must fix them all and restart the review meeting. + +Don't re-use any existing implementation in another branch. Build it from scratch. diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php deleted file mode 100644 index 3c7c00c8..00000000 --- a/app/Actions/Fortify/CreateNewUser.php +++ /dev/null @@ -1,33 +0,0 @@ - $input - */ - public function create(array $input): User - { - Validator::make($input, [ - ...$this->profileRules(), - 'password' => $this->passwordRules(), - ])->validate(); - - return User::create([ - 'name' => $input['name'], - 'email' => $input['email'], - 'password' => $input['password'], - ]); - } -} diff --git a/app/Actions/Fortify/ResetUserPassword.php b/app/Actions/Fortify/ResetUserPassword.php deleted file mode 100644 index 8fda5ddd..00000000 --- a/app/Actions/Fortify/ResetUserPassword.php +++ /dev/null @@ -1,29 +0,0 @@ - $input - */ - public function reset(User $user, array $input): void - { - Validator::make($input, [ - 'password' => $this->passwordRules(), - ])->validate(); - - $user->forceFill([ - 'password' => $input['password'], - ])->save(); - } -} diff --git a/app/Actions/SanitizeHtml.php b/app/Actions/SanitizeHtml.php new file mode 100644 index 00000000..4e383d97 --- /dev/null +++ b/app/Actions/SanitizeHtml.php @@ -0,0 +1,226 @@ +> + */ + private const ALLOWED = [ + 'p' => [], + 'br' => [], + 'strong' => [], + 'em' => [], + 'u' => [], + 'ol' => [], + 'ul' => [], + 'li' => [], + 'a' => ['href'], + 'img' => ['src', 'alt'], + 'h1' => [], + 'h2' => [], + 'h3' => [], + 'h4' => [], + 'h5' => [], + 'h6' => [], + 'blockquote' => [], + 'table' => [], + 'thead' => [], + 'tbody' => [], + 'tr' => [], + 'th' => [], + 'td' => [], + 'div' => [], + 'span' => [], + ]; + + /** + * Elements removed together with their text content. + * + * @var list + */ + private const REMOVE_WITH_CONTENT = ['script', 'style']; + + /** + * Allowed URL schemes for href/src attributes (relative URLs pass too). + * + * @var list + */ + private const ALLOWED_SCHEMES = ['http', 'https', 'mailto']; + + /** + * Elements pruned when they contain no text and no element children. + * + * @var list + */ + private const PRUNE_WHEN_EMPTY = [ + 'p', 'strong', 'em', 'u', 'ol', 'ul', 'li', 'a', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', + 'table', 'thead', 'tbody', 'tr', 'div', 'span', + ]; + + /** + * Sanitize the given HTML fragment. Null and empty input pass through. + */ + public function __invoke(?string $html): ?string + { + if ($html === null || trim($html) === '') { + return $html; + } + + $document = new DOMDocument; + $document->loadHTML( + ''.$html, + LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING + ); + + // Remove the encoding workaround processing instruction. With + // LIBXML_HTML_NOIMPLIED it can surface as a comment node instead. + foreach (iterator_to_array($document->childNodes) as $child) { + if ($child instanceof \DOMProcessingInstruction + || ($child instanceof \DOMComment && str_contains($child->nodeValue ?? '', '?xml encoding'))) { + $document->removeChild($child); + } + } + + $this->sanitizeChildren($document); + $this->pruneEmptyElements($document); + + $output = ''; + foreach ($document->childNodes as $child) { + $output .= $document->saveHTML($child); + } + + return trim($output); + } + + /** + * Recursively sanitize all child nodes of the given parent. + */ + private function sanitizeChildren(DOMNode $parent): void + { + foreach (iterator_to_array($parent->childNodes) as $child) { + if (! $child instanceof DOMElement) { + continue; + } + + $tag = strtolower($child->tagName); + + if (in_array($tag, self::REMOVE_WITH_CONTENT, true)) { + $parent->removeChild($child); + + continue; + } + + if (! array_key_exists($tag, self::ALLOWED)) { + // Unwrap: keep the children, drop the element itself. + $this->sanitizeChildren($child); + + while ($child->firstChild !== null) { + $parent->insertBefore($child->firstChild, $child); + } + $parent->removeChild($child); + + continue; + } + + $this->sanitizeAttributes($child, $tag); + $this->sanitizeChildren($child); + } + } + + /** + * Strip attributes outside the allowlist and neutralize unsafe URLs. + * + * @param key-of $tag + */ + private function sanitizeAttributes(DOMElement $element, string $tag): void + { + $allowedAttributes = self::ALLOWED[$tag]; + + foreach (iterator_to_array($element->attributes) as $attribute) { + $name = strtolower($attribute->nodeName); + + if (! in_array($name, $allowedAttributes, true)) { + $element->removeAttribute($attribute->nodeName); + + continue; + } + + if (in_array($name, ['href', 'src'], true) && ! $this->isSafeUrl($attribute->nodeValue)) { + $element->removeAttribute($attribute->nodeName); + } + } + } + + /** + * Allow relative URLs and safe schemes only (blocks javascript:, data:, ...). + */ + private function isSafeUrl(string $url): bool + { + $url = trim($url); + + if ($url === '' || str_starts_with($url, '#')) { + return true; + } + + $scheme = parse_url($url, PHP_URL_SCHEME); + + if ($scheme === null) { + return true; // relative URL + } + + return in_array(strtolower($scheme), self::ALLOWED_SCHEMES, true); + } + + /** + * Whether the element has at least one child element. + */ + private function hasElementChild(DOMElement $element): bool + { + foreach ($element->childNodes as $child) { + if ($child instanceof DOMElement) { + return true; + } + } + + return false; + } + + /** + * Remove elements that carry no content (repeatedly, for nesting). + */ + private function pruneEmptyElements(DOMDocument $document): void + { + $xpath = new DOMXPath($document); + + do { + $removed = 0; + + foreach (self::PRUNE_WHEN_EMPTY as $tag) { + foreach (iterator_to_array($xpath->query('//'.$tag) ?: []) as $element) { + /** @var DOMElement $element */ + if (trim($element->textContent) === '' && ! $this->hasElementChild($element)) { + $element->parentNode?->removeChild($element); + $removed++; + } + } + } + } while ($removed > 0); + } +} diff --git a/app/Auth/CustomerPasswordBrokerManager.php b/app/Auth/CustomerPasswordBrokerManager.php new file mode 100644 index 00000000..e5c8427a --- /dev/null +++ b/app/Auth/CustomerPasswordBrokerManager.php @@ -0,0 +1,41 @@ + $config + */ + protected function createTokenRepository(array $config) + { + if (($config['table'] ?? null) !== 'customer_password_reset_tokens') { + return parent::createTokenRepository($config); + } + + $key = $this->app['config']['app.key']; + + if (str_starts_with($key, 'base64:')) { + $key = base64_decode(substr($key, 7)); + } + + return new CustomerTokenRepository( + $this->app['db']->connection($config['connection'] ?? null), + $this->app['hash'], + $config['table'], + $key, + ($config['expire'] ?? 60) * 60, + $config['throttle'] ?? 0, + ); + } +} diff --git a/app/Auth/CustomerTokenRepository.php b/app/Auth/CustomerTokenRepository.php new file mode 100644 index 00000000..15937316 --- /dev/null +++ b/app/Auth/CustomerTokenRepository.php @@ -0,0 +1,88 @@ +getTable() + ->where('store_id', $this->storeId()) + ->where('email', $user->getEmailForPasswordReset()) + ->first(); + + return $record && $this->tokenRecentlyCreated($record['created_at']); + } + + /** + * Determine if a token record exists and is valid for this store. + * + * @param string $token + */ + public function exists(CanResetPasswordContract $user, #[\SensitiveParameter] $token) + { + $record = (array) $this->getTable() + ->where('store_id', $this->storeId()) + ->where('email', $user->getEmailForPasswordReset()) + ->first(); + + return $record && + ! $this->tokenExpired($record['created_at']) && + $this->hasher->check($token, $record['token']); + } + + /** + * Delete only the current store's tokens for the user. + */ + protected function deleteExisting(CanResetPasswordContract $user) + { + return $this->getTable() + ->where('store_id', $this->storeId()) + ->where('email', $user->getEmailForPasswordReset()) + ->delete(); + } + + /** + * Build the record payload, including the store the reset belongs to. + * + * @param string $email + * @param string $token + * @return array + */ + protected function getPayload($email, #[\SensitiveParameter] $token) + { + return [ + 'store_id' => $this->storeId(), + 'email' => $email, + 'token' => $this->hasher->make($token), + 'created_at' => new Carbon, + ]; + } + + /** + * The id of the store bound to the container by ResolveStore. + */ + private function storeId(): int + { + if (! app()->bound('current_store')) { + throw new RuntimeException('Customer password resets require a resolved store.'); + } + + return (int) app('current_store')->getKey(); + } +} diff --git a/app/Auth/CustomerUserProvider.php b/app/Auth/CustomerUserProvider.php new file mode 100644 index 00000000..873530b7 --- /dev/null +++ b/app/Auth/CustomerUserProvider.php @@ -0,0 +1,27 @@ + $credentials + */ + public function retrieveByCredentials(array $credentials): ?Authenticatable + { + if (app()->bound('current_store')) { + $credentials['store_id'] = app('current_store')->getKey(); + } + + return parent::retrieveByCredentials($credentials); + } +} diff --git a/app/Contracts/PaymentProvider.php b/app/Contracts/PaymentProvider.php new file mode 100644 index 00000000..62cfa481 --- /dev/null +++ b/app/Contracts/PaymentProvider.php @@ -0,0 +1,28 @@ + $details + */ + public function charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult; + + /** + * Refund (part of) a captured payment. + */ + public function refund(Payment $payment, int $amount): RefundResult; +} diff --git a/app/Contracts/TaxProvider.php b/app/Contracts/TaxProvider.php new file mode 100644 index 00000000..4be216d1 --- /dev/null +++ b/app/Contracts/TaxProvider.php @@ -0,0 +1,16 @@ +cart_version}."); + } +} diff --git a/app/Exceptions/FulfillmentGuardException.php b/app/Exceptions/FulfillmentGuardException.php new file mode 100644 index 00000000..f78df643 --- /dev/null +++ b/app/Exceptions/FulfillmentGuardException.php @@ -0,0 +1,18 @@ +variant_id}: requested {$quantity}, available {$item->available()}." + ); + } +} diff --git a/app/Exceptions/InvalidCheckoutTransitionException.php b/app/Exceptions/InvalidCheckoutTransitionException.php new file mode 100644 index 00000000..e6bd2128 --- /dev/null +++ b/app/Exceptions/InvalidCheckoutTransitionException.php @@ -0,0 +1,17 @@ +user()->hasVerifiedEmail()) { + return redirect('/admin'); + } + + return view('admin.auth.verify-email'); + } + + /** + * Mark the user's email as verified via the signed link. + */ + public function verify(EmailVerificationRequest $request): RedirectResponse + { + $request->fulfill(); + + return redirect('/admin'); + } + + /** + * Resend the verification email (throttled by the route). + */ + public function send(Request $request): RedirectResponse + { + if ($request->user()->hasVerifiedEmail()) { + return redirect('/admin'); + } + + $request->user()->sendEmailVerificationNotification(); + + return back()->with('status', 'verification-link-sent'); + } +} diff --git a/app/Http/Controllers/Admin/Auth/LogoutController.php b/app/Http/Controllers/Admin/Auth/LogoutController.php new file mode 100644 index 00000000..b5fc3e93 --- /dev/null +++ b/app/Http/Controllers/Admin/Auth/LogoutController.php @@ -0,0 +1,28 @@ +logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect('/admin/login')->withHeaders([ + 'Cache-Control' => 'no-cache, no-store, must-revalidate', + 'Pragma' => 'no-cache', + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/CollectionController.php b/app/Http/Controllers/Api/Admin/CollectionController.php new file mode 100644 index 00000000..9d8aa07a --- /dev/null +++ b/app/Http/Controllers/Api/Admin/CollectionController.php @@ -0,0 +1,178 @@ +validate([ + 'status' => ['sometimes', Rule::enum(CollectionStatus::class)], + 'query' => ['sometimes', 'string', 'max:255'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], + ]); + + $query = Collection::query()->withCount('products')->orderByDesc('updated_at'); + + if (isset($validated['status'])) { + $query->where('status', $validated['status']); + } + + if (($validated['query'] ?? '') !== '') { + $query->where('title', 'like', '%'.$validated['query'].'%'); + } + + return CollectionResource::collection( + $query->paginate((int) ($validated['per_page'] ?? 25)), + ); + } + + /** + * POST /api/admin/v1/stores/{storeId}/collections — create. + */ + public function store(Request $request, int $storeId): JsonResponse + { + $validated = $request->validate($this->rules()); + + /** @var Store $store */ + $store = app('current_store'); + + $collection = Collection::query()->create([ + 'title' => $validated['title'], + 'handle' => HandleGenerator::generate($validated['handle'] ?? $validated['title'], 'collections', $store->getKey()), + 'description_html' => $validated['description_html'] ?? null, + 'type' => CollectionType::from($validated['type']), + 'status' => isset($validated['status']) ? CollectionStatus::from($validated['status']) : CollectionStatus::Active, + ]); + + $this->attachProducts($collection, $validated['product_ids'] ?? []); + + return (new CollectionResource($collection->load('products'))) + ->response() + ->setStatusCode(201); + } + + /** + * PUT /api/admin/v1/stores/{storeId}/collections/{collectionId} — + * partial update. product_ids replaces the set; add_/remove_product_ids + * apply incremental changes. + */ + public function update(Request $request, int $storeId, int $collectionId): CollectionResource + { + $validated = $request->validate(array_merge($this->rules(partial: true), [ + 'add_product_ids' => ['sometimes', 'array'], + 'add_product_ids.*' => ['integer'], + 'remove_product_ids' => ['sometimes', 'array'], + 'remove_product_ids.*' => ['integer'], + ])); + + $collection = Collection::query()->findOrFail($collectionId); + + /** @var Store $store */ + $store = app('current_store'); + + $attributes = Arr::only($validated, ['title', 'description_html']); + + if (isset($validated['type'])) { + $attributes['type'] = CollectionType::from($validated['type']); + } + + if (isset($validated['status'])) { + $attributes['status'] = CollectionStatus::from($validated['status']); + } + + if (array_key_exists('handle', $validated)) { + $attributes['handle'] = HandleGenerator::generate( + $validated['handle'] ?: ($validated['title'] ?? $collection->title), + 'collections', + $store->getKey(), + $collection->getKey(), + ); + } + + $collection->update($attributes); + + if (array_key_exists('product_ids', $validated)) { + $this->attachProducts($collection, $validated['product_ids'] ?? [], replace: true); + } + + if (($validated['add_product_ids'] ?? []) !== []) { + $existing = $collection->products()->pluck('products.id'); + $this->attachProducts($collection, array_values(array_diff($validated['add_product_ids'], $existing->all()))); + } + + if (($validated['remove_product_ids'] ?? []) !== []) { + $collection->products()->detach($validated['remove_product_ids']); + } + + return new CollectionResource($collection->load('products')->loadCount('products')); + } + + /** + * DELETE /api/admin/v1/stores/{storeId}/collections/{collectionId}. + */ + public function destroy(Request $request, int $storeId, int $collectionId): JsonResponse + { + Collection::query()->findOrFail($collectionId)->delete(); + + return response()->json(['message' => 'Collection deleted']); + } + + /** + * @return array + */ + private function rules(bool $partial = false): array + { + $required = fn (array $rules): array => $partial ? ['sometimes', ...$rules] : $rules; + + return [ + 'title' => $required(['required', 'string', 'max:255']), + 'handle' => ['sometimes', 'nullable', 'string', 'max:255', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'], + 'description_html' => ['sometimes', 'nullable', 'string', 'max:65535'], + 'type' => $required(['required', Rule::enum(CollectionType::class)]), + 'status' => ['sometimes', Rule::enum(CollectionStatus::class)], + 'product_ids' => ['sometimes', 'array'], + 'product_ids.*' => ['integer'], + ]; + } + + /** + * Attach products (restricted to the current store) with sequential + * pivot positions, optionally replacing the full set. + * + * @param list $productIds + */ + private function attachProducts(Collection $collection, array $productIds, bool $replace = false): void + { + $ownedIds = Product::query()->whereIn('id', $productIds)->pluck('id'); + + $pivot = $ownedIds->mapWithKeys(fn (int $id, int $index): array => [$id => ['position' => $index]])->all(); + + if ($replace) { + $collection->products()->sync($pivot); + } else { + $collection->products()->syncWithoutDetaching($pivot); + } + } +} diff --git a/app/Http/Controllers/Api/Admin/OrderController.php b/app/Http/Controllers/Api/Admin/OrderController.php new file mode 100644 index 00000000..5e411134 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/OrderController.php @@ -0,0 +1,277 @@ +validate($this->filterRules()); + + $query = $this->filteredQuery($validated) + ->with('customer') + ->withCount('lines'); + + match ($validated['sort'] ?? 'placed_at_desc') { + 'placed_at_asc' => $query->orderBy('placed_at'), + 'total_desc' => $query->orderByDesc('total_amount'), + 'total_asc' => $query->orderBy('total_amount'), + default => $query->orderByDesc('placed_at'), + }; + + return OrderListResource::collection( + $query->paginate((int) ($validated['per_page'] ?? 25)), + ); + } + + /** + * GET /api/admin/v1/stores/{storeId}/orders/{orderId} — full detail. + */ + public function show(Request $request, int $storeId, int $orderId): OrderResource + { + return new OrderResource(Order::query()->findOrFail($orderId)); + } + + /** + * POST /api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments — + * create a fulfillment and mark it shipped (spec 02 §3.4). + */ + public function storeFulfillment(Request $request, int $storeId, int $orderId, FulfillmentService $fulfillments): JsonResponse + { + $validated = $request->validate([ + 'tracking_company' => ['sometimes', 'nullable', 'string', 'max:255'], + 'tracking_number' => ['sometimes', 'nullable', 'string', 'max:255'], + 'tracking_url' => ['sometimes', 'nullable', 'url', 'max:2048'], + 'line_items' => ['required', 'array', 'min:1'], + 'line_items.*.order_line_id' => ['required', 'integer'], + 'line_items.*.quantity' => ['required', 'integer', 'min:1'], + 'notify_customer' => ['sometimes', 'boolean'], + ]); + + $order = Order::query()->findOrFail($orderId); + + abort_if( + in_array($order->status, [OrderStatus::Cancelled, OrderStatus::Fulfilled], true), + 409, + 'Order is not in a fulfillable state.', + ); + + $lines = collect($validated['line_items']) + ->mapWithKeys(fn (array $line): array => [(int) $line['order_line_id'] => (int) $line['quantity']]) + ->all(); + + $tracking = Arr::only($validated, ['tracking_company', 'tracking_number', 'tracking_url']); + + try { + $fulfillment = $fulfillments->create($order, $lines, $tracking); + } catch (FulfillmentGuardException $exception) { + abort(409, $exception->getMessage()); + } + + $fulfillments->markAsShipped($fulfillment, $tracking); + + return (new FulfillmentResource($fulfillment->refresh())) + ->response() + ->setStatusCode(201); + } + + /** + * POST /api/admin/v1/stores/{storeId}/orders/{orderId}/refunds — + * create a refund against the order's captured payment (spec 02 §3.4). + */ + public function storeRefund(Request $request, int $storeId, int $orderId, RefundService $refunds): JsonResponse + { + $validated = $request->validate([ + 'amount' => ['required', 'integer', 'min:1'], + 'reason' => ['sometimes', 'nullable', 'string', 'max:1000'], + 'line_items' => ['sometimes', 'array'], + 'line_items.*.order_line_id' => ['required', 'integer'], + 'line_items.*.quantity' => ['required', 'integer', 'min:1'], + 'notify_customer' => ['sometimes', 'boolean'], + ]); + + $order = Order::query()->findOrFail($orderId); + + $payment = $order->payments() + ->where('status', PaymentStatus::Captured) + ->orderByDesc('id') + ->first(); + + abort_if($payment === null || $order->refundableAmount() <= 0, 409, 'Order cannot be refunded.'); + + $refund = $refunds->create( + $order, + $payment, + (int) $validated['amount'], + $validated['reason'] ?? null, + ); + + return (new RefundResource($refund)) + ->response() + ->setStatusCode(201); + } + + /** + * GET /api/admin/v1/stores/{storeId}/orders/export — CSV of orders + * matching the current filter criteria (spec 05 §11.6). + */ + public function export(Request $request, int $storeId): Response + { + $validated = $request->validate($this->filterRules()); + + $orders = $this->filteredQuery($validated) + ->with(['customer', 'checkout', 'fulfillments']) + ->orderByDesc('placed_at') + ->get(); + + $shippingRateNames = []; + + $rows = $orders->map(function (Order $order) use (&$shippingRateNames): array { + $shippingRateId = $order->checkout?->shipping_method_id; + + if ($shippingRateId !== null && ! array_key_exists($shippingRateId, $shippingRateNames)) { + $shippingRateNames[$shippingRateId] = ShippingRate::query()->whereKey($shippingRateId)->value('name'); + } + + $trackingNumber = $order->fulfillments + ->sortByDesc('id') + ->first(fn ($fulfillment): bool => $fulfillment->tracking_number !== null) + ?->tracking_number; + + return [ + $order->order_number, + $order->created_at?->toIso8601ZuluString(), + $order->status->value, + $order->financial_status->value, + $order->fulfillment_status->value, + $order->customer?->email ?? $order->email, + $order->customer?->name, + $order->subtotal_amount, + $order->discount_amount, + $order->shipping_amount, + $order->tax_amount, + $order->total_amount, + $order->currency, + $shippingRateNames[$shippingRateId] ?? null, + $trackingNumber, + ]; + }); + + $handle = fopen('php://temp', 'r+'); + fputcsv($handle, [ + 'order_number', 'created_at', 'status', 'financial_status', 'fulfillment_status', + 'customer_email', 'customer_name', 'subtotal_amount', 'discount_amount', + 'shipping_amount', 'tax_amount', 'total_amount', 'currency', + 'shipping_method', 'tracking_number', + ]); + + foreach ($rows as $row) { + fputcsv($handle, $row); + } + + rewind($handle); + $csv = stream_get_contents($handle); + fclose($handle); + + return response($csv, 200, [ + 'Content-Type' => 'text/csv', + 'Content-Disposition' => 'attachment; filename="orders-'.now()->format('Y-m-d').'.csv"', + ]); + } + + /** + * Shared filter validation for list and export. + * + * @return array + */ + private function filterRules(): array + { + return [ + 'status' => ['sometimes', Rule::enum(OrderStatus::class)], + 'financial_status' => ['sometimes', Rule::enum(FinancialStatus::class)], + 'fulfillment_status' => ['sometimes', Rule::enum(FulfillmentOrderStatus::class)], + 'customer_id' => ['sometimes', 'integer'], + 'created_after' => ['sometimes', 'date'], + 'created_before' => ['sometimes', 'date'], + 'query' => ['sometimes', 'string', 'max:255'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], + 'sort' => ['sometimes', Rule::in(['placed_at_desc', 'placed_at_asc', 'total_desc', 'total_asc'])], + ]; + } + + /** + * Build the filtered order query (tenant-scoped via the global scope). + * + * @param array $filters + * @return Builder + */ + private function filteredQuery(array $filters): Builder + { + $query = Order::query(); + + if (isset($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (isset($filters['financial_status'])) { + $query->where('financial_status', $filters['financial_status']); + } + + if (isset($filters['fulfillment_status'])) { + $query->where('fulfillment_status', $filters['fulfillment_status']); + } + + if (isset($filters['customer_id'])) { + $query->where('customer_id', (int) $filters['customer_id']); + } + + if (isset($filters['created_after'])) { + $query->where('placed_at', '>=', $filters['created_after']); + } + + if (isset($filters['created_before'])) { + $query->where('placed_at', '<=', $filters['created_before']); + } + + if (($filters['query'] ?? '') !== '') { + $term = '%'.$filters['query'].'%'; + $query->where(function (Builder $builder) use ($term): void { + $builder->where('order_number', 'like', $term) + ->orWhere('email', 'like', $term) + ->orWhereHas('customer', fn (Builder $customers) => $customers->where('email', 'like', $term)); + }); + } + + return $query; + } +} diff --git a/app/Http/Controllers/Api/Admin/PlatformController.php b/app/Http/Controllers/Api/Admin/PlatformController.php new file mode 100644 index 00000000..5632b734 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/PlatformController.php @@ -0,0 +1,135 @@ +validate([ + 'name' => ['required', 'string', 'max:255'], + 'billing_email' => ['required', 'email', 'max:255'], + ]); + + $organization = Organization::query()->create($validated); + + return (new OrganizationResource($organization)) + ->response() + ->setStatusCode(201); + } + + /** + * POST /api/admin/v1/platform/stores — create a store in an organization. + */ + public function createStore(Request $request): JsonResponse + { + $validated = $request->validate([ + 'organization_id' => ['required', 'integer', Rule::exists('organizations', 'id')], + 'name' => ['required', 'string', 'max:255'], + 'handle' => ['required', 'string', 'max:63', 'regex:/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/', Rule::unique('stores', 'handle')], + 'default_currency' => ['required', 'string', 'size:3', 'alpha'], + 'default_locale' => ['required', 'string', 'max:10'], + 'timezone' => ['required', 'string', 'timezone'], + ]); + + $store = Store::query()->create(array_merge($validated, [ + 'default_currency' => strtoupper($validated['default_currency']), + 'status' => StoreStatus::Active, + ])); + + return (new StoreResource($store)) + ->response() + ->setStatusCode(201); + } + + /** + * POST /api/admin/v1/stores/{storeId}/invites — create-or-attach a + * user with the given role. 409 when already a member. + */ + public function invite(Request $request, int $storeId): JsonResponse + { + $validated = $request->validate([ + 'email' => ['required', 'email', 'max:255'], + 'role' => ['required', Rule::enum(StoreUserRole::class)], + ]); + + /** @var Store $store */ + $store = app('current_store'); + + $user = User::query()->where('email', $validated['email'])->first(); + + $isMember = $user !== null && StoreUser::query() + ->where('store_id', $store->getKey()) + ->where('user_id', $user->getKey()) + ->exists(); + + abort_if($isMember, 409, 'User is already a member of this store.'); + + $user ??= User::query()->create([ + 'name' => Str::before($validated['email'], '@'), + 'email' => $validated['email'], + 'password_hash' => Str::random(32), + ]); + + StoreUser::query()->create([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => StoreUserRole::from($validated['role']), + ]); + + return response()->json([ + 'data' => [ + 'email' => $user->email, + 'role' => $validated['role'], + 'invited_at' => now()->toIso8601ZuluString(), + 'expires_at' => now()->addDays(7)->toIso8601ZuluString(), + ], + ], 201); + } + + /** + * GET /api/admin/v1/stores/{storeId}/me — the token user's membership + * details for the store. + */ + public function me(Request $request, int $storeId): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + + /** @var Store $store */ + $store = app('current_store'); + + return response()->json([ + 'data' => [ + 'user_id' => $user->getKey(), + 'store_id' => $store->getKey(), + 'role' => $user->roleForStore($store)?->value, + 'email' => $user->email, + 'name' => $user->name, + 'permissions' => $user->currentAccessToken()?->abilities ?? [], + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/ProductController.php b/app/Http/Controllers/Api/Admin/ProductController.php new file mode 100644 index 00000000..5844d340 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/ProductController.php @@ -0,0 +1,322 @@ +validate([ + 'status' => ['sometimes', Rule::enum(ProductStatus::class)], + 'query' => ['sometimes', 'string', 'max:255'], + 'collection_id' => ['sometimes', 'integer'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], + 'sort' => ['sometimes', Rule::in(['title_asc', 'title_desc', 'created_at_asc', 'created_at_desc', 'updated_at_desc'])], + ]); + + $query = Product::query()->with(['variants.inventoryItem', 'media']); + + if (isset($validated['status'])) { + $query->where('status', $validated['status']); + } + + if (($validated['query'] ?? '') !== '') { + $term = '%'.$validated['query'].'%'; + $query->where(function ($builder) use ($term): void { + $builder->where('title', 'like', $term) + ->orWhere('vendor', 'like', $term) + ->orWhereHas('variants', fn ($variants) => $variants->where('sku', 'like', $term)); + }); + } + + if (isset($validated['collection_id'])) { + $query->whereHas('collections', fn ($collections) => $collections->where('collections.id', (int) $validated['collection_id'])); + } + + match ($validated['sort'] ?? 'updated_at_desc') { + 'title_asc' => $query->orderBy('title'), + 'title_desc' => $query->orderByDesc('title'), + 'created_at_asc' => $query->orderBy('created_at'), + 'created_at_desc' => $query->orderByDesc('created_at'), + default => $query->orderByDesc('updated_at'), + }; + + return ProductListResource::collection( + $query->paginate((int) ($validated['per_page'] ?? 25)), + ); + } + + /** + * POST /api/admin/v1/stores/{storeId}/products — nested create via + * the ProductService (options, variants, inventory, collections). + */ + public function store(Request $request, int $storeId): JsonResponse + { + $validated = $request->validate($this->productRules()); + + /** @var Store $store */ + $store = app('current_store'); + + $product = $this->products->create($store, $this->mapToServicePayload($validated, $store)); + + if (($validated['collections'] ?? []) !== []) { + $this->syncCollections($product, $validated['collections']); + } + + return (new ProductResource($product->refresh())) + ->response() + ->setStatusCode(201); + } + + /** + * GET /api/admin/v1/stores/{storeId}/products/{productId} — full + * product with variants, options, media, and collections. + */ + public function show(Request $request, int $storeId, int $productId): ProductResource + { + return new ProductResource(Product::query()->findOrFail($productId)); + } + + /** + * PUT /api/admin/v1/stores/{storeId}/products/{productId} — partial + * update. Status changes go through the state machine. + */ + public function update(Request $request, int $storeId, int $productId): JsonResponse|ProductResource + { + $validated = $request->validate($this->productRules(partial: true)); + + $product = Product::query()->findOrFail($productId); + + /** @var Store $store */ + $store = app('current_store'); + + $servicePayload = $this->mapToServicePayload($validated, $store); + + if ($servicePayload !== []) { + $product = $this->products->update($product, $servicePayload); + } + + if (isset($validated['status']) && $product->status !== ProductStatus::from($validated['status'])) { + try { + $this->products->transitionStatus($product, ProductStatus::from($validated['status'])); + } catch (InvalidProductTransitionException $exception) { + abort(409, $exception->getMessage()); + } + } + + if (($validated['collections'] ?? null) !== null) { + $this->syncCollections($product, $validated['collections']); + } + + return new ProductResource($product->refresh()); + } + + /** + * DELETE /api/admin/v1/stores/{storeId}/products/{productId} — + * archive (soft delete). Products with orders cannot be hard-deleted. + */ + public function destroy(Request $request, int $storeId, int $productId): JsonResponse + { + $product = Product::query()->findOrFail($productId); + + try { + if ($product->status !== ProductStatus::Archived) { + $this->products->transitionStatus($product, ProductStatus::Archived); + } + } catch (InvalidProductTransitionException $exception) { + abort(409, $exception->getMessage()); + } + + return response()->json([ + 'data' => [ + 'id' => $product->id, + 'status' => $product->status->value, + 'updated_at' => $product->updated_at?->toIso8601ZuluString(), + ], + ]); + } + + /** + * POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload + * — stub presigned upload: creates the media row in processing state + * and returns the storage target (spec 02 §3.2). + */ + public function presignUpload(Request $request, int $storeId, int $productId): JsonResponse + { + $validated = $request->validate([ + 'filename' => ['required', 'string', 'max:255'], + 'content_type' => ['required', Rule::in(['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'video/mp4'])], + 'byte_size' => ['required', 'integer', 'min:1'], + ]); + + $product = Product::query()->findOrFail($productId); + + $isVideo = $validated['content_type'] === 'video/mp4'; + $maxBytes = $isVideo ? 500 * 1024 * 1024 : 50 * 1024 * 1024; + + if ((int) $validated['byte_size'] > $maxBytes) { + throw ValidationException::withMessages([ + 'byte_size' => ['The file exceeds the maximum allowed size.'], + ]); + } + + $extension = pathinfo($validated['filename'], PATHINFO_EXTENSION) ?: ($isVideo ? 'mp4' : 'jpg'); + $storageKey = sprintf('stores/%d/products/%d/media/%s.%s', $storeId, $product->id, (string) Str::uuid(), $extension); + + $media = $product->media()->create([ + 'type' => $isVideo ? MediaType::Video : MediaType::Image, + 'storage_key' => $storageKey, + 'mime_type' => $validated['content_type'], + 'byte_size' => $validated['byte_size'], + 'position' => ((int) $product->media()->max('position')) + 1, + 'status' => MediaStatus::Processing, + ]); + + // Stub URL: there is no real object storage behind this build. + $uploadUrl = url('/storage/'.$storageKey).'?expires='.now()->addMinutes(10)->getTimestamp().'&signature='.Str::random(40); + + return response()->json([ + 'upload_url' => $uploadUrl, + 'method' => 'PUT', + 'headers' => [ + 'Content-Type' => $validated['content_type'], + ], + 'storage_key' => $storageKey, + 'media_id' => $media->id, + 'expires_at' => now()->addMinutes(10)->toIso8601ZuluString(), + ], 201); + } + + /** + * Validation rules for create (all required per spec) and partial + * update (everything optional). + * + * @return array + */ + private function productRules(bool $partial = false): array + { + $required = fn (array $rules): array => $partial ? ['sometimes', ...$rules] : $rules; + + return [ + 'title' => $required(['required', 'string', 'max:255']), + 'handle' => ['sometimes', 'nullable', 'string', 'max:255', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'], + 'description_html' => ['sometimes', 'nullable', 'string', 'max:65535'], + 'vendor' => ['sometimes', 'nullable', 'string', 'max:255'], + 'product_type' => ['sometimes', 'nullable', 'string', 'max:255'], + 'status' => ['sometimes', Rule::in([ProductStatus::Draft->value, ProductStatus::Active->value])], + 'tags' => ['sometimes', 'array', 'max:50'], + 'tags.*' => ['string', 'max:255'], + 'options' => ['sometimes', 'array', 'max:3'], + 'options.*.name' => ['required', 'string', 'max:255'], + 'options.*.position' => ['required', 'integer', 'min:1', 'max:3'], + 'variants' => $partial ? ['sometimes', 'array', 'max:100'] : ['required', 'array', 'min:1', 'max:100'], + 'variants.*.id' => ['sometimes', 'integer'], + 'variants.*.sku' => $partial ? ['sometimes', 'nullable', 'string', 'max:255'] : ['required', 'string', 'max:255'], + 'variants.*.barcode' => ['sometimes', 'nullable', 'string', 'max:255'], + 'variants.*.price_amount' => $partial ? ['sometimes', 'integer', 'min:0'] : ['required', 'integer', 'min:0'], + 'variants.*.compare_at_amount' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'variants.*.currency' => ['sometimes', 'nullable', 'string', 'size:3'], + 'variants.*.weight_g' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'variants.*.requires_shipping' => ['sometimes', 'boolean'], + 'variants.*.position' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'variants.*.option_values' => ['sometimes', 'array'], + 'variants.*.option_values.*.option_name' => ['required', 'string', 'max:255'], + 'variants.*.option_values.*.value' => ['required', 'string', 'max:255'], + 'variants.*.inventory' => ['sometimes', 'array'], + 'variants.*.inventory.quantity_on_hand' => ['sometimes', 'integer', 'min:0'], + 'variants.*.inventory.policy' => ['sometimes', Rule::in(['deny', 'continue'])], + 'collections' => ['sometimes', 'array'], + 'collections.*' => ['integer'], + ]; + } + + /** + * Map the API request shape onto the ProductService's internal + * payload shape: option values are derived from the variants' + * option_values, and variants match by plain value lists. + * + * @param array $validated + * @return array + */ + private function mapToServicePayload(array $validated, Store $store): array + { + $payload = Arr::only($validated, [ + 'title', 'handle', 'description_html', 'vendor', 'product_type', 'tags', + ]); + + if (array_key_exists('options', $validated)) { + $payload['options'] = collect($validated['options'])->map(fn (array $option): array => [ + 'name' => $option['name'], + 'values' => collect($validated['variants'] ?? []) + ->flatMap(fn (array $variant): array => $variant['option_values'] ?? []) + ->where('option_name', $option['name']) + ->pluck('value') + ->unique() + ->values() + ->all(), + ])->all(); + } + + if (array_key_exists('variants', $validated)) { + $variants = collect($validated['variants'])->map(fn (array $variant): array => array_merge( + Arr::only($variant, ['id', 'sku', 'barcode', 'price_amount', 'compare_at_amount', 'weight_g', 'requires_shipping', 'position']), + ['currency' => $variant['currency'] ?? $store->default_currency], + ['option_values' => collect($variant['option_values'] ?? [])->pluck('value')->all()], + isset($variant['inventory']) ? ['inventory' => $variant['inventory']] : [], + ))->all(); + + // Products without options take their single default variant. + $payload['variants'] = array_key_exists('options', $validated) && $validated['options'] !== [] + ? $variants + : [Arr::except($variants[0] ?? [], ['option_values'])]; + } + + return $payload; + } + + /** + * Attach the given collection ids (restricted to the current store) + * with sequential pivot positions. + * + * @param list $collectionIds + */ + private function syncCollections(Product $product, array $collectionIds): void + { + $ownedIds = \App\Models\Collection::query() + ->whereIn('id', $collectionIds) + ->pluck('id'); + + $product->collections()->sync( + $ownedIds->mapWithKeys(fn (int $id, int $index): array => [$id => ['position' => $index]])->all(), + ); + + $product->unsetRelation('collections'); + } +} diff --git a/app/Http/Controllers/Api/Storefront/AnalyticsController.php b/app/Http/Controllers/Api/Storefront/AnalyticsController.php new file mode 100644 index 00000000..709668a1 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/AnalyticsController.php @@ -0,0 +1,98 @@ +validate([ + 'events' => ['required', 'array', 'min:1', 'max:50'], + 'events.*.type' => ['required', 'string', Rule::in(AnalyticsService::EVENT_TYPES)], + 'events.*.session_id' => ['required', 'string', 'max:100'], + 'events.*.client_event_id' => ['required', 'string', 'max:100'], + 'events.*.occurred_at' => [ + 'required', + 'date', + function (string $attribute, mixed $value, \Closure $fail): void { + $occurredAt = Carbon::parse($value); + + if ($occurredAt->greaterThan(now()->addHour()) || $occurredAt->lessThan(now()->subHour())) { + $fail('The '.$attribute.' must be within one hour of the current time.'); + } + }, + ], + 'events.*.properties' => [ + 'nullable', + 'array', + function (string $attribute, mixed $value, \Closure $fail): void { + if (is_array($value) && $this->arrayDepth($value) > 3) { + $fail('The '.$attribute.' must not be more than 3 levels deep.'); + } + }, + ], + ]); + + /** @var Store $store */ + $store = app('current_store'); + + $accepted = 0; + $rejected = 0; + + foreach ($validated['events'] as $event) { + try { + $this->analytics->track( + $store, + $event['type'], + $event['properties'] ?? [], + $event['session_id'], + null, + $event['client_event_id'], + $event['occurred_at'], + ); + $accepted++; + } catch (\Throwable) { + // A single malformed row must not fail the whole batch. + $rejected++; + } + } + + return response()->json(['accepted' => $accepted, 'rejected' => $rejected], 202); + } + + /** + * Nesting depth of an associative/array structure (root level = 1). + */ + private function arrayDepth(array $array): int + { + $depth = 1; + + foreach ($array as $value) { + if (is_array($value)) { + $depth = max($depth, 1 + $this->arrayDepth($value)); + } + } + + return $depth; + } +} diff --git a/app/Http/Controllers/Api/Storefront/CartController.php b/app/Http/Controllers/Api/Storefront/CartController.php new file mode 100644 index 00000000..329873a5 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/CartController.php @@ -0,0 +1,129 @@ +validate([ + 'currency' => ['nullable', 'string', 'size:3', 'alpha'], + ]); + + $cart = $this->carts->create(app('current_store'), $request->user('customer')); + + if (! empty($validated['currency'])) { + $cart->update(['currency' => strtoupper($validated['currency'])]); + } + + return (new CartResource($cart)) + ->response() + ->setStatusCode(201); + } + + /** + * GET /api/storefront/v1/carts/{cartId} — retrieve a cart. + */ + public function show(int $cartId): CartResource + { + return new CartResource(Cart::findOrFail($cartId)); + } + + /** + * POST /api/storefront/v1/carts/{cartId}/lines — add a line item. + */ + public function addLine(Request $request, int $cartId): JsonResponse + { + $validated = $request->validate([ + 'variant_id' => ['required', 'integer'], + 'quantity' => ['required', 'integer', 'min:1', 'max:9999'], + 'cart_version' => ['sometimes', 'integer', 'min:1'], + ]); + + $cart = Cart::findOrFail($cartId); + $this->assertVersion($cart, $validated['cart_version'] ?? null); + + try { + $this->carts->addLine($cart, (int) $validated['variant_id'], (int) $validated['quantity']); + } catch (InsufficientInventoryException) { + throw ValidationException::withMessages([ + 'variant_id' => ['The selected variant is out of stock.'], + ]); + } + + return (new CartResource($cart->refresh())) + ->response() + ->setStatusCode(201); + } + + /** + * PUT /api/storefront/v1/carts/{cartId}/lines/{lineId} — update quantity. + */ + public function updateLine(Request $request, int $cartId, int $lineId): CartResource + { + $validated = $request->validate([ + 'quantity' => ['required', 'integer', 'min:1', 'max:9999'], + 'cart_version' => ['required', 'integer', 'min:1'], + ]); + + $cart = Cart::findOrFail($cartId); + $this->assertVersion($cart, $validated['cart_version']); + + try { + $this->carts->updateLineQuantity($cart, $lineId, (int) $validated['quantity']); + } catch (InsufficientInventoryException) { + throw ValidationException::withMessages([ + 'quantity' => ['The selected variant is out of stock.'], + ]); + } + + return new CartResource($cart->refresh()); + } + + /** + * DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId} — remove a line. + */ + public function removeLine(Request $request, int $cartId, int $lineId): CartResource + { + $validated = $request->validate([ + 'cart_version' => ['required', 'integer', 'min:1'], + ]); + + $cart = Cart::findOrFail($cartId); + $this->assertVersion($cart, $validated['cart_version']); + + $this->carts->removeLine($cart, $lineId); + + return new CartResource($cart->refresh()); + } + + /** + * Verify the expected version when the client sends one. + * + * @throws CartVersionMismatchException + */ + private function assertVersion(Cart $cart, ?int $expectedVersion): void + { + if ($expectedVersion !== null) { + $this->carts->assertVersion($cart, $expectedVersion); + } + } +} diff --git a/app/Http/Controllers/Api/Storefront/CheckoutController.php b/app/Http/Controllers/Api/Storefront/CheckoutController.php new file mode 100644 index 00000000..8f304fdb --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/CheckoutController.php @@ -0,0 +1,238 @@ +validate([ + 'cart_id' => ['required', 'integer'], + 'email' => ['required', 'email', 'max:255'], + ]); + + $cart = Cart::findOrFail((int) $validated['cart_id']); + + $checkout = $this->checkoutService->createFromCart( + $cart, + $validated['email'], + $request->user('customer'), + ); + + return (new CheckoutResource($checkout)) + ->response() + ->setStatusCode(201); + } + + /** + * GET /api/storefront/v1/checkouts/{checkoutId} — current state (410 when expired). + */ + public function show(int $checkoutId): CheckoutResource + { + $checkout = Checkout::findOrFail($checkoutId); + $this->guardNotExpired($checkout); + + return new CheckoutResource($checkout); + } + + /** + * PUT /api/storefront/v1/checkouts/{checkoutId}/address. + */ + public function setAddress(SetCheckoutAddressRequest $request, int $checkoutId): CheckoutResource + { + $checkout = Checkout::findOrFail($checkoutId); + $this->guardNotExpired($checkout); + + $this->checkoutService->setAddress($checkout, $request->validated()); + + return new CheckoutResource($checkout->refresh()); + } + + /** + * PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method. + */ + public function setShippingMethod(Request $request, int $checkoutId): CheckoutResource + { + $validated = $request->validate([ + 'shipping_method_id' => ['required', 'integer'], + ]); + + $checkout = Checkout::findOrFail($checkoutId); + $this->guardNotExpired($checkout); + + $this->checkoutService->setShippingMethod($checkout, (int) $validated['shipping_method_id']); + + return new CheckoutResource($checkout->refresh()); + } + + /** + * PUT /api/storefront/v1/checkouts/{checkoutId}/payment-method. + */ + public function selectPaymentMethod(Request $request, int $checkoutId): CheckoutResource + { + $validated = $request->validate([ + 'payment_method' => ['required', 'string', 'in:credit_card,paypal,bank_transfer'], + ]); + + $checkout = Checkout::findOrFail($checkoutId); + $this->guardNotExpired($checkout); + + $this->checkoutService->selectPaymentMethod($checkout, $validated['payment_method']); + + return new CheckoutResource($checkout->refresh()); + } + + /** + * POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount. + */ + public function applyDiscount(ApplyDiscountRequest $request, int $checkoutId): JsonResponse|CheckoutResource + { + $checkout = Checkout::findOrFail($checkoutId); + $this->guardNotExpired($checkout); + + $result = $this->checkoutService->applyDiscount($checkout, $request->validated('code')); + + if (! $result->valid) { + $status = in_array($result->errorCode, ['discount_expired', 'discount_usage_limit_reached'], true) + ? 400 + : 422; + + return response()->json([ + 'message' => $result->errorMessage, + 'error_code' => $result->errorCode, + ], $status); + } + + return new CheckoutResource($checkout->refresh()); + } + + /** + * POST /api/storefront/v1/checkouts/{checkoutId}/pay (spec 02 §2.3): + * charge the selected payment method via the Mock PSP and create the + * order. Idempotent — a completed checkout returns its existing order. + */ + public function pay(Request $request, int $checkoutId): JsonResponse + { + $validated = $request->validate([ + 'payment_method' => ['required', 'string', 'in:credit_card,paypal,bank_transfer'], + 'card_number' => ['required_if:payment_method,credit_card', 'string', 'max:25'], + 'card_expiry' => ['required_if:payment_method,credit_card', 'string', 'max:7'], + 'card_cvc' => ['required_if:payment_method,credit_card', 'string', 'max:4'], + 'card_holder' => ['required_if:payment_method,credit_card', 'string', 'max:255'], + ]); + + $checkout = Checkout::findOrFail($checkoutId); + $this->guardNotExpired($checkout); + + if ($checkout->status === CheckoutStatus::Completed) { + $order = Order::query()->where('checkout_id', $checkout->id)->firstOrFail(); + + return $this->paymentResponse($checkout, $order); + } + + abort_if($checkout->status !== CheckoutStatus::PaymentSelected, 409, 'The checkout is not in a valid state for payment.'); + + if ($checkout->payment_method->value !== $validated['payment_method']) { + throw ValidationException::withMessages([ + 'payment_method' => ['The payment method does not match the method selected for this checkout.'], + ]); + } + + try { + $order = $this->checkoutService->completeCheckout($checkout, $validated); + } catch (PaymentFailedException $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + 'error_code' => $exception->errorCode, + ], 422); + } catch (InsufficientInventoryException $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + 'error_code' => 'insufficient_inventory', + ], 422); + } + + return $this->paymentResponse($checkout->refresh(), $order); + } + + /** + * DELETE /api/storefront/v1/checkouts/{checkoutId}/discount. + */ + public function removeDiscount(int $checkoutId): CheckoutResource + { + $checkout = Checkout::findOrFail($checkoutId); + $this->guardNotExpired($checkout); + + abort_if($checkout->discount_code === null, 404, 'No discount applied to this checkout.'); + + $this->checkoutService->removeDiscount($checkout); + + return new CheckoutResource($checkout->refresh()); + } + + /** + * Build the /pay success payload (spec 02 §2.3), including bank transfer + * instructions for deferred payments. + */ + private function paymentResponse(Checkout $checkout, Order $order): JsonResponse + { + $payload = [ + 'checkout_id' => $checkout->id, + 'status' => CheckoutStatus::Completed->value, + 'order' => [ + 'id' => $order->id, + 'order_number' => $order->order_number, + 'status' => $order->status->value, + 'financial_status' => $order->financial_status->value, + 'payment_method' => $order->payment_method->value, + 'total_amount' => $order->total_amount, + 'currency' => $order->currency, + ], + ]; + + if ($order->payment_method === PaymentMethod::BankTransfer) { + $payload['bank_transfer_instructions'] = [ + 'bank_name' => 'Mock Bank AG', + 'iban' => 'DE89 3704 0044 0532 0130 00', + 'bic' => 'COBADEFFXXX', + 'reference' => $order->order_number, + 'amount_formatted' => Money::format($order->total_amount, $order->currency), + ]; + } + + return response()->json($payload); + } + + /** + * Abort with 410 when the checkout has expired. + */ + private function guardNotExpired(Checkout $checkout): void + { + abort_if($checkout->isExpired(), 410, 'The checkout has expired.'); + } +} diff --git a/app/Http/Controllers/Api/Storefront/OrderController.php b/app/Http/Controllers/Api/Storefront/OrderController.php new file mode 100644 index 00000000..10bfc90f --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/OrderController.php @@ -0,0 +1,72 @@ +where('order_number', $orderNumber) + ->firstOrFail(); + + abort_unless(OrderToken::validate($order, $request->query('token')), 401, 'Invalid or missing order token.'); + + $address = $order->shipping_address_json ?? []; + + return response()->json([ + 'order_number' => $order->order_number, + 'status' => $order->status->value, + 'financial_status' => $order->financial_status->value, + 'fulfillment_status' => $order->fulfillment_status->value, + 'email' => $order->email, + 'currency' => $order->currency, + 'placed_at' => $order->placed_at?->toIso8601ZuluString(), + 'lines' => $order->lines->map(fn ($line): array => [ + 'title_snapshot' => $line->title_snapshot, + 'variant_title' => $line->variant?->title(), + 'sku_snapshot' => $line->sku_snapshot, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'total_amount' => $line->total_amount, + ])->all(), + 'totals' => [ + 'subtotal_amount' => $order->subtotal_amount, + 'discount_amount' => $order->discount_amount, + 'shipping_amount' => $order->shipping_amount, + 'tax_amount' => $order->tax_amount, + 'total_amount' => $order->total_amount, + ], + 'shipping_address' => [ + 'first_name' => $address['first_name'] ?? null, + 'last_name' => $address['last_name'] ?? null, + 'address1' => $address['address1'] ?? null, + 'city' => $address['city'] ?? null, + 'country' => $address['country_code'] ?? $address['country'] ?? null, + 'postal_code' => $address['postal_code'] ?? null, + ], + 'fulfillments' => $order->fulfillments->map(fn ($fulfillment): array => [ + 'id' => $fulfillment->id, + 'status' => $fulfillment->status->value, + 'tracking_company' => $fulfillment->tracking_company, + 'tracking_number' => $fulfillment->tracking_number, + 'tracking_url' => $fulfillment->tracking_url, + 'shipped_at' => $fulfillment->shipped_at?->toIso8601ZuluString(), + ])->all(), + ]); + } +} diff --git a/app/Http/Controllers/Api/Storefront/SearchController.php b/app/Http/Controllers/Api/Storefront/SearchController.php new file mode 100644 index 00000000..eab63e7a --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/SearchController.php @@ -0,0 +1,108 @@ +validate([ + 'q' => ['required', 'string', 'min:1', 'max:200'], + 'filters' => ['nullable', 'string', 'json'], + 'sort' => ['nullable', Rule::in(['relevance', 'price_asc', 'price_desc', 'newest', 'best_selling'])], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:50'], + ]); + + $filters = json_decode($validated['filters'] ?? '[]', true); + $filters = is_array($filters) ? $filters : []; + + /** @var \App\Models\Store $store */ + $store = app('current_store'); + + $paginator = $this->search->search( + $store, + $validated['q'], + $filters, + (int) ($validated['per_page'] ?? 24), + $validated['sort'] ?? 'relevance', + (int) ($validated['page'] ?? 1), + ); + + return response()->json([ + 'query' => $validated['q'], + 'results' => collect($paginator->items()) + ->map(fn (Product $product): array => $this->productResult($store, $product)) + ->all(), + 'facets' => $this->search->facets($store, $validated['q']), + 'pagination' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + /** + * GET /api/storefront/v1/search/suggest — autocomplete suggestions. + */ + public function suggest(Request $request): JsonResponse + { + $validated = $request->validate([ + 'q' => ['required', 'string', 'min:1', 'max:100'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:10'], + ]); + + return response()->json([ + 'query' => $validated['q'], + 'suggestions' => $this->search + ->autocomplete(app('current_store'), $validated['q'], (int) ($validated['limit'] ?? 5)) + ->values() + ->all(), + ]); + } + + /** + * Shape a product for the search result payload. + * + * @return array + */ + private function productResult(\App\Models\Store $store, Product $product): array + { + $variant = $product->variants->firstWhere('is_default', true) ?? $product->variants->first(); + $image = $product->media->firstWhere('status', \App\Enums\MediaStatus::Ready); + $compareAt = $product->variants->max('compare_at_amount'); + + return [ + 'id' => $product->id, + 'title' => $product->title, + 'handle' => $product->handle, + 'vendor' => $product->vendor, + 'product_type' => $product->product_type, + 'price_amount' => $product->variants->min('price_amount'), + 'compare_at_amount' => $compareAt !== null ? (int) $compareAt : null, + 'currency' => $variant?->currency ?? $store->default_currency, + 'image_url' => $image?->url(), + 'in_stock' => $product->variants->contains( + fn (\App\Models\ProductVariant $variant): bool => $variant->isInStock(), + ), + 'tags' => $product->tags ?? [], + ]; + } +} diff --git a/app/Http/Controllers/Storefront/Account/LogoutController.php b/app/Http/Controllers/Storefront/Account/LogoutController.php new file mode 100644 index 00000000..18e3aef5 --- /dev/null +++ b/app/Http/Controllers/Storefront/Account/LogoutController.php @@ -0,0 +1,28 @@ +logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect('/account/login')->withHeaders([ + 'Cache-Control' => 'no-cache, no-store, must-revalidate', + 'Pragma' => 'no-cache', + ]); + } +} diff --git a/app/Http/Middleware/CheckAnyStoreRole.php b/app/Http/Middleware/CheckAnyStoreRole.php new file mode 100644 index 00000000..015dedd9 --- /dev/null +++ b/app/Http/Middleware/CheckAnyStoreRole.php @@ -0,0 +1,23 @@ +bound('current_store'), 403, 'You do not have access to this store.'); + + $store = app('current_store'); + $user = $request->user(); + + $storeUser = $user === null ? null : StoreUser::query() + ->where('store_id', $store->getKey()) + ->where('user_id', $user->getKey()) + ->first(); + + abort_if($storeUser === null, 403, 'You do not have access to this store.'); + + $allowedRoles = array_map( + fn (string $role): StoreUserRole => StoreUserRole::from($role), + $roles, + ); + + abort_unless(in_array($storeUser->role, $allowedRoles, true), 403, 'Insufficient permissions.'); + + $request->attributes->set('store_user', $storeUser); + + return $next($request); + } +} diff --git a/app/Http/Middleware/CheckTokenAbility.php b/app/Http/Middleware/CheckTokenAbility.php new file mode 100644 index 00000000..9fb54231 --- /dev/null +++ b/app/Http/Middleware/CheckTokenAbility.php @@ -0,0 +1,26 @@ +user(); + + abort_if($user === null || ! $user->tokenCan($ability), 403, "This token is missing the required ability: {$ability}."); + + return $next($request); + } +} diff --git a/app/Http/Middleware/CustomerAuthenticate.php b/app/Http/Middleware/CustomerAuthenticate.php new file mode 100644 index 00000000..7be5f811 --- /dev/null +++ b/app/Http/Middleware/CustomerAuthenticate.php @@ -0,0 +1,27 @@ +check()) { + $request->session()->put('url.intended', $request->fullUrl()); + + return redirect('/account/login'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/ResolveAdminStore.php b/app/Http/Middleware/ResolveAdminStore.php new file mode 100644 index 00000000..b9dbe44d --- /dev/null +++ b/app/Http/Middleware/ResolveAdminStore.php @@ -0,0 +1,21 @@ +resolveAdminStore($request) + : $this->resolveStorefrontStore($request); + + app()->instance('current_store', $store); + view()->share('currentStore', $store); + + return $next($request); + } + + /** + * Resolve the store from the request hostname (storefront). + */ + protected function resolveStorefrontStore(Request $request): Store + { + $hostname = $request->getHost(); + + $storeId = Cache::remember( + "store_domain:{$hostname}", + now()->addMinutes(5), + function () use ($hostname): ?int { + $id = StoreDomain::query()->where('hostname', $hostname)->value('store_id'); + + return $id === null ? null : (int) $id; + }, + ); + + abort_if($storeId === null, 404, 'Store not found.'); + + $store = Store::query()->find($storeId); + + abort_if($store === null, 404, 'Store not found.'); + abort_if($store->status === StoreStatus::Suspended, 503, 'This store is currently unavailable.'); + + return $store; + } + + /** + * Resolve the store from the session (admin panel). + */ + protected function resolveAdminStore(Request $request): Store + { + $storeId = $request->session()->get('current_store_id'); + $user = $request->user(); + + abort_if($storeId === null || $user === null, 403, 'You do not have access to this store.'); + + $hasMembership = StoreUser::query() + ->where('store_id', $storeId) + ->where('user_id', $user->getKey()) + ->exists(); + + abort_unless($hasMembership, 403, 'You do not have access to this store.'); + + $store = Store::query()->find($storeId); + + abort_if($store === null, 403, 'You do not have access to this store.'); + abort_if($store->status === StoreStatus::Suspended, 403, 'This store is currently unavailable.'); + + return $store; + } +} diff --git a/app/Http/Middleware/ResolveStoreFromRoute.php b/app/Http/Middleware/ResolveStoreFromRoute.php new file mode 100644 index 00000000..719eeabf --- /dev/null +++ b/app/Http/Middleware/ResolveStoreFromRoute.php @@ -0,0 +1,44 @@ +route('storeId'); + + if ($storeId === null) { + return $next($request); + } + + $store = Store::query()->find($storeId); + + abort_if($store === null, 404, 'Store not found.'); + + $user = $request->user(); + + $hasMembership = $user !== null && StoreUser::query() + ->where('store_id', $store->getKey()) + ->where('user_id', $user->getKey()) + ->exists(); + + abort_unless($hasMembership, 403, 'You do not have access to this store.'); + + app()->instance('current_store', $store); + + return $next($request); + } +} diff --git a/app/Http/Middleware/ResolveStorefrontStore.php b/app/Http/Middleware/ResolveStorefrontStore.php new file mode 100644 index 00000000..502403a5 --- /dev/null +++ b/app/Http/Middleware/ResolveStorefrontStore.php @@ -0,0 +1,21 @@ +> + */ + public function rules(): array + { + return [ + 'code' => ['required', 'string', 'max:50'], + ]; + } +} diff --git a/app/Http/Requests/SetCheckoutAddressRequest.php b/app/Http/Requests/SetCheckoutAddressRequest.php new file mode 100644 index 00000000..80b42332 --- /dev/null +++ b/app/Http/Requests/SetCheckoutAddressRequest.php @@ -0,0 +1,50 @@ +> + */ + public function rules(): array + { + return [ + 'email' => ['sometimes', 'email', 'max:255'], + 'shipping_address' => ['required', 'array'], + 'shipping_address.first_name' => ['required', 'string', 'max:255'], + 'shipping_address.last_name' => ['required', 'string', 'max:255'], + 'shipping_address.address1' => ['required', 'string', 'max:500'], + 'shipping_address.address2' => ['nullable', 'string', 'max:500'], + 'shipping_address.company' => ['nullable', 'string', 'max:255'], + 'shipping_address.city' => ['required', 'string', 'max:255'], + 'shipping_address.province' => ['nullable', 'string', 'max:255'], + 'shipping_address.province_code' => ['nullable', 'string', 'max:10'], + 'shipping_address.country' => ['required', 'string', 'max:255'], + 'shipping_address.country_code' => ['required', 'string', 'size:2', 'alpha'], + 'shipping_address.postal_code' => ['required', 'string', 'max:20'], + 'shipping_address.phone' => ['nullable', 'string', 'max:50'], + 'billing_address' => ['nullable', 'array'], + 'billing_address.first_name' => ['required_with:billing_address', 'string', 'max:255'], + 'billing_address.last_name' => ['required_with:billing_address', 'string', 'max:255'], + 'billing_address.address1' => ['required_with:billing_address', 'string', 'max:500'], + 'billing_address.address2' => ['nullable', 'string', 'max:500'], + 'billing_address.company' => ['nullable', 'string', 'max:255'], + 'billing_address.city' => ['required_with:billing_address', 'string', 'max:255'], + 'billing_address.province' => ['nullable', 'string', 'max:255'], + 'billing_address.province_code' => ['nullable', 'string', 'max:10'], + 'billing_address.country' => ['required_with:billing_address', 'string', 'max:255'], + 'billing_address.country_code' => ['required_with:billing_address', 'string', 'size:2', 'alpha'], + 'billing_address.postal_code' => ['required_with:billing_address', 'string', 'max:20'], + 'billing_address.phone' => ['nullable', 'string', 'max:50'], + 'use_shipping_as_billing' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Resources/Admin/CollectionResource.php b/app/Http/Resources/Admin/CollectionResource.php new file mode 100644 index 00000000..773602d5 --- /dev/null +++ b/app/Http/Resources/Admin/CollectionResource.php @@ -0,0 +1,41 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'title' => $this->title, + 'handle' => $this->handle, + 'description_html' => $this->description_html, + 'type' => $this->type->value, + 'status' => $this->status->value, + 'products_count' => $this->whenCounted('products'), + 'product_ids' => $this->when( + $this->resource->relationLoaded('products'), + fn (): array => $this->products->pluck('id')->all(), + ), + 'created_at' => $this->created_at?->toIso8601ZuluString(), + 'updated_at' => $this->updated_at?->toIso8601ZuluString(), + ]; + } +} diff --git a/app/Http/Resources/Admin/FulfillmentResource.php b/app/Http/Resources/Admin/FulfillmentResource.php new file mode 100644 index 00000000..f044ec80 --- /dev/null +++ b/app/Http/Resources/Admin/FulfillmentResource.php @@ -0,0 +1,40 @@ + + */ + public function toArray(Request $request): array + { + $this->resource->loadMissing('lines'); + + return [ + 'id' => $this->id, + 'order_id' => $this->order_id, + 'status' => $this->status->value, + 'tracking_company' => $this->tracking_company, + 'tracking_number' => $this->tracking_number, + 'tracking_url' => $this->tracking_url, + 'shipped_at' => $this->shipped_at?->toIso8601ZuluString(), + 'line_items' => $this->lines->map(fn (FulfillmentLine $line): array => [ + 'order_line_id' => $line->order_line_id, + 'quantity' => $line->quantity, + ])->values()->all(), + ]; + } +} diff --git a/app/Http/Resources/Admin/OrderListResource.php b/app/Http/Resources/Admin/OrderListResource.php new file mode 100644 index 00000000..10fc067b --- /dev/null +++ b/app/Http/Resources/Admin/OrderListResource.php @@ -0,0 +1,46 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'order_number' => $this->order_number, + 'status' => $this->status->value, + 'financial_status' => $this->financial_status->value, + 'fulfillment_status' => $this->fulfillment_status->value, + 'customer' => $this->customer !== null ? [ + 'id' => $this->customer->id, + 'name' => $this->customer->name, + 'email' => $this->customer->email, + ] : null, + 'currency' => $this->currency, + 'subtotal_amount' => $this->subtotal_amount, + 'discount_amount' => $this->discount_amount, + 'shipping_amount' => $this->shipping_amount, + 'tax_amount' => $this->tax_amount, + 'total_amount' => $this->total_amount, + 'line_count' => $this->lines_count ?? $this->lines()->count(), + 'placed_at' => $this->placed_at?->toIso8601ZuluString(), + 'created_at' => $this->created_at?->toIso8601ZuluString(), + ]; + } +} diff --git a/app/Http/Resources/Admin/OrderResource.php b/app/Http/Resources/Admin/OrderResource.php new file mode 100644 index 00000000..2f28d430 --- /dev/null +++ b/app/Http/Resources/Admin/OrderResource.php @@ -0,0 +1,80 @@ + + */ + public function toArray(Request $request): array + { + $this->resource->loadMissing(['customer', 'lines', 'payments', 'fulfillments.lines', 'refunds']); + + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'order_number' => $this->order_number, + 'status' => $this->status->value, + 'financial_status' => $this->financial_status->value, + 'fulfillment_status' => $this->fulfillment_status->value, + 'customer' => $this->customer !== null ? [ + 'id' => $this->customer->id, + 'name' => $this->customer->name, + 'email' => $this->customer->email, + ] : null, + 'email' => $this->email, + 'currency' => $this->currency, + 'subtotal_amount' => $this->subtotal_amount, + 'discount_amount' => $this->discount_amount, + 'shipping_amount' => $this->shipping_amount, + 'tax_amount' => $this->tax_amount, + 'total_amount' => $this->total_amount, + 'billing_address_json' => $this->billing_address_json, + 'shipping_address_json' => $this->shipping_address_json, + 'lines' => $this->lines->map(fn (OrderLine $line): array => [ + 'id' => $line->id, + 'product_id' => $line->product_id, + 'variant_id' => $line->variant_id, + 'title_snapshot' => $line->title_snapshot, + 'sku_snapshot' => $line->sku_snapshot, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'total_amount' => $line->total_amount, + 'tax_lines_json' => $line->tax_lines_json ?? [], + 'discount_allocations_json' => $line->discount_allocations_json ?? [], + ])->values()->all(), + 'payments' => $this->payments->map(fn (Payment $payment): array => [ + 'id' => $payment->id, + 'provider' => $payment->provider, + 'method' => $payment->method->value, + 'provider_payment_id' => $payment->provider_payment_id, + 'status' => $payment->status->value, + 'amount' => $payment->amount, + 'currency' => $payment->currency, + 'created_at' => $payment->created_at?->toIso8601ZuluString(), + ])->values()->all(), + 'fulfillments' => $this->fulfillments->map(fn (Fulfillment $fulfillment): array => (new FulfillmentResource($fulfillment))->toArray($request))->values()->all(), + 'refunds' => $this->refunds->map(fn (Refund $refund): array => (new RefundResource($refund))->toArray($request))->values()->all(), + 'placed_at' => $this->placed_at?->toIso8601ZuluString(), + 'created_at' => $this->created_at?->toIso8601ZuluString(), + 'updated_at' => $this->updated_at?->toIso8601ZuluString(), + ]; + } +} diff --git a/app/Http/Resources/Admin/OrganizationResource.php b/app/Http/Resources/Admin/OrganizationResource.php new file mode 100644 index 00000000..0412f312 --- /dev/null +++ b/app/Http/Resources/Admin/OrganizationResource.php @@ -0,0 +1,31 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'billing_email' => $this->billing_email, + 'created_at' => $this->created_at?->toIso8601ZuluString(), + 'updated_at' => $this->updated_at?->toIso8601ZuluString(), + ]; + } +} diff --git a/app/Http/Resources/Admin/ProductListResource.php b/app/Http/Resources/Admin/ProductListResource.php new file mode 100644 index 00000000..9f9fa71b --- /dev/null +++ b/app/Http/Resources/Admin/ProductListResource.php @@ -0,0 +1,46 @@ + + */ + public function toArray(Request $request): array + { + $featuredImage = $this->media->first(); + + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'title' => $this->title, + 'handle' => $this->handle, + 'status' => $this->status->value, + 'vendor' => $this->vendor, + 'product_type' => $this->product_type, + 'tags' => $this->tags ?? [], + 'variants_count' => $this->variants->count(), + 'total_inventory' => $this->variants->sum(fn ($variant): int => (int) ($variant->inventoryItem?->quantity_on_hand ?? 0)), + 'published_at' => $this->published_at?->toIso8601ZuluString(), + 'created_at' => $this->created_at?->toIso8601ZuluString(), + 'updated_at' => $this->updated_at?->toIso8601ZuluString(), + 'featured_image' => $featuredImage !== null ? [ + 'url' => $featuredImage->url(), + 'alt_text' => $featuredImage->alt_text, + ] : null, + ]; + } +} diff --git a/app/Http/Resources/Admin/ProductResource.php b/app/Http/Resources/Admin/ProductResource.php new file mode 100644 index 00000000..26af981d --- /dev/null +++ b/app/Http/Resources/Admin/ProductResource.php @@ -0,0 +1,101 @@ + + */ + public function toArray(Request $request): array + { + $this->resource->loadMissing([ + 'options.values', + 'variants.optionValues.option', + 'variants.inventoryItem', + 'media', + 'collections', + ]); + + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'title' => $this->title, + 'handle' => $this->handle, + 'description_html' => $this->description_html, + 'vendor' => $this->vendor, + 'product_type' => $this->product_type, + 'status' => $this->status->value, + 'tags' => $this->tags ?? [], + 'published_at' => $this->published_at?->toIso8601ZuluString(), + 'created_at' => $this->created_at?->toIso8601ZuluString(), + 'updated_at' => $this->updated_at?->toIso8601ZuluString(), + 'options' => $this->options->map(fn (ProductOption $option): array => [ + 'id' => $option->id, + 'name' => $option->name, + 'position' => $option->position, + 'values' => $option->values->map(fn ($value): array => [ + 'id' => $value->id, + 'value' => $value->value, + 'position' => $value->position, + ])->values()->all(), + ])->values()->all(), + 'variants' => $this->variants->map(fn (ProductVariant $variant): array => [ + 'id' => $variant->id, + 'sku' => $variant->sku, + 'barcode' => $variant->barcode, + 'price_amount' => $variant->price_amount, + 'compare_at_amount' => $variant->compare_at_amount, + 'currency' => $variant->currency, + 'weight_g' => $variant->weight_g, + 'requires_shipping' => (bool) $variant->requires_shipping, + 'is_default' => (bool) $variant->is_default, + 'position' => $variant->position, + 'status' => $variant->status->value, + 'option_values' => $variant->optionValues->map(fn ($value): array => [ + 'option_name' => $value->option?->name, + 'value' => $value->value, + ])->values()->all(), + 'inventory' => [ + 'quantity_on_hand' => (int) ($variant->inventoryItem?->quantity_on_hand ?? 0), + 'quantity_reserved' => (int) ($variant->inventoryItem?->quantity_reserved ?? 0), + 'policy' => $variant->inventoryItem?->policy?->value ?? 'deny', + ], + ])->values()->all(), + 'media' => $this->media->map(fn (ProductMedia $media): array => [ + 'id' => $media->id, + 'type' => $media->type->value, + 'storage_key' => $media->storage_key, + 'url' => $media->url(), + 'alt_text' => $media->alt_text, + 'width' => $media->width, + 'height' => $media->height, + 'mime_type' => $media->mime_type, + 'byte_size' => $media->byte_size, + 'position' => $media->position, + 'status' => $media->status->value, + ])->values()->all(), + 'collections' => $this->collections->map(fn (Collection $collection): array => [ + 'id' => $collection->id, + 'title' => $collection->title, + 'handle' => $collection->handle, + ])->values()->all(), + ]; + } +} diff --git a/app/Http/Resources/Admin/RefundResource.php b/app/Http/Resources/Admin/RefundResource.php new file mode 100644 index 00000000..fe7c7062 --- /dev/null +++ b/app/Http/Resources/Admin/RefundResource.php @@ -0,0 +1,34 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'order_id' => $this->order_id, + 'payment_id' => $this->payment_id, + 'provider_refund_id' => $this->provider_refund_id, + 'amount' => $this->amount, + 'reason' => $this->reason, + 'status' => $this->status->value, + 'created_at' => $this->created_at?->toIso8601ZuluString(), + ]; + } +} diff --git a/app/Http/Resources/Admin/StoreResource.php b/app/Http/Resources/Admin/StoreResource.php new file mode 100644 index 00000000..4b362a18 --- /dev/null +++ b/app/Http/Resources/Admin/StoreResource.php @@ -0,0 +1,35 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'organization_id' => $this->organization_id, + 'name' => $this->name, + 'handle' => $this->handle, + 'status' => $this->status->value, + 'default_currency' => $this->default_currency, + 'default_locale' => $this->default_locale, + 'timezone' => $this->timezone, + 'created_at' => $this->created_at?->toIso8601ZuluString(), + ]; + } +} diff --git a/app/Http/Resources/Storefront/CartResource.php b/app/Http/Resources/Storefront/CartResource.php new file mode 100644 index 00000000..4406209e --- /dev/null +++ b/app/Http/Resources/Storefront/CartResource.php @@ -0,0 +1,67 @@ + + */ + public function toArray(Request $request): array + { + $this->resource->loadMissing(['lines.variant.product.media', 'lines.variant.inventoryItem']); + + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'customer_id' => $this->customer_id, + 'currency' => $this->currency, + 'cart_version' => $this->cart_version, + 'status' => $this->status->value, + 'lines' => $this->lines->map(fn (CartLine $line): array => [ + 'id' => $line->id, + 'variant_id' => $line->variant_id, + 'product_title' => $line->variant?->product?->title, + 'variant_title' => $line->variant?->title(), + 'sku' => $line->variant?->sku, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'line_subtotal_amount' => $line->line_subtotal_amount, + 'line_discount_amount' => $line->line_discount_amount, + 'line_total_amount' => $line->line_total_amount, + 'image_url' => $line->variant?->product?->media->first()?->url(), + 'requires_shipping' => (bool) ($line->variant?->requires_shipping ?? false), + 'available_quantity' => $line->variant?->availableQuantity() ?? 0, + ])->all(), + 'totals' => [ + 'subtotal' => $this->subtotal(), + 'discount' => (int) $this->lines->sum('line_discount_amount'), + 'total' => (int) $this->lines->sum('line_total_amount'), + 'currency' => $this->currency, + 'line_count' => $this->lineCount(), + 'item_count' => $this->itemCount(), + ], + 'created_at' => $this->created_at?->toIso8601ZuluString(), + 'updated_at' => $this->updated_at?->toIso8601ZuluString(), + ]; + } +} diff --git a/app/Http/Resources/Storefront/CheckoutResource.php b/app/Http/Resources/Storefront/CheckoutResource.php new file mode 100644 index 00000000..632cbad0 --- /dev/null +++ b/app/Http/Resources/Storefront/CheckoutResource.php @@ -0,0 +1,132 @@ + + */ + public function toArray(Request $request): array + { + $this->resource->loadMissing(['cart.lines.variant.product']); + + $cart = $this->cart; + $totals = $this->totals_json ?? []; + + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'cart_id' => $this->cart_id, + 'customer_id' => $this->customer_id, + 'status' => $this->status->value, + 'email' => $this->email, + 'shipping_address_json' => $this->shipping_address_json, + 'billing_address_json' => $this->billing_address_json, + 'shipping_method_id' => $this->shipping_method_id, + 'payment_method' => $this->payment_method?->value, + 'discount_code' => $this->discount_code, + 'lines' => $cart->lines->map(fn ($line): array => [ + 'variant_id' => $line->variant_id, + 'product_title' => $line->variant?->product?->title, + 'variant_title' => $line->variant?->title(), + 'sku' => $line->variant?->sku, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'line_total_amount' => $line->line_total_amount, + ])->all(), + 'totals' => [ + 'subtotal' => $totals['subtotal'] ?? 0, + 'discount' => $totals['discount'] ?? 0, + 'shipping' => $totals['shipping'] ?? 0, + 'tax' => $totals['tax'] ?? 0, + 'total' => $totals['total'] ?? 0, + 'currency' => $totals['currency'] ?? $cart->currency, + ], + 'available_shipping_methods' => $this->availableShippingMethods(), + 'applied_discounts' => $this->appliedDiscounts($totals), + 'tax_provider_snapshot_json' => $this->tax_provider_snapshot_json, + 'expires_at' => $this->expires_at?->toIso8601ZuluString(), + 'created_at' => $this->created_at?->toIso8601ZuluString(), + ]; + } + + /** + * Rates available for the checkout's shipping address. + * + * @return array> + */ + private function availableShippingMethods(): array + { + if (empty($this->shipping_address_json)) { + return []; + } + + $methods = app(ShippingCalculator::class)->getAvailableRates( + $this->store, + Address::fromArray($this->shipping_address_json), + $this->cart, + ); + + return $methods->map(fn ($rate): array => [ + 'id' => $rate->id, + 'name' => $rate->name, + 'type' => $rate->type->value, + 'price_amount' => $rate->amount, + 'currency' => $this->cart->currency, + 'estimated_days_min' => $rate->estimatedDaysMin, + 'estimated_days_max' => $rate->estimatedDaysMax, + ])->all(); + } + + /** + * The applied code discount with its calculated amount. + * + * @param array $totals + * @return array> + */ + private function appliedDiscounts(array $totals): array + { + if ($this->discount_code === null) { + return []; + } + + $discount = Discount::query() + ->where('store_id', $this->store_id) + ->whereRaw('lower(code) = ?', [mb_strtolower($this->discount_code)]) + ->first(); + + if ($discount === null) { + return []; + } + + return [[ + 'code' => $discount->code, + 'type' => $discount->value_type->value, + 'value_amount' => $discount->value_amount, + 'applied_amount' => $totals['discount'] ?? 0, + 'description' => null, + ]]; + } +} diff --git a/app/Jobs/AggregateAnalytics.php b/app/Jobs/AggregateAnalytics.php new file mode 100644 index 00000000..beba9341 --- /dev/null +++ b/app/Jobs/AggregateAnalytics.php @@ -0,0 +1,44 @@ +date ?? now()->subDay()->toDateString(); + + $storeIds = AnalyticsEvent::withoutGlobalScope(StoreScope::class) + ->whereRaw('DATE(occurred_at) = ?', [$date]) + ->distinct() + ->pluck('store_id'); + + foreach ($storeIds as $storeId) { + DB::table('analytics_daily')->updateOrInsert( + ['store_id' => (int) $storeId, 'date' => $date], + $analytics->aggregateForDate((int) $storeId, $date), + ); + } + } +} diff --git a/app/Jobs/CancelUnpaidBankTransferOrders.php b/app/Jobs/CancelUnpaidBankTransferOrders.php new file mode 100644 index 00000000..a19068c5 --- /dev/null +++ b/app/Jobs/CancelUnpaidBankTransferOrders.php @@ -0,0 +1,48 @@ +where('payment_method', PaymentMethod::BankTransfer->value) + ->where('financial_status', FinancialStatus::Pending->value); + + foreach ($candidates->pluck('store_id')->unique() as $storeId) { + $store = Store::find($storeId); + $days = (int) ($store?->settings?->settings_json['bank_transfer_cancel_days'] ?? 7); + + Order::query() + ->where('store_id', $storeId) + ->where('payment_method', PaymentMethod::BankTransfer->value) + ->where('financial_status', FinancialStatus::Pending->value) + ->where('placed_at', '<', now()->subDays($days)) + ->chunkById(100, function ($stale) use ($orders): void { + foreach ($stale as $order) { + $orders->cancel($order, 'bank_transfer_unpaid_timeout'); + } + }); + } + } +} diff --git a/app/Jobs/CleanupAbandonedCarts.php b/app/Jobs/CleanupAbandonedCarts.php new file mode 100644 index 00000000..1e4e57a2 --- /dev/null +++ b/app/Jobs/CleanupAbandonedCarts.php @@ -0,0 +1,48 @@ +where('status', CartStatus::Active->value) + ->where('updated_at', '<', now()->subDay()) + ->with('store.settings') + ->chunkById(100, function ($carts) use ($checkoutService): void { + foreach ($carts as $cart) { + $thresholdDays = (int) ($cart->store->settings?->settings_json['cart_abandon_days'] ?? 14); + + if ($cart->updated_at->gte(now()->subDays($thresholdDays))) { + continue; + } + + foreach ($cart->checkouts()->whereNotIn('status', ['completed', 'expired'])->get() as $checkout) { + $checkoutService->expireCheckout($checkout); + } + + $cart->update(['status' => CartStatus::Abandoned]); + } + }); + } +} diff --git a/app/Jobs/DeliverWebhook.php b/app/Jobs/DeliverWebhook.php new file mode 100644 index 00000000..6461cbf6 --- /dev/null +++ b/app/Jobs/DeliverWebhook.php @@ -0,0 +1,150 @@ + $payload + */ + public function __construct( + public WebhookSubscription $subscription, + public string $eventType, + public array $payload, + ?string $deliveryId = null, + ) { + $this->deliveryId = $deliveryId ?? (string) Str::uuid(); + } + + /** + * Retry delays in seconds: 1 min, 5 min, 30 min, 2 h, 12 h + * (spec 05 §13.3). + * + * @return list + */ + public function backoff(): array + { + return [60, 300, 1800, 7200, 43200]; + } + + /** + * POST the signed JSON payload to the target URL and record the + * result in webhook_deliveries. + * + * @throws RuntimeException when the endpoint failed and retries remain + */ + public function handle(WebhookService $webhooks): void + { + $this->subscription->refresh(); + + // Paused or disabled subscriptions receive no further deliveries. + if ($this->subscription->status !== WebhookSubscriptionStatus::Active) { + return; + } + + $delivery = WebhookDelivery::query()->firstOrCreate( + ['subscription_id' => $this->subscription->id, 'event_id' => $this->deliveryId], + ['status' => WebhookDeliveryStatus::Pending, 'attempt_count' => 0], + ); + + $attempt = $this->attempts() ?? 1; + $timestamp = time(); + $body = (string) json_encode($this->payload); + + $statusCode = null; + $snippet = null; + $successful = false; + + try { + $response = Http::withBody($body, 'application/json') + ->withHeaders([ + 'X-Platform-Signature' => $webhooks->sign($body, $this->subscription->signing_secret_encrypted, $timestamp), + 'X-Platform-Event' => $this->eventType, + 'X-Platform-Delivery-Id' => $this->deliveryId, + 'X-Platform-Timestamp' => (string) $timestamp, + ]) + ->timeout(10) + ->post($this->subscription->target_url); + + $statusCode = $response->status(); + $snippet = Str::limit($response->body(), 500, ''); + $successful = $response->successful(); + } catch (ConnectionException $exception) { + $snippet = Str::limit($exception->getMessage(), 500, ''); + } + + $delivery->forceFill([ + 'attempt_count' => $attempt, + 'status' => $successful ? WebhookDeliveryStatus::Success : WebhookDeliveryStatus::Failed, + 'response_code' => $statusCode, + 'response_body_snippet' => $snippet, + 'last_attempt_at' => now(), + ])->save(); + + if ($successful) { + return; + } + + $this->tripCircuitBreakerIfNeeded(); + + if ($attempt < $this->tries) { + throw new RuntimeException("Webhook delivery {$this->deliveryId} failed (response code: ".($statusCode ?? 'none').').'); + } + } + + /** + * Pause the subscription once the consecutive failure streak reaches + * the threshold; manual re-enable is required (spec 05 §13.4). + */ + private function tripCircuitBreakerIfNeeded(): void + { + if ($this->subscription->consecutiveFailures() < self::CIRCUIT_BREAKER_THRESHOLD) { + return; + } + + $this->subscription->forceFill(['status' => WebhookSubscriptionStatus::Paused])->save(); + + Log::warning('Webhook subscription paused after consecutive delivery failures', [ + 'subscription_id' => $this->subscription->id, + 'store_id' => $this->subscription->store_id, + 'target_url' => $this->subscription->target_url, + ]); + } +} diff --git a/app/Jobs/ExpireAbandonedCheckouts.php b/app/Jobs/ExpireAbandonedCheckouts.php new file mode 100644 index 00000000..9fe1268a --- /dev/null +++ b/app/Jobs/ExpireAbandonedCheckouts.php @@ -0,0 +1,38 @@ +whereNotIn('status', [CheckoutStatus::Completed->value, CheckoutStatus::Expired->value]) + ->where(function ($query): void { + $query->where('expires_at', '<', now()) + ->orWhere('updated_at', '<', now()->subHours(24)); + }) + ->chunkById(100, function ($checkouts) use ($checkoutService): void { + foreach ($checkouts as $checkout) { + $checkoutService->expireCheckout($checkout); + } + }); + } +} diff --git a/app/Jobs/ProcessMediaUpload.php b/app/Jobs/ProcessMediaUpload.php new file mode 100644 index 00000000..c97ea10b --- /dev/null +++ b/app/Jobs/ProcessMediaUpload.php @@ -0,0 +1,162 @@ + maximum width/height in pixels. + * + * @var array + */ + private const TARGETS = [ + 'thumbnail' => 150, + 'small' => 300, + 'medium' => 600, + 'large' => 1200, + ]; + + public function __construct(public ProductMedia $media) {} + + /** + * Resize the original into the standard sizes and mark the record ready. + * + * @throws RuntimeException when the original cannot be processed + */ + public function handle(): void + { + if (! extension_loaded('gd')) { + throw new RuntimeException('The GD extension is required to process media uploads.'); + } + + $disk = Storage::disk('public'); + $originalPath = $disk->path($this->media->storage_key); + + $info = @getimagesize($originalPath); + + if ($info === false) { + throw new RuntimeException("Cannot read image data for media {$this->media->id}."); + } + + [$width, $height] = $info; + $mimeType = $info['mime']; + + $source = $this->createImageFrom($originalPath, $mimeType); + + foreach (self::TARGETS as $size => $maxDimension) { + [$targetWidth, $targetHeight] = $this->containDimensions($width, $height, $maxDimension); + + $resized = imagecreatetruecolor($targetWidth, $targetHeight); + + if ($resized === false) { + throw new RuntimeException("Failed to allocate canvas for media {$this->media->id}."); + } + + imagecopyresampled($resized, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height); + + $relativePath = $this->media->pathFor($size); + $absolutePath = $disk->path($relativePath); + + if (! is_dir(dirname($absolutePath))) { + mkdir(dirname($absolutePath), 0755, true); + } + + $this->saveImage($resized, $absolutePath, $mimeType); + imagedestroy($resized); + } + + imagedestroy($source); + + $this->media->update([ + 'width' => $width, + 'height' => $height, + 'mime_type' => $mimeType, + 'byte_size' => $disk->size($this->media->storage_key), + 'status' => MediaStatus::Ready, + ]); + } + + /** + * Mark the media as failed after the job exhausted its attempts. + */ + public function failed(?Throwable $exception): void + { + Log::error('Media processing failed', [ + 'product_media_id' => $this->media->id, + 'storage_key' => $this->media->storage_key, + 'error' => $exception?->getMessage(), + ]); + + $this->media->update(['status' => MediaStatus::Failed]); + } + + /** + * Load an image resource from disk based on its mime type. + */ + private function createImageFrom(string $path, string $mimeType): \GdImage + { + $image = match ($mimeType) { + 'image/jpeg' => @imagecreatefromjpeg($path), + 'image/png' => @imagecreatefrompng($path), + 'image/webp' => @imagecreatefromwebp($path), + 'image/gif' => @imagecreatefromgif($path), + default => false, + }; + + if ($image === false) { + throw new RuntimeException("Unsupported or corrupt image ({$mimeType}) for media {$this->media->id}."); + } + + return $image; + } + + /** + * Compute dimensions contained within the max size, preserving aspect + * ratio and never upscaling. + * + * @return array{0: int, 1: int} + */ + private function containDimensions(int $width, int $height, int $maxDimension): array + { + $ratio = min($maxDimension / $width, $maxDimension / $height, 1.0); + + return [ + max(1, (int) round($width * $ratio)), + max(1, (int) round($height * $ratio)), + ]; + } + + /** + * Persist an image resource in the given format. + */ + private function saveImage(\GdImage $image, string $path, string $mimeType): void + { + $saved = match ($mimeType) { + 'image/jpeg' => imagejpeg($image, $path, 85), + 'image/png' => imagepng($image, $path), + 'image/webp' => imagewebp($image, $path), + 'image/gif' => imagegif($image, $path), + default => false, + }; + + if (! $saved) { + throw new RuntimeException("Failed to write resized image to {$path}."); + } + } +} diff --git a/app/Listeners/DispatchWebhooks.php b/app/Listeners/DispatchWebhooks.php new file mode 100644 index 00000000..d7d5e8e4 --- /dev/null +++ b/app/Listeners/DispatchWebhooks.php @@ -0,0 +1,127 @@ + webhook event type (spec 05 §13.1). + * + * @var array + */ + public const EVENT_MAP = [ + OrderCreated::class => 'order.created', + OrderPaid::class => 'order.paid', + OrderFulfilled::class => 'order.fulfilled', + OrderRefunded::class => 'order.refunded', + ProductCreated::class => 'product.created', + ProductUpdated::class => 'product.updated', + ProductDeleted::class => 'product.deleted', + CheckoutCompleted::class => 'checkout.completed', + ]; + + public function __construct(private WebhookService $webhooks) {} + + /** + * Dispatch a webhook for each active subscription of the event's store. + */ + public function handle(object $event): void + { + $eventType = self::EVENT_MAP[$event::class] ?? null; + + if ($eventType === null) { + return; + } + + [$store, $payload] = match (true) { + $event instanceof OrderCreated, $event instanceof OrderPaid, $event instanceof OrderFulfilled => [ + $event->order->store, + $this->orderPayload($event->order), + ], + $event instanceof OrderRefunded => [ + $event->order->store, + $this->orderPayload($event->order) + [ + 'refund' => [ + 'id' => $event->refund->id, + 'amount' => $event->refund->amount, + 'reason' => $event->refund->reason, + ], + ], + ], + $event instanceof ProductCreated, $event instanceof ProductUpdated, $event instanceof ProductDeleted => [ + $event->product->store, + $this->productPayload($event->product), + ], + $event instanceof CheckoutCompleted => [ + $event->checkout->store, + $this->checkoutPayload($event->checkout), + ], + default => [null, null], + }; + + if ($store === null || $payload === null) { + return; + } + + $this->webhooks->dispatch($store, $eventType, $payload); + } + + /** + * @return array + */ + private function orderPayload(Order $order): array + { + return [ + 'id' => $order->id, + 'order_number' => $order->order_number, + 'status' => $order->status->value, + 'financial_status' => $order->financial_status->value, + 'fulfillment_status' => $order->fulfillment_status->value, + 'total_amount' => $order->total_amount, + 'currency' => $order->currency, + 'placed_at' => $order->placed_at?->toIso8601ZuluString(), + ]; + } + + /** + * @return array + */ + private function productPayload(Product $product): array + { + return [ + 'id' => $product->id, + 'title' => $product->title, + 'handle' => $product->handle, + 'status' => $product->status->value, + ]; + } + + /** + * @return array + */ + private function checkoutPayload(Checkout $checkout): array + { + return [ + 'id' => $checkout->id, + 'status' => $checkout->status->value, + 'email' => $checkout->email, + ]; + } +} diff --git a/app/Listeners/ReleaseOrderInventory.php b/app/Listeners/ReleaseOrderInventory.php new file mode 100644 index 00000000..f9f2b75b --- /dev/null +++ b/app/Listeners/ReleaseOrderInventory.php @@ -0,0 +1,42 @@ +order; + + if ($order->financial_status !== FinancialStatus::Pending) { + return; + } + + $order->loadMissing('lines.variant.inventoryItem'); + + foreach ($order->lines as $line) { + $item = $line->variant?->inventoryItem; + + if ($item !== null) { + $this->inventory->release($item, $line->quantity); + } + } + } +} diff --git a/app/Listeners/SendOrderEmails.php b/app/Listeners/SendOrderEmails.php new file mode 100644 index 00000000..55087bef --- /dev/null +++ b/app/Listeners/SendOrderEmails.php @@ -0,0 +1,72 @@ + [ + $event->order->email, + new OrderConfirmationMail($event->order), + ], + $event instanceof FulfillmentShipped => [ + $event->fulfillment->order->email, + new OrderShippedMail($event->fulfillment->order, $event->fulfillment), + ], + $event instanceof OrderCancelled => [ + $event->order->email, + new OrderCancelledMail($event->order, $event->reason), + ], + $event instanceof OrderRefunded => [ + $event->order->email, + new OrderRefundedMail($event->order, $event->refund), + ], + default => [null, null], + }; + + if ($recipient === null || $mailable === null) { + return; + } + + $this->sendSafely($recipient, $mailable); + } + + /** + * Send the mailable, reporting (not propagating) any failure. + */ + private function sendSafely(string $recipient, Mailable $mailable): void + { + try { + Mail::to($recipient)->send($mailable); + } catch (Throwable $exception) { + report($exception); + } + } +} diff --git a/app/Listeners/WriteAuditLog.php b/app/Listeners/WriteAuditLog.php new file mode 100644 index 00000000..c6668993 --- /dev/null +++ b/app/Listeners/WriteAuditLog.php @@ -0,0 +1,89 @@ +logProduct($event); + + return; + } + + $order = $this->orderOf($event); + + Log::channel('audit')->info('order.'.$this->eventName($event), array_filter([ + 'event' => $event::class, + 'store_id' => $order?->store_id, + 'order_id' => $order?->id, + 'order_number' => $order?->order_number, + 'financial_status' => $order?->financial_status?->value, + 'status' => $order?->status?->value, + 'fulfillment_id' => $event->fulfillment->id ?? null, + 'refund_id' => $event->refund->id ?? null, + 'reason' => $event->reason ?? null, + ], fn ($value): bool => $value !== null)); + } + + /** + * Write a product.created / product.updated audit entry. + */ + private function logProduct(ProductCreated|ProductUpdated $event): void + { + $product = $event->product; + + Log::channel('audit')->info('product.'.($event instanceof ProductCreated ? 'created' : 'updated'), [ + 'event' => $event::class, + 'store_id' => $product->store_id, + 'product_id' => $product->id, + 'handle' => $product->handle, + 'status' => $product->status->value, + ]); + } + + /** + * Resolve the order the event relates to. + */ + private function orderOf(object $event): ?Order + { + if (isset($event->order) && $event->order instanceof Order) { + return $event->order; + } + + if (isset($event->fulfillment)) { + return $event->fulfillment->order; + } + + return null; + } + + /** + * Map the event class to a short audit action name. + */ + private function eventName(object $event): string + { + return match (class_basename($event)) { + 'OrderCreated' => 'created', + 'OrderPaid' => 'paid', + 'OrderCancelled' => 'cancelled', + 'OrderRefunded' => 'refunded', + 'FulfillmentShipped' => 'fulfillment_shipped', + default => strtolower(class_basename($event)), + }; + } +} diff --git a/app/Listeners/WriteAuthAuditLog.php b/app/Listeners/WriteAuthAuditLog.php new file mode 100644 index 00000000..8fe8f4ee --- /dev/null +++ b/app/Listeners/WriteAuthAuditLog.php @@ -0,0 +1,31 @@ +guard !== 'web') { + return; + } + + Log::channel('audit')->info('auth.login', [ + 'guard' => $event->guard, + 'user_id' => $event->user->getAuthIdentifier(), + 'email' => $event->user->email ?? null, + 'remember' => $event->remember, + 'ip' => request()->ip(), + ]); + } +} diff --git a/app/Livewire/Actions/Logout.php b/app/Livewire/Actions/Logout.php deleted file mode 100644 index 45993bb8..00000000 --- a/app/Livewire/Actions/Logout.php +++ /dev/null @@ -1,22 +0,0 @@ -logout(); - - Session::invalidate(); - Session::regenerateToken(); - - return redirect('/'); - } -} diff --git a/app/Livewire/Admin/Analytics/Index.php b/app/Livewire/Admin/Analytics/Index.php new file mode 100644 index 00000000..da930111 --- /dev/null +++ b/app/Livewire/Admin/Analytics/Index.php @@ -0,0 +1,162 @@ +period(); + + $metrics = $analytics->getDailyMetrics($store, $start->toDateString(), $end->toDateString()); + + $totalSales = (int) $metrics->sum('revenue_amount'); + $ordersCount = (int) $metrics->sum('orders_count'); + $visits = (int) $metrics->sum('visits_count'); + $completed = (int) $metrics->sum('checkout_completed_count'); + + [$topProducts, $topProductsRevenue] = $this->topProducts($store, $start, $end); + + return view('livewire.admin.analytics.index', [ + 'totalSales' => $totalSales, + 'ordersCount' => $ordersCount, + 'averageOrderValue' => $ordersCount > 0 ? intdiv($totalSales, $ordersCount) : 0, + 'conversionRate' => $visits > 0 ? round($completed / $visits * 100, 1) : null, + 'salesChart' => $this->chartData($metrics, 'revenue_amount'), + 'trafficChart' => $this->chartData($metrics, 'visits_count'), + 'funnel' => $this->funnel($metrics), + 'topProducts' => $topProducts, + 'topProductsRevenue' => $topProductsRevenue, + 'recentSearches' => SearchQuery::query()->latest('created_at')->limit(10)->get(), + 'currency' => $store->default_currency, + ])->layout('admin.layouts.app')->title('Analytics'); + } + + /** + * Inclusive current period: [today - (range - 1) days, now]. + * + * @return array{0: CarbonImmutable, 1: CarbonImmutable} + */ + private function period(): array + { + $end = CarbonImmutable::now()->endOfDay(); + $start = CarbonImmutable::now()->subDays($this->dateRange - 1)->startOfDay(); + + return [$start, $end]; + } + + /** + * Daily values for an SVG chart, including polyline points (same + * pattern as the admin dashboard). + * + * @param Collection> $metrics + * @return array{days: list, points: string, max: int} + */ + private function chartData(Collection $metrics, string $key): array + { + $days = $metrics->values() + ->map(fn (array $day): array => ['date' => $day['date'], 'value' => (int) $day[$key]]) + ->all(); + + $max = max(1, max(array_column($days, 'value') ?: [0])); + $count = count($days); + + $points = []; + + foreach ($days as $index => $day) { + $x = $count > 1 ? $index * (600 / ($count - 1)) : 300; + $y = 150 - ($day['value'] / $max) * 140; + $points[] = round($x, 1).','.round($y, 1); + } + + return [ + 'days' => $days, + 'points' => implode(' ', $points), + 'max' => $max, + ]; + } + + /** + * Conversion funnel steps with percentage-of-visits and relative bar + * widths (spec 03 §17 funnel visualization). + * + * @param Collection> $metrics + * @return list + */ + private function funnel(Collection $metrics): array + { + $visits = (int) $metrics->sum('visits_count'); + + $steps = [ + ['label' => 'Visits', 'count' => $visits], + ['label' => 'Added to cart', 'count' => (int) $metrics->sum('add_to_cart_count')], + ['label' => 'Checkout started', 'count' => (int) $metrics->sum('checkout_started_count')], + ['label' => 'Checkout completed', 'count' => (int) $metrics->sum('checkout_completed_count')], + ]; + + $max = max(1, max(array_column($steps, 'count'))); + + return array_map(fn (array $step): array => [ + 'label' => $step['label'], + 'count' => $step['count'], + 'percent' => $visits > 0 ? round($step['count'] / $visits * 100, 1) : null, + 'width' => round($step['count'] / $max * 100, 1), + ], $steps); + } + + /** + * Top products by revenue within the period, aggregated from order + * lines, plus the total line revenue used for the share column. + * + * @return array{0: Collection, 1: int} + */ + private function topProducts(Store $store, CarbonImmutable $start, CarbonImmutable $end): array + { + $base = DB::table('order_lines') + ->join('orders', 'orders.id', '=', 'order_lines.order_id') + ->where('orders.store_id', $store->id) + ->whereNotNull('orders.placed_at') + ->whereBetween('orders.placed_at', [$start, $end]); + + $rows = (clone $base) + ->groupBy('order_lines.product_id', 'order_lines.title_snapshot') + ->selectRaw('order_lines.title_snapshot as title') + ->selectRaw('SUM(order_lines.quantity) as units') + ->selectRaw('SUM(order_lines.total_amount) as revenue') + ->orderByDesc('revenue') + ->limit(10) + ->get(); + + $total = (int) ((clone $base)->sum('order_lines.total_amount') ?? 0); + + return [$rows, $total]; + } +} diff --git a/app/Livewire/Admin/Apps/Index.php b/app/Livewire/Admin/Apps/Index.php new file mode 100644 index 00000000..4eb60de0 --- /dev/null +++ b/app/Livewire/Admin/Apps/Index.php @@ -0,0 +1,110 @@ +}> + */ + public const CATALOG = [ + [ + 'name' => 'My Integration App', + 'description' => 'Syncs products and orders with your external systems.', + 'scopes' => ['read-products', 'write-products', 'read-orders'], + ], + [ + 'name' => 'Analytics Plugin', + 'description' => 'Sends storefront and order events to your analytics warehouse.', + 'scopes' => ['read-orders', 'read-analytics'], + ], + [ + 'name' => 'Review Connector', + 'description' => 'Imports product reviews from your review provider.', + 'scopes' => ['read-products', 'read-customers'], + ], + ]; + + public function mount(): void + { + Gate::authorize('manage-apps'); + } + + /** + * Install a catalog app: creates the app record on first use and an + * active installation with the catalog's default scopes. + */ + public function installApp(int|string $catalogKey): void + { + Gate::authorize('manage-apps'); + + $entry = is_int($catalogKey) || ctype_digit((string) $catalogKey) + ? self::CATALOG[(int) $catalogKey] ?? null + : collect(self::CATALOG)->firstWhere('name', $catalogKey); + + abort_if($entry === null, 404); + + $app = App::query()->firstOrCreate(['name' => $entry['name']], ['status' => 'active']); + + $installation = AppInstallation::query()->where('app_id', $app->id)->first(); + + if ($installation !== null) { + $installation->forceFill([ + 'status' => 'active', + 'scopes_json' => $entry['scopes'], + 'installed_at' => now(), + ])->save(); + } else { + AppInstallation::query()->create([ + 'app_id' => $app->id, + 'scopes_json' => $entry['scopes'], + 'status' => 'active', + 'installed_at' => now(), + ]); + } + + $this->dispatch('toast', type: 'success', message: "{$entry['name']} installed"); + } + + /** + * Uninstall an app (keeps the record, marks it uninstalled). + */ + public function uninstallApp(int $installationId): void + { + Gate::authorize('manage-apps'); + + $installation = AppInstallation::query()->findOrFail($installationId); + $installation->forceFill(['status' => 'uninstalled'])->save(); + + $this->dispatch('toast', type: 'success', message: 'App uninstalled'); + } + + public function render(): View + { + $installations = AppInstallation::query() + ->with('app') + ->where('status', 'active') + ->orderByDesc('installed_at') + ->get(); + + $availableApps = collect(self::CATALOG) + ->reject(fn (array $entry): bool => $installations->contains(fn (AppInstallation $installation): bool => $installation->app?->name === $entry['name'])) + ->values(); + + return view('livewire.admin.apps.index', [ + 'installedApps' => $installations, + 'availableApps' => $availableApps, + ])->layout('admin.layouts.app')->title('Apps'); + } +} diff --git a/app/Livewire/Admin/Apps/Show.php b/app/Livewire/Admin/Apps/Show.php new file mode 100644 index 00000000..2e9f62cd --- /dev/null +++ b/app/Livewire/Admin/Apps/Show.php @@ -0,0 +1,55 @@ +installation = $installation->load('app'); + } + + /** + * Uninstall the app and return to the apps list. + */ + public function uninstallApp(): void + { + Gate::authorize('manage-apps'); + + $this->installation->forceFill(['status' => 'uninstalled'])->save(); + + $this->dispatch('toast', type: 'success', message: 'App uninstalled'); + + $this->redirectRoute('admin.apps.index', navigate: true); + } + + public function render(): View + { + $webhooks = $this->installation->webhookSubscriptions()->orderByDesc('id')->get(); + + $deliveries = WebhookDelivery::query() + ->whereIn('subscription_id', $webhooks->pluck('id')) + ->orderByDesc('id') + ->limit(10) + ->get(); + + return view('livewire.admin.apps.show', [ + 'webhooks' => $webhooks, + 'deliveries' => $deliveries, + ])->layout('admin.layouts.app')->title($this->installation->app->name); + } +} diff --git a/app/Livewire/Admin/Auth/ForgotPassword.php b/app/Livewire/Admin/Auth/ForgotPassword.php new file mode 100644 index 00000000..b58ca4d1 --- /dev/null +++ b/app/Livewire/Admin/Auth/ForgotPassword.php @@ -0,0 +1,51 @@ +check()) { + $this->redirect('/admin'); + } + } + + /** + * Send a reset link through the "users" broker (spec 06 §1.1). The + * response is always generic so it never reveals whether the email + * exists. The broker throttles to one email per 60 seconds. + */ + public function sendResetLink(): void + { + $this->validate([ + 'email' => 'required|email', + ]); + + Password::broker('users')->sendResetLink(['email' => $this->email]); + + $this->linkSent = true; + } + + /** + * Render the forgot-password page on the centered auth layout. + */ + public function render(): View + { + return view('livewire.admin.auth.forgot-password') + ->layout('admin.layouts.auth') + ->title('Forgot password'); + } +} diff --git a/app/Livewire/Admin/Auth/Login.php b/app/Livewire/Admin/Auth/Login.php new file mode 100644 index 00000000..35084b0b --- /dev/null +++ b/app/Livewire/Admin/Auth/Login.php @@ -0,0 +1,88 @@ +check()) { + $this->redirect('/admin'); + } + } + + /** + * Attempt to authenticate against the web guard (spec 06 §1.1). The + * failure message is always generic so it never reveals which field + * was wrong. + */ + public function login(): void + { + $this->errorMessage = null; + + $credentials = $this->validate([ + 'email' => 'required|email', + 'password' => 'required|string', + ]); + + $this->ensureIsNotRateLimited(); + + if (! Auth::guard('web')->attempt($credentials, $this->remember)) { + $this->hitLoginRateLimiter(); + + $this->errorMessage = 'Invalid credentials.'; + + return; + } + + session()->regenerate(); + + $user = Auth::guard('web')->user(); + $user->forceFill(['last_login_at' => now()])->save(); + + $storeId = $user->stores()->value('stores.id'); + + if ($storeId === null) { + Auth::guard('web')->logout(); + session()->invalidate(); + session()->regenerateToken(); + + $this->errorMessage = 'You do not have access to any store.'; + + return; + } + + session(['current_store_id' => $storeId]); + $this->clearLoginRateLimiter(); + + $this->redirect('/admin'); + } + + /** + * Render the login page on the centered auth layout. + */ + public function render(): View + { + return view('livewire.admin.auth.login') + ->layout('admin.layouts.auth') + ->title('Log in'); + } +} diff --git a/app/Livewire/Admin/Auth/ResetPassword.php b/app/Livewire/Admin/Auth/ResetPassword.php new file mode 100644 index 00000000..2f874bc5 --- /dev/null +++ b/app/Livewire/Admin/Auth/ResetPassword.php @@ -0,0 +1,75 @@ +token = $token; + $this->email = (string) request()->query('email', ''); + } + + /** + * Reset the password through the "users" broker (spec 06 §1.1). + */ + public function resetPassword(): void + { + $this->errorMessage = null; + + $this->validate([ + 'token' => 'required', + 'email' => 'required|email', + 'password' => 'required|min:8|confirmed', + ]); + + $status = Password::broker('users')->reset( + $this->only('email', 'password', 'password_confirmation', 'token'), + function (User $user, string $password): void { + $user->forceFill([ + 'password_hash' => $password, + 'remember_token' => Str::random(60), + ])->save(); + }, + ); + + if ($status !== Password::PASSWORD_RESET) { + $this->errorMessage = 'This password reset link is invalid or has expired.'; + + return; + } + + session()->flash('status', 'Your password has been reset. You can now log in.'); + + $this->redirect(route('admin.login')); + } + + /** + * Render the reset-password page on the centered auth layout. + */ + public function render(): View + { + return view('livewire.admin.auth.reset-password') + ->layout('admin.layouts.auth') + ->title('Reset password'); + } +} diff --git a/app/Livewire/Admin/Collections/Form.php b/app/Livewire/Admin/Collections/Form.php new file mode 100644 index 00000000..e308e5e3 --- /dev/null +++ b/app/Livewire/Admin/Collections/Form.php @@ -0,0 +1,218 @@ + */ + public array $assignedProductIds = []; + + public function mount(?Collection $collection = null): void + { + if ($collection !== null && $collection->exists) { + $this->authorize('update', $collection); + + $this->collection = $collection; + $this->title = $collection->title; + $this->handle = $collection->handle; + $this->descriptionHtml = (string) ($collection->description_html ?? ''); + $this->type = $collection->type->value; + $this->status = $collection->status->value; + $this->assignedProductIds = $collection->products()->pluck('products.id')->all(); + } else { + $this->authorize('create', Collection::class); + } + } + + /** + * Auto-generate the URL handle from the title while the user has not + * edited it manually (spec 03 §5.2). + */ + public function updatedTitle(string $value): void + { + if (! $this->isEditing() && ! $this->handleManuallyEdited) { + $this->handle = Str::slug($value); + } + } + + public function updatedHandle(): void + { + $this->handleManuallyEdited = true; + } + + /** + * Add a product to the collection. + */ + public function addProduct(int $productId): void + { + if (! in_array($productId, array_map('intval', $this->assignedProductIds), true)) { + $this->assignedProductIds[] = $productId; + } + + $this->productSearch = ''; + } + + /** + * Remove a product from the collection. + */ + public function removeProduct(int $productId): void + { + $this->assignedProductIds = array_values(array_filter( + $this->assignedProductIds, + fn ($id): bool => (int) $id !== $productId, + )); + } + + /** + * Move an assigned product one position up or down. Drag-and-drop is + * intentionally replaced by buttons (see blade note, spec 03 §5.2). + */ + public function moveProduct(int $index, string $direction): void + { + $ids = array_values($this->assignedProductIds); + $swapWith = $direction === 'up' ? $index - 1 : $index + 1; + + if (! isset($ids[$index], $ids[$swapWith])) { + return; + } + + [$ids[$index], $ids[$swapWith]] = [$ids[$swapWith], $ids[$index]]; + + $this->assignedProductIds = $ids; + } + + /** + * Validate and save the collection with its product assignments + * (spec 03 §5.2). Positions are the order of the assigned ids. + */ + public function save(): void + { + $validated = $this->validate($this->rules()); + + /** @var Store $store */ + $store = app('current_store'); + + $data = [ + 'title' => $validated['title'], + 'handle' => $validated['handle'], + 'description_html' => app(SanitizeHtml::class)($validated['descriptionHtml'] ?? null), + 'type' => $validated['type'], + 'status' => $validated['status'], + ]; + + $sync = []; + foreach (array_map('intval', $this->assignedProductIds) as $position => $productId) { + $sync[$productId] = ['position' => $position]; + } + + if ($this->isEditing()) { + $this->authorize('update', $this->collection); + + $this->collection->update($data); + $this->collection->products()->sync($sync); + + $this->dispatch('toast', type: 'success', message: 'Collection saved'); + } else { + $this->authorize('create', Collection::class); + + $collection = Collection::create(array_merge($data, ['store_id' => $store->id])); + $collection->products()->sync($sync); + + session()->flash('toast', ['type' => 'success', 'message' => 'Collection saved']); + + $this->redirect(route('admin.collections.edit', $collection)); + } + } + + public function render(): View + { + return view('livewire.admin.collections.form', [ + 'searchResults' => $this->searchProducts(), + 'assignedProducts' => Product::query() + ->whereIn('id', $this->assignedProductIds === [] ? [0] : $this->assignedProductIds) + ->get() + ->sortBy(fn (Product $product) => array_search($product->id, array_map('intval', $this->assignedProductIds), true)) + ->values(), + ])->layout('admin.layouts.app')->title($this->isEditing() ? $this->collection->title : 'Create collection'); + } + + /** + * Whether the form is editing an existing collection. + */ + public function isEditing(): bool + { + return $this->collection !== null && $this->collection->exists; + } + + /** + * Validation rules (spec 03 §5.2). + * + * @return array + */ + protected function rules(): array + { + /** @var Store $store */ + $store = app('current_store'); + + return [ + 'title' => ['required', 'string', 'max:255'], + 'handle' => [ + 'required', 'string', 'max:255', + Rule::unique('collections', 'handle') + ->where('store_id', $store->id) + ->ignore($this->collection?->id), + ], + 'descriptionHtml' => ['nullable', 'string', 'max:65535'], + 'type' => ['required', Rule::in(['manual', 'automated'])], + 'status' => ['required', Rule::in(['draft', 'active', 'archived'])], + 'assignedProductIds' => ['array'], + 'assignedProductIds.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $store->id)], + ]; + } + + /** + * Product search results for the picker, excluding assigned products. + * + * @return \Illuminate\Database\Eloquent\Collection + */ + private function searchProducts(): \Illuminate\Database\Eloquent\Collection + { + if (trim($this->productSearch) === '') { + return Product::query()->whereRaw('1 = 0')->get(); + } + + $term = '%'.addcslashes($this->productSearch, '\\%_').'%'; + + return Product::query() + ->where('title', 'like', $term) + ->whereNotIn('id', array_map('intval', $this->assignedProductIds)) + ->orderBy('title') + ->limit(8) + ->get(); + } +} diff --git a/app/Livewire/Admin/Collections/Index.php b/app/Livewire/Admin/Collections/Index.php new file mode 100644 index 00000000..a0374055 --- /dev/null +++ b/app/Livewire/Admin/Collections/Index.php @@ -0,0 +1,87 @@ +authorize('viewAny', Collection::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + /** + * Open the delete confirmation modal for a collection. + */ + public function confirmDelete(int $id): void + { + $this->deletingId = $id; + $this->confirmingDelete = true; + } + + /** + * Delete the collection and detach its products (spec 03 §5.1). + */ + public function delete(): void + { + $this->confirmingDelete = false; + + $collection = Collection::query()->find($this->deletingId); + $this->deletingId = null; + + if ($collection === null) { + return; + } + + $this->authorize('delete', $collection); + + $collection->products()->detach(); + $collection->delete(); + + $this->dispatch('toast', type: 'success', message: 'Collection deleted'); + } + + public function render(): View + { + $collections = Collection::query() + ->withCount('products') + ->when($this->search !== '', function (Builder $query): void { + $term = '%'.addcslashes($this->search, '\\%_').'%'; + + $query->where('title', 'like', $term); + }) + ->when($this->statusFilter !== 'all', fn (Builder $query) => $query->where('status', $this->statusFilter)) + ->orderByDesc('updated_at') + ->paginate(15); + + return view('livewire.admin.collections.index', [ + 'collections' => $collections, + 'hasCollections' => Collection::query()->exists(), + ])->layout('admin.layouts.app')->title('Collections'); + } +} diff --git a/app/Livewire/Admin/Customers/Index.php b/app/Livewire/Admin/Customers/Index.php new file mode 100644 index 00000000..7bc06fd6 --- /dev/null +++ b/app/Livewire/Admin/Customers/Index.php @@ -0,0 +1,62 @@ +authorize('viewAny', Customer::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function render(): View + { + $customers = $this->customersQuery() + ->withCount('orders') + ->withSum('orders', 'total_amount') + ->paginate(15); + + /** @var \App\Models\Store $store */ + $store = app('current_store'); + + return view('livewire.admin.customers.index', [ + 'customers' => $customers, + 'hasCustomers' => Customer::query()->exists(), + 'currency' => $store->default_currency, + ])->layout('admin.layouts.app')->title('Customers'); + } + + /** + * Customers matching the name/email search (spec 03 §9.1). + * + * @return Builder + */ + private function customersQuery(): Builder + { + return Customer::query() + ->when($this->search !== '', function (Builder $query): void { + $term = '%'.addcslashes($this->search, '\\%_').'%'; + + $query->where(function (Builder $query) use ($term): void { + $query->where('name', 'like', $term) + ->orWhere('email', 'like', $term); + }); + }) + ->latest('created_at'); + } +} diff --git a/app/Livewire/Admin/Customers/Show.php b/app/Livewire/Admin/Customers/Show.php new file mode 100644 index 00000000..d6973812 --- /dev/null +++ b/app/Livewire/Admin/Customers/Show.php @@ -0,0 +1,90 @@ +authorize('view', $customer); + + $customer->loadMissing('addresses'); + + $this->customer = $customer; + } + + /** + * Open the edit modal with the current values preloaded. + */ + public function openEditModal(): void + { + $this->authorize('update', $this->customer); + + $this->resetValidation(); + $this->name = (string) ($this->customer->name ?? ''); + $this->marketingOptIn = $this->customer->marketing_opt_in; + $this->showEditModal = true; + } + + /** + * Update the customer's name and marketing opt-in (spec 03 §9.2). + */ + public function saveCustomer(): void + { + $this->authorize('update', $this->customer); + + $validated = $this->validate([ + 'name' => ['nullable', 'string', 'max:255'], + 'marketingOptIn' => ['boolean'], + ]); + + $name = trim((string) ($validated['name'] ?? '')); + + $this->customer->update([ + 'name' => $name !== '' ? $name : null, + 'marketing_opt_in' => (bool) ($validated['marketingOptIn'] ?? false), + ]); + + $this->showEditModal = false; + $this->customer->refresh(); + + $this->dispatch('toast', type: 'success', message: 'Customer saved'); + } + + public function render(): View + { + $orders = $this->customer->orders() + ->latest('placed_at') + ->paginate(10); + + /** @var \App\Models\Store $store */ + $store = app('current_store'); + + $ordersCount = $this->customer->orders()->count(); + $totalSpent = (int) $this->customer->orders()->sum('total_amount'); + + return view('livewire.admin.customers.show', [ + 'customer' => $this->customer, + 'orders' => $orders, + 'ordersCount' => $ordersCount, + 'totalSpent' => $totalSpent, + 'averageOrderValue' => $ordersCount > 0 ? intdiv($totalSpent, $ordersCount) : 0, + 'currency' => $store->default_currency, + ])->layout('admin.layouts.app')->title($this->customer->name ?? $this->customer->email); + } +} diff --git a/app/Livewire/Admin/Dashboard.php b/app/Livewire/Admin/Dashboard.php new file mode 100644 index 00000000..9408e3d4 --- /dev/null +++ b/app/Livewire/Admin/Dashboard.php @@ -0,0 +1,177 @@ +period(); + [$previousStart, $previousEnd] = $this->previousPeriod(); + + $current = $this->orderMetrics($start, $end); + $previous = $this->orderMetrics($previousStart, $previousEnd); + + $totalSales = $current['total_sales']; + $ordersCount = $current['orders_count']; + $averageOrderValue = $ordersCount > 0 ? (int) intdiv($totalSales, $ordersCount) : 0; + $previousAov = $previous['orders_count'] > 0 ? (int) intdiv($previous['total_sales'], $previous['orders_count']) : 0; + + $recentOrders = Order::query() + ->with('customer') + ->whereNotNull('placed_at') + ->latest('placed_at') + ->limit(10) + ->get(); + + return view('livewire.admin.dashboard', [ + 'totalSales' => $totalSales, + 'ordersCount' => $ordersCount, + 'averageOrderValue' => $averageOrderValue, + 'conversionRate' => $this->conversionRate($start, $end, $ordersCount), + 'salesChange' => $this->percentageChange($previous['total_sales'], $totalSales), + 'ordersChange' => $this->percentageChange($previous['orders_count'], $ordersCount), + 'aovChange' => $this->percentageChange($previousAov, $averageOrderValue), + 'chart' => $this->chartData($start, $end), + 'recentOrders' => $recentOrders, + 'currency' => $store->default_currency, + ])->layout('admin.layouts.app')->title('Dashboard'); + } + + /** + * Inclusive current period: [today - (range - 1) days, now]. + * + * @return array{0: CarbonImmutable, 1: CarbonImmutable} + */ + private function period(): array + { + $end = CarbonImmutable::now()->endOfDay(); + $start = CarbonImmutable::now()->subDays($this->dateRange - 1)->startOfDay(); + + return [$start, $end]; + } + + /** + * The period immediately before the current one, same length. + * + * @return array{0: CarbonImmutable, 1: CarbonImmutable} + */ + private function previousPeriod(): array + { + [$start] = $this->period(); + + return [ + $start->subDays($this->dateRange), + $start->subDay()->endOfDay(), + ]; + } + + /** + * Aggregate sales and order count for a period. + * + * @return array{total_sales: int, orders_count: int} + */ + private function orderMetrics(CarbonImmutable $start, CarbonImmutable $end): array + { + $row = Order::query() + ->whereBetween('placed_at', [$start, $end]) + ->selectRaw('COALESCE(SUM(total_amount), 0) as total_sales, COUNT(*) as orders_count') + ->first(); + + return [ + 'total_sales' => (int) $row->total_sales, + 'orders_count' => (int) $row->orders_count, + ]; + } + + /** + * Conversion rate: orders per unique analytics session (page_view). + * Null when no analytics events exist for the period. + */ + private function conversionRate(CarbonImmutable $start, CarbonImmutable $end, int $ordersCount): ?float + { + $sessions = DB::table('analytics_events') + ->where('store_id', app('current_store')->id) + ->where('type', 'page_view') + ->whereNotNull('session_id') + ->whereBetween('created_at', [$start, $end]) + ->distinct() + ->count('session_id'); + + if ($sessions === 0) { + return null; + } + + return round($ordersCount / $sessions * 100, 1); + } + + /** + * Percentage change between two values, null when there is no baseline. + */ + private function percentageChange(int $previous, int $current): ?float + { + if ($previous === 0) { + return null; + } + + return round(($current - $previous) / $previous * 100, 1); + } + + /** + * Daily order counts for the chart, including SVG polyline points. + * + * @return array{days: list, points: string, max: int} + */ + private function chartData(CarbonImmutable $start, CarbonImmutable $end): array + { + $counts = Order::query() + ->whereBetween('placed_at', [$start, $end]) + ->selectRaw('DATE(placed_at) as day, COUNT(*) as count') + ->groupBy('day') + ->pluck('count', 'day'); + + $days = []; + + for ($date = $start; $date->lte($end); $date = $date->addDay()) { + $key = $date->toDateString(); + $days[] = ['date' => $key, 'count' => (int) ($counts[$key] ?? 0)]; + } + + $max = max(1, max(array_column($days, 'count'))); + $count = count($days); + + $points = []; + + foreach ($days as $index => $day) { + $x = $count > 1 ? $index * (600 / ($count - 1)) : 300; + $y = 150 - ($day['count'] / $max) * 140; + $points[] = round($x, 1).','.round($y, 1); + } + + return [ + 'days' => $days, + 'points' => implode(' ', $points), + 'max' => $max, + ]; + } +} diff --git a/app/Livewire/Admin/Developers/Index.php b/app/Livewire/Admin/Developers/Index.php new file mode 100644 index 00000000..136f0356 --- /dev/null +++ b/app/Livewire/Admin/Developers/Index.php @@ -0,0 +1,289 @@ + + */ + public const ABILITIES = [ + 'read-products' => 'Read products', + 'write-products' => 'Write products', + 'read-orders' => 'Read orders', + 'write-orders' => 'Write orders', + 'read-customers' => 'Read customers', + 'write-customers' => 'Write customers', + 'read-collections' => 'Read collections', + 'write-collections' => 'Write collections', + 'read-discounts' => 'Read discounts', + 'write-discounts' => 'Write discounts', + 'read-analytics' => 'Read analytics', + 'read-settings' => 'Read settings', + 'write-settings' => 'Write settings', + 'read-themes' => 'Read themes', + 'write-themes' => 'Write themes', + 'read-content' => 'Read content', + 'write-content' => 'Write content', + 'manage-platform' => 'Manage platform', + ]; + + /** + * Webhook event types that can be subscribed to (spec 05 §13.1). + * + * @var list + */ + public const EVENT_TYPES = [ + 'order.created', + 'order.paid', + 'order.fulfilled', + 'order.refunded', + 'product.created', + 'product.updated', + 'product.deleted', + 'checkout.completed', + ]; + + public string $newTokenName = ''; + + /** @var list */ + public array $newTokenAbilities = []; + + public string $newTokenExpiresAt = ''; + + public ?string $generatedToken = null; + + public bool $showTokenModal = false; + + public string $webhookEventType = 'order.created'; + + public string $webhookUrl = ''; + + public ?int $editingWebhookId = null; + + public bool $showWebhookModal = false; + + public ?string $generatedWebhookSecret = null; + + public ?int $viewingDeliveriesFor = null; + + public bool $showDeliveriesModal = false; + + public function mount(): void + { + Gate::authorize('manage-developers'); + } + + /** + * Create a new API token; the plain value is shown once (spec 03 §16). + */ + public function generateToken(ApiTokenService $tokens): void + { + Gate::authorize('manage-developers'); + + $validated = $this->validate([ + 'newTokenName' => ['required', 'string', 'max:255'], + 'newTokenAbilities' => ['required', 'array', 'min:1'], + 'newTokenAbilities.*' => ['string', Rule::in(array_keys(self::ABILITIES))], + 'newTokenExpiresAt' => ['nullable', 'date', 'after:today'], + ]); + + $expiresAt = ($validated['newTokenExpiresAt'] ?? '') !== '' + ? CarbonImmutable::parse($validated['newTokenExpiresAt'])->endOfDay() + : null; + + $this->generatedToken = $tokens->create( + $this->user(), + $validated['newTokenName'], + $validated['newTokenAbilities'], + $expiresAt, + ); + + $this->reset('newTokenName', 'newTokenAbilities', 'newTokenExpiresAt'); + $this->showTokenModal = false; + + $this->dispatch('toast', type: 'success', message: 'API token created'); + } + + /** + * Revoke (delete) one of the current user's tokens. + */ + public function revokeToken(ApiTokenService $tokens, int $tokenId): void + { + Gate::authorize('manage-developers'); + + $tokens->revoke($this->user(), $tokenId); + + $this->dispatch('toast', type: 'success', message: 'API token revoked'); + } + + /** + * Open the webhook create/edit modal. + */ + public function openWebhookModal(?int $webhookId = null): void + { + Gate::authorize('manage-developers'); + + $this->resetValidation(); + $this->generatedWebhookSecret = null; + + if ($webhookId === null) { + $this->editingWebhookId = null; + $this->webhookEventType = self::EVENT_TYPES[0]; + $this->webhookUrl = ''; + } else { + $webhook = WebhookSubscription::query()->findOrFail($webhookId); + $this->editingWebhookId = $webhook->id; + $this->webhookEventType = $webhook->event_type; + $this->webhookUrl = $webhook->target_url; + } + + $this->showWebhookModal = true; + } + + /** + * Create or update the webhook subscription. The signing secret is + * generated on create and shown exactly once. + */ + public function saveWebhook(): void + { + Gate::authorize('manage-developers'); + + $validated = $this->validate([ + 'webhookEventType' => ['required', Rule::in(self::EVENT_TYPES)], + 'webhookUrl' => ['required', 'url:https', 'max:2048'], + ]); + + if ($this->editingWebhookId !== null) { + $webhook = WebhookSubscription::query()->findOrFail($this->editingWebhookId); + $webhook->forceFill([ + 'event_type' => $validated['webhookEventType'], + 'target_url' => $validated['webhookUrl'], + ])->save(); + + $this->dispatch('toast', type: 'success', message: 'Webhook updated'); + } else { + $secret = 'whsec_'.Str::random(32); + + WebhookSubscription::query()->create([ + 'event_type' => $validated['webhookEventType'], + 'target_url' => $validated['webhookUrl'], + 'signing_secret_encrypted' => $secret, + 'status' => WebhookSubscriptionStatus::Active, + ]); + + $this->generatedWebhookSecret = $secret; + + $this->dispatch('toast', type: 'success', message: 'Webhook created'); + } + + $this->showWebhookModal = false; + } + + /** + * Pause deliveries to the subscription (circuit breaker state or + * manual pause). Manual action per spec 05 §13.4. + */ + public function pauseWebhook(int $webhookId): void + { + Gate::authorize('manage-developers'); + + WebhookSubscription::query()->findOrFail($webhookId) + ->forceFill(['status' => WebhookSubscriptionStatus::Paused]) + ->save(); + + $this->dispatch('toast', type: 'success', message: 'Webhook paused'); + } + + /** + * Re-enable a paused subscription; resets the failure streak since + * only failed deliveries count against it. + */ + public function resumeWebhook(int $webhookId): void + { + Gate::authorize('manage-developers'); + + WebhookSubscription::query()->findOrFail($webhookId) + ->forceFill(['status' => WebhookSubscriptionStatus::Active]) + ->save(); + + $this->dispatch('toast', type: 'success', message: 'Webhook resumed'); + } + + /** + * Delete the subscription; its deliveries cascade away. + */ + public function deleteWebhook(int $webhookId): void + { + Gate::authorize('manage-developers'); + + WebhookSubscription::query()->findOrFail($webhookId)->delete(); + + if ($this->viewingDeliveriesFor === $webhookId) { + $this->viewingDeliveriesFor = null; + $this->showDeliveriesModal = false; + } + + $this->dispatch('toast', type: 'success', message: 'Webhook deleted'); + } + + /** + * Open the deliveries log modal for a subscription. + */ + public function viewDeliveries(int $webhookId): void + { + Gate::authorize('manage-developers'); + + WebhookSubscription::query()->findOrFail($webhookId); + + $this->viewingDeliveriesFor = $webhookId; + $this->showDeliveriesModal = true; + } + + public function render(): View + { + $deliveries = $this->viewingDeliveriesFor !== null + ? WebhookDelivery::query() + ->where('subscription_id', $this->viewingDeliveriesFor) + ->orderByDesc('id') + ->limit(20) + ->get() + : collect(); + + return view('livewire.admin.developers.index', [ + 'tokens' => $this->user()->tokens()->orderByDesc('created_at')->get(), + 'webhooks' => WebhookSubscription::query()->orderByDesc('id')->get(), + 'deliveries' => $deliveries, + 'abilities' => self::ABILITIES, + 'eventTypes' => self::EVENT_TYPES, + ])->layout('admin.layouts.app')->title('Developers'); + } + + /** + * The authenticated admin user. + */ + private function user(): User + { + /** @var User $user */ + $user = auth()->user(); + + return $user; + } +} diff --git a/app/Livewire/Admin/Discounts/Form.php b/app/Livewire/Admin/Discounts/Form.php new file mode 100644 index 00000000..89051f7f --- /dev/null +++ b/app/Livewire/Admin/Discounts/Form.php @@ -0,0 +1,337 @@ + */ + public array $specificProductIds = []; + + /** @var array */ + public array $specificCollectionIds = []; + + public ?int $usageLimit = null; + + public string $startsAt = ''; + + public ?string $endsAt = null; + + public bool $isActive = true; + + public string $productSearch = ''; + + public string $collectionSearch = ''; + + public function mount(?Discount $discount = null): void + { + if ($discount !== null && $discount->exists) { + $this->authorize('update', $discount); + + $this->discount = $discount; + $this->loadFromDiscount($discount); + } else { + $this->authorize('create', Discount::class); + + $this->startsAt = now()->format('Y-m-d\TH:i'); + } + } + + /** + * Auto-generate a random discount code (spec 03 §10.2). + */ + public function generateCode(): void + { + $this->code = mb_strtoupper(Str::random(10)); + } + + /** + * Add a product to the applicability rules. + */ + public function addProduct(int $productId): void + { + if (! in_array($productId, array_map('intval', $this->specificProductIds), true)) { + $this->specificProductIds[] = $productId; + } + + $this->productSearch = ''; + } + + /** + * Remove a product from the applicability rules. + */ + public function removeProduct(int $productId): void + { + $this->specificProductIds = array_values(array_filter( + $this->specificProductIds, + fn ($id): bool => (int) $id !== $productId, + )); + } + + /** + * Add a collection to the applicability rules. + */ + public function addCollection(int $collectionId): void + { + if (! in_array($collectionId, array_map('intval', $this->specificCollectionIds), true)) { + $this->specificCollectionIds[] = $collectionId; + } + + $this->collectionSearch = ''; + } + + /** + * Remove a collection from the applicability rules. + */ + public function removeCollection(int $collectionId): void + { + $this->specificCollectionIds = array_values(array_filter( + $this->specificCollectionIds, + fn ($id): bool => (int) $id !== $collectionId, + )); + } + + /** + * Validate and save the discount (spec 03 §10.2, spec 05 §7). + */ + public function save(): void + { + $this->normalizeNullableInputs(); + + $validated = $this->validate($this->rules()); + + /** @var Store $store */ + $store = app('current_store'); + + $type = DiscountType::from($validated['type']); + $code = $type === DiscountType::Code ? mb_strtoupper(trim($validated['code'])) : null; + + if ($code !== null) { + $this->assertCodeIsUnique($store, $code); + } + + $valueType = DiscountValueType::from($validated['valueType']); + + $data = [ + 'type' => $type, + 'code' => $code, + 'value_type' => $valueType, + 'value_amount' => $valueType === DiscountValueType::FreeShipping ? 0 : (int) $validated['valueAmount'], + 'starts_at' => $validated['startsAt'], + 'ends_at' => $validated['endsAt'] ?? null, + 'usage_limit' => $validated['usageLimit'] ?? null, + 'rules_json' => array_merge($this->discount?->rules_json ?? [], [ + 'min_purchase_amount' => $validated['minimumPurchaseAmount'] ?? null, + 'applicable_product_ids' => array_map('intval', $this->specificProductIds), + 'applicable_collection_ids' => array_map('intval', $this->specificCollectionIds), + ]), + 'status' => $this->targetStatus(), + ]; + + if ($this->isEditing()) { + $this->authorize('update', $this->discount); + + $this->discount->update($data); + $this->discount->refresh(); + + $this->dispatch('toast', type: 'success', message: 'Discount saved'); + } else { + $this->authorize('create', Discount::class); + + $discount = Discount::create(array_merge($data, ['store_id' => $store->id])); + + session()->flash('toast', ['type' => 'success', 'message' => 'Discount saved']); + + $this->redirect(route('admin.discounts.edit', $discount)); + } + } + + public function render(): View + { + return view('livewire.admin.discounts.form', [ + 'productResults' => $this->searchProducts(), + 'collectionResults' => $this->searchCollections(), + 'selectedProducts' => Product::query()->whereIn('id', $this->specificProductIds)->orderBy('title')->get(), + 'selectedCollections' => Collection::query()->whereIn('id', $this->specificCollectionIds)->orderBy('title')->get(), + ])->layout('admin.layouts.app')->title($this->isEditing() ? ($this->discount->code ?? 'Automatic discount') : 'Create discount'); + } + + /** + * Whether the form is editing an existing discount. + */ + public function isEditing(): bool + { + return $this->discount !== null && $this->discount->exists; + } + + /** + * Validation rules (spec 03 §10.2, spec 05 §7). + * + * @return array + */ + protected function rules(): array + { + /** @var Store $store */ + $store = app('current_store'); + + return [ + 'type' => ['required', Rule::in(['code', 'automatic'])], + 'code' => [$this->type === 'code' ? 'required' : 'nullable', 'string', 'max:255'], + 'valueType' => ['required', Rule::in(['percent', 'fixed', 'free_shipping'])], + 'valueAmount' => array_merge( + [$this->valueType === 'free_shipping' ? 'nullable' : 'required', 'integer'], + $this->valueType === 'percent' ? ['min:1', 'max:100'] : ['min:1'], + ), + 'minimumPurchaseAmount' => ['nullable', 'integer', 'min:0'], + 'usageLimit' => ['nullable', 'integer', 'min:1'], + 'startsAt' => ['required', 'date'], + 'endsAt' => ['nullable', 'date', 'after_or_equal:startsAt'], + 'specificProductIds' => ['array'], + 'specificProductIds.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $store->id)], + 'specificCollectionIds' => ['array'], + 'specificCollectionIds.*' => ['integer', Rule::exists('collections', 'id')->where('store_id', $store->id)], + 'isActive' => ['boolean'], + ]; + } + + /** + * Convert empty-string optional inputs to null so "nullable|integer" + * validates correctly from Livewire inputs. + */ + private function normalizeNullableInputs(): void + { + foreach (['valueAmount', 'minimumPurchaseAmount', 'usageLimit'] as $property) { + if ($this->{$property} === '' || $this->{$property} === null) { + $this->{$property} = null; + } else { + $this->{$property} = (int) $this->{$property}; + } + } + + if ($this->endsAt === '') { + $this->endsAt = null; + } + } + + /** + * Ensure the code is unique within the store, case-insensitively + * (spec 05 §7.1). + */ + private function assertCodeIsUnique(Store $store, string $code): void + { + $exists = Discount::query() + ->where('store_id', $store->id) + ->whereRaw('lower(code) = ?', [mb_strtolower($code)]) + ->when($this->isEditing(), fn (Builder $query) => $query->whereKeyNot($this->discount->id)) + ->exists(); + + if ($exists) { + throw ValidationException::withMessages([ + 'code' => ["The code '{$code}' is already used by another discount in this store."], + ]); + } + } + + /** + * Next status given the current lifecycle state and the active toggle + * (spec 05 §7 state machine; expired is terminal and automatic). + */ + private function targetStatus(): DiscountStatus + { + if (! $this->isEditing()) { + return $this->isActive ? DiscountStatus::Active : DiscountStatus::Draft; + } + + return match ($this->discount->status) { + DiscountStatus::Expired => DiscountStatus::Expired, + DiscountStatus::Active => $this->isActive ? DiscountStatus::Active : DiscountStatus::Disabled, + DiscountStatus::Draft => $this->isActive ? DiscountStatus::Active : DiscountStatus::Draft, + DiscountStatus::Disabled => $this->isActive ? DiscountStatus::Active : DiscountStatus::Disabled, + }; + } + + /** + * Load the discount's data into the form properties (edit mode). + */ + private function loadFromDiscount(Discount $discount): void + { + $rules = $discount->rules_json ?? []; + + $this->type = $discount->type->value; + $this->code = (string) ($discount->code ?? ''); + $this->valueType = $discount->value_type->value; + $this->valueAmount = $discount->value_type === DiscountValueType::FreeShipping ? null : $discount->value_amount; + $this->minimumPurchaseAmount = $rules['min_purchase_amount'] ?? null; + $this->specificProductIds = array_map('intval', $rules['applicable_product_ids'] ?? []); + $this->specificCollectionIds = array_map('intval', $rules['applicable_collection_ids'] ?? []); + $this->usageLimit = $discount->usage_limit; + $this->startsAt = $discount->starts_at?->format('Y-m-d\TH:i') ?? now()->format('Y-m-d\TH:i'); + $this->endsAt = $discount->ends_at?->format('Y-m-d\TH:i'); + $this->isActive = $discount->status === DiscountStatus::Active; + } + + /** + * Product search results for the applicability picker. + * + * @return \Illuminate\Database\Eloquent\Collection + */ + private function searchProducts(): \Illuminate\Database\Eloquent\Collection + { + if (trim($this->productSearch) === '') { + return Product::query()->whereRaw('1 = 0')->get(); + } + + $term = '%'.addcslashes($this->productSearch, '\\%_').'%'; + + return Product::query() + ->where('title', 'like', $term) + ->orderBy('title') + ->limit(8) + ->get(); + } + + /** + * Collection search results for the applicability picker. + * + * @return \Illuminate\Database\Eloquent\Collection + */ + private function searchCollections(): \Illuminate\Database\Eloquent\Collection + { + if (trim($this->collectionSearch) === '') { + return Collection::query()->whereRaw('1 = 0')->get(); + } + + $term = '%'.addcslashes($this->collectionSearch, '\\%_').'%'; + + return Collection::query() + ->where('title', 'like', $term) + ->orderBy('title') + ->limit(8) + ->get(); + } +} diff --git a/app/Livewire/Admin/Discounts/Index.php b/app/Livewire/Admin/Discounts/Index.php new file mode 100644 index 00000000..98aedb45 --- /dev/null +++ b/app/Livewire/Admin/Discounts/Index.php @@ -0,0 +1,185 @@ +authorize('viewAny', Discount::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function updatedTypeFilter(): void + { + $this->resetPage(); + } + + /** + * Disable an active discount (active -> disabled, spec 05 §7). + */ + public function disable(int $discountId): void + { + $discount = Discount::query()->findOrFail($discountId); + $this->authorize('update', $discount); + + if ($discount->status !== DiscountStatus::Active) { + $this->dispatch('toast', type: 'error', message: 'Only active discounts can be disabled.'); + + return; + } + + $discount->update(['status' => DiscountStatus::Disabled]); + + $this->dispatch('toast', type: 'success', message: 'Discount disabled'); + } + + /** + * Re-enable a disabled discount or activate a draft (spec 05 §7). + */ + public function enable(int $discountId): void + { + $discount = Discount::query()->findOrFail($discountId); + $this->authorize('update', $discount); + + if (! in_array($discount->status, [DiscountStatus::Disabled, DiscountStatus::Draft], true)) { + $this->dispatch('toast', type: 'error', message: 'Only draft or disabled discounts can be activated.'); + + return; + } + + $discount->update(['status' => DiscountStatus::Active]); + + $this->dispatch('toast', type: 'success', message: 'Discount activated'); + } + + /** + * Open the delete confirmation modal (spec 03 §19.3). + */ + public function confirmDelete(int $discountId): void + { + $this->deletingId = $discountId; + $this->confirmingDelete = true; + } + + /** + * Delete the discount (owner/admin only per DiscountPolicy). + */ + public function delete(): void + { + $discount = Discount::query()->findOrFail($this->deletingId); + $this->authorize('delete', $discount); + + $discount->delete(); + + $this->confirmingDelete = false; + $this->deletingId = null; + + $this->dispatch('toast', type: 'success', message: 'Discount deleted'); + } + + /** + * Effective display status: derives "scheduled" and "expired" from the + * date window of active discounts (spec 03 §10.1). + */ + public function displayStatus(Discount $discount): string + { + return match (true) { + $discount->status === DiscountStatus::Active && $discount->starts_at?->isFuture() => 'scheduled', + $discount->status === DiscountStatus::Active && $discount->ends_at?->isPast() => 'expired', + default => $discount->status->value, + }; + } + + /** + * Human-readable discount value, e.g. "10%", "5.00 EUR", "Free shipping". + */ + public function displayValue(Discount $discount, string $currency): string + { + return match ($discount->value_type) { + DiscountValueType::Percent => $discount->value_amount.'%', + DiscountValueType::Fixed => Money::format($discount->value_amount, $currency), + DiscountValueType::FreeShipping => 'Free shipping', + }; + } + + public function render(): View + { + $discounts = $this->discountsQuery()->paginate(15); + + /** @var \App\Models\Store $store */ + $store = app('current_store'); + + return view('livewire.admin.discounts.index', [ + 'discounts' => $discounts, + 'hasDiscounts' => Discount::query()->exists(), + 'currency' => $store->default_currency, + ])->layout('admin.layouts.app')->title('Discounts'); + } + + /** + * Base query with code search and status/type filters (spec 03 §10.1). + * + * @return Builder + */ + private function discountsQuery(): Builder + { + return Discount::query() + ->when($this->search !== '', function (Builder $query): void { + $term = '%'.addcslashes($this->search, '\\%_').'%'; + + $query->where('code', 'like', $term); + }) + ->when($this->typeFilter !== 'all', fn (Builder $query) => $query->where('type', $this->typeFilter)) + ->when($this->statusFilter !== 'all', function (Builder $query): void { + match ($this->statusFilter) { + 'draft' => $query->where('status', DiscountStatus::Draft), + 'active' => $query->where('status', DiscountStatus::Active) + ->where(fn (Builder $query) => $query->whereNull('starts_at')->orWhere('starts_at', '<=', now())) + ->where(fn (Builder $query) => $query->whereNull('ends_at')->orWhere('ends_at', '>', now())), + 'scheduled' => $query->where('status', DiscountStatus::Active) + ->whereNotNull('starts_at') + ->where('starts_at', '>', now()), + 'expired' => $query->where(fn (Builder $query) => $query + ->where('status', DiscountStatus::Expired) + ->orWhere(fn (Builder $expired) => $expired + ->where('status', DiscountStatus::Active) + ->whereNotNull('ends_at') + ->where('ends_at', '<=', now()))), + 'disabled' => $query->where('status', DiscountStatus::Disabled), + default => null, + }; + }) + ->latest('created_at'); + } +} diff --git a/app/Livewire/Admin/Inventory/Index.php b/app/Livewire/Admin/Inventory/Index.php new file mode 100644 index 00000000..4ede8401 --- /dev/null +++ b/app/Livewire/Admin/Inventory/Index.php @@ -0,0 +1,142 @@ +authorize('viewAny', Product::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStockFilter(): void + { + $this->resetPage(); + } + + /** + * Set the on-hand quantity of an inventory item (spec 03 §6 inline edit). + */ + public function setQuantity(int $itemId, mixed $value): void + { + $item = $this->findItem($itemId); + $this->authorize('update', $item->variant->product); + + $quantity = max(0, (int) $value); + + DB::transaction(function () use ($itemId, $quantity): void { + InventoryItem::query()->lockForUpdate()->findOrFail($itemId) + ->update(['quantity_on_hand' => $quantity]); + }); + + $this->dispatch('toast', type: 'success', message: 'Changes saved.'); + } + + /** + * Adjust the on-hand quantity by a relative delta. + */ + public function adjustQuantity(int $itemId, int $delta): void + { + $item = $this->findItem($itemId); + $this->authorize('update', $item->variant->product); + + DB::transaction(function () use ($itemId, $delta): void { + $locked = InventoryItem::query()->lockForUpdate()->findOrFail($itemId); + $locked->update(['quantity_on_hand' => max(0, $locked->quantity_on_hand + $delta)]); + }); + + $this->dispatch('toast', type: 'success', message: 'Changes saved.'); + } + + /** + * Flip the oversell policy between deny and continue (spec 03 §6). + */ + public function togglePolicy(int $itemId): void + { + $item = $this->findItem($itemId); + $this->authorize('update', $item->variant->product); + + $item->update([ + 'policy' => $item->policy === InventoryPolicy::Deny ? InventoryPolicy::Continue : InventoryPolicy::Deny, + ]); + + $this->dispatch('toast', type: 'success', message: 'Changes saved.'); + } + + public function render(): View + { + $items = $this->itemsQuery() + ->with(['variant.product', 'variant.optionValues']) + ->paginate(15); + + return view('livewire.admin.inventory.index', [ + 'items' => $items, + ])->layout('admin.layouts.app')->title('Inventory'); + } + + /** + * Inventory items with search and stock-level filters (spec 03 §6). + * + * @return Builder + */ + private function itemsQuery(): Builder + { + return InventoryItem::query() + ->join('product_variants', 'inventory_items.variant_id', '=', 'product_variants.id') + ->join('products', 'product_variants.product_id', '=', 'products.id') + ->select('inventory_items.*') + ->when($this->search !== '', function (Builder $query): void { + $term = '%'.addcslashes($this->search, '\\%_').'%'; + + $query->where(function (Builder $query) use ($term): void { + $query->where('product_variants.sku', 'like', $term) + ->orWhere('products.title', 'like', $term); + }); + }) + ->when($this->stockFilter !== 'all', function (Builder $query): void { + $available = '(inventory_items.quantity_on_hand - inventory_items.quantity_reserved)'; + + match ($this->stockFilter) { + 'in_stock' => $query->whereRaw("{$available} > ?", [self::LOW_STOCK_THRESHOLD]), + 'low_stock' => $query->whereRaw("{$available} BETWEEN 1 AND ?", [self::LOW_STOCK_THRESHOLD]), + 'out_of_stock' => $query->whereRaw("{$available} <= 0"), + default => null, + }; + }) + ->orderBy('products.title') + ->orderBy('product_variants.position'); + } + + /** + * Find an inventory item, scoped to the current store by the global scope. + */ + private function findItem(int $itemId): InventoryItem + { + return InventoryItem::query()->with('variant.product')->findOrFail($itemId); + } +} diff --git a/app/Livewire/Admin/Layout/Breadcrumbs.php b/app/Livewire/Admin/Layout/Breadcrumbs.php new file mode 100644 index 00000000..764333bb --- /dev/null +++ b/app/Livewire/Admin/Layout/Breadcrumbs.php @@ -0,0 +1,112 @@ + + */ + private const SECTIONS = [ + 'products' => ['Products', 'admin.products.index'], + 'inventory' => ['Inventory', 'admin.inventory.index'], + 'orders' => ['Orders', 'admin.orders.index'], + 'collections' => ['Collections', 'admin.collections.index'], + 'customers' => ['Customers', 'admin.customers.index'], + 'discounts' => ['Discounts', 'admin.discounts.index'], + 'pages' => ['Pages', 'admin.pages.index'], + 'navigation' => ['Navigation', 'admin.navigation.index'], + 'themes' => ['Themes', 'admin.themes.index'], + 'analytics' => ['Analytics', 'admin.analytics.index'], + 'settings' => ['Settings', 'admin.settings.index'], + 'apps' => ['Apps', 'admin.apps.index'], + 'developers' => ['Developers', 'admin.developers.index'], + 'search' => ['Search', 'admin.search.settings'], + ]; + + public function render(): View + { + return view('livewire.admin.layout.breadcrumbs', [ + 'trail' => $this->trail(), + ]); + } + + /** + * Build the breadcrumb trail from the current route name (spec 03 §1.4, + * §19.5). First item is always "Home" linking to the dashboard; the + * last item is the current page title without a link. + * + * @return list + */ + private function trail(): array + { + $routeName = Route::currentRouteName() ?? ''; + $request = request(); + + $trail = [ + ['label' => 'Home', 'url' => $routeName === 'admin.dashboard' ? null : route('admin.dashboard')], + ]; + + if ($routeName === 'admin.dashboard') { + $trail[] = ['label' => 'Dashboard', 'url' => null]; + + return $trail; + } + + $segments = explode('.', str($routeName)->after('admin.')->toString()); + $section = $segments[0] ?? ''; + $action = $segments[1] ?? 'index'; + + if (! isset(self::SECTIONS[$section])) { + return $trail; + } + + [$label, $indexRoute] = self::SECTIONS[$section]; + $indexUrl = Route::has($indexRoute) ? route($indexRoute) : null; + + if ($action === 'index') { + $trail[] = ['label' => $label, 'url' => null]; + + return $trail; + } + + $trail[] = ['label' => $label, 'url' => $indexUrl]; + + $trail[] = match ($action) { + 'create' => ['label' => 'Create', 'url' => null], + 'edit' => ['label' => $this->currentModelTitle($section) ?? 'Edit', 'url' => null], + 'show' => ['label' => $this->currentModelTitle($section) ?? 'Details', 'url' => null], + default => ['label' => ucfirst($action), 'url' => null], + }; + + return $trail; + } + + /** + * Resolve the title of the route-bound model for edit/show pages, e.g. + * the product title for "Home > Products > Blue T-Shirt" or the order + * number for "Home > Orders > #1001". + */ + private function currentModelTitle(string $section): ?string + { + $model = request()->route($section === 'products' ? 'product' : rtrim($section, 's')); + + if (! is_object($model)) { + return null; + } + + foreach (['title', 'name', 'order_number', 'code', 'email'] as $attribute) { + if (isset($model->{$attribute}) && (string) $model->{$attribute} !== '') { + return (string) $model->{$attribute}; + } + } + + return null; + } +} diff --git a/app/Livewire/Admin/Layout/Sidebar.php b/app/Livewire/Admin/Layout/Sidebar.php new file mode 100644 index 00000000..d800e9db --- /dev/null +++ b/app/Livewire/Admin/Layout/Sidebar.php @@ -0,0 +1,119 @@ +currentRoute = Route::currentRouteName() ?? ''; + } + + /** + * Navigation structure (spec 03 §1.2). Each item carries the gate or + * policy ability that decides whether it is shown for the user's role, + * plus route-name patterns used for active highlighting. + * + * @return list, allowed: bool}>}> + */ + public function navigation(): array + { + return [ + ['group' => null, 'items' => [ + ['label' => 'Dashboard', 'icon' => 'chart-bar', 'route' => 'admin.dashboard', 'patterns' => ['admin.dashboard'], 'allowed' => true], + ]], + ['group' => 'Products', 'items' => [ + ['label' => 'Products', 'icon' => 'cube', 'route' => 'admin.products.index', 'patterns' => ['admin.products.*'], 'allowed' => Gate::allows('viewAny', Product::class)], + ['label' => 'Collections', 'icon' => 'rectangle-stack', 'route' => 'admin.collections.index', 'patterns' => ['admin.collections.*'], 'allowed' => Gate::allows('viewAny', Collection::class)], + ['label' => 'Inventory', 'icon' => 'archive-box', 'route' => 'admin.inventory.index', 'patterns' => ['admin.inventory.*'], 'allowed' => Gate::allows('viewAny', Product::class)], + ]], + ['group' => 'Orders', 'items' => [ + ['label' => 'Orders', 'icon' => 'shopping-bag', 'route' => 'admin.orders.index', 'patterns' => ['admin.orders.*'], 'allowed' => Gate::allows('viewAny', Order::class)], + ]], + ['group' => 'Customers', 'items' => [ + ['label' => 'Customers', 'icon' => 'users', 'route' => 'admin.customers.index', 'patterns' => ['admin.customers.*'], 'allowed' => Gate::allows('viewAny', Customer::class)], + ]], + ['group' => 'Discounts', 'items' => [ + ['label' => 'Discounts', 'icon' => 'tag', 'route' => 'admin.discounts.index', 'patterns' => ['admin.discounts.*'], 'allowed' => Gate::allows('create', Discount::class)], + ]], + ['group' => 'Content', 'items' => [ + ['label' => 'Pages', 'icon' => 'document-text', 'route' => 'admin.pages.index', 'patterns' => ['admin.pages.*'], 'allowed' => Gate::allows('create', Page::class)], + ['label' => 'Navigation', 'icon' => 'bars-3', 'route' => 'admin.navigation.index', 'patterns' => ['admin.navigation.*'], 'allowed' => Gate::allows('manage-navigation')], + ['label' => 'Themes', 'icon' => 'paint-brush', 'route' => 'admin.themes.index', 'patterns' => ['admin.themes.*'], 'allowed' => Gate::allows('create', Theme::class)], + ]], + ['group' => null, 'items' => [ + ['label' => 'Analytics', 'icon' => 'chart-pie', 'route' => 'admin.analytics.index', 'patterns' => ['admin.analytics.*'], 'allowed' => Gate::allows('view-analytics')], + ]], + ['group' => 'Settings', 'items' => [ + ['label' => 'Settings', 'icon' => 'cog-6-tooth', 'route' => 'admin.settings.index', 'patterns' => ['admin.settings.*'], 'allowed' => Gate::allows('manage-store-settings')], + ['label' => 'Shipping', 'icon' => 'truck', 'route' => 'admin.settings.shipping', 'patterns' => ['admin.settings.shipping*'], 'allowed' => Gate::allows('manage-shipping')], + ['label' => 'Taxes', 'icon' => 'receipt-percent', 'route' => 'admin.settings.taxes', 'patterns' => ['admin.settings.taxes*'], 'allowed' => Gate::allows('manage-taxes')], + ['label' => 'Search', 'icon' => 'magnifying-glass', 'route' => 'admin.search.settings', 'patterns' => ['admin.search.*'], 'allowed' => Gate::allows('manage-search-settings')], + ['label' => 'Apps', 'icon' => 'squares-2x2', 'route' => 'admin.apps.index', 'patterns' => ['admin.apps.*'], 'allowed' => Gate::allows('manage-apps')], + ['label' => 'Developers', 'icon' => 'code-bracket', 'route' => 'admin.developers.index', 'patterns' => ['admin.developers.*'], 'allowed' => Gate::allows('manage-developers')], + ]], + ]; + } + + /** + * Resolve the href for a nav item, falling back to "#" for sections + * whose routes land in a later phase. + */ + public function hrefFor(string $routeName): string + { + return Route::has($routeName) ? route($routeName) : '#'; + } + + /** + * Whether the nav item matches the current route. + * + * @param list $patterns + */ + public function isActive(array $patterns): bool + { + foreach ($patterns as $pattern) { + if ($this->currentRoute === $pattern || fnmatch($pattern, $this->currentRoute)) { + return true; + } + } + + return false; + } + + public function render(): View + { + /** @var Store $store */ + $store = app('current_store'); + + return view('livewire.admin.layout.sidebar', [ + 'store' => $store, + 'navigation' => array_filter( + array_map( + fn (array $section): array => array_merge($section, [ + 'items' => array_values(array_filter($section['items'], fn (array $item): bool => $item['allowed'])), + ]), + $this->navigation(), + ), + fn (array $section): bool => $section['items'] !== [], + ), + ]); + } +} diff --git a/app/Livewire/Admin/Layout/TopBar.php b/app/Livewire/Admin/Layout/TopBar.php new file mode 100644 index 00000000..313fc984 --- /dev/null +++ b/app/Livewire/Admin/Layout/TopBar.php @@ -0,0 +1,56 @@ +user(); + + $hasMembership = StoreUser::query() + ->where('store_id', $storeId) + ->where('user_id', $user->getKey()) + ->exists(); + + abort_unless($hasMembership, 403, 'You do not have access to this store.'); + + session(['current_store_id' => $storeId]); + + $this->redirect(route('admin.dashboard')); + } + + public function render(): View + { + $user = Auth::guard('web')->user(); + + /** @var Store $currentStore */ + $currentStore = app('current_store'); + + /** @var EloquentCollection $stores */ + $stores = $user->stores()->orderBy('name')->get(); + + return view('livewire.admin.layout.top-bar', [ + 'user' => $user, + 'currentStore' => $currentStore, + 'stores' => $stores, + 'currentRole' => $user->roleForStore($currentStore), + ]); + } +} diff --git a/app/Livewire/Admin/Navigation/Index.php b/app/Livewire/Admin/Navigation/Index.php new file mode 100644 index 00000000..bd43973e --- /dev/null +++ b/app/Livewire/Admin/Navigation/Index.php @@ -0,0 +1,298 @@ +authorize('viewAny', NavigationMenu::class); + + $this->selectedMenuId ??= NavigationMenu::query()->orderBy('id')->value('id'); + } + + /** + * Select a menu for editing (spec 03 §14). + */ + public function selectMenu(int $menuId): void + { + $this->selectedMenuId = $menuId; + } + + public function openMenuForm(): void + { + Gate::authorize('manage-navigation'); + + $this->menuTitle = ''; + $this->menuHandle = ''; + $this->showMenuForm = true; + } + + public function updatedMenuTitle(string $value): void + { + if ($this->menuHandle === '' || $this->menuHandle === Str::slug($this->menuTitle)) { + $this->menuHandle = Str::slug($value); + } + } + + /** + * Create a new navigation menu (spec 03 §14). + */ + public function createMenu(): void + { + Gate::authorize('manage-navigation'); + + /** @var Store $store */ + $store = app('current_store'); + + $validated = $this->validate([ + 'menuTitle' => ['required', 'string', 'max:255'], + 'menuHandle' => [ + 'required', 'string', 'max:255', + Rule::unique('navigation_menus', 'handle')->where('store_id', $store->id), + ], + ]); + + $menu = NavigationMenu::create([ + 'store_id' => $store->id, + 'title' => $validated['menuTitle'], + 'handle' => $validated['menuHandle'], + ]); + + $this->showMenuForm = false; + $this->selectedMenuId = $menu->id; + + $this->dispatch('toast', type: 'success', message: 'Navigation saved'); + } + + /** + * Open the item form modal in create or edit mode (spec 03 §14). + */ + public function openItemForm(?int $itemId = null): void + { + Gate::authorize('manage-navigation'); + + $this->editingItemId = $itemId; + + if ($itemId !== null) { + $item = $this->selectedMenu()?->items->firstWhere('id', $itemId); + + if ($item === null) { + return; + } + + $this->itemLabel = $item->label; + $this->itemType = $item->type->value; + $this->itemUrl = (string) ($item->url ?? ''); + $this->itemResourceId = $item->resource_id; + } else { + $this->itemLabel = ''; + $this->itemType = 'link'; + $this->itemUrl = ''; + $this->itemResourceId = null; + } + + $this->showItemForm = true; + } + + /** + * Add or update a menu item. Positions are persisted immediately; the + * cached tree is flushed by the model hooks (spec 03 §14). + */ + public function saveItem(): void + { + Gate::authorize('manage-navigation'); + + $menu = $this->selectedMenu(); + + if ($menu === null) { + return; + } + + $validated = $this->validate($this->itemRules()); + + $type = NavigationItemType::from($validated['itemType']); + $isLink = $type === NavigationItemType::Link; + + $data = [ + 'type' => $type, + 'label' => $validated['itemLabel'], + 'url' => $isLink ? $validated['itemUrl'] : null, + 'resource_id' => $isLink ? null : (int) $validated['itemResourceId'], + ]; + + if ($this->editingItemId !== null) { + $item = $menu->items->firstWhere('id', $this->editingItemId); + + if ($item === null) { + return; + } + + $item->update($data); + } else { + $menu->items()->create(array_merge($data, [ + 'position' => (int) $menu->items()->max('position') + 1, + ])); + } + + $this->showItemForm = false; + $this->editingItemId = null; + + $this->dispatch('toast', type: 'success', message: 'Navigation saved'); + } + + /** + * Remove an item from the selected menu. + */ + public function removeItem(int $itemId): void + { + Gate::authorize('manage-navigation'); + + $item = $this->selectedMenu()?->items->firstWhere('id', $itemId); + + if ($item === null) { + return; + } + + $item->delete(); + + $this->dispatch('toast', type: 'success', message: 'Navigation saved'); + } + + /** + * Swap an item's position with its neighbour. Buttons are used instead + * of drag-and-drop (spec 03 §14 note). + */ + public function moveItem(int $itemId, string $direction): void + { + Gate::authorize('manage-navigation'); + + $menu = $this->selectedMenu(); + + if ($menu === null) { + return; + } + + $items = $menu->items()->orderBy('position')->orderBy('id')->get(); + $index = $items->search(fn (NavigationItem $item): bool => $item->id === $itemId); + + if ($index === false) { + return; + } + + $swapIndex = $direction === 'up' ? $index - 1 : $index + 1; + + if (! isset($items[$swapIndex])) { + return; + } + + DB::transaction(function () use ($items, $index, $swapIndex): void { + $currentPosition = $items[$index]->position; + + $items[$index]->update(['position' => $items[$swapIndex]->position]); + $items[$swapIndex]->update(['position' => $currentPosition]); + }); + } + + public function render(): View + { + $selectedMenu = $this->selectedMenu(); + + $pages = Page::query()->orderBy('title')->get(['id', 'title']); + $collections = Collection::query()->orderBy('title')->get(['id', 'title']); + $products = Product::query()->orderBy('title')->limit(200)->get(['id', 'title']); + + return view('livewire.admin.navigation.index', [ + 'menus' => NavigationMenu::query()->withCount('items')->orderBy('id')->get(), + 'selectedMenu' => $selectedMenu, + 'items' => $selectedMenu?->items ?? collect(), + 'pages' => $pages, + 'collections' => $collections, + 'products' => $products, + 'resourceLabels' => [ + 'page' => $pages->pluck('title', 'id'), + 'collection' => $collections->pluck('title', 'id'), + 'product' => $products->pluck('title', 'id'), + ], + ])->layout('admin.layouts.app')->title('Navigation'); + } + + /** + * Validation rules for the item form, conditional on the item type. + * + * @return array + */ + private function itemRules(): array + { + $rules = [ + 'itemLabel' => ['required', 'string', 'max:255'], + 'itemType' => ['required', Rule::in(['link', 'page', 'collection', 'product'])], + 'itemUrl' => [$this->itemType === 'link' ? 'required' : 'nullable', 'string', 'max:2048'], + 'itemResourceId' => ['nullable'], + ]; + + if ($this->itemType !== 'link') { + /** @var Store $store */ + $store = app('current_store'); + + $table = match ($this->itemType) { + 'page' => 'pages', + 'collection' => 'collections', + 'product' => 'products', + }; + + $rules['itemResourceId'] = [ + 'required', 'integer', + Rule::exists($table, 'id')->where('store_id', $store->id), + ]; + } + + return $rules; + } + + /** + * The currently selected menu model. + */ + private function selectedMenu(): ?NavigationMenu + { + if ($this->selectedMenuId === null) { + return null; + } + + return NavigationMenu::query()->find($this->selectedMenuId); + } +} diff --git a/app/Livewire/Admin/Orders/Index.php b/app/Livewire/Admin/Orders/Index.php new file mode 100644 index 00000000..a31a14ed --- /dev/null +++ b/app/Livewire/Admin/Orders/Index.php @@ -0,0 +1,125 @@ +authorize('viewAny', Order::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function updatedFinancialFilter(): void + { + $this->resetPage(); + } + + public function updatedFulfillmentFilter(): void + { + $this->resetPage(); + } + + public function updatedDateFrom(): void + { + $this->resetPage(); + } + + public function updatedDateTo(): void + { + $this->resetPage(); + } + + /** + * Toggle the sort column or flip the direction (spec 03 §7). + */ + public function sortBy(string $field): void + { + if (! in_array($field, ['order_number', 'placed_at', 'total_amount'], true)) { + return; + } + + if ($this->sortField === $field) { + $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortField = $field; + $this->sortDirection = 'asc'; + } + + $this->resetPage(); + } + + public function render(): View + { + $orders = $this->ordersQuery() + ->with('customer') + ->paginate(15); + + return view('livewire.admin.orders.index', [ + 'orders' => $orders, + 'hasOrders' => Order::query()->exists(), + ])->layout('admin.layouts.app')->title('Orders'); + } + + /** + * Base query with search, status filters, date range, and sorting + * (spec 03 §7). + * + * @return Builder + */ + private function ordersQuery(): Builder + { + return Order::query() + ->when($this->search !== '', function (Builder $query): void { + $term = '%'.addcslashes($this->search, '\\%_').'%'; + + $query->where(function (Builder $query) use ($term): void { + $query->where('order_number', 'like', $term) + ->orWhere('email', 'like', $term) + ->orWhereHas('customer', fn (Builder $customer) => $customer + ->where('email', 'like', $term) + ->orWhere('name', 'like', $term)); + }); + }) + ->when($this->statusFilter !== 'all', fn (Builder $query) => $query->where('status', $this->statusFilter)) + ->when($this->financialFilter !== 'all', fn (Builder $query) => $query->where('financial_status', $this->financialFilter)) + ->when($this->fulfillmentFilter !== 'all', fn (Builder $query) => $query->where('fulfillment_status', $this->fulfillmentFilter)) + ->when($this->dateFrom !== '', fn (Builder $query) => $query->whereDate('placed_at', '>=', $this->dateFrom)) + ->when($this->dateTo !== '', fn (Builder $query) => $query->whereDate('placed_at', '<=', $this->dateTo)) + ->orderBy($this->sortField, $this->sortDirection) + ->orderBy('id', $this->sortDirection); + } +} diff --git a/app/Livewire/Admin/Orders/Show.php b/app/Livewire/Admin/Orders/Show.php new file mode 100644 index 00000000..cc1f9083 --- /dev/null +++ b/app/Livewire/Admin/Orders/Show.php @@ -0,0 +1,443 @@ + */ + public array $fulfillmentLines = []; + + public string $trackingCompany = ''; + + public string $trackingNumber = ''; + + public string $trackingUrl = ''; + + public ?int $refundAmount = null; + + public string $refundReason = ''; + + public bool $refundRestock = false; + + public string $cancelReason = ''; + + public bool $showFulfillmentModal = false; + + public bool $showRefundModal = false; + + public bool $showCancelModal = false; + + public bool $showShipModal = false; + + public ?int $shippingFulfillmentId = null; + + public function mount(Order $order): void + { + $this->authorize('view', $order); + + $order->load(['lines.product.media', 'payments', 'refunds', 'fulfillments.lines.orderLine', 'customer']); + + $this->order = $order; + } + + /** + * Confirm a bank transfer payment was received (spec 05 §10.7). + */ + public function confirmPayment(OrderService $orders): void + { + $this->authorize('update', $this->order); + + try { + $orders->confirmBankTransferPayment($this->order); + } catch (InvalidOrderTransitionException $exception) { + $this->dispatch('toast', type: 'error', message: $exception->getMessage()); + + return; + } + + $this->order->refresh(); + + $this->dispatch('toast', type: 'success', message: 'Payment confirmed'); + } + + /** + * Open the cancel-order modal (reason required). + */ + public function openCancelModal(): void + { + $this->authorize('cancel', $this->order); + + $this->resetValidation(); + $this->cancelReason = ''; + $this->showCancelModal = true; + } + + /** + * Cancel the order, releasing reserved inventory (spec 05 §11). + */ + public function cancelOrder(OrderService $orders): void + { + $this->authorize('cancel', $this->order); + + $validated = $this->validate([ + 'cancelReason' => ['required', 'string', 'max:1000'], + ]); + + try { + $orders->cancel($this->order, $validated['cancelReason']); + } catch (InvalidOrderTransitionException $exception) { + $this->showCancelModal = false; + $this->dispatch('toast', type: 'error', message: $exception->getMessage()); + + return; + } + + $this->showCancelModal = false; + $this->cancelReason = ''; + $this->order->refresh(); + + $this->dispatch('toast', type: 'success', message: 'Order cancelled'); + } + + /** + * Open the fulfillment modal with all unfulfilled quantities preselected + * (spec 03 §8). + */ + public function openFulfillmentModal(): void + { + $this->authorize('createFulfillment', $this->order); + + $this->resetValidation(); + $this->resetFulfillmentForm(); + + foreach ($this->unfulfilledQuantities() as $lineId => $quantity) { + if ($quantity > 0) { + $this->fulfillmentLines[$lineId] = $quantity; + } + } + + $this->showFulfillmentModal = true; + } + + /** + * Create a fulfillment for the selected lines (spec 05 §11.5). The + * fulfillment guard is enforced by the service. + */ + public function createFulfillment(FulfillmentService $fulfillments): void + { + $this->authorize('createFulfillment', $this->order); + + $this->validate($this->trackingRules()); + + $lines = collect($this->fulfillmentLines) + ->map(fn ($quantity): int => (int) $quantity) + ->filter(fn (int $quantity): bool => $quantity > 0); + + if ($lines->isEmpty()) { + $this->addError('fulfillmentLines', 'Select at least one item to fulfill.'); + + return; + } + + try { + $fulfillments->create($this->order, $lines->all(), $this->trackingPayload()); + } catch (FulfillmentGuardException $exception) { + $this->showFulfillmentModal = false; + $this->dispatch('toast', type: 'error', message: $exception->getMessage()); + + return; + } + + $this->showFulfillmentModal = false; + $this->resetFulfillmentForm(); + $this->order->refresh(); + + $this->dispatch('toast', type: 'success', message: 'Fulfillment created'); + } + + /** + * Open the tracking form before marking a fulfillment as shipped. + */ + public function openShipModal(int $fulfillmentId): void + { + $fulfillment = $this->findFulfillment($fulfillmentId); + $this->authorize('update', $fulfillment); + + $this->resetValidation(); + $this->shippingFulfillmentId = $fulfillment->id; + $this->trackingCompany = (string) ($fulfillment->tracking_company ?? ''); + $this->trackingNumber = (string) ($fulfillment->tracking_number ?? ''); + $this->trackingUrl = (string) ($fulfillment->tracking_url ?? ''); + $this->showShipModal = true; + } + + /** + * Transition a pending fulfillment to shipped with tracking data + * (spec 05 §11.5). + */ + public function markAsShipped(FulfillmentService $fulfillments): void + { + $fulfillment = $this->findFulfillment($this->shippingFulfillmentId); + $this->authorize('update', $fulfillment); + + $this->validate($this->trackingRules()); + + $fulfillments->markAsShipped($fulfillment, $this->trackingPayload()); + + $this->showShipModal = false; + $this->shippingFulfillmentId = null; + $this->order->refresh(); + + $this->dispatch('toast', type: 'success', message: 'Fulfillment marked as shipped'); + } + + /** + * Transition a shipped fulfillment to delivered (spec 05 §11.5). + */ + public function markAsDelivered(int $fulfillmentId, FulfillmentService $fulfillments): void + { + $fulfillment = $this->findFulfillment($fulfillmentId); + $this->authorize('update', $fulfillment); + + $fulfillments->markAsDelivered($fulfillment); + + $this->order->refresh(); + + $this->dispatch('toast', type: 'success', message: 'Fulfillment marked as delivered'); + } + + /** + * Open the refund modal with the full refundable amount preselected. + */ + public function openRefundModal(): void + { + $this->authorize('createRefund', $this->order); + + $this->resetValidation(); + $this->refundAmount = $this->order->refundableAmount(); + $this->refundReason = ''; + $this->refundRestock = false; + $this->showRefundModal = true; + } + + /** + * Create a refund for a custom amount (spec 05 §11.4). + */ + public function createRefund(RefundService $refunds): void + { + $this->authorize('createRefund', $this->order); + + $refundable = $this->order->refundableAmount(); + + $validated = $this->validate([ + 'refundAmount' => ['required', 'integer', 'min:1', 'max:'.$refundable], + 'refundReason' => ['nullable', 'string', 'max:1000'], + 'refundRestock' => ['boolean'], + ]); + + $payment = $this->order->payments() + ->whereIn('status', [PaymentStatus::Captured->value, PaymentStatus::Refunded->value]) + ->latest('id') + ->first(); + + if ($payment === null) { + $this->showRefundModal = false; + $this->dispatch('toast', type: 'error', message: 'No refundable payment found for this order.'); + + return; + } + + $reason = trim((string) ($validated['refundReason'] ?? '')); + + try { + $refunds->create( + $this->order, + $payment, + (int) $validated['refundAmount'], + $reason !== '' ? $reason : null, + (bool) ($validated['refundRestock'] ?? false), + ); + } catch (ValidationException $exception) { + $this->addError('refundAmount', $exception->validator->errors()->first('amount')); + + return; + } + + $this->showRefundModal = false; + $this->order->refresh(); + + $this->dispatch('toast', type: 'success', message: 'Refund issued'); + } + + /** + * Whether the "Confirm payment" button applies (spec 05 §10.7). + */ + public function canConfirmPayment(): bool + { + return $this->order->payment_method === PaymentMethod::BankTransfer + && $this->order->financial_status === FinancialStatus::Pending; + } + + /** + * Whether the fulfillment guard blocks fulfillment creation: payment + * must be confirmed (paid or partially refunded) first (spec 05 §11.5). + */ + public function fulfillmentGuardBlocks(): bool + { + return ! in_array($this->order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true); + } + + /** + * Whether a refund can still be created for the order. + */ + public function canRefund(): bool + { + return in_array($this->order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true) + && $this->order->refundableAmount() > 0; + } + + /** + * Whether the order can still be cancelled (not fulfilled/closed). + */ + public function canCancel(): bool + { + return ! in_array($this->order->status, [OrderStatus::Fulfilled, OrderStatus::Cancelled, OrderStatus::Refunded], true) + && $this->order->fulfillment_status !== FulfillmentOrderStatus::Fulfilled; + } + + public function render(): View + { + return view('livewire.admin.orders.show', [ + 'order' => $this->order, + 'unfulfilled' => $this->unfulfilledQuantities(), + 'refundableAmount' => $this->order->refundableAmount(), + 'timeline' => $this->timeline(), + ])->layout('admin.layouts.app')->title('Order '.$this->order->order_number); + } + + /** + * Unfulfilled quantity per order line id. + * + * @return array + */ + private function unfulfilledQuantities(): array + { + $fulfilled = FulfillmentLine::query() + ->whereIn('order_line_id', $this->order->lines->pluck('id')) + ->selectRaw('order_line_id, SUM(quantity) as total') + ->groupBy('order_line_id') + ->pluck('total', 'order_line_id') + ->map(fn ($total): int => (int) $total); + + $quantities = []; + + foreach ($this->order->lines as $line) { + $quantities[$line->id] = max(0, $line->quantity - (int) ($fulfilled[$line->id] ?? 0)); + } + + return $quantities; + } + + /** + * Chronological list of order events for the timeline (spec 03 §8). + * + * @return list + */ + private function timeline(): array + { + $events = [ + ['title' => 'Order placed', 'time' => $this->order->placed_at], + ]; + + if (in_array($this->order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded, FinancialStatus::Refunded], true)) { + $events[] = [ + 'title' => 'Payment received', + 'time' => $this->order->payments->firstWhere('status', PaymentStatus::Captured)?->created_at ?? $this->order->updated_at, + ]; + } + + foreach ($this->order->fulfillments as $fulfillment) { + $events[] = ['title' => 'Fulfillment created', 'time' => $fulfillment->created_at]; + + if ($fulfillment->shipped_at !== null) { + $events[] = ['title' => 'Fulfillment shipped', 'time' => $fulfillment->shipped_at]; + } + } + + foreach ($this->order->refunds as $refund) { + $events[] = ['title' => 'Refund issued ('.Money::format($refund->amount, $this->order->currency).')', 'time' => $refund->created_at]; + } + + if ($this->order->status === OrderStatus::Cancelled) { + $events[] = ['title' => 'Order cancelled', 'time' => $this->order->updated_at]; + } + + usort($events, fn (array $a, array $b): int => ($a['time'] ?? $this->order->placed_at) <=> ($b['time'] ?? $this->order->placed_at)); + + return $events; + } + + /** + * Find a fulfillment belonging to this order. + */ + private function findFulfillment(?int $fulfillmentId): Fulfillment + { + return $this->order->fulfillments()->findOrFail($fulfillmentId); + } + + /** + * @return array + */ + private function trackingRules(): array + { + return [ + 'trackingCompany' => ['nullable', 'string', 'max:255'], + 'trackingNumber' => ['nullable', 'string', 'max:255'], + 'trackingUrl' => ['nullable', 'url', 'max:255'], + ]; + } + + /** + * Tracking payload for the fulfillment service: empty strings become + * null. + * + * @return array{tracking_company: string|null, tracking_number: string|null, tracking_url: string|null} + */ + private function trackingPayload(): array + { + return [ + 'tracking_company' => trim($this->trackingCompany) !== '' ? trim($this->trackingCompany) : null, + 'tracking_number' => trim($this->trackingNumber) !== '' ? trim($this->trackingNumber) : null, + 'tracking_url' => trim($this->trackingUrl) !== '' ? trim($this->trackingUrl) : null, + ]; + } + + private function resetFulfillmentForm(): void + { + $this->fulfillmentLines = []; + $this->trackingCompany = ''; + $this->trackingNumber = ''; + $this->trackingUrl = ''; + } +} diff --git a/app/Livewire/Admin/Pages/Form.php b/app/Livewire/Admin/Pages/Form.php new file mode 100644 index 00000000..7557e9f8 --- /dev/null +++ b/app/Livewire/Admin/Pages/Form.php @@ -0,0 +1,155 @@ +exists) { + $this->authorize('update', $page); + + $this->page = $page; + $this->title = $page->title; + $this->handle = $page->handle; + $this->bodyHtml = (string) ($page->body_html ?? ''); + $this->status = $page->status->value; + $this->publishedAt = $page->published_at?->format('Y-m-d\TH:i'); + } else { + $this->authorize('create', Page::class); + } + } + + /** + * Auto-generate the URL handle from the title while the user has not + * edited it manually (spec 03 §13.2). + */ + public function updatedTitle(string $value): void + { + if (! $this->isEditing() && ! $this->handleManuallyEdited) { + $this->handle = Str::slug($value); + } + } + + public function updatedHandle(): void + { + $this->handleManuallyEdited = true; + } + + /** + * Validate and save the page (spec 03 §13.2). Body HTML is sanitized by + * the model; publishing sets published_at when not already set. + */ + public function save(): void + { + if ($this->publishedAt === '') { + $this->publishedAt = null; + } + + $validated = $this->validate($this->rules()); + + /** @var Store $store */ + $store = app('current_store'); + + $status = PageStatus::from($validated['status']); + $publishedAt = $this->resolvePublishedAt($status, $validated['publishedAt'] ?? null); + + $data = [ + 'title' => $validated['title'], + 'handle' => $validated['handle'], + 'body_html' => $validated['bodyHtml'] ?? null, + 'status' => $status, + 'published_at' => $publishedAt, + ]; + + if ($this->isEditing()) { + $this->authorize('update', $this->page); + + $this->page->update($data); + + $this->dispatch('toast', type: 'success', message: 'Page saved'); + } else { + $this->authorize('create', Page::class); + + $page = Page::create(array_merge($data, ['store_id' => $store->id])); + + session()->flash('toast', ['type' => 'success', 'message' => 'Page saved']); + + $this->redirect(route('admin.pages.edit', $page)); + } + } + + public function render(): View + { + return view('livewire.admin.pages.form') + ->layout('admin.layouts.app') + ->title($this->isEditing() ? $this->page->title : 'Create page'); + } + + /** + * Whether the form is editing an existing page. + */ + public function isEditing(): bool + { + return $this->page !== null && $this->page->exists; + } + + /** + * Validation rules (spec 03 §13.2). + * + * @return array + */ + protected function rules(): array + { + /** @var Store $store */ + $store = app('current_store'); + + return [ + 'title' => ['required', 'string', 'max:255'], + 'handle' => [ + 'required', 'string', 'max:255', + Rule::unique('pages', 'handle') + ->where('store_id', $store->id) + ->ignore($this->page?->id), + ], + 'bodyHtml' => ['nullable', 'string', 'max:65535'], + 'status' => ['required', Rule::in(['draft', 'published', 'archived'])], + 'publishedAt' => ['nullable', 'date'], + ]; + } + + /** + * Publishing auto-sets published_at when none is set; unpublishing or + * archiving keeps the stored timestamp for history. + */ + private function resolvePublishedAt(PageStatus $status, ?string $input): ?string + { + if ($status === PageStatus::Published) { + return $input ?? $this->page?->published_at?->format('Y-m-d H:i:s') ?? now()->format('Y-m-d H:i:s'); + } + + return $input; + } +} diff --git a/app/Livewire/Admin/Pages/Index.php b/app/Livewire/Admin/Pages/Index.php new file mode 100644 index 00000000..4a331d31 --- /dev/null +++ b/app/Livewire/Admin/Pages/Index.php @@ -0,0 +1,77 @@ +authorize('viewAny', Page::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + /** + * Open the delete confirmation modal for a page. + */ + public function confirmDelete(int $id): void + { + $this->deletingId = $id; + $this->confirmingDelete = true; + } + + /** + * Delete the page (spec 03 §13.1). + */ + public function delete(): void + { + $this->confirmingDelete = false; + + $page = Page::query()->find($this->deletingId); + $this->deletingId = null; + + if ($page === null) { + return; + } + + $this->authorize('delete', $page); + + $page->delete(); + + $this->dispatch('toast', type: 'success', message: 'Page deleted'); + } + + public function render(): View + { + $pages = Page::query() + ->when($this->search !== '', function (Builder $query): void { + $term = '%'.addcslashes($this->search, '\\%_').'%'; + + $query->where('title', 'like', $term); + }) + ->orderByDesc('updated_at') + ->paginate(15); + + return view('livewire.admin.pages.index', [ + 'pages' => $pages, + 'hasPages' => Page::query()->exists(), + ])->layout('admin.layouts.app')->title('Pages'); + } +} diff --git a/app/Livewire/Admin/Products/Form.php b/app/Livewire/Admin/Products/Form.php new file mode 100644 index 00000000..f9cb84bb --- /dev/null +++ b/app/Livewire/Admin/Products/Form.php @@ -0,0 +1,720 @@ + */ + public array $collectionIds = []; + + /** @var list */ + public array $options = []; + + /** @var list> */ + public array $variants = []; + + /** @var list */ + public array $media = []; + + /** @var array */ + public array $newMedia = []; + + public bool $confirmingDelete = false; + + public bool $handleManuallyEdited = false; + + public function mount(?Product $product = null): void + { + if ($product !== null && $product->exists) { + $this->authorize('update', $product); + + $this->product = $product; + $this->loadFromProduct($product); + } else { + $this->authorize('create', Product::class); + + $this->variants = [$this->blankVariant()]; + } + } + + /** + * Auto-generate the URL handle from the title while the user has not + * edited it manually (spec 03 §4). + */ + public function updatedTitle(string $value): void + { + if (! $this->isEditing() && ! $this->handleManuallyEdited) { + $this->handle = Str::slug($value); + } + } + + public function updatedHandle(): void + { + $this->handleManuallyEdited = true; + } + + public function updatedNewMedia(): void + { + $this->validateOnly('newMedia.*'); + } + + /** + * Add an option row (maximum of three, spec 03 §4). + */ + public function addOption(): void + { + if (count($this->options) >= 3) { + return; + } + + $this->options[] = ['name' => '', 'values' => '']; + } + + /** + * Remove an option and regenerate the variant matrix. + */ + public function removeOption(int $index): void + { + unset($this->options[$index]); + $this->options = array_values($this->options); + + $this->generateVariants(); + } + + /** + * Add a value to an option (appended to the comma-separated list). + */ + public function addOptionValue(int $optionIndex): void + { + // Values are edited as a comma-separated list; regeneration happens + // on change. This action exists for parity with the spec and simply + // triggers a rebuild. + $this->generateVariants(); + } + + /** + * Remove a value from an option's comma-separated list. + */ + public function removeOptionValue(int $optionIndex, int $valueIndex): void + { + $values = array_map('trim', explode(',', $this->options[$optionIndex]['values'] ?? '')); + unset($values[$valueIndex]); + + $this->options[$optionIndex]['values'] = implode(', ', array_values($values)); + + $this->generateVariants(); + } + + /** + * Generate the variant matrix from the current options, preserving any + * per-variant data already entered (spec 03 §4). + */ + public function generateVariants(): void + { + $parsed = $this->parsedOptions(); + + if ($parsed === []) { + $default = collect($this->variants)->firstWhere('key', 'default') ?? $this->blankVariant(); + $this->variants = [$default]; + + return; + } + + $previous = collect($this->variants)->keyBy('key'); + $defaultData = $previous->get('default'); + + $combinations = $this->cartesian(array_column($parsed, 'values')); + + $this->variants = array_map(function (array $combination) use ($previous, $defaultData): array { + $key = $this->variantKey($combination); + + $row = $previous->get($key) ?? array_merge($this->blankVariant(), $defaultData !== null ? [ + 'sku' => $defaultData['sku'], + 'price' => $defaultData['price'], + 'compareAtPrice' => $defaultData['compareAtPrice'], + 'barcode' => $defaultData['barcode'], + 'weight_g' => $defaultData['weight_g'], + 'quantity' => $defaultData['quantity'], + 'policy' => $defaultData['policy'], + 'requiresShipping' => $defaultData['requiresShipping'], + ] : []); + + $row['key'] = $key; + $row['label'] = implode(' / ', $combination); + $row['optionValues'] = $combination; + + return $row; + }, $combinations); + } + + /** + * Remove an uploaded-but-unsaved file from the pending list. + */ + public function removeNewMedia(int $index): void + { + unset($this->newMedia[$index]); + $this->newMedia = array_values($this->newMedia); + } + + /** + * Delete an existing media item (file cleanup happens on the model). + */ + public function removeMedia(int $mediaId): void + { + abort_if($this->product === null, 404); + $this->authorize('update', $this->product); + + $this->product->media()->whereKey($mediaId)->firstOrFail()->delete(); + + $this->media = array_values(array_filter( + $this->media, + fn (array $media): bool => $media['id'] !== $mediaId, + )); + } + + /** + * Move a media item up or down in the grid and persist positions. + */ + public function moveMedia(int $mediaId, string $direction): void + { + $index = array_search($mediaId, array_column($this->media, 'id'), true); + + if ($index === false) { + return; + } + + $swap = $direction === 'up' ? $index - 1 : $index + 1; + + if (! isset($this->media[$swap])) { + return; + } + + [$this->media[$index], $this->media[$swap]] = [$this->media[$swap], $this->media[$index]]; + + $this->persistMediaOrder(); + } + + /** + * Persist an explicit ordering of media ids (spec 03 §4 reorderMedia). + * + * @param list $order + */ + public function reorderMedia(array $order): void + { + abort_if($this->product === null, 404); + $this->authorize('update', $this->product); + + foreach (array_values($order) as $position => $mediaId) { + $this->product->media()->whereKey($mediaId)->update(['position' => $position]); + } + + $this->media = $this->mediaFromProduct($this->product->refresh()); + } + + /** + * Update the alt text of a media item. + */ + public function updateMediaAlt(int $mediaId, string $alt): void + { + abort_if($this->product === null, 404); + $this->authorize('update', $this->product); + + $this->product->media()->whereKey($mediaId)->update(['alt_text' => $alt]); + + foreach ($this->media as $index => $media) { + if ($media['id'] === $mediaId) { + $this->media[$index]['alt_text'] = $alt; + } + } + } + + /** + * Archive the product from the edit page (spec 03 §4 delete modal). + */ + public function deleteProduct(ProductService $products): void + { + abort_if($this->product === null, 404); + $this->authorize('archive', $this->product); + + $this->confirmingDelete = false; + + if ($this->product->status !== ProductStatus::Archived) { + $products->transitionStatus($this->product, ProductStatus::Archived); + } + + session()->flash('toast', ['type' => 'success', 'message' => 'Product archived']); + + $this->redirect(route('admin.products.index')); + } + + /** + * Validate and save the product graph (spec 03 §4). + */ + public function save(ProductService $products): void + { + $this->normalizeNullableInputs(); + + $validated = $this->validate($this->rules()); + + /** @var Store $store */ + $store = app('current_store'); + + $this->assertSkusAreUnique($store); + + $parsedOptions = $this->parsedOptions(); + + $data = [ + 'title' => $validated['title'], + 'description_html' => $validated['descriptionHtml'] ?? null, + 'vendor' => $validated['vendor'] ?? null, + 'product_type' => $validated['productType'] ?? null, + 'tags' => array_values(array_filter(array_map('trim', explode(',', $this->tags)))), + 'published_at' => $validated['publishedAt'] ?? null, + 'handle' => $validated['handle'], + 'variants' => $this->variantPayload(), + ]; + + // Only pass options when the product has (or should have) any. For a + // product without options the service applies the single default + // variant from the "variants" payload instead. + if ($parsedOptions !== [] || ($this->isEditing() && $this->product->options()->exists())) { + $data['options'] = $parsedOptions; + } + + if ($this->isEditing()) { + $this->authorize('update', $this->product); + + $product = $products->update($this->product, $data); + + $this->applyStatus($products, $product); + } else { + $this->authorize('create', Product::class); + + $product = $products->create($store, array_merge($data, [ + 'status' => ProductStatus::from($validated['status']), + ])); + } + + $product->collections()->sync( + collect($this->collectionIds)->mapWithKeys(fn ($id, $index): array => [(int) $id => ['position' => $index]])->all(), + ); + + $this->storeUploadedMedia($product); + + if ($this->isEditing()) { + $this->persistMediaAltTexts($product); + + $this->product = $product->refresh(); + $this->loadFromProduct($this->product); + $this->newMedia = []; + + $this->dispatch('toast', type: 'success', message: 'Product saved'); + } else { + session()->flash('toast', ['type' => 'success', 'message' => 'Product saved']); + + $this->redirect(route('admin.products.edit', $product)); + } + } + + public function render(): View + { + return view('livewire.admin.products.form', [ + 'availableCollections' => Collection::query()->orderBy('title')->get(), + ])->layout('admin.layouts.app')->title($this->isEditing() ? $this->title : 'Add product'); + } + + /** + * Whether the form is editing an existing product. + */ + public function isEditing(): bool + { + return $this->product !== null && $this->product->exists; + } + + /** + * Validation rules (spec 03 §4). + * + * @return array + */ + protected function rules(): array + { + /** @var Store $store */ + $store = app('current_store'); + + return [ + 'title' => ['required', 'string', 'max:255'], + 'descriptionHtml' => ['nullable', 'string', 'max:65535'], + 'status' => ['required', Rule::in(['draft', 'active', 'archived'])], + 'vendor' => ['nullable', 'string', 'max:255'], + 'productType' => ['nullable', 'string', 'max:255'], + 'tags' => ['nullable', 'string'], + 'handle' => [ + 'required', 'string', 'max:255', + Rule::unique('products', 'handle') + ->where('store_id', $store->id) + ->ignore($this->product?->id), + ], + 'publishedAt' => ['nullable', 'date'], + 'options' => ['array', 'max:3'], + 'options.*.name' => ['nullable', 'string', 'max:255'], + 'options.*.values' => ['nullable', 'string'], + 'variants' => ['array', 'min:1'], + 'variants.*.sku' => ['nullable', 'string', 'max:255'], + 'variants.*.price' => ['required', 'integer', 'min:0'], + 'variants.*.compareAtPrice' => ['nullable', 'integer', 'min:0'], + 'variants.*.barcode' => ['nullable', 'string', 'max:255'], + 'variants.*.weight_g' => ['nullable', 'integer', 'min:0'], + 'variants.*.quantity' => ['required', 'integer', 'min:0'], + 'variants.*.requiresShipping' => ['boolean'], + 'variants.*.policy' => ['required', Rule::in(['deny', 'continue'])], + 'collectionIds' => ['array'], + 'collectionIds.*' => ['integer', Rule::exists('collections', 'id')->where('store_id', $store->id)], + 'newMedia' => ['array'], + 'newMedia.*' => ['image', 'max:5120'], + ]; + } + + /** + * Convert empty-string optional numerics to null so "nullable|integer" + * validates correctly from Livewire inputs. + */ + private function normalizeNullableInputs(): void + { + $this->publishedAt = $this->publishedAt === '' ? null : $this->publishedAt; + + foreach ($this->variants as $index => $variant) { + foreach (['compareAtPrice', 'weight_g'] as $field) { + if (($variant[$field] ?? null) === '') { + $this->variants[$index][$field] = null; + } + } + } + } + + /** + * Parse the option rows into service payload shape, skipping incomplete + * rows (no name or no values). + * + * @return list}> + */ + private function parsedOptions(): array + { + $parsed = []; + + foreach ($this->options as $option) { + $name = trim((string) ($option['name'] ?? '')); + $values = array_values(array_unique(array_filter( + array_map('trim', explode(',', (string) ($option['values'] ?? ''))), + fn (string $value): bool => $value !== '', + ))); + + if ($name === '' || $values === []) { + continue; + } + + $parsed[] = ['name' => $name, 'values' => $values]; + } + + return $parsed; + } + + /** + * Build the variants payload for the product service. + * + * @return list> + */ + private function variantPayload(): array + { + $hasOptions = $this->parsedOptions() !== []; + + return array_map(function (array $variant) use ($hasOptions): array { + $payload = [ + 'id' => $variant['id'] ?? null, + 'sku' => trim((string) ($variant['sku'] ?? '')) !== '' ? trim((string) $variant['sku']) : null, + 'price_amount' => (int) $variant['price'], + 'compare_at_amount' => $variant['compareAtPrice'] !== null && $variant['compareAtPrice'] !== '' ? (int) $variant['compareAtPrice'] : null, + 'barcode' => trim((string) ($variant['barcode'] ?? '')) !== '' ? trim((string) $variant['barcode']) : null, + 'weight_g' => $variant['weight_g'] !== null && $variant['weight_g'] !== '' ? (int) $variant['weight_g'] : null, + 'requires_shipping' => (bool) ($variant['requiresShipping'] ?? true), + 'inventory' => [ + 'quantity_on_hand' => (int) $variant['quantity'], + 'policy' => $variant['policy'] ?? InventoryPolicy::Deny->value, + ], + ]; + + if ($hasOptions) { + $payload['option_values'] = $variant['optionValues']; + } + + return $payload; + }, $this->variants); + } + + /** + * Apply the requested status on edit via the state machine, surfacing + * blocked transitions as an error toast (spec 03 §4). + */ + private function applyStatus(ProductService $products, Product $product): void + { + $newStatus = ProductStatus::from($this->status); + + if ($product->status === $newStatus) { + return; + } + + try { + $products->transitionStatus($product, $newStatus); + } catch (InvalidProductTransitionException $exception) { + $this->status = $product->status->value; + + $this->dispatch('toast', type: 'error', message: $exception->getMessage()); + } + } + + /** + * Ensure entered SKUs are unique within the form and across the store + * (spec 03 §4: SKU uniqueness errors surfaced). + */ + private function assertSkusAreUnique(Store $store): void + { + $seen = []; + + foreach ($this->variants as $index => $variant) { + $sku = trim((string) ($variant['sku'] ?? '')); + + if ($sku === '') { + continue; + } + + if (in_array($sku, $seen, true)) { + throw ValidationException::withMessages([ + "variants.{$index}.sku" => ["The SKU '{$sku}' is entered more than once."], + ]); + } + + $seen[] = $sku; + + $query = DB::table('product_variants') + ->join('products', 'products.id', '=', 'product_variants.product_id') + ->where('products.store_id', $store->id) + ->where('product_variants.sku', $sku); + + if ($this->isEditing()) { + $query->where('products.id', '!=', $this->product->id); + } + + if ($query->exists()) { + throw ValidationException::withMessages([ + "variants.{$index}.sku" => ["The SKU '{$sku}' is already used by another variant in this store."], + ]); + } + } + } + + /** + * Store pending uploads on the public disk and queue processing + * (spec 03 §4 media section). + */ + private function storeUploadedMedia(Product $product): void + { + $position = (int) $product->media()->max('position'); + + foreach ($this->newMedia as $file) { + $path = $file->store("media/{$product->id}/originals", 'public'); + + $media = $product->media()->create([ + 'type' => MediaType::Image, + 'storage_key' => $path, + 'alt_text' => '', + 'position' => ++$position, + 'status' => MediaStatus::Processing, + ]); + + ProcessMediaUpload::dispatch($media); + } + } + + /** + * Persist edited alt texts for existing media items. + */ + private function persistMediaAltTexts(Product $product): void + { + foreach ($this->media as $media) { + $product->media()->whereKey($media['id'])->update(['alt_text' => $media['alt_text'] ?? '']); + } + } + + /** + * Persist current grid order to the database. + */ + private function persistMediaOrder(): void + { + if ($this->product === null) { + return; + } + + foreach ($this->media as $position => $media) { + $this->product->media()->whereKey($media['id'])->update(['position' => $position]); + } + } + + /** + * Load the product's data into the form properties (edit mode). + */ + private function loadFromProduct(Product $product): void + { + $product->loadMissing(['options.values', 'variants.optionValues', 'variants.inventoryItem', 'media', 'collections']); + + $this->title = $product->title; + $this->handle = $product->handle; + $this->descriptionHtml = (string) ($product->description_html ?? ''); + $this->status = $product->status->value; + $this->vendor = (string) ($product->vendor ?? ''); + $this->productType = (string) ($product->product_type ?? ''); + $this->tags = implode(', ', $product->tags ?? []); + $this->publishedAt = $product->published_at?->format('Y-m-d\TH:i'); + $this->collectionIds = $product->collections->pluck('id')->all(); + + $this->options = $product->options->map(fn ($option): array => [ + 'name' => $option->name, + 'values' => $option->values->pluck('value')->implode(', '), + ])->values()->all(); + + $this->variants = $product->variants->map(function ($variant): array { + $optionValues = $variant->optionValues->pluck('value')->values()->all(); + + return [ + 'key' => $optionValues === [] ? 'default' : $this->variantKey($optionValues), + 'label' => $optionValues === [] ? 'Default' : implode(' / ', $optionValues), + 'id' => $variant->id, + 'optionValues' => $optionValues, + 'sku' => (string) ($variant->sku ?? ''), + 'price' => $variant->price_amount, + 'compareAtPrice' => $variant->compare_at_amount, + 'barcode' => (string) ($variant->barcode ?? ''), + 'weight_g' => $variant->weight_g, + 'quantity' => $variant->inventoryItem?->quantity_on_hand ?? 0, + 'policy' => $variant->inventoryItem?->policy->value ?? InventoryPolicy::Deny->value, + 'requiresShipping' => $variant->requires_shipping, + ]; + })->values()->all(); + + if ($this->variants === []) { + $this->variants = [$this->blankVariant()]; + } + + $this->media = $this->mediaFromProduct($product); + } + + /** + * Media list items for the grid. + * + * @return list + */ + private function mediaFromProduct(Product $product): array + { + return $product->media->map(fn (ProductMedia $media): array => [ + 'id' => $media->id, + 'url' => $media->status === MediaStatus::Ready ? $media->urlFor('thumbnail') : $media->url(), + 'alt_text' => (string) ($media->alt_text ?? ''), + 'position' => $media->position, + ])->values()->all(); + } + + /** + * A blank variant row with sensible defaults. + * + * @return array + */ + private function blankVariant(): array + { + return [ + 'key' => 'default', + 'label' => 'Default', + 'id' => null, + 'optionValues' => [], + 'sku' => '', + 'price' => 0, + 'compareAtPrice' => null, + 'barcode' => '', + 'weight_g' => null, + 'quantity' => 0, + 'policy' => InventoryPolicy::Deny->value, + 'requiresShipping' => true, + ]; + } + + /** + * Stable key for a combination of option values. + * + * @param list $values + */ + private function variantKey(array $values): string + { + return mb_strtolower(implode('/', $values)); + } + + /** + * Cartesian product of option value sets. + * + * @param list> $sets + * @return list> + */ + private function cartesian(array $sets): array + { + $result = [[]]; + + foreach ($sets as $set) { + $next = []; + + foreach ($result as $combination) { + foreach ($set as $value) { + $next[] = array_merge($combination, [$value]); + } + } + + $result = $next; + } + + return $result; + } +} diff --git a/app/Livewire/Admin/Products/Index.php b/app/Livewire/Admin/Products/Index.php new file mode 100644 index 00000000..42fe6224 --- /dev/null +++ b/app/Livewire/Admin/Products/Index.php @@ -0,0 +1,287 @@ + */ + public array $selectedIds = []; + + public string $sortField = 'updated_at'; + + public string $sortDirection = 'desc'; + + public bool $confirmingBulkDelete = false; + + public function mount(): void + { + $this->authorize('viewAny', Product::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + $this->clearSelection(); + } + + public function updatedTypeFilter(): void + { + $this->resetPage(); + $this->clearSelection(); + } + + /** + * Toggle the sort column or flip the direction (spec 03 §3). + */ + public function sortBy(string $field): void + { + if (! in_array($field, ['title', 'inventory', 'updated_at'], true)) { + return; + } + + if ($this->sortField === $field) { + $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortField = $field; + $this->sortDirection = 'asc'; + } + + $this->resetPage(); + } + + /** + * Select or deselect every product visible on the current page. + */ + public function toggleSelectAll(): void + { + $visibleIds = $this->productsQuery()->paginate($this->perPage())->pluck('id')->all(); + + if ($this->allVisibleSelected($visibleIds)) { + $this->selectedIds = array_values(array_diff($this->selectedIds, $visibleIds)); + } else { + $this->selectedIds = array_values(array_unique(array_merge($this->selectedIds, $visibleIds))); + } + } + + /** + * Archive every selected product the user may archive (spec 03 §3). + */ + public function bulkArchive(ProductService $products): void + { + $archived = 0; + + foreach ($this->selectedProducts() as $product) { + if (! Gate::allows('archive', $product) || $product->status === ProductStatus::Archived) { + continue; + } + + $products->transitionStatus($product, ProductStatus::Archived); + $archived++; + } + + $this->clearSelection(); + + $archived > 0 + ? $this->dispatch('toast', type: 'success', message: trans_choice(':count product archived.|:count products archived.', $archived)) + : $this->dispatch('toast', type: 'error', message: 'No selected products could be archived.'); + } + + /** + * Set every selected product the user may update to active. + */ + public function bulkSetActive(ProductService $products): void + { + $activated = 0; + $skipped = 0; + + foreach ($this->selectedProducts() as $product) { + if (! Gate::allows('update', $product) || $product->status === ProductStatus::Active) { + continue; + } + + try { + $products->transitionStatus($product, ProductStatus::Active); + $activated++; + } catch (InvalidProductTransitionException) { + $skipped++; + } + } + + $this->clearSelection(); + + if ($activated > 0) { + $this->dispatch('toast', type: 'success', message: trans_choice(':count product activated.|:count products activated.', $activated)); + } + + if ($skipped > 0) { + $this->dispatch('toast', type: 'error', message: trans_choice(':count product could not be activated.|:count products could not be activated.', $skipped)); + } + + if ($activated === 0 && $skipped === 0) { + $this->dispatch('toast', type: 'error', message: 'No selected products could be activated.'); + } + } + + /** + * Open the bulk delete confirmation modal. + */ + public function confirmBulkDelete(): void + { + $this->confirmingBulkDelete = true; + } + + /** + * Hard-delete selected drafts; products with orders or non-draft status + * are refused by the service and reported (spec 03 §3 modal). + */ + public function bulkDelete(ProductService $products): void + { + $this->confirmingBulkDelete = false; + + $deleted = 0; + $skipped = 0; + + foreach ($this->selectedProducts() as $product) { + if (! Gate::allows('delete', $product)) { + $skipped++; + + continue; + } + + try { + $products->delete($product); + $deleted++; + } catch (InvalidProductTransitionException) { + $skipped++; + } + } + + $this->clearSelection(); + + if ($deleted > 0) { + $this->dispatch('toast', type: 'success', message: trans_choice(':count product deleted.|:count products deleted.', $deleted)); + } + + if ($skipped > 0) { + $this->dispatch('toast', type: 'error', message: trans_choice(':count product could not be deleted. Only drafts without orders can be deleted.|:count products could not be deleted. Only drafts without orders can be deleted.', $skipped)); + } + } + + public function render(): View + { + $products = $this->productsQuery() + ->with(['variants.inventoryItem', 'media']) + ->withCount('variants') + ->paginate($this->perPage()); + + return view('livewire.admin.products.index', [ + 'products' => $products, + 'productTypes' => $this->productTypes(), + 'hasProducts' => Product::query()->exists(), + ])->layout('admin.layouts.app')->title('Products'); + } + + /** + * Base query with search, filters, and sorting applied (spec 03 §3). + * + * @return Builder + */ + private function productsQuery(): Builder + { + return Product::query() + ->when($this->search !== '', function (Builder $query): void { + $term = '%'.addcslashes($this->search, '\\%_').'%'; + + $query->where(function (Builder $query) use ($term): void { + $query->where('title', 'like', $term) + ->orWhere('vendor', 'like', $term) + ->orWhereHas('variants', fn (Builder $variants) => $variants->where('sku', 'like', $term)); + }); + }) + ->when($this->statusFilter !== 'all', fn (Builder $query) => $query->where('status', $this->statusFilter)) + ->when($this->typeFilter !== 'all', fn (Builder $query) => $query->where('product_type', $this->typeFilter)) + ->when( + $this->sortField === 'inventory', + fn (Builder $query) => $query->orderBy($this->inventorySubquery(), $this->sortDirection), + fn (Builder $query) => $query->orderBy($this->sortField, $this->sortDirection), + ); + } + + /** + * Sum of on-hand inventory across all variants of a product. + */ + private function inventorySubquery(): Builder + { + return InventoryItem::query() + ->selectRaw('COALESCE(SUM(inventory_items.quantity_on_hand), 0)') + ->join('product_variants', 'inventory_items.variant_id', '=', 'product_variants.id') + ->whereColumn('product_variants.product_id', 'products.id'); + } + + /** + * Distinct product types for the type filter. + * + * @return \Illuminate\Support\Collection + */ + private function productTypes(): \Illuminate\Support\Collection + { + return Product::query() + ->whereNotNull('product_type') + ->where('product_type', '!=', '') + ->distinct() + ->orderBy('product_type') + ->pluck('product_type'); + } + + /** + * Selected products, re-queried so stale ids are ignored. + * + * @return \Illuminate\Database\Eloquent\Collection + */ + private function selectedProducts(): \Illuminate\Database\Eloquent\Collection + { + return Product::query()->whereIn('id', $this->selectedIds)->get(); + } + + /** + * @param list $visibleIds + */ + private function allVisibleSelected(array $visibleIds): bool + { + return $visibleIds !== [] && array_diff($visibleIds, array_map('intval', $this->selectedIds)) === []; + } + + private function clearSelection(): void + { + $this->selectedIds = []; + } + + private function perPage(): int + { + return 15; + } +} diff --git a/app/Livewire/Admin/Search/Settings.php b/app/Livewire/Admin/Search/Settings.php new file mode 100644 index 00000000..36f1f957 --- /dev/null +++ b/app/Livewire/Admin/Search/Settings.php @@ -0,0 +1,138 @@ + Synonym groups, each a comma-separated string */ + public array $synonymGroups = []; + + public string $stopWords = ''; + + public ?string $lastIndexedAt = null; + + public bool $isReindexing = false; + + public function mount(): void + { + Gate::authorize('manage-search-settings'); + + /** @var Store $store */ + $store = app('current_store'); + + $settings = SearchSettings::query()->find($store->id); + + $this->synonymGroups = collect($settings?->synonyms_json ?? []) + ->map(fn ($group): string => is_array($group) ? implode(', ', $group) : (string) $group) + ->values() + ->all(); + $this->stopWords = implode(', ', $settings?->stop_words_json ?? []); + $this->lastIndexedAt = $settings?->updated_at?->toDayDateTimeString(); + } + + /** + * Append a new empty synonym group row. + */ + public function addSynonymGroup(): void + { + $this->synonymGroups[] = ''; + } + + /** + * Drop a synonym group row. + */ + public function removeSynonymGroup(int $index): void + { + unset($this->synonymGroups[$index]); + + $this->synonymGroups = array_values($this->synonymGroups); + } + + /** + * Persist synonym and stop word settings. + */ + public function save(): void + { + Gate::authorize('manage-search-settings'); + + /** @var Store $store */ + $store = app('current_store'); + + $synonyms = collect($this->synonymGroups) + ->map(fn (string $group): array => $this->splitTerms($group)) + ->filter(fn (array $group): bool => count($group) > 1) + ->values() + ->all(); + + $stopWords = $this->splitTerms($this->stopWords); + + SearchSettings::query()->updateOrCreate( + ['store_id' => $store->id], + ['synonyms_json' => $synonyms, 'stop_words_json' => $stopWords], + ); + + $this->synonymGroups = collect($synonyms)->map(fn (array $group): string => implode(', ', $group))->all(); + $this->stopWords = implode(', ', $stopWords); + + $this->dispatch('toast', type: 'success', message: 'Search settings saved'); + } + + /** + * Rebuild the store's FTS index synchronously (SQLite is fast enough + * that a queued job adds no value here). + */ + public function triggerReindex(SearchService $search): void + { + Gate::authorize('manage-search-settings'); + + /** @var Store $store */ + $store = app('current_store'); + + $this->isReindexing = true; + + $count = $search->reindex($store); + + $settings = SearchSettings::query()->firstOrNew(['store_id' => $store->id]); + $settings->save(); + $settings->touch(); + + $this->lastIndexedAt = now()->toDayDateTimeString(); + $this->isReindexing = false; + + $this->dispatch('toast', type: 'success', message: "Search index rebuilt ({$count} products)"); + } + + public function render(): View + { + return view('livewire.admin.search.settings', [ + 'recentQueries' => \App\Models\SearchQuery::query() + ->orderByDesc('created_at') + ->orderByDesc('id') + ->limit(20) + ->get(), + ])->layout('admin.layouts.app')->title('Search Settings'); + } + + /** + * Split a comma-separated string into trimmed, non-empty terms. + * + * @return list + */ + private function splitTerms(string $value): array + { + return array_values(array_filter( + array_map(fn (string $term): string => trim($term), explode(',', $value)), + fn (string $term): bool => $term !== '', + )); + } +} diff --git a/app/Livewire/Admin/Settings/Index.php b/app/Livewire/Admin/Settings/Index.php new file mode 100644 index 00000000..0c6c6489 --- /dev/null +++ b/app/Livewire/Admin/Settings/Index.php @@ -0,0 +1,278 @@ +settings?->settings_json ?? []; + + $this->storeName = $store->name; + $this->defaultCurrency = $store->default_currency; + $this->defaultLocale = $store->default_locale; + $this->timezone = $store->timezone; + + $this->contactEmail = (string) ($settings['contact_email'] ?? ''); + $this->orderNumberPrefix = (string) ($settings['order_number_prefix'] ?? '#'); + $this->orderNumberStart = (int) ($settings['order_number_start'] ?? 1001); + $this->bankTransferCancelDays = (int) ($settings['bank_transfer_cancel_days'] ?? 7); + $this->cartAbandonDays = (int) ($settings['cart_abandon_days'] ?? 14); + $this->orderConfirmationEmail = (bool) ($settings['notifications']['order_confirmation_email'] ?? true); + $this->shippingEmail = (bool) ($settings['notifications']['shipping_email'] ?? true); + $this->marketingEmail = (bool) ($settings['notifications']['marketing'] ?? false); + } + + /** + * Switch the visible settings tab (spec 03 §11.2). + */ + public function setTab(string $tab): void + { + if (in_array($tab, ['general', 'domains', 'shipping', 'taxes', 'checkout', 'notifications'], true)) { + $this->tab = $tab; + } + } + + /** + * Save the general tab: store columns plus contact email in + * store_settings.settings_json (spec 03 §11.1). + */ + public function saveGeneral(): void + { + Gate::authorize('manage-store-settings'); + + $validated = $this->validate([ + 'storeName' => ['required', 'string', 'max:255'], + 'contactEmail' => ['nullable', 'email', 'max:255'], + 'defaultCurrency' => ['required', 'string', 'size:3'], + 'defaultLocale' => ['required', 'string', 'max:10'], + 'timezone' => ['required', 'string', Rule::in(\DateTimeZone::listIdentifiers())], + ]); + + /** @var Store $store */ + $store = app('current_store'); + + DB::transaction(function () use ($store, $validated): void { + $store->update([ + 'name' => $validated['storeName'], + 'default_currency' => mb_strtoupper($validated['defaultCurrency']), + 'default_locale' => $validated['defaultLocale'], + 'timezone' => $validated['timezone'], + ]); + + $this->mergeSettings($store, [ + 'contact_email' => $validated['contactEmail'] ?: null, + 'store_name' => $validated['storeName'], + ]); + }); + + $this->dispatch('toast', type: 'success', message: 'Settings saved'); + } + + /** + * Add a domain to the store (spec 03 §11.2). + */ + public function addDomain(): void + { + Gate::authorize('manage-store-settings'); + + $validated = $this->validate([ + 'newHostname' => [ + 'required', 'string', 'max:253', + 'regex:/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/i', + Rule::unique('store_domains', 'hostname'), + ], + 'newType' => ['required', Rule::in(['storefront', 'admin', 'api'])], + ]); + + /** @var Store $store */ + $store = app('current_store'); + + StoreDomain::create([ + 'store_id' => $store->id, + 'hostname' => mb_strtolower($validated['newHostname']), + 'type' => $validated['newType'], + 'is_primary' => ! $store->domains()->exists(), + 'tls_mode' => 'managed', + ]); + + $this->showDomainForm = false; + $this->newHostname = ''; + $this->newType = 'storefront'; + + $this->dispatch('toast', type: 'success', message: 'Domain added'); + } + + /** + * Remove a domain. The primary domain cannot be removed — set another + * primary first (spec 03 §11.2). + */ + public function removeDomain(int $domainId): void + { + Gate::authorize('manage-store-settings'); + + /** @var Store $store */ + $store = app('current_store'); + + $domain = $store->domains()->find($domainId); + + if ($domain === null) { + return; + } + + if ($domain->is_primary) { + $this->dispatch('toast', type: 'error', message: 'Set another domain as primary before removing this one.'); + + return; + } + + $domain->delete(); + + $this->dispatch('toast', type: 'success', message: 'Domain removed'); + } + + /** + * Mark a domain as the store's primary domain (spec 03 §11.2). + */ + public function setPrimary(int $domainId): void + { + Gate::authorize('manage-store-settings'); + + /** @var Store $store */ + $store = app('current_store'); + + $domain = $store->domains()->find($domainId); + + if ($domain === null) { + return; + } + + DB::transaction(function () use ($store, $domain): void { + $store->domains()->where('is_primary', true)->update(['is_primary' => false]); + $domain->update(['is_primary' => true]); + }); + + $this->dispatch('toast', type: 'success', message: 'Primary domain updated'); + } + + /** + * Save the checkout tab into store_settings.settings_json. + */ + public function saveCheckout(): void + { + Gate::authorize('manage-store-settings'); + + $validated = $this->validate([ + 'orderNumberPrefix' => ['required', 'string', 'max:10'], + 'orderNumberStart' => ['required', 'integer', 'min:1'], + 'bankTransferCancelDays' => ['required', 'integer', 'min:1', 'max:90'], + 'cartAbandonDays' => ['required', 'integer', 'min:1', 'max:365'], + ]); + + /** @var Store $store */ + $store = app('current_store'); + + $this->mergeSettings($store, [ + 'order_number_prefix' => $validated['orderNumberPrefix'], + 'order_number_start' => (int) $validated['orderNumberStart'], + 'bank_transfer_cancel_days' => (int) $validated['bankTransferCancelDays'], + 'cart_abandon_days' => (int) $validated['cartAbandonDays'], + ]); + + $this->dispatch('toast', type: 'success', message: 'Settings saved'); + } + + /** + * Save the notification toggles into store_settings.settings_json + * (cosmetic persistence — no mail is wired up yet). + */ + public function saveNotifications(): void + { + Gate::authorize('manage-store-settings'); + + /** @var Store $store */ + $store = app('current_store'); + + $this->mergeSettings($store, [ + 'notifications' => [ + 'order_confirmation_email' => $this->orderConfirmationEmail, + 'shipping_email' => $this->shippingEmail, + 'marketing' => $this->marketingEmail, + ], + ]); + + $this->dispatch('toast', type: 'success', message: 'Settings saved'); + } + + public function render(): View + { + /** @var Store $store */ + $store = app('current_store'); + + return view('livewire.admin.settings.index', [ + 'domains' => $store->domains()->orderByDesc('is_primary')->orderBy('id')->get(), + 'timezones' => \DateTimeZone::listIdentifiers(), + ])->layout('admin.layouts.app')->title('Settings'); + } + + /** + * Merge keys into the store's settings bag, creating it when missing. + * + * @param array $values + */ + private function mergeSettings(Store $store, array $values): void + { + $settings = StoreSettings::firstOrNew(['store_id' => $store->id]); + $settings->settings_json = array_merge($settings->settings_json ?? [], $values); + $settings->save(); + } +} diff --git a/app/Livewire/Admin/Settings/Shipping.php b/app/Livewire/Admin/Settings/Shipping.php new file mode 100644 index 00000000..b2619270 --- /dev/null +++ b/app/Livewire/Admin/Settings/Shipping.php @@ -0,0 +1,402 @@ +> */ + public array $rateRanges = []; + + public function mount(): void + { + Gate::authorize('manage-shipping'); + } + + /** + * Open the zone modal in create or edit mode (spec 03 §11.3). + */ + public function openZoneForm(?int $zoneId = null): void + { + Gate::authorize('manage-shipping'); + + $this->editingZoneId = $zoneId; + + if ($zoneId !== null) { + $zone = ShippingZone::query()->find($zoneId); + + if ($zone === null) { + return; + } + + $this->zoneName = $zone->name; + $this->zoneCountries = implode(', ', $zone->countries_json ?? []); + $this->zoneRegions = implode(', ', $zone->regions_json ?? []); + } else { + $this->zoneName = ''; + $this->zoneCountries = ''; + $this->zoneRegions = ''; + } + + $this->showZoneForm = true; + } + + /** + * Create or update a shipping zone (spec 03 §11.3). + */ + public function saveZone(): void + { + Gate::authorize('manage-shipping'); + + /** @var Store $store */ + $store = app('current_store'); + + $validated = $this->validate([ + 'zoneName' => ['required', 'string', 'max:255'], + 'zoneCountries' => ['required', 'string', 'max:2000'], + 'zoneRegions' => ['nullable', 'string', 'max:2000'], + ]); + + $countries = $this->parseCodeList($validated['zoneCountries']); + + if ($countries === []) { + $this->addError('zoneCountries', 'Enter at least one ISO country code.'); + + return; + } + + $data = [ + 'name' => $validated['zoneName'], + 'countries_json' => $countries, + 'regions_json' => $this->parseCodeList($validated['zoneRegions'] ?? ''), + ]; + + if ($this->editingZoneId !== null) { + $zone = ShippingZone::query()->find($this->editingZoneId); + + if ($zone === null) { + return; + } + + $zone->update($data); + } else { + ShippingZone::create(array_merge($data, ['store_id' => $store->id])); + } + + $this->showZoneForm = false; + $this->editingZoneId = null; + + $this->dispatch('toast', type: 'success', message: 'Shipping zone saved'); + } + + /** + * Delete a zone with its rates (spec 03 §11.3). + */ + public function deleteZone(int $zoneId): void + { + Gate::authorize('manage-shipping'); + + $zone = ShippingZone::query()->find($zoneId); + + if ($zone === null) { + return; + } + + DB::transaction(function () use ($zone): void { + $zone->rates()->delete(); + $zone->delete(); + }); + + $this->dispatch('toast', type: 'success', message: 'Shipping zone deleted'); + } + + /** + * Open the rate modal in create or edit mode (spec 03 §11.3). + */ + public function openRateForm(int $zoneId, ?int $rateId = null): void + { + Gate::authorize('manage-shipping'); + + $this->rateZoneId = $zoneId; + $this->editingRateId = $rateId; + + if ($rateId !== null) { + $rate = ShippingRate::query()->where('zone_id', $zoneId)->find($rateId); + + if ($rate === null) { + return; + } + + $this->rateName = $rate->name; + $this->rateType = $rate->type->value; + $this->rateActive = $rate->is_active; + $this->rateAmount = $rate->config_json['amount'] ?? null; + $this->rateRanges = array_values($rate->config_json['ranges'] ?? []); + } else { + $this->rateName = ''; + $this->rateType = 'flat'; + $this->rateActive = true; + $this->rateAmount = null; + $this->rateRanges = []; + } + + $this->showRateForm = true; + } + + /** + * Add an empty range row for weight/price based rates. + */ + public function addRange(): void + { + $this->rateRanges[] = $this->rateType === 'weight' + ? ['min_g' => null, 'max_g' => null, 'amount' => null] + : ['min_amount' => null, 'max_amount' => null, 'amount' => null]; + } + + /** + * Remove a range row. + */ + public function removeRange(int $index): void + { + unset($this->rateRanges[$index]); + $this->rateRanges = array_values($this->rateRanges); + } + + /** + * Reset type-dependent fields when the rate type changes. + */ + public function updatedRateType(): void + { + $this->rateRanges = []; + $this->rateAmount = null; + } + + /** + * Create or update a shipping rate (spec 03 §11.3). + */ + public function saveRate(): void + { + Gate::authorize('manage-shipping'); + + $this->normalizeRateInputs(); + + $validated = $this->validate($this->rateRules()); + + $zone = ShippingZone::query()->find($this->rateZoneId); + + if ($zone === null) { + return; + } + + $data = [ + 'name' => $validated['rateName'], + 'type' => $validated['rateType'], + 'config_json' => $this->buildRateConfig($validated), + 'is_active' => $this->rateActive, + ]; + + if ($this->editingRateId !== null) { + $rate = ShippingRate::query()->where('zone_id', $zone->id)->find($this->editingRateId); + + if ($rate === null) { + return; + } + + $rate->update($data); + } else { + $zone->rates()->create($data); + } + + $this->showRateForm = false; + $this->editingRateId = null; + + $this->dispatch('toast', type: 'success', message: 'Shipping rate saved'); + } + + /** + * Delete a shipping rate. + */ + public function deleteRate(int $rateId): void + { + Gate::authorize('manage-shipping'); + + $this->rateInCurrentStore($rateId)?->delete(); + + $this->dispatch('toast', type: 'success', message: 'Shipping rate deleted'); + } + + /** + * Toggle a rate's active flag from the rates table. + */ + public function toggleRate(int $rateId): void + { + Gate::authorize('manage-shipping'); + + $rate = $this->rateInCurrentStore($rateId); + + $rate?->update(['is_active' => ! $rate->is_active]); + } + + public function render(): View + { + return view('livewire.admin.settings.shipping', [ + 'zones' => ShippingZone::query()->with('rates')->orderBy('id')->get(), + ])->layout('admin.layouts.app')->title('Shipping'); + } + + /** + * Look up a rate only when its zone belongs to the current store. + */ + private function rateInCurrentStore(int $rateId): ?ShippingRate + { + return ShippingRate::query() + ->whereHas('zone', fn ($query) => $query->where('store_id', app('current_store')->getKey())) + ->find($rateId); + } + + /** + * Human-readable summary of a rate's configuration. + * + * @param array|null $config + */ + public function configSummary(ShippingRateType $type, ?array $config): string + { + $config ??= []; + + return match ($type) { + ShippingRateType::Flat => isset($config['amount']) ? number_format($config['amount'] / 100, 2) : '—', + ShippingRateType::Weight => count($config['ranges'] ?? []).' weight ranges', + ShippingRateType::Price => count($config['ranges'] ?? []).' price ranges', + ShippingRateType::Carrier => 'Carrier-calculated', + }; + } + + /** + * Convert empty-string inputs to null so integer validation passes. + */ + private function normalizeRateInputs(): void + { + if ($this->rateAmount === '' || $this->rateAmount === null) { + $this->rateAmount = null; + } else { + $this->rateAmount = (int) $this->rateAmount; + } + + foreach ($this->rateRanges as $index => $range) { + foreach ($range as $key => $value) { + $this->rateRanges[$index][$key] = $value === '' || $value === null ? null : (int) $value; + } + } + } + + /** + * Validation rules for the rate form, conditional on the rate type. + * Amounts are integers in minor units (spec 05 §9). + * + * @return array + */ + private function rateRules(): array + { + $rules = [ + 'rateName' => ['required', 'string', 'max:255'], + 'rateType' => ['required', Rule::in(['flat', 'weight', 'price', 'carrier'])], + 'rateActive' => ['boolean'], + ]; + + if ($this->rateType === 'flat') { + $rules['rateAmount'] = ['required', 'integer', 'min:0']; + } + + if ($this->rateType === 'weight') { + $rules['rateRanges'] = ['required', 'array', 'min:1']; + $rules['rateRanges.*.min_g'] = ['required', 'integer', 'min:0']; + $rules['rateRanges.*.max_g'] = ['required', 'integer', 'gte:rateRanges.*.min_g']; + $rules['rateRanges.*.amount'] = ['required', 'integer', 'min:0']; + } + + if ($this->rateType === 'price') { + $rules['rateRanges'] = ['required', 'array', 'min:1']; + $rules['rateRanges.*.min_amount'] = ['required', 'integer', 'min:0']; + $rules['rateRanges.*.max_amount'] = ['nullable', 'integer', 'gte:rateRanges.*.min_amount']; + $rules['rateRanges.*.amount'] = ['required', 'integer', 'min:0']; + } + + return $rules; + } + + /** + * Build the config_json payload for the rate type (spec 05 §9). + * + * @param array $validated + * @return array + */ + private function buildRateConfig(array $validated): array + { + return match ($validated['rateType']) { + 'flat' => ['amount' => (int) $validated['rateAmount']], + 'weight' => [ + 'ranges' => array_map(fn (array $range): array => [ + 'min_g' => (int) $range['min_g'], + 'max_g' => (int) $range['max_g'], + 'amount' => (int) $range['amount'], + ], $validated['rateRanges']), + ], + 'price' => [ + 'ranges' => array_map(fn (array $range): array => array_filter([ + 'min_amount' => (int) $range['min_amount'], + 'max_amount' => isset($range['max_amount']) ? (int) $range['max_amount'] : null, + 'amount' => (int) $range['amount'], + ], fn ($value): bool => $value !== null), $validated['rateRanges']), + ], + default => [], + }; + } + + /** + * Parse a comma-separated list of ISO codes into an uppercase list. + * + * @return list + */ + private function parseCodeList(string $input): array + { + return collect(explode(',', $input)) + ->map(fn (string $code): string => mb_strtoupper(trim($code))) + ->filter() + ->unique() + ->values() + ->all(); + } +} diff --git a/app/Livewire/Admin/Settings/Taxes.php b/app/Livewire/Admin/Settings/Taxes.php new file mode 100644 index 00000000..cc2a12b9 --- /dev/null +++ b/app/Livewire/Admin/Settings/Taxes.php @@ -0,0 +1,112 @@ + Zone id => rate in basis points */ + public array $zoneRates = []; + + public function mount(): void + { + Gate::authorize('manage-taxes'); + + /** @var Store $store */ + $store = app('current_store'); + + $settings = $store->id !== null ? TaxSettings::query()->find($store->id) : null; + $config = $settings?->config_json ?? []; + + $this->mode = $settings?->mode->value ?? 'manual'; + $this->provider = $settings?->provider ?? 'none'; + $this->pricesIncludeTax = $settings?->prices_include_tax ?? false; + $this->defaultRateBps = isset($config['default_rate_bps']) ? (int) $config['default_rate_bps'] : null; + $this->fallback = (string) ($config['fallback'] ?? 'block'); + $this->zoneRates = array_map('intval', $config['zone_rates'] ?? []); + } + + /** + * Persist tax settings (spec 03 §11.4, spec 05 §8). + */ + public function save(): void + { + Gate::authorize('manage-taxes'); + + $this->normalizeInputs(); + + $validated = $this->validate([ + 'mode' => ['required', Rule::in(['manual', 'provider'])], + 'provider' => ['required', Rule::in(['none', 'stripe_tax'])], + 'pricesIncludeTax' => ['boolean'], + 'defaultRateBps' => ['required', 'integer', 'min:0', 'max:10000'], + 'fallback' => ['required', Rule::in(['block', 'allow'])], + 'zoneRates' => ['array'], + 'zoneRates.*' => ['nullable', 'integer', 'min:0', 'max:10000'], + ]); + + /** @var Store $store */ + $store = app('current_store'); + + $zoneRates = collect($validated['zoneRates'] ?? []) + ->filter(fn ($rate): bool => $rate !== null && $rate !== '') + ->map(fn ($rate): int => (int) $rate) + ->all(); + + TaxSettings::query()->updateOrCreate( + ['store_id' => $store->id], + [ + 'mode' => $validated['mode'], + 'provider' => $validated['mode'] === 'provider' ? $validated['provider'] : 'none', + 'prices_include_tax' => $this->pricesIncludeTax, + 'config_json' => [ + 'default_rate_bps' => (int) $validated['defaultRateBps'], + 'zone_rates' => $zoneRates, + 'fallback' => $validated['fallback'], + ], + ], + ); + + $this->dispatch('toast', type: 'success', message: 'Settings saved'); + } + + public function render(): View + { + return view('livewire.admin.settings.taxes', [ + 'zones' => ShippingZone::query()->orderBy('id')->get(), + ])->layout('admin.layouts.app')->title('Taxes'); + } + + /** + * Convert empty-string inputs to null so integer validation passes. + */ + private function normalizeInputs(): void + { + if ($this->defaultRateBps === '' || $this->defaultRateBps === null) { + $this->defaultRateBps = null; + } else { + $this->defaultRateBps = (int) $this->defaultRateBps; + } + + foreach ($this->zoneRates as $zoneId => $rate) { + $this->zoneRates[$zoneId] = $rate === '' || $rate === null ? null : (int) $rate; + } + } +} diff --git a/app/Livewire/Admin/Themes/Editor.php b/app/Livewire/Admin/Themes/Editor.php new file mode 100644 index 00000000..f01b2036 --- /dev/null +++ b/app/Livewire/Admin/Themes/Editor.php @@ -0,0 +1,132 @@ + */ + public array $settings = []; + + public string $featuredCollectionHandles = ''; + + /** + * Sections shown in the left panel, in display order. Sections with an + * `enabled` key support the visibility toggle. + * + * @var array + */ + private const SECTION_LABELS = [ + 'announcement' => 'Announcement', + 'colors' => 'Colors', + 'hero' => 'Hero', + 'featured_collections' => 'Featured collections', + 'featured_products' => 'Featured products', + 'newsletter' => 'Newsletter', + 'rich_text' => 'Rich text', + 'footer' => 'Footer', + 'seo' => 'SEO', + ]; + + public function mount(Theme $theme): void + { + $this->authorize('update', $theme); + + $this->theme = $theme; + $this->settings = array_replace_recursive( + ThemeSettingsService::DEFAULTS, + $theme->settings?->settings_json ?? [], + ); + $this->featuredCollectionHandles = implode(', ', $this->settings['featured_collections']['collection_handles'] ?? []); + } + + /** + * Select a section to edit its settings (spec 03 §12.2). + */ + public function selectSection(string $sectionKey): void + { + if (array_key_exists($sectionKey, self::SECTION_LABELS)) { + $this->selectedSection = $sectionKey; + } + } + + /** + * Toggle a section's visibility on the storefront. + */ + public function toggleSection(string $sectionKey): void + { + if (isset($this->settings[$sectionKey]) && array_key_exists('enabled', $this->settings[$sectionKey])) { + $this->settings[$sectionKey]['enabled'] = ! $this->settings[$sectionKey]['enabled']; + } + } + + /** + * Save all section settings to theme_settings. The cached storefront + * settings are invalidated by the model hooks (spec 03 §12.2). + */ + public function save(): void + { + $this->authorize('update', $this->theme); + + $this->settings['featured_collections']['collection_handles'] = collect(explode(',', $this->featuredCollectionHandles)) + ->map(fn (string $handle): string => Str::slug(trim($handle))) + ->filter() + ->values() + ->all(); + + $this->theme->settings()->updateOrCreate( + ['theme_id' => $this->theme->id], + ['settings_json' => $this->settings], + ); + + $this->dispatch('toast', type: 'success', message: 'Settings saved'); + } + + /** + * Save settings and publish the theme in one go (spec 03 §12.2). + */ + public function saveAndPublish(): void + { + $this->authorize('publish', $this->theme); + + $this->save(); + $this->theme->publish(); + + $this->dispatch('toast', type: 'success', message: 'Theme published'); + } + + public function render(): View + { + return view('livewire.admin.themes.editor', [ + 'sectionLabels' => self::SECTION_LABELS, + 'previewUrl' => $this->previewUrl(), + ])->layout('admin.layouts.app')->title('Customize '.$this->theme->name); + } + + /** + * Storefront home URL for the live preview iframe. Simplified: always + * points at the live storefront; a draft-theme preview token is out of + * scope (spec 03 §12.2 note). + */ + private function previewUrl(): string + { + $scheme = parse_url((string) config('app.url'), PHP_URL_SCHEME) ?: 'http'; + + $hostname = $this->theme->store->domains() + ->where('type', StoreDomainType::Storefront) + ->orderByDesc('is_primary') + ->value('hostname'); + + return $hostname !== null ? "{$scheme}://{$hostname}" : (string) config('app.url'); + } +} diff --git a/app/Livewire/Admin/Themes/Index.php b/app/Livewire/Admin/Themes/Index.php new file mode 100644 index 00000000..5d375b3c --- /dev/null +++ b/app/Livewire/Admin/Themes/Index.php @@ -0,0 +1,134 @@ +authorize('viewAny', Theme::class); + } + + /** + * Create a new draft theme with default settings (spec 03 §12.1). + */ + public function createTheme(): void + { + $this->authorize('create', Theme::class); + + $validated = $this->validate([ + 'newThemeName' => ['required', 'string', 'max:255'], + ]); + + /** @var Store $store */ + $store = app('current_store'); + + $theme = Theme::create([ + 'store_id' => $store->id, + 'name' => $validated['newThemeName'], + 'version' => '1.0.0', + 'status' => ThemeStatus::Draft, + ]); + $theme->settings()->create(['settings_json' => []]); + + $this->showCreateForm = false; + $this->newThemeName = ''; + + $this->dispatch('toast', type: 'success', message: 'Theme created'); + } + + /** + * Publish a theme, demoting every other theme to draft (spec 03 §12.1). + */ + public function publishTheme(int $themeId): void + { + $theme = Theme::query()->find($themeId); + + if ($theme === null) { + return; + } + + $this->authorize('publish', $theme); + + $theme->publish(); + + $this->dispatch('toast', type: 'success', message: 'Theme published'); + } + + /** + * Duplicate a theme including files and settings (spec 03 §12.1). + */ + public function duplicateTheme(int $themeId): void + { + $this->authorize('create', Theme::class); + + $theme = Theme::query()->find($themeId); + + if ($theme === null) { + return; + } + + $theme->duplicate($theme->name.' (copy)'); + + $this->dispatch('toast', type: 'success', message: 'Theme duplicated'); + } + + /** + * Open the delete confirmation modal for a theme. + */ + public function confirmDelete(int $themeId): void + { + $this->deletingId = $themeId; + $this->confirmingDelete = true; + } + + /** + * Delete a theme. The published theme cannot be deleted (spec 03 §12.1). + */ + public function deleteTheme(): void + { + $this->confirmingDelete = false; + + $theme = Theme::query()->find($this->deletingId); + $this->deletingId = null; + + if ($theme === null) { + return; + } + + $this->authorize('delete', $theme); + + if ($theme->isPublished()) { + $this->dispatch('toast', type: 'error', message: 'The published theme cannot be deleted. Publish another theme first.'); + + return; + } + + $theme->files()->delete(); + $theme->settings()->delete(); + $theme->delete(); + + $this->dispatch('toast', type: 'success', message: 'Theme deleted'); + } + + public function render(): View + { + return view('livewire.admin.themes.index', [ + 'themes' => Theme::query()->orderByDesc('status')->orderBy('name')->get(), + ])->layout('admin.layouts.app')->title('Themes'); + } +} diff --git a/app/Livewire/Concerns/ThrottlesLoginAttempts.php b/app/Livewire/Concerns/ThrottlesLoginAttempts.php new file mode 100644 index 00000000..8d490a77 --- /dev/null +++ b/app/Livewire/Concerns/ThrottlesLoginAttempts.php @@ -0,0 +1,65 @@ +loginLimit(); + + if (! RateLimiter::tooManyAttempts($this->throttleKey(), $limit->maxAttempts)) { + return; + } + + $seconds = RateLimiter::availableIn($this->throttleKey()); + + throw new TooManyRequestsHttpException($seconds, "Too many attempts. Try again in {$seconds} seconds."); + } + + /** + * Record a failed login attempt against the login limiter. + */ + protected function hitLoginRateLimiter(): void + { + RateLimiter::hit($this->throttleKey(), $this->loginLimit()->decaySeconds); + } + + /** + * Clear the login rate limiter after a successful login. + */ + protected function clearLoginRateLimiter(): void + { + RateLimiter::clear($this->throttleKey()); + } + + /** + * The throttle key of the shared "login" rate limiter. + */ + private function throttleKey(): string + { + return (string) $this->loginLimit()->key; + } + + /** + * Resolve the named "login" limiter (5 per minute, keyed by IP). + */ + private function loginLimit(): Limit + { + return (RateLimiter::limiter('login'))(request()); + } +} diff --git a/app/Livewire/Settings/Appearance.php b/app/Livewire/Settings/Appearance.php deleted file mode 100644 index 7e87193e..00000000 --- a/app/Livewire/Settings/Appearance.php +++ /dev/null @@ -1,10 +0,0 @@ -validate([ - 'password' => $this->currentPasswordRules(), - ]); - - tap(Auth::user(), $logout(...))->delete(); - - $this->redirect('/', navigate: true); - } -} diff --git a/app/Livewire/Settings/Password.php b/app/Livewire/Settings/Password.php deleted file mode 100644 index 613abebe..00000000 --- a/app/Livewire/Settings/Password.php +++ /dev/null @@ -1,44 +0,0 @@ -validate([ - 'current_password' => $this->currentPasswordRules(), - 'password' => $this->passwordRules(), - ]); - } catch (ValidationException $e) { - $this->reset('current_password', 'password', 'password_confirmation'); - - throw $e; - } - - Auth::user()->update([ - 'password' => $validated['password'], - ]); - - $this->reset('current_password', 'password', 'password_confirmation'); - - $this->dispatch('password-updated'); - } -} diff --git a/app/Livewire/Settings/Profile.php b/app/Livewire/Settings/Profile.php deleted file mode 100644 index bfecd6cf..00000000 --- a/app/Livewire/Settings/Profile.php +++ /dev/null @@ -1,79 +0,0 @@ -name = Auth::user()->name; - $this->email = Auth::user()->email; - } - - /** - * Update the profile information for the currently authenticated user. - */ - public function updateProfileInformation(): void - { - $user = Auth::user(); - - $validated = $this->validate($this->profileRules($user->id)); - - $user->fill($validated); - - if ($user->isDirty('email')) { - $user->email_verified_at = null; - } - - $user->save(); - - $this->dispatch('profile-updated', name: $user->name); - } - - /** - * Send an email verification notification to the current user. - */ - public function resendVerificationNotification(): void - { - $user = Auth::user(); - - if ($user->hasVerifiedEmail()) { - $this->redirectIntended(default: route('dashboard', absolute: false)); - - return; - } - - $user->sendEmailVerificationNotification(); - - Session::flash('status', 'verification-link-sent'); - } - - #[Computed] - public function hasUnverifiedEmail(): bool - { - return Auth::user() instanceof MustVerifyEmail && ! Auth::user()->hasVerifiedEmail(); - } - - #[Computed] - public function showDeleteUser(): bool - { - return ! Auth::user() instanceof MustVerifyEmail - || (Auth::user() instanceof MustVerifyEmail && Auth::user()->hasVerifiedEmail()); - } -} diff --git a/app/Livewire/Settings/TwoFactor.php b/app/Livewire/Settings/TwoFactor.php deleted file mode 100644 index a1641b56..00000000 --- a/app/Livewire/Settings/TwoFactor.php +++ /dev/null @@ -1,182 +0,0 @@ -user()->two_factor_confirmed_at)) { - $disableTwoFactorAuthentication(auth()->user()); - } - - $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication(); - $this->requiresConfirmation = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'); - } - - /** - * Enable two-factor authentication for the user. - */ - public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthentication): void - { - $enableTwoFactorAuthentication(auth()->user()); - - if (! $this->requiresConfirmation) { - $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication(); - } - - $this->loadSetupData(); - - $this->showModal = true; - } - - /** - * Load the two-factor authentication setup data for the user. - */ - private function loadSetupData(): void - { - $user = auth()->user(); - - try { - $this->qrCodeSvg = $user?->twoFactorQrCodeSvg(); - $this->manualSetupKey = decrypt($user->two_factor_secret); - } catch (Exception) { - $this->addError('setupData', 'Failed to fetch setup data.'); - - $this->reset('qrCodeSvg', 'manualSetupKey'); - } - } - - /** - * Show the two-factor verification step if necessary. - */ - public function showVerificationIfNecessary(): void - { - if ($this->requiresConfirmation) { - $this->showVerificationStep = true; - - $this->resetErrorBag(); - - return; - } - - $this->closeModal(); - } - - /** - * Confirm two-factor authentication for the user. - */ - public function confirmTwoFactor(ConfirmTwoFactorAuthentication $confirmTwoFactorAuthentication): void - { - $this->validate(); - - $confirmTwoFactorAuthentication(auth()->user(), $this->code); - - $this->closeModal(); - - $this->twoFactorEnabled = true; - } - - /** - * Reset two-factor verification state. - */ - public function resetVerification(): void - { - $this->reset('code', 'showVerificationStep'); - - $this->resetErrorBag(); - } - - /** - * Disable two-factor authentication for the user. - */ - public function disable(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void - { - $disableTwoFactorAuthentication(auth()->user()); - - $this->twoFactorEnabled = false; - } - - /** - * Close the two-factor authentication modal. - */ - public function closeModal(): void - { - $this->reset( - 'code', - 'manualSetupKey', - 'qrCodeSvg', - 'showModal', - 'showVerificationStep', - ); - - $this->resetErrorBag(); - - if (! $this->requiresConfirmation) { - $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication(); - } - } - - /** - * Get the current modal configuration state. - */ - public function getModalConfigProperty(): array - { - if ($this->twoFactorEnabled) { - return [ - 'title' => __('Two-Factor Authentication Enabled'), - 'description' => __('Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.'), - 'buttonText' => __('Close'), - ]; - } - - if ($this->showVerificationStep) { - return [ - 'title' => __('Verify Authentication Code'), - 'description' => __('Enter the 6-digit code from your authenticator app.'), - 'buttonText' => __('Continue'), - ]; - } - - return [ - 'title' => __('Enable Two-Factor Authentication'), - 'description' => __('To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app.'), - 'buttonText' => __('Continue'), - ]; - } -} diff --git a/app/Livewire/Settings/TwoFactor/RecoveryCodes.php b/app/Livewire/Settings/TwoFactor/RecoveryCodes.php deleted file mode 100644 index 7352d80f..00000000 --- a/app/Livewire/Settings/TwoFactor/RecoveryCodes.php +++ /dev/null @@ -1,50 +0,0 @@ -loadRecoveryCodes(); - } - - /** - * Generate new recovery codes for the user. - */ - public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generateNewRecoveryCodes): void - { - $generateNewRecoveryCodes(auth()->user()); - - $this->loadRecoveryCodes(); - } - - /** - * Load the recovery codes for the user. - */ - private function loadRecoveryCodes(): void - { - $user = auth()->user(); - - if ($user->hasEnabledTwoFactorAuthentication() && $user->two_factor_recovery_codes) { - try { - $this->recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true); - } catch (Exception) { - $this->addError('recoveryCodes', 'Failed to load recovery codes'); - - $this->recoveryCodes = []; - } - } - } -} diff --git a/app/Livewire/Storefront/Account/Addresses/Index.php b/app/Livewire/Storefront/Account/Addresses/Index.php new file mode 100644 index 00000000..67c266c5 --- /dev/null +++ b/app/Livewire/Storefront/Account/Addresses/Index.php @@ -0,0 +1,220 @@ +resetForm(); + $this->editingId = null; + $this->showModal = true; + } + + /** + * Open the modal pre-filled with an existing address (own addresses + * only, others are a 404). + */ + public function edit(int $addressId): void + { + $address = $this->findAddress($addressId); + $data = $address->address_json ?? []; + + $this->editingId = $address->id; + $this->label = (string) ($address->label ?? ''); + $this->first_name = (string) ($data['first_name'] ?? ''); + $this->last_name = (string) ($data['last_name'] ?? ''); + $this->company = (string) ($data['company'] ?? ''); + $this->address1 = (string) ($data['address1'] ?? ''); + $this->address2 = (string) ($data['address2'] ?? ''); + $this->city = (string) ($data['city'] ?? ''); + $this->province = (string) ($data['province'] ?? ''); + $this->province_code = (string) ($data['province_code'] ?? ''); + $this->country = (string) ($data['country'] ?? ''); + $this->country_code = (string) ($data['country_code'] ?? ''); + $this->postal_code = (string) ($data['postal_code'] ?? ''); + $this->phone = (string) ($data['phone'] ?? ''); + $this->is_default = $address->is_default; + $this->showModal = true; + } + + /** + * Validate and persist the address form (create or update). + */ + public function save(): void + { + $validated = $this->validate($this->rules()); + + $customer = $this->customer(); + + $addressJson = [ + 'first_name' => $validated['first_name'], + 'last_name' => $validated['last_name'], + 'company' => $validated['company'] ?: null, + 'address1' => $validated['address1'], + 'address2' => $validated['address2'] ?: null, + 'city' => $validated['city'], + 'province' => $validated['province'] ?: null, + 'province_code' => $validated['province_code'] ?: null, + 'country' => $validated['country'], + 'country_code' => $validated['country_code'], + 'postal_code' => $validated['postal_code'], + 'phone' => $validated['phone'] ?: null, + ]; + + if ($this->editingId !== null) { + $address = $this->findAddress($this->editingId); + + $address->update([ + 'label' => $validated['label'] ?: null, + 'address_json' => $addressJson, + 'is_default' => $this->is_default, + ]); + } else { + // The first saved address becomes the default automatically. + $address = $customer->addresses()->create([ + 'label' => $validated['label'] ?: null, + 'address_json' => $addressJson, + 'is_default' => $this->is_default || ! $customer->addresses()->exists(), + ]); + } + + if ($address->is_default) { + $customer->addresses()->whereKeyNot($address->id)->update(['is_default' => false]); + } + + $this->showModal = false; + $this->resetForm(); + + $this->dispatch('toast', type: 'success', message: 'Address saved'); + } + + /** + * Delete an address (own addresses only). + */ + public function delete(int $addressId): void + { + $this->findAddress($addressId)->delete(); + } + + /** + * Mark an address as the default and clear the flag on the rest. + */ + public function setDefault(int $addressId): void + { + $address = $this->findAddress($addressId); + + $this->customer()->addresses()->whereKeyNot($address->id)->update(['is_default' => false]); + $address->update(['is_default' => true]); + } + + /** + * Render the address book (spec 04 §10.6). + */ + public function render(): View + { + return view('livewire.storefront.account.addresses.index', [ + 'addresses' => $this->customer()->addresses() + ->orderByDesc('is_default') + ->orderBy('id') + ->get(), + ]) + ->layout('storefront.layouts.app') + ->title('Your addresses'); + } + + /** + * Validation rules for the address form (address_json fields). + * + * @return array + */ + private function rules(): array + { + return [ + 'label' => 'nullable|string|max:255', + 'first_name' => 'required|string|max:255', + 'last_name' => 'required|string|max:255', + 'company' => 'nullable|string|max:255', + 'address1' => 'required|string|max:500', + 'address2' => 'nullable|string|max:500', + 'city' => 'required|string|max:255', + 'province' => 'nullable|string|max:255', + 'province_code' => 'nullable|string|max:10', + 'country' => 'required|string|max:255', + 'country_code' => 'required|string|size:2', + 'postal_code' => 'required|string|max:20', + 'phone' => 'nullable|string|max:50', + 'is_default' => 'boolean', + ]; + } + + /** + * Find an address of the authenticated customer or 404. + */ + private function findAddress(int $addressId): CustomerAddress + { + return $this->customer()->addresses()->findOrFail($addressId); + } + + /** + * The authenticated storefront customer. + */ + private function customer(): Customer + { + return Auth::guard('customer')->user(); + } + + /** + * Reset the form fields back to a blank state. + */ + private function resetForm(): void + { + $this->resetErrorBag(); + $this->reset([ + 'label', 'first_name', 'last_name', 'company', 'address1', 'address2', + 'city', 'province', 'province_code', 'country', 'country_code', + 'postal_code', 'phone', 'is_default', + ]); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/ForgotPassword.php b/app/Livewire/Storefront/Account/Auth/ForgotPassword.php new file mode 100644 index 00000000..402ff16f --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/ForgotPassword.php @@ -0,0 +1,51 @@ +check()) { + $this->redirect('/account'); + } + } + + /** + * Send a reset link through the store-scoped "customers" broker + * (spec 06 §1.2). The response is always generic so it never reveals + * whether the email exists in this store. + */ + public function sendResetLink(): void + { + $this->validate([ + 'email' => 'required|email', + ]); + + Password::broker('customers')->sendResetLink(['email' => $this->email]); + + $this->linkSent = true; + } + + /** + * Render the forgot-password page in the storefront layout. + */ + public function render(): View + { + return view('livewire.storefront.account.auth.forgot-password') + ->layout('storefront.layouts.app') + ->title('Forgot password'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Login.php b/app/Livewire/Storefront/Account/Auth/Login.php new file mode 100644 index 00000000..228e395b --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Login.php @@ -0,0 +1,75 @@ +check()) { + $this->redirect('/account'); + } + } + + /** + * Attempt to authenticate against the store-scoped customer guard + * (spec 06 §1.2). The failure message is always generic. + */ + public function login(): void + { + $this->errorMessage = null; + + $credentials = $this->validate([ + 'email' => 'required|email', + 'password' => 'required|string', + ]); + + $this->ensureIsNotRateLimited(); + + if (! Auth::guard('customer')->attempt($credentials, $this->remember)) { + $this->hitLoginRateLimiter(); + + $this->errorMessage = 'Invalid credentials.'; + + return; + } + + session()->regenerate(); + $this->clearLoginRateLimiter(); + + $customer = Auth::guard('customer')->user(); + + $this->mergeGuestCartOnLogin($customer); + + $this->redirect(session()->pull('url.intended', '/account')); + } + + /** + * Render the login page in the storefront layout (spec 04 §10.1). + */ + public function render(): View + { + return view('livewire.storefront.account.auth.login') + ->layout('storefront.layouts.app') + ->title('Log in'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Register.php b/app/Livewire/Storefront/Account/Auth/Register.php new file mode 100644 index 00000000..afb8e035 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Register.php @@ -0,0 +1,76 @@ +check()) { + $this->redirect('/account'); + } + } + + /** + * Create the customer scoped to the current store, auto-login and + * merge the guest cart (spec 06 §1.2). + */ + public function register(CustomerService $customers): void + { + $store = app('current_store'); + + $validated = $this->validate([ + 'name' => 'required|string|max:255', + 'email' => [ + 'required', + 'email', + 'max:255', + Rule::unique('customers', 'email')->where('store_id', $store->id), + ], + 'password' => 'required|min:8|confirmed', + 'marketing_opt_in' => 'boolean', + ]); + + $customer = $customers->register($store, $validated); + + Auth::guard('customer')->login($customer); + + session()->regenerate(); + + $this->mergeGuestCartOnLogin($customer); + + $this->redirect('/account'); + } + + /** + * Render the registration page in the storefront layout (spec 04 §10.2). + */ + public function render(): View + { + return view('livewire.storefront.account.auth.register') + ->layout('storefront.layouts.app') + ->title('Create an account'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/ResetPassword.php b/app/Livewire/Storefront/Account/Auth/ResetPassword.php new file mode 100644 index 00000000..2bbcae0f --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/ResetPassword.php @@ -0,0 +1,77 @@ +token = $token; + $this->email = (string) request()->query('email', ''); + } + + /** + * Reset the password through the store-scoped "customers" broker + * (spec 06 §1.2). Both the token and the customer lookup are scoped + * to the current store. + */ + public function resetPassword(): void + { + $this->errorMessage = null; + + $this->validate([ + 'token' => 'required', + 'email' => 'required|email', + 'password' => 'required|min:8|confirmed', + ]); + + $status = Password::broker('customers')->reset( + $this->only('email', 'password', 'password_confirmation', 'token'), + function (Customer $customer, string $password): void { + $customer->forceFill([ + 'password_hash' => $password, + 'remember_token' => Str::random(60), + ])->save(); + }, + ); + + if ($status !== Password::PASSWORD_RESET) { + $this->errorMessage = 'This password reset link is invalid or has expired.'; + + return; + } + + session()->flash('status', 'Your password has been reset. You can now log in.'); + + $this->redirect(route('storefront.account.login')); + } + + /** + * Render the reset-password page in the storefront layout. + */ + public function render(): View + { + return view('livewire.storefront.account.auth.reset-password') + ->layout('storefront.layouts.app') + ->title('Reset password'); + } +} diff --git a/app/Livewire/Storefront/Account/Dashboard.php b/app/Livewire/Storefront/Account/Dashboard.php new file mode 100644 index 00000000..02af1029 --- /dev/null +++ b/app/Livewire/Storefront/Account/Dashboard.php @@ -0,0 +1,67 @@ +customer(); + + $this->name = (string) ($customer->name ?? ''); + $this->marketing_opt_in = (bool) $customer->marketing_opt_in; + } + + /** + * Persist the editable profile fields (name, marketing preference). + */ + public function updateProfile(): void + { + $validated = $this->validate([ + 'name' => 'required|string|max:255', + 'marketing_opt_in' => 'boolean', + ]); + + $this->customer()->update($validated); + + $this->profileSaved = true; + } + + /** + * Render the account dashboard with the five most recent orders + * (spec 04 §10.3). + */ + public function render(): View + { + $customer = $this->customer(); + + return view('livewire.storefront.account.dashboard', [ + 'customer' => $customer, + 'recentOrders' => $customer->orders()->latest('placed_at')->limit(5)->get(), + ]) + ->layout('storefront.layouts.app') + ->title('My account'); + } + + /** + * The authenticated storefront customer. + */ + private function customer(): Customer + { + return Auth::guard('customer')->user(); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Index.php b/app/Livewire/Storefront/Account/Orders/Index.php new file mode 100644 index 00000000..9a42978c --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Index.php @@ -0,0 +1,31 @@ +user() + ->orders() + ->latest('placed_at') + ->paginate(10); + + return view('livewire.storefront.account.orders.index', [ + 'orders' => $orders, + ]) + ->layout('storefront.layouts.app') + ->title('Order history'); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Show.php b/app/Livewire/Storefront/Account/Orders/Show.php new file mode 100644 index 00000000..0eed6085 --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Show.php @@ -0,0 +1,41 @@ +order = Auth::guard('customer')->user() + ->orders() + ->where(fn ($query) => $query + ->where('order_number', $number) + ->orWhere('order_number', '#'.$number)) + ->with(['lines', 'fulfillments', 'payments']) + ->firstOrFail(); + } + + /** + * Render the order detail page. + */ + public function render(): View + { + return view('livewire.storefront.account.orders.show') + ->layout('storefront.layouts.app') + ->title('Order '.$this->order->order_number); + } +} diff --git a/app/Livewire/Storefront/Cart/Show.php b/app/Livewire/Storefront/Cart/Show.php new file mode 100644 index 00000000..ef4de4f7 --- /dev/null +++ b/app/Livewire/Storefront/Cart/Show.php @@ -0,0 +1,40 @@ +redirectRoute('storefront.checkout.show', ['checkoutId' => 'new']); + } + + /** + * Render the cart page. + */ + public function render(): View + { + $cart = $this->sessionCart(); + $cart?->loadMissing(['lines.variant.product.media', 'lines.variant.optionValues.option']); + + return view('livewire.storefront.cart.show', [ + 'cart' => $cart, + 'discount' => $cart !== null ? $this->appliedSessionDiscount($cart) : null, + ]) + ->layout('storefront.layouts.app') + ->title('Your Cart - '.app('current_store')->name); + } +} diff --git a/app/Livewire/Storefront/CartDrawer.php b/app/Livewire/Storefront/CartDrawer.php new file mode 100644 index 00000000..ecfd2b00 --- /dev/null +++ b/app/Livewire/Storefront/CartDrawer.php @@ -0,0 +1,72 @@ +getOrCreateForSession($this->currentStore(), auth('customer')->user()); + + try { + $carts->addLine($cart, $variantId, $quantity); + } catch (InsufficientInventoryException|ValidationException) { + return; + } + + $this->broadcastCartCount($cart->refresh()); + $this->dispatch('cart-drawer-open'); + } + + /** + * Re-render when another component changed the cart. + */ + #[On('cart-updated')] + public function refreshCart(): void + { + // The render cycle reloads the session cart. + } + + /** + * Proceed to checkout (step 1 collects contact and address). + */ + public function checkout() + { + return $this->redirectRoute('storefront.checkout.show', ['checkoutId' => 'new']); + } + + /** + * Render the drawer content. + */ + public function render(): View + { + $cart = $this->sessionCart(); + $cart?->loadMissing(['lines.variant.product.media', 'lines.variant.optionValues.option']); + + return view('livewire.storefront.cart-drawer', [ + 'cart' => $cart, + 'discount' => $cart !== null ? $this->appliedSessionDiscount($cart) : null, + ]); + } +} diff --git a/app/Livewire/Storefront/Checkout/Confirmation.php b/app/Livewire/Storefront/Checkout/Confirmation.php new file mode 100644 index 00000000..92b69600 --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Confirmation.php @@ -0,0 +1,72 @@ +where('checkout_id', $checkout->id) + ->first(); + + abort_if($order === null, 404); + + $this->checkout = $checkout; + $this->order = $order; + } + + /** + * Tokenized URL of the guest order status API endpoint. + */ + public function orderStatusUrl(): string + { + return '/api/storefront/v1/orders/'.urlencode($this->order->order_number).'?token='.OrderToken::for($this->order); + } + + /** + * Render the confirmation page. + */ + public function render(): View + { + return view('livewire.storefront.checkout.confirmation', [ + 'paymentLast4' => $this->paymentLast4(), + 'isBankTransfer' => $this->order->payment_method === PaymentMethod::BankTransfer, + ]) + ->layout('storefront.layouts.app') + ->title('Order '.$this->order->order_number.' - '.app('current_store')->name); + } + + /** + * Last four card digits from the sanitized provider payload, if any. + */ + private function paymentLast4(): ?string + { + return $this->order->payments->first()?->raw_json_encrypted['card_last4'] ?? null; + } +} diff --git a/app/Livewire/Storefront/Checkout/Show.php b/app/Livewire/Storefront/Checkout/Show.php new file mode 100644 index 00000000..60f0ec63 --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Show.php @@ -0,0 +1,366 @@ + + */ + public array $address = [ + 'first_name' => '', + 'last_name' => '', + 'company' => '', + 'address1' => '', + 'address2' => '', + 'city' => '', + 'province' => '', + 'province_code' => '', + 'country' => '', + 'country_code' => '', + 'postal_code' => '', + 'phone' => '', + ]; + + public bool $useShippingAsBilling = true; + + public string $paymentMethod = 'credit_card'; + + public bool $paymentSelected = false; + + public string $cardNumber = ''; + + public string $cardExpiry = ''; + + public string $cardCvc = ''; + + public string $cardHolder = ''; + + public ?string $paymentError = null; + + public string $discountCode = ''; + + public ?string $discountError = null; + + /** + * Load an existing checkout, or start fresh for "new". + */ + public function mount(string $checkoutId): void + { + if ($checkoutId === 'new') { + $cart = app(CartService::class)->findForSession(app('current_store')); + + if ($cart === null || $cart->lines->isEmpty()) { + $this->redirectRoute('storefront.cart.show'); + + return; + } + + return; + } + + $checkout = Checkout::find((int) $checkoutId); + + abort_if($checkout === null, 404); + + if ($checkout->isExpired()) { + $this->expired = true; + + return; + } + + $this->checkoutDbId = $checkout->id; + $this->email = $checkout->email ?? ''; + + if (! empty($checkout->shipping_address_json)) { + $this->address = array_merge( + $this->address, + array_filter($checkout->shipping_address_json, fn ($value) => $value !== null), + ); + } + + $this->step = match ($checkout->status) { + CheckoutStatus::Started => 1, + CheckoutStatus::Addressed => 2, + default => 3, + }; + + if ($checkout->payment_method !== null) { + $this->paymentMethod = $checkout->payment_method->value; + $this->paymentSelected = true; + } + } + + /** + * Step 1 submit: create the checkout (when new) and set the address. + */ + public function submitAddress(): void + { + // The form collects the ISO country code only; the address payload + // carries both representations (spec 02 §2.2). + $this->address['country'] = $this->address['country_code']; + $this->address['country_code'] = strtoupper($this->address['country_code']); + $this->address['country'] = strtoupper($this->address['country']); + + $this->validate($this->addressRules()); + + $service = app(CheckoutService::class); + + try { + if ($this->checkoutDbId === null) { + $cart = app(CartService::class)->findForSession(app('current_store')); + + if ($cart === null || $cart->lines->isEmpty()) { + $this->addError('email', 'Your cart is empty.'); + + return; + } + + $checkout = $service->createFromCart( + $cart, + $this->email, + auth('customer')->user(), + session('discount_code'), + ); + } else { + $checkout = Checkout::findOrFail($this->checkoutDbId); + } + + $service->setAddress($checkout, [ + 'email' => $this->email, + 'shipping_address' => $this->address, + 'use_shipping_as_billing' => $this->useShippingAsBilling, + ]); + } catch (ValidationException $exception) { + foreach ($exception->errors() as $key => $messages) { + $this->addError(str_replace('shipping_address', 'address', $key), $messages[0]); + } + + return; + } + + session()->forget('discount_code'); + + $this->redirectRoute('storefront.checkout.show', ['checkoutId' => $checkout->id]); + } + + /** + * Step 2: pick a shipping method and advance to payment. + */ + public function selectShipping(int $rateId): void + { + $checkout = Checkout::findOrFail($this->checkoutDbId); + + try { + app(CheckoutService::class)->setShippingMethod($checkout, $rateId); + } catch (ValidationException) { + $this->addError('shippingMethodId', 'The selected shipping method is not available for your address.'); + + return; + } + + $this->step = 3; + } + + /** + * Step 2 shortcut for carts without shippable items. + */ + public function continueWithoutShipping(): void + { + $checkout = Checkout::findOrFail($this->checkoutDbId); + + app(CheckoutService::class)->setShippingMethod($checkout, null); + + $this->step = 3; + } + + /** + * Step 3: record the payment method (reserves inventory). + */ + public function selectPayment(): void + { + $this->validate([ + 'paymentMethod' => ['required', 'in:credit_card,paypal,bank_transfer'], + ]); + + $checkout = Checkout::findOrFail($this->checkoutDbId); + + app(CheckoutService::class)->selectPaymentMethod($checkout, $this->paymentMethod); + + $this->paymentSelected = true; + } + + /** + * Step 3 submit: charge the selected payment method and create the order + * (spec 04 §8.2). On decline the customer stays on the payment step and + * sees the error; on success they are redirected to the confirmation. + */ + public function pay(): void + { + $this->paymentError = null; + + $checkout = Checkout::findOrFail($this->checkoutDbId); + $method = $checkout->payment_method?->value ?? $this->paymentMethod; + + $rules = []; + + if ($method === 'credit_card') { + $rules = [ + 'cardNumber' => ['required', 'string', 'max:25'], + 'cardExpiry' => ['required', 'string', 'max:7'], + 'cardCvc' => ['required', 'string', 'max:4'], + 'cardHolder' => ['required', 'string', 'max:255'], + ]; + } + + if ($rules !== []) { + $this->validate($rules); + } + + try { + app(CheckoutService::class)->completeCheckout($checkout, [ + 'payment_method' => $method, + 'card_number' => $this->cardNumber, + 'card_expiry' => $this->cardExpiry, + 'card_cvc' => $this->cardCvc, + 'card_holder' => $this->cardHolder, + ]); + } catch (PaymentFailedException $exception) { + $this->paymentError = 'Payment declined: '.$exception->getMessage(); + + return; + } catch (InsufficientInventoryException) { + $this->paymentError = 'Some items in your order are no longer available.'; + + return; + } + + $this->redirectRoute('storefront.checkout.confirmation', ['checkoutId' => $checkout->id]); + } + + /** + * Apply a discount code to the checkout. + */ + public function applyDiscount(): void + { + $this->discountError = null; + $code = trim($this->discountCode); + + if ($this->checkoutDbId === null || $code === '') { + return; + } + + $result = app(CheckoutService::class)->applyDiscount( + Checkout::findOrFail($this->checkoutDbId), + $code, + ); + + if (! $result->valid) { + $this->discountError = $result->errorMessage; + + return; + } + + $this->discountCode = ''; + } + + /** + * Remove the discount code from the checkout. + */ + public function removeDiscount(): void + { + if ($this->checkoutDbId === null) { + return; + } + + app(CheckoutService::class)->removeDiscount(Checkout::findOrFail($this->checkoutDbId)); + } + + /** + * Validation rules for the step 1 form (mirrors SetCheckoutAddressRequest). + * + * @return array> + */ + private function addressRules(): array + { + return [ + 'email' => ['required', 'email', 'max:255'], + 'address.first_name' => ['required', 'string', 'max:255'], + 'address.last_name' => ['required', 'string', 'max:255'], + 'address.company' => ['nullable', 'string', 'max:255'], + 'address.address1' => ['required', 'string', 'max:500'], + 'address.address2' => ['nullable', 'string', 'max:500'], + 'address.city' => ['required', 'string', 'max:255'], + 'address.province' => ['nullable', 'string', 'max:255'], + 'address.province_code' => ['nullable', 'string', 'max:10'], + 'address.country' => ['required', 'string', 'max:255'], + 'address.country_code' => ['required', 'string', 'size:2', 'alpha'], + 'address.postal_code' => ['required', 'string', 'max:20'], + 'address.phone' => ['nullable', 'string', 'max:50'], + 'useShippingAsBilling' => ['boolean'], + ]; + } + + /** + * Render the checkout page. + */ + public function render(): View + { + $checkout = $this->checkoutDbId !== null + ? Checkout::with(['cart.lines.variant.product.media', 'cart.lines.variant.optionValues.option'])->find($this->checkoutDbId) + : null; + + $rates = collect(); + + if ($checkout !== null && $checkout->status === CheckoutStatus::Addressed && $checkout->requiresShipping()) { + $rates = app(ShippingCalculator::class)->getAvailableRates( + $checkout->store, + Address::fromArray($checkout->shipping_address_json), + $checkout->cart, + ); + } + + // Preview cart for the "new" step (before the checkout exists). + $previewCart = $checkout === null + ? app(CartService::class)->findForSession(app('current_store')) + : null; + + return view('livewire.storefront.checkout.show', [ + 'checkout' => $checkout, + 'previewCart' => $previewCart, + 'rates' => $rates, + ]) + ->layout('storefront.layouts.app') + ->title('Checkout - '.app('current_store')->name); + } +} diff --git a/app/Livewire/Storefront/Collections/Index.php b/app/Livewire/Storefront/Collections/Index.php new file mode 100644 index 00000000..bb22234f --- /dev/null +++ b/app/Livewire/Storefront/Collections/Index.php @@ -0,0 +1,30 @@ +where('status', CollectionStatus::Active) + ->latest() + ->get(); + + return view('livewire.storefront.collections.index', [ + 'collections' => $collections, + ]) + ->layout('storefront.layouts.app', [ + 'metaDescription' => 'Browse all collections of '.app('current_store')->name, + ]) + ->title('Collections - '.app('current_store')->name); + } +} diff --git a/app/Livewire/Storefront/Collections/Show.php b/app/Livewire/Storefront/Collections/Show.php new file mode 100644 index 00000000..779086cc --- /dev/null +++ b/app/Livewire/Storefront/Collections/Show.php @@ -0,0 +1,240 @@ + + */ + public const SORT_OPTIONS = [ + 'featured' => 'Featured', + 'price-asc' => 'Price: Low to High', + 'price-desc' => 'Price: High to Low', + 'newest' => 'Newest', + 'best-selling' => 'Best Selling', + ]; + + public Collection $collection; + + public string $sort = 'featured'; + + public bool $inStock = false; + + public ?string $minPrice = null; + + public ?string $maxPrice = null; + + /** @var list */ + public array $types = []; + + /** @var list */ + public array $vendors = []; + + /** + * Resolve the collection by handle; only active collections are visible. + */ + public function mount(string $handle): void + { + $this->collection = Collection::query() + ->where('handle', $handle) + ->where('status', CollectionStatus::Active) + ->firstOrFail(); + } + + /** + * Any filter or sort change returns to the first page. + */ + public function updated(): void + { + $this->resetPage(); + } + + /** + * Reset every filter to its default. + */ + public function clearFilters(): void + { + $this->reset(['inStock', 'minPrice', 'maxPrice', 'types', 'vendors']); + $this->resetPage(); + } + + /** + * Render the collection page. + */ + public function render(): View + { + $products = $this->collection->products() + ->visible() + ->with(['variants.inventoryItem', 'media']); + + $this->applyFilters($products); + $this->applySort($products); + + $paginator = $products->paginate(self::PER_PAGE); + + $storeName = app('current_store')->name; + + return view('livewire.storefront.collections.show', [ + 'products' => $paginator, + 'sortOptions' => self::SORT_OPTIONS, + 'availableTypes' => $this->availableFilterValues('product_type'), + 'availableVendors' => $this->availableFilterValues('vendor'), + 'activeFilters' => $this->activeFilters(), + ]) + ->layout('storefront.layouts.app', [ + 'metaDescription' => str()->limit(trim(strip_tags($this->collection->description_html ?? '')), 160, ''), + ]) + ->title("{$this->collection->title} - {$storeName}"); + } + + /** + * Apply the active filters to the product query. + * + * @param BelongsToMany<\App\Models\Product> $query + */ + private function applyFilters(BelongsToMany $query): void + { + if ($this->inStock) { + $query->whereHas('variants.inventoryItem', function ($q): void { + $q->whereRaw('(quantity_on_hand - quantity_reserved) > 0'); + }); + } + + $minCents = $this->priceToCents($this->minPrice); + if ($minCents !== null) { + $query->whereHas('variants', fn ($q) => $q->where('price_amount', '>=', $minCents)); + } + + $maxCents = $this->priceToCents($this->maxPrice); + if ($maxCents !== null) { + $query->whereHas('variants', fn ($q) => $q->where('price_amount', '<=', $maxCents)); + } + + if ($this->types !== []) { + $query->whereIn('products.product_type', $this->types); + } + + if ($this->vendors !== []) { + $query->whereIn('products.vendor', $this->vendors); + } + } + + /** + * Apply the selected sort order to the product query. + * + * @param BelongsToMany<\App\Models\Product> $query + */ + private function applySort(BelongsToMany $query): void + { + if ($this->sort !== 'featured') { + // The products() relation has a default orderBy on the pivot + // position; a later orderBy would only act as a tie-breaker, so + // reset the ordering before applying the requested sort. + $query->reorder(); + } + + match ($this->sort) { + 'price-asc' => $query->orderBy($this->minimumPriceSubquery()), + 'price-desc' => $query->orderByDesc($this->minimumPriceSubquery()), + 'newest' => $query->orderByDesc('products.created_at'), + 'best-selling' => $query->orderByDesc($this->salesCountSubquery()), + default => $query->orderBy('collection_products.position'), + }; + } + + /** + * Subquery selecting the minimum variant price of a product. + */ + private function minimumPriceSubquery(): BuilderContract + { + return ProductVariant::query() + ->selectRaw('MIN(price_amount)') + ->whereColumn('product_variants.product_id', 'products.id'); + } + + /** + * Subquery selecting the total sold quantity of a product. + */ + private function salesCountSubquery(): \Illuminate\Database\Query\Builder + { + return DB::table('order_lines') + ->selectRaw('COALESCE(SUM(quantity), 0)') + ->whereColumn('order_lines.product_id', 'products.id'); + } + + /** + * Distinct non-null values of a product attribute within this collection. + * + * @return list + */ + private function availableFilterValues(string $column): array + { + return $this->collection->products() + ->visible() + ->whereNotNull("products.{$column}") + ->distinct() + ->reorder("products.{$column}") + ->pluck("products.{$column}") + ->all(); + } + + /** + * Human-readable list of the currently active filters. + * + * @return list + */ + private function activeFilters(): array + { + $active = []; + + if ($this->inStock) { + $active[] = 'In stock'; + } + if ($this->priceToCents($this->minPrice) !== null) { + $active[] = "Min {$this->minPrice}"; + } + if ($this->priceToCents($this->maxPrice) !== null) { + $active[] = "Max {$this->maxPrice}"; + } + foreach ($this->types as $type) { + $active[] = "Type: {$type}"; + } + foreach ($this->vendors as $vendor) { + $active[] = "Vendor: {$vendor}"; + } + + return $active; + } + + /** + * Convert a user-entered major-unit price into cents. + */ + private function priceToCents(?string $price): ?int + { + if ($price === null || trim($price) === '' || ! is_numeric($price)) { + return null; + } + + return max(0, (int) round(((float) $price) * 100)); + } +} diff --git a/app/Livewire/Storefront/Concerns/InteractsWithCart.php b/app/Livewire/Storefront/Concerns/InteractsWithCart.php new file mode 100644 index 00000000..85a9d481 --- /dev/null +++ b/app/Livewire/Storefront/Concerns/InteractsWithCart.php @@ -0,0 +1,194 @@ +changeLineQuantity($lineId, 1); + } + + /** + * Decrement a line's quantity by one (removes the line at zero). + */ + public function decrementLine(int $lineId): void + { + $this->changeLineQuantity($lineId, -1); + } + + /** + * Remove a line from the session cart. + */ + public function removeLine(int $lineId): void + { + $cart = $this->sessionCart(); + + if ($cart === null) { + return; + } + + app(CartService::class)->removeLine($cart, $lineId); + $this->broadcastCartCount($cart->refresh()); + } + + /** + * Validate the entered discount code and store it in the session. + */ + public function applyDiscount(): void + { + $this->discountError = null; + $cart = $this->sessionCart(); + $code = trim($this->discountCode); + + if ($cart === null || $code === '') { + return; + } + + $result = app(DiscountService::class)->validate($code, $this->currentStore(), $cart); + + if (! $result->valid) { + $this->discountError = $result->errorMessage; + + return; + } + + session(['discount_code' => $result->discount->code]); + $this->discountCode = ''; + } + + /** + * Remove the session discount code. + */ + public function removeDiscount(): void + { + session()->forget('discount_code'); + $this->discountError = null; + } + + /** + * Change a line quantity by a delta, ignoring stock rejections. + */ + private function changeLineQuantity(int $lineId, int $delta): void + { + $cart = $this->sessionCart(); + $line = $cart?->lines->firstWhere('id', $lineId); + + if ($cart === null || $line === null) { + return; + } + + try { + app(CartService::class)->updateLineQuantity($cart, $lineId, $line->quantity + $delta); + } catch (InsufficientInventoryException|ValidationException) { + return; + } + + $this->broadcastCartCount($cart->refresh()); + } + + /** + * The active cart bound to the session (not created on demand). + */ + protected function sessionCart(): ?Cart + { + return app(CartService::class)->findForSession($this->currentStore()); + } + + /** + * The store resolved for the current request. + */ + protected function currentStore(): Store + { + return app('current_store'); + } + + /** + * Tell the header badge the new item count. + */ + protected function broadcastCartCount(Cart $cart): void + { + $this->dispatch('cart-updated', count: $cart->itemCount()); + } + + /** + * The validated session discount with its calculated amount, if any. + * + * @return array{code: string, label: string, amount: int, free_shipping: bool}|null + */ + protected function appliedSessionDiscount(Cart $cart): ?array + { + $code = session('discount_code'); + + if ($code === null) { + return null; + } + + $discount = Discount::query() + ->where('store_id', $this->currentStore()->id) + ->whereRaw('lower(code) = ?', [mb_strtolower($code)]) + ->first(); + + if ($discount === null) { + session()->forget('discount_code'); + + return null; + } + + $result = app(DiscountService::class)->calculate( + $discount, + $cart->subtotal(), + $this->calculationLines($cart), + ); + + $label = match ($discount->value_type) { + \App\Enums\DiscountValueType::Percent => "-{$discount->value_amount}%", + \App\Enums\DiscountValueType::Fixed => '-'.\App\Support\Money::format($result['amount'], $cart->currency), + \App\Enums\DiscountValueType::FreeShipping => 'Free shipping', + }; + + return [ + 'code' => $discount->code, + 'label' => $label, + 'amount' => $result['amount'], + 'free_shipping' => $result['free_shipping'], + ]; + } + + /** + * Flat calculation representation of the cart lines for discounts. + * + * @return array> + */ + protected function calculationLines(Cart $cart): array + { + $cart->loadMissing('lines.variant.product.collections'); + + return $cart->lines->values()->map(fn ($line): array => [ + 'variant_id' => $line->variant_id, + 'product_id' => $line->variant?->product_id, + 'collection_ids' => $line->variant?->product?->collections->pluck('id')->all() ?? [], + 'line_subtotal_amount' => $line->line_subtotal_amount, + ])->all(); + } +} diff --git a/app/Livewire/Storefront/Concerns/MergesGuestCartOnLogin.php b/app/Livewire/Storefront/Concerns/MergesGuestCartOnLogin.php new file mode 100644 index 00000000..d892082f --- /dev/null +++ b/app/Livewire/Storefront/Concerns/MergesGuestCartOnLogin.php @@ -0,0 +1,42 @@ +findForSession($store); + + if ($guest === null || $guest->customer_id === $customer->id) { + return; + } + + $customerCart = Cart::query() + ->where('store_id', $store->id) + ->where('customer_id', $customer->id) + ->where('status', CartStatus::Active) + ->latest('id') + ->first() ?? $cartService->create($store, $customer); + + $cartService->mergeOnLogin($guest, $customerCart); + + session(['cart_id' => $customerCart->id]); + } +} diff --git a/app/Livewire/Storefront/Home.php b/app/Livewire/Storefront/Home.php new file mode 100644 index 00000000..20746d90 --- /dev/null +++ b/app/Livewire/Storefront/Home.php @@ -0,0 +1,93 @@ +get('sections_order', [])) + ->filter(fn (string $section): bool => (bool) $settings->get("{$section}.enabled", false)) + ->values(); + + return view('livewire.storefront.home', [ + 'sections' => $sections, + 'hero' => $settings->get('hero', []), + 'featuredCollections' => $sections->contains('featured_collections') + ? $this->featuredCollections($settings) + : collect(), + 'featuredProducts' => $sections->contains('featured_products') + ? $this->featuredProducts($settings) + : collect(), + 'richTextHtml' => $sections->contains('rich_text') ? $settings->get('rich_text.html') : null, + ]) + ->layout('storefront.layouts.app', [ + 'metaDescription' => $settings->get('seo.description'), + ]) + ->title(app('current_store')->name); + } + + /** + * Collections picked in theme settings, falling back to the newest ones. + * + * @return SupportCollection + */ + private function featuredCollections(ThemeSettingsService $settings): SupportCollection + { + $handles = array_filter($settings->get('featured_collections.collection_handles', [])); + $count = max(1, (int) $settings->get('featured_collections.count', 3)); + + $query = Collection::query()->where('status', CollectionStatus::Active); + + if ($handles !== []) { + return $query->whereIn('handle', $handles)->limit($count)->get(); + } + + return $query->latest()->limit($count)->get(); + } + + /** + * Products from the configured collection, falling back to the newest + * visible products of the store. + * + * @return SupportCollection + */ + private function featuredProducts(ThemeSettingsService $settings): SupportCollection + { + $count = max(1, (int) $settings->get('featured_products.count', 8)); + $collectionHandle = $settings->get('featured_products.collection_handle'); + + if (is_string($collectionHandle) && $collectionHandle !== '') { + $collection = Collection::query() + ->where('handle', $collectionHandle) + ->where('status', CollectionStatus::Active) + ->first(); + + if ($collection !== null) { + return $collection->products() + ->visible() + ->with(['variants.inventoryItem', 'media']) + ->limit($count) + ->get(); + } + } + + return Product::query() + ->visible() + ->with(['variants.inventoryItem', 'media']) + ->latest() + ->limit($count) + ->get(); + } +} diff --git a/app/Livewire/Storefront/Pages/Show.php b/app/Livewire/Storefront/Pages/Show.php new file mode 100644 index 00000000..adf4cb3f --- /dev/null +++ b/app/Livewire/Storefront/Pages/Show.php @@ -0,0 +1,37 @@ +page = Page::query() + ->published() + ->where('handle', $handle) + ->firstOrFail(); + } + + /** + * Render the content page. + */ + public function render(): View + { + $storeName = app('current_store')->name; + + return view('livewire.storefront.pages.show') + ->layout('storefront.layouts.app', [ + 'metaDescription' => str()->limit(trim(strip_tags($this->page->body_html ?? '')), 160, ''), + ]) + ->title("{$this->page->title} - {$storeName}"); + } +} diff --git a/app/Livewire/Storefront/Products/Show.php b/app/Livewire/Storefront/Products/Show.php new file mode 100644 index 00000000..31022108 --- /dev/null +++ b/app/Livewire/Storefront/Products/Show.php @@ -0,0 +1,236 @@ + + */ + public array $selectedOptions = []; + + public int $quantity = 1; + + /** + * Resolve the product by handle; only visible products are reachable. + */ + public function mount(string $handle): void + { + $this->product = Product::query() + ->visible() + ->where('handle', $handle) + ->with(['media', 'options.values', 'variants.optionValues', 'variants.inventoryItem', 'collections']) + ->firstOrFail(); + + // Preselect the default variant's option values. + $default = $this->product->variants->firstWhere('is_default', true) ?? $this->product->variants->first(); + + foreach ($this->product->options as $option) { + $this->selectedOptions[$option->name] = $default?->optionValues + ->firstWhere('product_option_id', $option->id)?->value; + } + } + + /** + * The variant matching all currently selected options, if any. + */ + #[Computed] + public function selectedVariant(): ?ProductVariant + { + return $this->product->variants->first(function (ProductVariant $variant): bool { + foreach ($this->product->options as $option) { + $selected = $this->selectedOptions[$option->name] ?? null; + $variantValue = $variant->optionValues->firstWhere('product_option_id', $option->id)?->value; + + if ($selected !== $variantValue) { + return false; + } + } + + return true; + }); + } + + /** + * Whether a value of the given option leads to a purchasable variant + * given the other current selections. + */ + public function isValueAvailable(ProductOption $option, string $value): bool + { + return $this->product->variants->contains(function (ProductVariant $variant) use ($option, $value): bool { + $matches = $variant->optionValues->firstWhere('product_option_id', $option->id)?->value === $value; + + if (! $matches) { + return false; + } + + foreach ($this->product->options as $otherOption) { + if ($otherOption->id === $option->id) { + continue; + } + + $selected = $this->selectedOptions[$otherOption->name] ?? null; + + if ($selected !== null && $variant->optionValues->firstWhere('product_option_id', $otherOption->id)?->value !== $selected) { + return false; + } + } + + return $variant->isInStock() || $variant->isBackorderable(); + }); + } + + /** + * Stock state of the selected variant for messaging and purchase rules. + * + * @return array{state: string, message: string, purchasable: bool, max: int|null} + */ + public function stockState(): array + { + $variant = $this->selectedVariant(); + + if ($variant === null) { + return ['state' => 'unavailable', 'message' => 'Unavailable', 'purchasable' => false, 'max' => null]; + } + + $available = $variant->availableQuantity(); + + if ($available > 10) { + return ['state' => 'in_stock', 'message' => 'In stock', 'purchasable' => true, 'max' => $this->maxQuantity($variant, $available)]; + } + + if ($available > 0) { + return ['state' => 'low_stock', 'message' => "Only {$available} left in stock", 'purchasable' => true, 'max' => $this->maxQuantity($variant, $available)]; + } + + if ($variant->inventoryItem?->policy === InventoryPolicy::Continue) { + return ['state' => 'backorder', 'message' => 'Available on backorder', 'purchasable' => true, 'max' => null]; + } + + return ['state' => 'out_of_stock', 'message' => 'Out of stock', 'purchasable' => false, 'max' => null]; + } + + /** + * Select an option value and keep the quantity within bounds. + */ + public function selectOption(string $optionName, string $value): void + { + $this->selectedOptions[$optionName] = $value; + $this->clampQuantity(); + } + + /** + * Add the selected variant to the session cart, update the header + * badge and open the cart drawer. + */ + public function addToCart(): void + { + $variant = $this->selectedVariant(); + + if ($variant === null || (! $variant->isInStock() && ! $variant->isBackorderable())) { + return; + } + + $carts = app(\App\Services\CartService::class); + $cart = $carts->getOrCreateForSession(app('current_store'), auth('customer')->user()); + + try { + $carts->addLine($cart, $variant->id, max(1, $this->quantity)); + } catch (\App\Exceptions\InsufficientInventoryException|\Illuminate\Validation\ValidationException) { + return; + } + + $this->dispatch('cart-updated', count: $cart->refresh()->itemCount()); + $this->dispatch('cart-drawer-open'); + } + + /** + * Render the product page. + */ + public function render(): View + { + $variant = $this->selectedVariant(); + $storeName = app('current_store')->name; + $currency = $variant?->currency ?? $this->product->variants->first()?->currency + ?? app('current_store')->default_currency; + $primaryImage = $this->product->media->first(); + $metaDescription = str()->limit(trim(strip_tags($this->product->description_html ?? '')), 160, ''); + + return view('livewire.storefront.products.show', [ + 'currency' => $currency, + 'stock' => $this->stockState(), + 'jsonLd' => $this->jsonLd($variant, $primaryImage?->url()), + ]) + ->layout('storefront.layouts.app', [ + 'metaDescription' => $metaDescription, + 'og' => array_filter([ + 'title' => $this->product->title, + 'description' => $metaDescription, + 'image' => $primaryImage?->url(), + 'type' => 'product', + 'price_amount' => $variant !== null ? number_format($variant->price_amount / 100, 2, '.', '') : null, + 'price_currency' => $currency, + ]), + ]) + ->title("{$this->product->title} - {$storeName}"); + } + + /** + * JSON-LD structured data for the product (spec 04 §19). + * + * @return array + */ + private function jsonLd(?ProductVariant $variant, ?string $imageUrl): array + { + return array_filter([ + '@context' => 'https://schema.org', + '@type' => 'Product', + 'name' => $this->product->title, + 'description' => trim(strip_tags($this->product->description_html ?? '')), + 'image' => $imageUrl, + 'brand' => $this->product->vendor !== null ? ['@type' => 'Brand', 'name' => $this->product->vendor] : null, + 'offers' => $variant !== null ? [ + '@type' => 'Offer', + 'price' => number_format($variant->price_amount / 100, 2, '.', ''), + 'priceCurrency' => $variant->currency, + 'availability' => $variant->isInStock() || $variant->isBackorderable() + ? 'https://schema.org/InStock' + : 'https://schema.org/OutOfStock', + ] : null, + ]); + } + + /** + * Maximum selectable quantity for the variant. + */ + private function maxQuantity(ProductVariant $variant, int $available): ?int + { + return $variant->inventoryItem?->policy === InventoryPolicy::Deny ? $available : null; + } + + /** + * Keep the quantity within the bounds of the selected variant. + */ + private function clampQuantity(): void + { + $max = $this->stockState()['max']; + + if ($max !== null) { + $this->quantity = min($this->quantity, $max); + } + + $this->quantity = max(1, $this->quantity); + } +} diff --git a/app/Livewire/Storefront/Search/Index.php b/app/Livewire/Storefront/Search/Index.php new file mode 100644 index 00000000..4085ebc4 --- /dev/null +++ b/app/Livewire/Storefront/Search/Index.php @@ -0,0 +1,159 @@ + + */ + public const SORT_OPTIONS = [ + 'relevance' => 'Relevance', + 'price_asc' => 'Price: Low to High', + 'price_desc' => 'Price: High to Low', + 'newest' => 'Newest', + 'best_selling' => 'Best Selling', + ]; + + #[Url(as: 'q')] + public string $query = ''; + + public string $sort = 'relevance'; + + public bool $inStock = false; + + public ?string $minPrice = null; + + public ?string $maxPrice = null; + + public ?int $collectionId = null; + + /** @var list */ + public array $vendors = []; + + /** + * Any filter or sort change returns to the first page. + */ + public function updated(): void + { + $this->resetPage(); + } + + /** + * Reset every filter to its default. + */ + public function clearFilters(): void + { + $this->reset(['inStock', 'minPrice', 'maxPrice', 'collectionId', 'vendors']); + $this->resetPage(); + } + + /** + * Render the results page. + */ + public function render(): View + { + /** @var Store $store */ + $store = app('current_store'); + + $products = null; + $facets = null; + + if (trim($this->query) !== '') { + $service = app(SearchService::class); + + $products = $service->search($store, $this->query, $this->filters(), self::PER_PAGE, $this->sort); + $facets = $service->facets($store, $this->query); + } + + return view('livewire.storefront.search.index', [ + 'products' => $products, + 'facets' => $facets, + 'sortOptions' => self::SORT_OPTIONS, + 'availableCollections' => Collection::query() + ->where('status', CollectionStatus::Active) + ->orderBy('title') + ->get(['id', 'title']), + 'activeFilters' => $this->activeFilters(), + ]) + ->layout('storefront.layouts.app') + ->title("Search - {$store->name}"); + } + + /** + * Active filters in the service's filter schema (spec 02 §2.5). + * + * @return array + */ + private function filters(): array + { + return array_filter([ + 'collection_id' => $this->collectionId, + 'price_min' => $this->priceToCents($this->minPrice), + 'price_max' => $this->priceToCents($this->maxPrice), + 'in_stock' => $this->inStock ?: null, + 'vendor' => $this->vendors !== [] ? $this->vendors : null, + ], fn ($value): bool => $value !== null); + } + + /** + * Human-readable list of the currently active filters. + * + * @return list + */ + private function activeFilters(): array + { + $active = []; + + if ($this->inStock) { + $active[] = 'In stock'; + } + if ($this->priceToCents($this->minPrice) !== null) { + $active[] = "Min {$this->minPrice}"; + } + if ($this->priceToCents($this->maxPrice) !== null) { + $active[] = "Max {$this->maxPrice}"; + } + if ($this->collectionId !== null) { + $active[] = 'Collection filter'; + } + foreach ($this->vendors as $vendor) { + $active[] = "Vendor: {$vendor}"; + } + + return $active; + } + + /** + * Convert a user-entered major-unit price into cents. + */ + private function priceToCents(?string $price): ?int + { + if ($price === null || trim($price) === '' || ! is_numeric($price)) { + return null; + } + + return max(0, (int) round(((float) $price) * 100)); + } +} diff --git a/app/Livewire/Storefront/Search/Modal.php b/app/Livewire/Storefront/Search/Modal.php new file mode 100644 index 00000000..0bfddb2c --- /dev/null +++ b/app/Livewire/Storefront/Search/Modal.php @@ -0,0 +1,53 @@ +redirectRoute('storefront.search', ['q' => $this->query]); + } + + /** + * Render the modal with autocomplete suggestions for the current query. + */ + public function render(): View + { + $suggestions = collect(); + + if (trim($this->query) !== '') { + /** @var Store $store */ + $store = app('current_store'); + + $suggestions = app(SearchService::class)->autocomplete($store, $this->query, self::LIMIT); + } + + return view('livewire.storefront.search.modal', [ + 'suggestions' => $suggestions, + 'products' => $suggestions->where('type', 'product'), + 'collections' => $suggestions->where('type', 'collection'), + 'pastQueries' => $suggestions->where('type', 'query'), + ]); + } +} diff --git a/app/Mail/OrderCancelledMail.php b/app/Mail/OrderCancelledMail.php new file mode 100644 index 00000000..b46996c8 --- /dev/null +++ b/app/Mail/OrderCancelledMail.php @@ -0,0 +1,53 @@ +order->order_number} cancelled", + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + return new Content( + markdown: 'emails.orders.cancelled', + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments(): array + { + return []; + } +} diff --git a/app/Mail/OrderConfirmationMail.php b/app/Mail/OrderConfirmationMail.php new file mode 100644 index 00000000..1016a5df --- /dev/null +++ b/app/Mail/OrderConfirmationMail.php @@ -0,0 +1,52 @@ +order->order_number} confirmed", + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + return new Content( + markdown: 'emails.orders.confirmation', + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments(): array + { + return []; + } +} diff --git a/app/Mail/OrderRefundedMail.php b/app/Mail/OrderRefundedMail.php new file mode 100644 index 00000000..2c95b0c9 --- /dev/null +++ b/app/Mail/OrderRefundedMail.php @@ -0,0 +1,55 @@ +order->order_number}", + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + return new Content( + markdown: 'emails.orders.refunded', + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments(): array + { + return []; + } +} diff --git a/app/Mail/OrderShippedMail.php b/app/Mail/OrderShippedMail.php new file mode 100644 index 00000000..568091d3 --- /dev/null +++ b/app/Mail/OrderShippedMail.php @@ -0,0 +1,55 @@ +order->order_number} has shipped", + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + return new Content( + markdown: 'emails.orders.shipped', + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments(): array + { + return []; + } +} diff --git a/app/Models/AnalyticsDaily.php b/app/Models/AnalyticsDaily.php new file mode 100644 index 00000000..257f0ca6 --- /dev/null +++ b/app/Models/AnalyticsDaily.php @@ -0,0 +1,77 @@ + */ + use BelongsToStore, HasFactory; + + /** + * The table name (not the plural Eloquent would guess). + * + * @var string + */ + protected $table = 'analytics_daily'; + + /** + * The table has a composite primary key (store_id + date). + * + * @var string|null + */ + protected $primaryKey = null; + + /** + * The composite primary key is not auto-incrementing. + * + * @var bool + */ + public $incrementing = false; + + /** + * The table has no timestamp columns. + * + * @var bool + */ + public $timestamps = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'date', + 'orders_count', + 'revenue_amount', + 'aov_amount', + 'visits_count', + 'add_to_cart_count', + 'checkout_started_count', + 'checkout_completed_count', + ]; + + /** + * Get the attributes that should be cast. The date column stays a plain + * Y-m-d string (no cast) so range comparisons stay lexicographic. + * + * @return array + */ + protected function casts(): array + { + return [ + 'orders_count' => 'integer', + 'revenue_amount' => 'integer', + 'aov_amount' => 'integer', + 'visits_count' => 'integer', + 'add_to_cart_count' => 'integer', + 'checkout_started_count' => 'integer', + 'checkout_completed_count' => 'integer', + ]; + } +} diff --git a/app/Models/AnalyticsEvent.php b/app/Models/AnalyticsEvent.php new file mode 100644 index 00000000..c0335ce9 --- /dev/null +++ b/app/Models/AnalyticsEvent.php @@ -0,0 +1,49 @@ + */ + use BelongsToStore, HasFactory; + + /** + * The table only has a created_at column. + * + * @var string|null + */ + const UPDATED_AT = null; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'type', + 'session_id', + 'customer_id', + 'properties_json', + 'client_event_id', + 'occurred_at', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'properties_json' => 'array', + 'occurred_at' => 'datetime', + 'created_at' => 'datetime', + ]; + } +} diff --git a/app/Models/App.php b/app/Models/App.php new file mode 100644 index 00000000..d30d96b4 --- /dev/null +++ b/app/Models/App.php @@ -0,0 +1,62 @@ + */ + use HasFactory; + + /** + * The table only has a created_at column. + * + * @var string|null + */ + public const UPDATED_AT = null; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'name', + 'status', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'created_at' => 'datetime', + ]; + } + + /** + * Get the installations of the app across stores. + * + * @return HasMany + */ + public function installations(): HasMany + { + return $this->hasMany(AppInstallation::class); + } + + /** + * Get the OAuth clients registered for the app. + * + * @return HasMany + */ + public function oauthClients(): HasMany + { + return $this->hasMany(OauthClient::class); + } +} diff --git a/app/Models/AppInstallation.php b/app/Models/AppInstallation.php new file mode 100644 index 00000000..afb809b4 --- /dev/null +++ b/app/Models/AppInstallation.php @@ -0,0 +1,78 @@ + */ + use BelongsToStore, HasFactory; + + /** + * The table has no timestamp columns. + * + * @var bool + */ + public $timestamps = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'app_id', + 'scopes_json', + 'status', + 'installed_at', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'scopes_json' => 'array', + 'installed_at' => 'datetime', + ]; + } + + /** + * Get the app this installation belongs to. + * + * @return BelongsTo + */ + public function app(): BelongsTo + { + return $this->belongsTo(App::class); + } + + /** + * Get the webhook subscriptions linked to the installation. + * + * @return HasMany + */ + public function webhookSubscriptions(): HasMany + { + return $this->hasMany(WebhookSubscription::class, 'app_installation_id'); + } + + /** + * Get the OAuth tokens issued for the installation. + * + * @return HasMany + */ + public function oauthTokens(): HasMany + { + return $this->hasMany(OauthToken::class, 'installation_id'); + } +} diff --git a/app/Models/Cart.php b/app/Models/Cart.php new file mode 100644 index 00000000..061253cb --- /dev/null +++ b/app/Models/Cart.php @@ -0,0 +1,122 @@ + */ + use BelongsToStore, HasFactory; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'customer_id', + 'currency', + 'cart_version', + 'status', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'status' => CartStatus::class, + 'cart_version' => 'integer', + ]; + } + + /** + * Get the customer that owns the cart. + * + * @return BelongsTo + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** + * Get the lines in the cart. + * + * @return HasMany + */ + public function lines(): HasMany + { + return $this->hasMany(CartLine::class); + } + + /** + * Get the checkouts created from the cart. + * + * @return HasMany + */ + public function checkouts(): HasMany + { + return $this->hasMany(Checkout::class); + } + + /** + * Sum of all line subtotals (before discounts), in minor units. + */ + public function subtotal(): int + { + return (int) $this->lines->sum('line_subtotal_amount'); + } + + /** + * Total number of units across all lines. + */ + public function itemCount(): int + { + return (int) $this->lines->sum('quantity'); + } + + /** + * Number of distinct lines in the cart. + */ + public function lineCount(): int + { + return $this->lines->count(); + } + + /** + * Find a line by variant ID. + */ + public function findLineByVariant(int $variantId): ?CartLine + { + return $this->lines->firstWhere('variant_id', $variantId); + } + + /** + * Recalculate subtotal/total amounts on every line. + */ + public function recalculateLines(): void + { + $this->lines->each->recalculate(); + } + + /** + * Whether any line in the cart requires physical shipping. + */ + public function requiresShipping(): bool + { + return $this->lines->contains( + fn (CartLine $line): bool => (bool) $line->variant?->requires_shipping + ); + } +} diff --git a/app/Models/CartLine.php b/app/Models/CartLine.php new file mode 100644 index 00000000..288f1aca --- /dev/null +++ b/app/Models/CartLine.php @@ -0,0 +1,81 @@ + */ + use HasFactory; + + /** + * The table has no timestamp columns. + * + * @var bool + */ + public $timestamps = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'cart_id', + 'variant_id', + 'quantity', + 'unit_price_amount', + 'line_subtotal_amount', + 'line_discount_amount', + 'line_total_amount', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'quantity' => 'integer', + 'unit_price_amount' => 'integer', + 'line_subtotal_amount' => 'integer', + 'line_discount_amount' => 'integer', + 'line_total_amount' => 'integer', + ]; + } + + /** + * Get the cart that owns the line. + * + * @return BelongsTo + */ + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + /** + * Get the variant being purchased. + * + * @return BelongsTo + */ + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + /** + * Recalculate the line amounts from unit price, quantity and discount. + */ + public function recalculate(): void + { + $this->line_subtotal_amount = $this->unit_price_amount * $this->quantity; + $this->line_total_amount = $this->line_subtotal_amount - $this->line_discount_amount; + $this->save(); + } +} diff --git a/app/Models/Checkout.php b/app/Models/Checkout.php new file mode 100644 index 00000000..a33647c1 --- /dev/null +++ b/app/Models/Checkout.php @@ -0,0 +1,99 @@ + */ + use BelongsToStore, HasFactory; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'cart_id', + 'customer_id', + 'status', + 'payment_method', + 'email', + 'shipping_address_json', + 'billing_address_json', + 'shipping_method_id', + 'discount_code', + 'tax_provider_snapshot_json', + 'totals_json', + 'expires_at', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'status' => CheckoutStatus::class, + 'payment_method' => PaymentMethod::class, + 'shipping_address_json' => 'array', + 'billing_address_json' => 'array', + 'tax_provider_snapshot_json' => 'array', + 'totals_json' => 'array', + 'expires_at' => 'datetime', + ]; + } + + /** + * Get the cart backing the checkout. + * + * @return BelongsTo + */ + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + /** + * Get the customer that owns the checkout. + * + * @return BelongsTo + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** + * Whether any cart line requires physical shipping. + */ + public function requiresShipping(): bool + { + return $this->cart->requiresShipping(); + } + + /** + * Whether the checkout is expired (either transitioned or past its deadline). + */ + public function isExpired(): bool + { + if ($this->status === CheckoutStatus::Expired) { + return true; + } + + if ($this->status === CheckoutStatus::Completed) { + return false; + } + + return $this->expires_at !== null && $this->expires_at->isPast(); + } +} diff --git a/app/Models/Collection.php b/app/Models/Collection.php new file mode 100644 index 00000000..4f14797a --- /dev/null +++ b/app/Models/Collection.php @@ -0,0 +1,55 @@ + */ + use BelongsToStore, HasFactory; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'description_html', + 'type', + 'status', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'type' => CollectionType::class, + 'status' => CollectionStatus::class, + ]; + } + + /** + * Get the products in the collection, ordered by position. + * + * @return BelongsToMany + */ + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'collection_products') + ->withPivot('position') + ->orderBy('collection_products.position'); + } +} diff --git a/app/Models/Concerns/BelongsToStore.php b/app/Models/Concerns/BelongsToStore.php new file mode 100644 index 00000000..5a405dea --- /dev/null +++ b/app/Models/Concerns/BelongsToStore.php @@ -0,0 +1,36 @@ +getAttribute('store_id')) && app()->bound('current_store')) { + $model->setAttribute('store_id', app('current_store')->getKey()); + } + }); + } + + /** + * Get the store that owns the model. + * + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php new file mode 100644 index 00000000..94b9306c --- /dev/null +++ b/app/Models/Customer.php @@ -0,0 +1,88 @@ + */ + use BelongsToStore, HasFactory, Notifiable; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'email', + 'password_hash', + 'name', + 'marketing_opt_in', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var list + */ + protected $hidden = [ + 'password_hash', + 'remember_token', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'password_hash' => 'hashed', + 'marketing_opt_in' => 'boolean', + ]; + } + + /** + * Get the password for authentication (custom column name). + */ + public function getAuthPassword(): string + { + return $this->password_hash ?? ''; + } + + /** + * Get the saved addresses for the customer. + * + * @return HasMany + */ + public function addresses(): HasMany + { + return $this->hasMany(CustomerAddress::class); + } + + /** + * Get the orders placed by the customer. + * + * @return HasMany + */ + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + /** + * Get the carts owned by the customer. + * + * @return HasMany + */ + public function carts(): HasMany + { + return $this->hasMany(Cart::class); + } +} diff --git a/app/Models/CustomerAddress.php b/app/Models/CustomerAddress.php new file mode 100644 index 00000000..6790776f --- /dev/null +++ b/app/Models/CustomerAddress.php @@ -0,0 +1,55 @@ + */ + use HasFactory; + + /** + * The table has no timestamp columns. + * + * @var bool + */ + public $timestamps = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'customer_id', + 'label', + 'address_json', + 'is_default', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'address_json' => 'array', + 'is_default' => 'boolean', + ]; + } + + /** + * Get the customer that owns the address. + * + * @return BelongsTo + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/Discount.php b/app/Models/Discount.php new file mode 100644 index 00000000..cf55e8e9 --- /dev/null +++ b/app/Models/Discount.php @@ -0,0 +1,66 @@ + */ + use BelongsToStore, HasFactory; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'type', + 'code', + 'value_type', + 'value_amount', + 'starts_at', + 'ends_at', + 'usage_limit', + 'usage_count', + 'rules_json', + 'status', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'type' => DiscountType::class, + 'value_type' => DiscountValueType::class, + 'status' => DiscountStatus::class, + 'rules_json' => 'array', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'value_amount' => 'integer', + 'usage_limit' => 'integer', + 'usage_count' => 'integer', + ]; + } + + /** + * Scope to active discounts. + * + * @param Builder $query + */ + protected function scopeActive(Builder $query): void + { + $query->where('status', DiscountStatus::Active); + } +} diff --git a/app/Models/Fulfillment.php b/app/Models/Fulfillment.php new file mode 100644 index 00000000..cd09c185 --- /dev/null +++ b/app/Models/Fulfillment.php @@ -0,0 +1,67 @@ + */ + use HasFactory; + + /** + * The table only has created_at, no updated_at. + */ + public const UPDATED_AT = null; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'order_id', + 'status', + 'tracking_company', + 'tracking_number', + 'tracking_url', + 'shipped_at', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'status' => FulfillmentShipmentStatus::class, + 'shipped_at' => 'datetime', + ]; + } + + /** + * Get the order the fulfillment belongs to. + * + * @return BelongsTo + */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** + * Get the lines included in the fulfillment. + * + * @return HasMany + */ + public function lines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } +} diff --git a/app/Models/FulfillmentLine.php b/app/Models/FulfillmentLine.php new file mode 100644 index 00000000..b06ac79c --- /dev/null +++ b/app/Models/FulfillmentLine.php @@ -0,0 +1,63 @@ + */ + use HasFactory; + + /** + * The table has no timestamp columns. + * + * @var bool + */ + public $timestamps = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'fulfillment_id', + 'order_line_id', + 'quantity', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'quantity' => 'integer', + ]; + } + + /** + * Get the fulfillment the line belongs to. + * + * @return BelongsTo + */ + public function fulfillment(): BelongsTo + { + return $this->belongsTo(Fulfillment::class); + } + + /** + * Get the order line being fulfilled. + * + * @return BelongsTo + */ + public function orderLine(): BelongsTo + { + return $this->belongsTo(OrderLine::class); + } +} diff --git a/app/Models/InventoryItem.php b/app/Models/InventoryItem.php new file mode 100644 index 00000000..c96de366 --- /dev/null +++ b/app/Models/InventoryItem.php @@ -0,0 +1,65 @@ + */ + use BelongsToStore, HasFactory; + + /** + * The table has no timestamp columns. + * + * @var bool + */ + public $timestamps = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'variant_id', + 'quantity_on_hand', + 'quantity_reserved', + 'policy', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'policy' => InventoryPolicy::class, + ]; + } + + /** + * Get the variant tracked by this inventory item. + * + * @return BelongsTo + */ + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + /** + * Available stock: on hand minus reserved. + */ + public function available(): int + { + return $this->quantity_on_hand - $this->quantity_reserved; + } +} diff --git a/app/Models/NavigationItem.php b/app/Models/NavigationItem.php new file mode 100644 index 00000000..a8f9c9ae --- /dev/null +++ b/app/Models/NavigationItem.php @@ -0,0 +1,82 @@ + */ + use HasFactory; + + /** + * The table has no timestamp columns. + * + * @var bool + */ + public $timestamps = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'menu_id', + 'type', + 'label', + 'url', + 'resource_id', + 'position', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'type' => NavigationItemType::class, + ]; + } + + /** + * Register model event hooks. + */ + protected static function booted(): void + { + // Keep the cached navigation tree of the parent menu in sync. + $flush = fn (NavigationItem $item) => $item->flushMenuCache(); + + static::saved($flush); + static::deleted($flush); + } + + /** + * Get the menu the item belongs to. + * + * @return BelongsTo + */ + public function menu(): BelongsTo + { + return $this->belongsTo(NavigationMenu::class, 'menu_id'); + } + + /** + * Invalidate the cached tree of the parent menu. + */ + protected function flushMenuCache(): void + { + $menu = $this->relationLoaded('menu') ? $this->menu : $this->menu()->first(); + + if ($menu !== null) { + app(NavigationService::class)->invalidate($menu->store_id, $menu->handle); + } + } +} diff --git a/app/Models/NavigationMenu.php b/app/Models/NavigationMenu.php new file mode 100644 index 00000000..70a7600b --- /dev/null +++ b/app/Models/NavigationMenu.php @@ -0,0 +1,48 @@ + */ + use BelongsToStore, HasFactory; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'handle', + 'title', + ]; + + /** + * Register model event hooks. + */ + protected static function booted(): void + { + // Keep the cached navigation tree of this menu in sync. + $flush = fn (NavigationMenu $menu) => app(NavigationService::class)->invalidate($menu->store_id, $menu->handle); + + static::saved($flush); + static::deleted($flush); + } + + /** + * Get the items of the menu in display order. + * + * @return HasMany + */ + public function items(): HasMany + { + return $this->hasMany(NavigationItem::class, 'menu_id')->orderBy('position'); + } +} diff --git a/app/Models/OauthClient.php b/app/Models/OauthClient.php new file mode 100644 index 00000000..30457f82 --- /dev/null +++ b/app/Models/OauthClient.php @@ -0,0 +1,55 @@ + */ + use HasFactory; + + /** + * The table has no timestamp columns. + * + * @var bool + */ + public $timestamps = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'app_id', + 'client_id', + 'client_secret_encrypted', + 'redirect_uris_json', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'client_secret_encrypted' => 'encrypted', + 'redirect_uris_json' => 'array', + ]; + } + + /** + * Get the app the client belongs to. + * + * @return BelongsTo + */ + public function app(): BelongsTo + { + return $this->belongsTo(App::class); + } +} diff --git a/app/Models/OauthToken.php b/app/Models/OauthToken.php new file mode 100644 index 00000000..64d4fbd7 --- /dev/null +++ b/app/Models/OauthToken.php @@ -0,0 +1,54 @@ + */ + use HasFactory; + + /** + * The table has no timestamp columns. + * + * @var bool + */ + public $timestamps = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'installation_id', + 'access_token_hash', + 'refresh_token_hash', + 'expires_at', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'expires_at' => 'datetime', + ]; + } + + /** + * Get the installation the token was issued for. + * + * @return BelongsTo + */ + public function installation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class, 'installation_id'); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 00000000..cf7288e8 --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,172 @@ + */ + use BelongsToStore, HasFactory; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'checkout_id', + 'customer_id', + 'order_number', + 'payment_method', + 'status', + 'financial_status', + 'fulfillment_status', + 'currency', + 'subtotal_amount', + 'discount_amount', + 'shipping_amount', + 'tax_amount', + 'total_amount', + 'email', + 'billing_address_json', + 'shipping_address_json', + 'placed_at', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'status' => OrderStatus::class, + 'financial_status' => FinancialStatus::class, + 'fulfillment_status' => FulfillmentOrderStatus::class, + 'payment_method' => PaymentMethod::class, + 'billing_address_json' => 'array', + 'shipping_address_json' => 'array', + 'placed_at' => 'datetime', + 'subtotal_amount' => 'integer', + 'discount_amount' => 'integer', + 'shipping_amount' => 'integer', + 'tax_amount' => 'integer', + 'total_amount' => 'integer', + ]; + } + + /** + * Get the checkout the order was created from. + * + * @return BelongsTo + */ + public function checkout(): BelongsTo + { + return $this->belongsTo(Checkout::class); + } + + /** + * Get the customer that placed the order (null for unlinked guests). + * + * @return BelongsTo + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** + * Get the lines in the order. + * + * @return HasMany + */ + public function lines(): HasMany + { + return $this->hasMany(OrderLine::class); + } + + /** + * Get the payments recorded for the order. + * + * @return HasMany + */ + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } + + /** + * Get the refunds recorded for the order. + * + * @return HasMany + */ + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } + + /** + * Get the fulfillments for the order. + * + * @return HasMany + */ + public function fulfillments(): HasMany + { + return $this->hasMany(Fulfillment::class); + } + + /** + * Whether payment has been captured for the order. + */ + public function isPaid(): bool + { + return $this->financial_status === FinancialStatus::Paid; + } + + /** + * Whether every line is a digital item (no shipping required). A line + * whose variant was deleted counts as physical (fallback: shipping + * required), so nothing is silently auto-fulfilled. + */ + public function isDigital(): bool + { + $this->loadMissing('lines.variant'); + + return $this->lines->isNotEmpty() && $this->lines->every( + fn (OrderLine $line): bool => $line->variant !== null && ! $line->variant->requires_shipping + ); + } + + /** + * Amount that can still be refunded: total minus non-failed refunds. + */ + public function refundableAmount(): int + { + $refunded = $this->refunds() + ->whereIn('status', [RefundStatus::Pending->value, RefundStatus::Processed->value]) + ->sum('amount'); + + return max(0, $this->total_amount - (int) $refunded); + } + + /** + * Formatted grand total for display, e.g. "65.45 EUR". + */ + public function formattedTotal(): string + { + return Money::format($this->total_amount, $this->currency); + } +} diff --git a/app/Models/OrderLine.php b/app/Models/OrderLine.php new file mode 100644 index 00000000..69fde069 --- /dev/null +++ b/app/Models/OrderLine.php @@ -0,0 +1,96 @@ + */ + use HasFactory; + + /** + * The table has no timestamp columns. + * + * @var bool + */ + public $timestamps = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'order_id', + 'product_id', + 'variant_id', + 'title_snapshot', + 'sku_snapshot', + 'quantity', + 'unit_price_amount', + 'total_amount', + 'tax_lines_json', + 'discount_allocations_json', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'tax_lines_json' => 'array', + 'discount_allocations_json' => 'array', + 'quantity' => 'integer', + 'unit_price_amount' => 'integer', + 'total_amount' => 'integer', + ]; + } + + /** + * Get the order that owns the line. + * + * @return BelongsTo + */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** + * Get the product the line references (null after product deletion). + * + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * Get the variant the line references (null after variant deletion). + * + * @return BelongsTo + */ + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + /** + * Units of this line not yet covered by any fulfillment. + */ + public function unfulfilledQuantity(): int + { + $fulfilled = FulfillmentLine::query() + ->where('order_line_id', $this->id) + ->sum('quantity'); + + return max(0, $this->quantity - (int) $fulfilled); + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php new file mode 100644 index 00000000..d26c7771 --- /dev/null +++ b/app/Models/Organization.php @@ -0,0 +1,33 @@ + */ + use HasFactory; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'name', + 'billing_email', + ]; + + /** + * Get the stores owned by the organization. + * + * @return HasMany + */ + public function stores(): HasMany + { + return $this->hasMany(Store::class); + } +} diff --git a/app/Models/Page.php b/app/Models/Page.php new file mode 100644 index 00000000..bdc018f2 --- /dev/null +++ b/app/Models/Page.php @@ -0,0 +1,72 @@ + */ + use BelongsToStore, HasFactory; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'body_html', + 'status', + 'published_at', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'status' => PageStatus::class, + 'published_at' => 'datetime', + ]; + } + + /** + * Register model event hooks. + */ + protected static function booted(): void + { + // Default the handle from the title, unique per store. + static::creating(function (Page $page): void { + if (blank($page->handle)) { + $page->handle = HandleGenerator::generate($page->title, 'pages', $page->store_id); + } + }); + + // Sanitize rich-text content against the HTML allowlist. + static::saving(function (Page $page): void { + $page->body_html = app(SanitizeHtml::class)($page->body_html); + }); + } + + /** + * Scope to pages visible on the storefront. + * + * @param Builder $query + */ + protected function scopePublished(Builder $query): void + { + $query->where('status', PageStatus::Published); + } +} diff --git a/app/Models/Payment.php b/app/Models/Payment.php new file mode 100644 index 00000000..af3a76e0 --- /dev/null +++ b/app/Models/Payment.php @@ -0,0 +1,72 @@ + */ + use HasFactory; + + /** + * The table only has created_at, no updated_at. + */ + public const UPDATED_AT = null; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'order_id', + 'provider', + 'method', + 'provider_payment_id', + 'status', + 'amount', + 'currency', + 'raw_json_encrypted', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'method' => PaymentMethod::class, + 'status' => PaymentStatus::class, + 'amount' => 'integer', + 'raw_json_encrypted' => 'encrypted:array', + ]; + } + + /** + * Get the order the payment belongs to. + * + * @return BelongsTo + */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** + * Get the refunds issued against this payment. + * + * @return HasMany + */ + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 00000000..04941e96 --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,123 @@ + */ + use BelongsToStore, HasFactory; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'status', + 'description_html', + 'vendor', + 'product_type', + 'tags', + 'published_at', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'status' => ProductStatus::class, + 'tags' => 'array', + 'published_at' => 'datetime', + ]; + } + + /** + * Register model event hooks. + */ + protected static function booted(): void + { + // Delete media records through Eloquent so the file-cleanup hook on + // ProductMedia fires (database cascade would bypass model events). + static::deleting(function (Product $product): void { + $product->media->each->delete(); + }); + } + + /** + * Get the variants of the product. + * + * @return HasMany + */ + public function variants(): HasMany + { + return $this->hasMany(ProductVariant::class)->orderBy('position'); + } + + /** + * Get the options of the product. + * + * @return HasMany + */ + public function options(): HasMany + { + return $this->hasMany(ProductOption::class)->orderBy('position'); + } + + /** + * Get the media attached to the product. + * + * @return HasMany + */ + public function media(): HasMany + { + return $this->hasMany(ProductMedia::class)->orderBy('position'); + } + + /** + * Get the collections containing the product. + * + * @return BelongsToMany + */ + public function collections(): BelongsToMany + { + return $this->belongsToMany(Collection::class, 'collection_products') + ->withPivot('position') + ->orderBy('collection_products.position'); + } + + /** + * Get the default variant of the product. + * + * @return HasOne + */ + public function defaultVariant(): HasOne + { + return $this->hasOne(ProductVariant::class)->where('is_default', true); + } + + /** + * Scope to products visible on the storefront: active and published. + * + * @param Builder $query + */ + protected function scopeVisible(Builder $query): void + { + $query->where('status', ProductStatus::Active)->whereNotNull('published_at'); + } +} diff --git a/app/Models/ProductMedia.php b/app/Models/ProductMedia.php new file mode 100644 index 00000000..ecf410e8 --- /dev/null +++ b/app/Models/ProductMedia.php @@ -0,0 +1,115 @@ + */ + use HasFactory; + + /** + * The table only has a created_at column. + * + * @var string|null + */ + const UPDATED_AT = null; + + /** + * Processed derivative sizes generated by ProcessMediaUpload. + * + * @var list + */ + const SIZES = ['thumbnail', 'small', 'medium', 'large']; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'product_id', + 'type', + 'storage_key', + 'alt_text', + 'width', + 'height', + 'mime_type', + 'byte_size', + 'position', + 'status', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'type' => MediaType::class, + 'status' => MediaStatus::class, + 'created_at' => 'datetime', + ]; + } + + /** + * Register model event hooks. + */ + protected static function booted(): void + { + // Remove all files (original plus every processed size) from storage. + static::deleted(function (ProductMedia $media): void { + $disk = Storage::disk('public'); + + $disk->delete($media->storage_key); + + foreach (self::SIZES as $size) { + $disk->delete($media->pathFor($size)); + } + }); + } + + /** + * Get the product that owns the media. + * + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * Storage path of a processed derivative: media/{product_id}/{media_id}/{size}.{ext} + */ + public function pathFor(string $size): string + { + $extension = pathinfo($this->storage_key, PATHINFO_EXTENSION); + + return "media/{$this->product_id}/{$this->id}/{$size}.{$extension}"; + } + + /** + * Public URL of the original file. + */ + public function url(): string + { + return Storage::disk('public')->url($this->storage_key); + } + + /** + * Public URL of a processed derivative size. + */ + public function urlFor(string $size): string + { + return Storage::disk('public')->url($this->pathFor($size)); + } +} diff --git a/app/Models/ProductOption.php b/app/Models/ProductOption.php new file mode 100644 index 00000000..981ff959 --- /dev/null +++ b/app/Models/ProductOption.php @@ -0,0 +1,52 @@ + */ + use HasFactory; + + /** + * The table has no timestamp columns. + * + * @var bool + */ + public $timestamps = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'product_id', + 'name', + 'position', + ]; + + /** + * Get the product that owns the option. + * + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * Get the values of the option. + * + * @return HasMany + */ + public function values(): HasMany + { + return $this->hasMany(ProductOptionValue::class)->orderBy('position'); + } +} diff --git a/app/Models/ProductOptionValue.php b/app/Models/ProductOptionValue.php new file mode 100644 index 00000000..40f4124f --- /dev/null +++ b/app/Models/ProductOptionValue.php @@ -0,0 +1,41 @@ + */ + use HasFactory; + + /** + * The table has no timestamp columns. + * + * @var bool + */ + public $timestamps = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'product_option_id', + 'value', + 'position', + ]; + + /** + * Get the option that owns the value. + * + * @return BelongsTo + */ + public function option(): BelongsTo + { + return $this->belongsTo(ProductOption::class, 'product_option_id'); + } +} diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php new file mode 100644 index 00000000..04110b89 --- /dev/null +++ b/app/Models/ProductVariant.php @@ -0,0 +1,116 @@ + */ + use HasFactory; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'product_id', + 'sku', + 'barcode', + 'price_amount', + 'compare_at_amount', + 'currency', + 'weight_g', + 'requires_shipping', + 'is_default', + 'position', + 'status', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'requires_shipping' => 'boolean', + 'is_default' => 'boolean', + 'status' => VariantStatus::class, + ]; + } + + /** + * Get the product that owns the variant. + * + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * Get the inventory item tracking stock for the variant. + * + * @return HasOne + */ + public function inventoryItem(): HasOne + { + return $this->hasOne(InventoryItem::class, 'variant_id'); + } + + /** + * Get the option values that define the variant. + * + * @return BelongsToMany + */ + public function optionValues(): BelongsToMany + { + return $this->belongsToMany(ProductOptionValue::class, 'variant_option_values', 'variant_id', 'product_option_value_id'); + } + + /** + * Build the display title from the option values, e.g. "Blue / Medium". + */ + public function title(): string + { + $values = $this->optionValues + ->sortBy(fn (ProductOptionValue $value) => $value->option->position) + ->pluck('value'); + + return $values->isEmpty() ? 'Default' : $values->implode(' / '); + } + + /** + * Available stock: on hand minus reserved. + */ + public function availableQuantity(): int + { + return $this->inventoryItem?->available() ?? 0; + } + + /** + * Whether the variant has stock available to sell. + */ + public function isInStock(): bool + { + return $this->availableQuantity() > 0; + } + + /** + * Whether the variant may be sold below zero stock (backorder policy). + */ + public function isBackorderable(): bool + { + return $this->inventoryItem?->policy === InventoryPolicy::Continue; + } +} diff --git a/app/Models/Refund.php b/app/Models/Refund.php new file mode 100644 index 00000000..db9e308f --- /dev/null +++ b/app/Models/Refund.php @@ -0,0 +1,66 @@ + */ + use HasFactory; + + /** + * The table only has created_at, no updated_at. + */ + public const UPDATED_AT = null; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'order_id', + 'payment_id', + 'amount', + 'reason', + 'status', + 'provider_refund_id', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'status' => RefundStatus::class, + 'amount' => 'integer', + ]; + } + + /** + * Get the order the refund belongs to. + * + * @return BelongsTo + */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** + * Get the payment the refund was issued against. + * + * @return BelongsTo + */ + public function payment(): BelongsTo + { + return $this->belongsTo(Payment::class); + } +} diff --git a/app/Models/Scopes/StoreScope.php b/app/Models/Scopes/StoreScope.php new file mode 100644 index 00000000..a1895ebe --- /dev/null +++ b/app/Models/Scopes/StoreScope.php @@ -0,0 +1,26 @@ + $builder + */ + public function apply(Builder $builder, Model $model): void + { + if (app()->bound('current_store')) { + $builder->where($model->qualifyColumn('store_id'), app('current_store')->getKey()); + } + } +} diff --git a/app/Models/SearchQuery.php b/app/Models/SearchQuery.php new file mode 100644 index 00000000..05d9acf0 --- /dev/null +++ b/app/Models/SearchQuery.php @@ -0,0 +1,46 @@ + */ + use BelongsToStore, HasFactory; + + /** + * The table only has a created_at column. + * + * @var string|null + */ + const UPDATED_AT = null; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'query', + 'filters_json', + 'results_count', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'filters_json' => 'array', + 'results_count' => 'integer', + 'created_at' => 'datetime', + ]; + } +} diff --git a/app/Models/SearchSettings.php b/app/Models/SearchSettings.php new file mode 100644 index 00000000..d8535384 --- /dev/null +++ b/app/Models/SearchSettings.php @@ -0,0 +1,69 @@ + */ + use HasFactory; + + /** + * The table only has an updated_at column. + * + * @var string|null + */ + const CREATED_AT = null; + + /** + * The primary key is the store id (one-to-one with stores). + * + * @var string + */ + protected $primaryKey = 'store_id'; + + /** + * The primary key is not auto-incrementing. + * + * @var bool + */ + public $incrementing = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'synonyms_json', + 'stop_words_json', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'synonyms_json' => 'array', + 'stop_words_json' => 'array', + 'updated_at' => 'datetime', + ]; + } + + /** + * Get the store that owns the settings. + * + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/ShippingRate.php b/app/Models/ShippingRate.php new file mode 100644 index 00000000..8d19d10e --- /dev/null +++ b/app/Models/ShippingRate.php @@ -0,0 +1,58 @@ + */ + use HasFactory; + + /** + * The table has no timestamp columns. + * + * @var bool + */ + public $timestamps = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'zone_id', + 'name', + 'type', + 'config_json', + 'is_active', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'type' => ShippingRateType::class, + 'config_json' => 'array', + 'is_active' => 'boolean', + ]; + } + + /** + * Get the zone that owns the rate. + * + * @return BelongsTo + */ + public function zone(): BelongsTo + { + return $this->belongsTo(ShippingZone::class, 'zone_id'); + } +} diff --git a/app/Models/ShippingZone.php b/app/Models/ShippingZone.php new file mode 100644 index 00000000..4d1ab8a2 --- /dev/null +++ b/app/Models/ShippingZone.php @@ -0,0 +1,56 @@ + */ + use BelongsToStore, HasFactory; + + /** + * The table has no timestamp columns. + * + * @var bool + */ + public $timestamps = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'name', + 'countries_json', + 'regions_json', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'countries_json' => 'array', + 'regions_json' => 'array', + ]; + } + + /** + * Get the rates within the zone. + * + * @return HasMany + */ + public function rates(): HasMany + { + return $this->hasMany(ShippingRate::class, 'zone_id'); + } +} diff --git a/app/Models/Store.php b/app/Models/Store.php new file mode 100644 index 00000000..9875f813 --- /dev/null +++ b/app/Models/Store.php @@ -0,0 +1,86 @@ + */ + use HasFactory; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'organization_id', + 'name', + 'handle', + 'status', + 'default_currency', + 'default_locale', + 'timezone', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'status' => StoreStatus::class, + ]; + } + + /** + * Get the organization that owns the store. + * + * @return BelongsTo + */ + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } + + /** + * Get the domains attached to the store. + * + * @return HasMany + */ + public function domains(): HasMany + { + return $this->hasMany(StoreDomain::class); + } + + /** + * Get the admin users linked to the store. + * + * @return BelongsToMany + */ + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'store_users') + ->withPivot('role') + ->using(StoreUser::class); + } + + /** + * Get the settings bag for the store. + * + * @return HasOne + */ + public function settings(): HasOne + { + return $this->hasOne(StoreSettings::class); + } +} diff --git a/app/Models/StoreDomain.php b/app/Models/StoreDomain.php new file mode 100644 index 00000000..97e6258d --- /dev/null +++ b/app/Models/StoreDomain.php @@ -0,0 +1,56 @@ + */ + use HasFactory; + + /** + * The store_domains table only has created_at, no updated_at. + */ + public const UPDATED_AT = null; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'hostname', + 'type', + 'is_primary', + 'tls_mode', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'type' => StoreDomainType::class, + 'is_primary' => 'boolean', + 'created_at' => 'datetime', + ]; + } + + /** + * Get the store that owns the domain. + * + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/StoreSettings.php b/app/Models/StoreSettings.php new file mode 100644 index 00000000..d93c74fe --- /dev/null +++ b/app/Models/StoreSettings.php @@ -0,0 +1,65 @@ + */ + use HasFactory; + + /** + * The store_settings table only has updated_at, no created_at. + */ + public const CREATED_AT = null; + + /** + * The primary key is the owning store's id (one-to-one). + * + * @var string + */ + protected $primaryKey = 'store_id'; + + /** + * The primary key is not auto-incrementing. + * + * @var bool + */ + public $incrementing = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'settings_json', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'settings_json' => 'array', + 'updated_at' => 'datetime', + ]; + } + + /** + * Get the store that owns the settings. + * + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/StoreUser.php b/app/Models/StoreUser.php new file mode 100644 index 00000000..2c7f301c --- /dev/null +++ b/app/Models/StoreUser.php @@ -0,0 +1,45 @@ + */ + use HasFactory; + + /** + * The table associated with the model. + * + * @var string + */ + protected $table = 'store_users'; + + /** + * The store_users table only has created_at, no updated_at. + */ + public const UPDATED_AT = null; + + /** + * The store_users pivot manages its own timestamps on insert. + * + * @var bool + */ + public $timestamps = true; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'role' => StoreUserRole::class, + 'created_at' => 'datetime', + ]; + } +} diff --git a/app/Models/TaxSettings.php b/app/Models/TaxSettings.php new file mode 100644 index 00000000..3d76386b --- /dev/null +++ b/app/Models/TaxSettings.php @@ -0,0 +1,72 @@ + */ + use HasFactory; + + /** + * The table has no timestamp columns. + * + * @var bool + */ + public $timestamps = false; + + /** + * The primary key is the store ID (one settings row per store). + * + * @var string + */ + protected $primaryKey = 'store_id'; + + /** + * The primary key is not auto-incrementing. + * + * @var bool + */ + public $incrementing = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'mode', + 'provider', + 'prices_include_tax', + 'config_json', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'mode' => TaxMode::class, + 'prices_include_tax' => 'boolean', + 'config_json' => 'array', + ]; + } + + /** + * Get the store these settings belong to. + * + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/Theme.php b/app/Models/Theme.php new file mode 100644 index 00000000..bd1c30f3 --- /dev/null +++ b/app/Models/Theme.php @@ -0,0 +1,129 @@ + */ + use BelongsToStore, HasFactory; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'name', + 'version', + 'status', + 'published_at', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'status' => ThemeStatus::class, + 'published_at' => 'datetime', + ]; + } + + /** + * Register model event hooks. + */ + protected static function booted(): void + { + // Theme changes (including publish/unpublish) affect the cached + // storefront settings of the owning store. + $flush = fn (Theme $theme) => app(ThemeSettingsService::class)->invalidate($theme->store_id); + + static::saved($flush); + static::deleted($flush); + } + + /** + * Get the files belonging to the theme. + * + * @return HasMany + */ + public function files(): HasMany + { + return $this->hasMany(ThemeFile::class); + } + + /** + * Get the settings record of the theme. + * + * @return HasOne + */ + public function settings(): HasOne + { + return $this->hasOne(ThemeSettings::class); + } + + /** + * Whether the theme is the published one for its store. + */ + public function isPublished(): bool + { + return $this->status === ThemeStatus::Published; + } + + /** + * Publish this theme and demote every other theme of the store to draft. + */ + public function publish(): void + { + DB::transaction(function (): void { + static::withoutGlobalScopes() + ->where('store_id', $this->store_id) + ->whereKeyNot($this->getKey()) + ->where('status', ThemeStatus::Published->value) + ->update(['status' => ThemeStatus::Draft->value]); + + $this->forceFill([ + 'status' => ThemeStatus::Published, + 'published_at' => now(), + ])->save(); + }); + } + + /** + * Create a draft copy of the theme including files and settings. + */ + public function duplicate(string $name): Theme + { + return DB::transaction(function () use ($name): Theme { + $copy = $this->replicate(['status', 'published_at'])->forceFill([ + 'name' => $name, + 'status' => ThemeStatus::Draft, + 'published_at' => null, + ]); + $copy->save(); + + foreach ($this->files as $file) { + $copy->files()->create($file->only(['path', 'storage_key', 'sha256', 'byte_size'])); + } + + if ($this->settings !== null) { + $copy->settings()->create(['settings_json' => $this->settings->settings_json]); + } + + return $copy; + }); + } +} diff --git a/app/Models/ThemeFile.php b/app/Models/ThemeFile.php new file mode 100644 index 00000000..d56a910d --- /dev/null +++ b/app/Models/ThemeFile.php @@ -0,0 +1,43 @@ + */ + use HasFactory; + + /** + * The table has no timestamp columns. + * + * @var bool + */ + public $timestamps = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'theme_id', + 'path', + 'storage_key', + 'sha256', + 'byte_size', + ]; + + /** + * Get the theme that owns the file. + * + * @return BelongsTo + */ + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } +} diff --git a/app/Models/ThemeSettings.php b/app/Models/ThemeSettings.php new file mode 100644 index 00000000..3ccd5ee6 --- /dev/null +++ b/app/Models/ThemeSettings.php @@ -0,0 +1,79 @@ + */ + use HasFactory; + + /** + * The primary key is the owning theme's id (one-to-one). + * + * @var string + */ + protected $primaryKey = 'theme_id'; + + /** + * The primary key is not auto-incrementing. + * + * @var bool + */ + public $incrementing = false; + + /** + * The table only has an updated_at column. + * + * @var string|null + */ + const CREATED_AT = null; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'theme_id', + 'settings_json', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'settings_json' => 'array', + ]; + } + + /** + * Register model event hooks. + */ + protected static function booted(): void + { + $flush = fn (ThemeSettings $settings) => app(ThemeSettingsService::class) + ->invalidate($settings->theme?->store_id ?? Theme::withoutGlobalScopes()->whereKey($settings->theme_id)->value('store_id')); + + static::saved($flush); + static::deleted($flush); + } + + /** + * Get the theme these settings belong to. + * + * @return BelongsTo + */ + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 214bea4e..444f8bd3 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,17 +2,19 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Enums\StoreUserRole; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; -use Laravel\Fortify\TwoFactorAuthenticatable; +use Laravel\Sanctum\HasApiTokens; -class User extends Authenticatable +class User extends Authenticatable implements MustVerifyEmail { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable, TwoFactorAuthenticatable; + use HasApiTokens, HasFactory, Notifiable; /** * The attributes that are mass assignable. @@ -22,7 +24,8 @@ class User extends Authenticatable protected $fillable = [ 'name', 'email', - 'password', + 'password_hash', + 'status', ]; /** @@ -31,10 +34,10 @@ class User extends Authenticatable * @var list */ protected $hidden = [ - 'password', + 'password_hash', + 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes', - 'remember_token', ]; /** @@ -46,10 +49,47 @@ protected function casts(): array { return [ 'email_verified_at' => 'datetime', - 'password' => 'hashed', + 'last_login_at' => 'datetime', + 'two_factor_confirmed_at' => 'datetime', + 'password_hash' => 'hashed', ]; } + /** + * Get the password for authentication (custom column name). + */ + public function getAuthPassword(): string + { + return $this->password_hash; + } + + /** + * Get the stores the user is a member of. + * + * @return BelongsToMany + */ + public function stores(): BelongsToMany + { + return $this->belongsToMany(Store::class, 'store_users') + ->withPivot('role') + ->using(StoreUser::class); + } + + /** + * Get the user's role for a given store, or null when the user has + * no access to that store. + */ + public function roleForStore(Store $store): ?StoreUserRole + { + /** @var Store|null $membership */ + $membership = $this->stores()->where('stores.id', $store->getKey())->first(); + + /** @var StoreUserRole|null $role */ + $role = $membership?->pivot->role; + + return $role; + } + /** * Get the user's initials */ diff --git a/app/Models/WebhookDelivery.php b/app/Models/WebhookDelivery.php new file mode 100644 index 00000000..c5d255b0 --- /dev/null +++ b/app/Models/WebhookDelivery.php @@ -0,0 +1,61 @@ + */ + use HasFactory; + + /** + * The table has no timestamp columns. + * + * @var bool + */ + public $timestamps = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'subscription_id', + 'event_id', + 'attempt_count', + 'status', + 'last_attempt_at', + 'response_code', + 'response_body_snippet', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'status' => WebhookDeliveryStatus::class, + 'attempt_count' => 'integer', + 'response_code' => 'integer', + 'last_attempt_at' => 'datetime', + ]; + } + + /** + * Get the subscription the delivery belongs to. + * + * @return BelongsTo + */ + public function subscription(): BelongsTo + { + return $this->belongsTo(WebhookSubscription::class, 'subscription_id'); + } +} diff --git a/app/Models/WebhookSubscription.php b/app/Models/WebhookSubscription.php new file mode 100644 index 00000000..84a230f6 --- /dev/null +++ b/app/Models/WebhookSubscription.php @@ -0,0 +1,96 @@ + */ + use BelongsToStore, HasFactory; + + /** + * The table has no timestamp columns. + * + * @var bool + */ + public $timestamps = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'app_installation_id', + 'event_type', + 'target_url', + 'signing_secret_encrypted', + 'status', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'signing_secret_encrypted' => 'encrypted', + 'status' => WebhookSubscriptionStatus::class, + ]; + } + + /** + * Get the app installation the subscription belongs to (null for + * subscriptions created directly by the merchant). + * + * @return BelongsTo + */ + public function appInstallation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class, 'app_installation_id'); + } + + /** + * Get the delivery attempts recorded for the subscription. + * + * @return HasMany + */ + public function deliveries(): HasMany + { + return $this->hasMany(WebhookDelivery::class, 'subscription_id'); + } + + /** + * Count of consecutive failed deliveries, most recent first. Any + * non-failed delivery resets the streak (spec 05 §13.4). + */ + public function consecutiveFailures(): int + { + $streak = 0; + + $recentStatuses = $this->deliveries() + ->orderByDesc('id') + ->limit(20) + ->pluck('status'); + + foreach ($recentStatuses as $status) { + if ($status !== WebhookDeliveryStatus::Failed) { + break; + } + + $streak++; + } + + return $streak; + } +} diff --git a/app/Observers/ProductObserver.php b/app/Observers/ProductObserver.php new file mode 100644 index 00000000..7aa09396 --- /dev/null +++ b/app/Observers/ProductObserver.php @@ -0,0 +1,40 @@ +search->syncProduct($product); + } + + /** + * Re-index the product after an update (delete + insert; FTS5 has no UPDATE). + */ + public function updated(Product $product): void + { + $this->search->syncProduct($product); + } + + /** + * Drop the product from the index after deletion. + */ + public function deleted(Product $product): void + { + $this->search->removeProduct($product->id); + } +} diff --git a/app/Policies/CollectionPolicy.php b/app/Policies/CollectionPolicy.php new file mode 100644 index 00000000..b4706741 --- /dev/null +++ b/app/Policies/CollectionPolicy.php @@ -0,0 +1,41 @@ +currentStoreId(); + + return $storeId !== null && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, Collection $collection): bool + { + return $this->isAnyRole($user, $collection->store_id); + } + + public function create(User $user): bool + { + $storeId = $this->currentStoreId(); + + return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function update(User $user, Collection $collection): bool + { + return $this->isOwnerAdminOrStaff($user, $collection->store_id); + } + + public function delete(User $user, Collection $collection): bool + { + return $this->isOwnerOrAdmin($user, $collection->store_id); + } +} diff --git a/app/Policies/CustomerPolicy.php b/app/Policies/CustomerPolicy.php new file mode 100644 index 00000000..96996090 --- /dev/null +++ b/app/Policies/CustomerPolicy.php @@ -0,0 +1,29 @@ +currentStoreId(); + + return $storeId !== null && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, Customer $customer): bool + { + return $this->isAnyRole($user, $customer->store_id); + } + + public function update(User $user, Customer $customer): bool + { + return $this->isOwnerAdminOrStaff($user, $customer->store_id); + } +} diff --git a/app/Policies/DiscountPolicy.php b/app/Policies/DiscountPolicy.php new file mode 100644 index 00000000..eedc15cb --- /dev/null +++ b/app/Policies/DiscountPolicy.php @@ -0,0 +1,41 @@ +currentStoreId(); + + return $storeId !== null && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, Discount $discount): bool + { + return $this->isAnyRole($user, $discount->store_id); + } + + public function create(User $user): bool + { + $storeId = $this->currentStoreId(); + + return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function update(User $user, Discount $discount): bool + { + return $this->isOwnerAdminOrStaff($user, $discount->store_id); + } + + public function delete(User $user, Discount $discount): bool + { + return $this->isOwnerOrAdmin($user, $discount->store_id); + } +} diff --git a/app/Policies/FulfillmentPolicy.php b/app/Policies/FulfillmentPolicy.php new file mode 100644 index 00000000..280ba53e --- /dev/null +++ b/app/Policies/FulfillmentPolicy.php @@ -0,0 +1,32 @@ +isOwnerAdminOrStaff($user, $order->store_id); + } + + public function update(User $user, Fulfillment $fulfillment): bool + { + return $this->isOwnerAdminOrStaff($user, $fulfillment->order->store_id); + } + + public function cancel(User $user, Fulfillment $fulfillment): bool + { + return $this->isOwnerAdminOrStaff($user, $fulfillment->order->store_id); + } +} diff --git a/app/Policies/NavigationMenuPolicy.php b/app/Policies/NavigationMenuPolicy.php new file mode 100644 index 00000000..e07e9bd3 --- /dev/null +++ b/app/Policies/NavigationMenuPolicy.php @@ -0,0 +1,25 @@ +currentStoreId(); + + return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function manage(User $user): bool + { + $storeId = $this->currentStoreId(); + + return $storeId !== null && $this->isOwnerOrAdmin($user, $storeId); + } +} diff --git a/app/Policies/OrderPolicy.php b/app/Policies/OrderPolicy.php new file mode 100644 index 00000000..7824e14b --- /dev/null +++ b/app/Policies/OrderPolicy.php @@ -0,0 +1,44 @@ +currentStoreId(); + + return $storeId !== null && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, Order $order): bool + { + return $this->isAnyRole($user, $order->store_id); + } + + public function update(User $user, Order $order): bool + { + return $this->isOwnerAdminOrStaff($user, $order->store_id); + } + + public function cancel(User $user, Order $order): bool + { + return $this->isOwnerOrAdmin($user, $order->store_id); + } + + public function createFulfillment(User $user, Order $order): bool + { + return $this->isOwnerAdminOrStaff($user, $order->store_id); + } + + public function createRefund(User $user, Order $order): bool + { + return $this->isOwnerOrAdmin($user, $order->store_id); + } +} diff --git a/app/Policies/PagePolicy.php b/app/Policies/PagePolicy.php new file mode 100644 index 00000000..d5beb9aa --- /dev/null +++ b/app/Policies/PagePolicy.php @@ -0,0 +1,41 @@ +currentStoreId(); + + return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function view(User $user, Page $page): bool + { + return $this->isOwnerAdminOrStaff($user, $page->store_id); + } + + public function create(User $user): bool + { + $storeId = $this->currentStoreId(); + + return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function update(User $user, Page $page): bool + { + return $this->isOwnerAdminOrStaff($user, $page->store_id); + } + + public function delete(User $user, Page $page): bool + { + return $this->isOwnerOrAdmin($user, $page->store_id); + } +} diff --git a/app/Policies/ProductPolicy.php b/app/Policies/ProductPolicy.php new file mode 100644 index 00000000..daf709f9 --- /dev/null +++ b/app/Policies/ProductPolicy.php @@ -0,0 +1,51 @@ +currentStoreId(); + + return $storeId !== null && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, Product $product): bool + { + return $this->isAnyRole($user, $product->store_id); + } + + public function create(User $user): bool + { + $storeId = $this->currentStoreId(); + + return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function update(User $user, Product $product): bool + { + return $this->isOwnerAdminOrStaff($user, $product->store_id); + } + + public function delete(User $user, Product $product): bool + { + return $this->isOwnerOrAdmin($user, $product->store_id); + } + + public function archive(User $user, Product $product): bool + { + return $this->isOwnerOrAdmin($user, $product->store_id); + } + + public function restore(User $user, Product $product): bool + { + return $this->isOwnerOrAdmin($user, $product->store_id); + } +} diff --git a/app/Policies/RefundPolicy.php b/app/Policies/RefundPolicy.php new file mode 100644 index 00000000..b8e381eb --- /dev/null +++ b/app/Policies/RefundPolicy.php @@ -0,0 +1,21 @@ +isOwnerOrAdmin($user, $order->store_id); + } +} diff --git a/app/Policies/StorePolicy.php b/app/Policies/StorePolicy.php new file mode 100644 index 00000000..eb79584c --- /dev/null +++ b/app/Policies/StorePolicy.php @@ -0,0 +1,28 @@ +isOwnerOrAdmin($user, $store->id); + } + + public function updateSettings(User $user, Store $store): bool + { + return $this->isOwnerOrAdmin($user, $store->id); + } + + public function delete(User $user, Store $store): bool + { + return $this->hasRole($user, $store->id, [StoreUserRole::Owner]); + } +} diff --git a/app/Policies/ThemePolicy.php b/app/Policies/ThemePolicy.php new file mode 100644 index 00000000..23f032cb --- /dev/null +++ b/app/Policies/ThemePolicy.php @@ -0,0 +1,46 @@ +currentStoreId(); + + return $storeId !== null && $this->isOwnerOrAdmin($user, $storeId); + } + + public function view(User $user, Theme $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } + + public function create(User $user): bool + { + $storeId = $this->currentStoreId(); + + return $storeId !== null && $this->isOwnerOrAdmin($user, $storeId); + } + + public function update(User $user, Theme $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } + + public function delete(User $user, Theme $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } + + public function publish(User $user, Theme $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8a29e6f5..adf403e7 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,11 +2,42 @@ namespace App\Providers; +use App\Auth\CustomerPasswordBrokerManager; +use App\Auth\CustomerUserProvider; +use App\Contracts\PaymentProvider; +use App\Enums\StoreUserRole; +use App\Events\FulfillmentShipped; +use App\Events\OrderCancelled; +use App\Events\OrderCreated; +use App\Events\OrderPaid; +use App\Events\OrderRefunded; +use App\Http\Middleware\CheckAnyStoreRole; +use App\Http\Middleware\CustomerAuthenticate; +use App\Http\Middleware\ResolveAdminStore; +use App\Http\Middleware\ResolveStorefrontStore; +use App\Listeners\WriteAuditLog; +use App\Models\Customer; +use App\Models\Product; +use App\Models\User; +use App\Observers\ProductObserver; +use App\Services\Payments\MockPaymentProvider; +use App\Services\ThemeSettingsService; use Carbon\CarbonImmutable; +use Illuminate\Auth\Notifications\ResetPassword as ResetPasswordNotification; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Contracts\Foundation\Application; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; +use Livewire\Livewire; class AppServiceProvider extends ServiceProvider { @@ -15,7 +46,10 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->singleton(ThemeSettingsService::class); + + // Mock PSP: in-process payment provider (spec 05 §10). + $this->app->bind(PaymentProvider::class, MockPaymentProvider::class); } /** @@ -24,6 +58,31 @@ public function register(): void public function boot(): void { $this->configureDefaults(); + $this->configureAuth(); + $this->configureGates(); + $this->configureRateLimiting(); + $this->configureAuditLog(); + $this->configureWebhookDispatch(); + + // Keep the products_fts full-text index in sync (spec 05 §16.2). + Product::observe(ProductObserver::class); + + // Anonymous storefront components: + Blade::anonymousComponentPath(resource_path('views/storefront/components'), 'storefront'); + + // Anonymous admin views (layouts): + Blade::anonymousComponentPath(resource_path('views/admin'), 'admin'); + + // Tenant/auth middleware must be re-applied to Livewire update + // requests (Livewire persists route middleware across network + // requests, but only for parameter-less middleware classes). + Livewire::addPersistentMiddleware([ + ResolveStorefrontStore::class, + ResolveAdminStore::class, + CheckAnyStoreRole::class, + CustomerAuthenticate::class, + \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, + ]); } /** @@ -47,4 +106,142 @@ protected function configureDefaults(): void : null ); } + + /** + * Register the store-scoped customer user provider. + */ + protected function configureAuth(): void + { + Auth::provider('customer', function (Application $app, array $config): CustomerUserProvider { + return new CustomerUserProvider($app['hash'], $config['model']); + }); + + // Store-scoped password broker for storefront customers (spec 06 §1.2). + // The framework's PasswordResetServiceProvider is deferred and would + // lazily rebind these services on first resolution, so its deferred + // entries are removed in favour of this binding. This must run in + // boot(): the deferred service map is only populated after all + // register() calls. + $this->app->removeDeferredServices(['auth.password', 'auth.password.broker']); + $this->app->singleton('auth.password', fn ($app): CustomerPasswordBrokerManager => new CustomerPasswordBrokerManager($app)); + $this->app->bind('auth.password.broker', fn ($app) => $app['auth.password']->broker()); + + // Password reset links point at the admin form for users and at the + // storefront form for customers (spec 06 §1.1/§1.2). + ResetPasswordNotification::createUrlUsing(function (Authenticatable $notifiable, string $token): string { + $route = $notifiable instanceof Customer + ? 'storefront.password.reset' + : 'admin.password.reset'; + + return route($route, [ + 'token' => $token, + 'email' => $notifiable->getEmailForPasswordReset(), + ]); + }); + } + + /** + * Register gates for non-model operations (spec 06 §2.5). + */ + protected function configureGates(): void + { + $ownerOrAdmin = [StoreUserRole::Owner, StoreUserRole::Admin]; + $ownerAdminOrStaff = [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]; + + Gate::define('manage-store-settings', fn (User $user): bool => $this->gateAllows($user, $ownerOrAdmin)); + Gate::define('manage-staff', fn (User $user): bool => $this->gateAllows($user, $ownerOrAdmin)); + Gate::define('manage-developers', fn (User $user): bool => $this->gateAllows($user, $ownerOrAdmin)); + Gate::define('view-analytics', fn (User $user): bool => $this->gateAllows($user, $ownerAdminOrStaff)); + Gate::define('manage-shipping', fn (User $user): bool => $this->gateAllows($user, $ownerOrAdmin)); + Gate::define('manage-taxes', fn (User $user): bool => $this->gateAllows($user, $ownerOrAdmin)); + Gate::define('manage-search-settings', fn (User $user): bool => $this->gateAllows($user, $ownerOrAdmin)); + Gate::define('manage-navigation', fn (User $user): bool => $this->gateAllows($user, $ownerOrAdmin)); + Gate::define('manage-apps', fn (User $user): bool => $this->gateAllows($user, $ownerOrAdmin)); + } + + /** + * Resolve the current store from the container and check the user's + * role membership against the given roles. + * + * @param array $roles + */ + protected function gateAllows(User $user, array $roles): bool + { + if (! app()->bound('current_store')) { + return false; + } + + $role = $user->roleForStore(app('current_store')); + + return $role !== null && in_array($role, $roles, true); + } + + /** + * Register the audit log listener for order lifecycle events + * (spec 05 §17, spec 06 §4.6). + */ + protected function configureAuditLog(): void + { + Event::listen([ + OrderCreated::class, + OrderPaid::class, + OrderCancelled::class, + OrderRefunded::class, + FulfillmentShipped::class, + \App\Events\ProductCreated::class, + \App\Events\ProductUpdated::class, + ], WriteAuditLog::class); + + // Admin panel logins (spec 06 §4.6); the customer guard is ignored. + Event::listen(\Illuminate\Auth\Events\Login::class, \App\Listeners\WriteAuthAuditLog::class); + + // Customer-facing order lifecycle emails (spec 05 §17). The listener + // is failure-safe: mail errors are reported, never propagated. + Event::listen([ + OrderCreated::class, + OrderCancelled::class, + OrderRefunded::class, + FulfillmentShipped::class, + ], \App\Listeners\SendOrderEmails::class); + } + + /** + * Register the webhook dispatcher for all domain events that have + * webhook counterparts (spec 05 §13.1). + */ + protected function configureWebhookDispatch(): void + { + Event::listen([ + \App\Events\OrderCreated::class, + \App\Events\OrderPaid::class, + \App\Events\OrderFulfilled::class, + \App\Events\OrderRefunded::class, + \App\Events\ProductCreated::class, + \App\Events\ProductUpdated::class, + \App\Events\ProductDeleted::class, + \App\Events\CheckoutCompleted::class, + ], \App\Listeners\DispatchWebhooks::class); + } + + /** + * Register the application's rate limiters (spec 06 §4.2). + */ + protected function configureRateLimiting(): void + { + RateLimiter::for('login', fn (Request $request): Limit => Limit::perMinute(5)->by($request->ip())); + + RateLimiter::for('api.admin', fn (Request $request): Limit => Limit::perMinute(60)->by($request->user()?->id ?: $request->ip())); + + RateLimiter::for('api.storefront', fn (Request $request): Limit => Limit::perMinute(120)->by($request->ip())); + + RateLimiter::for('checkout', fn (Request $request): Limit => Limit::perMinute(10)->by( + $request->hasSession() ? $request->session()->getId() : $request->ip(), + )); + + RateLimiter::for('search', fn (Request $request): Limit => Limit::perMinute(30)->by($request->ip())); + + RateLimiter::for('analytics', fn (Request $request): Limit => Limit::perMinute(60)->by($request->ip())); + + RateLimiter::for('webhooks', fn (Request $request): Limit => Limit::perMinute(100)->by($request->ip())); + } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php deleted file mode 100644 index 44e57aa0..00000000 --- a/app/Providers/FortifyServiceProvider.php +++ /dev/null @@ -1,72 +0,0 @@ -configureActions(); - $this->configureViews(); - $this->configureRateLimiting(); - } - - /** - * Configure Fortify actions. - */ - private function configureActions(): void - { - Fortify::resetUserPasswordsUsing(ResetUserPassword::class); - Fortify::createUsersUsing(CreateNewUser::class); - } - - /** - * Configure Fortify views. - */ - private function configureViews(): void - { - Fortify::loginView(fn () => view('livewire.auth.login')); - Fortify::verifyEmailView(fn () => view('livewire.auth.verify-email')); - Fortify::twoFactorChallengeView(fn () => view('livewire.auth.two-factor-challenge')); - Fortify::confirmPasswordView(fn () => view('livewire.auth.confirm-password')); - Fortify::registerView(fn () => view('livewire.auth.register')); - Fortify::resetPasswordView(fn () => view('livewire.auth.reset-password')); - Fortify::requestPasswordResetLinkView(fn () => view('livewire.auth.forgot-password')); - } - - /** - * Configure rate limiting. - */ - private function configureRateLimiting(): void - { - RateLimiter::for('two-factor', function (Request $request) { - return Limit::perMinute(5)->by($request->session()->get('login.id')); - }); - - RateLimiter::for('login', function (Request $request) { - $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip()); - - return Limit::perMinute(5)->by($throttleKey); - }); - } -} diff --git a/app/Services/AnalyticsService.php b/app/Services/AnalyticsService.php new file mode 100644 index 00000000..e7586aa1 --- /dev/null +++ b/app/Services/AnalyticsService.php @@ -0,0 +1,286 @@ + + */ + public const EVENT_TYPES = [ + 'page_view', + 'product_view', + 'add_to_cart', + 'remove_from_cart', + 'checkout_started', + 'checkout_completed', + 'search', + ]; + + /** + * Insert a raw event. A client_event_id that was already recorded for + * the store is silently dropped (spec 05 §14.1 deduplication). + * + * @param array $properties + */ + public function track( + Store $store, + string $type, + array $properties = [], + ?string $sessionId = null, + ?int $customerId = null, + ?string $clientEventId = null, + ?string $occurredAt = null, + ): void { + if ($clientEventId !== null && $this->isDuplicate($store, $clientEventId)) { + return; + } + + try { + AnalyticsEvent::withoutGlobalScope(StoreScope::class)->create([ + 'store_id' => $store->id, + 'type' => $type, + 'session_id' => $sessionId, + 'customer_id' => $customerId, + 'properties_json' => $properties, + 'client_event_id' => $clientEventId, + 'occurred_at' => $occurredAt ?? now(), + ]); + } catch (QueryException $exception) { + // A unique-constraint violation means a concurrent duplicate; + // drop it silently. Anything else is a real failure. + if ($clientEventId === null || ! str_contains($exception->getMessage(), 'UNIQUE')) { + throw $exception; + } + } + } + + /** + * Track an event from a server-side commerce flow. Session and customer + * are resolved from the current request when not given. Never throws: + * analytics must never break commerce. + * + * @param array $properties + */ + public function trackSafely( + Store $store, + string $type, + array $properties = [], + ?int $customerId = null, + ?string $clientEventId = null, + ): void { + try { + $this->track( + $store, + $type, + $properties, + $this->requestSessionId(), + $customerId ?? $this->requestCustomerId(), + $clientEventId, + ); + } catch (\Throwable $exception) { + report($exception); + } + } + + /** + * Daily metrics for a date range (inclusive), keyed by date. Stored + * analytics_daily rows win; days without a stored row fall back to live + * aggregation of the raw events so dashboards are correct before the + * nightly job has run (spec 05 §14.2). + * + * @return Collection + */ + public function getDailyMetrics(Store $store, string $startDate, string $endDate): Collection + { + $stored = AnalyticsDaily::withoutGlobalScope(StoreScope::class) + ->where('store_id', $store->id) + ->whereBetween('date', [$startDate, $endDate]) + ->get() + ->keyBy(fn (AnalyticsDaily $row): string => $row->date); + + $live = $this->aggregateRawByDay($store->id, $startDate, $endDate); + + $days = collect(); + $end = CarbonImmutable::parse($endDate); + + for ($date = CarbonImmutable::parse($startDate); $date->lte($end); $date = $date->addDay()) { + $key = $date->toDateString(); + $row = $stored->get($key); + + $days->put($key, $row !== null + ? [ + 'date' => $key, + 'orders_count' => $row->orders_count, + 'revenue_amount' => $row->revenue_amount, + 'aov_amount' => $row->aov_amount, + 'visits_count' => $row->visits_count, + 'add_to_cart_count' => $row->add_to_cart_count, + 'checkout_started_count' => $row->checkout_started_count, + 'checkout_completed_count' => $row->checkout_completed_count, + ] + : $live->get($key, $this->emptyMetrics($key))); + } + + return $days; + } + + /** + * Aggregate a store's raw events for a single day into the analytics_daily + * metric shape (spec 05 §14.2). + * + * @return array{orders_count: int, revenue_amount: int, aov_amount: int, visits_count: int, add_to_cart_count: int, checkout_started_count: int, checkout_completed_count: int} + */ + public function aggregateForDate(int $storeId, string $date): array + { + $row = $this->metricSelects( + AnalyticsEvent::withoutGlobalScope(StoreScope::class) + ->where('store_id', $storeId) + ->whereRaw('DATE(occurred_at) = ?', [$date]), + )->first(); + + $orders = (int) ($row->orders_count ?? 0); + $revenue = (int) ($row->revenue_amount ?? 0); + + return [ + 'orders_count' => $orders, + 'revenue_amount' => $revenue, + 'aov_amount' => $orders > 0 ? intdiv($revenue, $orders) : 0, + 'visits_count' => (int) ($row->visits_count ?? 0), + 'add_to_cart_count' => (int) ($row->add_to_cart_count ?? 0), + 'checkout_started_count' => (int) ($row->checkout_started_count ?? 0), + 'checkout_completed_count' => (int) ($row->checkout_completed_count ?? 0), + ]; + } + + /** + * Live aggregation of raw events grouped by day for a range, keyed by + * date, in the same shape as getDailyMetrics rows. + * + * @return Collection + */ + private function aggregateRawByDay(int $storeId, string $startDate, string $endDate): Collection + { + $rows = $this->metricSelects( + AnalyticsEvent::withoutGlobalScope(StoreScope::class) + ->where('store_id', $storeId) + ->whereRaw('DATE(occurred_at) BETWEEN ? AND ?', [$startDate, $endDate]), + ) + ->selectRaw('DATE(occurred_at) as date') + ->groupBy('date') + ->get() + ->keyBy('date'); + + return $rows->map(function ($row): array { + $orders = (int) $row->orders_count; + $revenue = (int) $row->revenue_amount; + + return [ + 'date' => $row->date, + 'orders_count' => $orders, + 'revenue_amount' => $revenue, + 'aov_amount' => $orders > 0 ? intdiv($revenue, $orders) : 0, + 'visits_count' => (int) $row->visits_count, + 'add_to_cart_count' => (int) $row->add_to_cart_count, + 'checkout_started_count' => (int) $row->checkout_started_count, + 'checkout_completed_count' => (int) $row->checkout_completed_count, + ]; + }); + } + + /** + * Add the metric aggregate selects shared by the per-day and per-range + * aggregation queries (spec 05 §14.2 metric definitions). + * + * @template TBuilder of \Illuminate\Database\Eloquent\Builder + * + * @param TBuilder $query + * @return TBuilder + */ + private function metricSelects(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder + { + return $query + ->selectRaw("SUM(CASE WHEN type = 'checkout_completed' THEN 1 ELSE 0 END) as orders_count") + ->selectRaw("COALESCE(SUM(CASE WHEN type = 'checkout_completed' THEN CAST(json_extract(properties_json, '$.total') AS INTEGER) END), 0) as revenue_amount") + ->selectRaw("COUNT(DISTINCT CASE WHEN type = 'page_view' THEN session_id END) as visits_count") + ->selectRaw("SUM(CASE WHEN type = 'add_to_cart' THEN 1 ELSE 0 END) as add_to_cart_count") + ->selectRaw("SUM(CASE WHEN type = 'checkout_started' THEN 1 ELSE 0 END) as checkout_started_count") + ->selectRaw("SUM(CASE WHEN type = 'checkout_completed' THEN 1 ELSE 0 END) as checkout_completed_count"); + } + + /** + * A zeroed metrics row for a day without any events. + * + * @return array{date: string, orders_count: int, revenue_amount: int, aov_amount: int, visits_count: int, add_to_cart_count: int, checkout_started_count: int, checkout_completed_count: int} + */ + private function emptyMetrics(string $date): array + { + return [ + 'date' => $date, + 'orders_count' => 0, + 'revenue_amount' => 0, + 'aov_amount' => 0, + 'visits_count' => 0, + 'add_to_cart_count' => 0, + 'checkout_started_count' => 0, + 'checkout_completed_count' => 0, + ]; + } + + /** + * Whether the client event ID was already recorded for the store. + */ + private function isDuplicate(Store $store, string $clientEventId): bool + { + return AnalyticsEvent::withoutGlobalScope(StoreScope::class) + ->where('store_id', $store->id) + ->where('client_event_id', $clientEventId) + ->exists(); + } + + /** + * The ID of the current HTTP session, when there is a started one. + */ + private function requestSessionId(): ?string + { + try { + $request = app('request'); + + return $request instanceof Request && $request->hasSession(true) + ? $request->session()->getId() + : null; + } catch (\Throwable) { + return null; + } + } + + /** + * The ID of the authenticated storefront customer, when there is one. + */ + private function requestCustomerId(): ?int + { + try { + $id = auth('customer')->id(); + + return $id === null ? null : (int) $id; + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/Services/ApiTokenService.php b/app/Services/ApiTokenService.php new file mode 100644 index 00000000..4da52220 --- /dev/null +++ b/app/Services/ApiTokenService.php @@ -0,0 +1,44 @@ + $abilities + */ + public function create(User $user, string $name, array $abilities, ?DateTimeInterface $expiresAt = null): string + { + $plainTextToken = 'shop_'.Str::random(40); + + $user->tokens()->create([ + 'name' => $name, + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'expires_at' => $expiresAt ?? now()->addYear(), + ]); + + return $plainTextToken; + } + + /** + * Revoke one of the user's tokens (deletes the row). Returns false + * when the token does not belong to the user. + */ + public function revoke(User $user, int $tokenId): bool + { + return $user->tokens()->whereKey($tokenId)->delete() > 0; + } +} diff --git a/app/Services/CartService.php b/app/Services/CartService.php new file mode 100644 index 00000000..a6472be0 --- /dev/null +++ b/app/Services/CartService.php @@ -0,0 +1,264 @@ + $store->id, + 'customer_id' => $customer?->id, + 'currency' => $store->default_currency, + 'cart_version' => 1, + 'status' => CartStatus::Active, + ]); + } + + /** + * Add a variant to the cart. Existing lines for the variant are + * incremented instead of duplicated (spec 05 §4.2). + * + * @throws ValidationException invalid variant / inactive product + * @throws InsufficientInventoryException policy "deny" and out of stock + */ + public function addLine(Cart $cart, int $variantId, int $quantity): CartLine + { + $variant = ProductVariant::query() + ->whereKey($variantId) + ->whereHas('product', fn ($query) => $query->where('store_id', $cart->store_id)) + ->with(['product', 'inventoryItem']) + ->first(); + + if ($variant === null) { + throw ValidationException::withMessages([ + 'variant_id' => ['The selected variant is invalid.'], + ]); + } + + if ($variant->product->status !== ProductStatus::Active) { + throw ValidationException::withMessages([ + 'variant_id' => ['The selected product is not available.'], + ]); + } + + if ($variant->status !== VariantStatus::Active) { + throw ValidationException::withMessages([ + 'variant_id' => ['The selected variant is not available.'], + ]); + } + + $line = $cart->lines()->where('variant_id', $variantId)->first(); + $newQuantity = ($line?->quantity ?? 0) + $quantity; + + // The merged quantity is what will be reserved at checkout, so the + // inventory policy is checked against it. + $item = $variant->inventoryItem; + + if ($item !== null && ! $this->inventory->checkAvailability($item, $newQuantity)) { + throw InsufficientInventoryException::forReservation($item, $newQuantity); + } + + if ($line !== null) { + $line->quantity = $newQuantity; + $line->recalculate(); + } else { + $line = new CartLine([ + 'variant_id' => $variant->id, + 'quantity' => $quantity, + 'unit_price_amount' => $variant->price_amount, + 'line_discount_amount' => 0, + ]); + $line->cart()->associate($cart); + $line->recalculate(); + } + + $this->touchVersion($cart); + + $this->analytics->trackSafely($cart->store, 'add_to_cart', [ + 'product_id' => $variant->product_id, + 'variant_id' => $variant->id, + 'quantity' => $quantity, + 'price_amount' => $variant->price_amount, + 'currency' => $cart->currency, + ], $cart->customer_id); + + return $line; + } + + /** + * Update a line's quantity. Setting 0 removes the line. + * + * @throws InsufficientInventoryException policy "deny" and out of stock + */ + public function updateLineQuantity(Cart $cart, int $lineId, int $quantity): CartLine + { + if ($quantity < 0) { + throw ValidationException::withMessages([ + 'quantity' => ['The quantity must not be negative.'], + ]); + } + + $line = $cart->lines()->findOrFail($lineId); + + if ($quantity === 0) { + $line->delete(); + $this->touchVersion($cart); + + $this->analytics->trackSafely($cart->store, 'remove_from_cart', [ + 'product_id' => $line->variant?->product_id, + 'variant_id' => $line->variant_id, + 'quantity' => $line->quantity, + ], $cart->customer_id); + + return $line; + } + + $item = $line->variant?->inventoryItem; + + if ($item !== null && ! $this->inventory->checkAvailability($item, $quantity)) { + throw InsufficientInventoryException::forReservation($item, $quantity); + } + + $line->quantity = $quantity; + $line->recalculate(); + + $this->touchVersion($cart); + + return $line; + } + + /** + * Remove a line from the cart. + */ + public function removeLine(Cart $cart, int $lineId): void + { + $line = $cart->lines()->findOrFail($lineId); + $line->delete(); + + $this->touchVersion($cart); + + $this->analytics->trackSafely($cart->store, 'remove_from_cart', [ + 'product_id' => $line->variant?->product_id, + 'variant_id' => $line->variant_id, + 'quantity' => $line->quantity, + ], $cart->customer_id); + } + + /** + * The active cart bound to the current session, or null when the + * visitor has no cart yet (does not create one). + */ + public function findForSession(Store $store): ?Cart + { + $cartId = session('cart_id'); + + if ($cartId === null) { + return null; + } + + return Cart::query() + ->where('store_id', $store->id) + ->where('status', CartStatus::Active) + ->find($cartId); + } + + /** + * The active cart for the current session, creating and binding one on + * first use (spec 05 §4.1 guest identification). + */ + public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart + { + $cart = $this->findForSession($store); + + if ($cart === null) { + $cart = $this->create($store, $customer); + session(['cart_id' => $cart->id]); + } elseif ($customer !== null && $cart->customer_id === null) { + $cart->update(['customer_id' => $customer->id]); + } + + return $cart; + } + + /** + * Merge a guest cart into a customer cart on login: duplicate variants + * keep the combined quantity, the guest cart is abandoned and the + * session key is cleared (spec 05 §4.1; the spec 09 test tables require + * summed quantities, which overrides the MAX() in the §4.1 pseudocode). + */ + public function mergeOnLogin(Cart $guest, Cart $customer): Cart + { + $guest->loadMissing('lines'); + $customer->loadMissing('lines'); + + foreach ($guest->lines as $line) { + $existing = $customer->findLineByVariant($line->variant_id); + + if ($existing !== null) { + $existing->quantity += $line->quantity; + $existing->recalculate(); + } else { + $line->cart()->associate($customer); + $line->save(); + } + } + + $guest->update(['status' => CartStatus::Abandoned]); + + $customer->unsetRelation('lines'); + $customer->load('lines'); + $customer->recalculateLines(); + + $this->touchVersion($customer); + + session()->forget('cart_id'); + + return $customer->refresh(); + } + + /** + * Verify the client's expected version against the current version. + * + * @throws CartVersionMismatchException + */ + public function assertVersion(Cart $cart, int $expectedVersion): void + { + if ($cart->cart_version !== $expectedVersion) { + throw new CartVersionMismatchException($cart); + } + } + + /** + * Increment the cart version and keep the in-memory model in sync. + */ + private function touchVersion(Cart $cart): void + { + $cart->increment('cart_version'); + $cart->refresh(); + } +} diff --git a/app/Services/CheckoutService.php b/app/Services/CheckoutService.php new file mode 100644 index 00000000..abc59ba0 --- /dev/null +++ b/app/Services/CheckoutService.php @@ -0,0 +1,479 @@ + addressed -> shipping_selected -> payment_selected -> completed. + * Any active state can transition to expired. Pricing is recalculated on + * every significant state change and snapshotted to checkouts.totals_json. + */ +class CheckoutService +{ + public function __construct( + private PricingEngine $pricingEngine, + private DiscountService $discounts, + private ShippingCalculator $shipping, + private TaxCalculator $taxCalculator, + private InventoryService $inventory, + private PaymentService $payments, + private OrderService $orders, + private AnalyticsService $analytics, + ) {} + + /** + * Create a checkout from an active cart with at least one line. + * + * @throws ValidationException empty cart or inactive cart + */ + public function createFromCart(Cart $cart, string $email, ?Customer $customer = null, ?string $discountCode = null): Checkout + { + $cart->loadMissing('lines'); + + if ($cart->status !== CartStatus::Active) { + throw ValidationException::withMessages([ + 'cart_id' => ['The cart is not active.'], + ]); + } + + if ($cart->lines->isEmpty()) { + throw ValidationException::withMessages([ + 'cart_id' => ['The cart is empty.'], + ]); + } + + $checkout = Checkout::create([ + 'store_id' => $cart->store_id, + 'cart_id' => $cart->id, + 'customer_id' => $customer?->id ?? $cart->customer_id, + 'status' => CheckoutStatus::Started, + 'email' => $email, + 'expires_at' => now()->addHours(24), + ]); + + $this->recalculate($checkout); + + if ($discountCode !== null && $discountCode !== '') { + $this->applyDiscount($checkout, $discountCode); + } + + // Deterministic client_event_id makes the event idempotent. + $this->analytics->trackSafely($checkout->store, 'checkout_started', [ + 'checkout_id' => $checkout->id, + 'cart_id' => $cart->id, + ], $checkout->customer_id, 'checkout_started:checkout:'.$checkout->id); + + return $checkout->refresh(); + } + + /** + * Transition started -> addressed: store email and addresses, verify the + * address is serviceable, recalculate pricing (spec 05 §6.2). + * + * @param array{email?: string, shipping_address: array, billing_address?: array|null, use_shipping_as_billing?: bool} $data + * + * @throws InvalidCheckoutTransitionException|ValidationException + */ + public function setAddress(Checkout $checkout, array $data): Checkout + { + $this->assertStatus($checkout, [CheckoutStatus::Started, CheckoutStatus::Addressed, CheckoutStatus::ShippingSelected], 'setAddress'); + + $address = Address::fromArray($data['shipping_address']); + + if ($checkout->requiresShipping() + && $this->shipping->getMatchingZone($checkout->store, $address) === null) { + throw ValidationException::withMessages([ + 'shipping_address' => ['Cannot ship to this address.'], + ]); + } + + $useShippingAsBilling = $data['use_shipping_as_billing'] ?? true; + $billing = ! $useShippingAsBilling && ! empty($data['billing_address']) + ? Address::fromArray($data['billing_address']) + : $address; + + // Re-addressing after shipping was selected invalidates the chosen + // rate (the new address may fall into a different zone). + $checkout->fill([ + 'email' => $data['email'] ?? $checkout->email, + 'shipping_address_json' => $address->toArray(), + 'billing_address_json' => $billing->toArray(), + 'shipping_method_id' => null, + 'status' => CheckoutStatus::Addressed, + ])->save(); + + $this->recalculate($checkout); + + CheckoutAddressed::dispatch($checkout); + + return $checkout->refresh(); + } + + /** + * Transition addressed -> shipping_selected. Carts without shippable + * lines skip the step: shipping_method_id stays null and shipping is 0 + * (spec 05 §6.2 / §9.3). + * + * @throws InvalidCheckoutTransitionException|ValidationException + */ + public function setShippingMethod(Checkout $checkout, ?int $shippingRateId): Checkout + { + $this->assertStatus($checkout, [CheckoutStatus::Addressed, CheckoutStatus::ShippingSelected], 'setShippingMethod'); + + if (! $checkout->requiresShipping()) { + $checkout->fill([ + 'shipping_method_id' => null, + 'status' => CheckoutStatus::ShippingSelected, + ])->save(); + + $this->recalculate($checkout); + + CheckoutShippingSelected::dispatch($checkout); + + return $checkout->refresh(); + } + + $address = Address::fromArray($checkout->shipping_address_json ?? []); + $available = $this->shipping->getAvailableRates($checkout->store, $address, $checkout->cart); + + if ($shippingRateId === null || ! $available->contains('id', $shippingRateId)) { + throw ValidationException::withMessages([ + 'shipping_method_id' => ['The selected shipping method is not available for this address.'], + ]); + } + + $checkout->fill([ + 'shipping_method_id' => $shippingRateId, + 'status' => CheckoutStatus::ShippingSelected, + ])->save(); + + $this->recalculate($checkout); + + CheckoutShippingSelected::dispatch($checkout); + + return $checkout->refresh(); + } + + /** + * Transition shipping_selected -> payment_selected: store the method, + * reserve inventory for all lines and set the 24h expiry (spec 05 §6.2). + * + * @throws InvalidCheckoutTransitionException + */ + public function selectPaymentMethod(Checkout $checkout, PaymentMethod|string $method): Checkout + { + $this->assertStatus($checkout, [CheckoutStatus::ShippingSelected], 'selectPaymentMethod'); + + $method = $method instanceof PaymentMethod ? $method : PaymentMethod::from($method); + + DB::transaction(function () use ($checkout, $method): void { + $checkout->loadMissing('cart.lines.variant.inventoryItem'); + + foreach ($checkout->cart->lines as $line) { + $item = $line->variant?->inventoryItem; + + if ($item !== null) { + $this->inventory->reserve($item, $line->quantity); + } + } + + $checkout->fill([ + 'payment_method' => $method, + 'expires_at' => now()->addHours(24), + 'status' => CheckoutStatus::PaymentSelected, + ])->save(); + }); + + return $checkout->refresh(); + } + + /** + * Transition payment_selected -> completed (spec 05 §6.2). Charges the + * payment via the Mock PSP and creates the order. IDEMPOTENT: repeated + * calls for the same checkout return the already-created order. + * + * On payment failure the reserved inventory is released (in its own + * transaction so the release is not rolled back by the thrown + * exception), the checkout stays payment_selected, and a + * PaymentFailedException is thrown. The reservation is refreshed + * (released + re-reserved) before every charge attempt so retries after + * a decline re-establish it and re-validate availability. + * + * @param array $paymentDetails + * + * @throws InvalidCheckoutTransitionException|PaymentFailedException|InsufficientInventoryException + */ + public function completeCheckout(Checkout $checkout, array $paymentDetails = []): Order + { + $this->assertStatus($checkout, [CheckoutStatus::PaymentSelected, CheckoutStatus::Completed], 'completeCheckout'); + + $existing = Order::query()->where('checkout_id', $checkout->id)->first(); + + if ($existing !== null) { + return $existing; + } + + $checkout->loadMissing('cart.lines.variant.inventoryItem'); + + DB::transaction(function () use ($checkout): void { + foreach ($checkout->cart->lines as $line) { + $item = $line->variant?->inventoryItem; + + if ($item !== null) { + $this->inventory->release($item, $line->quantity); + $this->inventory->reserve($item, $line->quantity); + } + } + }); + + $result = $this->payments->charge($checkout, $checkout->payment_method, $paymentDetails); + + if (! $result->success) { + DB::transaction(function () use ($checkout): void { + foreach ($checkout->cart->lines as $line) { + $item = $line->variant?->inventoryItem; + + if ($item !== null) { + $this->inventory->release($item, $line->quantity); + } + } + }); + + throw new PaymentFailedException( + $result->errorCode ?? 'payment_failed', + $result->errorMessage ?? 'The payment failed.', + ); + } + + return $this->orders->createFromCheckout($checkout, $result, $paymentDetails); + } + + /** + * Validate and apply a discount code, then recalculate totals. + */ + public function applyDiscount(Checkout $checkout, string $code): DiscountValidationResult + { + $result = $this->discounts->validate($code, $checkout->store, $checkout->cart); + + if ($result->valid) { + $checkout->fill(['discount_code' => $result->discount->code])->save(); + $this->recalculate($checkout); + } + + return $result; + } + + /** + * Remove the applied discount code and recalculate totals. + */ + public function removeDiscount(Checkout $checkout): void + { + $checkout->fill(['discount_code' => null])->save(); + $this->recalculate($checkout); + } + + /** + * Transition any active state -> expired, releasing reserved inventory + * when payment had been selected (spec 05 §6.2). + */ + public function expireCheckout(Checkout $checkout): void + { + if (in_array($checkout->status, [CheckoutStatus::Completed, CheckoutStatus::Expired], true)) { + return; + } + + DB::transaction(function () use ($checkout): void { + if ($checkout->status === CheckoutStatus::PaymentSelected) { + $checkout->loadMissing('cart.lines.variant.inventoryItem'); + + foreach ($checkout->cart->lines as $line) { + $item = $line->variant?->inventoryItem; + + if ($item !== null) { + $this->inventory->release($item, $line->quantity); + } + } + } + + $checkout->fill(['status' => CheckoutStatus::Expired])->save(); + }); + + CheckoutExpired::dispatch($checkout); + } + + /** + * Recalculate pricing and persist the snapshot: checkouts.totals_json, + * per-line discount amounts on cart_lines, and the tax provider snapshot + * (spec 05 §5.4 / §6.2). + */ + public function recalculate(Checkout $checkout): PricingResult + { + $cart = $checkout->cart()->with(['lines.variant.product.collections'])->firstOrFail(); + $cart->loadMissing('lines.variant.product.collections'); + + $lines = $cart->lines->values()->map(fn ($line): array => [ + 'variant_id' => $line->variant_id, + 'product_id' => $line->variant?->product_id, + 'collection_ids' => $line->variant?->product?->collections->pluck('id')->all() ?? [], + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'requires_shipping' => (bool) ($line->variant?->requires_shipping ?? false), + ])->all(); + + $store = $checkout->store; + + $codeDiscount = $checkout->discount_code !== null + ? Discount::query() + ->where('store_id', $store->id) + ->whereRaw('lower(code) = ?', [mb_strtolower($checkout->discount_code)]) + ->first() + : null; + + $automaticDiscounts = $this->discounts->getApplicableAutomaticDiscounts($store, $cart)->all(); + + $shippingRate = $this->resolveShippingRate($checkout, $cart); + + $taxSettings = TaxSettings::find($store->id) ?? new TaxSettings([ + 'store_id' => $store->id, + 'prices_include_tax' => false, + 'config_json' => [], + ]); + + $address = ! empty($checkout->shipping_address_json) + ? Address::fromArray($checkout->shipping_address_json) + : null; + + $result = $this->pricingEngine->calculate( + lines: $lines, + codeDiscount: $codeDiscount, + automaticDiscounts: $automaticDiscounts, + shippingRate: $shippingRate, + taxSettings: $taxSettings, + address: $address, + currency: $cart->currency, + ); + + // Persist per-line discount allocations on the cart lines. + foreach ($cart->lines->values() as $index => $line) { + $discountAmount = $result->lineDiscounts[$index] ?? 0; + + if ($line->line_discount_amount !== $discountAmount) { + $line->line_discount_amount = $discountAmount; + $line->recalculate(); + } + } + + $checkout->fill([ + 'totals_json' => $result->toArray(), + 'tax_provider_snapshot_json' => $address !== null + ? $this->buildTaxSnapshot($lines, $result, $shippingRate, $taxSettings, $address) + : null, + ])->save(); + + return $result; + } + + /** + * Resolve the selected shipping rate into a calculated VO, if any. + */ + private function resolveShippingRate(Checkout $checkout, Cart $cart): ?ShippingRateVO + { + if ($checkout->shipping_method_id === null || ! $checkout->requiresShipping()) { + return null; + } + + $rate = ShippingRate::find($checkout->shipping_method_id); + + if ($rate === null) { + return null; + } + + $amount = $this->shipping->calculate($rate, $cart); + + if ($amount === null) { + return null; + } + + $config = $rate->config_json ?? []; + + return new ShippingRateVO( + id: $rate->id, + name: $rate->name, + amount: $amount, + type: $rate->type, + estimatedDaysMin: isset($config['estimated_days_min']) ? (int) $config['estimated_days_min'] : null, + estimatedDaysMax: isset($config['estimated_days_max']) ? (int) $config['estimated_days_max'] : null, + ); + } + + /** + * Build the tax_provider_snapshot_json payload (spec 02 §2.2). + * + * @param array> $lines + * @return array + */ + private function buildTaxSnapshot(array $lines, PricingResult $result, ?ShippingRateVO $shippingRate, TaxSettings $taxSettings, Address $address): array + { + $lineItems = []; + + foreach ($lines as $index => $line) { + $lineItems[] = [ + 'variant_id' => $line['variant_id'] ?? null, + 'amount' => ($line['unit_price_amount'] * $line['quantity']) - ($result->lineDiscounts[$index] ?? 0), + ]; + } + + $taxResult = $this->taxCalculator->calculate(new TaxCalculationRequest( + lineItems: $lineItems, + shippingAmount: $result->shipping, + address: $address, + taxSettings: $taxSettings, + )); + + return [ + 'provider' => $taxSettings->mode?->value === 'provider' ? $taxSettings->provider : 'manual', + 'calculated_at' => now()->toIso8601ZuluString(), + 'lines' => $taxResult->lineDetails, + 'shipping_tax_amount' => $taxResult->shippingTaxAmount, + 'shipping_tax_rate' => $taxResult->shippingTaxRate, + ]; + } + + /** + * Guard the checkout's current status against the allowed set. + * + * @param array $allowed + * + * @throws InvalidCheckoutTransitionException + */ + private function assertStatus(Checkout $checkout, array $allowed, string $transition): void + { + if (! in_array($checkout->status, $allowed, true)) { + throw InvalidCheckoutTransitionException::make($checkout->status->value, $transition); + } + } +} diff --git a/app/Services/CustomerService.php b/app/Services/CustomerService.php new file mode 100644 index 00000000..bb02b97f --- /dev/null +++ b/app/Services/CustomerService.php @@ -0,0 +1,29 @@ + $store->id, + 'name' => $data['name'], + 'email' => $data['email'], + 'password_hash' => $data['password'], + 'marketing_opt_in' => $data['marketing_opt_in'] ?? false, + ]); + } +} diff --git a/app/Services/DiscountService.php b/app/Services/DiscountService.php new file mode 100644 index 00000000..0ebda009 --- /dev/null +++ b/app/Services/DiscountService.php @@ -0,0 +1,205 @@ +where('store_id', $store->id) + ->where('type', DiscountType::Code) + ->whereRaw('lower(code) = ?', [mb_strtolower($code)]) + ->first(); + + if ($discount === null) { + return DiscountValidationResult::invalid('discount_not_found', 'This discount code is invalid.'); + } + + if ($discount->status !== DiscountStatus::Active) { + return DiscountValidationResult::invalid('discount_expired', 'This discount code has expired.'); + } + + if ($discount->starts_at !== null && $discount->starts_at->isFuture()) { + return DiscountValidationResult::invalid('discount_not_yet_active', 'This discount code is not active yet.'); + } + + if ($discount->ends_at !== null && $discount->ends_at->isPast()) { + return DiscountValidationResult::invalid('discount_expired', 'This discount code has expired.'); + } + + if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) { + return DiscountValidationResult::invalid('discount_usage_limit_reached', 'This discount code has reached its usage limit.'); + } + + $minPurchase = $discount->rules_json['min_purchase_amount'] ?? null; + + if ($minPurchase !== null && $cart->subtotal() < (int) $minPurchase) { + return DiscountValidationResult::invalid('discount_min_purchase_not_met', 'The cart does not meet the minimum purchase amount for this discount.'); + } + + if ($this->qualifyingLineIndexes($discount, $this->linesForCalculation($cart)) === []) { + return DiscountValidationResult::invalid('discount_not_applicable', 'This discount does not apply to the items in your cart.'); + } + + return DiscountValidationResult::valid($discount); + } + + /** + * Calculate the discount amount and allocate it proportionally across + * qualifying lines using the largest-remainder method (spec 05 §7.6): + * every qualifying line except the last gets ROUND(total * share), the + * last line gets the remainder. + * + * Percent discounts use integer truncation for the total + * (subtotal * value / 100), per the spec 09 test tables. + * + * @param array, line_subtotal_amount: int}> $lines + * @return array{amount: int, allocations: array, free_shipping: bool} + */ + public function calculate(Discount $discount, int $subtotal, array $lines): array + { + if ($discount->value_type === DiscountValueType::FreeShipping) { + return ['amount' => 0, 'allocations' => [], 'free_shipping' => true]; + } + + $qualifying = $this->qualifyingLineIndexes($discount, $lines); + $qualifyingSubtotal = 0; + + foreach ($qualifying as $index) { + $qualifyingSubtotal += $lines[$index]['line_subtotal_amount']; + } + + if ($qualifying === [] || $qualifyingSubtotal <= 0) { + return ['amount' => 0, 'allocations' => [], 'free_shipping' => false]; + } + + $total = match ($discount->value_type) { + DiscountValueType::Percent => intdiv($qualifyingSubtotal * $discount->value_amount, 100), + default => min($discount->value_amount, $qualifyingSubtotal), + }; + + $allocations = []; + $remaining = $total; + $lastIndex = end($qualifying); + + foreach ($qualifying as $index) { + if ($index === $lastIndex) { + $allocations[$index] = $remaining; + } else { + $lineDiscount = (int) round($total * $lines[$index]['line_subtotal_amount'] / $qualifyingSubtotal); + $allocations[$index] = $lineDiscount; + $remaining -= $lineDiscount; + } + } + + return ['amount' => $total, 'allocations' => $allocations, 'free_shipping' => false]; + } + + /** + * Automatic discounts currently applicable to the cart, ordered by ID + * for deterministic stacking (spec 05 §7.1). + * + * @return Collection + */ + public function getApplicableAutomaticDiscounts(Store $store, Cart $cart): Collection + { + $lines = $this->linesForCalculation($cart); + $subtotal = $cart->subtotal(); + + return Discount::query() + ->where('store_id', $store->id) + ->where('type', DiscountType::Automatic) + ->active() + ->orderBy('id') + ->get() + ->filter(function (Discount $discount) use ($subtotal, $lines): bool { + if ($discount->starts_at !== null && $discount->starts_at->isFuture()) { + return false; + } + + if ($discount->ends_at !== null && $discount->ends_at->isPast()) { + return false; + } + + if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) { + return false; + } + + $minPurchase = $discount->rules_json['min_purchase_amount'] ?? null; + + if ($minPurchase !== null && $subtotal < (int) $minPurchase) { + return false; + } + + return $this->qualifyingLineIndexes($discount, $lines) !== []; + }) + ->values(); + } + + /** + * Indexes of the lines the discount applies to: union of + * applicable_product_ids / applicable_collection_ids rules; when both + * are empty the discount applies to every line. + * + * @param array}> $lines + * @return array + */ + public function qualifyingLineIndexes(Discount $discount, array $lines): array + { + $productIds = array_map('intval', $discount->rules_json['applicable_product_ids'] ?? []); + $collectionIds = array_map('intval', $discount->rules_json['applicable_collection_ids'] ?? []); + + if ($productIds === [] && $collectionIds === []) { + return array_keys($lines); + } + + $qualifying = []; + + foreach ($lines as $index => $line) { + $lineProductId = (int) ($line['product_id'] ?? 0); + $lineCollectionIds = array_map('intval', $line['collection_ids'] ?? []); + + if (in_array($lineProductId, $productIds, true) + || array_intersect($collectionIds, $lineCollectionIds) !== []) { + $qualifying[] = $index; + } + } + + return $qualifying; + } + + /** + * Flat calculation representation of a cart's lines. + * + * @return array, line_subtotal_amount: int}> + */ + private function linesForCalculation(Cart $cart): array + { + $cart->loadMissing('lines.variant.product.collections'); + + return $cart->lines->values()->map(fn ($line): array => [ + 'variant_id' => $line->variant_id, + 'product_id' => $line->variant?->product_id, + 'collection_ids' => $line->variant?->product?->collections->pluck('id')->all() ?? [], + 'line_subtotal_amount' => $line->line_subtotal_amount, + ])->all(); + } +} diff --git a/app/Services/FulfillmentService.php b/app/Services/FulfillmentService.php new file mode 100644 index 00000000..be81cf56 --- /dev/null +++ b/app/Services/FulfillmentService.php @@ -0,0 +1,211 @@ + quantity). + * + * @param array $lines + * @param array{tracking_company?: string|null, tracking_number?: string|null, tracking_url?: string|null}|null $tracking + * + * @throws FulfillmentGuardException|ValidationException + */ + public function create(Order $order, array $lines, ?array $tracking = null): Fulfillment + { + if (! in_array($order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true)) { + throw FulfillmentGuardException::forFinancialStatus($order->financial_status->value); + } + + return DB::transaction(function () use ($order, $lines, $tracking): Fulfillment { + $order->loadMissing('lines'); + $fulfilledSoFar = $this->fulfilledQuantities($order); + $orderLines = $order->lines->keyBy('id'); + + $errors = []; + + foreach ($lines as $orderLineId => $quantity) { + $orderLine = $orderLines->get((int) $orderLineId); + $quantity = (int) $quantity; + + if ($orderLine === null) { + $errors["lines.{$orderLineId}"] = ['The order line does not belong to this order.']; + + continue; + } + + $unfulfilled = $orderLine->quantity - ($fulfilledSoFar[$orderLine->id] ?? 0); + + if ($quantity < 1 || $quantity > $unfulfilled) { + $errors["lines.{$orderLineId}"] = ["Cannot fulfill {$quantity} units; only {$unfulfilled} unfulfilled."]; + } + } + + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + + $fulfillment = $order->fulfillments()->create([ + 'status' => FulfillmentShipmentStatus::Pending, + 'tracking_company' => $tracking['tracking_company'] ?? null, + 'tracking_number' => $tracking['tracking_number'] ?? null, + 'tracking_url' => $tracking['tracking_url'] ?? null, + ]); + + foreach ($lines as $orderLineId => $quantity) { + $fulfillment->lines()->create([ + 'order_line_id' => (int) $orderLineId, + 'quantity' => (int) $quantity, + ]); + } + + $this->recomputeOrderStatus($order->refresh()); + + FulfillmentCreated::dispatch($fulfillment); + + return $fulfillment; + }); + } + + /** + * Auto-create a delivered fulfillment covering all lines of an + * all-digital order (spec 05 §11.7). Skips pending/shipped and sets + * shipped_at immediately. No-op for mixed or physical orders. + */ + public function autoFulfillDigital(Order $order): ?Fulfillment + { + if (! $order->isDigital() || $order->fulfillment_status === FulfillmentOrderStatus::Fulfilled) { + return null; + } + + $order->loadMissing('lines'); + + $fulfillment = $order->fulfillments()->create([ + 'status' => FulfillmentShipmentStatus::Delivered, + 'shipped_at' => now(), + ]); + + foreach ($order->lines as $line) { + $fulfillment->lines()->create([ + 'order_line_id' => $line->id, + 'quantity' => $line->quantity, + ]); + } + + $order->forceFill([ + 'fulfillment_status' => FulfillmentOrderStatus::Fulfilled, + 'status' => OrderStatus::Fulfilled, + ])->save(); + + FulfillmentCreated::dispatch($fulfillment); + OrderFulfilled::dispatch($order); + + return $fulfillment; + } + + /** + * Transition pending -> shipped: set tracking data and shipped_at. + * + * @param array{tracking_company?: string|null, tracking_number?: string|null, tracking_url?: string|null}|null $tracking + */ + public function markAsShipped(Fulfillment $fulfillment, ?array $tracking = null): void + { + $fulfillment->forceFill([ + 'status' => FulfillmentShipmentStatus::Shipped, + 'shipped_at' => $fulfillment->shipped_at ?? now(), + 'tracking_company' => $tracking['tracking_company'] ?? $fulfillment->tracking_company, + 'tracking_number' => $tracking['tracking_number'] ?? $fulfillment->tracking_number, + 'tracking_url' => $tracking['tracking_url'] ?? $fulfillment->tracking_url, + ])->save(); + + FulfillmentShipped::dispatch($fulfillment); + } + + /** + * Transition shipped -> delivered. + */ + public function markAsDelivered(Fulfillment $fulfillment): void + { + $fulfillment->forceFill(['status' => FulfillmentShipmentStatus::Delivered])->save(); + + FulfillmentDelivered::dispatch($fulfillment); + } + + /** + * Recompute the order's fulfillment_status (and overall status when fully + * fulfilled) from all fulfillment lines. + */ + private function recomputeOrderStatus(Order $order): void + { + $order->loadMissing('lines'); + $fulfilled = $this->fulfilledQuantities($order); + + $allFulfilled = true; + $anyFulfilled = false; + + foreach ($order->lines as $line) { + $quantity = (int) ($fulfilled[$line->id] ?? 0); + + if ($quantity > 0) { + $anyFulfilled = true; + } + + if ($quantity < $line->quantity) { + $allFulfilled = false; + } + } + + if ($allFulfilled && $order->lines->isNotEmpty()) { + $wasFulfilled = $order->fulfillment_status === FulfillmentOrderStatus::Fulfilled; + + $order->forceFill([ + 'fulfillment_status' => FulfillmentOrderStatus::Fulfilled, + 'status' => OrderStatus::Fulfilled, + ])->save(); + + if (! $wasFulfilled) { + OrderFulfilled::dispatch($order); + } + } elseif ($anyFulfilled) { + $order->forceFill(['fulfillment_status' => FulfillmentOrderStatus::Partial])->save(); + } + } + + /** + * Fulfilled quantity per order line id, across all fulfillments. + * + * @return array + */ + private function fulfilledQuantities(Order $order): array + { + return FulfillmentLine::query() + ->whereIn('fulfillment_id', $order->fulfillments()->pluck('id')) + ->selectRaw('order_line_id, SUM(quantity) as total') + ->groupBy('order_line_id') + ->pluck('total', 'order_line_id') + ->map(fn ($total): int => (int) $total) + ->all(); + } +} diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php new file mode 100644 index 00000000..66311bfe --- /dev/null +++ b/app/Services/InventoryService.php @@ -0,0 +1,87 @@ +policy === InventoryPolicy::Continue) { + return true; + } + + return $item->available() >= $quantity; + } + + /** + * Reserve stock for an open checkout: quantity_reserved += quantity. + * + * @throws InsufficientInventoryException when policy is "deny" and + * available stock is insufficient + */ + public function reserve(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity): void { + $item = $this->lockAndRefresh($item); + + if ($item->policy === InventoryPolicy::Deny && $item->available() < $quantity) { + throw InsufficientInventoryException::forReservation($item, $quantity); + } + + $item->increment('quantity_reserved', $quantity); + }); + } + + /** + * Release a reservation (checkout expired/abandoned, payment declined): + * reserved -= quantity. Floored at zero so double-release paths (e.g. + * release on payment failure followed by checkout expiry) can never + * drive reserved stock negative. + */ + public function release(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity): void { + $locked = $this->lockAndRefresh($item); + $locked->decrement('quantity_reserved', min($quantity, $locked->quantity_reserved)); + }); + } + + /** + * Commit a reservation after payment: both on_hand and reserved decrease. + */ + public function commit(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity): void { + $locked = $this->lockAndRefresh($item); + $locked->decrement('quantity_on_hand', $quantity); + $locked->decrement('quantity_reserved', $quantity); + }); + } + + /** + * Restock returned units after a refund: on_hand += quantity. + */ + public function restock(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity): void { + $this->lockAndRefresh($item)->increment('quantity_on_hand', $quantity); + }); + } + + /** + * Reload the item with a write lock inside the current transaction. + */ + private function lockAndRefresh(InventoryItem $item): InventoryItem + { + return InventoryItem::query()->lockForUpdate()->findOrFail($item->id); + } +} diff --git a/app/Services/NavigationService.php b/app/Services/NavigationService.php new file mode 100644 index 00000000..a6e47b8c --- /dev/null +++ b/app/Services/NavigationService.php @@ -0,0 +1,171 @@ + + */ + public function forHandle(string $handle): array + { + if (! app()->bound('current_store')) { + return []; + } + + $storeId = (int) app('current_store')->getKey(); + + return Cache::remember( + $this->cacheKey($storeId, $handle), + self::TTL_SECONDS, + function () use ($handle): array { + $menu = NavigationMenu::query()->where('handle', $handle)->first(); + + return $menu === null ? [] : $this->buildTree($menu); + }, + ); + } + + /** + * Build the navigation tree for a menu: a flat list of resolved items + * ordered by position (nesting is not modelled in the schema, so every + * item carries an empty `children` list for forward compatibility). + * + * @return array + */ + public function buildTree(NavigationMenu $menu): array + { + $items = $menu->items; + $handles = $this->prefetchResourceHandles($items); + + return $items->map(fn (NavigationItem $item): array => [ + 'id' => $item->id, + 'label' => $item->label, + 'url' => $this->resolveUrl($item, $handles), + 'type' => $item->type, + 'children' => [], + ])->all(); + } + + /** + * Resolve the storefront URL of a navigation item based on its type. + * + * @param array>|null $handles Prefetched resource handles keyed by type, then resource id. + */ + public function resolveUrl(NavigationItem $item, ?array $handles = null): string + { + if ($item->type === NavigationItemType::Link) { + return $item->url ?? '#'; + } + + $handle = $handles[$item->type->value][$item->resource_id] ?? $this->lookupResourceHandle($item); + + if ($handle === null) { + return '#'; + } + + return match ($item->type) { + NavigationItemType::Page => "/pages/{$handle}", + NavigationItemType::Collection => "/collections/{$handle}", + NavigationItemType::Product => "/products/{$handle}", + default => '#', + }; + } + + /** + * Forget the cached tree of a store's menu. + */ + public function invalidate(?int $storeId, string $menuHandle): void + { + if ($storeId === null) { + return; + } + + Cache::forget($this->cacheKey($storeId, $menuHandle)); + } + + /** + * Look up the handle of the resource a page/collection/product item + * points to. Returns null when the resource no longer exists. + */ + private function lookupResourceHandle(NavigationItem $item): ?string + { + if ($item->resource_id === null) { + return null; + } + + $model = match ($item->type) { + NavigationItemType::Page => Page::class, + NavigationItemType::Collection => Collection::class, + NavigationItemType::Product => Product::class, + default => null, + }; + + if ($model === null) { + return null; + } + + return $model::query()->whereKey($item->resource_id)->value('handle'); + } + + /** + * Bulk-load the handles of all resources referenced by the items to + * avoid one query per item. + * + * @param \Illuminate\Database\Eloquent\Collection $items + * @return array> + */ + private function prefetchResourceHandles(\Illuminate\Database\Eloquent\Collection $items): array + { + $handles = []; + + $resourceItems = $items + ->filter(fn (NavigationItem $item): bool => $item->type !== NavigationItemType::Link && $item->resource_id !== null) + ->groupBy(fn (NavigationItem $item): string => $item->type->value); + + foreach ($resourceItems as $type => $group) { + $model = match ($type) { + 'page' => Page::class, + 'collection' => Collection::class, + 'product' => Product::class, + default => null, + }; + + if ($model === null) { + continue; + } + + $handles[$type] = $model::query() + ->whereIn('id', $group->pluck('resource_id')->all()) + ->pluck('handle', 'id') + ->all(); + } + + return $handles; + } + + /** + * Cache key for a store's menu tree. + */ + private function cacheKey(int $storeId, string $menuHandle): string + { + return "nav:{$storeId}:{$menuHandle}"; + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php new file mode 100644 index 00000000..42b20cfa --- /dev/null +++ b/app/Services/OrderService.php @@ -0,0 +1,335 @@ + $paymentDetails + */ + public function createFromCheckout(Checkout $checkout, PaymentResult $paymentResult, array $paymentDetails = []): Order + { + $order = DB::transaction(function () use ($checkout, $paymentResult, $paymentDetails): Order { + $existing = Order::query()->where('checkout_id', $checkout->id)->first(); + + if ($existing !== null) { + return $existing; + } + + $checkout->loadMissing([ + 'cart.lines.variant.product', + 'cart.lines.variant.inventoryItem', + 'cart.lines.variant.optionValues.option', + 'store.settings', + ]); + + $cart = $checkout->cart; + $method = $checkout->payment_method; + $instantCapture = in_array($method, [PaymentMethod::CreditCard, PaymentMethod::Paypal], true); + $totals = $checkout->totals_json ?? []; + + $order = Order::create([ + 'store_id' => $checkout->store_id, + 'checkout_id' => $checkout->id, + 'customer_id' => $checkout->customer_id, + 'order_number' => $this->generateOrderNumber($checkout->store), + 'payment_method' => $method, + 'status' => $instantCapture ? OrderStatus::Paid : OrderStatus::Pending, + 'financial_status' => $instantCapture ? FinancialStatus::Paid : FinancialStatus::Pending, + 'fulfillment_status' => FulfillmentOrderStatus::Unfulfilled, + 'currency' => $cart->currency, + 'subtotal_amount' => (int) ($totals['subtotal'] ?? 0), + 'discount_amount' => (int) ($totals['discount'] ?? 0), + 'shipping_amount' => (int) ($totals['shipping'] ?? 0), + 'tax_amount' => (int) ($totals['tax'] ?? 0), + 'total_amount' => (int) ($totals['total'] ?? 0), + 'email' => $checkout->email, + 'billing_address_json' => $checkout->billing_address_json, + 'shipping_address_json' => $checkout->shipping_address_json, + 'placed_at' => now(), + ]); + + $taxDetailsByVariant = collect($checkout->tax_provider_snapshot_json['lines'] ?? [])->keyBy('variant_id'); + $taxName = (string) ($totals['tax_lines'][0]['name'] ?? 'Tax'); + $codeDiscount = $this->resolveCodeDiscount($checkout); + + foreach ($cart->lines as $line) { + $variant = $line->variant; + $taxDetail = $taxDetailsByVariant->get($line->variant_id); + + $order->lines()->create([ + 'product_id' => $variant?->product_id, + 'variant_id' => $line->variant_id, + 'title_snapshot' => $this->titleSnapshot($line), + 'sku_snapshot' => $variant?->sku, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'total_amount' => $line->line_total_amount, + 'tax_lines_json' => ($taxDetail !== null && (int) ($taxDetail['tax_amount'] ?? 0) > 0) + ? [['title' => $taxName, 'rate' => (int) $taxDetail['rate'], 'amount' => (int) $taxDetail['tax_amount']]] + : [], + 'discount_allocations_json' => ($codeDiscount !== null && $line->line_discount_amount > 0) + ? [['discount_id' => $codeDiscount->id, 'amount' => $line->line_discount_amount]] + : [], + ]); + } + + $this->payments->recordPayment($order, $method, $paymentResult, $paymentDetails); + + if ($instantCapture) { + foreach ($cart->lines as $line) { + $item = $line->variant?->inventoryItem; + + if ($item !== null) { + $this->inventory->commit($item, $line->quantity); + } + } + } + + if ($codeDiscount !== null) { + $codeDiscount->increment('usage_count'); + } + + $cart->forceFill(['status' => CartStatus::Converted])->save(); + $checkout->forceFill(['status' => CheckoutStatus::Completed])->save(); + + if ($order->customer_id === null && $checkout->email !== null) { + $order->forceFill([ + 'customer_id' => $this->linkGuestToCustomer($checkout->email, $checkout->store_id)->id, + ])->save(); + } + + $order = $order->refresh(); + + if ($instantCapture && $order->isDigital()) { + $this->fulfillments->autoFulfillDigital($order); + } + + OrderCreated::dispatch($order); + CheckoutCompleted::dispatch($checkout); + + if ($instantCapture) { + OrderPaid::dispatch($order->refresh()); + } + + return $order->refresh(); + }); + + // Tracked after the transaction commits so analytics can never roll + // back an order. The deterministic client_event_id keeps the event + // idempotent across repeated calls. The total in properties feeds + // the revenue aggregation (spec 05 §14.2). + $this->analytics->trackSafely($order->store, 'checkout_completed', [ + 'order_id' => $order->id, + 'checkout_id' => $checkout->id, + 'order_number' => $order->order_number, + 'total' => $order->total_amount, + 'currency' => $order->currency, + ], $order->customer_id, 'checkout_completed:checkout:'.$checkout->id); + + return $order; + } + + /** + * Next sequential order number for the store (spec 05 §11.2): configured + * prefix (default "#") + max numeric suffix + 1, starting at the + * configured start (default 1001). Runs inside the creation transaction. + */ + public function generateOrderNumber(Store $store): string + { + $settings = $store->settings?->settings_json ?? []; + $prefix = (string) ($settings['order_number_prefix'] ?? '#'); + $start = (int) ($settings['order_number_start'] ?? 1001); + + $max = Order::withoutGlobalScope(StoreScope::class) + ->where('store_id', $store->id) + ->where('order_number', 'like', $prefix.'%') + ->selectRaw('MAX(CAST(SUBSTR(order_number, ?) AS INTEGER)) as aggregate', [mb_strlen($prefix) + 1]) + ->value('aggregate'); + + $next = $max === null ? $start : max($start, (int) $max + 1); + + return $prefix.$next; + } + + /** + * Cancel an order before fulfillment: releases reserved inventory (bank + * transfer orders whose stock is still reserved), marks the order + * cancelled and dispatches OrderCancelled (spec 05 §11). + * + * @throws InvalidOrderTransitionException already fulfilled/cancelled/refunded + */ + public function cancel(Order $order, string $reason): void + { + if (in_array($order->status, [OrderStatus::Fulfilled, OrderStatus::Cancelled, OrderStatus::Refunded], true) + || $order->fulfillment_status === FulfillmentOrderStatus::Fulfilled) { + throw InvalidOrderTransitionException::make($order->status->value, 'cancel'); + } + + DB::transaction(function () use ($order): void { + $awaitingPayment = $order->financial_status === FinancialStatus::Pending; + + if ($awaitingPayment) { + $order->loadMissing('lines.variant.inventoryItem'); + + foreach ($order->lines as $line) { + $item = $line->variant?->inventoryItem; + + if ($item !== null) { + $this->inventory->release($item, $line->quantity); + } + } + + $order->financial_status = FinancialStatus::Voided; + } + + $order->status = OrderStatus::Cancelled; + $order->save(); + + $order->payments() + ->where('status', PaymentStatus::Pending->value) + ->update(['status' => PaymentStatus::Failed->value]); + }); + + OrderCancelled::dispatch($order, $reason); + } + + /** + * Confirm a bank transfer payment was received (spec 05 §10.7): captures + * the payment, marks the order paid, commits the reserved inventory and + * auto-fulfills digital orders. + * + * @throws InvalidOrderTransitionException wrong method or not pending + */ + public function confirmBankTransferPayment(Order $order): void + { + if ($order->payment_method !== PaymentMethod::BankTransfer) { + throw InvalidOrderTransitionException::make($order->payment_method->value, 'confirmBankTransferPayment'); + } + + if ($order->financial_status !== FinancialStatus::Pending) { + throw InvalidOrderTransitionException::make($order->financial_status->value, 'confirmBankTransferPayment'); + } + + DB::transaction(function () use ($order): void { + $order->payments() + ->where('status', PaymentStatus::Pending->value) + ->update(['status' => PaymentStatus::Captured->value]); + + $order->forceFill([ + 'financial_status' => FinancialStatus::Paid, + 'status' => OrderStatus::Paid, + ])->save(); + + $order->loadMissing('lines.variant.inventoryItem'); + + foreach ($order->lines as $line) { + $item = $line->variant?->inventoryItem; + + if ($item !== null) { + $this->inventory->commit($item, $line->quantity); + } + } + + if ($order->isDigital()) { + $this->fulfillments->autoFulfillDigital($order); + } + }); + + OrderPaid::dispatch($order->refresh()); + } + + /** + * Link the checkout email to a customer account (spec 05 §12.4): reuse + * the store's customer with that email, or create a password-less guest + * customer that can later claim the account. + */ + private function linkGuestToCustomer(string $email, int $storeId): Customer + { + $customer = Customer::withoutGlobalScope(StoreScope::class) + ->where('store_id', $storeId) + ->where('email', $email) + ->first(); + + if ($customer !== null) { + return $customer; + } + + return Customer::create([ + 'store_id' => $storeId, + 'email' => $email, + 'password_hash' => null, + 'marketing_opt_in' => false, + ]); + } + + /** + * Build the line title snapshot: product title plus variant option labels + * (spec 05 §6.2 step 6). + */ + private function titleSnapshot(CartLine $line): string + { + $variant = $line->variant; + $title = $variant?->product?->title ?? 'Unknown product'; + $variantTitle = $variant?->title(); + + if ($variantTitle !== null && $variantTitle !== 'Default') { + $title .= ' - '.$variantTitle; + } + + return $title; + } + + /** + * Resolve the checkout's discount code to the store's discount record. + */ + private function resolveCodeDiscount(Checkout $checkout): ?Discount + { + if ($checkout->discount_code === null) { + return null; + } + + return Discount::query() + ->where('store_id', $checkout->store_id) + ->whereRaw('lower(code) = ?', [mb_strtolower($checkout->discount_code)]) + ->first(); + } +} diff --git a/app/Services/PaymentService.php b/app/Services/PaymentService.php new file mode 100644 index 00000000..f21c3d44 --- /dev/null +++ b/app/Services/PaymentService.php @@ -0,0 +1,83 @@ + $details + */ + public function charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult + { + return $this->provider->charge($checkout, $method, $details); + } + + /** + * Refund (part of) a payment. + */ + public function refund(Payment $payment, int $amount): RefundResult + { + return $this->provider->refund($payment, $amount); + } + + /** + * Persist the payment record for a freshly created order. + * + * @param array $details original charge details (sanitized before storage) + */ + public function recordPayment(Order $order, PaymentMethod $method, PaymentResult $result, array $details = []): Payment + { + return $order->payments()->create([ + 'provider' => 'mock', + 'method' => $method, + 'provider_payment_id' => $result->referenceId, + 'status' => $result->status === 'pending' ? PaymentStatus::Pending : PaymentStatus::Captured, + 'amount' => $order->total_amount, + 'currency' => $order->currency, + 'raw_json_encrypted' => $this->sanitizeRawPayload($method, $result, $details), + ]); + } + + /** + * Build the encrypted-at-rest payload, keeping only non-sensitive data. + * + * @param array $details + * @return array + */ + private function sanitizeRawPayload(PaymentMethod $method, PaymentResult $result, array $details): array + { + $payload = [ + 'provider' => 'mock', + 'method' => $method->value, + 'reference_id' => $result->referenceId, + 'status' => $result->status, + ]; + + if ($method === PaymentMethod::CreditCard) { + $number = str_replace(' ', '', (string) ($details['card_number'] ?? '')); + $payload['card_last4'] = $number !== '' ? substr($number, -4) : null; + $payload['card_holder'] = $details['card_holder'] ?? null; + } + + return $payload; + } +} diff --git a/app/Services/Payments/MockPaymentProvider.php b/app/Services/Payments/MockPaymentProvider.php new file mode 100644 index 00000000..63dc9bac --- /dev/null +++ b/app/Services/Payments/MockPaymentProvider.php @@ -0,0 +1,78 @@ + $details + */ + public function charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult + { + return match ($method) { + PaymentMethod::CreditCard => $this->chargeCard($details), + PaymentMethod::Paypal => PaymentResult::captured($this->referenceId()), + PaymentMethod::BankTransfer => PaymentResult::pending($this->referenceId()), + }; + } + + /** + * Mock refund: always succeeds. + */ + public function refund(Payment $payment, int $amount): RefundResult + { + return new RefundResult( + success: true, + providerRefundId: 'mock_refund_'.Str::random(16), + status: 'processed', + ); + } + + /** + * Evaluate the magic card number (spaces are stripped first). + * + * @param array $details + */ + private function chargeCard(array $details): PaymentResult + { + $number = str_replace(' ', '', (string) ($details['card_number'] ?? '')); + + return match ($number) { + self::CARD_DECLINED => PaymentResult::failed('card_declined', 'Your card was declined.'), + self::CARD_INSUFFICIENT_FUNDS => PaymentResult::failed('insufficient_funds', 'Your card has insufficient funds.'), + default => PaymentResult::captured($this->referenceId()), + }; + } + + /** + * Generate a mock reference ID. + */ + private function referenceId(): string + { + return 'mock_'.Str::random(16); + } +} diff --git a/app/Services/PricingEngine.php b/app/Services/PricingEngine.php new file mode 100644 index 00000000..b101a2bd --- /dev/null +++ b/app/Services/PricingEngine.php @@ -0,0 +1,135 @@ + cart subtotal -> discounts -> discounted subtotal -> + * shipping -> tax -> total. The same inputs always produce the same output. + * + * Discount-before-tax semantics (spec 05 §8.5): for tax-inclusive stores the + * discount is subtracted from the gross subtotal and tax is extracted from + * the post-discount gross amounts. + */ +class PricingEngine +{ + public function __construct( + private DiscountService $discounts, + private TaxCalculator $taxCalculator, + ) {} + + /** + * Run the full pricing pipeline. + * + * @param array, quantity: int, unit_price_amount: int, requires_shipping?: bool}> $lines + * @param array $automaticDiscounts stacked sequentially after the code discount + */ + public function calculate( + array $lines, + ?Discount $codeDiscount, + array $automaticDiscounts, + ?ShippingRateVO $shippingRate, + TaxSettings $taxSettings, + ?Address $address, + string $currency = 'USD', + ): PricingResult { + // Steps 1-2: line subtotals and cart subtotal. + $lineSubtotals = []; + $subtotal = 0; + + foreach ($lines as $index => $line) { + $lineSubtotal = $line['unit_price_amount'] * $line['quantity']; + $lineSubtotals[$index] = $lineSubtotal; + $subtotal += $lineSubtotal; + } + + // Step 3: discounts. The code discount applies first; automatic + // discounts then stack sequentially on the remaining undiscounted + // amount of each line. + $lineDiscounts = array_fill_keys(array_keys($lines), 0); + $freeShippingApplied = false; + + $appliedDiscounts = array_values(array_filter( + array_merge([$codeDiscount], $automaticDiscounts) + )); + + foreach ($appliedDiscounts as $discount) { + if ($discount->value_type === DiscountValueType::FreeShipping) { + $freeShippingApplied = true; + + continue; + } + + $remainingLines = []; + + foreach ($lines as $index => $line) { + $remainingLines[$index] = [ + 'product_id' => $line['product_id'] ?? null, + 'collection_ids' => $line['collection_ids'] ?? [], + 'line_subtotal_amount' => $lineSubtotals[$index] - $lineDiscounts[$index], + ]; + } + + $result = $this->discounts->calculate($discount, $subtotal, $remainingLines); + + foreach ($result['allocations'] as $index => $amount) { + $lineDiscounts[$index] += $amount; + } + } + + $discountTotal = array_sum($lineDiscounts); + + // Step 4: discounted subtotal. + $discountedSubtotal = $subtotal - $discountTotal; + + // Step 5: shipping (zeroed by free-shipping discounts; digital-only + // carts never pay shipping). + $requiresShipping = collect($lines)->contains( + fn (array $line): bool => (bool) ($line['requires_shipping'] ?? false) + ); + + $shipping = $requiresShipping ? ($shippingRate?->amount ?? 0) : 0; + + if ($freeShippingApplied) { + $shipping = 0; + } + + // Step 6: tax on discounted line amounts plus shipping. Without an + // address no tax is calculated yet. + $taxResult = $address === null + ? TaxCalculationResult::zero() + : $this->taxCalculator->calculate(new TaxCalculationRequest( + lineItems: collect($lines)->map(fn (array $line, int $index): array => [ + 'variant_id' => $line['variant_id'] ?? null, + 'amount' => $lineSubtotals[$index] - $lineDiscounts[$index], + ])->values()->all(), + shippingAmount: $shipping, + address: $address, + taxSettings: $taxSettings, + )); + + // Step 7: total. Tax-inclusive prices already contain the tax. + $total = $discountedSubtotal + $shipping + + ($taxSettings->prices_include_tax ? 0 : $taxResult->totalAmount); + + return new PricingResult( + subtotal: $subtotal, + discount: $discountTotal, + shipping: $shipping, + taxLines: $taxResult->taxLines, + taxTotal: $taxResult->totalAmount, + total: $total, + currency: $currency, + lineDiscounts: $lineDiscounts, + ); + } +} diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php new file mode 100644 index 00000000..37c6a7e7 --- /dev/null +++ b/app/Services/ProductService.php @@ -0,0 +1,434 @@ + $data + */ + public function create(Store $store, array $data): Product + { + return DB::transaction(function () use ($store, $data): Product { + $product = new Product; + $product->store_id = $store->id; + $product->fill($this->productAttributes($data, $store->id)); + $product->status = $data['status'] ?? ProductStatus::Draft; + $product->handle = HandleGenerator::generate( + $data['handle'] ?? $data['title'], + 'products', + $store->id, + ); + $product->save(); + + $this->syncOptions($product, $data['options'] ?? []); + + if ($product->options()->exists()) { + $this->variantMatrix->rebuildMatrix($product); + $this->applyVariantDefaults($product, $data['variant_defaults'] ?? []); + $this->applyVariantOverrides($product, $data['variants'] ?? []); + } else { + $this->createDefaultVariant($product, $data['variants'][0] ?? []); + } + + ProductCreated::dispatch($product); + + return $product->refresh(); + }); + } + + /** + * Update a product and its nested structure. + * + * @param array $data + */ + public function update(Product $product, array $data): Product + { + return DB::transaction(function () use ($product, $data): Product { + $product->fill($this->productAttributes($data, $product->store_id)); + + if (array_key_exists('handle', $data)) { + $product->handle = HandleGenerator::generate( + $data['handle'] ?: $product->title, + 'products', + $product->store_id, + $product->id, + ); + } + + $product->save(); + + if (array_key_exists('options', $data)) { + $this->syncOptions($product, $data['options']); + + if ($product->options()->exists()) { + $this->variantMatrix->rebuildMatrix($product); + $this->applyVariantDefaults($product, $data['variant_defaults'] ?? []); + $this->applyVariantOverrides($product, $data['variants'] ?? []); + } elseif (! $product->variants()->exists()) { + $this->createDefaultVariant($product, $data['variants'][0] ?? []); + } + } elseif (array_key_exists('variants', $data)) { + $this->applyVariantOverrides($product, $data['variants']); + } + + ProductUpdated::dispatch($product); + + return $product->refresh(); + }); + } + + /** + * Transition the product to a new status, enforcing the state machine. + * + * @throws InvalidProductTransitionException + */ + public function transitionStatus(Product $product, ProductStatus $newStatus): void + { + DB::transaction(function () use ($product, $newStatus): void { + $oldStatus = $product->status; + + if ($oldStatus === $newStatus) { + throw InvalidProductTransitionException::transition($oldStatus->value, $newStatus->value, 'Product is already in this status.'); + } + + match ($newStatus) { + ProductStatus::Active => $this->assertPublishable($product, $oldStatus), + ProductStatus::Draft => $this->assertNoOrderReferences($product, $oldStatus, $newStatus), + ProductStatus::Archived => null, + }; + + $product->status = $newStatus; + + if ($newStatus === ProductStatus::Active && $product->published_at === null) { + $product->published_at = now(); + } + + $product->save(); + + ProductStatusChanged::dispatch($product, $oldStatus, $newStatus); + }); + } + + /** + * Hard-delete a product. Only drafts without order references may be + * deleted; everything else must be archived instead. + * + * @throws InvalidProductTransitionException + */ + public function delete(Product $product): void + { + DB::transaction(function () use ($product): void { + if ($product->status !== ProductStatus::Draft) { + throw InvalidProductTransitionException::deletion('Only draft products can be deleted; archive it instead.'); + } + + if ($this->hasOrderReferences($product)) { + throw InvalidProductTransitionException::deletion('Products referenced by orders cannot be deleted; archive it instead.'); + } + + $product->delete(); + + ProductDeleted::dispatch($product); + }); + } + + /** + * Extract and sanitize the product's own attributes from the payload. + * + * @return array + */ + private function productAttributes(array $data, int $storeId): array + { + $attributes = Arr::only($data, [ + 'title', 'description_html', 'vendor', 'product_type', 'tags', 'published_at', + ]); + + if (array_key_exists('description_html', $attributes)) { + $attributes['description_html'] = ($this->sanitizeHtml)($attributes['description_html']); + } + + return $attributes; + } + + /** + * Sync the product's options and values against the payload. + * + * Existing records are matched by id, then by name/value, and updated in + * place so variant option-value links survive. Anything missing from the + * payload is deleted. + * + * @param list> $optionsData + */ + private function syncOptions(Product $product, array $optionsData): void + { + $keepOptionIds = []; + + foreach (array_values($optionsData) as $optionIndex => $optionData) { + $option = $this->matchOption($product, $optionData); + $option->fill(['name' => $optionData['name'], 'position' => $optionIndex + 1000])->save(); + + $keepValueIds = []; + + foreach (array_values($optionData['values'] ?? []) as $valueIndex => $valueData) { + $valueData = is_array($valueData) ? $valueData : ['value' => $valueData]; + + $value = $this->matchOptionValue($option, $valueData); + $value->fill(['value' => $valueData['value'], 'position' => $valueIndex + 1000])->save(); + + $keepValueIds[] = $value->id; + } + + $option->values()->whereNotIn('id', $keepValueIds)->delete(); + $option->values()->whereIn('id', $keepValueIds)->get()->each( + fn ($value, $index) => $value->update(['position' => $index]) + ); + + $option->update(['position' => $optionIndex]); + $keepOptionIds[] = $option->id; + } + + $product->options()->whereNotIn('id', $keepOptionIds)->delete(); + } + + /** + * Find an existing option by id or name, or make a new instance. + * + * @param array $optionData + */ + private function matchOption(Product $product, array $optionData): ProductOption + { + $option = null; + + if (! empty($optionData['id'])) { + $option = $product->options()->whereKey($optionData['id'])->first(); + } + + $option ??= $product->options()->where('name', $optionData['name'])->first(); + + return $option ?? $product->options()->make(); + } + + /** + * Find an existing option value by id or value string, or make a new one. + * + * @param array $valueData + */ + private function matchOptionValue(ProductOption $option, array $valueData): \App\Models\ProductOptionValue + { + $value = null; + + if (! empty($valueData['id'])) { + $value = $option->values()->whereKey($valueData['id'])->first(); + } + + $value ??= $option->values()->where('value', $valueData['value'])->first(); + + return $value ?? $option->values()->make(); + } + + /** + * Create the single default variant for a product without options. + * + * @param array $variantData + */ + private function createDefaultVariant(Product $product, array $variantData): void + { + $sku = $variantData['sku'] ?? null; + $this->assertSkuIsUnique($product->store_id, $sku); + + $variant = $product->variants()->create(array_merge( + $this->variantAttributes($variantData), + ['is_default' => true, 'position' => 0], + )); + + $variant->inventoryItem()->create(array_merge( + ['store_id' => $product->store_id, 'quantity_on_hand' => 0], + $variantData['inventory'] ?? [], + )); + } + + /** + * Apply pricing defaults to all variants of the product. + * + * @param array $defaults + */ + private function applyVariantDefaults(Product $product, array $defaults): void + { + if ($defaults === []) { + return; + } + + foreach ($product->variants()->get() as $variant) { + $variant->update($this->variantAttributes($defaults)); + } + } + + /** + * Apply per-variant overrides, matched by id or by option value names. + * + * @param list> $variantsData + */ + private function applyVariantOverrides(Product $product, array $variantsData): void + { + foreach ($variantsData as $variantData) { + $variant = $this->matchVariant($product, $variantData); + + if (! $variant instanceof ProductVariant) { + continue; + } + + $sku = $variantData['sku'] ?? null; + + if ($sku !== null && $sku !== $variant->sku) { + $this->assertSkuIsUnique($product->store_id, $sku, $variant->id); + } + + $variant->update($this->variantAttributes($variantData)); + + if (array_key_exists('inventory', $variantData)) { + $variant->inventoryItem()->updateOrCreate( + ['variant_id' => $variant->id], + array_merge(['store_id' => $product->store_id], $variantData['inventory']), + ); + } + } + } + + /** + * Find the variant an override payload refers to. + * + * @param array $variantData + */ + private function matchVariant(Product $product, array $variantData): ?ProductVariant + { + if (! empty($variantData['id'])) { + return $product->variants()->whereKey($variantData['id'])->first(); + } + + if (! empty($variantData['option_values'])) { + $wanted = collect($variantData['option_values'])->map(fn ($value) => mb_strtolower(trim((string) $value)))->sort()->values(); + + return $product->variants() + ->with('optionValues') + ->where('status', VariantStatus::Active) + ->get() + ->first(function (ProductVariant $variant) use ($wanted): bool { + $actual = $variant->optionValues->pluck('value')->map(fn ($value) => mb_strtolower(trim((string) $value)))->sort()->values(); + + return $actual->all() === $wanted->all(); + }); + } + + return null; + } + + /** + * Extract the variant's own attributes from the payload. + * + * @return array + */ + private function variantAttributes(array $data): array + { + return Arr::only($data, [ + 'sku', 'barcode', 'price_amount', 'compare_at_amount', 'currency', + 'weight_g', 'requires_shipping', 'position', + ]); + } + + /** + * Ensure the SKU is unique across all variants of the store. + * Null and empty SKUs are exempt. + * + * @throws ValidationException + */ + private function assertSkuIsUnique(int $storeId, ?string $sku, ?int $excludeVariantId = null): void + { + if ($sku === null || trim($sku) === '') { + return; + } + + $exists = DB::table('product_variants') + ->join('products', 'products.id', '=', 'product_variants.product_id') + ->where('products.store_id', $storeId) + ->where('product_variants.sku', $sku) + ->when($excludeVariantId !== null, fn ($query) => $query->where('product_variants.id', '!=', $excludeVariantId)) + ->exists(); + + if ($exists) { + throw ValidationException::withMessages([ + 'sku' => ["The SKU '{$sku}' is already used by another variant in this store."], + ]); + } + } + + /** + * Ensure the product satisfies the preconditions for activation: + * a non-empty title and at least one active variant with a price. + * + * @throws InvalidProductTransitionException + */ + private function assertPublishable(Product $product, ProductStatus $from): void + { + if (trim($product->title) === '') { + throw InvalidProductTransitionException::transition($from->value, ProductStatus::Active->value, 'The product title must not be empty.'); + } + + $hasPricedVariant = $product->variants() + ->where('status', VariantStatus::Active) + ->where('price_amount', '>', 0) + ->exists(); + + if (! $hasPricedVariant) { + throw InvalidProductTransitionException::transition($from->value, ProductStatus::Active->value, 'At least one variant with a price greater than zero is required.'); + } + } + + /** + * Ensure no order lines reference the product before reverting to draft. + * + * @throws InvalidProductTransitionException + */ + private function assertNoOrderReferences(Product $product, ProductStatus $from, ProductStatus $to): void + { + if ($this->hasOrderReferences($product)) { + throw InvalidProductTransitionException::transition($from->value, $to->value, 'The product is referenced by existing orders.'); + } + } + + /** + * Whether any order line references the product or one of its variants. + */ + private function hasOrderReferences(Product $product): bool + { + return DB::table('order_lines') + ->where('product_id', $product->id) + ->orWhereIn('variant_id', $product->variants()->select('id')) + ->exists(); + } +} diff --git a/app/Services/RefundService.php b/app/Services/RefundService.php new file mode 100644 index 00000000..d1525099 --- /dev/null +++ b/app/Services/RefundService.php @@ -0,0 +1,87 @@ +refundableAmount(); + + if ($amount <= 0 || $amount > $refundable) { + throw ValidationException::withMessages([ + 'amount' => ["The refund amount exceeds the refundable amount of {$refundable}."], + ]); + } + + return DB::transaction(function () use ($order, $payment, $amount, $reason, $restock): Refund { + $result = $this->payments->refund($payment, $amount); + + $refund = $order->refunds()->create([ + 'payment_id' => $payment->id, + 'amount' => $amount, + 'reason' => $reason, + 'status' => RefundStatus::Processed, + 'provider_refund_id' => $result->providerRefundId, + ]); + + $totalRefunded = (int) $order->refunds() + ->where('status', RefundStatus::Processed->value) + ->sum('amount'); + + if ($totalRefunded >= $order->total_amount) { + $order->forceFill([ + 'financial_status' => FinancialStatus::Refunded, + 'status' => OrderStatus::Refunded, + ])->save(); + + $payment->forceFill(['status' => PaymentStatus::Refunded])->save(); + } else { + $order->forceFill(['financial_status' => FinancialStatus::PartiallyRefunded])->save(); + } + + if ($restock) { + $order->loadMissing('lines.variant.inventoryItem'); + + foreach ($order->lines as $line) { + $item = $line->variant?->inventoryItem; + + if ($item !== null) { + $this->inventory->restock($item, $line->quantity); + } + } + } + + OrderRefunded::dispatch($order->refresh(), $refund); + + return $refund; + }); + } +} diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php new file mode 100644 index 00000000..e3f83087 --- /dev/null +++ b/app/Services/SearchService.php @@ -0,0 +1,491 @@ + $filters + */ + public function search( + Store $store, + string $query, + array $filters = [], + int $perPage = 24, + string $sort = 'relevance', + int $page = 1, + ): LengthAwarePaginator { + $matches = $this->matchingProductIds($store, $query); + + $products = $this->baseQuery($store, $matches); + + $this->applyFilters($products, $filters); + $this->applySort($products, $sort, $matches); + + $paginator = $products + ->with(['variants.inventoryItem', 'media']) + ->paginate(max(1, $perPage), ['products.*'], 'page', max(1, $page)); + + $this->logQuery($store, $query, $filters, $paginator->total()); + + return $paginator; + } + + /** + * Autocomplete suggestions: products and collections matching the prefix, + * backfilled with popular past queries (spec 02 §2.5, spec 04 §11.1). + * + * @return SupportCollection> + */ + public function autocomplete(Store $store, string $prefix, int $limit = 5): SupportCollection + { + $limit = max(1, $limit); + + if ($this->sanitizeQuery($prefix) === '') { + return collect(); + } + + $matches = $this->matchingProductIds($store, $prefix); + + $productsQuery = $this->baseQuery($store, $matches); + $this->orderByRank($productsQuery, $matches); + + $products = $productsQuery + ->with(['variants', 'media']) + ->limit($limit) + ->get(); + + $suggestions = $products->map(fn (Product $product): array => $this->productSuggestion($store, $product)); + + $collections = Collection::query() + ->where('store_id', $store->id) + ->where('status', CollectionStatus::Active) + ->where('title', 'like', $this->likePrefix($prefix).'%') + ->orderBy('title') + ->limit($limit) + ->get() + ->map(fn (Collection $collection): array => [ + 'type' => 'collection', + 'title' => $collection->title, + 'handle' => $collection->handle, + 'image_url' => null, + ]); + + $suggestions = $suggestions->concat($collections)->values(); + + // Backfill with popular past queries when products/collections are scarce. + if ($suggestions->count() < $limit) { + $pastQueries = SearchQuery::query() + ->where('store_id', $store->id) + ->where('query', 'like', $this->likePrefix($prefix).'%') + ->groupBy('query') + ->orderByRaw('COUNT(*) DESC') + ->orderByRaw('MAX(created_at) DESC') + ->limit($limit - $suggestions->count()) + ->pluck('query') + ->map(fn (string $query): array => [ + 'type' => 'query', + 'title' => $query, + 'handle' => null, + 'image_url' => null, + ]); + + $suggestions = $suggestions->concat($pastQueries)->values(); + } + + return $suggestions; + } + + /** + * Facets of the full matched (visible) result set (spec 02 §2.5). + * + * @return array{vendors: list, tags: list, price_range: array{min: int|null, max: int|null}} + */ + public function facets(Store $store, string $query): array + { + $matches = $this->matchingProductIds($store, $query); + $base = $this->baseQuery($store, $matches); + + $vendors = (clone $base) + ->whereNotNull('products.vendor') + ->groupBy('products.vendor') + ->selectRaw('products.vendor, COUNT(*) as aggregate') + ->orderBy('products.vendor') + ->pluck('aggregate', 'products.vendor') + ->map(fn ($count, $vendor): array => ['value' => $vendor, 'count' => (int) $count]) + ->values() + ->all(); + + $tags = (clone $base) + ->pluck('products.tags') + ->flatMap(fn ($tags): array => is_array($tags) ? $tags : (json_decode($tags ?? '[]', true) ?: [])) + ->countBy() + ->sortDesc() + ->map(fn ($count, $tag): array => ['value' => $tag, 'count' => (int) $count]) + ->values() + ->all(); + + $priceRange = DB::table('product_variants') + ->whereIn('product_id', (clone $base)->select('products.id')) + ->selectRaw('MIN(price_amount) as min_price, MAX(price_amount) as max_price') + ->first(); + + return [ + 'vendors' => $vendors, + 'tags' => $tags, + 'price_range' => [ + 'min' => $priceRange?->min_price !== null ? (int) $priceRange->min_price : null, + 'max' => $priceRange?->max_price !== null ? (int) $priceRange->max_price : null, + ], + ]; + } + + /** + * Insert or replace the product's FTS row (spec 05 §16.2). + */ + public function syncProduct(Product $product): void + { + DB::delete('DELETE FROM products_fts WHERE product_id = ?', [$product->id]); + + DB::insert( + 'INSERT INTO products_fts (store_id, product_id, title, description, vendor, product_type, tags) VALUES (?, ?, ?, ?, ?, ?, ?)', + [ + $product->store_id, + $product->id, + $product->title ?? '', + strip_tags($product->description_html ?? ''), + $product->vendor ?? '', + $product->product_type ?? '', + implode(' ', $product->tags ?? []), + ], + ); + } + + /** + * Remove the product from the FTS index (spec 05 §16.2). + */ + public function removeProduct(int $productId): void + { + DB::delete('DELETE FROM products_fts WHERE product_id = ?', [$productId]); + } + + /** + * Rebuild the store's FTS index from scratch. Returns the indexed count. + */ + public function reindex(Store $store): int + { + DB::delete('DELETE FROM products_fts WHERE store_id = ?', [$store->id]); + + $count = 0; + + Product::withoutGlobalScope(StoreScope::class) + ->where('store_id', $store->id) + ->chunkById(200, function ($products) use (&$count): void { + foreach ($products as $product) { + $this->syncProduct($product); + $count++; + } + }); + + return $count; + } + + /** + * Strip everything that is not a Unicode letter, number, or whitespace + * (spec 06 §4.6) and collapse runs of whitespace. + */ + public function sanitizeQuery(string $query): string + { + $clean = preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $query) ?? ''; + + return trim(preg_replace('/\s+/u', ' ', $clean) ?? ''); + } + + /** + * Base query over matched products: store-scoped, visible, ranked. + * + * @param array $matches + * @return Builder + */ + private function baseQuery(Store $store, array $matches): Builder + { + return Product::query() + ->where('products.store_id', $store->id) + ->visible() + ->whereIn('products.id', array_keys($matches)); + } + + /** + * Run the FTS5 MATCH query and return [product_id => rank] pairs. + * + * @return array + */ + private function matchingProductIds(Store $store, string $query): array + { + $matchExpression = $this->buildMatchExpression($store, $query); + + if ($matchExpression === null) { + return []; + } + + $rows = DB::select( + 'SELECT product_id, rank FROM products_fts WHERE products_fts MATCH ? AND store_id = ? ORDER BY rank', + [$matchExpression, $store->id], + ); + + $matches = []; + + foreach ($rows as $row) { + $matches[(int) $row->product_id] = (float) $row->rank; + } + + return $matches; + } + + /** + * Build a safe FTS5 MATCH expression from the user query: + * sanitized, stop words removed, synonyms expanded (OR), every token + * double-quoted (neutralizes FTS5 operators), prefix '*' on the last + * token (spec 05 §16.3, spec 06 §4.6). + */ + private function buildMatchExpression(Store $store, string $query): ?string + { + $tokens = explode(' ', $this->sanitizeQuery($query)); + $tokens = array_values(array_filter($tokens, fn (string $token): bool => $token !== '')); + + if ($tokens === []) { + return null; + } + + $stopWords = $this->stopWords($store); + $tokens = array_values(array_filter( + $tokens, + fn (string $token): bool => ! in_array(mb_strtolower($token), $stopWords, true), + )); + + if ($tokens === []) { + return null; + } + + $clauses = []; + + foreach ($tokens as $index => $token) { + $alternatives = $this->synonymsFor($store, $token); + $terms = array_map(fn (string $term): string => '"'.$term.'"', $alternatives); + + if ($index === array_key_last($tokens)) { + // Prefix matching on the typed token ("running sh" -> "running" "sh" *). + $terms[0] = '"'.$token.'" *'; + } + + $clauses[] = count($terms) === 1 ? $terms[0] : '('.implode(' OR ', $terms).')'; + } + + return implode(' ', $clauses); + } + + /** + * The store's configured stop words, lowercased. + * + * @return list + */ + private function stopWords(Store $store): array + { + $settings = SearchSettings::query()->find($store->id); + + return array_map( + fn ($word): string => mb_strtolower(trim((string) $word)), + $settings?->stop_words_json ?? [], + ); + } + + /** + * All terms of every synonym group containing the token (the token + * itself first). Terms are sanitized so they are safe to quote. + * + * @return list + */ + private function synonymsFor(Store $store, string $token): array + { + $settings = SearchSettings::query()->find($store->id); + $groups = $settings?->synonyms_json ?? []; + + $terms = [$token]; + + foreach ($groups as $group) { + if (! is_array($group)) { + continue; + } + + $normalized = array_map(fn ($term): string => mb_strtolower(trim((string) $term)), $group); + + if (in_array(mb_strtolower($token), $normalized, true)) { + foreach ($group as $term) { + $sanitized = $this->sanitizeQuery((string) $term); + + if ($sanitized !== '' && ! in_array($sanitized, $terms, true)) { + $terms[] = $sanitized; + } + } + } + } + + return $terms; + } + + /** + * Apply search filters (spec 02 §2.5 filters schema). + * + * @param Builder $query + * @param array $filters + */ + private function applyFilters(Builder $query, array $filters): void + { + if (! empty($filters['collection_id'])) { + $query->whereHas('collections', fn ($q) => $q->where('collections.id', (int) $filters['collection_id'])); + } + + if (isset($filters['price_min']) && is_numeric($filters['price_min'])) { + $query->whereHas('variants', fn ($q) => $q->where('price_amount', '>=', (int) $filters['price_min'])); + } + + if (isset($filters['price_max']) && is_numeric($filters['price_max'])) { + $query->whereHas('variants', fn ($q) => $q->where('price_amount', '<=', (int) $filters['price_max'])); + } + + if (! empty($filters['in_stock'])) { + $query->whereHas('variants.inventoryItem', fn ($q) => $q->whereRaw('(quantity_on_hand - quantity_reserved) > 0')); + } + + if (! empty($filters['tags']) && is_array($filters['tags'])) { + foreach ($filters['tags'] as $tag) { + $query->whereJsonContains('products.tags', $tag); + } + } + + if (! empty($filters['vendor'])) { + $vendors = is_array($filters['vendor']) ? $filters['vendor'] : [$filters['vendor']]; + $query->whereIn('products.vendor', $vendors); + } + } + + /** + * Apply the requested sort order (spec 02 §2.5). + * + * @param Builder $query + * @param array $matches + */ + private function applySort(Builder $query, string $sort, array $matches): void + { + match ($sort) { + 'price_asc' => $query->orderBy($this->minimumPriceSubquery()), + 'price_desc' => $query->orderByDesc($this->minimumPriceSubquery()), + 'newest' => $query->orderByDesc('products.published_at'), + 'best_selling' => $query->orderByDesc($this->salesCountSubquery()), + default => $this->orderByRank($query, $matches), + }; + } + + /** + * Order by FTS5 bm25 rank (lower is more relevant). + * + * @param Builder $query + * @param array $matches + */ + private function orderByRank(Builder $query, array $matches): void + { + if ($matches === []) { + return; + } + + $cases = collect($matches) + ->map(fn (float $rank, int $id): string => 'WHEN '.$id.' THEN '.$rank) + ->implode(' '); + + $query->orderByRaw("CASE products.id {$cases} ELSE 0 END"); + } + + /** + * Subquery selecting the minimum variant price of a product. + */ + private function minimumPriceSubquery(): \Illuminate\Contracts\Database\Query\Builder + { + return \App\Models\ProductVariant::query() + ->selectRaw('MIN(price_amount)') + ->whereColumn('product_variants.product_id', 'products.id'); + } + + /** + * Subquery selecting the total sold quantity of a product. + */ + private function salesCountSubquery(): \Illuminate\Database\Query\Builder + { + return DB::table('order_lines') + ->selectRaw('COALESCE(SUM(quantity), 0)') + ->whereColumn('order_lines.product_id', 'products.id'); + } + + /** + * Shape a product into an autocomplete suggestion. + * + * @return array + */ + private function productSuggestion(Store $store, Product $product): array + { + $variant = $product->variants->firstWhere('is_default', true) ?? $product->variants->first(); + $image = $product->media->firstWhere('status', MediaStatus::Ready); + + return [ + 'type' => 'product', + 'title' => $product->title, + 'handle' => $product->handle, + 'image_url' => $image?->url(), + 'price_amount' => $product->variants->min('price_amount'), + 'currency' => $variant?->currency ?? $store->default_currency, + ]; + } + + /** + * Escape LIKE wildcards in a user-entered prefix. + */ + private function likePrefix(string $prefix): string + { + return str_replace(['%', '_'], '', trim($prefix)); + } + + /** + * Log the search for analytics and autocomplete backfill (spec 05 §16.4). + * + * @param array $filters + */ + private function logQuery(Store $store, string $query, array $filters, int $resultsCount): void + { + SearchQuery::create([ + 'store_id' => $store->id, + 'query' => trim($query), + 'filters_json' => $filters !== [] ? $filters : null, + 'results_count' => $resultsCount, + ]); + } +} diff --git a/app/Services/ShippingCalculator.php b/app/Services/ShippingCalculator.php new file mode 100644 index 00000000..e54202aa --- /dev/null +++ b/app/Services/ShippingCalculator.php @@ -0,0 +1,155 @@ +where('store_id', $store->id) + ->get(); + + $bestMatch = null; + $bestSpecificity = -1; + + foreach ($zones as $zone) { + $countryMatch = in_array($address->countryCode, $zone->countries_json ?? [], true); + $regionMatch = $address->provinceCode !== null + && in_array($address->provinceCode, $zone->regions_json ?? [], true); + + if ($countryMatch && $regionMatch) { + $specificity = 2; + } elseif ($countryMatch) { + $specificity = 1; + } else { + continue; + } + + if ($specificity > $bestSpecificity + || ($specificity === $bestSpecificity && $bestMatch !== null && $zone->id < $bestMatch->id)) { + $bestMatch = $zone; + $bestSpecificity = $specificity; + } + } + + return $bestMatch; + } + + /** + * All active, purchasable rates for the address as calculated options. + * Rates whose conditions (weight/price range) do not match are excluded. + * Carts with no shippable lines have no available rates at all. + * + * @return Collection + */ + public function getAvailableRates(Store $store, Address $address, Cart $cart): Collection + { + $zone = $this->getMatchingZone($store, $address); + + if ($zone === null || ! $cart->requiresShipping()) { + return collect(); + } + + return $zone->rates() + ->where('is_active', true) + ->get() + ->map(function (ShippingRate $rate) use ($cart): ?ShippingRateVO { + $amount = $this->calculate($rate, $cart); + + if ($amount === null) { + return null; + } + + $config = $rate->config_json ?? []; + + return new ShippingRateVO( + id: $rate->id, + name: $rate->name, + amount: $amount, + type: $rate->type, + estimatedDaysMin: isset($config['estimated_days_min']) ? (int) $config['estimated_days_min'] : null, + estimatedDaysMax: isset($config['estimated_days_max']) ? (int) $config['estimated_days_max'] : null, + ); + }) + ->filter() + ->values(); + } + + /** + * Calculate the cost of a rate for a cart; null when the rate's + * conditions do not match (out of range / unsupported carrier stub). + */ + public function calculate(ShippingRate $rate, Cart $cart): ?int + { + $config = $rate->config_json ?? []; + + return match ($rate->type) { + ShippingRateType::Flat => isset($config['amount']) ? (int) $config['amount'] : null, + ShippingRateType::Weight => $this->calculateWeightRate($config, $cart), + ShippingRateType::Price => $this->calculatePriceRate($config, $cart->subtotal()), + ShippingRateType::Carrier => null, // carrier API integration stub + }; + } + + /** + * Match the cart's total shippable weight against the configured ranges. + * + * @param array $config + */ + private function calculateWeightRate(array $config, Cart $cart): ?int + { + $totalWeight = 0; + + foreach ($cart->lines as $line) { + if ($line->variant?->requires_shipping) { + $totalWeight += (int) ($line->variant->weight_g ?? 0) * $line->quantity; + } + } + + foreach ($config['ranges'] ?? [] as $range) { + if ($range['min_g'] <= $totalWeight && $totalWeight <= $range['max_g']) { + return (int) $range['amount']; + } + } + + return null; + } + + /** + * Match the cart subtotal against the configured ranges. A range without + * max_amount is open-ended ("free shipping over X"). + * + * @param array $config + */ + private function calculatePriceRate(array $config, int $cartSubtotal): ?int + { + foreach ($config['ranges'] ?? [] as $range) { + if ($range['min_amount'] <= $cartSubtotal + && (! isset($range['max_amount']) || $cartSubtotal <= $range['max_amount'])) { + return (int) $range['amount']; + } + } + + return null; + } +} diff --git a/app/Services/Tax/ManualTaxProvider.php b/app/Services/Tax/ManualTaxProvider.php new file mode 100644 index 00000000..df5f9297 --- /dev/null +++ b/app/Services/Tax/ManualTaxProvider.php @@ -0,0 +1,113 @@ +taxSettings; + $rateBps = $this->resolveRate($request); + + if ($rateBps <= 0) { + return TaxCalculationResult::zero(); + } + + $name = (string) ($settings->config_json['tax_name'] ?? 'Tax'); + $jurisdiction = $request->address?->countryCode; + $inclusive = $settings->prices_include_tax; + + $lineDetails = []; + $total = 0; + + foreach ($request->lineItems as $item) { + $tax = $inclusive + ? $this->extractInclusive($item['amount'], $rateBps) + : $this->addExclusive($item['amount'], $rateBps); + + $lineDetails[] = [ + 'variant_id' => $item['variant_id'] ?? null, + 'tax_amount' => $tax, + 'rate' => $rateBps, + 'jurisdiction' => $jurisdiction, + ]; + $total += $tax; + } + + $shippingTax = $inclusive + ? $this->extractInclusive($request->shippingAmount, $rateBps) + : $this->addExclusive($request->shippingAmount, $rateBps); + $total += $shippingTax; + + return new TaxCalculationResult( + taxLines: [new TaxLine(name: $name, rate: $rateBps, amount: $total)], + totalAmount: $total, + lineDetails: $lineDetails, + shippingTaxAmount: $shippingTax, + shippingTaxRate: $rateBps, + ); + } + + /** + * Tax added on top of a net amount. Integer truncation per line, summed + * afterwards (spec 09 test tables: 8999 @ 700 bps = 629). + */ + public function addExclusive(int $netAmount, int $rateBasisPoints): int + { + return intdiv($netAmount * $rateBasisPoints, 10000); + } + + /** + * Tax portion contained in a gross (tax-inclusive) amount. + * net = intdiv(gross * 10000, 10000 + rate); tax = gross - net. + */ + public function extractInclusive(int $grossAmount, int $rateBasisPoints): int + { + $net = intdiv($grossAmount * 10000, 10000 + $rateBasisPoints); + + return $grossAmount - $net; + } + + /** + * Resolve the applicable rate in basis points for the request. + */ + private function resolveRate(TaxCalculationRequest $request): int + { + $config = $request->taxSettings->config_json ?? []; + $zoneRates = $config['zone_rates'] ?? null; + + if (is_array($zoneRates) && $zoneRates !== [] && $request->address !== null) { + $store = Store::find($request->taxSettings->store_id); + + if ($store !== null) { + $zone = $this->shippingCalculator->getMatchingZone($store, $request->address); + + if ($zone !== null && isset($zoneRates[$zone->id])) { + return (int) $zoneRates[$zone->id]; + } + } + } + + return (int) ($config['default_rate_bps'] ?? 0); + } +} diff --git a/app/Services/Tax/StripeTaxProvider.php b/app/Services/Tax/StripeTaxProvider.php new file mode 100644 index 00000000..db8f7e0c --- /dev/null +++ b/app/Services/Tax/StripeTaxProvider.php @@ -0,0 +1,29 @@ +resolveProvider($request)->calculate($request); + } + + /** + * Tax added on top of a net amount (integer math, per-line truncation). + */ + public function addExclusive(int $netAmount, int $rateBasisPoints): int + { + return $this->manualProvider->addExclusive($netAmount, $rateBasisPoints); + } + + /** + * Tax portion contained in a gross (tax-inclusive) amount. + */ + public function extractInclusive(int $grossAmount, int $rateBasisPoints): int + { + return $this->manualProvider->extractInclusive($grossAmount, $rateBasisPoints); + } + + /** + * Pick the provider implementation for the request's tax settings. + */ + private function resolveProvider(TaxCalculationRequest $request): TaxProvider + { + $settings = $request->taxSettings; + + if ($settings->mode === TaxMode::Provider && $settings->provider === 'stripe_tax') { + return $this->stripeProvider; + } + + return $this->manualProvider; + } +} diff --git a/app/Services/ThemeSettingsService.php b/app/Services/ThemeSettingsService.php new file mode 100644 index 00000000..76955a5a --- /dev/null +++ b/app/Services/ThemeSettingsService.php @@ -0,0 +1,132 @@ + + */ + public const DEFAULTS = [ + 'announcement' => [ + 'enabled' => false, + 'text' => '', + 'link' => null, + ], + 'header' => [ + 'sticky' => false, + 'logo_url' => null, + ], + 'colors' => [ + 'primary' => '#2563eb', + 'secondary' => '#64748b', + 'accent' => '#f59e0b', + ], + 'dark_mode' => 'system', + 'sections_order' => ['hero', 'featured_collections', 'featured_products', 'newsletter', 'rich_text'], + 'hero' => [ + 'enabled' => true, + 'heading' => null, + 'subheading' => null, + 'cta_label' => 'Shop now', + 'cta_url' => '/collections', + 'image_url' => null, + ], + 'featured_collections' => [ + 'enabled' => true, + 'count' => 3, + 'collection_handles' => [], + ], + 'featured_products' => [ + 'enabled' => true, + 'count' => 8, + 'collection_handle' => null, + ], + 'newsletter' => [ + 'enabled' => true, + ], + 'rich_text' => [ + 'enabled' => false, + 'html' => null, + ], + 'footer' => [ + 'about' => null, + 'social' => [], + ], + 'seo' => [ + 'description' => null, + ], + ]; + + /** + * All resolved settings for the current store: the published theme's + * settings merged onto the defaults, cached for 5 minutes per store. + * + * @return array + */ + public function all(): array + { + if (! app()->bound('current_store')) { + return self::DEFAULTS; + } + + $storeId = (int) app('current_store')->getKey(); + + return Cache::remember( + "theme_settings:{$storeId}", + self::TTL_SECONDS, + fn (): array => $this->loadSettings($storeId), + ); + } + + /** + * Get a single setting by dot notation. + */ + public function get(string $key, mixed $default = null): mixed + { + return Arr::get($this->all(), $key, $default); + } + + /** + * Forget the cached settings of a store. + */ + public function invalidate(?int $storeId): void + { + if ($storeId === null) { + return; + } + + Cache::forget("theme_settings:{$storeId}"); + } + + /** + * Load the published theme's settings for the store merged onto defaults. + * + * @return array + */ + private function loadSettings(int $storeId): array + { + $theme = Theme::withoutGlobalScopes() + ->where('store_id', $storeId) + ->where('status', ThemeStatus::Published) + ->latest('published_at') + ->first(); + + $settings = $theme?->settings?->settings_json ?? []; + + return array_replace_recursive(self::DEFAULTS, $settings); + } +} diff --git a/app/Services/VariantMatrixService.php b/app/Services/VariantMatrixService.php new file mode 100644 index 00000000..30def6fc --- /dev/null +++ b/app/Services/VariantMatrixService.php @@ -0,0 +1,144 @@ +options()->with('values')->get(); + + if ($options->isEmpty()) { + return; + } + + $valueSets = $options + ->map(fn ($option) => $option->values->pluck('id')->all()) + ->all(); + + $desiredCombos = $this->cartesianProduct($valueSets); + $desiredKeys = array_map(fn (array $combo) => $this->comboKey($combo), $desiredCombos); + + $variants = $product->variants()->with('optionValues')->get(); + $activeVariants = $variants->where('status', VariantStatus::Active)->values(); + + $defaults = $this->defaultAttributes($variants->first()); + + $existingKeys = $activeVariants + ->map(fn (ProductVariant $variant) => $this->comboKey($variant->optionValues->pluck('id')->all())) + ->all(); + + // Create variants for combinations that do not exist yet. + $position = $activeVariants->count(); + $hasDefault = $variants->contains('is_default', true); + + foreach ($desiredCombos as $index => $combo) { + if (in_array($desiredKeys[$index], $existingKeys, true)) { + continue; + } + + $variant = $product->variants()->create(array_merge($defaults, [ + 'position' => $position++, + 'is_default' => ! $hasDefault, + ])); + $hasDefault = true; + + $variant->optionValues()->sync($combo); + $variant->inventoryItem()->create([ + 'store_id' => $product->store_id, + 'quantity_on_hand' => 0, + ]); + } + + // Remove variants that no longer match any desired combination. + foreach ($activeVariants as $variant) { + $key = $this->comboKey($variant->optionValues->pluck('id')->all()); + + if (in_array($key, $desiredKeys, true)) { + continue; + } + + if ($this->hasOrderLineReferences($variant)) { + $variant->update(['status' => VariantStatus::Archived]); + } else { + $variant->delete(); + } + } + }); + } + + /** + * Compute the cartesian product of the given sets of option value IDs. + * + * @param list> $sets + * @return list> + */ + private function cartesianProduct(array $sets): array + { + $result = [[]]; + + foreach ($sets as $set) { + $next = []; + + foreach ($result as $combination) { + foreach ($set as $valueId) { + $next[] = array_merge($combination, [$valueId]); + } + } + + $result = $next; + } + + return $result; + } + + /** + * Build a normalized comparison key for a combination of value IDs. + * + * @param list $valueIds + */ + private function comboKey(array $valueIds): string + { + sort($valueIds); + + return implode('-', $valueIds); + } + + /** + * Pricing/shipping defaults copied from the first existing variant. + * + * @return array + */ + private function defaultAttributes(?ProductVariant $reference): array + { + return [ + 'price_amount' => $reference?->price_amount ?? 0, + 'compare_at_amount' => $reference?->compare_at_amount, + 'currency' => $reference?->currency ?? 'USD', + 'weight_g' => $reference?->weight_g, + 'requires_shipping' => $reference?->requires_shipping ?? true, + ]; + } + + /** + * Whether any order line references the variant. + */ + private function hasOrderLineReferences(ProductVariant $variant): bool + { + return DB::table('order_lines')->where('variant_id', $variant->id)->exists(); + } +} diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php new file mode 100644 index 00000000..1769c02a --- /dev/null +++ b/app/Services/WebhookService.php @@ -0,0 +1,49 @@ + $payload + */ + public function dispatch(Store $store, string $eventType, array $payload): void + { + WebhookSubscription::query() + ->where('store_id', $store->id) + ->where('event_type', $eventType) + ->where('status', WebhookSubscriptionStatus::Active) + ->get() + ->each(fn (WebhookSubscription $subscription) => DeliverWebhook::dispatch($subscription, $eventType, $payload)); + } + + /** + * HMAC-SHA256 hex signature over "{timestamp}.{payload}" signed with + * the subscription's (decrypted) signing secret. + */ + public function sign(string $payload, string $secret, int $timestamp): string + { + return hash_hmac('sha256', "{$timestamp}.{$payload}", $secret); + } + + /** + * Verify a delivery signature by recomputing it with the given + * timestamp and comparing in constant time. + */ + public function verify(string $payload, string $signature, string $secret, int $timestamp): bool + { + return hash_equals($this->sign($payload, $secret, $timestamp), $signature); + } +} diff --git a/app/Support/HandleGenerator.php b/app/Support/HandleGenerator.php new file mode 100644 index 00000000..2687400c --- /dev/null +++ b/app/Support/HandleGenerator.php @@ -0,0 +1,46 @@ +where('store_id', $storeId) + ->where('handle', $handle) + ->when($excludeId !== null, fn ($query) => $query->where('id', '!=', $excludeId)) + ->exists(); + } +} diff --git a/app/Support/Money.php b/app/Support/Money.php new file mode 100644 index 00000000..87dbfdcf --- /dev/null +++ b/app/Support/Money.php @@ -0,0 +1,19 @@ + "24.99 EUR". + */ + public static function format(int $cents, string $currency): string + { + return number_format($cents / 100, 2, '.', ',').' '.strtoupper($currency); + } +} diff --git a/app/Support/OrderToken.php b/app/Support/OrderToken.php new file mode 100644 index 00000000..bb8bd046 --- /dev/null +++ b/app/Support/OrderToken.php @@ -0,0 +1,30 @@ +id.$order->order_number, (string) config('app.key')); + } + + /** + * Validate a token against the order (constant-time comparison). + */ + public static function validate(Order $order, ?string $token): bool + { + return is_string($token) && $token !== '' && hash_equals(self::for($order), $token); + } +} diff --git a/app/Traits/ChecksStoreRole.php b/app/Traits/ChecksStoreRole.php new file mode 100644 index 00000000..ca82076f --- /dev/null +++ b/app/Traits/ChecksStoreRole.php @@ -0,0 +1,72 @@ +where('store_id', $storeId) + ->where('user_id', $user->getKey()) + ->value('role'); + + if ($role instanceof StoreUserRole) { + return $role; + } + + return $role === null ? null : StoreUserRole::from($role); + } + + /** + * Determine whether the user's role for the store is in the given list. + * + * @param array $roles + */ + protected function hasRole(User $user, int $storeId, array $roles): bool + { + $role = $this->getStoreRole($user, $storeId); + + return $role !== null && in_array($role, $roles, true); + } + + /** + * Determine whether the user is an owner or admin of the store. + */ + protected function isOwnerOrAdmin(User $user, int $storeId): bool + { + return $this->hasRole($user, $storeId, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + /** + * Determine whether the user is an owner, admin, or staff of the store. + */ + protected function isOwnerAdminOrStaff(User $user, int $storeId): bool + { + return $this->hasRole($user, $storeId, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + /** + * Determine whether the user holds any role in the store. + */ + protected function isAnyRole(User $user, int $storeId): bool + { + return $this->getStoreRole($user, $storeId) !== null; + } + + /** + * Resolve the store id from the container-bound current store. + */ + protected function currentStoreId(): ?int + { + return app()->bound('current_store') ? (int) app('current_store')->getKey() : null; + } +} diff --git a/app/ValueObjects/Address.php b/app/ValueObjects/Address.php new file mode 100644 index 00000000..ccae3dbf --- /dev/null +++ b/app/ValueObjects/Address.php @@ -0,0 +1,72 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + firstName: (string) ($data['first_name'] ?? ''), + lastName: (string) ($data['last_name'] ?? ''), + company: $data['company'] ?? null, + address1: (string) ($data['address1'] ?? ''), + address2: $data['address2'] ?? null, + city: (string) ($data['city'] ?? ''), + province: $data['province'] ?? null, + provinceCode: $data['province_code'] ?? null, + country: (string) ($data['country'] ?? $data['country_code'] ?? ''), + countryCode: (string) ($data['country_code'] ?? $data['country'] ?? ''), + postalCode: (string) ($data['postal_code'] ?? ''), + phone: $data['phone'] ?? null, + ); + } + + /** + * Serialize to the snake_case JSON representation stored on checkouts. + * + * @return array + */ + public function toArray(): array + { + return [ + 'first_name' => $this->firstName, + 'last_name' => $this->lastName, + 'company' => $this->company, + 'address1' => $this->address1, + 'address2' => $this->address2, + 'city' => $this->city, + 'province' => $this->province, + 'province_code' => $this->provinceCode, + 'country' => $this->country, + 'country_code' => $this->countryCode, + 'postal_code' => $this->postalCode, + 'phone' => $this->phone, + ]; + } +} diff --git a/app/ValueObjects/DiscountValidationResult.php b/app/ValueObjects/DiscountValidationResult.php new file mode 100644 index 00000000..6a83f4bb --- /dev/null +++ b/app/ValueObjects/DiscountValidationResult.php @@ -0,0 +1,28 @@ + $taxLines + * @param array $lineDiscounts discount amount per line index + */ + public function __construct( + public int $subtotal, + public int $discount, + public int $shipping, + public array $taxLines, + public int $taxTotal, + public int $total, + public string $currency, + public array $lineDiscounts = [], + ) {} + + /** + * Snapshot structure stored in checkouts.totals_json. + * + * @return array{subtotal: int, discount: int, shipping: int, tax: int, tax_lines: array, total: int, currency: string} + */ + public function toArray(): array + { + return [ + 'subtotal' => $this->subtotal, + 'discount' => $this->discount, + 'shipping' => $this->shipping, + 'tax' => $this->taxTotal, + 'tax_lines' => array_map(fn (TaxLine $line): array => $line->toArray(), $this->taxLines), + 'total' => $this->total, + 'currency' => $this->currency, + ]; + } +} diff --git a/app/ValueObjects/RefundResult.php b/app/ValueObjects/RefundResult.php new file mode 100644 index 00000000..2d382a9f --- /dev/null +++ b/app/ValueObjects/RefundResult.php @@ -0,0 +1,15 @@ + $lineItems discounted line amounts + */ + public function __construct( + public array $lineItems, + public int $shippingAmount, + public ?Address $address, + public TaxSettings $taxSettings, + ) {} +} diff --git a/app/ValueObjects/TaxCalculationResult.php b/app/ValueObjects/TaxCalculationResult.php new file mode 100644 index 00000000..90ed91b1 --- /dev/null +++ b/app/ValueObjects/TaxCalculationResult.php @@ -0,0 +1,30 @@ + $taxLines + * @param array $lineDetails + */ + public function __construct( + public array $taxLines, + public int $totalAmount, + public array $lineDetails = [], + public int $shippingTaxAmount = 0, + public int $shippingTaxRate = 0, + ) {} + + /** + * Zero-tax result. + */ + public static function zero(): self + { + return new self(taxLines: [], totalAmount: 0); + } +} diff --git a/app/ValueObjects/TaxLine.php b/app/ValueObjects/TaxLine.php new file mode 100644 index 00000000..b7da73a7 --- /dev/null +++ b/app/ValueObjects/TaxLine.php @@ -0,0 +1,28 @@ + $this->name, + 'rate' => $this->rate, + 'amount' => $this->amount, + ]; + } +} diff --git a/boost.json b/boost.json new file mode 100644 index 00000000..ea5c3dc3 --- /dev/null +++ b/boost.json @@ -0,0 +1,19 @@ +{ + "agents": [ + "opencode", + "codex" + ], + "cloud": false, + "guidelines": true, + "mcp": true, + "nightwatch": false, + "sail": false, + "skills": [ + "developing-with-fortify", + "laravel-best-practices", + "fluxui-development", + "livewire-development", + "pest-testing", + "tailwindcss-development" + ] +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c1832766..e9b07bf8 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,18 +1,67 @@ withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', + then: function (): void { + Route::middleware('web')->group(base_path('routes/admin.php')); + }, ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'store.resolve' => ResolveStore::class, + 'store.resolve.storefront' => ResolveStorefrontStore::class, + 'store.resolve.admin' => ResolveAdminStore::class, + 'role.check' => CheckStoreRole::class, + 'role.check.any' => CheckAnyStoreRole::class, + 'auth.customer' => CustomerAuthenticate::class, + 'ability' => CheckTokenAbility::class, + ]); + + // Guests hitting the admin panel go to the admin login; everything + // else is storefront-facing and goes to the customer login. + $middleware->redirectGuestsTo( + fn (Illuminate\Http\Request $request): string => $request->is('admin', 'admin/*') ? '/admin/login' : '/account/login', + ); }) ->withExceptions(function (Exceptions $exceptions): void { - // + // Cart optimistic-concurrency conflicts return 409 with the current + // cart state in the response body (spec 02 §2.1, spec 05 §4.3). + $exceptions->render(function (App\Exceptions\CartVersionMismatchException $exception, Illuminate\Http\Request $request) { + if (! $request->expectsJson()) { + return null; + } + + return response()->json( + array_merge( + ['message' => $exception->getMessage()], + (new App\Http\Resources\Storefront\CartResource($exception->cart->refresh()))->toArray($request), + ), + 409, + ); + }); + + // Invalid checkout state transitions map to 422 (spec 02 §2.2). + $exceptions->render(function (App\Exceptions\InvalidCheckoutTransitionException $exception, Illuminate\Http\Request $request) { + if (! $request->expectsJson()) { + return null; + } + + return response()->json(['message' => $exception->getMessage()], 422); + }); })->create(); diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 0ad9c573..38b258d1 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,5 +2,4 @@ return [ App\Providers\AppServiceProvider::class, - App\Providers\FortifyServiceProvider::class, ]; diff --git a/composer.json b/composer.json index 1f848aaf..6152b538 100644 --- a/composer.json +++ b/composer.json @@ -10,21 +10,22 @@ "license": "MIT", "require": { "php": "^8.2", - "laravel/fortify": "^1.30", "laravel/framework": "^12.0", + "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1", "livewire/flux": "^2.9.0", "livewire/livewire": "^4.0" }, "require-dev": { "fakerphp/faker": "^1.23", - "laravel/boost": "^1.0", + "laravel/boost": "^2.4", "laravel/pail": "^1.2.2", "laravel/pint": "^1.24", "laravel/sail": "^1.41", "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.6", "pestphp/pest": "^4.3", + "pestphp/pest-plugin-browser": "^4.3", "pestphp/pest-plugin-laravel": "^4.0" }, "autoload": { diff --git a/composer.lock b/composer.lock index e4255dbd..6cc60cd5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,63 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e4aa7ad38dac6834e5ff6bf65b1cdf23", + "content-hash": "597dbb82d1dd1966a3c1dd10952c197d", "packages": [ - { - "name": "bacon/bacon-qr-code", - "version": "v3.0.3", - "source": { - "type": "git", - "url": "https://github.com/Bacon/BaconQrCode.git", - "reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/36a1cb2b81493fa5b82e50bf8068bf84d1542563", - "reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563", - "shasum": "" - }, - "require": { - "dasprid/enum": "^1.0.3", - "ext-iconv": "*", - "php": "^8.1" - }, - "require-dev": { - "phly/keep-a-changelog": "^2.12", - "phpunit/phpunit": "^10.5.11 || ^11.0.4", - "spatie/phpunit-snapshot-assertions": "^5.1.5", - "spatie/pixelmatch-php": "^1.2.0", - "squizlabs/php_codesniffer": "^3.9" - }, - "suggest": { - "ext-imagick": "to generate QR code images" - }, - "type": "library", - "autoload": { - "psr-4": { - "BaconQrCode\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-2-Clause" - ], - "authors": [ - { - "name": "Ben Scholzen 'DASPRiD'", - "email": "mail@dasprids.de", - "homepage": "https://dasprids.de/", - "role": "Developer" - } - ], - "description": "BaconQrCode is a QR code generator for PHP.", - "homepage": "https://github.com/Bacon/BaconQrCode", - "support": { - "issues": "https://github.com/Bacon/BaconQrCode/issues", - "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.3" - }, - "time": "2025-11-19T17:15:36+00:00" - }, { "name": "brick/math", "version": "0.14.8", @@ -190,56 +135,6 @@ ], "time": "2024-02-09T16:56:22+00:00" }, - { - "name": "dasprid/enum", - "version": "1.0.7", - "source": { - "type": "git", - "url": "https://github.com/DASPRiD/Enum.git", - "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", - "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", - "shasum": "" - }, - "require": { - "php": ">=7.1 <9.0" - }, - "require-dev": { - "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", - "squizlabs/php_codesniffer": "*" - }, - "type": "library", - "autoload": { - "psr-4": { - "DASPRiD\\Enum\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-2-Clause" - ], - "authors": [ - { - "name": "Ben Scholzen 'DASPRiD'", - "email": "mail@dasprids.de", - "homepage": "https://dasprids.de/", - "role": "Developer" - } - ], - "description": "PHP 7.1 enum implementation", - "keywords": [ - "enum", - "map" - ], - "support": { - "issues": "https://github.com/DASPRiD/Enum/issues", - "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" - }, - "time": "2025-09-16T12:23:56+00:00" - }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -1157,69 +1052,6 @@ ], "time": "2025-08-22T14:27:06+00:00" }, - { - "name": "laravel/fortify", - "version": "v1.34.1", - "source": { - "type": "git", - "url": "https://github.com/laravel/fortify.git", - "reference": "412575e9c0cb21d49a30b7045ad4902019f538c2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/fortify/zipball/412575e9c0cb21d49a30b7045ad4902019f538c2", - "reference": "412575e9c0cb21d49a30b7045ad4902019f538c2", - "shasum": "" - }, - "require": { - "bacon/bacon-qr-code": "^3.0", - "ext-json": "*", - "illuminate/console": "^10.0|^11.0|^12.0|^13.0", - "illuminate/support": "^10.0|^11.0|^12.0|^13.0", - "php": "^8.1", - "pragmarx/google2fa": "^9.0" - }, - "require-dev": { - "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", - "phpstan/phpstan": "^1.10" - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Laravel\\Fortify\\FortifyServiceProvider" - ] - }, - "branch-alias": { - "dev-master": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Laravel\\Fortify\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "Backend controllers and scaffolding for Laravel authentication.", - "keywords": [ - "auth", - "laravel" - ], - "support": { - "issues": "https://github.com/laravel/fortify/issues", - "source": "https://github.com/laravel/fortify" - }, - "time": "2026-02-03T06:55:55+00:00" - }, { "name": "laravel/framework", "version": "v12.51.0", @@ -1501,6 +1333,69 @@ }, "time": "2026-02-06T12:17:10+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-04-30T11:46:25+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.9", @@ -2836,75 +2731,6 @@ ], "time": "2025-11-20T02:34:59+00:00" }, - { - "name": "paragonie/constant_time_encoding", - "version": "v3.1.3", - "source": { - "type": "git", - "url": "https://github.com/paragonie/constant_time_encoding.git", - "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", - "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", - "shasum": "" - }, - "require": { - "php": "^8" - }, - "require-dev": { - "infection/infection": "^0", - "nikic/php-fuzzer": "^0", - "phpunit/phpunit": "^9|^10|^11", - "vimeo/psalm": "^4|^5|^6" - }, - "type": "library", - "autoload": { - "psr-4": { - "ParagonIE\\ConstantTime\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Paragon Initiative Enterprises", - "email": "security@paragonie.com", - "homepage": "https://paragonie.com", - "role": "Maintainer" - }, - { - "name": "Steve 'Sc00bz' Thomas", - "email": "steve@tobtu.com", - "homepage": "https://www.tobtu.com", - "role": "Original Developer" - } - ], - "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", - "keywords": [ - "base16", - "base32", - "base32_decode", - "base32_encode", - "base64", - "base64_decode", - "base64_encode", - "bin2hex", - "encoding", - "hex", - "hex2bin", - "rfc4648" - ], - "support": { - "email": "info@paragonie.com", - "issues": "https://github.com/paragonie/constant_time_encoding/issues", - "source": "https://github.com/paragonie/constant_time_encoding" - }, - "time": "2025-09-24T15:06:41+00:00" - }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -2980,58 +2806,6 @@ ], "time": "2025-12-27T19:41:33+00:00" }, - { - "name": "pragmarx/google2fa", - "version": "v9.0.0", - "source": { - "type": "git", - "url": "https://github.com/antonioribeiro/google2fa.git", - "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf", - "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf", - "shasum": "" - }, - "require": { - "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", - "php": "^7.1|^8.0" - }, - "require-dev": { - "phpstan/phpstan": "^1.9", - "phpunit/phpunit": "^7.5.15|^8.5|^9.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "PragmaRX\\Google2FA\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Antonio Carlos Ribeiro", - "email": "acr@antoniocarlosribeiro.com", - "role": "Creator & Designer" - } - ], - "description": "A One Time Password Authentication package, compatible with Google Authenticator.", - "keywords": [ - "2fa", - "Authentication", - "Two Factor Authentication", - "google2fa" - ], - "support": { - "issues": "https://github.com/antonioribeiro/google2fa/issues", - "source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0" - }, - "time": "2025-09-19T22:51:08+00:00" - }, { "name": "psr/clock", "version": "1.0.0", @@ -6429,55 +6203,1285 @@ ], "packages-dev": [ { - "name": "brianium/paratest", - "version": "v7.17.0", + "name": "amphp/amp", + "version": "v3.1.2", "source": { "type": "git", - "url": "https://github.com/paratestphp/paratest.git", - "reference": "53cb90a6aa3ef3840458781600628ade058a18b9" + "url": "https://github.com/amphp/amp.git", + "reference": "2f3ebed5a4f663968a0590dbb7654a8b32cb63cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/53cb90a6aa3ef3840458781600628ade058a18b9", - "reference": "53cb90a6aa3ef3840458781600628ade058a18b9", + "url": "https://api.github.com/repos/amphp/amp/zipball/2f3ebed5a4f663968a0590dbb7654a8b32cb63cb", + "reference": "2f3ebed5a4f663968a0590dbb7654a8b32cb63cb", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-pcre": "*", - "ext-reflection": "*", - "ext-simplexml": "*", - "fidry/cpu-core-counter": "^1.3.0", - "jean85/pretty-package-versions": "^2.1.1", - "php": "~8.3.0 || ~8.4.0 || ~8.5.0", - "phpunit/php-code-coverage": "^12.5.2", - "phpunit/php-file-iterator": "^6", - "phpunit/php-timer": "^8", - "phpunit/phpunit": "^12.5.8", - "sebastian/environment": "^8.0.3", - "symfony/console": "^7.3.4 || ^8.0.0", - "symfony/process": "^7.3.4 || ^8.0.0" + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" }, "require-dev": { - "doctrine/coding-standard": "^14.0.0", - "ext-pcntl": "*", - "ext-pcov": "*", - "ext-posix": "*", - "phpstan/phpstan": "^2.1.38", - "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.12", - "phpstan/phpstan-strict-rules": "^2.0.8", - "symfony/filesystem": "^7.3.2 || ^8.0.0" + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" }, - "bin": [ - "bin/paratest", - "bin/paratest_for_phpstorm" - ], "type": "library", "autoload": { + "files": [ + "src/functions.php", + "src/Future/functions.php", + "src/Internal/functions.php" + ], "psr-4": { - "ParaTest\\": [ - "src/" + "Amp\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A non-blocking concurrency framework for PHP applications.", + "homepage": "https://amphp.org/amp", + "keywords": [ + "async", + "asynchronous", + "awaitable", + "concurrency", + "event", + "event-loop", + "future", + "non-blocking", + "promise" + ], + "support": { + "issues": "https://github.com/amphp/amp/issues", + "source": "https://github.com/amphp/amp/tree/v3.1.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-06-21T13:59:44+00:00" + }, + { + "name": "amphp/byte-stream", + "version": "v2.1.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/byte-stream.git", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/parser": "^1.1", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2.3" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.22.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\ByteStream\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A stream abstraction to make working with non-blocking I/O simple.", + "homepage": "https://amphp.org/byte-stream", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "non-blocking", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/byte-stream/issues", + "source": "https://github.com/amphp/byte-stream/tree/v2.1.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-03-16T17:10:27+00:00" + }, + { + "name": "amphp/cache", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/cache.git", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Cache\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A fiber-aware cache API based on Amp and Revolt.", + "homepage": "https://amphp.org/cache", + "support": { + "issues": "https://github.com/amphp/cache/issues", + "source": "https://github.com/amphp/cache/tree/v2.0.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:38:06+00:00" + }, + { + "name": "amphp/dns", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/dns.git", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/process": "^2", + "daverandom/libdns": "^2.0.2", + "ext-filter": "*", + "ext-json": "*", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Dns\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Wright", + "email": "addr@daverandom.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Async DNS resolution for Amp.", + "homepage": "https://github.com/amphp/dns", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "dns", + "resolve" + ], + "support": { + "issues": "https://github.com/amphp/dns/issues", + "source": "https://github.com/amphp/dns/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-01-19T15:43:40+00:00" + }, + { + "name": "amphp/hpack", + "version": "v3.2.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/hpack.git", + "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/hpack/zipball/291da27078e7e149a9bad4d08ff05bf7d81c89f4", + "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "http2jp/hpack-test-case": "^1", + "nikic/php-fuzzer": "^0.0.11", + "phpunit/phpunit": "^7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Amp\\Http\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "HTTP/2 HPack implementation.", + "homepage": "https://github.com/amphp/hpack", + "keywords": [ + "headers", + "hpack", + "http-2" + ], + "support": { + "issues": "https://github.com/amphp/hpack/issues", + "source": "https://github.com/amphp/hpack/tree/v3.2.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-03T19:28:59+00:00" + }, + { + "name": "amphp/http", + "version": "v2.1.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/http.git", + "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http/zipball/3680d80bd38b5d6f3c2cef2214ca6dd6cef26588", + "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588", + "shasum": "" + }, + "require": { + "amphp/hpack": "^3", + "amphp/parser": "^1.1", + "league/uri-components": "^2.4.2 | ^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "league/uri": "^6.8 | ^7.1", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.26.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/constants.php" + ], + "psr-4": { + "Amp\\Http\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Basic HTTP primitives which can be shared by servers and clients.", + "support": { + "issues": "https://github.com/amphp/http/issues", + "source": "https://github.com/amphp/http/tree/v2.1.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-11-23T14:57:26+00:00" + }, + { + "name": "amphp/http-client", + "version": "v5.3.6", + "source": { + "type": "git", + "url": "https://github.com/amphp/http-client.git", + "reference": "ca155026acafa74a612d776a97202d53077fee86" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http-client/zipball/ca155026acafa74a612d776a97202d53077fee86", + "reference": "ca155026acafa74a612d776a97202d53077fee86", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/hpack": "^3", + "amphp/http": "^2", + "amphp/pipeline": "^1", + "amphp/socket": "^2", + "amphp/sync": "^2", + "league/uri": "^7", + "league/uri-components": "^7", + "league/uri-interfaces": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2", + "revolt/event-loop": "^1" + }, + "conflict": { + "amphp/file": "<3 | >=5" + }, + "require-dev": { + "amphp/file": "^3 | ^4", + "amphp/http-server": "^3", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "ext-json": "*", + "kelunik/link-header-rfc5988": "^1", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "suggest": { + "amphp/file": "Required for file request bodies and HTTP archive logging", + "ext-json": "Required for logging HTTP archives", + "ext-zlib": "Allows using compression for response bodies." + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\Http\\Client\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "An advanced async HTTP client library for PHP, enabling efficient, non-blocking, and concurrent requests and responses.", + "homepage": "https://amphp.org/http-client", + "keywords": [ + "async", + "client", + "concurrent", + "http", + "non-blocking", + "rest" + ], + "support": { + "issues": "https://github.com/amphp/http-client/issues", + "source": "https://github.com/amphp/http-client/tree/v5.3.6" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-15T23:29:38+00:00" + }, + { + "name": "amphp/http-server", + "version": "v3.4.6", + "source": { + "type": "git", + "url": "https://github.com/amphp/http-server.git", + "reference": "8a971bf92cf8cf2bc511f37a75b39126d5305315" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http-server/zipball/8a971bf92cf8cf2bc511f37a75b39126d5305315", + "reference": "8a971bf92cf8cf2bc511f37a75b39126d5305315", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/hpack": "^3", + "amphp/http": "^2", + "amphp/pipeline": "^1", + "amphp/socket": "^2.1", + "amphp/sync": "^2.2", + "league/uri": "^7.1", + "league/uri-interfaces": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2", + "psr/log": "^1 | ^2 | ^3", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/http-client": "^5", + "amphp/log": "^2", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "league/uri-components": "^7.1", + "monolog/monolog": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "suggest": { + "ext-zlib": "Allows GZip compression of response bodies" + }, + "type": "library", + "autoload": { + "files": [ + "src/Driver/functions.php", + "src/Middleware/functions.php", + "src/functions.php" + ], + "psr-4": { + "Amp\\Http\\Server\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "A non-blocking HTTP application server for PHP based on Amp.", + "homepage": "https://github.com/amphp/http-server", + "keywords": [ + "amp", + "amphp", + "async", + "http", + "non-blocking", + "server" + ], + "support": { + "issues": "https://github.com/amphp/http-server/issues", + "source": "https://github.com/amphp/http-server/tree/v3.4.6" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-06-27T10:31:48+00:00" + }, + { + "name": "amphp/parser", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/parser.git", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Parser\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A generator parser to make streaming parsers simple.", + "homepage": "https://github.com/amphp/parser", + "keywords": [ + "async", + "non-blocking", + "parser", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/parser/issues", + "source": "https://github.com/amphp/parser/tree/v1.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:16:53+00:00" + }, + { + "name": "amphp/pipeline", + "version": "v1.2.6", + "source": { + "type": "git", + "url": "https://github.com/amphp/pipeline.git", + "reference": "10941bf38de5c585aa2407b2ec4265d806d4eef2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/10941bf38de5c585aa2407b2ec4265d806d4eef2", + "reference": "10941bf38de5c585aa2407b2ec4265d806d4eef2", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Pipeline\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Asynchronous iterators and operators.", + "homepage": "https://amphp.org/pipeline", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "iterator", + "non-blocking" + ], + "support": { + "issues": "https://github.com/amphp/pipeline/issues", + "source": "https://github.com/amphp/pipeline/tree/v1.2.6" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-06-27T16:15:40+00:00" + }, + { + "name": "amphp/process", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/process.git", + "reference": "583959df17d00304ad7b0b32285373f985935643" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/process/zipball/583959df17d00304ad7b0b32285373f985935643", + "reference": "583959df17d00304ad7b0b32285373f985935643", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Process\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A fiber-aware process manager based on Amp and Revolt.", + "homepage": "https://amphp.org/process", + "support": { + "issues": "https://github.com/amphp/process/issues", + "source": "https://github.com/amphp/process/tree/v2.1.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-31T15:11:55+00:00" + }, + { + "name": "amphp/serialization", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/serialization.git", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/serialization/zipball/fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "ext-json": "*", + "ext-zlib": "*", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Serialization\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Serialization tools for IPC and data storage in PHP.", + "homepage": "https://github.com/amphp/serialization", + "keywords": [ + "async", + "asynchronous", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/amphp/serialization/issues", + "source": "https://github.com/amphp/serialization/tree/v1.1.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-05T15:59:53+00:00" + }, + { + "name": "amphp/socket", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/socket.git", + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/socket/zipball/dadb63c5d3179fd83803e29dfeac27350e619314", + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/dns": "^2", + "ext-openssl": "*", + "kelunik/certificate": "^1.1", + "league/uri": "^7", + "league/uri-interfaces": "^7", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/process": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php", + "src/SocketAddress/functions.php" + ], + "psr-4": { + "Amp\\Socket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.", + "homepage": "https://github.com/amphp/socket", + "keywords": [ + "amp", + "async", + "encryption", + "non-blocking", + "sockets", + "tcp", + "tls" + ], + "support": { + "issues": "https://github.com/amphp/socket/issues", + "source": "https://github.com/amphp/socket/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-19T15:09:56+00:00" + }, + { + "name": "amphp/sync", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/sync.git", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Sync\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" + } + ], + "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.", + "homepage": "https://github.com/amphp/sync", + "keywords": [ + "async", + "asynchronous", + "mutex", + "semaphore", + "synchronization" + ], + "support": { + "issues": "https://github.com/amphp/sync/issues", + "source": "https://github.com/amphp/sync/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-08-03T19:31:26+00:00" + }, + { + "name": "amphp/websocket", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/websocket.git", + "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/websocket/zipball/963904b6a883c4b62d9222d1d9749814fac96a3b", + "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/parser": "^1", + "amphp/pipeline": "^1", + "amphp/socket": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.18" + }, + "suggest": { + "ext-zlib": "Required for compression" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Websocket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + } + ], + "description": "Shared code for websocket servers and clients.", + "homepage": "https://github.com/amphp/websocket", + "keywords": [ + "amp", + "amphp", + "async", + "http", + "non-blocking", + "websocket" + ], + "support": { + "issues": "https://github.com/amphp/websocket/issues", + "source": "https://github.com/amphp/websocket/tree/v2.0.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-10-28T21:28:45+00:00" + }, + { + "name": "amphp/websocket-client", + "version": "v2.0.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/websocket-client.git", + "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/websocket-client/zipball/dc033fdce0af56295a23f63ac4f579b34d470d6c", + "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2.1", + "amphp/http": "^2.1", + "amphp/http-client": "^5", + "amphp/socket": "^2.2", + "amphp/websocket": "^2", + "league/uri": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1|^2", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/http-server": "^3", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/websocket-server": "^3|^4", + "phpunit/phpunit": "^9", + "psalm/phar": "~5.26.1", + "psr/log": "^1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Websocket\\Client\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Async WebSocket client for PHP based on Amp.", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "http", + "non-blocking", + "websocket" + ], + "support": { + "issues": "https://github.com/amphp/websocket-client/issues", + "source": "https://github.com/amphp/websocket-client/tree/v2.0.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-24T17:25:34+00:00" + }, + { + "name": "brianium/paratest", + "version": "v7.17.0", + "source": { + "type": "git", + "url": "https://github.com/paratestphp/paratest.git", + "reference": "53cb90a6aa3ef3840458781600628ade058a18b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/53cb90a6aa3ef3840458781600628ade058a18b9", + "reference": "53cb90a6aa3ef3840458781600628ade058a18b9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-pcre": "*", + "ext-reflection": "*", + "ext-simplexml": "*", + "fidry/cpu-core-counter": "^1.3.0", + "jean85/pretty-package-versions": "^2.1.1", + "php": "~8.3.0 || ~8.4.0 || ~8.5.0", + "phpunit/php-code-coverage": "^12.5.2", + "phpunit/php-file-iterator": "^6", + "phpunit/php-timer": "^8", + "phpunit/phpunit": "^12.5.8", + "sebastian/environment": "^8.0.3", + "symfony/console": "^7.3.4 || ^8.0.0", + "symfony/process": "^7.3.4 || ^8.0.0" + }, + "require-dev": { + "doctrine/coding-standard": "^14.0.0", + "ext-pcntl": "*", + "ext-pcov": "*", + "ext-posix": "*", + "phpstan/phpstan": "^2.1.38", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpstan/phpstan-phpunit": "^2.0.12", + "phpstan/phpstan-strict-rules": "^2.0.8", + "symfony/filesystem": "^7.3.2 || ^8.0.0" + }, + "bin": [ + "bin/paratest", + "bin/paratest_for_phpstorm" + ], + "type": "library", + "autoload": { + "psr-4": { + "ParaTest\\": [ + "src/" ] } }, @@ -6497,29 +7501,73 @@ "role": "Developer" } ], - "description": "Parallel testing for PHP", - "homepage": "https://github.com/paratestphp/paratest", + "description": "Parallel testing for PHP", + "homepage": "https://github.com/paratestphp/paratest", + "keywords": [ + "concurrent", + "parallel", + "phpunit", + "testing" + ], + "support": { + "issues": "https://github.com/paratestphp/paratest/issues", + "source": "https://github.com/paratestphp/paratest/tree/v7.17.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/Slamdunk", + "type": "github" + }, + { + "url": "https://paypal.me/filippotessarotto", + "type": "paypal" + } + ], + "time": "2026-02-05T09:14:44+00:00" + }, + { + "name": "daverandom/libdns", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/DaveRandom/LibDNS.git", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "Required for IDN support" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "LibDNS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "DNS protocol implementation written in pure PHP", "keywords": [ - "concurrent", - "parallel", - "phpunit", - "testing" + "dns" ], "support": { - "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.17.0" + "issues": "https://github.com/DaveRandom/LibDNS/issues", + "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" }, - "funding": [ - { - "url": "https://github.com/sponsors/Slamdunk", - "type": "github" - }, - { - "url": "https://paypal.me/filippotessarotto", - "type": "paypal" - } - ], - "time": "2026-02-05T09:14:44+00:00" + "time": "2024-04-12T12:12:48+00:00" }, { "name": "doctrine/deprecations", @@ -6875,37 +7923,96 @@ }, "time": "2025-03-19T14:43:43+00:00" }, + { + "name": "kelunik/certificate", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/kelunik/certificate.git", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">=7.0" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^6 | 7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Kelunik\\Certificate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Access certificate details and transform between different formats.", + "keywords": [ + "DER", + "certificate", + "certificates", + "openssl", + "pem", + "x509" + ], + "support": { + "issues": "https://github.com/kelunik/certificate/issues", + "source": "https://github.com/kelunik/certificate/tree/v1.1.3" + }, + "time": "2023-02-03T21:26:53+00:00" + }, { "name": "laravel/boost", - "version": "v1.0.18", + "version": "v2.4.13", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab" + "reference": "f55e08f5afa89ac72f23f574175005b67878f466" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", + "url": "https://api.github.com/repos/laravel/boost/zipball/f55e08f5afa89ac72f23f574175005b67878f466", + "reference": "f55e08f5afa89ac72f23f574175005b67878f466", "shasum": "" }, "require": { "guzzlehttp/guzzle": "^7.9", - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "laravel/mcp": "^0.1.0", - "laravel/prompts": "^0.1.9|^0.3", - "laravel/roster": "^0.2", - "php": "^8.1|^8.2" + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "laravel/mcp": "^0.7.1|^0.8.0|^0.9.0", + "laravel/prompts": "^0.3.10", + "laravel/roster": "^0.5.0", + "php": "^8.2" }, "require-dev": { - "laravel/pint": "^1.14|^1.23", - "mockery/mockery": "^1.6", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.0", - "phpstan/phpstan": "^2.0" + "laravel/pint": "^1.27.0", + "mockery/mockery": "^1.6.12", + "orchestra/testbench": "^9.15.0|^10.6|^11.0", + "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.1" }, "type": "library", "extra": { @@ -6927,7 +8034,7 @@ "license": [ "MIT" ], - "description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.", + "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.", "homepage": "https://github.com/laravel/boost", "keywords": [ "ai", @@ -6938,41 +8045,48 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2025-08-16T09:10:03+00:00" + "time": "2026-07-17T14:28:57+00:00" }, { "name": "laravel/mcp", - "version": "v0.1.1", + "version": "v0.9.0", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713" + "reference": "3d365d5db3493c806d190f3404cd7431634ca4e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713", + "url": "https://api.github.com/repos/laravel/mcp/zipball/3d365d5db3493c806d190f3404cd7431634ca4e1", + "reference": "3d365d5db3493c806d190f3404cd7431634ca4e1", "shasum": "" }, "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/http": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "illuminate/validation": "^10.0|^11.0|^12.0", - "php": "^8.1|^8.2" + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2", + "symfony/process": "^7.4.5|^8.0.5" }, "require-dev": { - "laravel/pint": "^1.14", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "phpstan/phpstan": "^2.0" + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" }, "type": "library", "extra": { "laravel": { "aliases": { - "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + "Mcp": "Laravel\\Mcp\\Facades\\Mcp" }, "providers": [ "Laravel\\Mcp\\Server\\McpServiceProvider" @@ -6982,8 +8096,6 @@ "autoload": { "psr-4": { "Laravel\\Mcp\\": "src/", - "Workbench\\App\\": "workbench/app/", - "Laravel\\Mcp\\Tests\\": "tests/", "Laravel\\Mcp\\Server\\": "src/Server/" } }, @@ -6991,10 +8103,15 @@ "license": [ "MIT" ], - "description": "The easiest way to add MCP servers to your Laravel app.", + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", "homepage": "https://github.com/laravel/mcp", "keywords": [ - "dev", "laravel", "mcp" ], @@ -7002,7 +8119,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2025-08-16T09:50:43+00:00" + "time": "2026-07-16T17:16:38+00:00" }, { "name": "laravel/pail", @@ -7153,30 +8270,31 @@ }, { "name": "laravel/roster", - "version": "v0.2.2", + "version": "v0.5.1", "source": { "type": "git", "url": "https://github.com/laravel/roster.git", - "reference": "67a39bce557a6cb7e7205a2a9d6c464f0e72956f" + "reference": "5089de7615f72f78e831590ff9d0435fed0102bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/roster/zipball/67a39bce557a6cb7e7205a2a9d6c464f0e72956f", - "reference": "67a39bce557a6cb7e7205a2a9d6c464f0e72956f", + "url": "https://api.github.com/repos/laravel/roster/zipball/5089de7615f72f78e831590ff9d0435fed0102bb", + "reference": "5089de7615f72f78e831590ff9d0435fed0102bb", "shasum": "" }, "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "php": "^8.1|^8.2" + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/routing": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/yaml": "^7.2|^8.0" }, "require-dev": { "laravel/pint": "^1.14", "mockery/mockery": "^1.6", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.0", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.1", "phpstan/phpstan": "^2.0" }, "type": "library", @@ -7209,7 +8327,7 @@ "issues": "https://github.com/laravel/roster/issues", "source": "https://github.com/laravel/roster" }, - "time": "2025-07-24T12:31:13+00:00" + "time": "2026-03-05T07:58:43+00:00" }, { "name": "laravel/sail", @@ -7274,6 +8392,90 @@ }, "time": "2026-02-06T12:16:02+00:00" }, + { + "name": "league/uri-components", + "version": "7.8.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-components.git", + "reference": "8b5ffcebcc0842b76eb80964795bd56a8333b2ba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/8b5ffcebcc0842b76eb80964795bd56a8333b2ba", + "reference": "8b5ffcebcc0842b76eb80964795bd56a8333b2ba", + "shasum": "" + }, + "require": { + "league/uri": "^7.8", + "php": "^8.1" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-mbstring": "to use the sorting algorithm of URLSearchParams", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI components manipulation library", + "homepage": "http://uri.thephpleague.com", + "keywords": [ + "authority", + "components", + "fragment", + "host", + "middleware", + "modifier", + "path", + "port", + "query", + "rfc3986", + "scheme", + "uri", + "url", + "userinfo" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-components/tree/7.8.0" + }, + "funding": [ + { + "url": "https://github.com/nyamsprod", + "type": "github" + } + ], + "time": "2026-01-14T17:24:56+00:00" + }, { "name": "mockery/mockery", "version": "1.6.12", @@ -7772,6 +8974,89 @@ ], "time": "2025-08-20T13:10:51+00:00" }, + { + "name": "pestphp/pest-plugin-browser", + "version": "v4.3.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-browser.git", + "reference": "48bc408033281974952a6b296592cef3b920a2db" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/48bc408033281974952a6b296592cef3b920a2db", + "reference": "48bc408033281974952a6b296592cef3b920a2db", + "shasum": "" + }, + "require": { + "amphp/amp": "^3.1.1", + "amphp/http-server": "^3.4.4", + "amphp/websocket-client": "^2.0.2", + "ext-sockets": "*", + "pestphp/pest": "^4.3.2", + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3", + "symfony/process": "^7.4.5|^8.0.5" + }, + "require-dev": { + "ext-pcntl": "*", + "ext-posix": "*", + "livewire/livewire": "^3.7.10", + "nunomaduro/collision": "^8.9.0", + "orchestra/testbench": "^10.9.0", + "pestphp/pest-dev-tools": "^4.1.0", + "pestphp/pest-plugin-laravel": "^4.0", + "pestphp/pest-plugin-type-coverage": "^4.0.3" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Browser\\Plugin" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Browser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Pest plugin to test browser interactions", + "keywords": [ + "browser", + "framework", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.3.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-02-17T14:54:40+00:00" + }, { "name": "pestphp/pest-plugin-laravel", "version": "v4.0.0", @@ -8769,6 +10054,78 @@ ], "time": "2026-01-27T06:12:29+00:00" }, + { + "name": "revolt/event-loop", + "version": "v1.0.9", + "source": { + "type": "git", + "url": "https://github.com/revoltphp/event-loop.git", + "reference": "44061cf513e53c6200372fc935ac42271566295d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/44061cf513e53c6200372fc935ac42271566295d", + "reference": "44061cf513e53c6200372fc935ac42271566295d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-json": "*", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Revolt\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "ceesjank@gmail.com" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Rock-solid event loop for concurrent PHP applications.", + "keywords": [ + "async", + "asynchronous", + "concurrency", + "event", + "event-loop", + "non-blocking", + "scheduler" + ], + "support": { + "issues": "https://github.com/revoltphp/event-loop/issues", + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.9" + }, + "time": "2026-05-16T17:55:38+00:00" + }, { "name": "sebastian/cli-parser", "version": "4.2.0", @@ -9974,5 +11331,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/auth.php b/config/auth.php index 7d1eb0de..c7e1a387 100644 --- a/config/auth.php +++ b/config/auth.php @@ -40,6 +40,11 @@ 'driver' => 'session', 'provider' => 'users', ], + + 'customer' => [ + 'driver' => 'session', + 'provider' => 'customers', + ], ], /* @@ -65,10 +70,10 @@ 'model' => env('AUTH_MODEL', App\Models\User::class), ], - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], + 'customers' => [ + 'driver' => 'customer', + 'model' => App\Models\Customer::class, + ], ], /* @@ -97,6 +102,13 @@ 'expire' => 60, 'throttle' => 60, ], + + 'customers' => [ + 'provider' => 'customers', + 'table' => 'customer_password_reset_tokens', + 'expire' => 60, + 'throttle' => 60, + ], ], /* diff --git a/config/cors.php b/config/cors.php new file mode 100644 index 00000000..19f67154 --- /dev/null +++ b/config/cors.php @@ -0,0 +1,31 @@ + ['api/*', 'sanctum/csrf-cookie'], + + 'allowed_methods' => ['*'], + + 'allowed_origins' => [env('FRONTEND_URL', '*')], + + 'allowed_origins_patterns' => [], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'Retry-After'], + + 'max_age' => 0, + + 'supports_credentials' => true, + +]; diff --git a/config/database.php b/config/database.php index df933e7f..ecfaacf9 100644 --- a/config/database.php +++ b/config/database.php @@ -37,9 +37,9 @@ 'database' => env('DB_DATABASE', database_path('database.sqlite')), 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), - 'busy_timeout' => null, - 'journal_mode' => null, - 'synchronous' => null, + 'busy_timeout' => 5000, + 'journal_mode' => 'wal', + 'synchronous' => 'normal', 'transaction_mode' => 'DEFERRED', ], diff --git a/config/fortify.php b/config/fortify.php deleted file mode 100644 index ce67e2c3..00000000 --- a/config/fortify.php +++ /dev/null @@ -1,157 +0,0 @@ - 'web', - - /* - |-------------------------------------------------------------------------- - | Fortify Password Broker - |-------------------------------------------------------------------------- - | - | Here you may specify which password broker Fortify can use when a user - | is resetting their password. This configured value should match one - | of your password brokers setup in your "auth" configuration file. - | - */ - - 'passwords' => 'users', - - /* - |-------------------------------------------------------------------------- - | Username / Email - |-------------------------------------------------------------------------- - | - | This value defines which model attribute should be considered as your - | application's "username" field. Typically, this might be the email - | address of the users but you are free to change this value here. - | - | Out of the box, Fortify expects forgot password and reset password - | requests to have a field named 'email'. If the application uses - | another name for the field you may define it below as needed. - | - */ - - 'username' => 'email', - - 'email' => 'email', - - /* - |-------------------------------------------------------------------------- - | Lowercase Usernames - |-------------------------------------------------------------------------- - | - | This value defines whether usernames should be lowercased before saving - | them in the database, as some database system string fields are case - | sensitive. You may disable this for your application if necessary. - | - */ - - 'lowercase_usernames' => true, - - /* - |-------------------------------------------------------------------------- - | Home Path - |-------------------------------------------------------------------------- - | - | Here you may configure the path where users will get redirected during - | authentication or password reset when the operations are successful - | and the user is authenticated. You are free to change this value. - | - */ - - 'home' => '/dashboard', - - /* - |-------------------------------------------------------------------------- - | Fortify Routes Prefix / Subdomain - |-------------------------------------------------------------------------- - | - | Here you may specify which prefix Fortify will assign to all the routes - | that it registers with the application. If necessary, you may change - | subdomain under which all of the Fortify routes will be available. - | - */ - - 'prefix' => '', - - 'domain' => null, - - /* - |-------------------------------------------------------------------------- - | Fortify Routes Middleware - |-------------------------------------------------------------------------- - | - | Here you may specify which middleware Fortify will assign to the routes - | that it registers with the application. If necessary, you may change - | these middleware but typically this provided default is preferred. - | - */ - - 'middleware' => ['web'], - - /* - |-------------------------------------------------------------------------- - | Rate Limiting - |-------------------------------------------------------------------------- - | - | By default, Fortify will throttle logins to five requests per minute for - | every email and IP address combination. However, if you would like to - | specify a custom rate limiter to call then you may specify it here. - | - */ - - 'limiters' => [ - 'login' => 'login', - 'two-factor' => 'two-factor', - ], - - /* - |-------------------------------------------------------------------------- - | Register View Routes - |-------------------------------------------------------------------------- - | - | Here you may specify if the routes returning views should be disabled as - | you may not need them when building your own application. This may be - | especially true if you're writing a custom single-page application. - | - */ - - 'views' => true, - - /* - |-------------------------------------------------------------------------- - | Features - |-------------------------------------------------------------------------- - | - | Some of the Fortify features are optional. You may disable the features - | by removing them from this array. You're free to only remove some of - | these features, or you can even remove all of these if you need to. - | - */ - - 'features' => [ - Features::registration(), - Features::resetPasswords(), - Features::emailVerification(), - Features::twoFactorAuthentication([ - 'confirm' => true, - 'confirmPassword' => true, - // 'window' => 0 - ]), - ], - -]; diff --git a/config/logging.php b/config/logging.php index 9e998a49..5c052a3b 100644 --- a/config/logging.php +++ b/config/logging.php @@ -73,6 +73,23 @@ 'replace_placeholders' => true, ], + 'json' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/json.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => 14, + 'formatter' => Monolog\Formatter\JsonFormatter::class, + 'replace_placeholders' => true, + ], + + 'audit' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/audit.log'), + 'level' => 'info', + 'days' => 90, + 'replace_placeholders' => true, + ], + 'slack' => [ 'driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), diff --git a/database/factories/AnalyticsDailyFactory.php b/database/factories/AnalyticsDailyFactory.php new file mode 100644 index 00000000..47b46474 --- /dev/null +++ b/database/factories/AnalyticsDailyFactory.php @@ -0,0 +1,32 @@ + + */ +class AnalyticsDailyFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'date' => now()->toDateString(), + 'orders_count' => fake()->numberBetween(0, 20), + 'revenue_amount' => fake()->numberBetween(0, 100000), + 'aov_amount' => fake()->numberBetween(0, 5000), + 'visits_count' => fake()->numberBetween(0, 500), + 'add_to_cart_count' => fake()->numberBetween(0, 100), + 'checkout_started_count' => fake()->numberBetween(0, 50), + 'checkout_completed_count' => fake()->numberBetween(0, 20), + ]; + } +} diff --git a/database/factories/AnalyticsEventFactory.php b/database/factories/AnalyticsEventFactory.php new file mode 100644 index 00000000..1c9c0b61 --- /dev/null +++ b/database/factories/AnalyticsEventFactory.php @@ -0,0 +1,31 @@ + + */ +class AnalyticsEventFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'type' => 'page_view', + 'session_id' => fake()->uuid(), + 'customer_id' => null, + 'properties_json' => [], + 'client_event_id' => fake()->uuid(), + 'occurred_at' => now(), + 'created_at' => now(), + ]; + } +} diff --git a/database/factories/AppFactory.php b/database/factories/AppFactory.php new file mode 100644 index 00000000..76774247 --- /dev/null +++ b/database/factories/AppFactory.php @@ -0,0 +1,34 @@ + + */ +class AppFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->unique()->words(2, true).' App', + 'status' => 'active', + ]; + } + + /** + * Indicate that the app is disabled. + */ + public function disabled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'disabled', + ]); + } +} diff --git a/database/factories/AppInstallationFactory.php b/database/factories/AppInstallationFactory.php new file mode 100644 index 00000000..2539362c --- /dev/null +++ b/database/factories/AppInstallationFactory.php @@ -0,0 +1,39 @@ + + */ +class AppInstallationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'app_id' => App::factory(), + 'scopes_json' => ['read-products', 'read-orders'], + 'status' => 'active', + 'installed_at' => now(), + ]; + } + + /** + * Indicate that the installation is uninstalled. + */ + public function uninstalled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'uninstalled', + ]); + } +} diff --git a/database/factories/CartFactory.php b/database/factories/CartFactory.php new file mode 100644 index 00000000..7e35d835 --- /dev/null +++ b/database/factories/CartFactory.php @@ -0,0 +1,49 @@ + + */ +class CartFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'customer_id' => null, + 'currency' => 'USD', + 'cart_version' => 1, + 'status' => CartStatus::Active, + ]; + } + + /** + * Indicate that the cart is abandoned. + */ + public function abandoned(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => CartStatus::Abandoned, + ]); + } + + /** + * Indicate that the cart is converted. + */ + public function converted(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => CartStatus::Converted, + ]); + } +} diff --git a/database/factories/CartLineFactory.php b/database/factories/CartLineFactory.php new file mode 100644 index 00000000..32e73a40 --- /dev/null +++ b/database/factories/CartLineFactory.php @@ -0,0 +1,43 @@ + + */ +class CartLineFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'cart_id' => Cart::factory(), + 'variant_id' => ProductVariant::factory(), + 'quantity' => 1, + 'unit_price_amount' => fake()->numberBetween(100, 10000), + 'line_subtotal_amount' => 0, + 'line_discount_amount' => 0, + 'line_total_amount' => 0, + ]; + } + + /** + * Recalculate derived amounts after creation. + */ + public function configure(): static + { + return $this->afterMaking(function (CartLine $line): void { + $line->line_subtotal_amount = $line->unit_price_amount * $line->quantity; + $line->line_total_amount = $line->line_subtotal_amount - $line->line_discount_amount; + }); + } +} diff --git a/database/factories/CheckoutFactory.php b/database/factories/CheckoutFactory.php new file mode 100644 index 00000000..cd58d9a8 --- /dev/null +++ b/database/factories/CheckoutFactory.php @@ -0,0 +1,58 @@ + + */ +class CheckoutFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => fn (array $attributes) => Cart::find($attributes['cart_id'])?->store_id, + 'cart_id' => Cart::factory(), + 'customer_id' => null, + 'status' => CheckoutStatus::Started, + 'payment_method' => null, + 'email' => fake()->safeEmail(), + 'shipping_address_json' => null, + 'billing_address_json' => null, + 'shipping_method_id' => null, + 'discount_code' => null, + 'tax_provider_snapshot_json' => null, + 'totals_json' => null, + 'expires_at' => now()->addHours(24), + ]; + } + + /** + * Indicate that the checkout has an address set. + */ + public function addressed(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => CheckoutStatus::Addressed, + ]); + } + + /** + * Indicate that the checkout is expired. + */ + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => CheckoutStatus::Expired, + 'expires_at' => now()->subHour(), + ]); + } +} diff --git a/database/factories/CollectionFactory.php b/database/factories/CollectionFactory.php new file mode 100644 index 00000000..aa643236 --- /dev/null +++ b/database/factories/CollectionFactory.php @@ -0,0 +1,51 @@ + + */ +class CollectionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'title' => fake()->unique()->words(2, true), + 'handle' => fake()->unique()->slug(2), + 'description_html' => null, + 'type' => CollectionType::Manual, + 'status' => CollectionStatus::Active, + ]; + } + + /** + * Indicate that the collection is rule-based. + */ + public function automated(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => CollectionType::Automated, + ]); + } + + /** + * Indicate that the collection is a draft. + */ + public function draft(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => CollectionStatus::Draft, + ]); + } +} diff --git a/database/factories/CustomerAddressFactory.php b/database/factories/CustomerAddressFactory.php new file mode 100644 index 00000000..c482b538 --- /dev/null +++ b/database/factories/CustomerAddressFactory.php @@ -0,0 +1,50 @@ + + */ +class CustomerAddressFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'customer_id' => Customer::factory(), + 'label' => fake()->randomElement(['Home', 'Work']), + 'address_json' => [ + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'company' => null, + 'address1' => fake()->streetAddress(), + 'address2' => null, + 'city' => fake()->city(), + 'province' => null, + 'province_code' => null, + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => fake()->postcode(), + 'phone' => null, + ], + 'is_default' => false, + ]; + } + + /** + * Indicate that the address is the customer's default. + */ + public function default(): static + { + return $this->state(fn (array $attributes) => [ + 'is_default' => true, + ]); + } +} diff --git a/database/factories/CustomerFactory.php b/database/factories/CustomerFactory.php new file mode 100644 index 00000000..419437f4 --- /dev/null +++ b/database/factories/CustomerFactory.php @@ -0,0 +1,44 @@ + + */ +class CustomerFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'email' => fake()->unique()->safeEmail(), + 'password_hash' => static::$password ??= Hash::make('password'), + 'name' => fake()->name(), + 'marketing_opt_in' => false, + ]; + } + + /** + * Indicate that the customer checked out as a guest (no password). + */ + public function guest(): static + { + return $this->state(fn (array $attributes) => [ + 'password_hash' => null, + ]); + } +} diff --git a/database/factories/DiscountFactory.php b/database/factories/DiscountFactory.php new file mode 100644 index 00000000..91663c3d --- /dev/null +++ b/database/factories/DiscountFactory.php @@ -0,0 +1,113 @@ + + */ +class DiscountFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'type' => DiscountType::Code, + 'code' => strtoupper(fake()->unique()->bothify('CODE##??')), + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'starts_at' => now()->subDay(), + 'ends_at' => null, + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [], + 'status' => DiscountStatus::Active, + ]; + } + + /** + * Indicate a percent discount with the given whole percentage. + */ + public function percent(int $value = 10): static + { + return $this->state(fn (array $attributes) => [ + 'value_type' => DiscountValueType::Percent, + 'value_amount' => $value, + ]); + } + + /** + * Indicate a fixed-amount discount in minor units. + */ + public function fixed(int $amount = 500): static + { + return $this->state(fn (array $attributes) => [ + 'value_type' => DiscountValueType::Fixed, + 'value_amount' => $amount, + ]); + } + + /** + * Indicate a free-shipping discount. + */ + public function freeShipping(): static + { + return $this->state(fn (array $attributes) => [ + 'value_type' => DiscountValueType::FreeShipping, + 'value_amount' => 0, + ]); + } + + /** + * Indicate an automatic (codeless) discount. + */ + public function automatic(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => DiscountType::Automatic, + 'code' => null, + ]); + } + + /** + * Indicate that the discount is expired. + */ + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'starts_at' => now()->subDays(10), + 'ends_at' => now()->subDay(), + ]); + } + + /** + * Indicate that the discount has reached its usage limit. + */ + public function maxedOut(): static + { + return $this->state(fn (array $attributes) => [ + 'usage_limit' => 10, + 'usage_count' => 10, + ]); + } + + /** + * Set a minimum purchase amount rule in minor units. + */ + public function withMinPurchase(int $amount): static + { + return $this->state(fn (array $attributes) => [ + 'rules_json' => array_merge($attributes['rules_json'] ?? [], ['min_purchase_amount' => $amount]), + ]); + } +} diff --git a/database/factories/FulfillmentFactory.php b/database/factories/FulfillmentFactory.php new file mode 100644 index 00000000..2be2cfbb --- /dev/null +++ b/database/factories/FulfillmentFactory.php @@ -0,0 +1,52 @@ + + */ +class FulfillmentFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'status' => FulfillmentShipmentStatus::Pending, + 'tracking_company' => null, + 'tracking_number' => null, + 'tracking_url' => null, + 'shipped_at' => null, + ]; + } + + /** + * Indicate that the fulfillment has shipped. + */ + public function shipped(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => FulfillmentShipmentStatus::Shipped, + 'shipped_at' => now(), + ]); + } + + /** + * Indicate that the fulfillment has been delivered. + */ + public function delivered(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => FulfillmentShipmentStatus::Delivered, + 'shipped_at' => now(), + ]); + } +} diff --git a/database/factories/FulfillmentLineFactory.php b/database/factories/FulfillmentLineFactory.php new file mode 100644 index 00000000..a8a4519f --- /dev/null +++ b/database/factories/FulfillmentLineFactory.php @@ -0,0 +1,27 @@ + + */ +class FulfillmentLineFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'fulfillment_id' => Fulfillment::factory(), + 'order_line_id' => OrderLine::factory(), + 'quantity' => 1, + ]; + } +} diff --git a/database/factories/InventoryItemFactory.php b/database/factories/InventoryItemFactory.php new file mode 100644 index 00000000..96a8ea86 --- /dev/null +++ b/database/factories/InventoryItemFactory.php @@ -0,0 +1,40 @@ + + */ +class InventoryItemFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'variant_id' => ProductVariant::factory(), + 'quantity_on_hand' => 10, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ]; + } + + /** + * Indicate that overselling is allowed (backorders). + */ + public function backorderable(): static + { + return $this->state(fn (array $attributes) => [ + 'policy' => InventoryPolicy::Continue, + ]); + } +} diff --git a/database/factories/NavigationItemFactory.php b/database/factories/NavigationItemFactory.php new file mode 100644 index 00000000..77c2ec6e --- /dev/null +++ b/database/factories/NavigationItemFactory.php @@ -0,0 +1,30 @@ + + */ +class NavigationItemFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'menu_id' => NavigationMenu::factory(), + 'type' => NavigationItemType::Link, + 'label' => fake()->words(2, true), + 'url' => '/'.fake()->slug(2), + 'resource_id' => null, + 'position' => 0, + ]; + } +} diff --git a/database/factories/NavigationMenuFactory.php b/database/factories/NavigationMenuFactory.php new file mode 100644 index 00000000..ab56aa81 --- /dev/null +++ b/database/factories/NavigationMenuFactory.php @@ -0,0 +1,48 @@ + + */ +class NavigationMenuFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'handle' => fake()->unique()->slug(2), + 'title' => fake()->words(2, true), + ]; + } + + /** + * Indicate that the menu is the storefront main menu. + */ + public function mainMenu(): static + { + return $this->state(fn (array $attributes) => [ + 'handle' => 'main-menu', + 'title' => 'Main menu', + ]); + } + + /** + * Indicate that the menu is the storefront footer menu. + */ + public function footerMenu(): static + { + return $this->state(fn (array $attributes) => [ + 'handle' => 'footer-menu', + 'title' => 'Footer menu', + ]); + } +} diff --git a/database/factories/OauthClientFactory.php b/database/factories/OauthClientFactory.php new file mode 100644 index 00000000..e9e85737 --- /dev/null +++ b/database/factories/OauthClientFactory.php @@ -0,0 +1,28 @@ + + */ +class OauthClientFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'app_id' => App::factory(), + 'client_id' => 'client_'.Str::random(24), + 'client_secret_encrypted' => Str::random(40), + 'redirect_uris_json' => ['https://example.com/oauth/callback'], + ]; + } +} diff --git a/database/factories/OauthTokenFactory.php b/database/factories/OauthTokenFactory.php new file mode 100644 index 00000000..831aa93e --- /dev/null +++ b/database/factories/OauthTokenFactory.php @@ -0,0 +1,28 @@ + + */ +class OauthTokenFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'installation_id' => AppInstallation::factory(), + 'access_token_hash' => hash('sha256', Str::random(40)), + 'refresh_token_hash' => null, + 'expires_at' => now()->addHour(), + ]; + } +} diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php new file mode 100644 index 00000000..ed5380a8 --- /dev/null +++ b/database/factories/OrderFactory.php @@ -0,0 +1,116 @@ + + */ +class OrderFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'checkout_id' => null, + 'customer_id' => null, + 'order_number' => '#'.fake()->unique()->numberBetween(1001, 999999), + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + 'fulfillment_status' => FulfillmentOrderStatus::Unfulfilled, + 'currency' => 'USD', + 'subtotal_amount' => 0, + 'discount_amount' => 0, + 'shipping_amount' => 0, + 'tax_amount' => 0, + 'total_amount' => 0, + 'email' => fake()->safeEmail(), + 'billing_address_json' => null, + 'shipping_address_json' => null, + 'placed_at' => now(), + ]; + } + + /** + * Indicate that the order is paid (instant capture). + */ + public function paid(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => OrderStatus::Paid, + 'financial_status' => FinancialStatus::Paid, + ]); + } + + /** + * Indicate that the order awaits a bank transfer payment. + */ + public function bankTransfer(): static + { + return $this->state(fn (array $attributes) => [ + 'payment_method' => PaymentMethod::BankTransfer, + 'status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + ]); + } + + /** + * Indicate that the order is fulfilled. + */ + public function fulfilled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => OrderStatus::Fulfilled, + 'fulfillment_status' => FulfillmentOrderStatus::Fulfilled, + ]); + } + + /** + * Create order lines with the given quantities. + * + * @param array $lines + */ + public function withLines(array $lines): static + { + return $this->afterCreating(function (Order $order) use ($lines): void { + $subtotal = 0; + + foreach ($lines as $line) { + $quantity = (int) ($line['quantity'] ?? 1); + $unitPrice = (int) ($line['unit_price_amount'] ?? 1000); + $total = $quantity * $unitPrice; + $subtotal += $total; + + $order->lines()->create([ + 'product_id' => $line['product_id'] ?? null, + 'variant_id' => $line['variant_id'] ?? null, + 'title_snapshot' => $line['title_snapshot'] ?? fake()->words(2, true), + 'sku_snapshot' => $line['sku_snapshot'] ?? fake()->bothify('SKU-####'), + 'quantity' => $quantity, + 'unit_price_amount' => $unitPrice, + 'total_amount' => $total, + 'tax_lines_json' => $line['tax_lines_json'] ?? [], + 'discount_allocations_json' => $line['discount_allocations_json'] ?? [], + ]); + } + + $order->forceFill([ + 'subtotal_amount' => $subtotal, + 'total_amount' => $subtotal + $order->tax_amount + $order->shipping_amount - $order->discount_amount, + ])->save(); + }); + } +} diff --git a/database/factories/OrderLineFactory.php b/database/factories/OrderLineFactory.php new file mode 100644 index 00000000..48b2e0d3 --- /dev/null +++ b/database/factories/OrderLineFactory.php @@ -0,0 +1,36 @@ + + */ +class OrderLineFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $unitPrice = fake()->numberBetween(100, 10000); + $quantity = fake()->numberBetween(1, 3); + + return [ + 'order_id' => Order::factory(), + 'product_id' => null, + 'variant_id' => null, + 'title_snapshot' => fake()->words(3, true), + 'sku_snapshot' => fake()->bothify('SKU-####'), + 'quantity' => $quantity, + 'unit_price_amount' => $unitPrice, + 'total_amount' => $unitPrice * $quantity, + 'tax_lines_json' => [], + 'discount_allocations_json' => [], + ]; + } +} diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php new file mode 100644 index 00000000..5f550971 --- /dev/null +++ b/database/factories/OrganizationFactory.php @@ -0,0 +1,24 @@ + + */ +class OrganizationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->company(), + 'billing_email' => fake()->unique()->safeEmail(), + ]; + } +} diff --git a/database/factories/PageFactory.php b/database/factories/PageFactory.php new file mode 100644 index 00000000..8e49b40b --- /dev/null +++ b/database/factories/PageFactory.php @@ -0,0 +1,41 @@ + + */ +class PageFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'title' => fake()->unique()->words(3, true), + 'handle' => fake()->unique()->slug(2), + 'body_html' => '

'.fake()->sentence().'

', + 'status' => PageStatus::Draft, + 'published_at' => null, + ]; + } + + /** + * Indicate that the page is published. + */ + public function published(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PageStatus::Published, + 'published_at' => now(), + ]); + } +} diff --git a/database/factories/PaymentFactory.php b/database/factories/PaymentFactory.php new file mode 100644 index 00000000..15fc3055 --- /dev/null +++ b/database/factories/PaymentFactory.php @@ -0,0 +1,45 @@ + + */ +class PaymentFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'provider' => 'mock', + 'method' => PaymentMethod::CreditCard, + 'provider_payment_id' => 'mock_'.Str::random(16), + 'status' => PaymentStatus::Captured, + 'amount' => fn (array $attributes) => Order::find($attributes['order_id'])?->total_amount ?? 0, + 'currency' => 'USD', + 'raw_json_encrypted' => null, + ]; + } + + /** + * Indicate that the payment is pending (bank transfer). + */ + public function pending(): static + { + return $this->state(fn (array $attributes) => [ + 'method' => PaymentMethod::BankTransfer, + 'status' => PaymentStatus::Pending, + ]); + } +} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php new file mode 100644 index 00000000..22095c16 --- /dev/null +++ b/database/factories/ProductFactory.php @@ -0,0 +1,88 @@ + + */ +class ProductFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $title = fake()->unique()->words(3, true); + + return [ + 'store_id' => Store::factory(), + 'title' => $title, + 'handle' => fake()->unique()->slug(2), + 'status' => ProductStatus::Draft, + 'description_html' => '

'.fake()->sentence().'

', + 'vendor' => fake()->company(), + 'product_type' => fake()->word(), + 'tags' => [], + 'published_at' => null, + ]; + } + + /** + * Indicate that the product is a draft. + */ + public function draft(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => ProductStatus::Draft, + 'published_at' => null, + ]); + } + + /** + * Indicate that the product is active and published. + */ + public function active(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => ProductStatus::Active, + 'published_at' => now(), + ]); + } + + /** + * Indicate that the product is archived. + */ + public function archived(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => ProductStatus::Archived, + ]); + } + + /** + * Create variants (each with an inventory item) for the product. + */ + public function withVariants(int $count = 1, array $attributes = []): static + { + return $this->afterCreating(function (\App\Models\Product $product) use ($count, $attributes): void { + for ($i = 0; $i < $count; $i++) { + $variant = $product->variants()->create(array_merge([ + 'price_amount' => fake()->numberBetween(100, 10000), + 'position' => $i, + 'is_default' => $i === 0, + ], $attributes)); + + $variant->inventoryItem()->create([ + 'store_id' => $product->store_id, + 'quantity_on_hand' => 10, + ]); + } + }); + } +} diff --git a/database/factories/ProductMediaFactory.php b/database/factories/ProductMediaFactory.php new file mode 100644 index 00000000..d0e14939 --- /dev/null +++ b/database/factories/ProductMediaFactory.php @@ -0,0 +1,41 @@ + + */ +class ProductMediaFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'type' => MediaType::Image, + 'storage_key' => 'media/originals/'.fake()->uuid().'.jpg', + 'alt_text' => fake()->sentence(), + 'position' => 0, + 'status' => MediaStatus::Processing, + ]; + } + + /** + * Indicate that the media finished processing. + */ + public function ready(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => MediaStatus::Ready, + ]); + } +} diff --git a/database/factories/ProductOptionFactory.php b/database/factories/ProductOptionFactory.php new file mode 100644 index 00000000..02279ff9 --- /dev/null +++ b/database/factories/ProductOptionFactory.php @@ -0,0 +1,26 @@ + + */ +class ProductOptionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'name' => fake()->randomElement(['Size', 'Color', 'Material']), + 'position' => 0, + ]; + } +} diff --git a/database/factories/ProductOptionValueFactory.php b/database/factories/ProductOptionValueFactory.php new file mode 100644 index 00000000..a47769fb --- /dev/null +++ b/database/factories/ProductOptionValueFactory.php @@ -0,0 +1,26 @@ + + */ +class ProductOptionValueFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'product_option_id' => ProductOption::factory(), + 'value' => fake()->word(), + 'position' => 0, + ]; + } +} diff --git a/database/factories/ProductVariantFactory.php b/database/factories/ProductVariantFactory.php new file mode 100644 index 00000000..e11b3fbc --- /dev/null +++ b/database/factories/ProductVariantFactory.php @@ -0,0 +1,68 @@ + + */ +class ProductVariantFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'sku' => fake()->unique()->bothify('SKU-####'), + 'barcode' => null, + 'price_amount' => fake()->numberBetween(100, 10000), + 'compare_at_amount' => null, + 'currency' => 'USD', + 'weight_g' => null, + 'requires_shipping' => true, + 'is_default' => false, + 'position' => 0, + 'status' => VariantStatus::Active, + ]; + } + + /** + * Indicate that the variant is the product's default variant. + */ + public function default(): static + { + return $this->state(fn (array $attributes) => [ + 'is_default' => true, + ]); + } + + /** + * Indicate that the variant is archived. + */ + public function archived(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => VariantStatus::Archived, + ]); + } + + /** + * Create an inventory item for the variant. + */ + public function withInventory(int $quantityOnHand = 10): static + { + return $this->afterCreating(function (\App\Models\ProductVariant $variant) use ($quantityOnHand): void { + $variant->inventoryItem()->create([ + 'store_id' => $variant->product->store_id, + 'quantity_on_hand' => $quantityOnHand, + ]); + }); + } +} diff --git a/database/factories/RefundFactory.php b/database/factories/RefundFactory.php new file mode 100644 index 00000000..a970ed37 --- /dev/null +++ b/database/factories/RefundFactory.php @@ -0,0 +1,31 @@ + + */ +class RefundFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => fn (array $attributes) => Payment::find($attributes['payment_id'])?->order_id, + 'payment_id' => Payment::factory(), + 'amount' => 1000, + 'reason' => null, + 'status' => RefundStatus::Processed, + 'provider_refund_id' => 'mock_refund_'.Str::random(16), + ]; + } +} diff --git a/database/factories/SearchQueryFactory.php b/database/factories/SearchQueryFactory.php new file mode 100644 index 00000000..691b567d --- /dev/null +++ b/database/factories/SearchQueryFactory.php @@ -0,0 +1,27 @@ + + */ +class SearchQueryFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'query' => fake()->words(2, true), + 'filters_json' => null, + 'results_count' => fake()->numberBetween(0, 50), + ]; + } +} diff --git a/database/factories/SearchSettingsFactory.php b/database/factories/SearchSettingsFactory.php new file mode 100644 index 00000000..96e946a3 --- /dev/null +++ b/database/factories/SearchSettingsFactory.php @@ -0,0 +1,50 @@ + + */ +class SearchSettingsFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'synonyms_json' => [], + 'stop_words_json' => [], + ]; + } + + /** + * Set the synonym groups (each a list of equivalent terms). + * + * @param list> $groups + */ + public function withSynonyms(array $groups): static + { + return $this->state(fn (array $attributes) => [ + 'synonyms_json' => $groups, + ]); + } + + /** + * Set the stop words. + * + * @param list $words + */ + public function withStopWords(array $words): static + { + return $this->state(fn (array $attributes) => [ + 'stop_words_json' => $words, + ]); + } +} diff --git a/database/factories/ShippingRateFactory.php b/database/factories/ShippingRateFactory.php new file mode 100644 index 00000000..5692a3f8 --- /dev/null +++ b/database/factories/ShippingRateFactory.php @@ -0,0 +1,86 @@ + + */ +class ShippingRateFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'zone_id' => ShippingZone::factory(), + 'name' => 'Standard Shipping', + 'type' => ShippingRateType::Flat, + 'config_json' => ['amount' => 500], + 'is_active' => true, + ]; + } + + /** + * Indicate a flat rate with the given amount. + */ + public function flat(int $amount = 500): static + { + return $this->state(fn (array $attributes) => [ + 'type' => ShippingRateType::Flat, + 'config_json' => ['amount' => $amount], + ]); + } + + /** + * Indicate a weight-based rate with the given ranges. + * + * @param array $ranges + */ + public function weight(array $ranges = []): static + { + return $this->state(fn (array $attributes) => [ + 'type' => ShippingRateType::Weight, + 'config_json' => [ + 'ranges' => $ranges !== [] ? $ranges : [ + ['min_g' => 0, 'max_g' => 1000, 'amount' => 500], + ['min_g' => 1001, 'max_g' => 5000, 'amount' => 1000], + ], + ], + ]); + } + + /** + * Indicate a price-based rate with the given ranges. + * + * @param array $ranges + */ + public function price(array $ranges = []): static + { + return $this->state(fn (array $attributes) => [ + 'type' => ShippingRateType::Price, + 'config_json' => [ + 'ranges' => $ranges !== [] ? $ranges : [ + ['min_amount' => 0, 'max_amount' => 5000, 'amount' => 500], + ['min_amount' => 5001, 'amount' => 0], + ], + ], + ]); + } + + /** + * Indicate that the rate is inactive. + */ + public function inactive(): static + { + return $this->state(fn (array $attributes) => [ + 'is_active' => false, + ]); + } +} diff --git a/database/factories/ShippingZoneFactory.php b/database/factories/ShippingZoneFactory.php new file mode 100644 index 00000000..e9cebe72 --- /dev/null +++ b/database/factories/ShippingZoneFactory.php @@ -0,0 +1,27 @@ + + */ +class ShippingZoneFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'name' => fake()->words(2, true), + 'countries_json' => ['DE'], + 'regions_json' => [], + ]; + } +} diff --git a/database/factories/StoreDomainFactory.php b/database/factories/StoreDomainFactory.php new file mode 100644 index 00000000..ed419e23 --- /dev/null +++ b/database/factories/StoreDomainFactory.php @@ -0,0 +1,29 @@ + + */ +class StoreDomainFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'hostname' => fake()->unique()->domainName(), + 'type' => StoreDomainType::Storefront, + 'is_primary' => false, + 'tls_mode' => 'managed', + ]; + } +} diff --git a/database/factories/StoreFactory.php b/database/factories/StoreFactory.php new file mode 100644 index 00000000..96c78d0f --- /dev/null +++ b/database/factories/StoreFactory.php @@ -0,0 +1,31 @@ + + */ +class StoreFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'organization_id' => Organization::factory(), + 'name' => fake()->company(), + 'handle' => fake()->unique()->slug(2), + 'status' => StoreStatus::Active, + 'default_currency' => 'USD', + 'default_locale' => 'en', + 'timezone' => 'UTC', + ]; + } +} diff --git a/database/factories/StoreSettingsFactory.php b/database/factories/StoreSettingsFactory.php new file mode 100644 index 00000000..9a5191e4 --- /dev/null +++ b/database/factories/StoreSettingsFactory.php @@ -0,0 +1,25 @@ + + */ +class StoreSettingsFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'settings_json' => [], + ]; + } +} diff --git a/database/factories/StoreUserFactory.php b/database/factories/StoreUserFactory.php new file mode 100644 index 00000000..9e696267 --- /dev/null +++ b/database/factories/StoreUserFactory.php @@ -0,0 +1,28 @@ + + */ +class StoreUserFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'user_id' => User::factory(), + 'role' => StoreUserRole::Staff, + ]; + } +} diff --git a/database/factories/TaxSettingsFactory.php b/database/factories/TaxSettingsFactory.php new file mode 100644 index 00000000..1c97f317 --- /dev/null +++ b/database/factories/TaxSettingsFactory.php @@ -0,0 +1,49 @@ + + */ +class TaxSettingsFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'mode' => TaxMode::Manual, + 'provider' => 'none', + 'prices_include_tax' => false, + 'config_json' => ['default_rate_bps' => 1900], + ]; + } + + /** + * Indicate that displayed prices include tax. + */ + public function inclusive(): static + { + return $this->state(fn (array $attributes) => [ + 'prices_include_tax' => true, + ]); + } + + /** + * Set the default manual rate in basis points. + */ + public function withRate(int $rateBps): static + { + return $this->state(fn (array $attributes) => [ + 'config_json' => array_merge($attributes['config_json'] ?? [], ['default_rate_bps' => $rateBps]), + ]); + } +} diff --git a/database/factories/ThemeFactory.php b/database/factories/ThemeFactory.php new file mode 100644 index 00000000..1eb1738e --- /dev/null +++ b/database/factories/ThemeFactory.php @@ -0,0 +1,40 @@ + + */ +class ThemeFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'name' => fake()->words(2, true), + 'version' => '1.0.0', + 'status' => ThemeStatus::Draft, + 'published_at' => null, + ]; + } + + /** + * Indicate that the theme is published. + */ + public function published(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => ThemeStatus::Published, + 'published_at' => now(), + ]); + } +} diff --git a/database/factories/ThemeFileFactory.php b/database/factories/ThemeFileFactory.php new file mode 100644 index 00000000..674ce2bc --- /dev/null +++ b/database/factories/ThemeFileFactory.php @@ -0,0 +1,30 @@ + + */ +class ThemeFileFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $path = 'templates/'.fake()->unique()->slug(1).'.blade.php'; + + return [ + 'theme_id' => Theme::factory(), + 'path' => $path, + 'storage_key' => 'themes/'.fake()->uuid().'/'.$path, + 'sha256' => hash('sha256', fake()->sentence()), + 'byte_size' => fake()->numberBetween(100, 50000), + ]; + } +} diff --git a/database/factories/ThemeSettingsFactory.php b/database/factories/ThemeSettingsFactory.php new file mode 100644 index 00000000..d81f805b --- /dev/null +++ b/database/factories/ThemeSettingsFactory.php @@ -0,0 +1,25 @@ + + */ +class ThemeSettingsFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'theme_id' => Theme::factory(), + 'settings_json' => [], + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 80da5ac7..32254b05 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -7,7 +7,7 @@ use Illuminate\Support\Str; /** - * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User> + * @extends Factory<\App\Models\User> */ class UserFactory extends Factory { @@ -27,7 +27,8 @@ public function definition(): array 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), - 'password' => static::$password ??= Hash::make('password'), + 'password_hash' => static::$password ??= Hash::make('password'), + 'status' => 'active', 'remember_token' => Str::random(10), 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, diff --git a/database/factories/WebhookDeliveryFactory.php b/database/factories/WebhookDeliveryFactory.php new file mode 100644 index 00000000..46d6f84e --- /dev/null +++ b/database/factories/WebhookDeliveryFactory.php @@ -0,0 +1,56 @@ + + */ +class WebhookDeliveryFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'subscription_id' => WebhookSubscription::factory(), + 'event_id' => (string) Str::uuid(), + 'attempt_count' => 1, + 'status' => WebhookDeliveryStatus::Pending, + 'last_attempt_at' => now(), + 'response_code' => null, + 'response_body_snippet' => null, + ]; + } + + /** + * Indicate that the delivery succeeded. + */ + public function success(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => WebhookDeliveryStatus::Success, + 'response_code' => 200, + 'response_body_snippet' => 'OK', + ]); + } + + /** + * Indicate that the delivery failed. + */ + public function failed(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => WebhookDeliveryStatus::Failed, + 'response_code' => 500, + 'response_body_snippet' => 'Internal Server Error', + ]); + } +} diff --git a/database/factories/WebhookSubscriptionFactory.php b/database/factories/WebhookSubscriptionFactory.php new file mode 100644 index 00000000..f660f4a5 --- /dev/null +++ b/database/factories/WebhookSubscriptionFactory.php @@ -0,0 +1,41 @@ + + */ +class WebhookSubscriptionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'app_installation_id' => null, + 'event_type' => 'order.created', + 'target_url' => 'https://example.com/webhooks/'.Str::random(8), + 'signing_secret_encrypted' => 'whsec_'.Str::random(32), + 'status' => WebhookSubscriptionStatus::Active, + ]; + } + + /** + * Indicate that the subscription is paused. + */ + public function paused(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => WebhookSubscriptionStatus::Paused, + ]); + } +} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 05fb5d9e..1396dae8 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -13,12 +13,20 @@ public function up(): void { Schema::create('users', function (Blueprint $table) { $table->id(); - $table->string('name'); - $table->string('email')->unique(); + $table->text('email'); + $table->text('password_hash'); + $table->text('name'); + $table->text('status')->default('active'); $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); - $table->rememberToken(); + $table->timestamp('last_login_at')->nullable(); + $table->text('two_factor_secret')->nullable(); + $table->text('two_factor_recovery_codes')->nullable(); + $table->timestamp('two_factor_confirmed_at')->nullable(); + $table->text('remember_token')->nullable(); $table->timestamps(); + + $table->unique('email', 'idx_users_email'); + $table->index('status', 'idx_users_status'); }); Schema::create('password_reset_tokens', function (Blueprint $table) { diff --git a/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php b/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php deleted file mode 100644 index 187d974d..00000000 --- a/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php +++ /dev/null @@ -1,34 +0,0 @@ -text('two_factor_secret')->after('password')->nullable(); - $table->text('two_factor_recovery_codes')->after('two_factor_secret')->nullable(); - $table->timestamp('two_factor_confirmed_at')->after('two_factor_recovery_codes')->nullable(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('users', function (Blueprint $table) { - $table->dropColumn([ - 'two_factor_secret', - 'two_factor_recovery_codes', - 'two_factor_confirmed_at', - ]); - }); - } -}; diff --git a/database/migrations/2026_01_01_000001_create_organizations_table.php b/database/migrations/2026_01_01_000001_create_organizations_table.php new file mode 100644 index 00000000..bd71e3da --- /dev/null +++ b/database/migrations/2026_01_01_000001_create_organizations_table.php @@ -0,0 +1,31 @@ +id(); + $table->text('name'); + $table->text('billing_email'); + $table->timestamps(); + + $table->index('billing_email', 'idx_organizations_billing_email'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('organizations'); + } +}; diff --git a/database/migrations/2026_01_01_000002_create_apps_table.php b/database/migrations/2026_01_01_000002_create_apps_table.php new file mode 100644 index 00000000..75f3cc24 --- /dev/null +++ b/database/migrations/2026_01_01_000002_create_apps_table.php @@ -0,0 +1,31 @@ +id(); + $table->text('name'); + $table->text('status')->default('active'); + $table->timestamp('created_at')->nullable(); + + $table->index('status', 'idx_apps_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('apps'); + } +}; diff --git a/database/migrations/2026_01_01_000003_create_stores_table.php b/database/migrations/2026_01_01_000003_create_stores_table.php new file mode 100644 index 00000000..76984da2 --- /dev/null +++ b/database/migrations/2026_01_01_000003_create_stores_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('organization_id')->constrained()->cascadeOnDelete(); + $table->text('name'); + $table->text('handle'); + $table->text('status')->default('active'); + $table->text('default_currency')->default('USD'); + $table->text('default_locale')->default('en'); + $table->text('timezone')->default('UTC'); + $table->timestamps(); + + $table->unique('handle', 'idx_stores_handle'); + $table->index('organization_id', 'idx_stores_organization_id'); + $table->index('status', 'idx_stores_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('stores'); + } +}; diff --git a/database/migrations/2026_01_01_000004_create_store_domains_table.php b/database/migrations/2026_01_01_000004_create_store_domains_table.php new file mode 100644 index 00000000..e4792d91 --- /dev/null +++ b/database/migrations/2026_01_01_000004_create_store_domains_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->text('hostname'); + $table->text('type')->default('storefront'); + $table->integer('is_primary')->default(0); + $table->text('tls_mode')->default('managed'); + $table->timestamp('created_at')->nullable(); + + $table->unique('hostname', 'idx_store_domains_hostname'); + $table->index('store_id', 'idx_store_domains_store_id'); + $table->index(['store_id', 'is_primary'], 'idx_store_domains_store_primary'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('store_domains'); + } +}; diff --git a/database/migrations/2026_01_01_000005_create_store_users_table.php b/database/migrations/2026_01_01_000005_create_store_users_table.php new file mode 100644 index 00000000..83cd6e25 --- /dev/null +++ b/database/migrations/2026_01_01_000005_create_store_users_table.php @@ -0,0 +1,33 @@ +foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->text('role')->default('staff'); + $table->timestamp('created_at')->nullable(); + + $table->primary(['store_id', 'user_id']); + $table->index('user_id', 'idx_store_users_user_id'); + $table->index(['store_id', 'role'], 'idx_store_users_role'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('store_users'); + } +}; diff --git a/database/migrations/2026_01_01_000006_create_store_settings_table.php b/database/migrations/2026_01_01_000006_create_store_settings_table.php new file mode 100644 index 00000000..33fc0d5f --- /dev/null +++ b/database/migrations/2026_01_01_000006_create_store_settings_table.php @@ -0,0 +1,28 @@ +foreignId('store_id')->primary()->constrained()->cascadeOnDelete(); + $table->text('settings_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('store_settings'); + } +}; diff --git a/database/migrations/2026_01_01_000007_create_customers_table.php b/database/migrations/2026_01_01_000007_create_customers_table.php new file mode 100644 index 00000000..512f23ea --- /dev/null +++ b/database/migrations/2026_01_01_000007_create_customers_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->text('email'); + $table->text('password_hash')->nullable(); + $table->text('name')->nullable(); + $table->integer('marketing_opt_in')->default(0); + $table->timestamps(); + + $table->unique(['store_id', 'email'], 'idx_customers_store_email'); + $table->index('store_id', 'idx_customers_store_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('customers'); + } +}; diff --git a/database/migrations/2026_01_01_000008_create_themes_table.php b/database/migrations/2026_01_01_000008_create_themes_table.php new file mode 100644 index 00000000..8bda1663 --- /dev/null +++ b/database/migrations/2026_01_01_000008_create_themes_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->text('name'); + $table->text('version')->nullable(); + $table->text('status')->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->index('store_id', 'idx_themes_store_id'); + $table->index(['store_id', 'status'], 'idx_themes_store_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('themes'); + } +}; diff --git a/database/migrations/2026_01_01_000009_create_pages_table.php b/database/migrations/2026_01_01_000009_create_pages_table.php new file mode 100644 index 00000000..8bacde94 --- /dev/null +++ b/database/migrations/2026_01_01_000009_create_pages_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->text('title'); + $table->text('handle'); + $table->text('body_html')->nullable(); + $table->text('status')->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'handle'], 'idx_pages_store_handle'); + $table->index('store_id', 'idx_pages_store_id'); + $table->index(['store_id', 'status'], 'idx_pages_store_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('pages'); + } +}; diff --git a/database/migrations/2026_01_01_000010_create_navigation_menus_table.php b/database/migrations/2026_01_01_000010_create_navigation_menus_table.php new file mode 100644 index 00000000..2e743d08 --- /dev/null +++ b/database/migrations/2026_01_01_000010_create_navigation_menus_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->text('handle'); + $table->text('title'); + $table->timestamps(); + + $table->unique(['store_id', 'handle'], 'idx_navigation_menus_store_handle'); + $table->index('store_id', 'idx_navigation_menus_store_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('navigation_menus'); + } +}; diff --git a/database/migrations/2026_01_01_000011_create_search_settings_table.php b/database/migrations/2026_01_01_000011_create_search_settings_table.php new file mode 100644 index 00000000..a566052f --- /dev/null +++ b/database/migrations/2026_01_01_000011_create_search_settings_table.php @@ -0,0 +1,29 @@ +foreignId('store_id')->primary()->constrained()->cascadeOnDelete(); + $table->text('synonyms_json')->default('[]'); + $table->text('stop_words_json')->default('[]'); + $table->timestamp('updated_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('search_settings'); + } +}; diff --git a/database/migrations/2026_01_01_000012_create_shipping_zones_table.php b/database/migrations/2026_01_01_000012_create_shipping_zones_table.php new file mode 100644 index 00000000..a7926952 --- /dev/null +++ b/database/migrations/2026_01_01_000012_create_shipping_zones_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->text('name'); + $table->text('countries_json')->default('[]'); + $table->text('regions_json')->default('[]'); + + $table->index('store_id', 'idx_shipping_zones_store_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shipping_zones'); + } +}; diff --git a/database/migrations/2026_01_01_000013_create_tax_settings_table.php b/database/migrations/2026_01_01_000013_create_tax_settings_table.php new file mode 100644 index 00000000..648559be --- /dev/null +++ b/database/migrations/2026_01_01_000013_create_tax_settings_table.php @@ -0,0 +1,30 @@ +foreignId('store_id')->primary()->constrained()->cascadeOnDelete(); + $table->text('mode')->default('manual'); + $table->text('provider')->default('none'); + $table->integer('prices_include_tax')->default(0); + $table->text('config_json')->default('{}'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tax_settings'); + } +}; diff --git a/database/migrations/2026_01_01_000014_create_discounts_table.php b/database/migrations/2026_01_01_000014_create_discounts_table.php new file mode 100644 index 00000000..637c9b38 --- /dev/null +++ b/database/migrations/2026_01_01_000014_create_discounts_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->text('type')->default('code'); + $table->text('code')->nullable(); + $table->text('value_type'); + $table->integer('value_amount')->default(0); + $table->text('starts_at'); + $table->text('ends_at')->nullable(); + $table->integer('usage_limit')->nullable(); + $table->integer('usage_count')->default(0); + $table->text('rules_json')->default('{}'); + $table->text('status')->default('active'); + $table->timestamps(); + + $table->unique(['store_id', 'code'], 'idx_discounts_store_code'); + $table->index('store_id', 'idx_discounts_store_id'); + $table->index(['store_id', 'status'], 'idx_discounts_store_status'); + $table->index(['store_id', 'type'], 'idx_discounts_store_type'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('discounts'); + } +}; diff --git a/database/migrations/2026_01_01_000015_create_products_table.php b/database/migrations/2026_01_01_000015_create_products_table.php new file mode 100644 index 00000000..d5a11189 --- /dev/null +++ b/database/migrations/2026_01_01_000015_create_products_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->text('title'); + $table->text('handle'); + $table->text('status')->default('draft'); + $table->text('description_html')->nullable(); + $table->text('vendor')->nullable(); + $table->text('product_type')->nullable(); + $table->text('tags')->default('[]'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'handle'], 'idx_products_store_handle'); + $table->index('store_id', 'idx_products_store_id'); + $table->index(['store_id', 'status'], 'idx_products_store_status'); + $table->index(['store_id', 'published_at'], 'idx_products_published_at'); + $table->index(['store_id', 'vendor'], 'idx_products_vendor'); + $table->index(['store_id', 'product_type'], 'idx_products_product_type'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('products'); + } +}; diff --git a/database/migrations/2026_01_01_000016_create_collections_table.php b/database/migrations/2026_01_01_000016_create_collections_table.php new file mode 100644 index 00000000..4af38034 --- /dev/null +++ b/database/migrations/2026_01_01_000016_create_collections_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->text('title'); + $table->text('handle'); + $table->text('description_html')->nullable(); + $table->text('type')->default('manual'); + $table->text('status')->default('active'); + $table->timestamps(); + + $table->unique(['store_id', 'handle'], 'idx_collections_store_handle'); + $table->index('store_id', 'idx_collections_store_id'); + $table->index(['store_id', 'status'], 'idx_collections_store_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('collections'); + } +}; diff --git a/database/migrations/2026_01_01_000017_create_app_installations_table.php b/database/migrations/2026_01_01_000017_create_app_installations_table.php new file mode 100644 index 00000000..92939640 --- /dev/null +++ b/database/migrations/2026_01_01_000017_create_app_installations_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('app_id')->constrained()->cascadeOnDelete(); + $table->text('scopes_json')->default('[]'); + $table->text('status')->default('active'); + $table->timestamp('installed_at')->nullable(); + + $table->unique(['store_id', 'app_id'], 'idx_app_installations_store_app'); + $table->index('store_id', 'idx_app_installations_store_id'); + $table->index('app_id', 'idx_app_installations_app_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('app_installations'); + } +}; diff --git a/database/migrations/2026_01_01_000018_create_oauth_clients_table.php b/database/migrations/2026_01_01_000018_create_oauth_clients_table.php new file mode 100644 index 00000000..7cc08c9a --- /dev/null +++ b/database/migrations/2026_01_01_000018_create_oauth_clients_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('app_id')->constrained()->cascadeOnDelete(); + $table->text('client_id'); + $table->text('client_secret_encrypted'); + $table->text('redirect_uris_json')->default('[]'); + + $table->unique('client_id', 'idx_oauth_clients_client_id'); + $table->index('app_id', 'idx_oauth_clients_app_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_clients'); + } +}; diff --git a/database/migrations/2026_01_01_000019_create_product_options_table.php b/database/migrations/2026_01_01_000019_create_product_options_table.php new file mode 100644 index 00000000..7ddecac7 --- /dev/null +++ b/database/migrations/2026_01_01_000019_create_product_options_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->text('name'); + $table->integer('position')->default(0); + + $table->index('product_id', 'idx_product_options_product_id'); + $table->unique(['product_id', 'position'], 'idx_product_options_product_position'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_options'); + } +}; diff --git a/database/migrations/2026_01_01_000020_create_product_variants_table.php b/database/migrations/2026_01_01_000020_create_product_variants_table.php new file mode 100644 index 00000000..089cd03e --- /dev/null +++ b/database/migrations/2026_01_01_000020_create_product_variants_table.php @@ -0,0 +1,44 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->text('sku')->nullable(); + $table->text('barcode')->nullable(); + $table->integer('price_amount')->default(0); + $table->integer('compare_at_amount')->nullable(); + $table->text('currency')->default('USD'); + $table->integer('weight_g')->nullable(); + $table->integer('requires_shipping')->default(1); + $table->integer('is_default')->default(0); + $table->integer('position')->default(0); + $table->text('status')->default('active'); + $table->timestamps(); + + $table->index('product_id', 'idx_product_variants_product_id'); + $table->index('sku', 'idx_product_variants_sku'); + $table->index('barcode', 'idx_product_variants_barcode'); + $table->index(['product_id', 'position'], 'idx_product_variants_product_position'); + $table->index(['product_id', 'is_default'], 'idx_product_variants_product_default'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_variants'); + } +}; diff --git a/database/migrations/2026_01_01_000021_create_product_media_table.php b/database/migrations/2026_01_01_000021_create_product_media_table.php new file mode 100644 index 00000000..4da52cc2 --- /dev/null +++ b/database/migrations/2026_01_01_000021_create_product_media_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->text('type')->default('image'); + $table->text('storage_key'); + $table->text('alt_text')->nullable(); + $table->integer('width')->nullable(); + $table->integer('height')->nullable(); + $table->text('mime_type')->nullable(); + $table->integer('byte_size')->nullable(); + $table->integer('position')->default(0); + $table->text('status')->default('processing'); + $table->timestamp('created_at')->nullable(); + + $table->index('product_id', 'idx_product_media_product_id'); + $table->index(['product_id', 'position'], 'idx_product_media_product_position'); + $table->index('status', 'idx_product_media_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_media'); + } +}; diff --git a/database/migrations/2026_01_01_000022_create_collection_products_table.php b/database/migrations/2026_01_01_000022_create_collection_products_table.php new file mode 100644 index 00000000..93592eb4 --- /dev/null +++ b/database/migrations/2026_01_01_000022_create_collection_products_table.php @@ -0,0 +1,32 @@ +foreignId('collection_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->integer('position')->default(0); + + $table->primary(['collection_id', 'product_id']); + $table->index('product_id', 'idx_collection_products_product_id'); + $table->index(['collection_id', 'position'], 'idx_collection_products_position'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('collection_products'); + } +}; diff --git a/database/migrations/2026_01_01_000023_create_customer_addresses_table.php b/database/migrations/2026_01_01_000023_create_customer_addresses_table.php new file mode 100644 index 00000000..3c30f55e --- /dev/null +++ b/database/migrations/2026_01_01_000023_create_customer_addresses_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('customer_id')->constrained()->cascadeOnDelete(); + $table->text('label')->nullable(); + $table->text('address_json')->default('{}'); + $table->integer('is_default')->default(0); + + $table->index('customer_id', 'idx_customer_addresses_customer_id'); + $table->index(['customer_id', 'is_default'], 'idx_customer_addresses_default'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('customer_addresses'); + } +}; diff --git a/database/migrations/2026_01_01_000024_create_carts_table.php b/database/migrations/2026_01_01_000024_create_carts_table.php new file mode 100644 index 00000000..ea75326b --- /dev/null +++ b/database/migrations/2026_01_01_000024_create_carts_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->text('currency')->default('USD'); + $table->integer('cart_version')->default(1); + $table->text('status')->default('active'); + $table->timestamps(); + + $table->index('store_id', 'idx_carts_store_id'); + $table->index('customer_id', 'idx_carts_customer_id'); + $table->index(['store_id', 'status'], 'idx_carts_store_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('carts'); + } +}; diff --git a/database/migrations/2026_01_01_000025_create_navigation_items_table.php b/database/migrations/2026_01_01_000025_create_navigation_items_table.php new file mode 100644 index 00000000..341bec80 --- /dev/null +++ b/database/migrations/2026_01_01_000025_create_navigation_items_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('menu_id')->constrained('navigation_menus')->cascadeOnDelete(); + $table->text('type')->default('link'); + $table->text('label'); + $table->text('url')->nullable(); + $table->integer('resource_id')->nullable(); + $table->integer('position')->default(0); + + $table->index('menu_id', 'idx_navigation_items_menu_id'); + $table->index(['menu_id', 'position'], 'idx_navigation_items_menu_position'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('navigation_items'); + } +}; diff --git a/database/migrations/2026_01_01_000026_create_theme_files_table.php b/database/migrations/2026_01_01_000026_create_theme_files_table.php new file mode 100644 index 00000000..5dc04b88 --- /dev/null +++ b/database/migrations/2026_01_01_000026_create_theme_files_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('theme_id')->constrained()->cascadeOnDelete(); + $table->text('path'); + $table->text('storage_key'); + $table->text('sha256'); + $table->integer('byte_size')->default(0); + + $table->unique(['theme_id', 'path'], 'idx_theme_files_theme_path'); + $table->index('theme_id', 'idx_theme_files_theme_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('theme_files'); + } +}; diff --git a/database/migrations/2026_01_01_000027_create_theme_settings_table.php b/database/migrations/2026_01_01_000027_create_theme_settings_table.php new file mode 100644 index 00000000..90a84785 --- /dev/null +++ b/database/migrations/2026_01_01_000027_create_theme_settings_table.php @@ -0,0 +1,28 @@ +foreignId('theme_id')->primary()->constrained()->cascadeOnDelete(); + $table->text('settings_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('theme_settings'); + } +}; diff --git a/database/migrations/2026_01_01_000028_create_search_queries_table.php b/database/migrations/2026_01_01_000028_create_search_queries_table.php new file mode 100644 index 00000000..4b1e5b39 --- /dev/null +++ b/database/migrations/2026_01_01_000028_create_search_queries_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->text('query'); + $table->text('filters_json')->nullable(); + $table->integer('results_count')->default(0); + $table->timestamp('created_at')->nullable(); + + $table->index('store_id', 'idx_search_queries_store_id'); + $table->index(['store_id', 'created_at'], 'idx_search_queries_store_created'); + $table->index(['store_id', 'query'], 'idx_search_queries_store_query'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('search_queries'); + } +}; diff --git a/database/migrations/2026_01_01_000029_create_shipping_rates_table.php b/database/migrations/2026_01_01_000029_create_shipping_rates_table.php new file mode 100644 index 00000000..f8761039 --- /dev/null +++ b/database/migrations/2026_01_01_000029_create_shipping_rates_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('zone_id')->constrained('shipping_zones')->cascadeOnDelete(); + $table->text('name'); + $table->text('type')->default('flat'); + $table->text('config_json')->default('{}'); + $table->integer('is_active')->default(1); + + $table->index('zone_id', 'idx_shipping_rates_zone_id'); + $table->index(['zone_id', 'is_active'], 'idx_shipping_rates_zone_active'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shipping_rates'); + } +}; diff --git a/database/migrations/2026_01_01_000030_create_oauth_tokens_table.php b/database/migrations/2026_01_01_000030_create_oauth_tokens_table.php new file mode 100644 index 00000000..57e0c6e0 --- /dev/null +++ b/database/migrations/2026_01_01_000030_create_oauth_tokens_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('installation_id')->constrained('app_installations')->cascadeOnDelete(); + $table->text('access_token_hash'); + $table->text('refresh_token_hash')->nullable(); + $table->text('expires_at'); + + $table->index('installation_id', 'idx_oauth_tokens_installation_id'); + $table->unique('access_token_hash', 'idx_oauth_tokens_access_hash'); + $table->index('expires_at', 'idx_oauth_tokens_expires_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_tokens'); + } +}; diff --git a/database/migrations/2026_01_01_000031_create_webhook_subscriptions_table.php b/database/migrations/2026_01_01_000031_create_webhook_subscriptions_table.php new file mode 100644 index 00000000..bd307b5f --- /dev/null +++ b/database/migrations/2026_01_01_000031_create_webhook_subscriptions_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('app_installation_id')->nullable()->constrained()->cascadeOnDelete(); + $table->text('event_type'); + $table->text('target_url'); + $table->text('signing_secret_encrypted'); + $table->text('status')->default('active'); + + $table->index('store_id', 'idx_webhook_subscriptions_store_id'); + $table->index(['store_id', 'event_type'], 'idx_webhook_subscriptions_store_event'); + $table->index('app_installation_id', 'idx_webhook_subscriptions_installation'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_subscriptions'); + } +}; diff --git a/database/migrations/2026_01_01_000032_create_product_option_values_table.php b/database/migrations/2026_01_01_000032_create_product_option_values_table.php new file mode 100644 index 00000000..a67a71f6 --- /dev/null +++ b/database/migrations/2026_01_01_000032_create_product_option_values_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('product_option_id')->constrained()->cascadeOnDelete(); + $table->text('value'); + $table->integer('position')->default(0); + + $table->index('product_option_id', 'idx_product_option_values_option_id'); + $table->unique(['product_option_id', 'position'], 'idx_product_option_values_option_position'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_option_values'); + } +}; diff --git a/database/migrations/2026_01_01_000033_create_inventory_items_table.php b/database/migrations/2026_01_01_000033_create_inventory_items_table.php new file mode 100644 index 00000000..35146d90 --- /dev/null +++ b/database/migrations/2026_01_01_000033_create_inventory_items_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->integer('quantity_on_hand')->default(0); + $table->integer('quantity_reserved')->default(0); + $table->text('policy')->default('deny'); + + $table->unique('variant_id', 'idx_inventory_items_variant_id'); + $table->index('store_id', 'idx_inventory_items_store_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('inventory_items'); + } +}; diff --git a/database/migrations/2026_01_01_000034_create_cart_lines_table.php b/database/migrations/2026_01_01_000034_create_cart_lines_table.php new file mode 100644 index 00000000..3adcdd3e --- /dev/null +++ b/database/migrations/2026_01_01_000034_create_cart_lines_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('cart_id')->constrained()->cascadeOnDelete(); + $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->integer('quantity')->default(1); + $table->integer('unit_price_amount')->default(0); + $table->integer('line_subtotal_amount')->default(0); + $table->integer('line_discount_amount')->default(0); + $table->integer('line_total_amount')->default(0); + + $table->index('cart_id', 'idx_cart_lines_cart_id'); + $table->unique(['cart_id', 'variant_id'], 'idx_cart_lines_cart_variant'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cart_lines'); + } +}; diff --git a/database/migrations/2026_01_01_000035_create_checkouts_table.php b/database/migrations/2026_01_01_000035_create_checkouts_table.php new file mode 100644 index 00000000..7724409c --- /dev/null +++ b/database/migrations/2026_01_01_000035_create_checkouts_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('cart_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->text('status')->default('started'); + $table->text('payment_method')->nullable(); + $table->text('email')->nullable(); + $table->text('shipping_address_json')->nullable(); + $table->text('billing_address_json')->nullable(); + $table->integer('shipping_method_id')->nullable(); + $table->text('discount_code')->nullable(); + $table->text('tax_provider_snapshot_json')->nullable(); + $table->text('totals_json')->nullable(); + $table->text('expires_at')->nullable(); + $table->timestamps(); + + $table->index('store_id', 'idx_checkouts_store_id'); + $table->index('cart_id', 'idx_checkouts_cart_id'); + $table->index('customer_id', 'idx_checkouts_customer_id'); + $table->index(['store_id', 'status'], 'idx_checkouts_status'); + $table->index('expires_at', 'idx_checkouts_expires_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('checkouts'); + } +}; diff --git a/database/migrations/2026_01_01_000036_create_orders_table.php b/database/migrations/2026_01_01_000036_create_orders_table.php new file mode 100644 index 00000000..d330e5f3 --- /dev/null +++ b/database/migrations/2026_01_01_000036_create_orders_table.php @@ -0,0 +1,52 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->text('order_number'); + $table->text('payment_method'); + $table->text('status')->default('pending'); + $table->text('financial_status')->default('pending'); + $table->text('fulfillment_status')->default('unfulfilled'); + $table->text('currency')->default('USD'); + $table->integer('subtotal_amount')->default(0); + $table->integer('discount_amount')->default(0); + $table->integer('shipping_amount')->default(0); + $table->integer('tax_amount')->default(0); + $table->integer('total_amount')->default(0); + $table->text('email')->nullable(); + $table->text('billing_address_json')->nullable(); + $table->text('shipping_address_json')->nullable(); + $table->text('placed_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'order_number'], 'idx_orders_store_order_number'); + $table->index('store_id', 'idx_orders_store_id'); + $table->index('customer_id', 'idx_orders_customer_id'); + $table->index(['store_id', 'status'], 'idx_orders_store_status'); + $table->index(['store_id', 'financial_status'], 'idx_orders_store_financial'); + $table->index(['store_id', 'fulfillment_status'], 'idx_orders_store_fulfillment'); + $table->index(['store_id', 'placed_at'], 'idx_orders_placed_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('orders'); + } +}; diff --git a/database/migrations/2026_01_01_000037_create_analytics_events_table.php b/database/migrations/2026_01_01_000037_create_analytics_events_table.php new file mode 100644 index 00000000..9c759bae --- /dev/null +++ b/database/migrations/2026_01_01_000037_create_analytics_events_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->text('type'); + $table->text('session_id')->nullable(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->text('properties_json')->default('{}'); + $table->text('client_event_id')->nullable(); + $table->text('occurred_at')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('store_id', 'idx_analytics_events_store_id'); + $table->index(['store_id', 'type'], 'idx_analytics_events_store_type'); + $table->index(['store_id', 'created_at'], 'idx_analytics_events_store_created'); + $table->index('session_id', 'idx_analytics_events_session'); + $table->index('customer_id', 'idx_analytics_events_customer'); + $table->unique(['store_id', 'client_event_id'], 'idx_analytics_events_client_event'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('analytics_events'); + } +}; diff --git a/database/migrations/2026_01_01_000038_create_analytics_daily_table.php b/database/migrations/2026_01_01_000038_create_analytics_daily_table.php new file mode 100644 index 00000000..480273f6 --- /dev/null +++ b/database/migrations/2026_01_01_000038_create_analytics_daily_table.php @@ -0,0 +1,37 @@ +foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->text('date'); + $table->integer('orders_count')->default(0); + $table->integer('revenue_amount')->default(0); + $table->integer('aov_amount')->default(0); + $table->integer('visits_count')->default(0); + $table->integer('add_to_cart_count')->default(0); + $table->integer('checkout_started_count')->default(0); + $table->integer('checkout_completed_count')->default(0); + + $table->primary(['store_id', 'date']); + $table->index(['store_id', 'date'], 'idx_analytics_daily_store_date'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('analytics_daily'); + } +}; diff --git a/database/migrations/2026_01_01_000039_create_webhook_deliveries_table.php b/database/migrations/2026_01_01_000039_create_webhook_deliveries_table.php new file mode 100644 index 00000000..2d259cee --- /dev/null +++ b/database/migrations/2026_01_01_000039_create_webhook_deliveries_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('subscription_id')->constrained('webhook_subscriptions')->cascadeOnDelete(); + $table->text('event_id'); + $table->integer('attempt_count')->default(1); + $table->text('status')->default('pending'); + $table->text('last_attempt_at')->nullable(); + $table->integer('response_code')->nullable(); + $table->text('response_body_snippet')->nullable(); + + $table->index('subscription_id', 'idx_webhook_deliveries_subscription_id'); + $table->index('event_id', 'idx_webhook_deliveries_event_id'); + $table->index('status', 'idx_webhook_deliveries_status'); + $table->index('last_attempt_at', 'idx_webhook_deliveries_last_attempt'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_deliveries'); + } +}; diff --git a/database/migrations/2026_01_01_000040_create_variant_option_values_table.php b/database/migrations/2026_01_01_000040_create_variant_option_values_table.php new file mode 100644 index 00000000..9381e442 --- /dev/null +++ b/database/migrations/2026_01_01_000040_create_variant_option_values_table.php @@ -0,0 +1,30 @@ +foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->foreignId('product_option_value_id')->constrained()->cascadeOnDelete(); + + $table->primary(['variant_id', 'product_option_value_id']); + $table->index('product_option_value_id', 'idx_variant_option_values_value_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('variant_option_values'); + } +}; diff --git a/database/migrations/2026_01_01_000041_create_order_lines_table.php b/database/migrations/2026_01_01_000041_create_order_lines_table.php new file mode 100644 index 00000000..d087a852 --- /dev/null +++ b/database/migrations/2026_01_01_000041_create_order_lines_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('variant_id')->nullable()->constrained('product_variants')->nullOnDelete(); + $table->text('title_snapshot'); + $table->text('sku_snapshot')->nullable(); + $table->integer('quantity')->default(1); + $table->integer('unit_price_amount')->default(0); + $table->integer('total_amount')->default(0); + $table->text('tax_lines_json')->default('[]'); + $table->text('discount_allocations_json')->default('[]'); + + $table->index('order_id', 'idx_order_lines_order_id'); + $table->index('product_id', 'idx_order_lines_product_id'); + $table->index('variant_id', 'idx_order_lines_variant_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('order_lines'); + } +}; diff --git a/database/migrations/2026_01_01_000042_create_payments_table.php b/database/migrations/2026_01_01_000042_create_payments_table.php new file mode 100644 index 00000000..53729d49 --- /dev/null +++ b/database/migrations/2026_01_01_000042_create_payments_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->text('provider')->default('mock'); + $table->text('method'); + $table->text('provider_payment_id')->nullable(); + $table->text('status')->default('pending'); + $table->integer('amount')->default(0); + $table->text('currency')->default('USD'); + $table->text('raw_json_encrypted')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('order_id', 'idx_payments_order_id'); + $table->index(['provider', 'provider_payment_id'], 'idx_payments_provider_id'); + $table->index('method', 'idx_payments_method'); + $table->index('status', 'idx_payments_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('payments'); + } +}; diff --git a/database/migrations/2026_01_01_000043_create_fulfillments_table.php b/database/migrations/2026_01_01_000043_create_fulfillments_table.php new file mode 100644 index 00000000..09f358f3 --- /dev/null +++ b/database/migrations/2026_01_01_000043_create_fulfillments_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->text('status')->default('pending'); + $table->text('tracking_company')->nullable(); + $table->text('tracking_number')->nullable(); + $table->text('tracking_url')->nullable(); + $table->text('shipped_at')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('order_id', 'idx_fulfillments_order_id'); + $table->index('status', 'idx_fulfillments_status'); + $table->index(['tracking_company', 'tracking_number'], 'idx_fulfillments_tracking'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('fulfillments'); + } +}; diff --git a/database/migrations/2026_01_01_000044_create_refunds_table.php b/database/migrations/2026_01_01_000044_create_refunds_table.php new file mode 100644 index 00000000..1e307009 --- /dev/null +++ b/database/migrations/2026_01_01_000044_create_refunds_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('payment_id')->constrained()->cascadeOnDelete(); + $table->integer('amount')->default(0); + $table->text('reason')->nullable(); + $table->text('status')->default('pending'); + $table->text('provider_refund_id')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('order_id', 'idx_refunds_order_id'); + $table->index('payment_id', 'idx_refunds_payment_id'); + $table->index('status', 'idx_refunds_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('refunds'); + } +}; diff --git a/database/migrations/2026_01_01_000045_create_fulfillment_lines_table.php b/database/migrations/2026_01_01_000045_create_fulfillment_lines_table.php new file mode 100644 index 00000000..4f347f72 --- /dev/null +++ b/database/migrations/2026_01_01_000045_create_fulfillment_lines_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('fulfillment_id')->constrained()->cascadeOnDelete(); + $table->foreignId('order_line_id')->constrained()->cascadeOnDelete(); + $table->integer('quantity')->default(1); + + $table->index('fulfillment_id', 'idx_fulfillment_lines_fulfillment_id'); + $table->unique(['fulfillment_id', 'order_line_id'], 'idx_fulfillment_lines_fulfillment_order_line'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('fulfillment_lines'); + } +}; diff --git a/database/migrations/2026_01_01_000046_create_customer_password_reset_tokens_table.php b/database/migrations/2026_01_01_000046_create_customer_password_reset_tokens_table.php new file mode 100644 index 00000000..2bd819f6 --- /dev/null +++ b/database/migrations/2026_01_01_000046_create_customer_password_reset_tokens_table.php @@ -0,0 +1,31 @@ +foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->text('email'); + $table->text('token'); + $table->timestamp('created_at')->nullable(); + + $table->primary(['store_id', 'email']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('customer_password_reset_tokens'); + } +}; diff --git a/database/migrations/2026_07_19_000001_add_checkout_id_to_orders_table.php b/database/migrations/2026_07_19_000001_add_checkout_id_to_orders_table.php new file mode 100644 index 00000000..9990a630 --- /dev/null +++ b/database/migrations/2026_07_19_000001_add_checkout_id_to_orders_table.php @@ -0,0 +1,34 @@ +foreignId('checkout_id')->nullable()->constrained()->nullOnDelete(); + $table->index('checkout_id', 'idx_orders_checkout_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('orders', function (Blueprint $table) { + $table->dropIndex('idx_orders_checkout_id'); + $table->dropConstrainedForeignId('checkout_id'); + }); + } +}; diff --git a/database/migrations/2026_07_19_000002_add_remember_token_to_customers_table.php b/database/migrations/2026_07_19_000002_add_remember_token_to_customers_table.php new file mode 100644 index 00000000..ac628df6 --- /dev/null +++ b/database/migrations/2026_07_19_000002_add_remember_token_to_customers_table.php @@ -0,0 +1,31 @@ +text('remember_token')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('customers', function (Blueprint $table): void { + $table->dropColumn('remember_token'); + }); + } +}; diff --git a/database/migrations/2026_07_19_000003_create_products_fts_table.php b/database/migrations/2026_07_19_000003_create_products_fts_table.php new file mode 100644 index 00000000..a6310fd9 --- /dev/null +++ b/database/migrations/2026_07_19_000003_create_products_fts_table.php @@ -0,0 +1,36 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/seeders/AnalyticsSeeder.php b/database/seeders/AnalyticsSeeder.php new file mode 100644 index 00000000..9d5acca7 --- /dev/null +++ b/database/seeders/AnalyticsSeeder.php @@ -0,0 +1,229 @@ +where('handle', 'acme-fashion')->firstOrFail(); + + $this->seedDaily($fashion); + $this->seedEvents($fashion); + }); + } + + /** + * One analytics_daily row per day for the past 30 days through today, + * with an upward revenue trend (spec 07 §3.17 algorithm). + */ + private function seedDaily(Store $store): void + { + for ($i = 30; $i >= 0; $i--) { + $date = now()->subDays($i)->toDateString(); + $dayFactor = 1 + (30 - $i) * 0.03; + + $visits = (int) round($this->randomBetween(50, 100, $date.'visits') * $dayFactor); + $addToCart = (int) round($visits * $this->randomBetween(18, 25, $date.'cart') / 100); + $checkoutStarted = (int) round($addToCart * $this->randomBetween(40, 55, $date.'checkout') / 100); + $orders = max(2, (int) round($checkoutStarted * $this->randomBetween(35, 55, $date.'orders') / 100)); + $aov = $this->randomBetween(4000, 9000, $date.'aov'); + + DB::table('analytics_daily')->updateOrInsert( + ['store_id' => $store->id, 'date' => $date], + [ + 'visits_count' => $visits, + 'add_to_cart_count' => $addToCart, + 'checkout_started_count' => $checkoutStarted, + 'checkout_completed_count' => $orders, + 'orders_count' => $orders, + 'revenue_amount' => $orders * $aov, + 'aov_amount' => $aov, + ], + ); + } + } + + /** + * Deterministic "random" integer in [$min, $max] derived from a salt, so + * re-seeding produces identical data without global RNG state. + */ + private function randomBetween(int $min, int $max, string $salt): int + { + return $min + (int) (hexdec(substr(md5($salt), 0, 8)) % ($max - $min + 1)); + } + + /** + * ~220 analytics events across the last 7 days with realistic session + * grouping, type distribution, and customer association (spec 07 §3.17). + */ + private function seedEvents(Store $store): void + { + $products = Product::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('status', 'active') + ->with('variants') + ->orderBy('id') + ->get(); + + $orders = Order::withoutGlobalScopes() + ->where('store_id', $store->id) + ->orderBy('id') + ->get(); + + $customerIds = Customer::withoutGlobalScopes() + ->where('store_id', $store->id) + ->orderBy('id') + ->pluck('id') + ->all(); + + // Event type distribution: 40/25/15/10/5/5 percent of 220 events. + $types = array_merge( + array_fill(0, 88, 'page_view'), + array_fill(0, 55, 'product_view'), + array_fill(0, 33, 'add_to_cart'), + array_fill(0, 22, 'checkout_started'), + array_fill(0, 11, 'checkout_completed'), + array_fill(0, 11, 'search'), + ); + + // Interleave the types deterministically so each day gets a mix. + $interleaved = []; + $pools = array_count_values($types); + $order = ['page_view', 'product_view', 'add_to_cart', 'checkout_started', 'checkout_completed', 'search']; + + while (count($interleaved) < 220) { + foreach ($order as $type) { + if (($pools[$type] ?? 0) > 0) { + $interleaved[] = $type; + $pools[$type]--; + } + } + } + + // Events per day, oldest to newest - more events on recent days. + $perDay = [20, 24, 28, 32, 36, 40, 40]; + + $eventIndex = 0; + + foreach ($perDay as $daysAgo => $count) { + for ($k = 0; $k < $count; $k++) { + $type = $interleaved[$eventIndex]; + $occurredAt = now() + ->subDays(6 - $daysAgo) + ->setTime(7 + ($eventIndex % 15), ($eventIndex * 7) % 60, ($eventIndex * 13) % 60); + + // forceFill: created_at is not mass assignable. + AnalyticsEvent::query() + ->firstOrNew(['store_id' => $store->id, 'client_event_id' => 'seed-'.$store->id.'-event-'.$eventIndex]) + ->forceFill([ + 'store_id' => $store->id, + 'type' => $type, + 'session_id' => 'seed-session-'.str_pad((string) ($eventIndex % 35 + 1), 2, '0', STR_PAD_LEFT), + 'customer_id' => $eventIndex % 10 < 3 && $customerIds !== [] + ? $customerIds[$eventIndex % count($customerIds)] + : null, + 'properties_json' => $this->properties($type, $eventIndex, $products, $orders), + 'occurred_at' => $occurredAt->toIso8601ZuluString(), + 'created_at' => $occurredAt, + ]) + ->save(); + + $eventIndex++; + } + } + } + + /** + * Build the properties payload for one event (spec 07 §3.17). + * + * @param \Illuminate\Support\Collection $products + * @param \Illuminate\Support\Collection $orders + * @return array + */ + private function properties(string $type, int $index, $products, $orders): array + { + $urls = ['/', '/collections/new-arrivals', '/collections/t-shirts', '/collections/sale', '/collections/pants-jeans', '/products/classic-cotton-t-shirt']; + + return match ($type) { + 'page_view' => [ + 'url' => $urls[$index % count($urls)], + 'referrer' => $index % 10 < 4 ? 'https://www.google.com' : null, + ], + 'product_view' => $this->productViewProperties($products, $index), + 'add_to_cart' => $this->addToCartProperties($products, $index), + 'checkout_started' => [ + 'cart_id' => 1000 + $index, + 'item_count' => $index % 4 + 1, + 'cart_total' => 2499 * ($index % 4 + 1), + ], + 'checkout_completed' => $this->checkoutCompletedProperties($orders, $index), + 'search' => [ + 'query' => ['cotton t-shirt', 'jeans', 'gift card', 'hoodie', 'sneakers'][$index % 5], + 'results_count' => $index % 12 + 1, + ], + default => [], + }; + } + + /** + * @param \Illuminate\Support\Collection $products + * @return array + */ + private function productViewProperties($products, int $index): array + { + $product = $products[$index % $products->count()]; + + return [ + 'product_id' => $product->id, + 'product_title' => $product->title, + 'url' => '/products/'.$product->handle, + ]; + } + + /** + * @param \Illuminate\Support\Collection $products + * @return array + */ + private function addToCartProperties($products, int $index): array + { + $product = $products[$index % $products->count()]; + $variant = $product->variants->firstWhere('is_default', true) ?? $product->variants->first(); + + return [ + 'product_id' => $product->id, + 'variant_id' => $variant?->id, + 'quantity' => $index % 3 + 1, + 'price_amount' => $variant?->price_amount ?? 0, + ]; + } + + /** + * @param \Illuminate\Support\Collection $orders + * @return array + */ + private function checkoutCompletedProperties($orders, int $index): array + { + $order = $orders[$index % $orders->count()]; + + return [ + 'order_id' => $order->id, + 'order_number' => $order->order_number, + 'total_amount' => $order->total_amount, + ]; + } +} diff --git a/database/seeders/CollectionSeeder.php b/database/seeders/CollectionSeeder.php new file mode 100644 index 00000000..39efecc6 --- /dev/null +++ b/database/seeders/CollectionSeeder.php @@ -0,0 +1,45 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + $collections = [ + [$fashion->id, 'New Arrivals', 'new-arrivals', 'Discover the latest additions to our store.'], + [$fashion->id, 'T-Shirts', 't-shirts', 'Premium cotton tees for every occasion.'], + [$fashion->id, 'Pants & Jeans', 'pants-jeans', 'Find the perfect fit from our denim and trouser range.'], + [$fashion->id, 'Sale', 'sale', 'Great deals on selected items.'], + [$electronics->id, 'Featured', 'featured', 'Our featured products.'], + [$electronics->id, 'Accessories', 'accessories', 'Cables, stands, and other accessories.'], + ]; + + foreach ($collections as [$storeId, $title, $handle, $description]) { + Collection::query()->updateOrCreate( + ['store_id' => $storeId, 'handle' => $handle], + [ + 'title' => $title, + 'description_html' => "

{$description}

", + 'type' => CollectionType::Manual, + 'status' => CollectionStatus::Active, + ], + ); + } + }); + } +} diff --git a/database/seeders/CustomerSeeder.php b/database/seeders/CustomerSeeder.php new file mode 100644 index 00000000..21f55861 --- /dev/null +++ b/database/seeders/CustomerSeeder.php @@ -0,0 +1,168 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + $passwordHash = Hash::make('password'); + + $fashionCustomers = [ + ['customer@acme.test', 'John Doe', true], + ['jane@example.com', 'Jane Smith', false], + ['michael@example.com', 'Michael Brown', true], + ['sarah@example.com', 'Sarah Wilson', false], + ['david@example.com', 'David Lee', true], + ['emma@example.com', 'Emma Garcia', false], + ['james@example.com', 'James Taylor', false], + ['lisa@example.com', 'Lisa Anderson', true], + ['robert@example.com', 'Robert Martinez', false], + ['anna@example.com', 'Anna Thomas', true], + ]; + + $customers = []; + + foreach ($fashionCustomers as [$email, $name, $marketingOptIn]) { + // forceFill: store_id is not mass assignable (BelongsToStore). + $customer = Customer::withoutGlobalScopes() + ->firstOrNew(['store_id' => $fashion->id, 'email' => $email]); + + $customer->forceFill([ + 'store_id' => $fashion->id, + 'name' => $name, + 'password_hash' => $passwordHash, + 'marketing_opt_in' => $marketingOptIn, + ])->save(); + + $customers[$email] = $customer; + } + + $electronicsCustomers = [ + ['techfan@example.com', 'Tech Fan'], + ['gadgetlover@example.com', 'Gadget Lover'], + ]; + + foreach ($electronicsCustomers as [$email, $name]) { + $customer = Customer::withoutGlobalScopes() + ->firstOrNew(['store_id' => $electronics->id, 'email' => $email]); + + $customer->forceFill([ + 'store_id' => $electronics->id, + 'name' => $name, + 'password_hash' => $passwordHash, + 'marketing_opt_in' => false, + ])->save(); + + $customers[$email] = $customer; + } + + $this->seedAddresses($customers); + }); + } + + /** + * Create the customers' addresses (spec 07 §3.12). The address JSON + * follows the App\ValueObjects\Address shape used across the app. + * + * @param array $customers + */ + private function seedAddresses(array $customers): void + { + $addresses = [ + 'customer@acme.test' => [ + ['Home', true, $this->address('John', 'Doe', 'Hauptstrasse 1', '10115', 'Berlin', phone: '+49 30 12345678')], + ['Work', false, $this->address('John', 'Doe', 'Friedrichstrasse 100', '10117', 'Berlin', company: 'Acme Corp', address2: '3rd Floor', phone: '+49 30 87654321')], + ], + 'jane@example.com' => [ + ['Home', true, $this->address('Jane', 'Smith', 'Schillerstrasse 45', '80336', 'Munich', province: 'Bavaria', provinceCode: 'BY')], + ], + 'michael@example.com' => [ + ['Home', true, $this->address('Michael', 'Brown', 'Torstrasse 61', '10119', 'Berlin')], + ], + 'sarah@example.com' => [ + ['Home', true, $this->address('Sarah', 'Wilson', 'Königsallee 27', '40212', 'Dusseldorf')], + ], + 'david@example.com' => [ + ['Home', true, $this->address('David', 'Lee', 'Maximilianstrasse 12', '80539', 'Munich')], + ], + 'emma@example.com' => [ + ['Home', true, $this->address('Emma', 'Garcia', 'Schildergasse 85', '50667', 'Cologne')], + ], + 'james@example.com' => [ + ['Home', true, $this->address('James', 'Taylor', 'Zeil 106', '60313', 'Frankfurt')], + ], + 'lisa@example.com' => [ + ['Home', true, $this->address('Lisa', 'Anderson', 'Mönckebergstrasse 7', '20095', 'Hamburg')], + ], + 'robert@example.com' => [ + ['Home', true, $this->address('Robert', 'Martinez', 'Königsstrasse 40', '70173', 'Stuttgart')], + ], + 'anna@example.com' => [ + ['Home', true, $this->address('Anna', 'Thomas', 'Petersstrasse 22', '04109', 'Leipzig')], + ], + 'techfan@example.com' => [ + ['Home', true, $this->address('Tech', 'Fan', 'Einsteinstrasse 5', '81675', 'Munich')], + ], + 'gadgetlover@example.com' => [ + ['Home', true, $this->address('Gadget', 'Lover', 'Linienstrasse 140', '10115', 'Berlin')], + ], + ]; + + foreach ($addresses as $email => $customerAddresses) { + foreach ($customerAddresses as [$label, $isDefault, $addressJson]) { + $customers[$email]->addresses()->updateOrCreate( + ['label' => $label], + ['address_json' => $addressJson, 'is_default' => $isDefault], + ); + } + } + } + + /** + * Build one address JSON array (App\ValueObjects\Address shape). + * + * @return array + */ + private function address( + string $firstName, + string $lastName, + string $address1, + string $postalCode, + string $city, + ?string $company = null, + ?string $address2 = null, + ?string $province = null, + ?string $provinceCode = null, + ?string $phone = null, + ): array { + return [ + 'first_name' => $firstName, + 'last_name' => $lastName, + 'company' => $company, + 'address1' => $address1, + 'address2' => $address2, + 'city' => $city, + 'province' => $province, + 'province_code' => $provinceCode, + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => $postalCode, + 'phone' => $phone, + ]; + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef2..67e638d0 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,22 +2,110 @@ namespace Database\Seeders; -use App\Models\User; -// use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; +/** + * Seeds the complete demo scenario (spec 07 §3) in dependency order. + * All seeders are idempotent (updateOrCreate/firstOrCreate), so + * `php artisan db:seed` can be run repeatedly without duplicating data. + * + * ============================================ + * ADMIN PANEL CREDENTIALS + * ============================================ + * + * Admin Login: + * Email: admin@acme.test + * Password: password + * Store: Acme Fashion (acme-fashion.test) + * Role: owner + * + * Staff Login: + * Email: staff@acme.test + * Password: password + * Store: Acme Fashion + * Role: staff + * + * Support Login: + * Email: support@acme.test + * Password: password + * Store: Acme Fashion + * Role: support + * + * Manager Login: + * Email: manager@acme.test + * Password: password + * Store: Acme Fashion + * Role: admin + * + * Admin Two Login: + * Email: admin2@acme.test + * Password: password + * Store: Acme Electronics (acme-electronics.test) + * Role: owner + * + * ============================================ + * STOREFRONT CUSTOMER CREDENTIALS + * ============================================ + * + * Primary Test Customer: + * Email: customer@acme.test + * Password: password + * Store: Acme Fashion + * Addresses: Home (default), Work + * + * Secondary Test Customer: + * Email: jane@example.com + * Password: password + * Store: Acme Fashion + * Addresses: Home (default) + * + * ============================================ + * DISCOUNT CODES (Acme Fashion) + * ============================================ + * + * WELCOME10 - 10% off, min order 20.00 EUR (active, usable) + * FLAT5 - 5.00 EUR off (active, usable) + * FREESHIP - Free shipping (active, usable) + * EXPIRED20 - 20% off (expired, must be rejected) + * MAXED - 10% off (usage limit reached, must be rejected) + * + * ============================================ + * SPECIAL PRODUCTS FOR TESTING + * ============================================ + * + * Draft: "Unreleased Winter Jacket" (not visible on storefront) + * Archived: "Discontinued Raincoat" (not visible on storefront) + * Sold Out: "Limited Edition Sneakers" (deny policy, qty 0) + * Backorder: "Backorder Denim Jacket" (continue policy, qty 0) + * Digital: "Gift Card" (no shipping required) + * Expensive: "Cashmere Overcoat" (499.99 EUR) + */ class DatabaseSeeder extends Seeder { /** - * Seed the application's database. + * Seed the application's database (spec 07 §1 execution order). */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', + $this->call([ + OrganizationSeeder::class, + StoreSeeder::class, + StoreDomainSeeder::class, + UserSeeder::class, + StoreUserSeeder::class, + StoreSettingsSeeder::class, + TaxSettingsSeeder::class, + ShippingSeeder::class, + CollectionSeeder::class, + ProductSeeder::class, + DiscountSeeder::class, + CustomerSeeder::class, + OrderSeeder::class, + ThemeSeeder::class, + PageSeeder::class, + NavigationSeeder::class, + AnalyticsSeeder::class, + SearchSettingsSeeder::class, ]); } } diff --git a/database/seeders/DiscountSeeder.php b/database/seeders/DiscountSeeder.php new file mode 100644 index 00000000..03b57fc0 --- /dev/null +++ b/database/seeders/DiscountSeeder.php @@ -0,0 +1,99 @@ +where('handle', 'acme-fashion')->firstOrFail(); + + $discounts = [ + [ + 'code' => 'WELCOME10', + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'starts_at' => '2025-01-01 00:00:00', + 'ends_at' => '2027-12-31 23:59:59', + 'usage_limit' => null, + 'usage_count' => 3, + 'rules_json' => ['min_purchase_amount' => 2000], + 'status' => DiscountStatus::Active, + ], + [ + 'code' => 'FLAT5', + 'value_type' => DiscountValueType::Fixed, + 'value_amount' => 500, + 'starts_at' => '2025-01-01 00:00:00', + 'ends_at' => '2027-12-31 23:59:59', + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [], + 'status' => DiscountStatus::Active, + ], + [ + 'code' => 'FREESHIP', + 'value_type' => DiscountValueType::FreeShipping, + 'value_amount' => 0, + 'starts_at' => '2025-01-01 00:00:00', + 'ends_at' => '2027-12-31 23:59:59', + 'usage_limit' => null, + 'usage_count' => 1, + 'rules_json' => [], + 'status' => DiscountStatus::Active, + ], + [ + 'code' => 'EXPIRED20', + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 20, + 'starts_at' => '2024-01-01 00:00:00', + 'ends_at' => '2024-12-31 23:59:59', + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [], + 'status' => DiscountStatus::Expired, + ], + [ + 'code' => 'MAXED', + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'starts_at' => '2025-01-01 00:00:00', + 'ends_at' => '2027-12-31 23:59:59', + 'usage_limit' => 5, + 'usage_count' => 5, + 'rules_json' => [], + 'status' => DiscountStatus::Active, + ], + ]; + + foreach ($discounts as $attributes) { + Discount::query()->updateOrCreate( + ['store_id' => $fashion->id, 'code' => $attributes['code']], + [ + 'type' => DiscountType::Code, + 'value_type' => $attributes['value_type'], + 'value_amount' => $attributes['value_amount'], + 'starts_at' => $attributes['starts_at'], + 'ends_at' => $attributes['ends_at'], + 'usage_limit' => $attributes['usage_limit'], + 'usage_count' => $attributes['usage_count'], + 'rules_json' => $attributes['rules_json'], + 'status' => $attributes['status'], + ], + ); + } + }); + } +} diff --git a/database/seeders/NavigationSeeder.php b/database/seeders/NavigationSeeder.php new file mode 100644 index 00000000..0236abac --- /dev/null +++ b/database/seeders/NavigationSeeder.php @@ -0,0 +1,105 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + $fashionMain = $this->menu($fashion->id, 'main-menu', 'Main Menu'); + $this->seedItems($fashionMain, [ + ['Home', NavigationItemType::Link, '/', null], + ['New Arrivals', NavigationItemType::Collection, null, $this->collectionId($fashion->id, 'new-arrivals')], + ['T-Shirts', NavigationItemType::Collection, null, $this->collectionId($fashion->id, 't-shirts')], + ['Pants & Jeans', NavigationItemType::Collection, null, $this->collectionId($fashion->id, 'pants-jeans')], + ['Sale', NavigationItemType::Collection, null, $this->collectionId($fashion->id, 'sale')], + ]); + + $fashionFooter = $this->menu($fashion->id, 'footer-menu', 'Footer Menu'); + $this->seedItems($fashionFooter, [ + ['About Us', NavigationItemType::Page, null, $this->pageId($fashion->id, 'about')], + ['FAQ', NavigationItemType::Page, null, $this->pageId($fashion->id, 'faq')], + ['Shipping & Returns', NavigationItemType::Page, null, $this->pageId($fashion->id, 'shipping-returns')], + ['Privacy Policy', NavigationItemType::Page, null, $this->pageId($fashion->id, 'privacy-policy')], + ['Terms of Service', NavigationItemType::Page, null, $this->pageId($fashion->id, 'terms')], + ]); + + $electronicsMain = $this->menu($electronics->id, 'main-menu', 'Main Menu'); + $this->seedItems($electronicsMain, [ + ['Home', NavigationItemType::Link, '/', null], + ['Featured', NavigationItemType::Collection, null, $this->collectionId($electronics->id, 'featured')], + ['Accessories', NavigationItemType::Collection, null, $this->collectionId($electronics->id, 'accessories')], + ]); + }); + } + + /** + * Create or update one menu. + */ + private function menu(int $storeId, string $handle, string $title): NavigationMenu + { + return NavigationMenu::query()->updateOrCreate( + ['store_id' => $storeId, 'handle' => $handle], + ['title' => $title], + ); + } + + /** + * Replace the menu's items with the given definitions. + * + * @param list $items + */ + private function seedItems(NavigationMenu $menu, array $items): void + { + $menu->items()->delete(); + + foreach ($items as $position => [$label, $type, $url, $resourceId]) { + $menu->items()->create([ + 'type' => $type, + 'label' => $label, + 'url' => $url, + 'resource_id' => $resourceId, + 'position' => $position, + ]); + } + } + + /** + * Resolve a collection id by handle. + */ + private function collectionId(int $storeId, string $handle): int + { + return Collection::withoutGlobalScopes() + ->where('store_id', $storeId) + ->where('handle', $handle) + ->firstOrFail() + ->id; + } + + /** + * Resolve a page id by handle. + */ + private function pageId(int $storeId, string $handle): int + { + return Page::withoutGlobalScopes() + ->where('store_id', $storeId) + ->where('handle', $handle) + ->firstOrFail() + ->id; + } +} diff --git a/database/seeders/OrderSeeder.php b/database/seeders/OrderSeeder.php new file mode 100644 index 00000000..44688594 --- /dev/null +++ b/database/seeders/OrderSeeder.php @@ -0,0 +1,568 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + foreach ($this->fashionOrders() as $definition) { + $this->seedOrder($fashion, $definition); + } + + foreach ($this->electronicsOrders() as $definition) { + $this->seedOrder($electronics, $definition); + } + }); + } + + /** + * Create or refresh one order and its child records. + * + * @param array $definition + */ + private function seedOrder(Store $store, array $definition): void + { + $customer = Customer::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('email', $definition['customer']) + ->firstOrFail(); + + $address = $customer->addresses()->where('is_default', true)->firstOrFail()->address_json; + + $order = Order::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->id, 'order_number' => $definition['number']], + [ + 'customer_id' => $customer->id, + 'payment_method' => $definition['payment_method'], + 'status' => $definition['status'], + 'financial_status' => $definition['financial'], + 'fulfillment_status' => $definition['fulfillment'], + 'currency' => 'EUR', + 'subtotal_amount' => $definition['subtotal'], + 'discount_amount' => $definition['discount'], + 'shipping_amount' => $definition['shipping'], + 'tax_amount' => $definition['tax'], + 'total_amount' => $definition['total'], + 'email' => $customer->email, + 'billing_address_json' => $address, + 'shipping_address_json' => $address, + 'placed_at' => $definition['placed_at'](), + ], + ); + + $this->wipeChildren($order); + + $lines = $this->seedLines($store, $order, $definition['lines']); + + $payment = $order->payments()->create([ + 'provider' => 'mock', + 'method' => $definition['payment_method'], + 'provider_payment_id' => $definition['payment']['id'], + 'status' => $definition['payment']['status'], + 'amount' => $definition['total'], + 'currency' => 'EUR', + 'raw_json_encrypted' => null, + ]); + + if (isset($definition['refund'])) { + $order->refunds()->create([ + 'payment_id' => $payment->id, + 'amount' => $definition['refund']['amount'], + 'reason' => $definition['refund']['reason'], + 'status' => RefundStatus::Processed, + 'provider_refund_id' => $definition['refund']['id'], + ]); + } + + if (isset($definition['fulfillment_record'])) { + $this->seedFulfillment($order, $lines, $definition['fulfillment_record']); + } + } + + /** + * Delete the order's child records so re-seeding stays exact. + */ + private function wipeChildren(Order $order): void + { + $order->refunds()->delete(); + $order->payments()->delete(); + + foreach ($order->fulfillments as $fulfillment) { + $fulfillment->lines()->delete(); + $fulfillment->delete(); + } + + $order->lines()->delete(); + } + + /** + * Create the order lines with product/SKU snapshots taken from the + * catalog records seeded earlier (spec 07 §6 "Order Line Snapshots"). + * + * @param list> $lineDefinitions + * @return list<\App\Models\OrderLine> + */ + private function seedLines(Store $store, Order $order, array $lineDefinitions): array + { + $lines = []; + + foreach ($lineDefinitions as $lineDefinition) { + $variant = $this->findVariant($store, $lineDefinition['product'], $lineDefinition['options'] ?? []); + + $total = $lineDefinition['qty'] * $lineDefinition['unit']; + $lineDiscount = $lineDefinition['line_discount'] ?? 0; + $taxAmount = (int) round(($total - $lineDiscount) * 19 / 119); + + $lines[] = $order->lines()->create([ + 'product_id' => $variant->product_id, + 'variant_id' => $variant->id, + 'title_snapshot' => $variant->product->title, + 'sku_snapshot' => $variant->sku, + 'quantity' => $lineDefinition['qty'], + 'unit_price_amount' => $lineDefinition['unit'], + 'total_amount' => $total, + 'tax_lines_json' => [['title' => 'Tax', 'rate' => 1900, 'amount' => $taxAmount]], + 'discount_allocations_json' => $lineDiscount > 0 + ? [['discount_id' => $this->discountId($store, 'WELCOME10'), 'amount' => $lineDiscount]] + : [], + ]); + } + + return $lines; + } + + /** + * Find a variant by product handle and its option value names. + * + * @param list $optionValues + */ + private function findVariant(Store $store, string $productHandle, array $optionValues): ProductVariant + { + $product = Product::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('handle', $productHandle) + ->firstOrFail(); + + $wanted = collect($optionValues) + ->map(fn (string $value): string => mb_strtolower(trim($value))) + ->sort() + ->values(); + + return $product->variants() + ->with('optionValues') + ->get() + ->first(function (ProductVariant $variant) use ($wanted): bool { + $actual = $variant->optionValues + ->pluck('value') + ->map(fn (string $value): string => mb_strtolower(trim($value))) + ->sort() + ->values(); + + return $actual->all() === $wanted->all(); + }) ?? $product->variants()->where('is_default', true)->firstOrFail(); + } + + /** + * Resolve a discount id by code. + */ + private function discountId(Store $store, string $code): int + { + return Discount::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('code', $code) + ->firstOrFail() + ->id; + } + + /** + * Create the fulfillment and its lines for an order. + * + * @param list<\App\Models\OrderLine> $lines + * @param array $definition + */ + private function seedFulfillment(Order $order, array $lines, array $definition): void + { + $fulfillment = $order->fulfillments()->create([ + 'status' => $definition['status'], + 'tracking_company' => $definition['company'] ?? null, + 'tracking_number' => $definition['number'] ?? null, + 'tracking_url' => isset($definition['number']) + ? 'https://tracking.example.com/'.$definition['number'] + : null, + 'shipped_at' => $definition['shipped_at'](), + ]); + + $coveredLines = $definition['lines'] === 'all' + ? $lines + : array_intersect_key($lines, array_flip($definition['lines'])); + + foreach ($coveredLines as $line) { + $fulfillment->lines()->create([ + 'order_line_id' => $line->id, + 'quantity' => $line->quantity, + ]); + } + } + + /** + * The 15 Acme Fashion orders (spec 07 §3.13). + * + * @return list> + */ + private function fashionOrders(): array + { + $captured = fn (string $id): array => ['id' => $id, 'status' => PaymentStatus::Captured]; + + return [ + [ + 'number' => '#1001', + 'customer' => 'customer@acme.test', + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Paid, + 'financial' => FinancialStatus::Paid, + 'fulfillment' => FulfillmentOrderStatus::Unfulfilled, + 'placed_at' => fn (): CarbonImmutable => now()->subDays(2)->toImmutable(), + 'lines' => [ + ['product' => 'classic-cotton-t-shirt', 'options' => ['S', 'White'], 'qty' => 2, 'unit' => 2499], + ], + 'subtotal' => 4998, 'discount' => 0, 'shipping' => 499, 'tax' => 798, 'total' => 5497, + 'payment' => $captured('mock_test_order1001'), + ], + [ + 'number' => '#1002', + 'customer' => 'customer@acme.test', + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Fulfilled, + 'financial' => FinancialStatus::Paid, + 'fulfillment' => FulfillmentOrderStatus::Fulfilled, + 'placed_at' => fn (): CarbonImmutable => now()->subDays(10)->toImmutable(), + 'lines' => [ + ['product' => 'organic-hoodie', 'options' => ['M'], 'qty' => 1, 'unit' => 5999], + ['product' => 'classic-cotton-t-shirt', 'options' => ['L', 'Black'], 'qty' => 1, 'unit' => 2499], + ], + 'subtotal' => 8498, 'discount' => 0, 'shipping' => 499, 'tax' => 1357, 'total' => 8997, + 'payment' => $captured('mock_test_order1002'), + 'fulfillment_record' => [ + 'status' => FulfillmentShipmentStatus::Delivered, + 'company' => 'DHL', + 'number' => 'DHL1234567890', + 'shipped_at' => fn (): CarbonImmutable => now()->subDays(8)->toImmutable(), + 'lines' => 'all', + ], + ], + [ + 'number' => '#1003', + 'customer' => 'jane@example.com', + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Paid, + 'financial' => FinancialStatus::Paid, + 'fulfillment' => FulfillmentOrderStatus::Partial, + 'placed_at' => fn (): CarbonImmutable => now()->subDays(5)->toImmutable(), + 'lines' => [ + ['product' => 'premium-slim-fit-jeans', 'options' => ['32', 'Blue'], 'qty' => 1, 'unit' => 7999], + ['product' => 'leather-belt', 'options' => ['L/XL', 'Brown'], 'qty' => 1, 'unit' => 3499], + ], + 'subtotal' => 11498, 'discount' => 0, 'shipping' => 499, 'tax' => 1836, 'total' => 11997, + 'payment' => $captured('mock_test_order1003'), + 'fulfillment_record' => [ + 'status' => FulfillmentShipmentStatus::Shipped, + 'company' => 'DHL', + 'number' => 'DHL9876543210', + 'shipped_at' => fn (): CarbonImmutable => now()->subDays(3)->toImmutable(), + 'lines' => [0], + ], + ], + [ + 'number' => '#1004', + 'customer' => 'customer@acme.test', + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Cancelled, + 'financial' => FinancialStatus::Refunded, + 'fulfillment' => FulfillmentOrderStatus::Unfulfilled, + 'placed_at' => fn (): CarbonImmutable => now()->subDays(15)->toImmutable(), + 'lines' => [ + ['product' => 'classic-cotton-t-shirt', 'options' => ['M', 'Navy'], 'qty' => 1, 'unit' => 2499], + ], + 'subtotal' => 2499, 'discount' => 0, 'shipping' => 499, 'tax' => 399, 'total' => 2998, + 'payment' => ['id' => 'mock_test_order1004', 'status' => PaymentStatus::Refunded], + 'refund' => ['amount' => 2998, 'reason' => 'Customer requested cancellation', 'id' => 'mock_re_test_order1004'], + ], + [ + 'number' => '#1005', + 'customer' => 'jane@example.com', + 'payment_method' => PaymentMethod::BankTransfer, + 'status' => OrderStatus::Pending, + 'financial' => FinancialStatus::Pending, + 'fulfillment' => FulfillmentOrderStatus::Unfulfilled, + 'placed_at' => fn (): CarbonImmutable => now()->subHours(2)->toImmutable(), + 'lines' => [ + ['product' => 'leather-belt', 'options' => ['S/M', 'Black'], 'qty' => 1, 'unit' => 3499], + ], + 'subtotal' => 3499, 'discount' => 0, 'shipping' => 499, 'tax' => 559, 'total' => 3998, + 'payment' => ['id' => 'mock_test_order1005', 'status' => PaymentStatus::Pending], + ], + [ + 'number' => '#1006', + 'customer' => 'michael@example.com', + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Paid, + 'financial' => FinancialStatus::Paid, + 'fulfillment' => FulfillmentOrderStatus::Unfulfilled, + 'placed_at' => fn (): CarbonImmutable => now()->subDay()->toImmutable(), + 'lines' => [ + ['product' => 'running-sneakers', 'options' => ['EU 42', 'Black'], 'qty' => 1, 'unit' => 11999], + ], + 'subtotal' => 11999, 'discount' => 0, 'shipping' => 499, 'tax' => 1916, 'total' => 12498, + 'payment' => $captured('mock_test_order1006'), + ], + [ + 'number' => '#1007', + 'customer' => 'sarah@example.com', + 'payment_method' => PaymentMethod::Paypal, + 'status' => OrderStatus::Fulfilled, + 'financial' => FinancialStatus::Paid, + 'fulfillment' => FulfillmentOrderStatus::Fulfilled, + 'placed_at' => fn (): CarbonImmutable => now()->subDays(20)->toImmutable(), + 'lines' => [ + ['product' => 'v-neck-linen-tee', 'options' => ['M', 'Beige'], 'qty' => 2, 'unit' => 3499], + ['product' => 'wool-scarf', 'options' => ['Grey'], 'qty' => 1, 'unit' => 2999], + ], + 'subtotal' => 9997, 'discount' => 0, 'shipping' => 499, 'tax' => 1596, 'total' => 10496, + 'payment' => $captured('mock_test_order1007'), + 'fulfillment_record' => [ + 'status' => FulfillmentShipmentStatus::Delivered, + 'company' => 'DHL', + 'number' => 'DHL1112223334', + 'shipped_at' => fn (): CarbonImmutable => now()->subDays(18)->toImmutable(), + 'lines' => 'all', + ], + ], + [ + 'number' => '#1008', + 'customer' => 'david@example.com', + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Paid, + 'financial' => FinancialStatus::PartiallyRefunded, + 'fulfillment' => FulfillmentOrderStatus::Fulfilled, + 'placed_at' => fn (): CarbonImmutable => now()->subDays(12)->toImmutable(), + 'lines' => [ + ['product' => 'cargo-pants', 'options' => ['32', 'Khaki'], 'qty' => 1, 'unit' => 5499], + ['product' => 'graphic-print-tee', 'options' => ['L'], 'qty' => 1, 'unit' => 2999], + ], + 'subtotal' => 8498, 'discount' => 0, 'shipping' => 499, 'tax' => 1357, 'total' => 8997, + 'payment' => $captured('mock_test_order1008'), + 'refund' => ['amount' => 2999, 'reason' => 'Item returned', 'id' => 'mock_re_test_order1008'], + 'fulfillment_record' => [ + 'status' => FulfillmentShipmentStatus::Delivered, + 'company' => 'UPS', + 'number' => 'UPS5556667778', + 'shipped_at' => fn (): CarbonImmutable => now()->subDays(10)->toImmutable(), + 'lines' => 'all', + ], + ], + [ + 'number' => '#1009', + 'customer' => 'emma@example.com', + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Paid, + 'financial' => FinancialStatus::Paid, + 'fulfillment' => FulfillmentOrderStatus::Unfulfilled, + 'placed_at' => fn (): CarbonImmutable => now()->subDays(3)->toImmutable(), + 'lines' => [ + ['product' => 'canvas-tote-bag', 'options' => ['Natural'], 'qty' => 1, 'unit' => 1999], + ['product' => 'bucket-hat', 'options' => ['S/M', 'Black'], 'qty' => 1, 'unit' => 2499], + ], + 'subtotal' => 4498, 'discount' => 0, 'shipping' => 499, 'tax' => 718, 'total' => 4997, + 'payment' => $captured('mock_test_order1009'), + ], + [ + 'number' => '#1010', + 'customer' => 'customer@acme.test', + 'payment_method' => PaymentMethod::Paypal, + 'status' => OrderStatus::Paid, + 'financial' => FinancialStatus::Paid, + 'fulfillment' => FulfillmentOrderStatus::Unfulfilled, + 'placed_at' => fn (): CarbonImmutable => now()->subDay()->toImmutable(), + 'lines' => [ + ['product' => 'cashmere-overcoat', 'options' => ['M', 'Camel'], 'qty' => 1, 'unit' => 49999], + ], + 'subtotal' => 49999, 'discount' => 0, 'shipping' => 499, 'tax' => 7983, 'total' => 50498, + 'payment' => $captured('mock_test_order1010'), + ], + [ + 'number' => '#1011', + 'customer' => 'james@example.com', + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Paid, + 'financial' => FinancialStatus::Paid, + 'fulfillment' => FulfillmentOrderStatus::Fulfilled, + 'placed_at' => fn (): CarbonImmutable => now()->subDays(25)->toImmutable(), + 'lines' => [ + ['product' => 'striped-polo-shirt', 'options' => ['XL'], 'qty' => 1, 'unit' => 2799], + ], + 'subtotal' => 2799, 'discount' => 0, 'shipping' => 499, 'tax' => 447, 'total' => 3298, + 'payment' => $captured('mock_test_order1011'), + 'fulfillment_record' => [ + 'status' => FulfillmentShipmentStatus::Delivered, + 'company' => 'FedEx', + 'number' => 'FX9998887776', + 'shipped_at' => fn (): CarbonImmutable => now()->subDays(23)->toImmutable(), + 'lines' => 'all', + ], + ], + [ + 'number' => '#1012', + 'customer' => 'lisa@example.com', + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Paid, + 'financial' => FinancialStatus::Paid, + 'fulfillment' => FulfillmentOrderStatus::Unfulfilled, + 'placed_at' => fn (): CarbonImmutable => now()->subDays(4)->toImmutable(), + 'lines' => [ + ['product' => 'chino-shorts', 'options' => ['34', 'Navy'], 'qty' => 2, 'unit' => 3999], + ], + 'subtotal' => 7998, 'discount' => 0, 'shipping' => 499, 'tax' => 1277, 'total' => 8497, + 'payment' => $captured('mock_test_order1012'), + ], + [ + 'number' => '#1013', + 'customer' => 'robert@example.com', + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Paid, + 'financial' => FinancialStatus::Paid, + 'fulfillment' => FulfillmentOrderStatus::Unfulfilled, + 'placed_at' => fn (): CarbonImmutable => now()->subDay()->toImmutable(), + 'lines' => [ + ['product' => 'wide-leg-trousers', 'options' => ['M'], 'qty' => 1, 'unit' => 4999], + ['product' => 'wool-scarf', 'options' => ['Burgundy'], 'qty' => 1, 'unit' => 2999], + ], + 'subtotal' => 7998, 'discount' => 0, 'shipping' => 499, 'tax' => 1277, 'total' => 8497, + 'payment' => $captured('mock_test_order1013'), + ], + [ + 'number' => '#1014', + 'customer' => 'anna@example.com', + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Paid, + 'financial' => FinancialStatus::Paid, + 'fulfillment' => FulfillmentOrderStatus::Fulfilled, + 'placed_at' => fn (): CarbonImmutable => now()->subDays(14)->toImmutable(), + 'lines' => [ + ['product' => 'gift-card', 'options' => ['50 EUR'], 'qty' => 1, 'unit' => 5000], + ], + 'subtotal' => 5000, 'discount' => 0, 'shipping' => 0, 'tax' => 798, 'total' => 5000, + 'payment' => $captured('mock_test_order1014'), + 'fulfillment_record' => [ + 'status' => FulfillmentShipmentStatus::Delivered, + 'company' => null, + 'number' => null, + 'shipped_at' => fn (): CarbonImmutable => now()->subDays(14)->toImmutable(), + 'lines' => 'all', + ], + ], + [ + 'number' => '#1015', + 'customer' => 'customer@acme.test', + 'payment_method' => PaymentMethod::BankTransfer, + 'status' => OrderStatus::Paid, + 'financial' => FinancialStatus::Paid, + 'fulfillment' => FulfillmentOrderStatus::Unfulfilled, + 'placed_at' => fn (): CarbonImmutable => now()->toImmutable(), + 'lines' => [ + ['product' => 'classic-cotton-t-shirt', 'options' => ['M', 'White'], 'qty' => 1, 'unit' => 2499, 'line_discount' => 250], + ['product' => 'graphic-print-tee', 'options' => ['M'], 'qty' => 1, 'unit' => 2999, 'line_discount' => 300], + ], + 'subtotal' => 5498, 'discount' => 550, 'shipping' => 499, 'tax' => 790, 'total' => 5447, + 'payment' => $captured('mock_test_order1015'), + ], + ]; + } + + /** + * The 3 Acme Electronics orders (spec 07 §3.13). + * + * @return list> + */ + private function electronicsOrders(): array + { + return [ + [ + 'number' => '#5001', + 'customer' => 'techfan@example.com', + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Paid, + 'financial' => FinancialStatus::Paid, + 'fulfillment' => FulfillmentOrderStatus::Fulfilled, + 'placed_at' => fn (): CarbonImmutable => now()->subDays(5)->toImmutable(), + 'lines' => [ + ['product' => 'pro-laptop-15', 'options' => ['512GB'], 'qty' => 1, 'unit' => 119999], + ['product' => 'usb-c-cable-2m', 'options' => [], 'qty' => 1, 'unit' => 1299], + ], + 'subtotal' => 121298, 'discount' => 0, 'shipping' => 0, 'tax' => 19367, 'total' => 121298, + 'payment' => ['id' => 'mock_test_order5001', 'status' => PaymentStatus::Captured], + 'fulfillment_record' => [ + 'status' => FulfillmentShipmentStatus::Delivered, + 'company' => 'DHL', + 'number' => 'DHL5001000001', + 'shipped_at' => fn (): CarbonImmutable => now()->subDays(4)->toImmutable(), + 'lines' => 'all', + ], + ], + [ + 'number' => '#5002', + 'customer' => 'gadgetlover@example.com', + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Paid, + 'financial' => FinancialStatus::Paid, + 'fulfillment' => FulfillmentOrderStatus::Unfulfilled, + 'placed_at' => fn (): CarbonImmutable => now()->subDays(2)->toImmutable(), + 'lines' => [ + ['product' => 'wireless-headphones', 'options' => ['Black'], 'qty' => 1, 'unit' => 14999], + ], + 'subtotal' => 14999, 'discount' => 0, 'shipping' => 0, 'tax' => 2395, 'total' => 14999, + 'payment' => ['id' => 'mock_test_order5002', 'status' => PaymentStatus::Captured], + ], + [ + 'number' => '#5003', + 'customer' => 'techfan@example.com', + 'payment_method' => PaymentMethod::BankTransfer, + 'status' => OrderStatus::Pending, + 'financial' => FinancialStatus::Pending, + 'fulfillment' => FulfillmentOrderStatus::Unfulfilled, + 'placed_at' => fn (): CarbonImmutable => now()->subHour()->toImmutable(), + 'lines' => [ + ['product' => 'monitor-stand', 'options' => [], 'qty' => 1, 'unit' => 4999], + ], + 'subtotal' => 4999, 'discount' => 0, 'shipping' => 0, 'tax' => 798, 'total' => 4999, + 'payment' => ['id' => 'mock_test_order5003', 'status' => PaymentStatus::Pending], + ], + ]; + } +} diff --git a/database/seeders/OrganizationSeeder.php b/database/seeders/OrganizationSeeder.php new file mode 100644 index 00000000..367682b5 --- /dev/null +++ b/database/seeders/OrganizationSeeder.php @@ -0,0 +1,23 @@ +firstOrCreate( + ['name' => 'Acme Corp'], + ['billing_email' => 'billing@acme.test'], + ); + }); + } +} diff --git a/database/seeders/PageSeeder.php b/database/seeders/PageSeeder.php new file mode 100644 index 00000000..5889bea0 --- /dev/null +++ b/database/seeders/PageSeeder.php @@ -0,0 +1,101 @@ +where('handle', 'acme-fashion')->firstOrFail(); + + $pages = [ + [ + 'title' => 'About Us', + 'handle' => 'about', + 'body_html' => '

Our Story

' + .'

Acme Fashion was founded in Berlin with a simple mission: to create modern wardrobe essentials that last. We believe great style should not come at the expense of comfort, quality, or the planet.

' + .'

Every piece in our collection is designed in-house and produced in small batches, so we can focus on the details that matter - the fabric, the fit, and the finish.

' + .'

Our Values

' + .'

We are committed to ethical sourcing and work only with certified suppliers who share our standards. Our cotton is organic, our wool is mulesing-free, and our packaging is fully recyclable.

' + .'

Sustainability and fair labor are not marketing words for us. We visit our partner factories regularly and publish an annual transparency report.

' + .'

Our Team

' + .'

We are a small team of Berlin-based designers, pattern makers, and product people. When you write to us, you talk to the same people who designed the clothes you wear.

', + ], + [ + 'title' => 'FAQ', + 'handle' => 'faq', + 'body_html' => '

Frequently Asked Questions

' + .'

How long does shipping take?

' + .'

Orders within Germany arrive in 2-4 business days with standard shipping and 1-2 business days with express shipping. Deliveries to EU countries take 5-7 business days.

' + .'

What is your return policy?

' + .'

You can return any unworn item in its original packaging within 30 days of delivery for a full refund.

' + .'

Do you ship internationally?

' + .'

Yes. We ship to all EU countries as well as the United States, United Kingdom, Canada, and Australia.

' + .'

How can I track my order?

' + .'

As soon as your order ships, you will receive an email with a tracking number and a link to follow your delivery.

', + ], + [ + 'title' => 'Shipping & Returns', + 'handle' => 'shipping-returns', + 'body_html' => '

Shipping Rates

' + .'

Germany

' + .'
  • Standard shipping (2-4 business days): 4.99 EUR
  • Express shipping (1-2 business days): 9.99 EUR
' + .'

European Union

' + .'
  • EU Standard (5-7 business days): 8.99 EUR
' + .'

International

' + .'
  • US, UK, Canada, Australia: 14.99 EUR
' + .'

Returns

' + .'

You may return unworn items in their original packaging within 30 days of delivery. Return shipping is paid by the customer unless the item is defective - in that case we cover all costs and send a prepaid label.

', + ], + [ + 'title' => 'Privacy Policy', + 'handle' => 'privacy-policy', + 'body_html' => '

Privacy Policy

' + .'

Information We Collect

' + .'

We collect the information you provide when creating an account or placing an order: your name, email address, shipping and billing addresses, and order history.

' + .'

How We Use Your Information

' + .'

We use your information to process orders, arrange delivery, and - only with your consent - send marketing emails. We never sell your data to third parties.

' + .'

Cookies

' + .'

We use strictly necessary cookies to operate the shop and anonymous analytics cookies to understand how the store is used.

' + .'

Contact

' + .'

For any privacy questions or data requests, contact us at privacy@acme-fashion.test.

', + ], + [ + 'title' => 'Terms of Service', + 'handle' => 'terms', + 'body_html' => '

Terms of Service

' + .'

Orders and Payments

' + .'

All prices are shown in EUR and include applicable taxes. Payment is processed at the time of ordering via the selected payment method.

' + .'

Product Descriptions

' + .'

We make every effort to display colors and details accurately, but slight variance can occur due to screen settings and production batches.

' + .'

Limitation of Liability

' + .'

Our liability is limited to the purchase price of the affected products. Nothing in these terms limits your statutory consumer rights.

' + .'

Governing Law

' + .'

These terms are governed by the laws of the Federal Republic of Germany.

', + ], + ]; + + foreach ($pages as $attributes) { + Page::query()->updateOrCreate( + ['store_id' => $fashion->id, 'handle' => $attributes['handle']], + [ + 'title' => $attributes['title'], + 'body_html' => $attributes['body_html'], + 'status' => PageStatus::Published, + 'published_at' => now()->subMonths(3), + ], + ); + } + }); + } +} diff --git a/database/seeders/ProductSeeder.php b/database/seeders/ProductSeeder.php new file mode 100644 index 00000000..cf95bf51 --- /dev/null +++ b/database/seeders/ProductSeeder.php @@ -0,0 +1,708 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + $fashionProducts = []; + + foreach ($this->fashionDefinitions() as $definition) { + $fashionProducts[$definition['handle']] = $this->seedProduct($fashion, $definition); + } + + foreach ($this->electronicsDefinitions() as $definition) { + $this->seedProduct($electronics, $definition); + } + + $this->assignCollections($fashion, $fashionProducts); + $this->assignElectronicsCollections($electronics); + + $this->search->reindex($fashion); + $this->search->reindex($electronics); + }); + } + + /** + * Create or update one product with its full entity graph. + * + * @param array $definition + */ + private function seedProduct(Store $store, array $definition): Product + { + $data = [ + 'title' => $definition['title'], + 'handle' => $definition['handle'], + 'status' => $definition['status'], + 'description_html' => '

'.$definition['description'].'

', + 'vendor' => $definition['vendor'], + 'product_type' => $definition['product_type'], + 'tags' => $definition['tags'], + 'published_at' => $definition['published_at'](), + 'options' => $definition['options'], + 'variant_defaults' => $definition['defaults'], + 'variants' => $this->buildVariants($definition), + ]; + + $existing = Product::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('handle', $definition['handle']) + ->first(); + + return $existing !== null + ? $this->products->update($existing, $data) + : $this->products->create($store, $data); + } + + /** + * Expand the cartesian product of the option values into per-variant + * overrides (SKU + inventory), merged with any explicit variant data. + * + * @param array $definition + * @return list> + */ + private function buildVariants(array $definition): array + { + if (isset($definition['variants'])) { + return $definition['variants']; + } + + if ($definition['options'] === []) { + return [$definition['single_variant']]; + } + + $valueSets = array_map( + fn (array $option): array => $option['values'], + $definition['options'], + ); + + $variants = []; + + foreach ($this->cartesian($valueSets) as $combo) { + $variants[] = [ + 'option_values' => $combo, + 'sku' => $this->sku($definition['code'], $combo), + 'inventory' => $definition['inventory'], + ]; + } + + return $variants; + } + + /** + * Cartesian product of the given sets (last set varies fastest, matching + * the VariantMatrixService ordering). + * + * @param list> $sets + * @return list> + */ + private function cartesian(array $sets): array + { + $result = [[]]; + + foreach ($sets as $set) { + $next = []; + + foreach ($result as $combination) { + foreach ($set as $value) { + $next[] = array_merge($combination, [$value]); + } + } + + $result = $next; + } + + return $result; + } + + /** + * Build a deterministic SKU from the product code and option values. + * + * @param list $optionValues + */ + private function sku(string $code, array $optionValues): string + { + $parts = array_map( + fn (string $value): string => trim(strtoupper((string) preg_replace('/[^A-Za-z0-9]+/', '-', $value)), '-'), + $optionValues, + ); + + return 'ACME-'.$code.'-'.implode('-', $parts); + } + + /** + * Link products to the Acme Fashion collections (spec 07 §4). + * + * @param array $productsByHandle + */ + private function assignCollections(Store $store, array $productsByHandle): void + { + $assignments = [ + 'new-arrivals' => ['classic-cotton-t-shirt', 'premium-slim-fit-jeans', 'organic-hoodie', 'running-sneakers', 'chino-shorts', 'bucket-hat', 'cashmere-overcoat'], + 't-shirts' => ['classic-cotton-t-shirt', 'graphic-print-tee', 'v-neck-linen-tee', 'striped-polo-shirt'], + 'pants-jeans' => ['premium-slim-fit-jeans', 'cargo-pants', 'chino-shorts', 'wide-leg-trousers'], + 'sale' => ['premium-slim-fit-jeans', 'striped-polo-shirt', 'wide-leg-trousers'], + ]; + + foreach ($assignments as $collectionHandle => $productHandles) { + $collection = Collection::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('handle', $collectionHandle) + ->firstOrFail(); + + $attach = []; + + foreach ($productHandles as $position => $productHandle) { + $attach[$productsByHandle[$productHandle]->id] = ['position' => $position]; + } + + $collection->products()->sync($attach); + } + } + + /** + * Link products to the Acme Electronics collections (spec 07 §4). + */ + private function assignElectronicsCollections(Store $store): void + { + $assignments = [ + 'featured' => ['pro-laptop-15', 'wireless-headphones', 'mechanical-keyboard'], + 'accessories' => ['usb-c-cable-2m', 'monitor-stand'], + ]; + + foreach ($assignments as $collectionHandle => $productHandles) { + $collection = Collection::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('handle', $collectionHandle) + ->firstOrFail(); + + $attach = []; + + foreach ($productHandles as $position => $productHandle) { + $product = Product::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('handle', $productHandle) + ->firstOrFail(); + + $attach[$product->id] = ['position' => $position]; + } + + $collection->products()->sync($attach); + } + } + + /** + * The 20 Acme Fashion products (spec 07 §3.10). + * + * @return list> + */ + private function fashionDefinitions(): array + { + $deny = fn (int $quantity): array => ['quantity_on_hand' => $quantity, 'policy' => InventoryPolicy::Deny]; + + return [ + [ + 'title' => 'Classic Cotton T-Shirt', + 'handle' => 'classic-cotton-t-shirt', + 'status' => ProductStatus::Active, + 'vendor' => 'Acme Basics', + 'product_type' => 'T-Shirts', + 'tags' => ['new', 'popular'], + 'description' => 'A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.', + 'published_at' => fn () => now(), + 'code' => 'CTSH', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ['name' => 'Color', 'values' => ['White', 'Black', 'Navy']], + ], + 'defaults' => ['price_amount' => 2499, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_g' => 200, 'requires_shipping' => true], + 'inventory' => $deny(15), + 'variants' => $this->cartesianVariants('CTSH', [['S', 'M', 'L', 'XL'], ['White', 'Black', 'Navy']], $deny(15), [ + 'White' => 'WHT', 'Black' => 'BLK', 'Navy' => 'NAV', + ]), + ], + [ + 'title' => 'Premium Slim Fit Jeans', + 'handle' => 'premium-slim-fit-jeans', + 'status' => ProductStatus::Active, + 'vendor' => 'Acme Denim', + 'product_type' => 'Pants', + 'tags' => ['new', 'sale'], + 'description' => 'Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.', + 'published_at' => fn () => now(), + 'code' => 'PSFJ', + 'options' => [ + ['name' => 'Size', 'values' => ['28', '30', '32', '34', '36']], + ['name' => 'Color', 'values' => ['Blue', 'Black']], + ], + 'defaults' => ['price_amount' => 7999, 'compare_at_amount' => 9999, 'currency' => 'EUR', 'weight_g' => 800, 'requires_shipping' => true], + 'inventory' => $deny(8), + ], + [ + 'title' => 'Organic Hoodie', + 'handle' => 'organic-hoodie', + 'status' => ProductStatus::Active, + 'vendor' => 'Acme Basics', + 'product_type' => 'Hoodies', + 'tags' => ['new', 'trending'], + 'description' => 'Made from 100% organic cotton. Warm, soft, and sustainably produced.', + 'published_at' => fn () => now(), + 'code' => 'OHOOD', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ], + 'defaults' => ['price_amount' => 5999, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_g' => 500, 'requires_shipping' => true], + 'inventory' => $deny(20), + ], + [ + 'title' => 'Leather Belt', + 'handle' => 'leather-belt', + 'status' => ProductStatus::Active, + 'vendor' => 'Acme Accessories', + 'product_type' => 'Accessories', + 'tags' => ['popular'], + 'description' => 'Genuine leather belt with brushed metal buckle. A wardrobe essential.', + 'published_at' => fn () => now(), + 'code' => 'LBELT', + 'options' => [ + ['name' => 'Size', 'values' => ['S/M', 'L/XL']], + ['name' => 'Color', 'values' => ['Brown', 'Black']], + ], + 'defaults' => ['price_amount' => 3499, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_g' => 150, 'requires_shipping' => true], + 'inventory' => $deny(25), + ], + [ + 'title' => 'Running Sneakers', + 'handle' => 'running-sneakers', + 'status' => ProductStatus::Active, + 'vendor' => 'Acme Sport', + 'product_type' => 'Shoes', + 'tags' => ['trending'], + 'description' => 'Lightweight running sneakers with responsive cushioning and breathable mesh upper.', + 'published_at' => fn () => now(), + 'code' => 'RSNEAK', + 'options' => [ + ['name' => 'Size', 'values' => ['EU 38', 'EU 39', 'EU 40', 'EU 41', 'EU 42', 'EU 43', 'EU 44']], + ['name' => 'Color', 'values' => ['White', 'Black']], + ], + 'defaults' => ['price_amount' => 11999, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_g' => 600, 'requires_shipping' => true], + 'inventory' => $deny(5), + ], + [ + 'title' => 'Graphic Print Tee', + 'handle' => 'graphic-print-tee', + 'status' => ProductStatus::Active, + 'vendor' => 'Acme Basics', + 'product_type' => 'T-Shirts', + 'tags' => ['new'], + 'description' => 'Bold graphic print on soft cotton. Express yourself with this statement piece.', + 'published_at' => fn () => now(), + 'code' => 'GPRTEE', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ], + 'defaults' => ['price_amount' => 2999, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_g' => 210, 'requires_shipping' => true], + 'inventory' => $deny(18), + ], + [ + 'title' => 'V-Neck Linen Tee', + 'handle' => 'v-neck-linen-tee', + 'status' => ProductStatus::Active, + 'vendor' => 'Acme Basics', + 'product_type' => 'T-Shirts', + 'tags' => ['popular'], + 'description' => 'Lightweight linen blend v-neck. Perfect for warm summer days.', + 'published_at' => fn () => now(), + 'code' => 'VNLTEE', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L']], + ['name' => 'Color', 'values' => ['Beige', 'Olive', 'Sky Blue']], + ], + 'defaults' => ['price_amount' => 3499, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_g' => 180, 'requires_shipping' => true], + 'inventory' => $deny(12), + ], + [ + 'title' => 'Striped Polo Shirt', + 'handle' => 'striped-polo-shirt', + 'status' => ProductStatus::Active, + 'vendor' => 'Acme Basics', + 'product_type' => 'T-Shirts', + 'tags' => ['sale'], + 'description' => 'Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.', + 'published_at' => fn () => now(), + 'code' => 'SPOLO', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ], + 'defaults' => ['price_amount' => 2799, 'compare_at_amount' => 3999, 'currency' => 'EUR', 'weight_g' => 250, 'requires_shipping' => true], + 'inventory' => $deny(10), + ], + [ + 'title' => 'Cargo Pants', + 'handle' => 'cargo-pants', + 'status' => ProductStatus::Active, + 'vendor' => 'Acme Workwear', + 'product_type' => 'Pants', + 'tags' => ['popular'], + 'description' => 'Utility cargo pants with multiple pockets. Durable cotton twill construction.', + 'published_at' => fn () => now(), + 'code' => 'CARGO', + 'options' => [ + ['name' => 'Size', 'values' => ['30', '32', '34', '36']], + ['name' => 'Color', 'values' => ['Khaki', 'Olive', 'Black']], + ], + 'defaults' => ['price_amount' => 5499, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_g' => 700, 'requires_shipping' => true], + 'inventory' => $deny(14), + ], + [ + 'title' => 'Chino Shorts', + 'handle' => 'chino-shorts', + 'status' => ProductStatus::Active, + 'vendor' => 'Acme Basics', + 'product_type' => 'Pants', + 'tags' => ['new', 'trending'], + 'description' => 'Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.', + 'published_at' => fn () => now(), + 'code' => 'CHSHORT', + 'options' => [ + ['name' => 'Size', 'values' => ['30', '32', '34', '36']], + ['name' => 'Color', 'values' => ['Navy', 'Sand']], + ], + 'defaults' => ['price_amount' => 3999, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_g' => 350, 'requires_shipping' => true], + 'inventory' => $deny(16), + ], + [ + 'title' => 'Wide Leg Trousers', + 'handle' => 'wide-leg-trousers', + 'status' => ProductStatus::Active, + 'vendor' => 'Acme Denim', + 'product_type' => 'Pants', + 'tags' => ['sale'], + 'description' => 'Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.', + 'published_at' => fn () => now(), + 'code' => 'WLTROU', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L']], + ], + 'defaults' => ['price_amount' => 4999, 'compare_at_amount' => 6999, 'currency' => 'EUR', 'weight_g' => 550, 'requires_shipping' => true], + 'inventory' => $deny(7), + ], + [ + 'title' => 'Wool Scarf', + 'handle' => 'wool-scarf', + 'status' => ProductStatus::Active, + 'vendor' => 'Acme Accessories', + 'product_type' => 'Accessories', + 'tags' => ['popular'], + 'description' => 'Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.', + 'published_at' => fn () => now(), + 'code' => 'WSCARF', + 'options' => [ + ['name' => 'Color', 'values' => ['Grey', 'Burgundy', 'Navy']], + ], + 'defaults' => ['price_amount' => 2999, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_g' => 120, 'requires_shipping' => true], + 'inventory' => $deny(30), + ], + [ + 'title' => 'Canvas Tote Bag', + 'handle' => 'canvas-tote-bag', + 'status' => ProductStatus::Active, + 'vendor' => 'Acme Accessories', + 'product_type' => 'Accessories', + 'tags' => ['trending'], + 'description' => 'Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.', + 'published_at' => fn () => now(), + 'code' => 'CTOTE', + 'options' => [ + ['name' => 'Color', 'values' => ['Natural', 'Black']], + ], + 'defaults' => ['price_amount' => 1999, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_g' => 300, 'requires_shipping' => true], + 'inventory' => $deny(40), + ], + [ + 'title' => 'Bucket Hat', + 'handle' => 'bucket-hat', + 'status' => ProductStatus::Active, + 'vendor' => 'Acme Accessories', + 'product_type' => 'Accessories', + 'tags' => ['new', 'trending'], + 'description' => 'Lightweight bucket hat for sun protection. Packable design, washed cotton twill.', + 'published_at' => fn () => now(), + 'code' => 'BHAT', + 'options' => [ + ['name' => 'Size', 'values' => ['S/M', 'L/XL']], + ['name' => 'Color', 'values' => ['Beige', 'Black', 'Olive']], + ], + 'defaults' => ['price_amount' => 2499, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_g' => 80, 'requires_shipping' => true], + 'inventory' => $deny(22), + ], + [ + 'title' => 'Unreleased Winter Jacket', + 'handle' => 'unreleased-winter-jacket', + 'status' => ProductStatus::Draft, + 'vendor' => 'Acme Outerwear', + 'product_type' => 'Jackets', + 'tags' => ['limited'], + 'description' => 'Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.', + 'published_at' => fn () => null, + 'code' => 'UWJACK', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ], + 'defaults' => ['price_amount' => 14999, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_g' => 900, 'requires_shipping' => true], + 'inventory' => $deny(0), + ], + [ + 'title' => 'Discontinued Raincoat', + 'handle' => 'discontinued-raincoat', + 'status' => ProductStatus::Archived, + 'vendor' => 'Acme Outerwear', + 'product_type' => 'Jackets', + 'tags' => [], + 'description' => 'Lightweight waterproof raincoat. This product has been discontinued.', + 'published_at' => fn () => now()->subMonths(6), + 'code' => 'DRAIN', + 'options' => [ + ['name' => 'Size', 'values' => ['M', 'L']], + ], + 'defaults' => ['price_amount' => 8999, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_g' => 400, 'requires_shipping' => true], + 'inventory' => $deny(3), + ], + [ + 'title' => 'Limited Edition Sneakers', + 'handle' => 'limited-edition-sneakers', + 'status' => ProductStatus::Active, + 'vendor' => 'Acme Sport', + 'product_type' => 'Shoes', + 'tags' => ['limited'], + 'description' => 'Limited edition collaboration sneakers. Once they are gone, they are gone.', + 'published_at' => fn () => now(), + 'code' => 'LESNEAK', + 'options' => [ + ['name' => 'Size', 'values' => ['EU 40', 'EU 42', 'EU 44']], + ], + 'defaults' => ['price_amount' => 15999, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_g' => 650, 'requires_shipping' => true], + 'inventory' => $deny(0), + ], + [ + 'title' => 'Backorder Denim Jacket', + 'handle' => 'backorder-denim-jacket', + 'status' => ProductStatus::Active, + 'vendor' => 'Acme Denim', + 'product_type' => 'Jackets', + 'tags' => ['popular'], + 'description' => 'Classic denim jacket. Currently on backorder - ships within 2-3 weeks.', + 'published_at' => fn () => now(), + 'code' => 'BDJACK', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ], + 'defaults' => ['price_amount' => 9999, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_g' => 750, 'requires_shipping' => true], + 'inventory' => ['quantity_on_hand' => 0, 'policy' => InventoryPolicy::Continue], + ], + [ + 'title' => 'Gift Card', + 'handle' => 'gift-card', + 'status' => ProductStatus::Active, + 'vendor' => 'Acme Fashion', + 'product_type' => 'Gift Cards', + 'tags' => ['popular'], + 'description' => 'Digital gift card delivered via email. The perfect gift when you are not sure what to choose.', + 'published_at' => fn () => now(), + 'code' => 'GIFT', + 'options' => [ + ['name' => 'Amount', 'values' => ['25 EUR', '50 EUR', '100 EUR']], + ], + 'defaults' => ['price_amount' => 2500, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_g' => 0, 'requires_shipping' => false], + 'inventory' => $deny(9999), + 'variants' => [ + ['option_values' => ['25 EUR'], 'sku' => 'ACME-GIFT-25', 'price_amount' => 2500, 'weight_g' => 0, 'requires_shipping' => false, 'inventory' => $deny(9999)], + ['option_values' => ['50 EUR'], 'sku' => 'ACME-GIFT-50', 'price_amount' => 5000, 'weight_g' => 0, 'requires_shipping' => false, 'inventory' => $deny(9999)], + ['option_values' => ['100 EUR'], 'sku' => 'ACME-GIFT-100', 'price_amount' => 10000, 'weight_g' => 0, 'requires_shipping' => false, 'inventory' => $deny(9999)], + ], + ], + [ + 'title' => 'Cashmere Overcoat', + 'handle' => 'cashmere-overcoat', + 'status' => ProductStatus::Active, + 'vendor' => 'Acme Premium', + 'product_type' => 'Jackets', + 'tags' => ['limited', 'new'], + 'description' => 'Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.', + 'published_at' => fn () => now(), + 'code' => 'COVER', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L']], + ['name' => 'Color', 'values' => ['Camel', 'Charcoal']], + ], + 'defaults' => ['price_amount' => 49999, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_g' => 1200, 'requires_shipping' => true], + 'inventory' => $deny(3), + ], + ]; + } + + /** + * The 5 Acme Electronics products (spec 07 §3.10). + * + * @return list> + */ + private function electronicsDefinitions(): array + { + $deny = fn (int $quantity): array => ['quantity_on_hand' => $quantity, 'policy' => InventoryPolicy::Deny]; + + return [ + [ + 'title' => 'Pro Laptop 15', + 'handle' => 'pro-laptop-15', + 'status' => ProductStatus::Active, + 'vendor' => 'TechCorp', + 'product_type' => 'Laptops', + 'tags' => ['new', 'popular'], + 'description' => 'Professional 15-inch laptop with a fast processor and all-day battery life.', + 'published_at' => fn () => now(), + 'code' => 'LAP15', + 'options' => [ + ['name' => 'Storage', 'values' => ['256GB', '512GB', '1TB']], + ], + 'defaults' => ['price_amount' => 99999, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_g' => 1800, 'requires_shipping' => true], + 'inventory' => $deny(10), + 'variants' => [ + ['option_values' => ['256GB'], 'sku' => 'ACME-LAP15-256GB', 'price_amount' => 99999, 'inventory' => $deny(10)], + ['option_values' => ['512GB'], 'sku' => 'ACME-LAP15-512GB', 'price_amount' => 119999, 'inventory' => $deny(10)], + ['option_values' => ['1TB'], 'sku' => 'ACME-LAP15-1TB', 'price_amount' => 149999, 'inventory' => $deny(10)], + ], + ], + [ + 'title' => 'Wireless Headphones', + 'handle' => 'wireless-headphones', + 'status' => ProductStatus::Active, + 'vendor' => 'AudioMax', + 'product_type' => 'Audio', + 'tags' => ['popular'], + 'description' => 'Wireless over-ear headphones with active noise cancellation.', + 'published_at' => fn () => now(), + 'code' => 'WHEAD', + 'options' => [ + ['name' => 'Color', 'values' => ['Black', 'Silver']], + ], + 'defaults' => ['price_amount' => 14999, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_g' => 250, 'requires_shipping' => true], + 'inventory' => $deny(25), + ], + [ + 'title' => 'USB-C Cable 2m', + 'handle' => 'usb-c-cable-2m', + 'status' => ProductStatus::Active, + 'vendor' => 'CablePro', + 'product_type' => 'Cables', + 'tags' => [], + 'description' => 'Durable braided USB-C cable, 2 meters, fast charging and data transfer.', + 'published_at' => fn () => now(), + 'code' => 'USBC', + 'options' => [], + 'defaults' => [], + 'inventory' => $deny(200), + 'single_variant' => [ + 'sku' => 'ACME-USBC-2M', + 'price_amount' => 1299, + 'currency' => 'EUR', + 'weight_g' => 50, + 'requires_shipping' => true, + 'inventory' => $deny(200), + ], + ], + [ + 'title' => 'Mechanical Keyboard', + 'handle' => 'mechanical-keyboard', + 'status' => ProductStatus::Active, + 'vendor' => 'KeyTech', + 'product_type' => 'Peripherals', + 'tags' => ['trending'], + 'description' => 'Compact mechanical keyboard with hot-swappable switches.', + 'published_at' => fn () => now(), + 'code' => 'MKEYB', + 'options' => [ + ['name' => 'Switch Type', 'values' => ['Red', 'Blue', 'Brown']], + ], + 'defaults' => ['price_amount' => 12999, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_g' => 1100, 'requires_shipping' => true], + 'inventory' => $deny(15), + ], + [ + 'title' => 'Monitor Stand', + 'handle' => 'monitor-stand', + 'status' => ProductStatus::Active, + 'vendor' => 'DeskGear', + 'product_type' => 'Accessories', + 'tags' => ['sale'], + 'description' => 'Sturdy aluminum monitor stand with cable management.', + 'published_at' => fn () => now(), + 'code' => 'MSTAND', + 'options' => [], + 'defaults' => [], + 'inventory' => $deny(30), + 'single_variant' => [ + 'sku' => 'ACME-MSTAND', + 'price_amount' => 4999, + 'currency' => 'EUR', + 'weight_g' => 2500, + 'requires_shipping' => true, + 'inventory' => $deny(30), + ], + ], + ]; + } + + /** + * Build explicit cartesian variant overrides with a value => SKU-token map. + * + * @param list> $valueSets + * @param array $inventory + * @param array $skuTokens + * @return list> + */ + private function cartesianVariants(string $code, array $valueSets, array $inventory, array $skuTokens = []): array + { + $variants = []; + + foreach ($this->cartesian($valueSets) as $combo) { + $tokens = array_map( + fn (string $value): string => $skuTokens[$value] ?? trim(strtoupper((string) preg_replace('/[^A-Za-z0-9]+/', '-', $value)), '-'), + $combo, + ); + + $variants[] = [ + 'option_values' => $combo, + 'sku' => 'ACME-'.$code.'-'.implode('-', $tokens), + 'inventory' => $inventory, + ]; + } + + return $variants; + } +} diff --git a/database/seeders/SearchSettingsSeeder.php b/database/seeders/SearchSettingsSeeder.php new file mode 100644 index 00000000..cccd674b --- /dev/null +++ b/database/seeders/SearchSettingsSeeder.php @@ -0,0 +1,48 @@ + [ + 'synonyms_json' => [ + ['tee', 't-shirt', 'tshirt'], + ['pants', 'trousers', 'jeans'], + ['sneakers', 'trainers', 'shoes'], + ['hoodie', 'sweatshirt'], + ], + 'stop_words_json' => ['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'is'], + ], + 'acme-electronics' => [ + 'synonyms_json' => [ + ['laptop', 'notebook', 'computer'], + ['headphones', 'earphones', 'earbuds'], + ['cable', 'cord', 'wire'], + ], + 'stop_words_json' => ['the', 'a', 'an', 'and', 'or'], + ], + ]; + + foreach ($settingsByHandle as $handle => $settings) { + $store = Store::query()->where('handle', $handle)->firstOrFail(); + + SearchSettings::query()->updateOrCreate( + ['store_id' => $store->id], + $settings, + ); + } + }); + } +} diff --git a/database/seeders/ShippingSeeder.php b/database/seeders/ShippingSeeder.php new file mode 100644 index 00000000..bfd00f8d --- /dev/null +++ b/database/seeders/ShippingSeeder.php @@ -0,0 +1,65 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + $this->seedZone($fashion->id, 'Domestic', ['DE'], [ + ['Standard Shipping', 499], + ['Express Shipping', 999], + ]); + + $this->seedZone($fashion->id, 'EU', ['AT', 'FR', 'IT', 'ES', 'NL', 'BE', 'PL'], [ + ['EU Standard', 899], + ]); + + $this->seedZone($fashion->id, 'Rest of World', ['US', 'GB', 'CA', 'AU'], [ + ['International', 1499], + ]); + + $this->seedZone($electronics->id, 'Germany', ['DE'], [ + ['Standard', 0], + ]); + }); + } + + /** + * Create one zone with its flat rates. + * + * @param list $countries + * @param list $rates + */ + private function seedZone(int $storeId, string $name, array $countries, array $rates): void + { + $zone = ShippingZone::query()->updateOrCreate( + ['store_id' => $storeId, 'name' => $name], + ['countries_json' => $countries, 'regions_json' => []], + ); + + foreach ($rates as [$rateName, $amount]) { + $zone->rates()->updateOrCreate( + ['name' => $rateName], + [ + 'type' => ShippingRateType::Flat, + 'config_json' => ['amount' => $amount], + 'is_active' => true, + ], + ); + } + } +} diff --git a/database/seeders/StoreDomainSeeder.php b/database/seeders/StoreDomainSeeder.php new file mode 100644 index 00000000..890e691f --- /dev/null +++ b/database/seeders/StoreDomainSeeder.php @@ -0,0 +1,49 @@ +store('acme-fashion'); + $electronics = $this->store('acme-electronics'); + + $domains = [ + [$fashion->id, 'acme-fashion.test', StoreDomainType::Storefront, true], + [$fashion->id, 'admin.acme-fashion.test', StoreDomainType::Admin, false], + [$electronics->id, 'acme-electronics.test', StoreDomainType::Storefront, true], + ]; + + foreach ($domains as [$storeId, $hostname, $type, $isPrimary]) { + StoreDomain::query()->updateOrCreate( + ['hostname' => $hostname], + [ + 'store_id' => $storeId, + 'type' => $type, + 'is_primary' => $isPrimary, + 'tls_mode' => 'managed', + ], + ); + } + }); + } + + /** + * Look up a store seeded by StoreSeeder. + */ + private function store(string $handle): Store + { + return Store::query()->where('handle', $handle)->firstOrFail(); + } +} diff --git a/database/seeders/StoreSeeder.php b/database/seeders/StoreSeeder.php new file mode 100644 index 00000000..c7d2d99a --- /dev/null +++ b/database/seeders/StoreSeeder.php @@ -0,0 +1,38 @@ +where('name', 'Acme Corp') + ->firstOrFail(); + + foreach (['acme-fashion' => 'Acme Fashion', 'acme-electronics' => 'Acme Electronics'] as $handle => $name) { + Store::query()->updateOrCreate( + ['handle' => $handle], + [ + 'organization_id' => $organization->id, + 'name' => $name, + 'status' => StoreStatus::Active, + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ], + ); + } + }); + } +} diff --git a/database/seeders/StoreSettingsSeeder.php b/database/seeders/StoreSettingsSeeder.php new file mode 100644 index 00000000..bb8204d6 --- /dev/null +++ b/database/seeders/StoreSettingsSeeder.php @@ -0,0 +1,43 @@ + [ + 'store_name' => 'Acme Fashion', + 'contact_email' => 'hello@acme-fashion.test', + 'order_number_prefix' => '#', + 'order_number_start' => 1001, + ], + 'acme-electronics' => [ + 'store_name' => 'Acme Electronics', + 'contact_email' => 'hello@acme-electronics.test', + 'order_number_prefix' => '#', + 'order_number_start' => 5001, + ], + ]; + + foreach ($settingsByHandle as $handle => $settings) { + $store = Store::query()->where('handle', $handle)->firstOrFail(); + + StoreSettings::query()->updateOrCreate( + ['store_id' => $store->id], + ['settings_json' => $settings], + ); + } + }); + } +} diff --git a/database/seeders/StoreUserSeeder.php b/database/seeders/StoreUserSeeder.php new file mode 100644 index 00000000..1721fd50 --- /dev/null +++ b/database/seeders/StoreUserSeeder.php @@ -0,0 +1,41 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + $memberships = [ + ['admin@acme.test', $fashion->id, StoreUserRole::Owner], + ['staff@acme.test', $fashion->id, StoreUserRole::Staff], + ['support@acme.test', $fashion->id, StoreUserRole::Support], + ['manager@acme.test', $fashion->id, StoreUserRole::Admin], + ['admin2@acme.test', $electronics->id, StoreUserRole::Owner], + ]; + + foreach ($memberships as [$email, $storeId, $role]) { + $user = User::query()->where('email', $email)->firstOrFail(); + + StoreUser::query()->updateOrCreate( + ['store_id' => $storeId, 'user_id' => $user->id], + ['role' => $role], + ); + } + }); + } +} diff --git a/database/seeders/TaxSettingsSeeder.php b/database/seeders/TaxSettingsSeeder.php new file mode 100644 index 00000000..8f0e6b1d --- /dev/null +++ b/database/seeders/TaxSettingsSeeder.php @@ -0,0 +1,34 @@ +whereIn('handle', ['acme-fashion', 'acme-electronics'])->get(); + + foreach ($stores as $store) { + TaxSettings::query()->updateOrCreate( + ['store_id' => $store->id], + [ + 'mode' => TaxMode::Manual, + 'provider' => 'none', + 'prices_include_tax' => true, + 'config_json' => ['default_rate_bps' => 1900], + ], + ); + } + }); + } +} diff --git a/database/seeders/ThemeSeeder.php b/database/seeders/ThemeSeeder.php new file mode 100644 index 00000000..2e3b7b3c --- /dev/null +++ b/database/seeders/ThemeSeeder.php @@ -0,0 +1,89 @@ + [ + 'announcement' => [ + 'enabled' => true, + 'text' => 'Free shipping on orders over 50 EUR - Use code FREESHIP', + 'link' => null, + ], + 'colors' => [ + 'primary' => '#1a1a2e', + 'secondary' => '#e94560', + ], + 'hero' => [ + 'enabled' => true, + 'heading' => 'Welcome to Acme Fashion', + 'subheading' => 'Discover our curated collection of modern essentials', + 'cta_label' => 'Shop New Arrivals', + 'cta_url' => '/collections/new-arrivals', + ], + 'featured_collections' => [ + 'enabled' => true, + 'count' => 3, + 'collection_handles' => ['new-arrivals', 't-shirts', 'sale'], + ], + 'footer' => [ + 'about' => '2025 Acme Fashion. All rights reserved.', + ], + ], + 'acme-electronics' => [ + 'colors' => [ + 'primary' => '#0f172a', + 'secondary' => '#3b82f6', + ], + 'hero' => [ + 'enabled' => true, + 'heading' => 'Acme Electronics', + 'subheading' => 'Premium tech for professionals', + 'cta_label' => 'Shop Featured', + 'cta_url' => '/collections/featured', + ], + 'featured_collections' => [ + 'enabled' => true, + 'count' => 1, + 'collection_handles' => ['featured'], + ], + 'footer' => [ + 'about' => '2025 Acme Electronics. All rights reserved.', + ], + ], + ]; + + foreach ($themesByHandle as $handle => $settings) { + $store = Store::query()->where('handle', $handle)->firstOrFail(); + + $theme = Theme::query()->updateOrCreate( + ['store_id' => $store->id, 'name' => 'Default Theme'], + [ + 'version' => '1.0.0', + 'status' => ThemeStatus::Published, + 'published_at' => now(), + ], + ); + + $theme->settings()->updateOrCreate( + ['theme_id' => $theme->id], + ['settings_json' => $settings], + ); + } + }); + } +} diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php new file mode 100644 index 00000000..67940d14 --- /dev/null +++ b/database/seeders/UserSeeder.php @@ -0,0 +1,43 @@ +subDays(2)], + ['support@acme.test', 'Support User', now()->subDay()], + ['manager@acme.test', 'Store Manager', now()->subDay()], + ['admin2@acme.test', 'Admin Two', now()->subDay()], + ]; + + foreach ($users as [$email, $name, $lastLoginAt]) { + // forceFill: email_verified_at/last_login_at are not mass assignable. + User::query()->firstOrNew(['email' => $email]) + ->forceFill([ + 'name' => $name, + 'password_hash' => $passwordHash, + 'status' => 'active', + 'email_verified_at' => now(), + 'last_login_at' => $lastLoginAt, + ]) + ->save(); + } + }); + } +} diff --git a/opencode.json b/opencode.json new file mode 100644 index 00000000..53e16f3d --- /dev/null +++ b/opencode.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "laravel-boost": { + "type": "local", + "enabled": true, + "command": [ + "php", + "artisan", + "boost:mcp" + ] + } + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b558d2d8..b505ae3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,9 @@ "tailwindcss": "^4.0.7", "vite": "^7.0.4" }, + "devDependencies": { + "playwright": "^1.62.0" + }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.9.5", "@tailwindcss/oxide-linux-x64-gnu": "^4.0.1", @@ -2056,6 +2059,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/package.json b/package.json index 688bea86..0c6e8d56 100644 --- a/package.json +++ b/package.json @@ -19,5 +19,8 @@ "@rollup/rollup-linux-x64-gnu": "4.9.5", "@tailwindcss/oxide-linux-x64-gnu": "^4.0.1", "lightningcss-linux-x64-gnu": "^1.29.1" + }, + "devDependencies": { + "playwright": "^1.62.0" } } diff --git a/phpunit.xml b/phpunit.xml index d7032415..c41d83b2 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,6 +18,7 @@ + @@ -27,7 +28,7 @@ - + diff --git a/resources/css/app.css b/resources/css/app.css index ad6eeedc..6606ddbe 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -64,3 +64,45 @@ select:focus[data-flux-control] { /* \[:where(&)\]:size-4 { @apply size-4; } */ + +[x-cloak] { + display: none !important; +} + +/* Lightweight rich-text styling for sanitized CMS/theme HTML. */ +.storefront-prose > * + * { + margin-top: 1rem; +} + +.storefront-prose h1, +.storefront-prose h2, +.storefront-prose h3, +.storefront-prose h4 { + font-weight: 700; + line-height: 1.25; +} + +.storefront-prose h1 { font-size: 1.875rem; } +.storefront-prose h2 { font-size: 1.5rem; } +.storefront-prose h3 { font-size: 1.25rem; } + +.storefront-prose ul { + list-style: disc; + padding-left: 1.5rem; +} + +.storefront-prose ol { + list-style: decimal; + padding-left: 1.5rem; +} + +.storefront-prose a { + text-decoration: underline; + text-underline-offset: 2px; +} + +.storefront-prose blockquote { + border-left: 4px solid currentColor; + padding-left: 1rem; + opacity: 0.85; +} diff --git a/resources/views/admin/auth/verify-email.blade.php b/resources/views/admin/auth/verify-email.blade.php new file mode 100644 index 00000000..0b4b6a6c --- /dev/null +++ b/resources/views/admin/auth/verify-email.blade.php @@ -0,0 +1,27 @@ + + Verify your email + + + Verify your email + + Before getting started, please verify your email address by clicking the link we just emailed to you. + If you did not receive the email, you can request another one below. + + + @if (session('status') === 'verification-link-sent') + + A new verification link has been sent to your email address. + + @endif + +
+ @csrf + Resend verification email +
+ +
+ @csrf + Log out +
+
+
diff --git a/resources/views/admin/layouts/app.blade.php b/resources/views/admin/layouts/app.blade.php new file mode 100644 index 00000000..30b245ef --- /dev/null +++ b/resources/views/admin/layouts/app.blade.php @@ -0,0 +1,92 @@ + + + + + + + {{ $title ?? config('app.name') }} + @vite(['resources/css/app.css', 'resources/js/app.js']) + + @fluxAppearance + + + + Skip to main content + + +
+ {{-- Mobile sidebar backdrop --}} + + + {{-- Sidebar: fixed 256px on desktop, slide-over on mobile (spec 03 §1.2) --}} + + +
+ + +
+ + + {{ $slot }} +
+
+
+ + {{-- Toast notifications (spec 03 §1.5, §20) --}} +
+ +
+ + @fluxScripts + + diff --git a/resources/views/admin/layouts/auth.blade.php b/resources/views/admin/layouts/auth.blade.php new file mode 100644 index 00000000..eccaa205 --- /dev/null +++ b/resources/views/admin/layouts/auth.blade.php @@ -0,0 +1,25 @@ + + + + + + + {{ $title ?? config('app.name') }} + @vite(['resources/css/app.css', 'resources/js/app.js']) + @fluxAppearance + + + + Skip to main content + + +
+
+ {{ $slot }} +
+
+ + @fluxScripts + + diff --git a/resources/views/components/action-message.blade.php b/resources/views/components/action-message.blade.php deleted file mode 100644 index d313ee61..00000000 --- a/resources/views/components/action-message.blade.php +++ /dev/null @@ -1,14 +0,0 @@ -@props([ - 'on', -]) - -
merge(['class' => 'text-sm']) }} -> - {{ $slot->isEmpty() ? __('Saved.') : $slot }} -
diff --git a/resources/views/components/app-logo-icon.blade.php b/resources/views/components/app-logo-icon.blade.php deleted file mode 100644 index 0adc3a2a..00000000 --- a/resources/views/components/app-logo-icon.blade.php +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/resources/views/components/app-logo.blade.php b/resources/views/components/app-logo.blade.php deleted file mode 100644 index 26e8f686..00000000 --- a/resources/views/components/app-logo.blade.php +++ /dev/null @@ -1,17 +0,0 @@ -@props([ - 'sidebar' => false, -]) - -@if($sidebar) - - - - - -@else - - - - - -@endif diff --git a/resources/views/components/auth-header.blade.php b/resources/views/components/auth-header.blade.php deleted file mode 100644 index e596a3f3..00000000 --- a/resources/views/components/auth-header.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -@props([ - 'title', - 'description', -]) - -
- {{ $title }} - {{ $description }} -
diff --git a/resources/views/components/auth-session-status.blade.php b/resources/views/components/auth-session-status.blade.php deleted file mode 100644 index 98e00112..00000000 --- a/resources/views/components/auth-session-status.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -@props([ - 'status', -]) - -@if ($status) -
merge(['class' => 'font-medium text-sm text-green-600']) }}> - {{ $status }} -
-@endif diff --git a/resources/views/components/desktop-user-menu.blade.php b/resources/views/components/desktop-user-menu.blade.php deleted file mode 100644 index 5b386c5c..00000000 --- a/resources/views/components/desktop-user-menu.blade.php +++ /dev/null @@ -1,39 +0,0 @@ - - only('name') }} - :initials="auth()->user()->initials()" - icon:trailing="chevrons-up-down" - data-test="sidebar-menu-button" - /> - - -
- -
- {{ auth()->user()->name }} - {{ auth()->user()->email }} -
-
- - - - {{ __('Settings') }} - -
- @csrf - - {{ __('Log Out') }} - -
-
-
-
diff --git a/resources/views/components/placeholder-pattern.blade.php b/resources/views/components/placeholder-pattern.blade.php deleted file mode 100644 index 8a434f04..00000000 --- a/resources/views/components/placeholder-pattern.blade.php +++ /dev/null @@ -1,12 +0,0 @@ -@props([ - 'id' => uniqid(), -]) - - - - - - - - - diff --git a/resources/views/components/settings/layout.blade.php b/resources/views/components/settings/layout.blade.php deleted file mode 100644 index 17c7a0a8..00000000 --- a/resources/views/components/settings/layout.blade.php +++ /dev/null @@ -1,23 +0,0 @@ -
-
- - {{ __('Profile') }} - {{ __('Password') }} - @if (Laravel\Fortify\Features::canManageTwoFactorAuthentication()) - {{ __('Two-Factor Auth') }} - @endif - {{ __('Appearance') }} - -
- - - -
- {{ $heading ?? '' }} - {{ $subheading ?? '' }} - -
- {{ $slot }} -
-
-
diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php deleted file mode 100644 index 8f08c05d..00000000 --- a/resources/views/dashboard.blade.php +++ /dev/null @@ -1,18 +0,0 @@ - -
-
-
- -
-
- -
-
- -
-
-
- -
-
-
diff --git a/resources/views/emails/orders/cancelled.blade.php b/resources/views/emails/orders/cancelled.blade.php new file mode 100644 index 00000000..5f017787 --- /dev/null +++ b/resources/views/emails/orders/cancelled.blade.php @@ -0,0 +1,26 @@ + +# Your order has been cancelled + +Your order **{{ $order->order_number }}** at **{{ $order->store->name }}** has been cancelled. + +@if ($reason) + +**Reason:** {{ $reason }} + +@endif + +@if ($order->lines->isNotEmpty()) + +| Item | Qty | +|:-----|----:| +@foreach ($order->lines as $line) +| {{ $line->title_snapshot }} | {{ $line->quantity }} | +@endforeach + +@endif + +If you did not request this cancellation or have questions, please contact us. + +Thanks,
+{{ $order->store->name }} +
diff --git a/resources/views/emails/orders/confirmation.blade.php b/resources/views/emails/orders/confirmation.blade.php new file mode 100644 index 00000000..fbd11dab --- /dev/null +++ b/resources/views/emails/orders/confirmation.blade.php @@ -0,0 +1,62 @@ + +# Thank you for your order! + +Hi from **{{ $order->store->name }}** — your order **{{ $order->order_number }}** has been received. + + +| Item | Qty | Total | +|:-----|----:|------:| +@foreach ($order->lines as $line) +| {{ $line->title_snapshot }}@if ($line->sku_snapshot) ({{ $line->sku_snapshot }})@endif | {{ $line->quantity }} | {{ \App\Support\Money::format($line->total_amount, $order->currency) }} | +@endforeach + + + +| Subtotal | {{ \App\Support\Money::format($order->subtotal_amount, $order->currency) }} | +|:---------|-----:| +@if ($order->discount_amount > 0) +| Discount | -{{ \App\Support\Money::format($order->discount_amount, $order->currency) }} | +@endif +| Shipping | {{ \App\Support\Money::format($order->shipping_amount, $order->currency) }} | +| Tax | {{ \App\Support\Money::format($order->tax_amount, $order->currency) }} | +| **Total** | **{{ \App\Support\Money::format($order->total_amount, $order->currency) }}** | + + +@if ($order->payment_method === \App\Enums\PaymentMethod::BankTransfer) + +**Bank Transfer Instructions** + +Please transfer **{{ \App\Support\Money::format($order->total_amount, $order->currency) }}** to: + +Bank: Mock Bank AG +IBAN: DE89 3704 0044 0532 0130 00 +BIC: COBADEFFXXX +Reference: {{ $order->order_number }} + +Your order will be processed once payment is confirmed. + +@endif + +@php($shipping = $order->shipping_address_json ?? []) +@if ($shipping !== []) +**Shipping address** + +{{ ($shipping['first_name'] ?? '').' '.($shipping['last_name'] ?? '') }} +{{ $shipping['address1'] ?? '' }}@if (! empty($shipping['address2'])), {{ $shipping['address2'] }}@endif +{{ ($shipping['postal_code'] ?? '').' '.($shipping['city'] ?? '') }} +{{ $shipping['country_code'] ?? $shipping['country'] ?? '' }} +@endif + +@php($billing = $order->billing_address_json ?? []) +@if ($billing !== []) +**Billing address** + +{{ ($billing['first_name'] ?? '').' '.($billing['last_name'] ?? '') }} +{{ $billing['address1'] ?? '' }}@if (! empty($billing['address2'])), {{ $billing['address2'] }}@endif +{{ ($billing['postal_code'] ?? '').' '.($billing['city'] ?? '') }} +{{ $billing['country_code'] ?? $billing['country'] ?? '' }} +@endif + +Thanks,
+{{ $order->store->name }} +
diff --git a/resources/views/emails/orders/refunded.blade.php b/resources/views/emails/orders/refunded.blade.php new file mode 100644 index 00000000..85956b83 --- /dev/null +++ b/resources/views/emails/orders/refunded.blade.php @@ -0,0 +1,16 @@ + +# Your refund has been processed + +We've issued a refund of **{{ \App\Support\Money::format($refund->amount, $order->currency) }}** for your order **{{ $order->order_number }}** at **{{ $order->store->name }}**. + +@if ($refund->reason) + +**Reason:** {{ $refund->reason }} + +@endif + +The amount will be credited back to your original payment method. Depending on your bank, this may take a few business days. + +Thanks,
+{{ $order->store->name }} +
diff --git a/resources/views/emails/orders/shipped.blade.php b/resources/views/emails/orders/shipped.blade.php new file mode 100644 index 00000000..e22a8640 --- /dev/null +++ b/resources/views/emails/orders/shipped.blade.php @@ -0,0 +1,37 @@ + +# Your order has shipped + +Good news from **{{ $order->store->name }}** — your order **{{ $order->order_number }}** is on its way. + +@if ($fulfillment->tracking_company || $fulfillment->tracking_number) + +**Tracking information** + +@if ($fulfillment->tracking_company) +Carrier: {{ $fulfillment->tracking_company }} +@endif +@if ($fulfillment->tracking_number) +Tracking number: {{ $fulfillment->tracking_number }} +@endif + +@endif + +@if ($fulfillment->tracking_url) + +Track your shipment + +@endif + +**Items in this shipment** + + +| Item | Qty | +|:-----|----:| +@foreach ($fulfillment->lines as $line) +| {{ $line->orderLine?->title_snapshot ?? 'Item' }} | {{ $line->quantity }} | +@endforeach + + +Thanks,
+{{ $order->store->name }} +
diff --git a/resources/views/errors/403.blade.php b/resources/views/errors/403.blade.php new file mode 100644 index 00000000..e3da298e --- /dev/null +++ b/resources/views/errors/403.blade.php @@ -0,0 +1,18 @@ +@extends('errors.layout', ['title' => 'Access denied']) + +@section('content') +
+ + +
+

Access denied

+

+ {{ $exception->getMessage() !== '' ? $exception->getMessage() : "You don't have permission to access this page." }} +

+ + Go to home page + +
+
+@endsection diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php new file mode 100644 index 00000000..8297c34b --- /dev/null +++ b/resources/views/errors/404.blade.php @@ -0,0 +1,30 @@ +@extends('errors.layout', ['title' => 'Page not found']) + +@section('content') +
+ {{-- Oversized muted status code as a background element (spec 04 §13.1) --}} + + +
+

Page not found

+

+ The page you're looking for doesn't exist or has been moved. +

+ + + + + Go to home page + +
+
+@endsection diff --git a/resources/views/errors/500.blade.php b/resources/views/errors/500.blade.php new file mode 100644 index 00000000..64c306ed --- /dev/null +++ b/resources/views/errors/500.blade.php @@ -0,0 +1,18 @@ +@extends('errors.layout', ['title' => 'Something went wrong']) + +@section('content') +
+ + +
+

Something went wrong

+

+ We're experiencing technical difficulties. Please try again in a moment. +

+ + Go to home page + +
+
+@endsection diff --git a/resources/views/errors/503.blade.php b/resources/views/errors/503.blade.php new file mode 100644 index 00000000..2b54f15a --- /dev/null +++ b/resources/views/errors/503.blade.php @@ -0,0 +1,23 @@ +@extends('errors.layout', ['title' => "We'll be back soon"]) + +@section('content') + @php + // Suspended stores abort with a specific message; real maintenance + // mode arrives with the generic "Service Unavailable" text. + $message = $exception->getMessage(); + if ($message === '' || $message === 'Service Unavailable') { + $message = "We're currently performing maintenance. Please check back shortly."; + } + @endphp + +
+ + +

We'll be back soon

+

+ {{ $message }} +

+
+@endsection diff --git a/resources/views/errors/layout.blade.php b/resources/views/errors/layout.blade.php new file mode 100644 index 00000000..7ed5e6f7 --- /dev/null +++ b/resources/views/errors/layout.blade.php @@ -0,0 +1,70 @@ +@php + /** + * Shared layout for storefront-family error pages (spec 04 §13). + * + * The store context is not guaranteed here: 404s fire for unknown + * domains and 503s for suspended stores before the tenant middleware + * shares `currentStore`, and 500s may be caused by the database being + * down. Branding is therefore resolved defensively from the hostname + * and every failure falls back to the platform name. + */ + $errorStore = null; + + try { + $errorDomain = \App\Models\StoreDomain::query() + ->where('hostname', request()->getHost()) + ->first(); + $errorStore = $errorDomain?->store; + } catch (\Throwable) { + $errorStore = null; + } + + $brandName = $errorStore?->name ?? config('app.name'); + $errorTitle = $title ?? 'Error'; +@endphp + + + + + + + {{ $errorTitle }} - {{ $brandName }} + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + + + Skip to main content + + +
+ +
+ +
+ @yield('content') +
+ + + + diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php deleted file mode 100644 index 037dd1bd..00000000 --- a/resources/views/layouts/app.blade.php +++ /dev/null @@ -1,5 +0,0 @@ - - - {{ $slot }} - - diff --git a/resources/views/layouts/app/header.blade.php b/resources/views/layouts/app/header.blade.php deleted file mode 100644 index e1f84d92..00000000 --- a/resources/views/layouts/app/header.blade.php +++ /dev/null @@ -1,78 +0,0 @@ - - - - @include('partials.head') - - - - - - - - - - {{ __('Dashboard') }} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{ __('Dashboard') }} - - - - - - - - - {{ __('Repository') }} - - - {{ __('Documentation') }} - - - - - {{ $slot }} - - @fluxScripts - - diff --git a/resources/views/layouts/app/sidebar.blade.php b/resources/views/layouts/app/sidebar.blade.php deleted file mode 100644 index ea25506b..00000000 --- a/resources/views/layouts/app/sidebar.blade.php +++ /dev/null @@ -1,95 +0,0 @@ - - - - @include('partials.head') - - - - - - - - - - - - {{ __('Dashboard') }} - - - - - - - - - {{ __('Repository') }} - - - - {{ __('Documentation') }} - - - - - - - - - - - - - - - - -
-
- - -
- {{ auth()->user()->name }} - {{ auth()->user()->email }} -
-
-
-
- - - - - - {{ __('Settings') }} - - - - - -
- @csrf - - {{ __('Log Out') }} - -
-
-
-
- - {{ $slot }} - - @fluxScripts - - diff --git a/resources/views/layouts/auth.blade.php b/resources/views/layouts/auth.blade.php deleted file mode 100644 index 71500919..00000000 --- a/resources/views/layouts/auth.blade.php +++ /dev/null @@ -1,3 +0,0 @@ - - {{ $slot }} - diff --git a/resources/views/layouts/auth/card.blade.php b/resources/views/layouts/auth/card.blade.php deleted file mode 100644 index db947161..00000000 --- a/resources/views/layouts/auth/card.blade.php +++ /dev/null @@ -1,26 +0,0 @@ - - - - @include('partials.head') - - - - @fluxScripts - - diff --git a/resources/views/layouts/auth/simple.blade.php b/resources/views/layouts/auth/simple.blade.php deleted file mode 100644 index 6e0d9093..00000000 --- a/resources/views/layouts/auth/simple.blade.php +++ /dev/null @@ -1,22 +0,0 @@ - - - - @include('partials.head') - - - - @fluxScripts - - diff --git a/resources/views/layouts/auth/split.blade.php b/resources/views/layouts/auth/split.blade.php deleted file mode 100644 index 4e9788bd..00000000 --- a/resources/views/layouts/auth/split.blade.php +++ /dev/null @@ -1,43 +0,0 @@ - - - - @include('partials.head') - - -
- - -
- @fluxScripts - - diff --git a/resources/views/livewire/admin/analytics/index.blade.php b/resources/views/livewire/admin/analytics/index.blade.php new file mode 100644 index 00000000..9c054d3f --- /dev/null +++ b/resources/views/livewire/admin/analytics/index.blade.php @@ -0,0 +1,178 @@ +@php + /** @var \App\Support\Money $money */ + $money = \App\Support\Money::class; + + $tiles = [ + ['label' => 'Total Sales', 'value' => $money::format($totalSales, $currency)], + ['label' => 'Orders', 'value' => number_format($ordersCount)], + ['label' => 'Avg. Order Value', 'value' => $money::format($averageOrderValue, $currency)], + ['label' => 'Conversion Rate', 'value' => $conversionRate === null ? '—' : $conversionRate.'%'], + ]; +@endphp + +
+
+ Analytics + + + Last 7 days + Last 30 days + Last 90 days + +
+ + {{-- KPI tiles --}} +
+ @foreach ($tiles as $tile) +
+ {{ $tile['label'] }} + {{ $tile['value'] }} +
+ @endforeach +
+ + {{-- Sales chart --}} +
+ Sales over time + +
+ @if (array_sum(array_column($salesChart['days'], 'value')) > 0) + + + + +
+ {{ $salesChart['days'][0]['date'] ?? '' }} + {{ $salesChart['days'][array_key_last($salesChart['days'])]['date'] ?? '' }} +
+ @else +

No sales in this period.

+ @endif +
+
+ +
+ {{-- Traffic chart --}} +
+ Visits over time + +
+ @if (array_sum(array_column($trafficChart['days'], 'value')) > 0) + + + + +
+ {{ $trafficChart['days'][0]['date'] ?? '' }} + {{ $trafficChart['days'][array_key_last($trafficChart['days'])]['date'] ?? '' }} +
+ @else +

No visits in this period.

+ @endif +
+
+ + {{-- Conversion funnel --}} +
+ Conversion funnel + +
+ @foreach ($funnel as $step) +
+
+ {{ $step['label'] }} + + {{ number_format($step['count']) }} + @if ($step['percent'] !== null) + ({{ $step['percent'] }}%) + @endif + +
+ +
+ @endforeach +
+
+
+ + {{-- Top products --}} +
+ Top products + + @if ($topProducts->isEmpty()) +

No product sales in this period.

+ @else +
+ + + + + + + + + + + + @foreach ($topProducts as $product) + + + + + + + + @endforeach + +
RankProductUnits SoldRevenue% of Total
{{ $loop->iteration }}{{ $product->title }}{{ number_format((int) $product->units) }}{{ $money::format((int) $product->revenue, $currency) }} + {{ $topProductsRevenue > 0 ? round($product->revenue / $topProductsRevenue * 100, 1) : 0 }}% +
+
+ @endif +
+ + {{-- Recent search queries --}} +
+ Recent search queries + + @if ($recentSearches->isEmpty()) +

No search queries yet.

+ @else +
+ + + + + + + + + + @foreach ($recentSearches as $search) + + + + + + @endforeach + +
QueryResultsDate
{{ $search->query }}{{ number_format($search->results_count) }}{{ $search->created_at?->format('M j, Y') }}
+
+ @endif +
+
diff --git a/resources/views/livewire/admin/apps/index.blade.php b/resources/views/livewire/admin/apps/index.blade.php new file mode 100644 index 00000000..02dab413 --- /dev/null +++ b/resources/views/livewire/admin/apps/index.blade.php @@ -0,0 +1,55 @@ +
+ Apps + + {{-- Installed apps (spec 03 §15) --}} +
+ @if ($installedApps->isEmpty()) +
+ No apps installed + Installed apps will appear here. Install one from the catalog below to extend your store. +
+ @else + @foreach ($installedApps as $installation) + +
+ +
+
+ {{ $installation->app?->name }} + Installed {{ $installation->installed_at?->diffForHumans() ?? 'recently' }} +
+ Active +
+ @endforeach + @endif +
+ + + + {{-- Available apps --}} +
+
+ Available apps + Apps that can be installed on this store. +
+ + @if ($availableApps->isEmpty()) + All catalog apps are installed. + @else + @foreach ($availableApps as $entry) +
+
+ +
+
+ {{ $entry['name'] }} + {{ $entry['description'] }} +
+ Install +
+ @endforeach + @endif +
+
diff --git a/resources/views/livewire/admin/apps/show.blade.php b/resources/views/livewire/admin/apps/show.blade.php new file mode 100644 index 00000000..276ab5bb --- /dev/null +++ b/resources/views/livewire/admin/apps/show.blade.php @@ -0,0 +1,113 @@ +
+
+
+
+ +
+
+ {{ $installation->app?->name }} + Installed {{ $installation->installed_at?->diffForHumans() ?? 'recently' }} +
+
+
+ @if ($installation->status === 'active') + Active + @else + Inactive + @endif + @if ($installation->status === 'active') + Uninstall + @endif +
+
+ + {{-- Scopes granted (spec 03 §15) --}} +
+ Scopes granted +
+ @forelse ($installation->scopes_json ?? [] as $scope) + {{ $scope }} + @empty + No scopes granted. + @endforelse +
+
+ + {{-- Webhook subscriptions (spec 03 §15) --}} +
+ Webhook subscriptions + + @if ($webhooks->isEmpty()) + This app has no webhook subscriptions. + @else +
+ + + + + + + + + + @foreach ($webhooks as $webhook) + + + + + + @endforeach + +
Event typeURLStatus
{{ $webhook->event_type }}{{ $webhook->target_url }} + @if ($webhook->status === \App\Enums\WebhookSubscriptionStatus::Active) + Active + @elseif ($webhook->status === \App\Enums\WebhookSubscriptionStatus::Paused) + Paused + @else + Disabled + @endif +
+
+ @endif +
+ + {{-- Recent deliveries --}} +
+ Recent deliveries + + @if ($deliveries->isEmpty()) + No webhook deliveries recorded yet. + @else +
+ + + + + + + + + + + @foreach ($deliveries as $delivery) + + + + + + + @endforeach + +
StatusAttemptsResponseLast attempt
+ @if ($delivery->status === \App\Enums\WebhookDeliveryStatus::Success) + Success + @elseif ($delivery->status === \App\Enums\WebhookDeliveryStatus::Failed) + Failed + @else + Pending + @endif + {{ $delivery->attempt_count }}{{ $delivery->response_code ?? '—' }}{{ $delivery->last_attempt_at?->toDayDateTimeString() ?? '—' }}
+
+ @endif +
+
diff --git a/resources/views/livewire/admin/auth/forgot-password.blade.php b/resources/views/livewire/admin/auth/forgot-password.blade.php new file mode 100644 index 00000000..730f3b4d --- /dev/null +++ b/resources/views/livewire/admin/auth/forgot-password.blade.php @@ -0,0 +1,26 @@ + +
+ Forgot password + Enter your email and we will send you a reset link. +
+ + @if ($linkSent) + + If that email exists, we sent a reset link. + + @endif + +
+ + Email + + + + + Send reset link +
+ +
+ Back to log in +
+
diff --git a/resources/views/livewire/admin/auth/login.blade.php b/resources/views/livewire/admin/auth/login.blade.php new file mode 100644 index 00000000..420ab239 --- /dev/null +++ b/resources/views/livewire/admin/auth/login.blade.php @@ -0,0 +1,39 @@ + +
+ Log in + Sign in to your admin account +
+ + @if (session('status')) + + {{ session('status') }} + + @endif + + @if ($errorMessage) + + {{ $errorMessage }} + + @endif + +
+ + Email + + + + + + Password + + + + +
+ + Forgot password? +
+ + Log in +
+
diff --git a/resources/views/livewire/admin/auth/reset-password.blade.php b/resources/views/livewire/admin/auth/reset-password.blade.php new file mode 100644 index 00000000..ef054180 --- /dev/null +++ b/resources/views/livewire/admin/auth/reset-password.blade.php @@ -0,0 +1,33 @@ + +
+ Reset password + Choose a new password for your account. +
+ + @if ($errorMessage) + + {{ $errorMessage }} + + @endif + +
+ + Email + + + + + + New password + + + + + + Confirm password + + + + Reset password +
+
diff --git a/resources/views/livewire/admin/collections/form.blade.php b/resources/views/livewire/admin/collections/form.blade.php new file mode 100644 index 00000000..6fa9f5c1 --- /dev/null +++ b/resources/views/livewire/admin/collections/form.blade.php @@ -0,0 +1,108 @@ +
+ {{ $this->isEditing() ? $collection->title : 'Add collection' }} + +
+ {{-- Left column (2/3): primary content (spec 03 §5.2) --}} +
+
+ + Title + + + + + + Handle + + + + + + Description + + + + + + Type + + Manual + Automated + + + + + @if ($type === 'automated') + + Automated collections store the type only for now — rule-based assignment is not implemented yet. Assign products manually below. + + @endif +
+ + {{-- Products assignment (spec 03 §5.2) --}} +
+ Products + + + Search products + + + + @if ($searchResults->isNotEmpty()) +
    + @foreach ($searchResults as $result) +
  • + {{ $result->title }} + Add +
  • + @endforeach +
+ @endif + + @if ($assignedProducts->isNotEmpty()) +
    + @foreach ($assignedProducts as $index => $product) +
  • + {{ $product->title }} + {{-- Reorder via buttons instead of drag-and-drop (spec 03 §5.2 note) --}} + + + +
  • + @endforeach +
+ @else + No products assigned yet. Search above to add products. + @endif + + +
+
+ + {{-- Right column (1/3): status --}} +
+
+ + Status + + Draft + Active + Archived + + + +
+
+
+ + {{-- Sticky save bar (spec 03 §19.2) --}} +
+
+ Discard + + Save + Saving... + +
+
+
diff --git a/resources/views/livewire/admin/collections/index.blade.php b/resources/views/livewire/admin/collections/index.blade.php new file mode 100644 index 00000000..fb986c02 --- /dev/null +++ b/resources/views/livewire/admin/collections/index.blade.php @@ -0,0 +1,105 @@ +
+
+ Collections + + @can('create', \App\Models\Collection::class) + Add collection + @endcan +
+ + @if (! $hasCollections) +
+ + Create your first collection + Group products into collections to organize your catalog. + @can('create', \App\Models\Collection::class) + Add collection + @endcan +
+ @else +
+ + + + All statuses + Draft + Active + Archived + +
+ +
+ + + + + + + + + + + + + @forelse ($collections as $collection) + + + + + + + + + @empty + + + + @endforelse + +
TitleTypeProductsStatusUpdatedActions
+ @can('update', $collection) + + {{ $collection->title }} + + @else + {{ $collection->title }} + @endcan + + + {{ ucfirst($collection->type->value) }} + + {{ $collection->products_count }} + {{ ucfirst($collection->status->value) }} + {{ $collection->updated_at->diffForHumans() }} +
+ @can('update', $collection) + + @endcan + @can('delete', $collection) + + @endcan +
+
+ No collections match your filters. +
+
+ + {{ $collections->links() }} + @endif + + {{-- Delete confirmation modal (spec 03 §19.3) --}} + +
+ Delete this collection? + The collection will be permanently removed. Products in it are not deleted. +
+ Cancel + Delete +
+
+
+
diff --git a/resources/views/livewire/admin/customers/index.blade.php b/resources/views/livewire/admin/customers/index.blade.php new file mode 100644 index 00000000..50142d02 --- /dev/null +++ b/resources/views/livewire/admin/customers/index.blade.php @@ -0,0 +1,58 @@ +
+
+ Customers +
+ + @if (! $hasCustomers) +
+ + No customers yet + Customers who register or check out will appear here. +
+ @else + + +
+ + + + + + + + + + + + + @forelse ($customers as $customer) + + + + + + + + + @empty + + + + @endforelse + +
NameEmailOrdersTotal spentMarketingCreated
+ + {{ $customer->name ?? '—' }} + + {{ $customer->email }}{{ $customer->orders_count }}{{ \App\Support\Money::format((int) $customer->orders_sum_total_amount, $currency) }} + + {{ $customer->marketing_opt_in ? 'Opted in' : 'Opted out' }} + + {{ $customer->created_at?->format('M j, Y') }}
+ No customers match your search. +
+
+ + {{ $customers->links() }} + @endif +
diff --git a/resources/views/livewire/admin/customers/show.blade.php b/resources/views/livewire/admin/customers/show.blade.php new file mode 100644 index 00000000..96b468c7 --- /dev/null +++ b/resources/views/livewire/admin/customers/show.blade.php @@ -0,0 +1,163 @@ +
+
+ {{ $customer->name ?? $customer->email }} + + @can('update', $customer) + Edit customer + @endcan +
+ +
+ {{-- Left column --}} +
+ {{-- Customer info card --}} +
+ Customer info + +
+
+
Name
+
{{ $customer->name ?? '—' }}
+
+
+
Email
+
{{ $customer->email }}
+
+
+
Member since
+
{{ $customer->created_at?->format('M j, Y') }}
+
+
+
Marketing
+
+ + {{ $customer->marketing_opt_in ? 'Opted in' : 'Opted out' }} + +
+
+
+ + {{-- Stats --}} +
+
+ Orders + {{ $ordersCount }} +
+
+ Total spent + {{ \App\Support\Money::format($totalSpent, $currency) }} +
+
+ Avg. order value + {{ \App\Support\Money::format($averageOrderValue, $currency) }} +
+
+
+ + {{-- Order history --}} +
+ Order history + +
+ + + + + + + + + + + + @forelse ($orders as $order) + + + + + + + + @empty + + + + @endforelse + +
OrderDatePaymentFulfillmentTotal
+ + {{ $order->order_number }} + + {{ $order->placed_at?->format('M j, Y') }} + {{ Str::headline($order->financial_status->value) }} + + {{ Str::headline($order->fulfillment_status->value) }} + {{ $order->formattedTotal() }}
+ This customer has not placed any orders yet. +
+
+ + {{ $orders->links() }} +
+
+ + {{-- Right column: addresses --}} +
+
+ Addresses + +
+ @forelse ($customer->addresses as $address) + @php($fields = $address->address_json ?? []) +
+
+ {{ $address->label ?? 'Address' }} + @if ($address->is_default) + Default + @endif +
+
+ {{ trim(($fields['first_name'] ?? '').' '.($fields['last_name'] ?? '')) }}
+ {{ $fields['address1'] ?? '' }}
+ @if (! empty($fields['address2'])){{ $fields['address2'] }}
@endif + {{ $fields['city'] ?? '' }}{{ ! empty($fields['province']) ? ', '.$fields['province'] : '' }} {{ $fields['postal_code'] ?? '' }}
+ {{ $fields['country'] ?? '' }} +
+
+ @empty + No saved addresses. + @endforelse +
+
+
+
+ + {{-- Edit customer modal --}} + +
+ Edit customer + + + Name + + + + + + +
+ Cancel + Save +
+
+
+
diff --git a/resources/views/livewire/admin/dashboard.blade.php b/resources/views/livewire/admin/dashboard.blade.php new file mode 100644 index 00000000..fc9aa90e --- /dev/null +++ b/resources/views/livewire/admin/dashboard.blade.php @@ -0,0 +1,117 @@ +@php + /** @var \App\Support\Money $money */ + $money = \App\Support\Money::class; + + $tiles = [ + ['label' => 'Total Sales', 'value' => $money::format($totalSales, $currency), 'change' => $salesChange], + ['label' => 'Orders', 'value' => number_format($ordersCount), 'change' => $ordersChange], + ['label' => 'Avg. Order Value', 'value' => $money::format($averageOrderValue, $currency), 'change' => $aovChange], + ['label' => 'Conversion Rate', 'value' => $conversionRate === null ? '—' : $conversionRate.'%', 'change' => null], + ]; +@endphp + +
+
+ Dashboard + + + Last 7 days + Last 30 days + Last 90 days + +
+ + {{-- KPI tiles --}} +
+ @foreach ($tiles as $tile) +
+ {{ $tile['label'] }} + {{ $tile['value'] }} + @if ($tile['change'] !== null) +
+ + {{ $tile['change'] >= 0 ? '+' : '' }}{{ $tile['change'] }}% + + +
+ @endif +
+ @endforeach +
+ + {{-- Orders chart --}} +
+ Orders over time + +
+ @if ($chart['max'] > 1 || array_sum(array_column($chart['days'], 'count')) > 0) + + + + +
+ {{ $chart['days'][0]['date'] ?? '' }} + {{ $chart['days'][array_key_last($chart['days'])]['date'] ?? '' }} +
+ @else +

No orders in this period.

+ @endif +
+
+ + {{-- Recent orders --}} +
+ Recent orders + + @if ($recentOrders->isEmpty()) +

No orders yet.

+ @else +
+ + + + + + + + + + + + + @foreach ($recentOrders as $order) + + + + + + + + + @endforeach + +
OrderDateCustomerPaymentFulfillmentTotal
{{ $order->order_number }}{{ $order->placed_at?->format('M j, Y') }}{{ $order->customer?->name ?? 'Guest' }} + {{ ucfirst(str_replace('_', ' ', $order->financial_status->value)) }} + + {{ ucfirst(str_replace('_', ' ', $order->fulfillment_status->value)) }} + {{ $order->formattedTotal() }}
+
+ @endif +
+
diff --git a/resources/views/livewire/admin/developers/index.blade.php b/resources/views/livewire/admin/developers/index.blade.php new file mode 100644 index 00000000..ddf89493 --- /dev/null +++ b/resources/views/livewire/admin/developers/index.blade.php @@ -0,0 +1,241 @@ +
+ Developers + + {{-- API tokens (spec 03 §16) --}} +
+ API tokens + Manage personal access tokens for the Admin API. + + @if ($generatedToken !== null) + + Copy this token now. It will not be shown again. + +
+ {{ $generatedToken }} + Copy +
+
+
+ @endif + +
+ + + + + + + + + + + + + @forelse ($tokens as $token) + + + + + + + + + @empty + + + + @endforelse + +
NameAbilitiesLast usedExpiresCreatedActions
{{ $token->name }} +
+ @foreach ($token->abilities ?? [] as $ability) + {{ $ability }} + @endforeach +
+
{{ $token->last_used_at?->diffForHumans() ?? 'Never' }}{{ $token->expires_at?->toFormattedDateString() ?? 'Never' }}{{ $token->created_at?->toFormattedDateString() }} + Revoke +
No API tokens yet.
+
+ +
+ Generate new token +
+
+ + + + {{-- Webhooks (spec 03 §16) --}} +
+ Webhooks + Manage webhook subscriptions for real-time event notifications. + + @if ($generatedWebhookSecret !== null) + + Copy this signing secret now. It will not be shown again. + +
+ {{ $generatedWebhookSecret }} + Copy +
+
+
+ @endif + +
+ + + + + + + + + + + @forelse ($webhooks as $webhook) + + + + + + + @empty + + + + @endforelse + +
Event typeURLStatusActions
{{ $webhook->event_type }}{{ $webhook->target_url }} + @if ($webhook->status === \App\Enums\WebhookSubscriptionStatus::Active && $webhook->consecutiveFailures() > 0) + Failing + @elseif ($webhook->status === \App\Enums\WebhookSubscriptionStatus::Active) + Active + @elseif ($webhook->status === \App\Enums\WebhookSubscriptionStatus::Paused) + Paused + @else + Disabled + @endif + +
+ + @if ($webhook->status === \App\Enums\WebhookSubscriptionStatus::Active) + + @else + + @endif + + +
+
No webhooks configured.
+
+ +
+ Add webhook +
+
+ + {{-- Generate token modal (spec 03 §16) --}} + +
+ Generate API token + + + Token name + + + + + + Abilities +
+ @foreach ($abilities as $ability => $label) + + @endforeach +
+ +
+ + + Expires at (optional) + + Defaults to one year from now. + + + +
+ Cancel + Generate +
+
+
+ + {{-- Webhook create/edit modal (spec 03 §16) --}} + +
+ {{ $editingWebhookId !== null ? 'Edit webhook' : 'Add webhook' }} + + + Event type + + @foreach ($eventTypes as $eventType) + {{ $eventType }} + @endforeach + + + + + + Endpoint URL + + + + +
+ Cancel + Save +
+
+
+ + {{-- Deliveries log modal (spec 03 §16) --}} + +
+ Recent deliveries + + @if ($deliveries->isEmpty()) + No deliveries recorded yet. + @else +
+ + + + + + + + + + + @foreach ($deliveries as $delivery) + + + + + + + @endforeach + +
StatusAttemptsResponseLast attempt
+ @if ($delivery->status === \App\Enums\WebhookDeliveryStatus::Success) + Success + @elseif ($delivery->status === \App\Enums\WebhookDeliveryStatus::Failed) + Failed + @else + Pending + @endif + {{ $delivery->attempt_count }}{{ $delivery->response_code ?? '—' }}{{ $delivery->last_attempt_at?->toDayDateTimeString() ?? '—' }}
+
+ @endif +
+
+
diff --git a/resources/views/livewire/admin/discounts/form.blade.php b/resources/views/livewire/admin/discounts/form.blade.php new file mode 100644 index 00000000..2ec5af68 --- /dev/null +++ b/resources/views/livewire/admin/discounts/form.blade.php @@ -0,0 +1,197 @@ +
+
+ {{ $this->isEditing() ? ($discount->code ?? 'Automatic discount') : 'Create discount' }} + @if ($this->isEditing()) + {{ Str::headline($discount->status->value) }} + @endif +
+ +
+ {{-- Type section --}} +
+ Type + + + + + +
+ + {{-- Code section (code type only) --}} + @if ($type === 'code') +
+ Code + + Discount code +
+ + Generate +
+ +
+
+ @endif + + {{-- Value section --}} +
+ Value + + + + + + + + @if ($valueType !== 'free_shipping') + + {{ $valueType === 'percent' ? 'Percentage' : 'Amount (cents)' }} + + @if ($valueType === 'percent') + Whole percentage, 1–100. + @else + Amount in cents (e.g. 500 = 5.00). + @endif + + + @endif +
+ + {{-- Conditions section --}} +
+ Conditions + + + Minimum purchase amount (cents) + + Leave empty for no minimum + + + + {{-- Specific products picker --}} + + Specific products + + Leave empty to apply to all products + + + + @if ($productResults->isNotEmpty()) +
+ @foreach ($productResults as $product) + + @endforeach +
+ @endif + + @if ($selectedProducts->isNotEmpty()) +
+ @foreach ($selectedProducts as $product) + + {{ $product->title }} + + + @endforeach +
+ @endif + + {{-- Specific collections picker --}} + + Specific collections + + Leave empty to apply to all collections + + + + @if ($collectionResults->isNotEmpty()) +
+ @foreach ($collectionResults as $collection) + + @endforeach +
+ @endif + + @if ($selectedCollections->isNotEmpty()) +
+ @foreach ($selectedCollections as $collection) + + {{ $collection->title }} + + + @endforeach +
+ @endif +
+ + {{-- Usage limits section --}} +
+ Usage limits + + + Total usage limit + + + + + @if ($this->isEditing()) + Used {{ $discount->usage_count }} {{ Str::plural('time', $discount->usage_count) }} so far. + @endif +
+ + {{-- Active dates section --}} +
+ Active dates + +
+ + Start date + + + + + + End date + + Leave empty for no end date + + +
+
+ + {{-- Status section --}} +
+ Status + @if ($this->isEditing() && $discount->status === \App\Enums\DiscountStatus::Expired) + This discount has expired and cannot be re-activated. + @else +
+ +
+ @endif +
+
+ + {{-- Sticky save bar (spec 03 §19.2) --}} +
+
+ Discard + + Save + Saving... + +
+
+
diff --git a/resources/views/livewire/admin/discounts/index.blade.php b/resources/views/livewire/admin/discounts/index.blade.php new file mode 100644 index 00000000..baa4e969 --- /dev/null +++ b/resources/views/livewire/admin/discounts/index.blade.php @@ -0,0 +1,124 @@ +
+
+ Discounts + + @can('create', \App\Models\Discount::class) + Create discount + @endcan +
+ + @if (! $hasDiscounts) +
+ + Create your first discount + Offer percentage, fixed amount, or free shipping discounts. + @can('create', \App\Models\Discount::class) + Create discount + @endcan +
+ @else +
+ + + + All statuses + Draft + Active + Scheduled + Expired + Disabled + + + + All types + Code + Automatic + +
+ +
+ + + + + + + + + + + + + + @forelse ($discounts as $discount) + @php($status = $this->displayStatus($discount)) + + + + + + + + + + @empty + + + + @endforelse + +
CodeTypeValueUsageDatesStatusActions
+ @can('update', $discount) + + {{ $discount->code ?? 'Automatic' }} + + @else + {{ $discount->code ?? 'Automatic' }} + @endcan + + + {{ $discount->type === \App\Enums\DiscountType::Automatic ? 'Automatic' : 'Code' }} + + {{ $this->displayValue($discount, $currency) }}{{ $discount->usage_count }} / {{ $discount->usage_limit ?? 'Unlimited' }} + {{ $discount->starts_at?->format('M j, Y') ?? '—' }} → {{ $discount->ends_at?->format('M j, Y') ?? 'No end' }} + + {{ ucfirst($status) }} + +
+ @can('update', $discount) + + @if ($discount->status === \App\Enums\DiscountStatus::Active) + Disable + @elseif (in_array($discount->status, [\App\Enums\DiscountStatus::Disabled, \App\Enums\DiscountStatus::Draft], true)) + Enable + @endif + @endcan + @can('delete', $discount) + + @endcan +
+
+ No discounts match your filters. +
+
+ + {{ $discounts->links() }} + @endif + + {{-- Delete confirmation modal (spec 03 §19.3) --}} + +
+ Delete this discount? + The discount will be permanently removed. Orders that already used it are not affected. +
+ Cancel + Delete +
+
+
+
diff --git a/resources/views/livewire/admin/inventory/index.blade.php b/resources/views/livewire/admin/inventory/index.blade.php new file mode 100644 index 00000000..2d4531a1 --- /dev/null +++ b/resources/views/livewire/admin/inventory/index.blade.php @@ -0,0 +1,76 @@ +
+ Inventory + +
+ + + + All stock + In stock + Low stock + Out of stock + +
+ +
+ + + + + + + + + + + + + + + @forelse ($items as $item) + + + + + + + + + + + @empty + + + + @endforelse + +
ProductVariantSKUOn handReservedAvailablePolicyActions
{{ $item->variant->product->title }}{{ $item->variant->title() }}{{ $item->variant->sku ?: '—' }} +
+ + + +
+
{{ $item->quantity_reserved }} + + {{ $item->available() }} + + + + + Edit product +
+ No inventory items match your filters. +
+
+ + {{ $items->links() }} +
diff --git a/resources/views/livewire/admin/layout/breadcrumbs.blade.php b/resources/views/livewire/admin/layout/breadcrumbs.blade.php new file mode 100644 index 00000000..32f8af28 --- /dev/null +++ b/resources/views/livewire/admin/layout/breadcrumbs.blade.php @@ -0,0 +1,11 @@ +@if (count($trail) > 1) + + @foreach ($trail as $crumb) + @if ($crumb['url'] !== null) + {{ $crumb['label'] }} + @else + {{ $crumb['label'] }} + @endif + @endforeach + +@endif diff --git a/resources/views/livewire/admin/layout/sidebar.blade.php b/resources/views/livewire/admin/layout/sidebar.blade.php new file mode 100644 index 00000000..8b6d6bd9 --- /dev/null +++ b/resources/views/livewire/admin/layout/sidebar.blade.php @@ -0,0 +1,35 @@ +
+
+ + + + + +
+ + +
diff --git a/resources/views/livewire/admin/layout/top-bar.blade.php b/resources/views/livewire/admin/layout/top-bar.blade.php new file mode 100644 index 00000000..138b25ed --- /dev/null +++ b/resources/views/livewire/admin/layout/top-bar.blade.php @@ -0,0 +1,58 @@ +
+ {{-- Hamburger (mobile only) --}} + + + {{-- Store selector --}} + + + {{ $currentStore->name }} + + + + @foreach ($stores as $store) + + {{ $store->name }} + + @endforeach + + + + + + {{-- Notifications --}} +
+ + @if ($unreadNotificationCount > 0) + + {{ $unreadNotificationCount }} + + @endif +
+ + {{-- User profile --}} + + + + +
+ {{ $user->name }} + {{ $user->email }} + @if ($currentRole !== null) + {{ $currentRole->value }} + @endif +
+ + + + @if (Route::has('admin.settings.index')) + Settings + + @endif + +
+ @csrf + Log out +
+
+
+
diff --git a/resources/views/livewire/admin/navigation/index.blade.php b/resources/views/livewire/admin/navigation/index.blade.php new file mode 100644 index 00000000..95eb324b --- /dev/null +++ b/resources/views/livewire/admin/navigation/index.blade.php @@ -0,0 +1,155 @@ +
+
+ Navigation + + @can('manage-navigation') + Create menu + @endcan +
+ + {{-- Menu selector cards (spec 03 §14) --}} + @if ($menus->isEmpty()) +
+ + Create your first menu + Menus like "Main menu" and "Footer menu" structure your storefront navigation. + @can('manage-navigation') + Create menu + @endcan +
+ @else +
+ @foreach ($menus as $menu) + + @endforeach +
+ + {{-- Menu editor (spec 03 §14) --}} + @if ($selectedMenu !== null) +
+
+ {{ $selectedMenu->title }} + + @can('manage-navigation') + Add item + @endcan +
+ + @if ($items->isEmpty()) + No items yet. Add links, pages, collections, or products. + @else +
    + @foreach ($items as $index => $item) +
  • +
    + {{ $item->label }} + + {{ $item->type->value }}: + @if ($item->type === \App\Enums\NavigationItemType::Link) + {{ $item->url }} + @else + {{ $resourceLabels[$item->type->value][$item->resource_id] ?? 'Missing resource' }} + @endif + +
    + + @can('manage-navigation') + {{-- Reorder via buttons instead of drag-and-drop (spec 03 §14 note) --}} + + + + + @endcan +
  • + @endforeach +
+ @endif +
+ @endif + @endif + + {{-- New menu modal --}} + +
+ Create menu + + + Title + + + + + + Handle + + + + +
+ Cancel + Create menu +
+
+
+ + {{-- Menu item form modal (spec 03 §14) --}} + +
+ {{ $editingItemId === null ? 'Add menu item' : 'Edit menu item' }} + + + Label + + + + + + Type + + Custom link + Page + Collection + Product + + + + + @if ($itemType === 'link') + + URL + + + + @else + + {{ ucfirst($itemType) }} + @php($resources = match ($itemType) { + 'page' => $pages, + 'collection' => $collections, + default => $products, + }) + + Select a {{ $itemType }}... + @foreach ($resources as $resource) + {{ $resource->title }} + @endforeach + + + + @endif + +
+ Cancel + Save item +
+
+
+
diff --git a/resources/views/livewire/admin/orders/index.blade.php b/resources/views/livewire/admin/orders/index.blade.php new file mode 100644 index 00000000..48529507 --- /dev/null +++ b/resources/views/livewire/admin/orders/index.blade.php @@ -0,0 +1,124 @@ +
+
+ Orders +
+ + @if (! $hasOrders) + {{-- Empty state (spec 03 §19.1) --}} +
+ + No orders yet + Orders placed in your storefront will appear here. +
+ @else +
+ + + + All payments + @foreach (\App\Enums\FinancialStatus::cases() as $status) + {{ Str::headline($status->value) }} + @endforeach + + + + All fulfillments + @foreach (\App\Enums\FulfillmentOrderStatus::cases() as $status) + {{ Str::headline($status->value) }} + @endforeach + + +
+ + + +
+
+ + {{-- Status tabs (spec 03 §7) --}} +
+ @foreach (['all' => 'All', 'pending' => 'Pending', 'paid' => 'Paid', 'fulfilled' => 'Fulfilled', 'cancelled' => 'Cancelled', 'refunded' => 'Refunded'] as $value => $label) + + @endforeach +
+ +
+ + + + + + + + + + + + + @forelse ($orders as $order) + + + + + + + + + @empty + + + + @endforelse + +
+ + + + CustomerPaymentFulfillment + +
+ + {{ $order->order_number }} + + {{ $order->placed_at?->format('M j, Y g:i A') ?? '—' }}{{ $order->customer?->name ?? 'Guest' }} + {{ Str::headline($order->financial_status->value) }} + + {{ Str::headline($order->fulfillment_status->value) }} + {{ $order->formattedTotal() }}
+ No orders match your filters. +
+
+ + {{ $orders->links() }} + @endif +
diff --git a/resources/views/livewire/admin/orders/show.blade.php b/resources/views/livewire/admin/orders/show.blade.php new file mode 100644 index 00000000..c0448536 --- /dev/null +++ b/resources/views/livewire/admin/orders/show.blade.php @@ -0,0 +1,455 @@ +
+ {{-- Order heading + status badges (spec 03 §8) --}} +
+ {{ $order->order_number }} + {{ Str::headline($order->financial_status->value) }} + {{ Str::headline($order->fulfillment_status->value) }} + {{ $order->placed_at?->format('M j, Y g:i A') }} +
+ + {{-- Action buttons --}} +
+ @if ($this->canConfirmPayment()) + @can('update', $order) + Confirm payment + @endcan + @endif + + @can('createFulfillment', $order) + @if (array_sum($unfulfilled) > 0) + Create fulfillment + @endif + @endcan + + @if ($this->canRefund()) + @can('createRefund', $order) + Refund + @endcan + @endif + + @if ($this->canCancel()) + @can('cancel', $order) + Cancel order + @endcan + @endif +
+ + {{-- Fulfillment guard callout (spec 03 §8, spec 05 §11.5) --}} + @if ($this->fulfillmentGuardBlocks() && array_sum($unfulfilled) > 0) + + Cannot create fulfillment + Fulfillment cannot be created until payment is confirmed. Current financial status: {{ Str::headline($order->financial_status->value) }}. + + @endif + +
+ {{-- Left column --}} +
+ {{-- Timeline --}} +
+ Timeline + +
    + @foreach ($timeline as $event) +
  1. +
    + {{ $event['title'] }} + {{ $event['time']?->format('M j, Y g:i A') }} +
  2. + @endforeach +
+
+ + {{-- Order lines --}} +
+ Order lines + +
+ + + + + + + + + + + + @foreach ($order->lines as $line) + + + + + + + + @endforeach + +
ImageProductQtyUnit priceTotal
+ @php($media = $line->product?->media->first()) + @if ($media !== null && $media->status === \App\Enums\MediaStatus::Ready) + + @else +
+ +
+ @endif +
+
{{ $line->title_snapshot }}
+ @if ($line->sku_snapshot !== null) +
SKU: {{ $line->sku_snapshot }}
+ @endif +
{{ $line->quantity }}{{ \App\Support\Money::format($line->unit_price_amount, $order->currency) }}{{ \App\Support\Money::format($line->total_amount, $order->currency) }}
+
+ + {{-- Order summary --}} +
+
+ Subtotal + {{ \App\Support\Money::format($order->subtotal_amount, $order->currency) }} +
+ @if ($order->discount_amount > 0) +
+ Discount + -{{ \App\Support\Money::format($order->discount_amount, $order->currency) }} +
+ @endif +
+ Shipping + {{ \App\Support\Money::format($order->shipping_amount, $order->currency) }} +
+
+ Tax + {{ \App\Support\Money::format($order->tax_amount, $order->currency) }} +
+
+ Total + {{ $order->formattedTotal() }} +
+
+
+ + {{-- Payment details --}} +
+ Payment details + +
+ @forelse ($order->payments as $payment) +
+
+ + {{ match ($payment->method) { + \App\Enums\PaymentMethod::CreditCard => 'Credit Card', + \App\Enums\PaymentMethod::Paypal => 'PayPal', + \App\Enums\PaymentMethod::BankTransfer => 'Bank Transfer', + } }} + + + {{ \App\Support\Money::format($payment->amount, $payment->currency) }} + @if ($payment->provider_payment_id !== null) + · Ref: {{ $payment->provider_payment_id }} + @endif + · {{ $payment->created_at?->format('M j, Y g:i A') }} + +
+ {{ Str::headline($payment->status->value) }} +
+ @empty + No payments recorded. + @endforelse +
+ + @if ($this->canConfirmPayment()) + @can('update', $order) + Confirm payment + @endcan + @endif +
+ + {{-- Fulfillments --}} + @if ($order->fulfillments->isNotEmpty()) +
+ @foreach ($order->fulfillments as $fulfillment) +
+
+
+ Fulfillment #{{ $fulfillment->id }} + {{ Str::headline($fulfillment->status->value) }} +
+
+ @if ($fulfillment->status === \App\Enums\FulfillmentShipmentStatus::Pending) + @can('update', $fulfillment) + Mark as shipped + @endcan + @elseif ($fulfillment->status === \App\Enums\FulfillmentShipmentStatus::Shipped) + @can('update', $fulfillment) + Mark as delivered + @endcan + @endif +
+
+ + @if ($fulfillment->tracking_company !== null || $fulfillment->tracking_number !== null || $fulfillment->tracking_url !== null) + + Tracking: + {{ $fulfillment->tracking_company ?? '—' }} + {{ $fulfillment->tracking_number ?? '' }} + @if ($fulfillment->tracking_url !== null) + · Track shipment + @endif + + @endif + + +
    + @foreach ($fulfillment->lines as $fulfillmentLine) +
  • + {{ $fulfillmentLine->orderLine?->title_snapshot ?? 'Unknown item' }} + × {{ $fulfillmentLine->quantity }} +
  • + @endforeach +
+
+ @endforeach +
+ @endif + + {{-- Refunds --}} + @if ($order->refunds->isNotEmpty()) +
+ Refunds + +
+ @foreach ($order->refunds->sortByDesc('created_at') as $refund) +
+
+ {{ \App\Support\Money::format($refund->amount, $order->currency) }} + + {{ $refund->created_at?->format('M j, Y g:i A') }} + @if ($refund->reason !== null) + · {{ $refund->reason }} + @endif + +
+ {{ Str::headline($refund->status->value) }} +
+ @endforeach +
+
+ @endif +
+ + {{-- Right column --}} +
+ {{-- Customer card --}} +
+ Customer + + {{ $order->customer?->name ?? 'Guest' }} + @if ($order->email !== null) + {{ $order->email }} + @endif + @if ($order->customer !== null) + + @endif +
+ + {{-- Shipping address --}} +
+ Shipping address + + @php($address = $order->shipping_address_json ?? []) + @if ($address === []) + No shipping address. + @else +
+ {{ trim(($address['first_name'] ?? '').' '.($address['last_name'] ?? '')) }}
+ @if (! empty($address['company'])){{ $address['company'] }}
@endif + {{ $address['address1'] ?? '' }}
+ @if (! empty($address['address2'])){{ $address['address2'] }}
@endif + {{ $address['city'] ?? '' }}{{ ! empty($address['province']) ? ', '.$address['province'] : '' }} {{ $address['postal_code'] ?? '' }}
+ {{ $address['country'] ?? '' }} +
+ @endif +
+ + {{-- Billing address --}} +
+ Billing address + + @php($address = $order->billing_address_json ?? []) + @if ($address === []) + No billing address. + @else +
+ {{ trim(($address['first_name'] ?? '').' '.($address['last_name'] ?? '')) }}
+ @if (! empty($address['company'])){{ $address['company'] }}
@endif + {{ $address['address1'] ?? '' }}
+ @if (! empty($address['address2'])){{ $address['address2'] }}
@endif + {{ $address['city'] ?? '' }}{{ ! empty($address['province']) ? ', '.$address['province'] : '' }} {{ $address['postal_code'] ?? '' }}
+ {{ $address['country'] ?? '' }} +
+ @endif +
+
+
+ + {{-- Fulfillment modal (spec 03 §8) --}} + +
+ Create fulfillment + + @if ($errors->isNotEmpty()) + + {{ $errors->first() }} + + @endif + +
+ @foreach ($order->lines as $line) + @if (($unfulfilled[$line->id] ?? 0) > 0) +
+ + {{ $line->title_snapshot }} + ({{ $unfulfilled[$line->id] }} unfulfilled) + + +
+ @endif + @endforeach +
+ + + + + Tracking company + + + + + + Tracking number + + + + + + Tracking URL + + + + +
+ Cancel + Create fulfillment +
+
+
+ + {{-- Mark-as-shipped tracking modal --}} + +
+ Mark as shipped + + @if ($errors->isNotEmpty()) + + {{ $errors->first() }} + + @endif + + + Tracking company + + + + + + Tracking number + + + + + + Tracking URL + + + + +
+ Cancel + Mark as shipped +
+
+
+ + {{-- Refund modal (spec 03 §8) --}} + +
+ Refund order + + + Amount (cents) + + Refundable: {{ \App\Support\Money::format($refundableAmount, $order->currency) }} + + + + + Reason + + + + + + +
+ Cancel + Create refund +
+
+
+ + {{-- Cancel order modal --}} + +
+ Cancel this order? + Reserved inventory will be released and any pending payment will be voided. This cannot be undone. + + + Reason + + + + +
+ Keep order + Cancel order +
+
+
+
diff --git a/resources/views/livewire/admin/pages/form.blade.php b/resources/views/livewire/admin/pages/form.blade.php new file mode 100644 index 00000000..38ad2584 --- /dev/null +++ b/resources/views/livewire/admin/pages/form.blade.php @@ -0,0 +1,63 @@ +
+ {{ $this->isEditing() ? $page->title : 'Add page' }} + +
+ {{-- Left column (2/3): primary content (spec 03 §13.2) --}} +
+
+ + Title + + + + + + Handle + + + + + + Body + + + +
+
+ + {{-- Right column (1/3): status and publishing --}} +
+
+ + Status + + Draft + Published + Archived + + + +
+ +
+ + Published at + + Set automatically when publishing. + + +
+
+
+ + {{-- Sticky save bar (spec 03 §19.2) --}} +
+
+ Discard + + Save + Saving... + +
+
+
diff --git a/resources/views/livewire/admin/pages/index.blade.php b/resources/views/livewire/admin/pages/index.blade.php new file mode 100644 index 00000000..33456e7e --- /dev/null +++ b/resources/views/livewire/admin/pages/index.blade.php @@ -0,0 +1,92 @@ +
+
+ Pages + + @can('create', \App\Models\Page::class) + Add page + @endcan +
+ + @if (! $hasPages) +
+ + Create your first page + Add content pages like About, Contact, or Policies. + @can('create', \App\Models\Page::class) + Add page + @endcan +
+ @else +
+ +
+ +
+ + + + + + + + + + + + @forelse ($pages as $page) + + + + + + + + @empty + + + + @endforelse + +
TitleHandleStatusUpdatedActions
+ @can('update', $page) + + {{ $page->title }} + + @else + {{ $page->title }} + @endcan + {{ $page->handle }} + {{ ucfirst($page->status->value) }} + {{ $page->updated_at->diffForHumans() }} +
+ @can('update', $page) + + @endcan + @can('delete', $page) + + @endcan +
+
+ No pages match your search. +
+
+ + {{ $pages->links() }} + @endif + + {{-- Delete confirmation modal (spec 03 §19.3) --}} + +
+ Delete this page? + The page will be permanently removed from your store. +
+ Cancel + Delete +
+
+
+
diff --git a/resources/views/livewire/admin/products/form.blade.php b/resources/views/livewire/admin/products/form.blade.php new file mode 100644 index 00000000..7363243a --- /dev/null +++ b/resources/views/livewire/admin/products/form.blade.php @@ -0,0 +1,275 @@ +
+
+ {{ $this->isEditing() ? $title : 'Add product' }} + + @if ($this->isEditing()) + @can('archive', $this->product) + Delete + @endcan + @endif +
+ +
+ {{-- Left column --}} +
+ {{-- Title & description --}} +
+ + Title + + + + + + Description + + + +
+ + {{-- Media --}} +
+ Media + + + +
+
+
+ + + + @if ($media !== [] || $newMedia !== []) +
+ @foreach ($media as $index => $item) +
+ {{ $item['alt_text'] }} + +
+
+ + + +
+
+ + +
+ @endforeach + + @foreach ($newMedia as $index => $file) +
+ + +
+ @endforeach +
+ @endif +
+ + {{-- Variants --}} +
+ Variants + +
+ @foreach ($options as $optionIndex => $option) +
+ + Option name + + + + Values (comma-separated) + + + +
+ @endforeach +
+ + @if (count($options) < 3) + Add another option + @endif + + @if ($variants !== []) +
+ + + + + + + + + + + + + + + + @foreach ($variants as $variantIndex => $variant) + + + + + + + + + + + + @endforeach + +
VariantSKUBarcodePrice (cents)Compare atWeight (g)QtyPolicyShip
+ {{ $variant['label'] }} + @if ($loop->first) + Default + @endif + + + + + + + + + + + + + + + + + + + +
+
+ @endif +
+ + {{-- SEO --}} +
+ + +
+ + URL handle + + + +
+
+
+ + {{-- Right column --}} +
+
+ + Status + + Draft + Active + Archived + + + +
+ +
+ + Published at + + + +
+ +
+ Organization + + + Vendor + + + + + + Product type + + + + + + Tags + + Separate tags with commas + + +
+ + @if ($availableCollections->isNotEmpty()) +
+ Collections + +
+ @foreach ($availableCollections as $collection) + + @endforeach +
+
+ @endif +
+
+ + {{-- Sticky save bar --}} +
+
+ Discard + + Save + Saving... + +
+
+ + {{-- Delete confirmation modal (edit only, spec 03 §4) --}} + @if ($this->isEditing()) + +
+ Delete this product? + This product will be archived. Products with existing orders cannot be permanently removed. +
+ Cancel + Delete +
+
+
+ @endif +
diff --git a/resources/views/livewire/admin/products/index.blade.php b/resources/views/livewire/admin/products/index.blade.php new file mode 100644 index 00000000..656e02b8 --- /dev/null +++ b/resources/views/livewire/admin/products/index.blade.php @@ -0,0 +1,152 @@ +
+
+ Products + + @can('create', \App\Models\Product::class) + Add product + @endcan +
+ + @if (! $hasProducts) + {{-- Empty state (spec 03 §3) --}} +
+ + Add your first product + Start building your catalog by adding products. + @can('create', \App\Models\Product::class) + Add product + @endcan +
+ @else +
+ + +
+ @foreach (['all' => 'All', 'draft' => 'Draft', 'active' => 'Active', 'archived' => 'Archived'] as $value => $label) + + @endforeach +
+ + + All types + @foreach ($productTypes as $type) + {{ $type }} + @endforeach + +
+ + {{-- Bulk action bar --}} + @if (count($selectedIds) > 0) +
+ {{ count($selectedIds) }} {{ Str::plural('product', count($selectedIds)) }} selected + Set Active + Archive + Delete +
+ @endif + +
+ + + + + + + + + + + + + + + + @forelse ($products as $product) + + + + + + + + + + + + @empty + + + + @endforelse + +
+ + Image + + Status + + VariantsTypeVendor + +
+ + + @if ($product->media->first() !== null && $product->media->first()->status === \App\Enums\MediaStatus::Ready) + + @else +
+ +
+ @endif +
+ + {{ $product->title }} + + + {{ ucfirst($product->status->value) }} + + {{ $product->variants->sum(fn ($variant) => $variant->inventoryItem?->quantity_on_hand ?? 0) }} + {{ $product->variants_count }}{{ $product->product_type ?: '—' }}{{ $product->vendor ?: '—' }}{{ $product->updated_at->diffForHumans() }}
+ No products match your filters. +
+
+ + {{ $products->links() }} + @endif + + {{-- Delete confirmation modal (spec 03 §3) --}} + +
+ Delete products? + This will archive {{ count($selectedIds) }} {{ Str::plural('product', count($selectedIds)) }}. Products with orders cannot be permanently deleted. +
+ Cancel + Delete +
+
+
+
diff --git a/resources/views/livewire/admin/search/settings.blade.php b/resources/views/livewire/admin/search/settings.blade.php new file mode 100644 index 00000000..79da3c96 --- /dev/null +++ b/resources/views/livewire/admin/search/settings.blade.php @@ -0,0 +1,83 @@ +
+ Search Settings + + {{-- Synonyms (spec 03 §18) --}} +
+ Synonyms + Define groups of words that should be treated as equivalent. + +
+ @foreach ($synonymGroups as $index => $group) +
+
+ +
+ +
+ @endforeach + + Add synonym group +
+
+ + {{-- Stop words --}} +
+ Stop words + Words that are excluded from search. + + + + Separate words with commas. + +
+ + {{-- Search index --}} +
+ Search index + +
+ + Reindex now + Reindexing... + + @if ($lastIndexedAt !== null) + Last indexed: {{ $lastIndexedAt }} + @endif +
+
+ + {{-- Recent search queries (spec 05 §16.4) --}} +
+ Recent search queries + + @if ($recentQueries->isEmpty()) + No searches recorded yet. + @else + + + + + + + + + + @foreach ($recentQueries as $recentQuery) + + + + + + @endforeach + +
QueryResultsDate
{{ $recentQuery->query }}{{ $recentQuery->results_count }}{{ $recentQuery->created_at?->toDayDateTimeString() }}
+ @endif +
+ +
+ + Save + Saving... + +
+
diff --git a/resources/views/livewire/admin/settings/index.blade.php b/resources/views/livewire/admin/settings/index.blade.php new file mode 100644 index 00000000..1e5c5e90 --- /dev/null +++ b/resources/views/livewire/admin/settings/index.blade.php @@ -0,0 +1,223 @@ +
+ Settings + + {{-- Tabs (spec 03 §11.2). Shipping and Taxes have dedicated pages. --}} +
+ @foreach (['general' => 'General', 'domains' => 'Domains', 'checkout' => 'Checkout', 'notifications' => 'Notifications'] as $key => $label) + + @endforeach + Shipping + Taxes +
+ + {{-- General tab (spec 03 §11.1) --}} + @if ($tab === 'general') +
+
+
+ Store details + Basic information about your store. +
+
+ + Store name + + + + + + Contact email + + + +
+
+ + + +
+
+ Defaults + Currency, language, and timezone settings. +
+
+ + Default currency + + @foreach (['EUR', 'USD', 'GBP', 'CHF', 'SEK', 'PLN'] as $currency) + {{ $currency }} + @endforeach + + + + + + Default locale + + English + German + French + + + + + + Timezone + + @foreach ($timezones as $tz) + {{ $tz }} + @endforeach + + + +
+
+ +
+ Save +
+
+ @endif + + {{-- Domains tab (spec 03 §11.2) --}} + @if ($tab === 'domains') +
+
+ Domains + Add domain +
+ +
+ + + + + + + + + + + + @foreach ($domains as $domain) + + + + + + + + @endforeach + +
HostnameTypePrimaryTLSActions
{{ $domain->hostname }}{{ ucfirst($domain->type->value) }} + @if ($domain->is_primary) + Primary + @endif + {{ $domain->tls_mode }} +
+ @if (! $domain->is_primary) + Set primary + + @endif +
+
+
+
+ + {{-- Add domain modal (spec 03 §11.2) --}} + +
+ Add domain + + + Hostname + + + + + + Type + + Storefront + Admin + API + + + + +
+ Cancel + Add domain +
+
+
+ @endif + + {{-- Checkout tab --}} + @if ($tab === 'checkout') +
+
+ Checkout + Order numbering and checkout lifecycle settings. +
+
+
+ + Order number prefix + + + + + + Order number start + + + + + + Bank transfer cancel days + + Days before unpaid bank transfer orders are cancelled. + + + + + Abandoned cart days + + Days of inactivity before a cart is marked abandoned. + + +
+ +
+ Save +
+
+
+ @endif + + {{-- Notifications tab --}} + @if ($tab === 'notifications') +
+
+ Notifications + Email notifications sent by your store. +
+
+ + + + +
+ Save +
+
+
+ @endif +
diff --git a/resources/views/livewire/admin/settings/shipping.blade.php b/resources/views/livewire/admin/settings/shipping.blade.php new file mode 100644 index 00000000..694d5f9a --- /dev/null +++ b/resources/views/livewire/admin/settings/shipping.blade.php @@ -0,0 +1,193 @@ +
+
+ Shipping + + Add zone +
+ + @if ($zones->isEmpty()) +
+ + Create your first shipping zone + Zones group countries and regions with their own shipping rates. + Add zone +
+ @else +
+ @foreach ($zones as $zone) +
+
+
+ {{ $zone->name }} + + Countries: {{ implode(', ', $zone->countries_json ?? []) }} + @if (! empty($zone->regions_json)) + · Regions: {{ implode(', ', $zone->regions_json) }} + @endif + +
+
+ + +
+
+ + @if ($zone->rates->isNotEmpty()) +
+ + + + + + + + + + + + @foreach ($zone->rates as $rate) + + + + + + + + @endforeach + +
NameTypeConfigActiveActions
{{ $rate->name }}{{ $rate->type->value }}{{ $this->configSummary($rate->type, $rate->config_json) }} + + +
+ + +
+
+
+ @endif + +
+ Add rate +
+
+ @endforeach +
+ @endif + + {{-- Zone modal (spec 03 §11.3) --}} + +
+ {{ $editingZoneId === null ? 'Add shipping zone' : 'Edit shipping zone' }} + + + Zone name + + + + + + Countries + + Comma-separated ISO country codes. + + + + + Regions + + Optional comma-separated region/state codes. + + + +
+ Cancel + Save zone +
+
+
+ + {{-- Rate modal (spec 03 §11.3) --}} + +
+ {{ $editingRateId === null ? 'Add shipping rate' : 'Edit shipping rate' }} + + + Rate name + + + + + + Rate type + + Flat rate + Weight-based + Price-based + Carrier-calculated + + + + + @if ($rateType === 'flat') + + Amount + + Amount in minor units (e.g. 500 = 5.00). + + + @elseif ($rateType === 'weight' || $rateType === 'price') +
+ Ranges + + @foreach ($rateRanges as $index => $range) +
+ @if ($rateType === 'weight') + + Min (g) + + + + Max (g) + + + @else + + Min amount + + + + Max amount + + + @endif + + Amount + + + +
+ + + + + + @endforeach + Add range +
+ @else + + Carrier-calculated rates require a carrier integration to be configured. + + @endif + +
+ +
+ +
+ Cancel + Save rate +
+
+
+
diff --git a/resources/views/livewire/admin/settings/taxes.blade.php b/resources/views/livewire/admin/settings/taxes.blade.php new file mode 100644 index 00000000..2b443dde --- /dev/null +++ b/resources/views/livewire/admin/settings/taxes.blade.php @@ -0,0 +1,80 @@ +
+ Taxes + + {{-- Mode selection (spec 03 §11.4) --}} +
+ Tax mode + + + + + + +
+ + @if ($mode === 'provider') +
+ Provider configuration + +
+ + Provider + + None + Stripe Tax + + + + + + On provider failure + + Block checkout + Allow checkout without tax + + + +
+
+ @endif + + {{-- Rates (spec 05 §8.2) --}} +
+ Rates + + + Default rate (basis points) + + 1900 = 19.00%. Applied when no zone override matches. + + + + @if ($zones->isNotEmpty()) + + Zone overrides + Optional per-zone rates. Leave empty to use the default rate. + +
+ @foreach ($zones as $zone) + + {{ $zone->name }} + + + + @endforeach +
+ @endif +
+ + {{-- Tax-inclusive toggle (spec 05 §8.3) --}} +
+ +
+ +
+ + Save + Saving... + +
+
diff --git a/resources/views/livewire/admin/themes/editor.blade.php b/resources/views/livewire/admin/themes/editor.blade.php new file mode 100644 index 00000000..82280431 --- /dev/null +++ b/resources/views/livewire/admin/themes/editor.blade.php @@ -0,0 +1,142 @@ +
+ {{-- Top toolbar (spec 03 §12.2) --}} +
+ Back to themes + +
+ Save + Save and publish +
+
+ +
+ {{-- Left panel: sections list (spec 03 §12.2) --}} +
+ Sections + +
    + @foreach ($sectionLabels as $key => $label) +
  • + + + @if (array_key_exists('enabled', $settings[$key] ?? [])) + + @endif +
  • + @endforeach +
+
+ + {{-- Center panel: live preview (spec 03 §12.2). Simplified: the iframe + always shows the live storefront home page; draft-theme preview + tokens are out of scope. --}} +
+ +
+ + {{-- Right panel: settings form (spec 03 §12.2) --}} +
+ {{ $sectionLabels[$selectedSection] }} + + +
+ @if ($selectedSection === 'announcement') + + + Text + + + + Link + + + + @elseif ($selectedSection === 'colors') + @foreach (['primary' => 'Primary', 'secondary' => 'Secondary', 'accent' => 'Accent'] as $colorKey => $colorLabel) + + {{ $colorLabel }} +
+ + +
+
+ @endforeach + + @elseif ($selectedSection === 'hero') + + + Heading + + + + Subheading + + + + Button label + + + + Button URL + + + + @elseif ($selectedSection === 'featured_collections') + + + Number of collections + + + + Collection handles + + Comma-separated handles. Empty uses the newest collections. + + + @elseif ($selectedSection === 'featured_products') + + + Number of products + + + + Collection handle + + + + @elseif ($selectedSection === 'newsletter') + + + @elseif ($selectedSection === 'rich_text') + + + Content + + + + @elseif ($selectedSection === 'footer') + + About text + + + + @elseif ($selectedSection === 'seo') + + Meta description + + + @endif +
+
+
+
diff --git a/resources/views/livewire/admin/themes/index.blade.php b/resources/views/livewire/admin/themes/index.blade.php new file mode 100644 index 00000000..ffb8d8cc --- /dev/null +++ b/resources/views/livewire/admin/themes/index.blade.php @@ -0,0 +1,89 @@ +
+
+ Themes + + Add theme +
+ + @if ($themes->isEmpty()) +
+ + Create your first theme + Themes control the look and feel of your storefront. + Add theme +
+ @else +
+ @foreach ($themes as $theme) +
+ {{-- Thumbnail placeholder --}} +
+ +
+ +
+
+ {{ $theme->name }} + v{{ $theme->version }} +
+ +
+ @if ($theme->isPublished()) + Published + {{ $theme->published_at?->diffForHumans() }} + @else + Draft + @endif +
+ +
+ Customize + + + + + @if (! $theme->isPublished()) + Publish + @endif + Duplicate + + Delete + + +
+
+
+ @endforeach +
+ @endif + + {{-- Create theme modal --}} + +
+ Add theme + + + Theme name + + + + +
+ Cancel + Create theme +
+
+
+ + {{-- Delete confirmation modal (spec 03 §19.3) --}} + +
+ Delete this theme? + The theme and its settings will be permanently removed. The published theme cannot be deleted. +
+ Cancel + Delete +
+
+
+
diff --git a/resources/views/livewire/auth/confirm-password.blade.php b/resources/views/livewire/auth/confirm-password.blade.php deleted file mode 100644 index 09b2fbc1..00000000 --- a/resources/views/livewire/auth/confirm-password.blade.php +++ /dev/null @@ -1,28 +0,0 @@ - -
- - - - -
- @csrf - - - - - {{ __('Confirm') }} - - -
-
diff --git a/resources/views/livewire/auth/forgot-password.blade.php b/resources/views/livewire/auth/forgot-password.blade.php deleted file mode 100644 index 4af48477..00000000 --- a/resources/views/livewire/auth/forgot-password.blade.php +++ /dev/null @@ -1,31 +0,0 @@ - -
- - - - - -
- @csrf - - - - - - {{ __('Email password reset link') }} - - - -
- {{ __('Or, return to') }} - {{ __('log in') }} -
-
-
diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php deleted file mode 100644 index 0fee9de2..00000000 --- a/resources/views/livewire/auth/login.blade.php +++ /dev/null @@ -1,59 +0,0 @@ - -
- - - - - -
- @csrf - - - - - -
- - - @if (Route::has('password.request')) - - {{ __('Forgot your password?') }} - - @endif -
- - - - -
- - {{ __('Log in') }} - -
- - - @if (Route::has('register')) -
- {{ __('Don\'t have an account?') }} - {{ __('Sign up') }} -
- @endif -
-
diff --git a/resources/views/livewire/auth/register.blade.php b/resources/views/livewire/auth/register.blade.php deleted file mode 100644 index 381ec0ac..00000000 --- a/resources/views/livewire/auth/register.blade.php +++ /dev/null @@ -1,67 +0,0 @@ - -
- - - - - -
- @csrf - - - - - - - - - - - - -
- - {{ __('Create account') }} - -
- - -
- {{ __('Already have an account?') }} - {{ __('Log in') }} -
-
-
diff --git a/resources/views/livewire/auth/reset-password.blade.php b/resources/views/livewire/auth/reset-password.blade.php deleted file mode 100644 index 1b6bd538..00000000 --- a/resources/views/livewire/auth/reset-password.blade.php +++ /dev/null @@ -1,52 +0,0 @@ - -
- - - - - -
- @csrf - - - - - - - - - - - - -
- - {{ __('Reset password') }} - -
- -
-
diff --git a/resources/views/livewire/auth/two-factor-challenge.blade.php b/resources/views/livewire/auth/two-factor-challenge.blade.php deleted file mode 100644 index bfba986d..00000000 --- a/resources/views/livewire/auth/two-factor-challenge.blade.php +++ /dev/null @@ -1,95 +0,0 @@ - -
-
-
- -
- -
- -
- -
- @csrf - -
-
-
- -
-
- -
-
- -
- - @error('recovery_code') - - {{ $message }} - - @enderror -
- - - {{ __('Continue') }} - -
- -
- {{ __('or you can') }} -
- {{ __('login using a recovery code') }} - {{ __('login using an authentication code') }} -
-
-
-
-
-
diff --git a/resources/views/livewire/auth/verify-email.blade.php b/resources/views/livewire/auth/verify-email.blade.php deleted file mode 100644 index 252d7bc4..00000000 --- a/resources/views/livewire/auth/verify-email.blade.php +++ /dev/null @@ -1,29 +0,0 @@ - -
- - {{ __('Please verify your email address by clicking on the link we just emailed to you.') }} - - - @if (session('status') == 'verification-link-sent') - - {{ __('A new verification link has been sent to the email address you provided during registration.') }} - - @endif - -
-
- @csrf - - {{ __('Resend verification email') }} - -
- -
- @csrf - - {{ __('Log out') }} - -
-
-
-
diff --git a/resources/views/livewire/settings/appearance.blade.php b/resources/views/livewire/settings/appearance.blade.php deleted file mode 100644 index 3272f6e5..00000000 --- a/resources/views/livewire/settings/appearance.blade.php +++ /dev/null @@ -1,13 +0,0 @@ -
- @include('partials.settings-heading') - - {{ __('Appearance Settings') }} - - - - {{ __('Light') }} - {{ __('Dark') }} - {{ __('System') }} - - -
diff --git a/resources/views/livewire/settings/delete-user-form.blade.php b/resources/views/livewire/settings/delete-user-form.blade.php deleted file mode 100644 index f8a0d4ea..00000000 --- a/resources/views/livewire/settings/delete-user-form.blade.php +++ /dev/null @@ -1,34 +0,0 @@ -
-
- {{ __('Delete account') }} - {{ __('Delete your account and all of its resources') }} -
- - - - {{ __('Delete account') }} - - - - -
-
- {{ __('Are you sure you want to delete your account?') }} - - - {{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }} - -
- - - -
- - {{ __('Cancel') }} - - - {{ __('Delete account') }} -
- -
-
diff --git a/resources/views/livewire/settings/password.blade.php b/resources/views/livewire/settings/password.blade.php deleted file mode 100644 index 10868a86..00000000 --- a/resources/views/livewire/settings/password.blade.php +++ /dev/null @@ -1,41 +0,0 @@ -
- @include('partials.settings-heading') - - {{ __('Password Settings') }} - - -
- - - - -
-
- {{ __('Save') }} -
- - - {{ __('Saved.') }} - -
- -
-
diff --git a/resources/views/livewire/settings/profile.blade.php b/resources/views/livewire/settings/profile.blade.php deleted file mode 100644 index 4de634b8..00000000 --- a/resources/views/livewire/settings/profile.blade.php +++ /dev/null @@ -1,47 +0,0 @@ -
- @include('partials.settings-heading') - - {{ __('Profile Settings') }} - - -
- - -
- - - @if ($this->hasUnverifiedEmail) -
- - {{ __('Your email address is unverified.') }} - - - {{ __('Click here to re-send the verification email.') }} - - - - @if (session('status') === 'verification-link-sent') - - {{ __('A new verification link has been sent to your email address.') }} - - @endif -
- @endif -
- -
-
- {{ __('Save') }} -
- - - {{ __('Saved.') }} - -
- - - @if ($this->showDeleteUser) - - @endif -
-
diff --git a/resources/views/livewire/settings/two-factor.blade.php b/resources/views/livewire/settings/two-factor.blade.php deleted file mode 100644 index fc01f3e7..00000000 --- a/resources/views/livewire/settings/two-factor.blade.php +++ /dev/null @@ -1,210 +0,0 @@ -
- @include('partials.settings-heading') - - {{ __('Two-Factor Authentication Settings') }} - - -
- @if ($twoFactorEnabled) -
-
- {{ __('Enabled') }} -
- - - {{ __('With two-factor authentication enabled, you will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }} - - - - -
- - {{ __('Disable 2FA') }} - -
-
- @else -
-
- {{ __('Disabled') }} -
- - - {{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }} - - - - {{ __('Enable 2FA') }} - -
- @endif -
-
- - -
-
-
-
-
- @for ($i = 1; $i <= 5; $i++) -
- @endfor -
- -
- @for ($i = 1; $i <= 5; $i++) -
- @endfor -
- - -
-
- -
- {{ $this->modalConfig['title'] }} - {{ $this->modalConfig['description'] }} -
-
- - @if ($showVerificationStep) -
-
- -
- -
- - {{ __('Back') }} - - - - {{ __('Confirm') }} - -
-
- @else - @error('setupData') - - @enderror - -
-
- @empty($qrCodeSvg) -
- -
- @else -
-
- {!! $qrCodeSvg !!} -
-
- @endempty -
-
- -
- - {{ $this->modalConfig['buttonText'] }} - -
- -
-
-
- - {{ __('or, enter the code manually') }} - -
- -
-
- @empty($manualSetupKey) -
- -
- @else - - - - @endempty -
-
-
- @endif -
-
-
diff --git a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php deleted file mode 100644 index 0c4232a8..00000000 --- a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php +++ /dev/null @@ -1,89 +0,0 @@ -
-
-
- - {{ __('2FA Recovery Codes') }} -
- - {{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }} - -
- -
-
- - - - {{ __('Hide Recovery Codes') }} - - - @if (filled($recoveryCodes)) - - {{ __('Regenerate Codes') }} - - @endif -
- -
-
- @error('recoveryCodes') - - @enderror - - @if (filled($recoveryCodes)) -
- @foreach($recoveryCodes as $code) -
- {{ $code }} -
- @endforeach -
- - {{ __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate Codes above.') }} - - @endif -
-
-
-
diff --git a/resources/views/livewire/storefront/account/addresses/index.blade.php b/resources/views/livewire/storefront/account/addresses/index.blade.php new file mode 100644 index 00000000..bd46f9fe --- /dev/null +++ b/resources/views/livewire/storefront/account/addresses/index.blade.php @@ -0,0 +1,156 @@ +
+
+

Your addresses

+ Add new address +
+ + @if ($addresses->isEmpty()) +

You have no saved addresses yet.

+ @else +
+ @foreach ($addresses as $address) + @php $data = $address->address_json ?? []; @endphp +
+
+ @if ($address->label) +

{{ $address->label }}

+ @else + + @endif + @if ($address->is_default) + Default + @endif +
+ +
+ {{ trim(($data['first_name'] ?? '').' '.($data['last_name'] ?? '')) }}
+ @if (! empty($data['company'])){{ $data['company'] }}
@endif + {{ $data['address1'] ?? '' }}
+ @if (! empty($data['address2'])){{ $data['address2'] }}
@endif + {{ $data['city'] ?? '' }}@if (! empty($data['province_code'] ?? $data['province'] ?? null)), {{ $data['province_code'] ?? $data['province'] }}@endif {{ $data['postal_code'] ?? '' }}
+ {{ $data['country'] ?? $data['country_code'] ?? '' }}
+ @if (! empty($data['phone'])){{ $data['phone'] }}
@endif +
+ +
+ + + @if (! $address->is_default) + + @endif +
+
+ @endforeach +
+ @endif + + {{-- Add/edit modal (spec 04 §10.6) --}} + +
+ {{ $editingId === null ? 'Add address' : 'Edit address' }} + +
+ + Label (optional) + + + + +
+ + First name + + + + + Last name + + + +
+ + + Company (optional) + + + + + + Address + + + + + + Apartment, suite, etc. (optional) + + + + +
+ + City + + + + + Postal code + + + +
+ +
+ + Province / state (optional) + + + + + Province code (optional) + + + +
+ +
+ + Country + + + + + Country code + + + +
+ + + Phone (optional) + + + + + + +
+ + Cancel + + Save address +
+ +
+
+
diff --git a/resources/views/livewire/storefront/account/auth/forgot-password.blade.php b/resources/views/livewire/storefront/account/auth/forgot-password.blade.php new file mode 100644 index 00000000..86aad471 --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/forgot-password.blade.php @@ -0,0 +1,26 @@ +
+

Forgot password

+

+ Enter your email and we will send you a reset link. +

+ + @if ($linkSent) + + If that email exists, we sent a reset link. + + @endif + +
+ + Email + + + + + Send reset link +
+ +

+ Back to log in +

+
diff --git a/resources/views/livewire/storefront/account/auth/login.blade.php b/resources/views/livewire/storefront/account/auth/login.blade.php new file mode 100644 index 00000000..3f368540 --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/login.blade.php @@ -0,0 +1,41 @@ +
+

Log in to your account

+ + @if (session('status')) + + {{ session('status') }} + + @endif + + @if ($errorMessage) + + {{ $errorMessage }} + + @endif + +
+ + Email + + + + + + Password + + + + +
+ + Forgot password? +
+ + Log in +
+ +

+ Don't have an account? + Create one +

+
diff --git a/resources/views/livewire/storefront/account/auth/register.blade.php b/resources/views/livewire/storefront/account/auth/register.blade.php new file mode 100644 index 00000000..0c1831be --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/register.blade.php @@ -0,0 +1,37 @@ +
+

Create an account

+ +
+ + Name + + + + + + Email + + + + + + Password + + + + + + Confirm password + + + + + + Create account + + +

+ Already have an account? + Log in +

+
diff --git a/resources/views/livewire/storefront/account/auth/reset-password.blade.php b/resources/views/livewire/storefront/account/auth/reset-password.blade.php new file mode 100644 index 00000000..71db17a7 --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/reset-password.blade.php @@ -0,0 +1,30 @@ +
+

Reset password

+ + @if ($errorMessage) + + {{ $errorMessage }} + + @endif + +
+ + Email + + + + + + New password + + + + + + Confirm password + + + + Reset password +
+
diff --git a/resources/views/livewire/storefront/account/dashboard.blade.php b/resources/views/livewire/storefront/account/dashboard.blade.php new file mode 100644 index 00000000..4c6d16ca --- /dev/null +++ b/resources/views/livewire/storefront/account/dashboard.blade.php @@ -0,0 +1,106 @@ +
+

Welcome back, {{ $customer->name }}!

+

{{ $customer->email }}

+ + {{-- Quick links (spec 04 §10.3) --}} +
+ + +

Order history

+

View all your orders

+
+ + + +

Addresses

+

Manage your addresses

+
+ +
+ @csrf + +
+
+ + {{-- Recent orders --}} +
+
+

Recent orders

+ + View all + +
+ + @if ($recentOrders->isEmpty()) +

You haven't placed any orders yet.

+ @else +
+ + + + + + + + + + + + @foreach ($recentOrders as $order) + + + + + + + + @endforeach + +
OrderDateStatusTotalView
{{ $order->order_number }}{{ $order->placed_at?->format('M j, Y') }}{{ \App\Support\Money::format($order->total_amount, $order->currency) }} + + View + +
+
+ @endif +
+ + {{-- Profile settings --}} +
+

Profile

+ + @if ($profileSaved) + + Your profile has been updated. + + @endif + +
+ + Name + + + + + + + Save + +
+
diff --git a/resources/views/livewire/storefront/account/orders/index.blade.php b/resources/views/livewire/storefront/account/orders/index.blade.php new file mode 100644 index 00000000..66ae6d03 --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/index.blade.php @@ -0,0 +1,76 @@ +
+ + +

Order history

+ + @if ($orders->isEmpty()) +

You haven't placed any orders yet.

+ @else + {{-- Table on desktop (spec 04 §10.4) --}} + + + + + + + + + + + + @foreach ($orders as $order) + + + + + + + + @endforeach + + + + {{-- Cards on mobile (spec 04 §10.4) --}} + + +
+ {{ $orders->links() }} +
+ @endif +
diff --git a/resources/views/livewire/storefront/account/orders/show.blade.php b/resources/views/livewire/storefront/account/orders/show.blade.php new file mode 100644 index 00000000..fcfdd7d6 --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/show.blade.php @@ -0,0 +1,220 @@ +@php + use App\Enums\FinancialStatus; + use App\Enums\FulfillmentShipmentStatus; + use App\Enums\PaymentMethod; + use App\Enums\PaymentStatus; + use App\Support\Money; + + $paymentMethodLabels = [ + PaymentMethod::CreditCard->value => 'Credit card', + PaymentMethod::Paypal->value => 'PayPal', + PaymentMethod::BankTransfer->value => 'Bank transfer', + ]; + + $formatAddress = function (?array $address): array { + if (empty($address)) { + return []; + } + + return array_values(array_filter([ + trim(($address['first_name'] ?? '').' '.($address['last_name'] ?? '')), + $address['company'] ?? null, + $address['address1'] ?? null, + $address['address2'] ?? null, + trim(($address['city'] ?? '').' '.($address['province_code'] ?? $address['province'] ?? '').' '.($address['postal_code'] ?? '')), + $address['country'] ?? $address['country_code'] ?? null, + $address['phone'] ?? null, + ])); + }; + + $shippingAddress = $formatAddress($order->shipping_address_json); + $billingAddress = $formatAddress($order->billing_address_json); + + $payment = $order->payments->firstWhere('status', PaymentStatus::Captured) ?? $order->payments->first(); + + $isPaid = in_array($order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded, FinancialStatus::Refunded], true); + $shippedAt = $order->fulfillments->whereNotNull('shipped_at')->min('shipped_at'); + $deliveredAt = $order->fulfillments->where('status', FulfillmentShipmentStatus::Delivered)->min('created_at'); + + $timeline = [ + ['label' => 'Placed', 'done' => $order->placed_at !== null, 'at' => $order->placed_at], + ['label' => 'Paid', 'done' => $isPaid, 'at' => $payment?->created_at], + ['label' => 'Shipped', 'done' => $shippedAt !== null, 'at' => $shippedAt], + ['label' => 'Delivered', 'done' => $deliveredAt !== null, 'at' => $deliveredAt], + ]; +@endphp + +
+ + + {{-- Header (spec 04 §10.5) --}} +
+

Order {{ $order->order_number }}

+
+ + +
+
+

Placed on {{ $order->placed_at?->format('F j, Y') }}

+ + {{-- Timeline: placed -> paid -> shipped -> delivered --}} +
    + @foreach ($timeline as $index => $step) +
  1. + @if ($index > 0) + + @endif + + @if ($step['done']) + + @else + + @endif + + {{ $step['label'] }} + @if ($step['done'] && $step['at'] !== null) + {{ \Illuminate\Support\Carbon::parse($step['at'])->format('M j, Y') }} + @endif + + +
  2. + @endforeach +
+ + {{-- Items --}} +
+

Items

+ + + @foreach ($order->lines as $line) + + + + + + @endforeach + +
+
+ @php $image = $line->variant?->product?->media->first(); @endphp + @if ($image !== null) + + @else + + @endif +
+

{{ $line->title_snapshot }}

+ @if ($line->sku_snapshot) +

SKU: {{ $line->sku_snapshot }}

+ @endif +
+
+
×{{ $line->quantity }}{{ Money::format($line->total_amount, $order->currency) }}
+
+ + {{-- Info grid: shipping / billing / payment --}} +
+
+

Shipping address

+
+ @forelse ($shippingAddress as $line) + {{ $line }}
+ @empty + No shipping address + @endforelse +
+
+
+

Billing address

+
+ @if ($billingAddress === $shippingAddress && $billingAddress !== []) + Same as shipping + @else + @forelse ($billingAddress as $line) + {{ $line }}
+ @empty + No billing address + @endforelse + @endif +
+
+
+

Payment

+

+ {{ $paymentMethodLabels[$order->payment_method->value] ?? $order->payment_method->value }} + @if ($payment !== null) + + {{ Money::format($payment->amount, $payment->currency) }} · {{ str_replace('_', ' ', $payment->status->value) }} + + @endif +

+
+
+ + {{-- Totals --}} +
+
+
Subtotal
+
{{ Money::format($order->subtotal_amount, $order->currency) }}
+
+
+
Shipping
+
{{ Money::format($order->shipping_amount, $order->currency) }}
+
+
+
Tax
+
{{ Money::format($order->tax_amount, $order->currency) }}
+
+
+
Discount
+
-{{ Money::format($order->discount_amount, $order->currency) }}
+
+
+
Total
+
{{ Money::format($order->total_amount, $order->currency) }}
+
+
+ + {{-- Fulfillment / tracking (only when fulfillments exist) --}} + @if ($order->fulfillments->isNotEmpty()) +
+

Fulfillment

+
+ @foreach ($order->fulfillments as $fulfillment) +
+

+ {{ $fulfillment->status->value }} + @if ($fulfillment->tracking_company) + via {{ $fulfillment->tracking_company }} + @endif + @if ($fulfillment->tracking_number) + · {{ $fulfillment->tracking_number }} + @endif +

+ @if ($fulfillment->tracking_url) + + Track shipment + + + @endif +
+ @endforeach +
+
+ @endif +
diff --git a/resources/views/livewire/storefront/cart-drawer.blade.php b/resources/views/livewire/storefront/cart-drawer.blade.php new file mode 100644 index 00000000..161d9383 --- /dev/null +++ b/resources/views/livewire/storefront/cart-drawer.blade.php @@ -0,0 +1,137 @@ +
+ {{-- Header --}} +
+

+ Your Cart @if ($cart !== null && $cart->itemCount() > 0) ({{ $cart->itemCount() }}) @endif +

+ +
+ + @if ($cart === null || $cart->lines->isEmpty()) + {{-- Empty state (spec 04 §6.7) --}} +
+ +

Your cart is empty

+ +
+ @else + {{-- Line items --}} +
    + @foreach ($cart->lines as $line) +
  • + @php $image = $line->variant?->product?->media->first(); @endphp + @if ($image !== null) + + @else + + @endif +
    +

    {{ $line->variant?->product?->title }}

    +

    {{ $line->variant?->title() }}

    +
    +
    + + {{ $line->quantity }} + +
    +

    {{ \App\Support\Money::format($line->line_total_amount, $cart->currency) }}

    +
    +
    + +
    +
    +
  • + @endforeach +
+ + {{-- Discount code (spec 04 §6.4) --}} +
+ @if ($discount !== null) +
+

+ {{ $discount['code'] }} ({{ $discount['label'] }}) +

+ +
+ @else +
+ + + +
+ @if ($discountError !== null) +

{{ $discountError }}

+ @endif + @endif +
+ + {{-- Totals (spec 04 §6.5) --}} +
+
+
+
Subtotal
+
{{ \App\Support\Money::format($cart->subtotal(), $cart->currency) }}
+
+ @if ($discount !== null && $discount['amount'] > 0) +
+
Discount ({{ $discount['code'] }})
+
-{{ \App\Support\Money::format($discount['amount'], $cart->currency) }}
+
+ @endif +
+
Estimated total
+
{{ \App\Support\Money::format($cart->subtotal() - ($discount['amount'] ?? 0), $cart->currency) }}
+
+
+

Shipping and taxes calculated at checkout

+ +
+ +
+
+ @endif +
diff --git a/resources/views/livewire/storefront/cart/show.blade.php b/resources/views/livewire/storefront/cart/show.blade.php new file mode 100644 index 00000000..bb7bf53b --- /dev/null +++ b/resources/views/livewire/storefront/cart/show.blade.php @@ -0,0 +1,174 @@ +
+

Your Cart

+ + @if ($cart === null || $cart->lines->isEmpty()) + {{-- Empty state (spec 04 §7.3) --}} +
+ +

Your cart is empty

+ + Continue shopping + +
+ @else +
+ {{-- Line items: table on desktop, cards on mobile (spec 04 §7.1) --}} +
+ + + + + + + + + + + + @foreach ($cart->lines as $line) + + + + + + + + @endforeach + + + + {{-- Mobile cards --}} +
    + @foreach ($cart->lines as $line) +
  • + @php $image = $line->variant?->product?->media->first(); @endphp + @if ($image !== null) + + @else + + @endif +
    +

    {{ $line->variant?->product?->title }}

    +

    {{ $line->variant?->title() }}

    +
    +
    + + {{ $line->quantity }} + +
    +

    {{ \App\Support\Money::format($line->line_total_amount, $cart->currency) }}

    +
    +
    +
  • + @endforeach +
+
+ + {{-- Order summary (spec 04 §7.2) --}} +
+
+

Order summary

+ +
+ @if ($discount !== null) +
+

{{ $discount['code'] }} ({{ $discount['label'] }})

+ +
+ @else +
+ + + +
+ @if ($discountError !== null) +

{{ $discountError }}

+ @endif + @endif +
+ +
+
+
Subtotal
+
{{ \App\Support\Money::format($cart->subtotal(), $cart->currency) }}
+
+ @if ($discount !== null && $discount['amount'] > 0) +
+
Discount
+
-{{ \App\Support\Money::format($discount['amount'], $cart->currency) }}
+
+ @endif +
+
Total
+
{{ \App\Support\Money::format($cart->subtotal() - ($discount['amount'] ?? 0), $cart->currency) }}
+
+
+

Shipping estimated at checkout. Taxes calculated at checkout.

+ + + +
+
+
+ @endif +
diff --git a/resources/views/livewire/storefront/checkout/confirmation.blade.php b/resources/views/livewire/storefront/checkout/confirmation.blade.php new file mode 100644 index 00000000..df003dd2 --- /dev/null +++ b/resources/views/livewire/storefront/checkout/confirmation.blade.php @@ -0,0 +1,122 @@ +@php + $order = $this->order; + $address = $order->shipping_address_json ?? []; + $payment = $order->payments->first(); +@endphp + +
+ {{-- Success header (spec 04 §9.1) --}} +
+
+ +
+

Thank you for your order!

+

Order {{ $order->order_number }}

+

We've sent a confirmation to {{ $order->email }}

+
+ + {{-- Items (spec 04 §9.2) --}} +
+

Order Summary

+
    + @foreach ($order->lines as $line) +
  • +
    +

    {{ $line->title_snapshot }}

    + @if ($line->sku_snapshot) +

    SKU: {{ $line->sku_snapshot }}

    + @endif +
    +

    ×{{ $line->quantity }}

    +

    {{ \App\Support\Money::format($line->total_amount, $order->currency) }}

    +
  • + @endforeach +
+
+ + {{-- Address & payment (spec 04 §9.2) --}} +
+
+

Shipping Address

+
+ {{ $address['first_name'] ?? '' }} {{ $address['last_name'] ?? '' }}
+ {{ $address['address1'] ?? '' }}
+ {{ $address['postal_code'] ?? '' }} {{ $address['city'] ?? '' }}
+ {{ $address['country_code'] ?? $address['country'] ?? '' }} +
+
+
+

Payment Method

+

+ @if ($order->payment_method->value === 'credit_card') + Credit Card @if ($paymentLast4) ending in {{ $paymentLast4 }} @endif + @elseif ($order->payment_method->value === 'paypal') + PayPal + @else + Bank Transfer + @endif +

+
+
+ + {{-- Bank transfer instructions (spec 04 §9.2) --}} + @if ($isBankTransfer) +
+

+ + Bank Transfer Instructions +

+

Please transfer the total amount to the following account:

+
+
Bank
Mock Bank AG
+
IBAN
DE89 3704 0044 0532 0130 00
+
BIC
COBADEFFXXX
+
Amount
{{ \App\Support\Money::format($order->total_amount, $order->currency) }}
+
Reference
{{ $order->order_number }}
+
+

Please complete your transfer within 7 days. Your order will be processed once payment is confirmed by our team.

+
+ @endif + + {{-- Totals --}} +
+
+
Subtotal
+
{{ \App\Support\Money::format($order->subtotal_amount, $order->currency) }}
+
+ @if ($order->discount_amount > 0) +
+
Discount
+
-{{ \App\Support\Money::format($order->discount_amount, $order->currency) }}
+
+ @endif +
+
Shipping
+
{{ \App\Support\Money::format($order->shipping_amount, $order->currency) }}
+
+
+
Tax
+
{{ \App\Support\Money::format($order->tax_amount, $order->currency) }}
+
+
+
Total
+
{{ \App\Support\Money::format($order->total_amount, $order->currency) }}
+
+
+ + {{-- Actions (spec 04 §9.3) --}} + +
diff --git a/resources/views/livewire/storefront/checkout/show.blade.php b/resources/views/livewire/storefront/checkout/show.blade.php new file mode 100644 index 00000000..4b5be1b4 --- /dev/null +++ b/resources/views/livewire/storefront/checkout/show.blade.php @@ -0,0 +1,320 @@ +
+ @if ($expired) + {{-- Expired checkout (410-style) --}} +
+ +

This checkout has expired

+

Checkouts are held for 24 hours. Your cart is still saved — start a new checkout when you're ready.

+ + Return to cart + +
+ @else + @php + $totals = $checkout?->totals_json ?? []; + $summaryLines = $checkout?->cart?->lines ?? $previewCart?->lines ?? collect(); + $summaryCurrency = $checkout?->cart?->currency ?? $previewCart?->currency ?? app('current_store')->default_currency; + $summarySubtotal = $checkout !== null ? ($totals['subtotal'] ?? 0) : ($previewCart?->subtotal() ?? 0); + $summaryDiscount = $totals['discount'] ?? 0; + $summaryShipping = $totals['shipping'] ?? null; + $summaryTax = $totals['tax'] ?? 0; + $summaryTotal = $checkout !== null ? ($totals['total'] ?? 0) : $summarySubtotal; + @endphp + +

Checkout

+ +
+ {{-- Steps --}} +
+ {{-- Step 1: Contact & shipping address --}} +
+

+ 1. Contact & shipping address + @if ($step > 1) + {{ $email }} + @endif +

+ + @if ($step === 1) +
+
+ + + @error('email')

{{ $message }}

@enderror +
+ +
+ @foreach ([ + 'first_name' => ['label' => 'First name', 'required' => true], + 'last_name' => ['label' => 'Last name', 'required' => true], + 'address1' => ['label' => 'Address line 1', 'required' => true, 'full' => true], + 'address2' => ['label' => 'Address line 2 (optional)', 'required' => false, 'full' => true], + 'city' => ['label' => 'City', 'required' => true], + 'province' => ['label' => 'State / Province (optional)', 'required' => false], + 'postal_code' => ['label' => 'Postal code', 'required' => true], + 'country_code' => ['label' => 'Country code (e.g. DE)', 'required' => true], + 'phone' => ['label' => 'Phone (optional)', 'required' => false, 'full' => true], + ] as $field => $options) +
+ + + @error('address.'.$field)

{{ $message }}

@enderror +
+ @endforeach +
+ + @error('address.country')

{{ $message }}

@enderror + @error('shipping_address')

{{ $message }}

@enderror + + + + +
+ @elseif ($checkout !== null && ! empty($checkout->shipping_address_json)) +
+ {{ $checkout->shipping_address_json['first_name'] ?? '' }} {{ $checkout->shipping_address_json['last_name'] ?? '' }}, + {{ $checkout->shipping_address_json['address1'] ?? '' }}, + {{ $checkout->shipping_address_json['postal_code'] ?? '' }} {{ $checkout->shipping_address_json['city'] ?? '' }}, + {{ $checkout->shipping_address_json['country_code'] ?? '' }} +
+ @endif +
+ + {{-- Step 2: Shipping method --}} +
+

+ 2. Shipping method +

+ + @if ($step === 2 && $checkout !== null) + @if (! $checkout->requiresShipping()) +
+

This order does not require shipping.

+ +
+ @elseif ($rates->isEmpty()) +
+

+ + No shipping methods are available for your address. Please verify your address or contact us. +

+
+ @else +
+ Available shipping methods + @foreach ($rates as $rate) + + @endforeach + @error('shippingMethodId')

{{ $message }}

@enderror +
+ @endif + @elseif ($step > 2 && $checkout !== null) +
+ {{ $checkout->requiresShipping() ? 'Shipping method selected' : 'No shipping required' }} +
+ @endif +
+ + {{-- Step 3: Payment method --}} +
+

+ 3. Payment +

+ + @if ($step === 3) +
+
+ Select a payment method + @foreach (['credit_card' => 'Credit Card', 'paypal' => 'PayPal', 'bank_transfer' => 'Bank Transfer'] as $value => $label) + + @endforeach +
+ + @if (! $paymentSelected) + + @else + @php $storedMethod = $checkout?->payment_method?->value ?? $paymentMethod; @endphp + +
+ @if ($storedMethod === 'credit_card') +
+ + + @error('cardNumber')

{{ $message }}

@enderror +
+
+ + + @error('cardHolder')

{{ $message }}

@enderror +
+
+
+ + + @error('cardExpiry')

{{ $message }}

@enderror +
+
+ + + @error('cardCvc')

{{ $message }}

@enderror +
+
+ @elseif ($storedMethod === 'paypal') +

Your PayPal payment will be processed securely.

+ @else +

After placing your order, you will receive bank transfer instructions. Your order will be held while we await your payment.

+ @endif + + @if ($paymentError !== null) + + @endif + + +
+ @endif +
+ @endif +
+
+ + {{-- Order summary (spec 04 §8.3) --}} + +
+ @endif +
diff --git a/resources/views/livewire/storefront/collections/index.blade.php b/resources/views/livewire/storefront/collections/index.blade.php new file mode 100644 index 00000000..1b8263f5 --- /dev/null +++ b/resources/views/livewire/storefront/collections/index.blade.php @@ -0,0 +1,32 @@ +
+ + +

Collections

+ + @if ($collections->isEmpty()) +
+ +

No collections yet

+

Check back soon for new collections.

+
+ @else +
+ @foreach ($collections as $collection) + + +
+ {{ $collection->title }} + Shop now +
+
+ @endforeach +
+ @endif +
diff --git a/resources/views/livewire/storefront/collections/partials/filters.blade.php b/resources/views/livewire/storefront/collections/partials/filters.blade.php new file mode 100644 index 00000000..55b3767e --- /dev/null +++ b/resources/views/livewire/storefront/collections/partials/filters.blade.php @@ -0,0 +1,70 @@ +{{-- Filter groups shared between the desktop sidebar and the mobile drawer. --}} +
+ @if (count($activeFilters) > 0) +
+ +
+ @endif + + {{-- Availability --}} +
+ Availability +
+ +
+
+ + {{-- Price range --}} +
+ Price +
+ + + + + +
+
+ + {{-- Product type --}} + @if (count($availableTypes) > 0) +
+ Product type +
+ @foreach ($availableTypes as $type) + + @endforeach +
+
+ @endif + + {{-- Vendor --}} + @if (count($availableVendors) > 0) +
+ Vendor +
+ @foreach ($availableVendors as $vendor) + + @endforeach +
+
+ @endif +
diff --git a/resources/views/livewire/storefront/collections/show.blade.php b/resources/views/livewire/storefront/collections/show.blade.php new file mode 100644 index 00000000..5da89aa1 --- /dev/null +++ b/resources/views/livewire/storefront/collections/show.blade.php @@ -0,0 +1,124 @@ +
+ + + {{-- Collection header --}} +
+

{{ $collection->title }}

+ @if (! empty($collection->description_html)) +
+ {!! $collection->description_html !!} +
+ @endif +
+ + {{-- Toolbar --}} +
+ + +
+ + +
+
+ + {{-- Active filter pills --}} + @if (count($activeFilters) > 0) +
+ @foreach ($activeFilters as $filter) + + {{ $filter }} + + @endforeach + +
+ @endif + +
+ {{-- Filter sidebar (desktop persistent / mobile drawer) --}} + + + {{-- Mobile filter drawer --}} + + + {{-- Product grid --}} +
+ @if ($products->isEmpty()) +
+ +

No products found

+

Try adjusting your filters or browse our full collection.

+ @if (count($activeFilters) > 0) + + @endif +
+ @else +
+ @foreach ($products as $product) + + @endforeach +
+
+ +
+ @endif +
+
+
diff --git a/resources/views/livewire/storefront/home.blade.php b/resources/views/livewire/storefront/home.blade.php new file mode 100644 index 00000000..63f2cab3 --- /dev/null +++ b/resources/views/livewire/storefront/home.blade.php @@ -0,0 +1,93 @@ +
+ @foreach ($sections as $section) + @if ($section === 'hero') + {{-- Hero banner --}} +
+ @if (! empty($hero['image_url'])) + + @else + + @endif + +
+

+ {{ $hero['heading'] ?? app('current_store')->name }} +

+ @if (! empty($hero['subheading'])) +

{{ $hero['subheading'] }}

+ @endif + @if (! empty($hero['cta_label']) && ! empty($hero['cta_url'])) + + {{ $hero['cta_label'] }} + + @endif +
+
+ @elseif ($section === 'featured_collections') + {{-- Featured collections --}} +
+ + @if ($featuredCollections->isNotEmpty()) +
+ @foreach ($featuredCollections as $collection) + + +
+ {{ $collection->title }} + Shop now +
+
+ @endforeach +
+ @endif +
+ @elseif ($section === 'featured_products') + {{-- Featured products --}} +
+ + @if ($featuredProducts->isNotEmpty()) +
+ @foreach ($featuredProducts as $product) + + @endforeach +
+ @endif +
+ @elseif ($section === 'newsletter') + {{-- Newsletter signup (client-side only until subscriptions are implemented) --}} +
+
+

Stay in the loop

+

Subscribe for exclusive offers and updates.

+
+ + +
+
+
+ @elseif ($section === 'rich_text' && ! empty($richTextHtml)) + {{-- Rich text --}} +
+
+ {!! app(\App\Actions\SanitizeHtml::class)($richTextHtml) !!} +
+
+ @endif + @endforeach +
diff --git a/resources/views/livewire/storefront/pages/show.blade.php b/resources/views/livewire/storefront/pages/show.blade.php new file mode 100644 index 00000000..f62d32f2 --- /dev/null +++ b/resources/views/livewire/storefront/pages/show.blade.php @@ -0,0 +1,14 @@ +
+ + +

{{ $page->title }}

+ + @if (! empty($page->body_html)) +
+ {!! $page->body_html !!} +
+ @endif +
diff --git a/resources/views/livewire/storefront/products/show.blade.php b/resources/views/livewire/storefront/products/show.blade.php new file mode 100644 index 00000000..041814f6 --- /dev/null +++ b/resources/views/livewire/storefront/products/show.blade.php @@ -0,0 +1,219 @@ +
+ @php + /** @var \App\Models\Product $product */ + $variant = $this->selectedVariant(); + $images = $product->media->filter(fn (\App\Models\ProductMedia $media): bool => $media->status === \App\Enums\MediaStatus::Ready)->values(); + $primaryCollection = $product->collections->first(); + $breadcrumbs = [['label' => 'Home', 'url' => route('storefront.home')]]; + if ($primaryCollection !== null) { + $breadcrumbs[] = ['label' => $primaryCollection->title, 'url' => route('storefront.collections.show', ['handle' => $primaryCollection->handle])]; + } + $breadcrumbs[] = ['label' => $product->title]; + $onSale = $variant !== null && $variant->compare_at_amount !== null && $variant->compare_at_amount > $variant->price_amount; + + $stockStyles = [ + 'in_stock' => 'text-green-600 dark:text-green-400', + 'low_stock' => 'text-amber-600 dark:text-amber-400', + 'out_of_stock' => 'text-red-600 dark:text-red-400', + 'backorder' => 'text-blue-600 dark:text-blue-400', + 'unavailable' => 'text-gray-500 dark:text-gray-400', + ]; + + $swatchColors = [ + 'black' => '#111827', 'white' => '#f9fafb', 'gray' => '#6b7280', 'grey' => '#6b7280', + 'red' => '#dc2626', 'orange' => '#ea580c', 'amber' => '#d97706', 'yellow' => '#eab308', + 'green' => '#16a34a', 'teal' => '#0d9488', 'blue' => '#2563eb', 'navy' => '#1e3a8a', + 'purple' => '#9333ea', 'pink' => '#db2777', 'brown' => '#92400e', 'beige' => '#d6c9b0', + ]; + @endphp + + + +
+ {{-- Image gallery --}} +
+ {{-- Desktop: main image + thumbnails --}} + + + {{-- Mobile: snap-scroll gallery with dots --}} +
+
+ @forelse ($images as $image) +
+ {{ $image->alt_text ?? $product->title }} +
+ @empty +
+ +
+ @endforelse +
+ @if ($images->count() > 1) + + @endif +
+
+ + {{-- Product info --}} +
+

{{ $product->title }}

+ + @if (! empty($product->vendor)) +

{{ $product->vendor }}

+ @endif + +
+ @if ($variant !== null) + + {{ \App\Support\Money::format($variant->price_amount, $currency) }} + + @if ($onSale) + {{ \App\Support\Money::format($variant->compare_at_amount, $currency) }} + + @endif + @else + Unavailable + @endif +
+ + {{-- Variant selectors --}} + @foreach ($product->options as $option) +
+ + {{ $option->name }}@if (! empty($selectedOptions[$option->name])){{ $selectedOptions[$option->name] }}@endif + + @if ($option->name === 'Color') +
+ @foreach ($option->values as $value) + @php $available = $this->isValueAvailable($option, $value->value); @endphp + + @endforeach +
+ @elseif ($option->values->count() <= 6) +
+ @foreach ($option->values as $value) + @php $available = $this->isValueAvailable($option, $value->value); @endphp + + @endforeach +
+ @else + + + @endif +
+ @endforeach + + {{-- Stock messaging --}} +

+ @if ($stock['state'] === 'in_stock') + + @elseif ($stock['state'] === 'low_stock') + + @elseif ($stock['state'] === 'out_of_stock') + + @else + + @endif + {{ $stock['message'] }} +

+ + {{-- Quantity + add to cart --}} +
+ @if ($stock['purchasable']) + + @endif + +
+ + {{-- Description --}} + @if (! empty($product->description_html)) +
+
+ {!! $product->description_html !!} +
+ @endif + + {{-- Tags --}} + @if (! empty($product->tags)) +
+ @foreach ($product->tags as $tag) + {{ $tag }} + @endforeach +
+ @endif +
+
+ + +
diff --git a/resources/views/livewire/storefront/search/index.blade.php b/resources/views/livewire/storefront/search/index.blade.php new file mode 100644 index 00000000..960d9e9e --- /dev/null +++ b/resources/views/livewire/storefront/search/index.blade.php @@ -0,0 +1,152 @@ +
+ + + {{-- Search header --}} +
+

+ @if (trim($query) !== '' && $products !== null) + {{ $products->total() }} {{ str('result')->plural($products->total()) }} for “{{ $query }}” + @elseif (trim($query) !== '') + Search results for “{{ $query }}” + @else + Search + @endif +

+ +
+ + @if (trim($query) === '') +
+ +

Search our store

+

Type a keyword above to find products.

+
+ @else + {{-- Toolbar --}} +
+ + +
+ + +
+
+ + {{-- Active filter pills --}} + @if (count($activeFilters) > 0) +
+ @foreach ($activeFilters as $filter) + + {{ $filter }} + + @endforeach + +
+ @endif + +
+ {{-- Filter sidebar (desktop persistent / mobile drawer) --}} + + + {{-- Mobile filter drawer --}} + + + {{-- Results grid --}} +
+ @if ($products->isEmpty()) +
+ +

No results found for “{{ $query }}”

+

Try a different search term or adjust your filters.

+ @if (count($activeFilters) > 0) + + @endif +
+ @else +
+ @foreach ($products as $product) + + @endforeach +
+
+ +
+ @endif +
+
+ @endif +
diff --git a/resources/views/livewire/storefront/search/modal.blade.php b/resources/views/livewire/storefront/search/modal.blade.php new file mode 100644 index 00000000..231ede5b --- /dev/null +++ b/resources/views/livewire/storefront/search/modal.blade.php @@ -0,0 +1,137 @@ +{{-- Search-as-you-type modal (spec 04 §11.1). Opened via the header search icon. --}} +
+
+ + +
+
+ {{-- Input row --}} + + + {{-- Loading skeleton --}} + + + @if (trim($query) !== '') +
+ @if ($suggestions->isEmpty()) +

No results for “{{ $query }}”

+ @else + + + + View all results for “{{ $query }}” → + + @endif +
+ @endif +
+
+
+
diff --git a/resources/views/livewire/storefront/search/partials/filters.blade.php b/resources/views/livewire/storefront/search/partials/filters.blade.php new file mode 100644 index 00000000..9b469e92 --- /dev/null +++ b/resources/views/livewire/storefront/search/partials/filters.blade.php @@ -0,0 +1,73 @@ +{{-- Filter groups shared between the desktop sidebar and the mobile drawer. --}} +
+ @if (count($activeFilters) > 0) +
+ +
+ @endif + + {{-- Availability --}} +
+ Availability +
+ +
+
+ + {{-- Price range --}} +
+ Price +
+ + + + + +
+
+ + {{-- Collection --}} + @if ($availableCollections->isNotEmpty()) +
+ Collection +
+ + +
+
+ @endif + + {{-- Vendor --}} + @if ($facets !== null && count($facets['vendors']) > 0) +
+ Vendor +
+ @foreach ($facets['vendors'] as $vendor) + + @endforeach +
+
+ @endif +
diff --git a/resources/views/partials/head.blade.php b/resources/views/partials/head.blade.php deleted file mode 100644 index dce80588..00000000 --- a/resources/views/partials/head.blade.php +++ /dev/null @@ -1,14 +0,0 @@ - - - -{{ $title ?? config('app.name') }} - - - - - - - - -@vite(['resources/css/app.css', 'resources/js/app.js']) -@fluxAppearance diff --git a/resources/views/partials/settings-heading.blade.php b/resources/views/partials/settings-heading.blade.php deleted file mode 100644 index 925ace9a..00000000 --- a/resources/views/partials/settings-heading.blade.php +++ /dev/null @@ -1,5 +0,0 @@ -
- {{ __('Settings') }} - {{ __('Manage your profile and account settings') }} - -
diff --git a/resources/views/storefront/components/badge.blade.php b/resources/views/storefront/components/badge.blade.php new file mode 100644 index 00000000..fec893e5 --- /dev/null +++ b/resources/views/storefront/components/badge.blade.php @@ -0,0 +1,25 @@ +{{-- + Styled badge/tag (spec 04 §16). + + Props: + - text: string (required) — badge text + - variant: string — sale | sold-out | new | default +--}} +@props([ + 'text', + 'variant' => 'default', +]) + +@php + $variantClasses = [ + 'sale' => 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400', + 'sold-out' => 'bg-gray-200 text-gray-600 dark:bg-gray-800 dark:text-gray-300', + 'new' => 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400', + 'default' => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300', + ]; + $classes = $variantClasses[$variant] ?? $variantClasses['default']; +@endphp + +class('inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium '.$classes) }}> + {{ $variant === 'sale' ? 'On sale: ' : '' }}{{ $text }} + diff --git a/resources/views/storefront/components/breadcrumbs.blade.php b/resources/views/storefront/components/breadcrumbs.blade.php new file mode 100644 index 00000000..5e86bd65 --- /dev/null +++ b/resources/views/storefront/components/breadcrumbs.blade.php @@ -0,0 +1,43 @@ +{{-- + Breadcrumb navigation trail with BreadcrumbList structured data (spec 04 §16). + + Props: + - items: array (required) — list of ['label' => string, 'url' => string|null]. + The last item is the current page and needs no URL. +--}} +@props(['items']) + +@php + $items = array_values($items); + $lastIndex = count($items) - 1; + $jsonLd = [ + '@context' => 'https://schema.org', + '@type' => 'BreadcrumbList', + 'itemListElement' => collect($items)->map(fn (array $item, int $index): array => array_filter([ + '@type' => 'ListItem', + 'position' => $index + 1, + 'name' => $item['label'], + 'item' => isset($item['url']) ? url($item['url']) : null, + ]))->values()->all(), + ]; +@endphp + +@if (count($items) > 1) + +@endif diff --git a/resources/views/storefront/components/order-status-badge.blade.php b/resources/views/storefront/components/order-status-badge.blade.php new file mode 100644 index 00000000..6ee32069 --- /dev/null +++ b/resources/views/storefront/components/order-status-badge.blade.php @@ -0,0 +1,25 @@ +{{-- + Order status badge (spec 04 §10.4: pending yellow, paid green, + fulfilled blue, cancelled gray, refunded red). + + Props: + - status: string (required) — order status value (pending|paid|fulfilled|cancelled|refunded) +--}} +@props([ + 'status', +]) + +@php + $variantClasses = [ + 'pending' => 'bg-yellow-100 text-yellow-800 dark:bg-yellow-950 dark:text-yellow-300', + 'paid' => 'bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-400', + 'fulfilled' => 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400', + 'cancelled' => 'bg-gray-200 text-gray-600 dark:bg-gray-800 dark:text-gray-300', + 'refunded' => 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400', + ]; + $classes = $variantClasses[$status] ?? $variantClasses['pending']; +@endphp + +class('inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize '.$classes) }}> + {{ str_replace('_', ' ', $status) }} + diff --git a/resources/views/storefront/components/pagination.blade.php b/resources/views/storefront/components/pagination.blade.php new file mode 100644 index 00000000..fe66fdb1 --- /dev/null +++ b/resources/views/storefront/components/pagination.blade.php @@ -0,0 +1,74 @@ +{{-- + Numbered pagination with previous/next arrows (spec 04 §4.6 + §16). + + Props: + - paginator: LengthAwarePaginator (required) +--}} +@props(['paginator']) + +@php + /** @var \Illuminate\Pagination\LengthAwarePaginator $paginator */ + $current = $paginator->currentPage(); + $last = $paginator->lastPage(); + + // Windowed page list with ellipsis markers. + $pages = []; + $window = 2; + for ($page = 1; $page <= $last; $page++) { + if ($page === 1 || $page === $last || abs($page - $current) <= $window) { + $pages[] = $page; + } elseif (end($pages) !== '…') { + $pages[] = '…'; + } + } + + $linkClasses = 'inline-flex min-w-9 items-center justify-center rounded-md px-3 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-blue-500'; + $mutedClasses = 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'; + $disabledClasses = 'cursor-not-allowed text-gray-400 opacity-60 dark:text-gray-600'; +@endphp + +@if ($paginator->hasPages()) + +@endif diff --git a/resources/views/storefront/components/price.blade.php b/resources/views/storefront/components/price.blade.php new file mode 100644 index 00000000..7aec4de4 --- /dev/null +++ b/resources/views/storefront/components/price.blade.php @@ -0,0 +1,25 @@ +{{-- + Formatted price with optional compare-at price (spec 04 §16). + + Props: + - amount: int (required) — price in minor units (cents) + - currency: string — ISO 4217 code (default: USD) + - compareAtAmount: int|null — original price in minor units for sale display +--}} +@props([ + 'amount', + 'currency' => 'USD', + 'compareAtAmount' => null, +]) + +@php + $onSale = $compareAtAmount !== null && (int) $compareAtAmount > (int) $amount; +@endphp + +class('inline-flex flex-wrap items-center gap-x-2 gap-y-1') }}> + {{ \App\Support\Money::format((int) $amount, $currency) }} + @if ($onSale) + {{ \App\Support\Money::format((int) $compareAtAmount, $currency) }} + + @endif + diff --git a/resources/views/storefront/components/product-card.blade.php b/resources/views/storefront/components/product-card.blade.php new file mode 100644 index 00000000..9c5e746a --- /dev/null +++ b/resources/views/storefront/components/product-card.blade.php @@ -0,0 +1,97 @@ +{{-- + Product card used in grids (spec 04 §4.5 + §16). + + Props: + - product: Product (required) — with variants.inventoryItem + media eager-loaded + - headingLevel: string — heading tag for the title (default: h3) + - showQuickAdd: bool — show quick add / choose options action (default: true) +--}} +@props([ + 'product', + 'headingLevel' => 'h3', + 'showQuickAdd' => true, +]) + +@php + /** @var \App\Models\Product $product */ + $variants = $product->variants; + $defaultVariant = $variants->firstWhere('is_default', true) ?? $variants->first(); + $currency = $defaultVariant?->currency ?? app('current_store')->default_currency ?? 'USD'; + $minPrice = $variants->min('price_amount'); + $maxCompareAt = $variants->max('compare_at_amount'); + $onSale = $maxCompareAt !== null && $minPrice !== null && $maxCompareAt > $minPrice; + $soldOut = $variants->isNotEmpty() && $variants->every( + fn (\App\Models\ProductVariant $variant): bool => ! $variant->isInStock() && ! $variant->isBackorderable() + ); + $images = $product->media->filter( + fn (\App\Models\ProductMedia $media): bool => $media->status === \App\Enums\MediaStatus::Ready + )->values(); + $primaryImage = $images->first(); + $hoverImage = $images->get(1); + $productUrl = route('storefront.products.show', ['handle' => $product->handle]); + $singleVariant = $variants->count() === 1; +@endphp + +
class('group relative flex flex-col') }}> +
+ +
+ @if ($onSale) + + @endif + @if ($soldOut) + + @endif +
+
+ + + <{{ $headingLevel }} class="line-clamp-2 text-sm font-semibold text-gray-900 dark:text-white"> + {{ $product->title }} + + + @if ($minPrice !== null) + + @endif + + + + @if ($showQuickAdd && $defaultVariant) +
+ @if ($soldOut) + Sold out + @elseif ($singleVariant) + + @else + + Choose options + + @endif +
+ @endif +
diff --git a/resources/views/storefront/components/quantity-selector.blade.php b/resources/views/storefront/components/quantity-selector.blade.php new file mode 100644 index 00000000..9e44702d --- /dev/null +++ b/resources/views/storefront/components/quantity-selector.blade.php @@ -0,0 +1,52 @@ +{{-- + Quantity stepper with increment/decrement buttons (spec 04 §16). + + Props: + - value: int — current quantity (default: 1) + - min: int — minimum allowed value (default: 1) + - max: int|null — maximum allowed value (null = unlimited) + - wireModel: string (required) — Livewire model binding for the input + - compact: bool — smaller variant for the cart drawer +--}} +@props([ + 'value' => 1, + 'min' => 1, + 'max' => null, + 'wireModel', + 'compact' => false, +]) + +@php + $buttonSize = $compact ? 'size-8' : 'size-10'; + $inputSize = $compact ? 'h-8 w-10' : 'h-10 w-14'; + $decreased = max($min, (int) $value - 1); + $increased = $max === null ? (int) $value + 1 : min((int) $max, (int) $value + 1); +@endphp + +
class('inline-flex items-center rounded-md border border-gray-300 dark:border-gray-700') }}> + + + + +
diff --git a/resources/views/storefront/layouts/app.blade.php b/resources/views/storefront/layouts/app.blade.php new file mode 100644 index 00000000..e8293db5 --- /dev/null +++ b/resources/views/storefront/layouts/app.blade.php @@ -0,0 +1,396 @@ +@php + /** @var \App\Models\Store $currentStore */ + $themeSettings = app(\App\Services\ThemeSettingsService::class); + $navigation = app(\App\Services\NavigationService::class); + $mainMenu = $navigation->forHandle('main-menu'); + $footerMenu = $navigation->forHandle('footer-menu'); + $announcement = $themeSettings->get('announcement', []); + $logoUrl = $themeSettings->get('header.logo_url'); + $isSticky = (bool) $themeSettings->get('header.sticky', false); + $darkMode = $themeSettings->get('dark_mode', 'system'); + $socialLinks = $themeSettings->get('footer.social', []); + $pageTitle = $title ?? $currentStore->name; + $organizationJsonLd = [ + '@context' => 'https://schema.org', + '@type' => 'Organization', + 'name' => $currentStore->name, + 'url' => url('/'), + ]; +@endphp + + + + + + + {{ $pageTitle }} + @if (! empty($metaDescription)) + + @endif + + @if (! empty($og) && is_array($og)) + + @if (! empty($og['description'])) + + @endif + @if (! empty($og['image'])) + + @endif + + + @if (! empty($og['price_amount'])) + + + @endif + @endif + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + + + + Skip to main content + + + {{-- Announcement bar --}} + @if (! empty($announcement['enabled']) && ! empty($announcement['text'])) +
+
+

+ {{ $announcement['text'] }} + @if (! empty($announcement['link'])) + Learn more + @endif +

+ +
+
+ @endif + + {{-- Header --}} +
+
+ {{-- Mobile hamburger --}} + + + {{-- Logo --}} + + @if ($logoUrl) + {{ $currentStore->name }} + @else + {{ $currentStore->name }} + @endif + + + {{-- Desktop navigation --}} + + + {{-- Right icon group --}} +
+ + + +
+
+ + {{-- Mobile navigation drawer --}} + +
+ + {{-- Main content --}} +
+ {{ $slot }} +
+ + {{-- Footer --}} +
+
+
+ @if (count($footerMenu) > 0) +
+

Shop

+ +
+ @endif +
+

{{ $currentStore->name }}

+ @if (! empty($themeSettings->get('footer.about'))) +

{{ $themeSettings->get('footer.about') }}

+ @endif +
+
+ + @if (count($socialLinks) > 0) +
+ @foreach ($socialLinks as $network => $url) + + {{ ucfirst($network) }} + + + @endforeach +
+ @endif + +
+

© {{ date('Y') }} {{ $currentStore->name }}. All rights reserved.

+ {{-- Accepted payment methods (placeholder icons until payments land) --}} +
+ VISA + MASTERCARD + AMEX + PAYPAL +
+
+
+ +
+ + {{-- Cart drawer (shell here, content in the CartDrawer Livewire component) --}} +
+
+ +
+ +
+
+
+ + {{-- Search modal (spec 04 §11.1) --}} + + + {{-- Toast notifications (spec 04 §21) --}} +
+ +
+ + @fluxScripts + + diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php deleted file mode 100644 index a808a399..00000000 --- a/resources/views/welcome.blade.php +++ /dev/null @@ -1,278 +0,0 @@ - - - - - - - Laravel - - - - - - - - - - - - - -
- @if (Route::has('login')) - - @endif -
-
-
-
-

Let's get started

-

Laravel has an incredibly rich ecosystem.
We suggest starting with the following.

- - -
-
- {{-- Laravel Logo --}} - - - - - - - - - - - {{-- Light Mode 12 SVG --}} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{-- Dark Mode 12 SVG --}} - -
-
-
-
- - @if (Route::has('login')) - - @endif - - diff --git a/routes/admin.php b/routes/admin.php new file mode 100644 index 00000000..104940c6 --- /dev/null +++ b/routes/admin.php @@ -0,0 +1,91 @@ +name('admin.')->group(function (): void { + // Auth pages (spec 02 §1.1). Submissions are handled by Livewire actions. + Route::livewire('/login', Login::class)->name('login'); + Route::livewire('/forgot-password', ForgotPassword::class)->name('password.request'); + Route::livewire('/reset-password/{token}', ResetPassword::class)->name('password.reset'); + + Route::post('/logout', LogoutController::class)->name('logout'); + + Route::middleware(['auth', 'verified', 'store.resolve.admin', 'role.check.any'])->group(function (): void { + Route::livewire('/', Dashboard::class)->name('dashboard'); + + Route::livewire('/products', Products\Index::class)->name('products.index'); + Route::livewire('/products/create', Products\Form::class)->name('products.create'); + Route::livewire('/products/{product}/edit', Products\Form::class)->name('products.edit'); + + Route::livewire('/collections', Collections\Index::class)->name('collections.index'); + Route::livewire('/collections/create', Collections\Form::class)->name('collections.create'); + Route::livewire('/collections/{collection}/edit', Collections\Form::class)->name('collections.edit'); + + Route::livewire('/inventory', Inventory\Index::class)->name('inventory.index'); + + Route::livewire('/orders', Orders\Index::class)->name('orders.index'); + Route::livewire('/orders/{order}', Orders\Show::class)->name('orders.show'); + + Route::livewire('/customers', Customers\Index::class)->name('customers.index'); + Route::livewire('/customers/{customer}', Customers\Show::class)->name('customers.show'); + + Route::livewire('/discounts', Discounts\Index::class)->name('discounts.index'); + Route::livewire('/discounts/create', Discounts\Form::class)->name('discounts.create'); + Route::livewire('/discounts/{discount}/edit', Discounts\Form::class)->name('discounts.edit'); + + Route::livewire('/pages', Pages\Index::class)->name('pages.index'); + Route::livewire('/pages/create', Pages\Form::class)->name('pages.create'); + Route::livewire('/pages/{page}/edit', Pages\Form::class)->name('pages.edit'); + + Route::livewire('/navigation', Navigation\Index::class)->name('navigation.index'); + + Route::livewire('/themes', Themes\Index::class)->name('themes.index'); + Route::livewire('/themes/{theme}/editor', Themes\Editor::class)->name('themes.editor'); + + Route::livewire('/settings', Settings\Index::class)->name('settings.index'); + Route::livewire('/settings/shipping', Settings\Shipping::class)->name('settings.shipping'); + Route::livewire('/settings/taxes', Settings\Taxes::class)->name('settings.taxes'); + + Route::livewire('/search/settings', Search\Settings::class)->name('search.settings'); + + Route::livewire('/analytics', Analytics\Index::class)->name('analytics.index'); + + Route::livewire('/apps', Apps\Index::class)->name('apps.index'); + Route::livewire('/apps/{installation}', Apps\Show::class)->name('apps.show'); + + Route::livewire('/developers', Developers\Index::class)->name('developers.index'); + }); +}); + +// Email verification for admin users. Route names are fixed by Laravel's +// "verified" middleware and MustVerifyEmail notification. +Route::middleware('auth')->group(function (): void { + Route::get('/email/verify', [EmailVerificationController::class, 'notice'])->name('verification.notice'); + Route::get('/email/verify/{id}/{hash}', [EmailVerificationController::class, 'verify']) + ->middleware('signed') + ->name('verification.verify'); + Route::post('/email/verification-notification', [EmailVerificationController::class, 'send']) + ->middleware('throttle:6,1') + ->name('verification.send'); +}); diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 00000000..cc85eb1e --- /dev/null +++ b/routes/api.php @@ -0,0 +1,103 @@ +prefix('storefront/v1') + ->group(function (): void { + // Cart endpoints (spec 02 §2.1). + Route::post('/carts', [CartController::class, 'create']); + Route::get('/carts/{cartId}', [CartController::class, 'show']); + Route::post('/carts/{cartId}/lines', [CartController::class, 'addLine']); + Route::put('/carts/{cartId}/lines/{lineId}', [CartController::class, 'updateLine']); + Route::delete('/carts/{cartId}/lines/{lineId}', [CartController::class, 'removeLine']); + + // Checkout endpoints (spec 02 §2.2) with the stricter checkout rate limit on top. + Route::middleware('throttle:checkout')->group(function (): void { + Route::post('/checkouts', [CheckoutController::class, 'create']); + Route::get('/checkouts/{checkoutId}', [CheckoutController::class, 'show']); + Route::put('/checkouts/{checkoutId}/address', [CheckoutController::class, 'setAddress']); + Route::put('/checkouts/{checkoutId}/shipping-method', [CheckoutController::class, 'setShippingMethod']); + Route::put('/checkouts/{checkoutId}/payment-method', [CheckoutController::class, 'selectPaymentMethod']); + Route::post('/checkouts/{checkoutId}/apply-discount', [CheckoutController::class, 'applyDiscount']); + Route::delete('/checkouts/{checkoutId}/discount', [CheckoutController::class, 'removeDiscount']); + Route::post('/checkouts/{checkoutId}/pay', [CheckoutController::class, 'pay']); + }); + + // Order status endpoint (spec 02 §2.4), token-authenticated. + Route::get('/orders/{orderNumber}', [OrderController::class, 'show']); + + // Search endpoints (spec 02 §2.5) with the stricter search rate limit on top. + Route::middleware('throttle:search')->group(function (): void { + Route::get('/search', [SearchController::class, 'index']); + Route::get('/search/suggest', [SearchController::class, 'suggest']); + }); + + // Analytics event ingestion (spec 02 §2.6) with the analytics rate limit on top. + Route::post('/analytics/events', [AnalyticsController::class, 'store']) + ->middleware('throttle:analytics'); + }); + +// Admin REST API (Sanctum personal access tokens, spec 02 §3). Store +// membership is resolved from the {storeId} route parameter because API +// tokens carry no session. +Route::middleware(['auth:sanctum', ResolveStoreFromRoute::class, 'throttle:api.admin']) + ->prefix('admin/v1') + ->group(function (): void { + // Platform management (spec 02 §3.1). + Route::post('/platform/organizations', [PlatformController::class, 'createOrganization']) + ->middleware('ability:manage-platform'); + Route::post('/platform/stores', [PlatformController::class, 'createStore']) + ->middleware('ability:manage-platform'); + Route::post('/stores/{storeId}/invites', [PlatformController::class, 'invite']) + ->middleware('ability:manage-platform'); + Route::get('/stores/{storeId}/me', [PlatformController::class, 'me']); + + // Products (spec 02 §3.2). + Route::get('/stores/{storeId}/products', [AdminProductController::class, 'index']) + ->middleware('ability:read-products'); + Route::post('/stores/{storeId}/products', [AdminProductController::class, 'store']) + ->middleware('ability:write-products'); + Route::get('/stores/{storeId}/products/{productId}', [AdminProductController::class, 'show']) + ->middleware('ability:read-products'); + Route::put('/stores/{storeId}/products/{productId}', [AdminProductController::class, 'update']) + ->middleware('ability:write-products'); + Route::delete('/stores/{storeId}/products/{productId}', [AdminProductController::class, 'destroy']) + ->middleware('ability:write-products'); + Route::post('/stores/{storeId}/products/{productId}/media/presign-upload', [AdminProductController::class, 'presignUpload']) + ->middleware('ability:write-products'); + + // Collections (spec 02 §3.3). + Route::get('/stores/{storeId}/collections', [AdminCollectionController::class, 'index']) + ->middleware('ability:read-collections'); + Route::post('/stores/{storeId}/collections', [AdminCollectionController::class, 'store']) + ->middleware('ability:write-collections'); + Route::put('/stores/{storeId}/collections/{collectionId}', [AdminCollectionController::class, 'update']) + ->middleware('ability:write-collections'); + Route::delete('/stores/{storeId}/collections/{collectionId}', [AdminCollectionController::class, 'destroy']) + ->middleware('ability:write-collections'); + + // Orders (spec 02 §3.4) and CSV export (spec 05 §11.6). The export + // route must precede the {orderId} show route. + Route::get('/stores/{storeId}/orders', [AdminOrderController::class, 'index']) + ->middleware('ability:read-orders'); + Route::get('/stores/{storeId}/orders/export', [AdminOrderController::class, 'export']) + ->middleware('ability:read-orders'); + Route::get('/stores/{storeId}/orders/{orderId}', [AdminOrderController::class, 'show']) + ->middleware('ability:read-orders'); + Route::post('/stores/{storeId}/orders/{orderId}/fulfillments', [AdminOrderController::class, 'storeFulfillment']) + ->middleware('ability:write-orders'); + Route::post('/stores/{storeId}/orders/{orderId}/refunds', [AdminOrderController::class, 'storeRefund']) + ->middleware('ability:write-orders'); + }); diff --git a/routes/console.php b/routes/console.php index 3c9adf1a..e87a3ddd 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,24 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +// Expire stale checkouts (spec 05 §6.2) and abandon inactive carts (spec 05 §4.5). +Schedule::job(new ExpireAbandonedCheckouts)->everyFifteenMinutes(); +Schedule::job(new CleanupAbandonedCarts)->daily(); + +// Cancel bank transfer orders that remain unpaid (spec 05 §10.8). +Schedule::job(new CancelUnpaidBankTransferOrders)->daily(); + +// Roll up the previous day's raw analytics events into daily aggregates +// (spec 05 §14.2). +Schedule::job(new AggregateAnalytics)->dailyAt('01:00'); diff --git a/routes/settings.php b/routes/settings.php deleted file mode 100644 index 2019a287..00000000 --- a/routes/settings.php +++ /dev/null @@ -1,30 +0,0 @@ -group(function () { - Route::redirect('settings', 'settings/profile'); - - Route::livewire('settings/profile', Profile::class)->name('profile.edit'); -}); - -Route::middleware(['auth', 'verified'])->group(function () { - Route::livewire('settings/password', Password::class)->name('user-password.edit'); - Route::livewire('settings/appearance', Appearance::class)->name('appearance.edit'); - - Route::livewire('settings/two-factor', TwoFactor::class) - ->middleware( - when( - Features::canManageTwoFactorAuthentication() - && Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword'), - ['password.confirm'], - [], - ), - ) - ->name('two-factor.show'); -}); diff --git a/routes/web.php b/routes/web.php index f755f111..002c7e49 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,13 +1,50 @@ name('home'); +// Storefront routes (spec 04). +Route::middleware(['store.resolve.storefront'])->group(function (): void { + Route::livewire('/', Home::class)->name('storefront.home'); + Route::livewire('/collections', CollectionsIndex::class)->name('storefront.collections.index'); + Route::livewire('/collections/{handle}', CollectionsShow::class)->name('storefront.collections.show'); + Route::livewire('/products/{handle}', ProductsShow::class)->name('storefront.products.show'); + Route::livewire('/cart', CartShow::class)->name('storefront.cart.show'); + Route::livewire('/search', SearchIndex::class)->name('storefront.search'); + Route::livewire('/checkout/{checkoutId}', CheckoutShow::class)->name('storefront.checkout.show'); + Route::livewire('/checkout/{checkoutId}/confirmation', CheckoutConfirmation::class)->name('storefront.checkout.confirmation'); + Route::livewire('/pages/{handle}', PagesShow::class)->name('storefront.pages.show'); -Route::view('dashboard', 'dashboard') - ->middleware(['auth', 'verified']) - ->name('dashboard'); + // Customer auth pages (spec 02 §1.3). Submissions are handled by Livewire actions. + Route::livewire('/account/login', AccountLogin::class)->name('storefront.account.login'); + Route::livewire('/account/register', AccountRegister::class)->name('storefront.account.register'); + Route::livewire('/forgot-password', AccountForgotPassword::class)->name('storefront.password.request'); + Route::livewire('/reset-password/{token}', AccountResetPassword::class)->name('storefront.password.reset'); -require __DIR__.'/settings.php'; + Route::post('/account/logout', AccountLogoutController::class)->name('storefront.account.logout'); + + // Customer account pages (spec 04 §10). + Route::middleware(['auth.customer'])->group(function (): void { + Route::livewire('/account', AccountDashboard::class)->name('storefront.account.dashboard'); + Route::livewire('/account/orders', AccountOrdersIndex::class)->name('storefront.account.orders.index'); + Route::livewire('/account/orders/{orderNumber}', AccountOrdersShow::class)->name('storefront.account.orders.show'); + Route::livewire('/account/addresses', AccountAddressesIndex::class)->name('storefront.account.addresses.index'); + }); +}); diff --git a/specs/progress.md b/specs/progress.md new file mode 100644 index 00000000..6ac06bf9 --- /dev/null +++ b/specs/progress.md @@ -0,0 +1,134 @@ +# Shop Implementation Progress + +> Living document tracking the implementation of the shop system per specs/*.md. +> Updated after every iteration. + +## Status Legend +- `[ ]` pending +- `[~]` in progress +- `[x]` done (implemented + tested) + +--- + +## Iteration Log + +| # | Date | Phase | Summary | Commit | +|---|------|-------|---------|--------| +| 0 | 2026-07-19 | Setup | Project scaffolding: sanctum + pest-plugin-browser deps, .env.testing, Herd site links (acme-fashion.test, acme-electronics.test), starter-kit cleanup, Fortify removed | c002d312 | +| 1 | 2026-07-19 | Phase 1 | Foundation: all 46-table migrations (+users rewrite), 28 enums, core models (Organization/Store/StoreDomain/StoreSettings/StoreUser/User/Customer), BelongsToStore+StoreScope, ResolveStore/CheckStoreRole/CustomerAuthenticate middleware, CustomerUserProvider, 11 policies, 9 gates, 7 rate limiters, route wiring (web/admin/api). 15 tests green. | 342ff904 | + +--- + +| 2 | 2026-07-19 | Phase 2 | Catalog: 7 models + factories, ProductService (state machine, SKU uniqueness), VariantMatrixService, InventoryService, HandleGenerator, SanitizeHtml, ProcessMediaUpload (GD). 78 tests green. | pending | + +| 3 | 2026-07-19 | Phase 3 | Themes/pages/navigation models + NavigationService + ThemeSettingsService + Money helper; storefront layout (dark mode, a11y) + components; Home/Collections/Products/Pages Livewire; real-env smoke OK (200s + 404). 105 tests green. | pending | + +| 4 | 2026-07-19 | Phase 4 | Cart/Checkout/Discount/Shipping/Tax engine: 7 models, 7 VOs, PricingEngine, DiscountService, ShippingCalculator, TaxCalculator(+providers), CartService, CheckoutService state machine, storefront cart/checkout REST API, cart drawer + cart page + checkout stepper UI, expiry/cleanup jobs. 219 tests green. Notable: intdiv tax math & merge-sum per spec 09 precedence. | pending | + +| 5 | 2026-07-19 | Phase 5 | Payments/orders/fulfillment/customers: MockPaymentProvider, PaymentService, OrderService (atomic creation, numbering, idempotency, digital auto-fulfill), RefundService, FulfillmentService (guard), 9 events + listeners, /pay + order-status APIs, checkout payment step + confirmation UI. 294 tests green. | b55fd22e | + +| 6 | 2026-07-19 | Phase 6 | Admin auth (login/logout/forgot/reset, rate-limited, session regen, current_store_id) + customer auth (login/register/forgot/reset, store-scoped token repository, cart merge on login) + account pages (dashboard/orders/addresses) + email verification routes. 342 tests green. | pending | + +| 7 | 2026-07-19 | Phase 7 | Admin panel complete: layout shell (sidebar/topbar/breadcrumbs/toasts), dashboard (KPIs, chart), products (form + variants + media), inventory, orders (fulfill/refund/confirm/cancel), customers, discounts, collections, pages, navigation, settings (general/domains/shipping/taxes/checkout/notifications), themes + editor. 462 tests green. | 9b32c262 | + +| 8 | 2026-07-19 | Phase 8 | FTS5 search: products_fts, SearchService (synonyms/stop words/facets), ProductObserver, search API + modal + results page, admin settings. 516 tests green. | 45fae4bf | +| 9 | 2026-07-19 | Phase 9 | Analytics: event ingestion API (dedupe/validation), server-side + JS tracking, AggregateAnalytics job, admin analytics dashboard (KPIs, charts, funnel). 549 tests green. | 89fe1ab9 | +| 10 | 2026-07-19 | Phase 10 | Apps/webhooks/API: webhook system (HMAC, retries, circuit breaker), developers UI (Sanctum tokens shop_ prefix, webhook CRUD), apps UI, admin REST API (platform/products/collections/orders+CSV). 634 tests green. | 4d60d582 | +| 11 | 2026-07-19 | Phase 11 | 19 idempotent seeders with exact spec-07 demo data (2 stores, 30 products/127 variants, orders, discounts, analytics); products_fts reindexed; real-env smoke green. 649 tests green. | 392fff42 | + +| 12 | 2026-07-19 | Phase 12 | Polish: styled error pages (404/503/403/500), a11y pass (skip links, focus traps, ARIA), order emails (4 mailables, failure-safe), audit logging (auth.login, product events). 675 tests green, pint clean, npm build green. | 79f5bfb6 | + +| 13 | 2026-07-19 | E2E-1 | Playwright MCP verification round 1: all major suites verified (smoke, auth, admin CRUD, orders lifecycle, discounts, settings, themes, analytics, browsing, cart, checkout CC/PayPal/bank-transfer, customer account, inventory enforcement, tenant isolation, RBAC, responsive, dark mode, Admin API). 9 real bugs found & fixed (Livewire persistent middleware, empty-rules pay, flux radio group, @js in Flux attrs, collection sort, 2x mobile overflow, favicon). 678 tests green. | fac163a3 | + +| 14 | 2026-07-26 | E2E-2 | Pest browser suite: 18 files, 143 tests, all green (678 unit/feature + 143 browser = 821). Browser-found bugs fixed: discount valueAmount field never rendered, tax provider values (tax settings unsaveable), address-save toast. | 2350620a | + +| 15 | 2026-07-26 | Review | Final review meeting: full showcase tour via Playwright MCP (storefront home/product/cart/checkout/confirmation, admin dashboard/products/orders/analytics, search modal, dark mode, mobile). All acceptance criteria verified. 821 tests green (678 unit/feature + 143 browser). Fresh migrate+seed verified. | see below | + +## Phase Checklist + +### Phase 1 — Foundation +- [x] Config: database pragmas, session, cache, queue, auth (customer guard), logging (json + audit), cors +- [x] All 46-table migrations (spec 01, dependency order) +- [x] Core models: Organization, Store, StoreDomain, StoreUser, StoreSettings, User +- [x] Enums (all, spec 05 §21) +- [x] ResolveStore / CheckStoreRole / CustomerAuthenticate middleware +- [x] BelongsToStore trait + StoreScope +- [x] CustomerUserProvider +- [x] Policies + ChecksStoreRole trait + Gates +- [x] Rate limiters +- [x] Tests: Tenancy (TenantResolutionTest, StoreIsolationTest) + +### Phase 2 — Catalog +- [x] Models: Product, ProductOption, ProductOptionValue, ProductVariant, InventoryItem, Collection, ProductMedia +- [x] ProductService, VariantMatrixService, InventoryService, HandleGenerator +- [x] ProcessMediaUpload job +- [x] SanitizeHtml action +- [x] Tests: ProductCrudTest, VariantTest, InventoryTest, CollectionTest, MediaUploadTest, HandleGeneratorTest + +### Phase 3 — Themes / Pages / Navigation / Storefront layout +- [x] Models: Theme, ThemeFile, ThemeSettings, Page, NavigationMenu, NavigationItem +- [x] NavigationService, ThemeSettings service +- [x] Storefront layout + components (product-card, price, badge, etc.) +- [x] Storefront Livewire: Home, Collections Index/Show, Products Show, Pages Show + +### Phase 4 — Cart / Checkout / Discounts / Shipping / Taxes +- [x] Models: Cart, CartLine, Checkout, ShippingZone, ShippingRate, TaxSettings, Discount +- [x] CartService, DiscountService, ShippingCalculator, TaxCalculator, PricingEngine, CheckoutService +- [x] Value objects: PricingResult, TaxLine, Address, etc. +- [x] Jobs: ExpireAbandonedCheckouts, CleanupAbandonedCarts +- [x] Storefront cart/checkout Livewire UI + REST API endpoints +- [x] Tests: PricingEngineTest, DiscountCalculatorTest, TaxCalculatorTest, ShippingCalculatorTest, CartVersionTest, CartServiceTest, CartApiTest, CheckoutFlowTest, CheckoutStateTest, PricingIntegrationTest, DiscountTest, ShippingTest, TaxTest + +### Phase 5 — Payments / Orders / Fulfillment +- [x] Models: Customer, CustomerAddress, Order, OrderLine, Payment, Refund, Fulfillment, FulfillmentLine +- [x] MockPaymentProvider, PaymentService, OrderService, RefundService, FulfillmentService, CustomerService +- [x] Events: OrderCreated, OrderPaid, OrderFulfilled, OrderCancelled, OrderRefunded, etc. +- [x] Jobs: CancelUnpaidBankTransferOrders +- [x] Tests: OrderCreationTest, RefundTest, FulfillmentTest, MockPaymentProviderTest, PaymentServiceTest, BankTransferConfirmationTest + +### Phase 6 — Customer Accounts + Auth UI +- [x] Admin auth: Login, Logout, ForgotPassword, ResetPassword (Livewire) +- [x] Customer auth: Login, Register, ForgotPassword, ResetPassword (Livewire) +- [x] Account pages: Dashboard, Orders Index/Show, Addresses Index +- [x] Tests: AdminAuthTest, CustomerAuthTest, SanctumTokenTest, CustomerAccountTest, AddressManagementTest + +### Phase 7 — Admin Panel +- [x] Admin layout (sidebar, topbar, breadcrumbs, toasts) +- [x] Dashboard (KPIs, chart, recent orders) +- [x] Products (index, form with variants builder, media upload) +- [x] Orders (index, show with fulfillment/refund modals, confirm payment) +- [x] Collections, Customers, Discounts, Settings (general/domains/shipping/taxes), Themes, Pages, Navigation, Inventory +- [x] Tests: DashboardTest, ProductManagementTest, OrderManagementTest, DiscountManagementTest, SettingsTest + +### Phase 8 — Search +- [x] FTS5 migration (products_fts), SearchService, ProductObserver +- [x] SearchSettings model, admin Search Settings page +- [x] Storefront Search Modal + Index +- [x] Tests: SearchTest, AutocompleteTest + +### Phase 9 — Analytics +- [x] AnalyticsEvent, AnalyticsDaily models, AnalyticsService, AggregateAnalytics job +- [x] Storefront event tracking + API endpoint +- [x] Admin Analytics page +- [x] Tests: EventIngestionTest, AggregationTest + +### Phase 10 — Apps / Webhooks / Developers / Admin REST API +- [x] Models: App, AppInstallation, OauthClient, OauthToken, WebhookSubscription, WebhookDelivery +- [x] WebhookService, DeliverWebhook job, DispatchWebhooks listener +- [x] Admin Apps + Developers pages (Sanctum token management) +- [x] Admin REST API (/api/admin/v1): products, collections, orders, customers, discounts, platform +- [x] Tests: WebhookDeliveryTest, WebhookSignatureTest, SanctumTokenTest, AdminProductApiTest, AdminOrderApiTest, StorefrontCartApiTest, StorefrontCheckoutApiTest + +### Phase 11 — Seeders +- [x] Exact demo data per spec 07 (2 stores, 5 users, 20+ products, collections, discounts, shipping, tax, pages, navigation, orders, customers) + +### Phase 12 — Polish +- [x] Error pages 404/503, dark mode, accessibility, structured logging +- [x] Pint clean, full test suite green, fresh migrate+seed verified + +### Browser E2E +- [x] Pest browser tests per spec 08 (18 files, 143 tests) +- [x] Playwright MCP verification of all acceptance criteria + +### Final +- [x] Review meeting: showcase all customer + admin features diff --git a/tests/Browser/Admin/AnalyticsTest.php b/tests/Browser/Admin/AnalyticsTest.php new file mode 100644 index 00000000..ccf85ed6 --- /dev/null +++ b/tests/Browser/Admin/AnalyticsTest.php @@ -0,0 +1,30 @@ +seed(DatabaseSeeder::class); + bindBrowserStorefrontDomain(); + + actingAsAdmin(User::query()->where('email', 'admin@acme.test')->sole()); +}); + +test('shows the analytics dashboard', function () { + visit('/admin/analytics') + ->assertSee('Analytics') + ->assertNoJavascriptErrors(); +}); + +test('shows sales data', function () { + visit('/admin/analytics') + ->assertSee('Orders') + ->assertSee('Revenue') + ->assertNoJavascriptErrors(); +}); + +test('shows conversion funnel data', function () { + visit('/admin/analytics') + ->assertSee('Visits') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Admin/AuthenticationTest.php b/tests/Browser/Admin/AuthenticationTest.php new file mode 100644 index 00000000..27aed492 --- /dev/null +++ b/tests/Browser/Admin/AuthenticationTest.php @@ -0,0 +1,144 @@ +seed(DatabaseSeeder::class); + bindBrowserStorefrontDomain(); +}); + +if (! function_exists('adminAuthenticationFormLogin')) { + /** + * Perform the full admin login sequence through the login form. Spec 08 + * suite 2 requires each test to log in via the UI for independence. + */ + function adminAuthenticationFormLogin(): AwaitableWebpage + { + return visit('/admin/login') + ->fill('email', 'admin@acme.test') + ->fill('password', 'password') + ->press('button[type="submit"]') + ->waitForText('Dashboard'); + } +} + +test('can log in as admin', function () { + visit('/admin/login') + ->fill('email', 'admin@acme.test') + ->fill('password', 'password') + ->press('button[type="submit"]') + ->waitForText('Dashboard') + ->assertPathIs('/admin') + ->assertSee('Dashboard') + ->assertNoJavascriptErrors(); +}); + +test('shows error for invalid credentials', function () { + visit('/admin/login') + ->fill('email', 'admin@acme.test') + ->fill('password', 'wrongpassword') + ->press('button[type="submit"]') + ->waitForText('Invalid credentials') + ->assertPathIs('/admin/login') + ->assertSee('Invalid credentials') + ->assertNoJavascriptErrors(); +}); + +test('shows error for empty email', function () { + visit('/admin/login') + ->fill('password', 'password') + ->press('button[type="submit"]') + ->waitForText('The email field is required') + ->assertSee('The email field is required') + ->assertNoJavascriptErrors(); +}); + +test('shows error for empty password', function () { + visit('/admin/login') + ->fill('email', 'admin@acme.test') + ->press('button[type="submit"]') + ->waitForText('The password field is required') + ->assertSee('The password field is required') + ->assertNoJavascriptErrors(); +}); + +test('redirects unauthenticated users to login from dashboard', function () { + visit('/admin') + ->assertPathIs('/admin/login') + ->assertSee('Sign in') + ->assertNoJavascriptErrors(); +}); + +test('redirects unauthenticated users to login from products', function () { + visit('/admin/products') + ->assertPathIs('/admin/login') + ->assertSee('Sign in') + ->assertNoJavascriptErrors(); +}); + +test('can log out', function () { + $page = adminAuthenticationFormLogin(); + + $page->assertSee('Dashboard') + ->press('button[data-flux-profile]') + ->click('Log out') + ->waitForText('Sign in') + ->assertPathIs('/admin/login') + ->assertSee('Sign in') + ->assertNoJavascriptErrors(); +}); + +test('can navigate through admin sidebar sections', function () { + $page = adminAuthenticationFormLogin(); + + $page->press('nav[aria-label="Admin"] a:has-text("Products")') + ->waitForText('Add product') + ->assertPathIs('/admin/products') + ->assertSee('Products') + ->assertNoJavascriptErrors(); + + $page->press('nav[aria-label="Admin"] a:has-text("Orders")') + ->waitForText('#1001') + ->assertPathIs('/admin/orders') + ->assertSee('Orders') + ->assertNoJavascriptErrors(); + + $page->press('nav[aria-label="Admin"] a:has-text("Customers")') + ->waitForText('customer@acme.test') + ->assertPathIs('/admin/customers') + ->assertSee('Customers') + ->assertNoJavascriptErrors(); + + $page->press('nav[aria-label="Admin"] a:has-text("Discounts")') + ->waitForText('WELCOME10') + ->assertPathIs('/admin/discounts') + ->assertSee('Discounts') + ->assertNoJavascriptErrors(); + + $page->press('nav[aria-label="Admin"] a:has-text("Settings")') + ->waitForText('Store details') + ->assertPathIs('/admin/settings') + ->assertSee('Settings') + ->assertNoJavascriptErrors(); +}); + +test('can navigate to analytics from sidebar', function () { + $page = adminAuthenticationFormLogin(); + + $page->press('nav[aria-label="Admin"] a:has-text("Analytics")') + ->waitForText('Sales over time') + ->assertPathIs('/admin/analytics') + ->assertSee('Analytics') + ->assertNoJavascriptErrors(); +}); + +test('can navigate to themes from sidebar', function () { + $page = adminAuthenticationFormLogin(); + + $page->press('nav[aria-label="Admin"] a:has-text("Themes")') + ->waitForText('Add theme') + ->assertPathIs('/admin/themes') + ->assertSee('Themes') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Admin/CollectionManagementTest.php b/tests/Browser/Admin/CollectionManagementTest.php new file mode 100644 index 00000000..65be78e2 --- /dev/null +++ b/tests/Browser/Admin/CollectionManagementTest.php @@ -0,0 +1,52 @@ +seed(DatabaseSeeder::class); + bindBrowserStorefrontDomain(); + + actingAsAdmin(User::query()->where('email', 'admin@acme.test')->sole()); +}); + +test('shows the collection list with seeded collections', function () { + visit('/admin/collections') + ->assertSee('Collections') + ->assertSee('T-Shirts') + ->assertSee('New Arrivals') + ->assertNoJavascriptErrors(); +}); + +test('can create a new collection', function () { + $page = visit('/admin/collections'); + + // The spec calls the button "Create collection"; the UI labels it "Add collection". + $page->press('main a:has-text("Add collection")') + ->waitForText('Search products') + ->fill('title', 'E2E Test Collection') + ->fill('descriptionHtml', 'A collection created by the E2E test suite.') + ->press('button:has-text("Save")') + ->waitForText('Collection saved') + ->assertSee('Collection saved') + ->assertNoJavascriptErrors(); + + // Fresh visit instead of the sidebar link: wire:navigate may restore a + // cached snapshot of the list that predates the creation. + visit('/admin/collections') + ->waitForText('E2E Test Collection') + ->assertSee('E2E Test Collection') + ->assertNoJavascriptErrors(); +}); + +test('can edit a collection', function () { + $page = visit('/admin/collections'); + + $page->press('table a:has-text("T-Shirts")') + ->waitForText('Search products') + ->fill('descriptionHtml', 'Updated description for T-Shirts collection.') + ->press('button:has-text("Save")') + ->waitForText('Collection saved') + ->assertSee('Collection saved') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Admin/CustomerManagementTest.php b/tests/Browser/Admin/CustomerManagementTest.php new file mode 100644 index 00000000..6391c597 --- /dev/null +++ b/tests/Browser/Admin/CustomerManagementTest.php @@ -0,0 +1,37 @@ +seed(DatabaseSeeder::class); + bindBrowserStorefrontDomain(); + + actingAsAdmin(User::query()->where('email', 'admin@acme.test')->sole()); +}); + +test('shows the customer list', function () { + visit('/admin/customers') + ->assertSee('customer@acme.test') + ->assertSee('John Doe') + ->assertNoJavascriptErrors(); +}); + +test('shows customer detail with order history', function () { + visit('/admin/customers') + ->click('John Doe') + ->wait(1) + ->assertSee('John Doe') + ->assertSee('customer@acme.test') + ->assertSee('#1001') + ->assertNoJavascriptErrors(); +}); + +test('shows customer addresses', function () { + visit('/admin/customers') + ->click('John Doe') + ->wait(1) + ->assertSee('Addresses') + ->assertSee('Hauptstrasse 1') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Admin/DiscountManagementTest.php b/tests/Browser/Admin/DiscountManagementTest.php new file mode 100644 index 00000000..606e3b5f --- /dev/null +++ b/tests/Browser/Admin/DiscountManagementTest.php @@ -0,0 +1,110 @@ +seed(DatabaseSeeder::class); + bindBrowserStorefrontDomain(); + + actingAsAdmin(User::query()->where('email', 'admin@acme.test')->sole()); +}); + +test('shows seeded discount codes', function () { + visit('/admin/discounts') + ->assertSee('WELCOME10') + ->assertSee('FLAT5') + ->assertSee('FREESHIP') + ->assertNoJavascriptErrors(); +}); + +test('can create a new percentage discount code', function () { + $page = visit('/admin/discounts/create'); + + // Percentage is the preselected value type. + $page->fill('code', 'E2ETEST25') + ->click('Percentage') + ->fill('startsAt', '2026-01-01T00:00') + ->fill('endsAt', '2026-12-31T23:59'); + + $page->fill('valueAmount', '25'); + + $page->press('Save') + ->wait(1) + ->assertSee('Discount saved') + ->assertNoJavascriptErrors(); + + visit('/admin/discounts') + ->assertSee('E2ETEST25') + ->assertNoJavascriptErrors(); + + expect(Discount::query()->where('code', 'E2ETEST25')->where('value_amount', 25)->exists())->toBeTrue(); +}); + +test('can create a fixed amount discount code', function () { + // The fixed-amount value field is in cents (minor units): 1000 = 10.00 EUR. + $page = visit('/admin/discounts/create'); + + $page->fill('code', 'E2EFLAT10') + ->click('Fixed amount') + ->wait(1) + ->fill('startsAt', '2026-01-01T00:00'); + + $page->fill('valueAmount', '1000'); + + $page->press('Save') + ->wait(1) + ->assertSee('Discount saved') + ->assertNoJavascriptErrors(); + + expect( + Discount::query() + ->where('code', 'E2EFLAT10') + ->where('value_type', DiscountValueType::Fixed->value) + ->where('value_amount', 1000) + ->exists() + )->toBeTrue(); +}); + +test('can create a free shipping discount code', function () { + visit('/admin/discounts/create') + ->fill('code', 'E2EFREESHIP') + ->click('Free shipping') + ->wait(1) + ->fill('startsAt', '2026-01-01T00:00') + ->press('Save') + ->wait(1) + ->assertSee('Discount saved') + ->assertNoJavascriptErrors(); + + expect( + Discount::query() + ->where('code', 'E2EFREESHIP') + ->where('value_type', DiscountValueType::FreeShipping->value) + ->exists() + )->toBeTrue(); +}); + +test('can edit a discount', function () { + $page = visit('/admin/discounts'); + + $page->click('WELCOME10')->wait(1); + + $page->fill('valueAmount', '15'); + + $page->press('Save') + ->wait(1) + ->assertSee('Discount saved') + ->assertNoJavascriptErrors(); + + expect(Discount::query()->where('code', 'WELCOME10')->sole()->value_amount)->toBe(15); +}); + +test('shows discount status indicators', function () { + visit('/admin/discounts') + ->assertSee('Active') + ->assertSee('Expired') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Admin/OrderManagementTest.php b/tests/Browser/Admin/OrderManagementTest.php new file mode 100644 index 00000000..b893e611 --- /dev/null +++ b/tests/Browser/Admin/OrderManagementTest.php @@ -0,0 +1,200 @@ +seed(DatabaseSeeder::class); + bindBrowserStorefrontDomain(); + + actingAsAdmin(User::query()->where('email', 'admin@acme.test')->sole()); +}); + +/** + * Resolve a seeded order by its number (orders are store-scoped via the + * HTTP context only, so the plain query is unscoped in tests). + */ +function orderByNumber(string $number): Order +{ + return Order::query()->where('order_number', $number)->sole(); +} + +test('shows the order list with seeded orders', function () { + visit('/admin/orders') + ->assertSee('#1001') + ->assertNoJavascriptErrors(); +}); + +test('can filter orders by status', function () { + $page = visit('/admin/orders'); + + $page->press('button[role="tab"]:has-text("Paid")') + ->wait(1) + ->assertSee('#1001') + ->assertNoJavascriptErrors(); + + $page->press('button[role="tab"]:has-text("Fulfilled")') + ->wait(1) + ->assertSee('#1002') + ->assertDontSee('#1001') + ->assertNoJavascriptErrors(); + + $page->press('button[role="tab"]:has-text("All")') + ->wait(1) + ->assertSee('#1001') + ->assertNoJavascriptErrors(); +}); + +test('shows order detail with line items and totals', function () { + visit('/admin/orders') + ->click('a:has-text("#1001")') + ->wait(1) + ->assertSee('#1001') + ->assertSee('Paid') + ->assertSee('Unfulfilled') + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('Subtotal') + ->assertSee('Shipping') + ->assertSee('Tax') + ->assertSee('Total') + ->assertNoJavascriptErrors(); +}); + +test('shows order timeline events', function () { + $order = orderByNumber('#1001'); + + visit("/admin/orders/{$order->id}") + ->assertSee('Timeline') + ->assertSee('Order placed') + ->assertNoJavascriptErrors(); +}); + +test('can create a fulfillment', function () { + $order = orderByNumber('#1001'); + + visit("/admin/orders/{$order->id}") + ->press('Create fulfillment') + ->wait(1) + ->fill('trackingCompany', 'DHL') + ->fill('trackingNumber', 'DHL123456789') + ->press('button[wire\:click="createFulfillment"]') + ->wait(1) + ->assertSee('Fulfillment created') + ->assertSee('DHL') + ->assertSee('DHL123456789') + ->assertNoJavascriptErrors(); + + expect( + Fulfillment::query() + ->where('order_id', $order->id) + ->where('tracking_company', 'DHL') + ->where('tracking_number', 'DHL123456789') + ->exists() + )->toBeTrue(); +}); + +test('can process a refund', function () { + $order = orderByNumber('#1001'); + + // The refund amount field is in cents (minor units): 1000 = 10.00 EUR. + visit("/admin/orders/{$order->id}") + ->press('Refund') + ->wait(1) + ->fill('refundAmount', '1000') + ->fill('refundReason', 'Customer requested partial refund') + ->press('Create refund') + ->wait(1) + ->assertSee('Refund issued') + ->assertSee('Partially refunded') + ->assertNoJavascriptErrors(); + + expect( + $order->refunds() + ->where('amount', 1000) + ->where('reason', 'Customer requested partial refund') + ->exists() + )->toBeTrue(); +}); + +test('shows customer information in order detail', function () { + $order = orderByNumber('#1001'); + + visit("/admin/orders/{$order->id}") + ->assertSee('customer@acme.test') + ->assertNoJavascriptErrors(); +}); + +test('can confirm bank transfer payment', function () { + $order = orderByNumber('#1005'); + + visit("/admin/orders/{$order->id}") + ->assertSee('Pending') + ->assertSee('Confirm payment') + ->press('Confirm payment') + ->wait(1) + ->assertSee('Payment confirmed') + ->assertSee('Paid') + ->assertDontSee('Confirm payment') + ->assertNoJavascriptErrors(); + + expect($order->refresh()->financial_status)->toBe(App\Enums\FinancialStatus::Paid); +}); + +test('shows fulfillment guard for unpaid order', function () { + $order = orderByNumber('#1005'); + + visit("/admin/orders/{$order->id}") + ->assertSee('Fulfillment cannot be created until payment is confirmed') + ->assertButtonDisabled('Create fulfillment') + ->assertNoJavascriptErrors(); +}); + +test('can mark fulfillment as shipped', function () { + $order = orderByNumber('#1001'); + + $page = visit("/admin/orders/{$order->id}"); + + // Create the fulfillment first so there is something to ship. + $page->press('Create fulfillment') + ->wait(1) + ->press('button[wire\:click="createFulfillment"]') + ->wait(1) + ->assertSee('Fulfillment created'); + + // The card button opens the tracking modal; submit it to ship. + $page->press('Mark as shipped') + ->wait(1) + ->press('button[wire\:click="markAsShipped"]') + ->wait(1) + ->assertSee('Fulfillment marked as shipped') + ->assertSee('Shipped') + ->assertNoJavascriptErrors(); +}); + +test('can mark fulfillment as delivered', function () { + $order = orderByNumber('#1001'); + + $page = visit("/admin/orders/{$order->id}"); + + // Create and ship a fulfillment so it can be delivered. + $page->press('Create fulfillment') + ->wait(1) + ->press('button[wire\:click="createFulfillment"]') + ->wait(1) + ->assertSee('Fulfillment created'); + + $page->press('Mark as shipped') + ->wait(1) + ->press('button[wire\:click="markAsShipped"]') + ->wait(1) + ->assertSee('Fulfillment marked as shipped'); + + $page->press('Mark as delivered') + ->wait(1) + ->assertSee('Fulfillment marked as delivered') + ->assertSee('Delivered') + ->assertSee('Fulfilled') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Admin/PageManagementTest.php b/tests/Browser/Admin/PageManagementTest.php new file mode 100644 index 00000000..6eb38d12 --- /dev/null +++ b/tests/Browser/Admin/PageManagementTest.php @@ -0,0 +1,53 @@ +seed(DatabaseSeeder::class); + bindBrowserStorefrontDomain(); + + actingAsAdmin(User::query()->where('email', 'admin@acme.test')->sole()); +}); + +test('shows the pages list', function () { + visit('/admin/pages') + ->assertSee('Pages') + ->assertSee('About') + ->assertNoJavascriptErrors(); +}); + +test('can create a new page', function () { + $page = visit('/admin/pages'); + + // The spec calls the button "Create page"; the UI labels it "Add page". + // The spec's title "FAQ" collides with the seeded FAQ page (handle must + // be unique), so a distinct title is used. + $page->press('main a:has-text("Add page")') + ->waitForText('Set automatically when publishing') + ->fill('title', 'FAQ E2E') + ->fill('bodyHtml', 'Frequently asked questions content here.') + ->press('button:has-text("Save")') + ->waitForText('Page saved') + ->assertSee('Page saved') + ->assertNoJavascriptErrors(); + + // Fresh visit instead of the sidebar link: wire:navigate may restore a + // cached snapshot of the list that predates the creation. + visit('/admin/pages') + ->waitForText('FAQ E2E') + ->assertSee('FAQ E2E') + ->assertNoJavascriptErrors(); +}); + +test('can edit an existing page', function () { + $page = visit('/admin/pages'); + + $page->press('table a:has-text("About")') + ->waitForText('Set automatically when publishing') + ->fill('bodyHtml', 'Updated about page content.') + ->press('button:has-text("Save")') + ->waitForText('Page saved') + ->assertSee('Page saved') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Admin/ProductManagementTest.php b/tests/Browser/Admin/ProductManagementTest.php new file mode 100644 index 00000000..97c51628 --- /dev/null +++ b/tests/Browser/Admin/ProductManagementTest.php @@ -0,0 +1,168 @@ +seed(DatabaseSeeder::class); + bindBrowserStorefrontDomain(); + + actingAsAdmin(User::query()->where('email', 'admin@acme.test')->sole()); +}); + +test('shows the product list with seeded products', function () { + // The list paginates 15 per page (20 seeded products) sorted by + // updated_at desc with identical seed timestamps, so the seeded products + // are located through the list's own search box for determinism. + visit('/admin/products') + ->assertSee('Add product') + ->fill('search', 'Classic Cotton') + ->waitForText('Classic Cotton T-Shirt') + ->assertSee('Classic Cotton T-Shirt') + ->fill('search', 'Premium Slim') + ->waitForText('Premium Slim Fit Jeans') + ->assertSee('Premium Slim Fit Jeans') + ->assertNoJavascriptErrors(); +}); + +test('can create a new product', function () { + $page = visit('/admin/products'); + + // The variant price input expects integer cents, so 29.99 EUR is 2999. + $page->press('main a:has-text("Add product")') + ->waitForText('Organization') + ->fill('title', 'Test Product Created by E2E') + ->fill('descriptionHtml', 'This product was created by the E2E test suite.') + ->fill('vendor', 'Test Vendor') + ->fill('productType', 'T-Shirts') + ->fill('[aria-label="Price in cents"]', '2999') + ->fill('[aria-label="SKU"]', 'E2E-TEST-001') + ->fill('[aria-label="Quantity on hand"]', '50') + ->press('button:has-text("Save")') + ->waitForText('Product saved') + ->assertSee('Product saved') + ->assertNoJavascriptErrors(); + + // Fresh visit instead of the sidebar link: wire:navigate may restore a + // cached snapshot of the list that predates the creation. + visit('/admin/products') + ->waitForText('Test Product Created by E2E') + ->assertSee('Test Product Created by E2E') + ->assertNoJavascriptErrors(); +}); + +test('can edit an existing product title', function () { + $page = visit('/admin/products'); + + $page->fill('search', 'Classic Cotton') + ->waitForText('Classic Cotton T-Shirt') + ->press('table a:has-text("Classic Cotton T-Shirt")') + ->waitForText('Organization') + ->fill('title', 'Classic Cotton T-Shirt Updated') + ->press('button:has-text("Save")') + ->waitForText('Product saved') + ->assertSee('Product saved') + ->assertNoJavascriptErrors(); + + // Fresh visit instead of the sidebar link: wire:navigate may restore a + // cached snapshot of the list that predates the rename. + visit('/admin/products') + ->waitForText('Classic Cotton T-Shirt Updated') + ->assertSee('Classic Cotton T-Shirt Updated') + ->assertNoJavascriptErrors(); +}); + +test('can archive a product', function () { + $page = visit('/admin/products'); + + // The variant price input expects integer cents, so 19.99 EUR is 1999. + $page->press('main a:has-text("Add product")') + ->waitForText('Organization') + ->fill('title', 'Product To Archive') + ->fill('[aria-label="Price in cents"]', '1999') + ->fill('[aria-label="SKU"]', 'E2E-ARCHIVE-001') + ->fill('[aria-label="Quantity on hand"]', '10') + ->press('button:has-text("Save")') + ->waitForText('Product saved') + ->assertSee('Product saved'); + + // Fresh visits instead of the sidebar link: wire:navigate may restore a + // cached snapshot of the list that predates the creation/archival. + // The list defaults to the "All" filter (spec assumed "Active"), so the + // archived product must disappear from the Active tab and show up under + // the Archived tab instead. + $page = visit('/admin/products'); + + $page->waitForText('Product To Archive') + ->press('table a:has-text("Product To Archive")') + ->waitForText('Organization') + ->select('status', 'archived') + ->keys('#status', ['Tab']) // trigger the blur sync of wire:model.blur + ->press('button:has-text("Save")') + ->waitForText('Product saved') + ->assertSee('Product saved') + ->assertNoJavascriptErrors(); + + $page = visit('/admin/products'); + + $page->waitForText('Add product') + ->press('button[role="tab"]:has-text("Active")') + ->wait(1) + ->assertDontSee('Product To Archive') + ->press('button[role="tab"]:has-text("Archived")') + ->waitForText('Product To Archive') + ->assertSee('Product To Archive') + ->assertNoJavascriptErrors(); +}); + +test('shows draft products only in admin not storefront', function () { + $page = visit('/admin/products'); + + // Draft product #15 shows in the admin list with its "Draft" badge. + $page->fill('search', 'Unreleased') + ->waitForText('Unreleased Winter Jacket') + ->assertVisible('table tr:has-text("Unreleased Winter Jacket"):has-text("Draft")') + ->assertNoJavascriptErrors(); + + // ...but is absent from the storefront collection listing. + visit('/collections/t-shirts') + ->assertSee('T-Shirts') + ->assertDontSee('Unreleased Winter Jacket') + ->assertNoJavascriptErrors(); + + // ...and absent from storefront search results. + visit('/search?q=Unreleased') + ->assertSee('No results found') + ->assertDontSee('Unreleased Winter Jacket') + ->assertNoJavascriptErrors(); +}); + +test('can search products in admin', function () { + visit('/admin/products') + ->fill('search', 'Cotton') + ->waitForText('Classic Cotton T-Shirt') + ->wait(1) + ->assertSee('Classic Cotton T-Shirt') + ->assertDontSee('Premium Slim Fit Jeans') + ->assertNoJavascriptErrors(); +}); + +test('can filter products by status in admin', function () { + $page = visit('/admin/products'); + + $page->press('button[role="tab"]:has-text("Draft")') + ->wait(1) + ->assertSee('Unreleased Winter Jacket') + ->assertDontSee('Classic Cotton T-Shirt') + ->assertNoJavascriptErrors(); + + // Search within the Active tab: with 18 active products on a 15-per-page + // list, this keeps the assertion independent of sort order. + $page->press('button[role="tab"]:has-text("Active")') + ->fill('search', 'Classic Cotton') + ->waitForText('Classic Cotton T-Shirt') + ->wait(1) + ->assertSee('Classic Cotton T-Shirt') + ->assertDontSee('Unreleased Winter Jacket') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Admin/SettingsTest.php b/tests/Browser/Admin/SettingsTest.php new file mode 100644 index 00000000..8fc43647 --- /dev/null +++ b/tests/Browser/Admin/SettingsTest.php @@ -0,0 +1,103 @@ +seed(DatabaseSeeder::class); + bindBrowserStorefrontDomain(); + + actingAsAdmin(User::query()->where('email', 'admin@acme.test')->sole()); +}); + +test('can view store settings', function () { + visit('/admin/settings') + ->assertSee('Settings') + ->assertSee('Acme Fashion') + ->assertValue('storeName', 'Acme Fashion') + ->assertNoJavascriptErrors(); +}); + +test('can update store name', function () { + visit('/admin/settings') + ->fill('storeName', 'Acme Fashion Updated') + ->press('Save') + ->wait(1) + ->assertSee('Settings saved') + ->assertNoJavascriptErrors(); + + // Reload the page and verify the change persisted. + visit('/admin/settings') + ->assertSee('Acme Fashion Updated') + ->assertValue('storeName', 'Acme Fashion Updated') + ->assertNoJavascriptErrors(); + + expect(Store::query()->where('handle', 'acme-fashion')->sole()->name)->toBe('Acme Fashion Updated'); +}); + +test('can view shipping zones', function () { + visit('/admin/settings') + ->click('div[role="tablist"] a:has-text("Shipping")') + ->wait(1) + ->assertSee('Domestic') + ->assertSee('Standard Shipping') + ->assertSee('4.99') + ->assertNoJavascriptErrors(); +}); + +test('can add a new shipping rate to existing zone', function () { + $zone = ShippingZone::query()->where('name', 'Domestic')->sole(); + + // Target the "Add rate" button inside the Domestic zone card exactly. + // The rate amount field is in cents (minor units): 1499 = 14.99 EUR. + visit('/admin/settings/shipping') + ->press('[wire\:click="openRateForm('.$zone->id.')"]') + ->wait(1) + ->fill('rateName', 'Overnight Shipping') + ->fill('rateAmount', '1499') + ->press('Save rate') + ->wait(1) + ->assertSee('Shipping rate saved') + ->assertSee('Overnight Shipping') + ->assertSee('14.99') + ->assertNoJavascriptErrors(); + + expect( + $zone->rates()->where('name', 'Overnight Shipping')->exists() + )->toBeTrue(); +}); + +test('can view tax settings', function () { + visit('/admin/settings') + ->click('div[role="tablist"] a:has-text("Taxes")') + ->wait(1) + ->assertSee('Taxes') + ->assertSee('Tax mode') + ->assertSee('Rates') + ->assertNoJavascriptErrors(); +}); + +test('can update tax inclusion setting', function () { + $store = Store::query()->where('handle', 'acme-fashion')->sole(); + + // Seeded with prices_include_tax = true; the toggle flips it to false. + visit('/admin/settings/taxes') + ->press('ui-switch[data-flux-switch]') + ->press('Save') + ->wait(1) + ->assertSee('Settings saved') + ->assertNoJavascriptErrors(); + + expect(TaxSettings::query()->find($store->id)->prices_include_tax)->toBeFalse(); +}); + +test('can view domain settings', function () { + visit('/admin/settings') + ->press('button[role="tab"]:has-text("Domains")') + ->wait(1) + ->assertSee('acme-fashion.test') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/SmokeTest.php b/tests/Browser/SmokeTest.php new file mode 100644 index 00000000..e9d9bcd6 --- /dev/null +++ b/tests/Browser/SmokeTest.php @@ -0,0 +1,76 @@ +seed(DatabaseSeeder::class); + bindBrowserStorefrontDomain(); +}); + +test('loads the storefront home page', function () { + visit('/') + ->assertSee('Acme Fashion') + ->assertNoJavaScriptErrors(); +}); + +test('loads a collection page', function () { + visit('/collections/t-shirts') + ->assertSee('T-Shirts') + ->assertNoJavaScriptErrors(); +}); + +test('loads a product page', function () { + visit('/products/classic-cotton-t-shirt') + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('24.99') + ->assertNoJavaScriptErrors(); +}); + +test('loads the cart page', function () { + visit('/cart') + ->assertSee('Your Cart') + ->assertNoJavaScriptErrors(); +}); + +test('loads the customer login page', function () { + visit('/account/login') + ->assertSee('Log in') + ->assertNoJavaScriptErrors(); +}); + +test('loads the admin login page', function () { + visit('/admin/login') + ->assertSee('Sign in') + ->assertNoJavaScriptErrors(); +}); + +test('loads the about page', function () { + visit('/pages/about') + ->assertSee('About') + ->assertNoJavaScriptErrors(); +}); + +test('loads the search page', function () { + visit('/search?q=shirt') + ->assertSee('shirt') + ->assertNoJavaScriptErrors(); +}); + +test('loads all collections listing', function () { + visit('/collections') + ->assertSee('Collections') + ->assertNoJavaScriptErrors(); +}); + +test('has no errors on critical pages', function () { + visit([ + '/', + '/collections/new-arrivals', + '/products/classic-cotton-t-shirt', + '/cart', + '/account/login', + '/admin/login', + '/pages/about', + '/search?q=shirt', + ])->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Storefront/AccessibilityTest.php b/tests/Browser/Storefront/AccessibilityTest.php new file mode 100644 index 00000000..1b12b845 --- /dev/null +++ b/tests/Browser/Storefront/AccessibilityTest.php @@ -0,0 +1,167 @@ +seed(DatabaseSeeder::class); + bindBrowserStorefrontDomain(); +}); + +/** + * Put a T-Shirt in the cart and land on the checkout contact step. + */ +function a11yTestOpenCheckout($page) +{ + return $page + ->press('M') + ->wait(1) + ->press('button[aria-label="Black"]') + ->wait(1) + ->press('Add to cart') + ->wait(1) + ->navigate('/cart') + ->press('main button:has-text("Checkout")') + ->wait(1) + ->assertPathIs('/checkout/new'); +} + +test('home page has no javascript errors or console warnings', function () { + visit('/') + ->assertNoJavascriptErrors() + ->assertNoConsoleLogs(); +}); + +test('home page has proper heading hierarchy', function () { + visit('/') + // Exactly one h1 on the page. + ->assertScript("document.querySelectorAll('h1').length", 1) + // The h1 carries the store name. + ->assertScript("document.querySelector('h1').textContent.includes('Acme Fashion')") + // The first heading in document order is the h1 (logical order). + ->assertScript("document.querySelector('h1, h2').tagName === 'H1'") + ->assertSee('Acme Fashion') + ->assertNoJavascriptErrors(); +}); + +test('product page has proper aria labels for variant selector', function () { + visit('/products/classic-cotton-t-shirt') + ->assertSee('Size') + ->assertSee('Color') + ->assertSee('Add to cart') + // Color swatches expose an accessible name via aria-label. + ->assertPresent('button[aria-label="Black"]') + ->assertPresent('button[aria-label="White"]') + ->assertPresent('button[aria-label="Navy"]') + // Option buttons expose pressed state. + ->assertAttribute('button:has-text("M")', 'aria-pressed', 'false') + ->assertNoJavascriptErrors(); +}); + +test('product page images have alt text', function () { + // The seeder intentionally creates no ProductMedia records, so the + // gallery renders an aria-hidden placeholder. The alt-text invariant is + // asserted for any rendered image (thumbnails inside labelled buttons + // are decorative), and the placeholder must be hidden from AT. + visit('/products/classic-cotton-t-shirt') + ->assertScript( + 'Array.from(document.querySelectorAll(\'section[aria-label="Product images"] img\')).filter((img) => ! img.closest(\'button\')).every((img) => (img.getAttribute(\'alt\') ?? \'\').trim().length > 0)' + ) + ->assertScript( + 'document.querySelector(\'section[aria-label="Product images"] [aria-hidden="true"]\') !== null || document.querySelectorAll(\'section[aria-label="Product images"] img\').length > 0' + ) + ->assertNoJavascriptErrors(); +}); + +test('customer login form has accessible labels', function () { + // Flux renders labels as custom elements and associates them + // with their inputs via aria-labelledby (resolved dynamically here); + // native