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..965e267e --- /dev/null +++ b/.claude/skills/laravel-best-practices/SKILL.md @@ -0,0 +1,190 @@ +--- +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, prioritized by impact. Each rule 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 — 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. + +## Quick Reference + +### 1. Database Performance → `rules/db-performance.md` + +- Eager load with `with()` to prevent N+1 queries +- Enable `Model::preventLazyLoading()` in development +- Select only needed columns, avoid `SELECT *` +- `chunk()` / `chunkById()` for large datasets +- Index columns used in `WHERE`, `ORDER BY`, `JOIN` +- `withCount()` instead of loading relations to count +- `cursor()` for memory-efficient read-only iteration +- Never query in Blade templates + +### 2. Advanced Query Patterns → `rules/advanced-queries.md` + +- `addSelect()` subqueries over eager-loading entire has-many for a single value +- Dynamic relationships via subquery FK + `belongsTo` +- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries +- `setRelation()` to prevent circular N+1 queries +- `whereIn` + `pluck()` over `whereHas` for better index usage +- Two simple queries can beat one complex query +- Compound indexes matching `orderBy` column order +- Correlated subqueries in `orderBy` for has-many sorting (avoid joins) + +### 3. Security → `rules/security.md` + +- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates +- No raw SQL with user input — use Eloquent or query builder +- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes +- Validate MIME type, extension, and size for file uploads +- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields + +### 4. Caching → `rules/caching.md` + +- `Cache::remember()` over manual get/put +- `Cache::flexible()` for stale-while-revalidate on high-traffic data +- `Cache::memo()` to avoid redundant cache hits within a request +- Cache tags to invalidate related groups +- `Cache::add()` for atomic conditional writes +- `once()` to memoize per-request or per-object lifetime +- `Cache::lock()` / `lockForUpdate()` for race conditions +- Failover cache stores in production + +### 5. Eloquent Patterns → `rules/eloquent.md` + +- Correct relationship types with return type hints +- Local scopes for reusable query constraints +- Global scopes sparingly — document their existence +- Attribute casts in the `casts()` method +- Cast date columns, use Carbon instances in templates +- `whereBelongsTo($model)` for cleaner queries +- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries + +### 6. Validation & Forms → `rules/validation.md` + +- Form Request classes, not inline validation +- Array notation `['required', 'email']` for new code; follow existing convention +- `$request->validated()` only — never `$request->all()` +- `Rule::when()` for conditional validation +- `after()` instead of `withValidator()` + +### 7. Configuration → `rules/config.md` + +- `env()` only inside config files +- `App::environment()` or `app()->isProduction()` +- Config, lang files, and constants over hardcoded text + +### 8. Testing Patterns → `rules/testing.md` + +- `LazilyRefreshDatabase` over `RefreshDatabase` for speed +- `assertModelExists()` over raw `assertDatabaseHas()` +- Factory states and sequences over manual overrides +- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before +- `recycle()` to share relationship instances across factories + +### 9. Queue & Job Patterns → `rules/queue-jobs.md` + +- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]` +- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release +- Always implement `failed()`; with `retryUntil()`, set `$tries = 0` +- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs +- Horizon for complex multi-queue scenarios + +### 10. Routing & Controllers → `rules/routing.md` + +- Implicit route model binding +- Scoped bindings for nested resources +- `Route::resource()` or `apiResource()` +- Methods under 10 lines — extract to actions/services +- Type-hint Form Requests for auto-validation + +### 11. HTTP Client → `rules/http-client.md` + +- Explicit `timeout` and `connectTimeout` on every request +- `retry()` with exponential backoff for external APIs +- Check response status or use `throw()` +- `Http::pool()` for concurrent independent requests +- `Http::fake()` and `preventStrayRequests()` in tests + +### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md` + +- Event discovery over manual registration; `event:cache` in production +- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions +- Queue notifications and mailables with `ShouldQueue` +- On-demand notifications for non-user recipients +- `HasLocalePreference` on notifiable models +- `assertQueued()` not `assertSent()` for queued mailables +- Markdown mailables for transactional emails + +### 13. Error Handling → `rules/error-handling.md` + +- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern +- `ShouldntReport` for exceptions that should never log +- Throttle high-volume exceptions to protect log sinks +- `dontReportDuplicates()` for multi-catch scenarios +- Force JSON rendering for API routes +- Structured context via `context()` on exception classes + +### 14. Task Scheduling → `rules/scheduling.md` + +- `withoutOverlapping()` on variable-duration tasks +- `onOneServer()` on multi-server deployments +- `runInBackground()` for concurrent long tasks +- `environments()` to restrict to appropriate environments +- `takeUntilTimeout()` for time-bounded processing +- Schedule groups for shared configuration + +### 15. Architecture → `rules/architecture.md` + +- Single-purpose Action classes; dependency injection over `app()` helper +- Prefer official Laravel packages and follow conventions, don't override defaults +- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety +- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution + +### 16. Migrations → `rules/migrations.md` + +- Generate migrations with `php artisan make:migration` +- `constrained()` for foreign keys +- Never modify migrations that have run in production +- Add indexes in the migration, not as an afterthought +- Mirror column defaults in model `$attributes` +- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes +- One concern per migration — never mix DDL and DML + +### 17. Collections → `rules/collections.md` + +- Higher-order messages for simple collection operations +- `cursor()` vs. `lazy()` — choose based on relationship needs +- `lazyById()` when updating records while iterating +- `toQuery()` for bulk operations on collections + +### 18. Blade & Views → `rules/blade-views.md` + +- `$attributes->merge()` in component templates +- Blade components over `@include`; `@pushOnce` for per-component scripts +- View Composers for shared view data +- `@aware` for deeply nested component props + +### 19. Conventions & Style → `rules/style.md` + +- Follow Laravel naming conventions for all entities +- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions +- No JS/CSS in Blade, no HTML in PHP classes +- Code should be readable; comments only for config files + +## How to Apply + +Always use a sub-agent to read rule files and explore this skill's content. + +1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10) +2. Check sibling files for existing patterns — follow those first per Consistency First +3. Verify API syntax with `search-docs` for the installed Laravel version 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..51c6e65d --- /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 execute(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..64d17308 --- /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/.env.testing b/.env.testing new file mode 100644 index 00000000..598fb784 --- /dev/null +++ b/.env.testing @@ -0,0 +1,19 @@ +APP_NAME=Shop +APP_ENV=testing +APP_KEY=base64:nW7zTogbucXWMRU2+3CxeYXYni4FTNdm8nIUeG7tOzQ= +APP_DEBUG=true +APP_URL=http://acme-fashion.test + +# NOTE: phpunit.xml overrides DB_DATABASE to ":memory:" for the test run. +# Pest's browser plugin serves the app in-process, so the in-memory database +# is shared between the test and the browser-issued HTTP requests. This file +# path is only used when running artisan commands with --env=testing. +DB_CONNECTION=sqlite +DB_DATABASE=/Users/fabianwesner/Herd/shop/database/testing.sqlite + +PAYMENT_PROVIDER=mock +MAIL_MAILER=array +QUEUE_CONNECTION=sync +CACHE_STORE=array +SESSION_DRIVER=array +BCRYPT_ROUNDS=4 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3c538985..64118bcb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,6 +7,7 @@ on: - main - master - workos + - '2026-*' pull_request: branches: - develop diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7cfd2dd6..ebc0aa8f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,6 +7,7 @@ on: - main - master - workos + - '2026-*' pull_request: branches: - develop @@ -41,6 +42,9 @@ jobs: - name: Install Node Dependencies run: npm i + - name: Install Playwright Browsers + run: npx playwright install --with-deps chromium + - name: Add Flux Credentials Loaded From ENV run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}" diff --git a/.gitignore b/.gitignore index c7cf1fa6..893a500d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ yarn-error.log /.nova /.vscode /.zed +tests/Browser/Screenshots +/database/testing.sqlite +review/ diff --git a/.mcp.json b/.mcp.json index 0ad95248..b2d6bef5 100644 --- a/.mcp.json +++ b/.mcp.json @@ -3,7 +3,7 @@ "laravel-boost": { "command": "php", "args": [ - "./artisan", + "artisan", "boost:mcp" ] }, diff --git a/.playwright-mcp/console-2026-06-10T08-32-30-740Z.log b/.playwright-mcp/console-2026-06-10T08-32-30-740Z.log new file mode 100644 index 00000000..60a62242 --- /dev/null +++ b/.playwright-mcp/console-2026-06-10T08-32-30-740Z.log @@ -0,0 +1 @@ +[ 122ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/:56 diff --git a/.playwright-mcp/console-2026-06-10T08-33-01-847Z.log b/.playwright-mcp/console-2026-06-10T08-33-01-847Z.log new file mode 100644 index 00000000..1ea61cda --- /dev/null +++ b/.playwright-mcp/console-2026-06-10T08-33-01-847Z.log @@ -0,0 +1 @@ +[ 93ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/collections/t-shirts:56 diff --git a/.playwright-mcp/console-2026-06-10T08-33-10-628Z.log b/.playwright-mcp/console-2026-06-10T08-33-10-628Z.log new file mode 100644 index 00000000..da71f844 --- /dev/null +++ b/.playwright-mcp/console-2026-06-10T08-33-10-628Z.log @@ -0,0 +1,4 @@ +[ 87ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/classic-cotton-t-shirt:56 +[ 101445ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout:56 +[ 187432ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://shop.test/livewire-6701cc17/update:0 +[ 187479ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ :6 diff --git a/.playwright-mcp/console-2026-06-10T08-39-38-791Z.log b/.playwright-mcp/console-2026-06-10T08-39-38-791Z.log new file mode 100644 index 00000000..9f01567c --- /dev/null +++ b/.playwright-mcp/console-2026-06-10T08-39-38-791Z.log @@ -0,0 +1,2 @@ +[ 148ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout:56 +[ 17044ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout/1/confirmation:56 diff --git a/.playwright-mcp/console-2026-06-10T08-40-44-556Z.log b/.playwright-mcp/console-2026-06-10T08-40-44-556Z.log new file mode 100644 index 00000000..fd1f4a49 --- /dev/null +++ b/.playwright-mcp/console-2026-06-10T08-40-44-556Z.log @@ -0,0 +1,2 @@ +[ 100ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/register:56 +[ 15111ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account:56 diff --git a/.playwright-mcp/console-2026-06-10T08-41-14-156Z.log b/.playwright-mcp/console-2026-06-10T08-41-14-156Z.log new file mode 100644 index 00000000..8a6c80d7 --- /dev/null +++ b/.playwright-mcp/console-2026-06-10T08-41-14-156Z.log @@ -0,0 +1 @@ +[ 161ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin:52 diff --git a/.playwright-mcp/console-2026-06-10T08-43-00-495Z.log b/.playwright-mcp/console-2026-06-10T08-43-00-495Z.log new file mode 100644 index 00000000..f4ffe5be --- /dev/null +++ b/.playwright-mcp/console-2026-06-10T08-43-00-495Z.log @@ -0,0 +1 @@ +[ 145ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/orders/5:52 diff --git a/.playwright-mcp/console-2026-06-10T08-43-30-034Z.log b/.playwright-mcp/console-2026-06-10T08-43-30-034Z.log new file mode 100644 index 00000000..63b373e4 --- /dev/null +++ b/.playwright-mcp/console-2026-06-10T08-43-30-034Z.log @@ -0,0 +1 @@ +[ 156ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/products:52 diff --git a/.playwright-mcp/console-2026-06-10T08-43-35-161Z.log b/.playwright-mcp/console-2026-06-10T08-43-35-161Z.log new file mode 100644 index 00000000..be256014 --- /dev/null +++ b/.playwright-mcp/console-2026-06-10T08-43-35-161Z.log @@ -0,0 +1 @@ +[ 125ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/products/1/edit:52 diff --git a/.playwright-mcp/console-2026-06-10T08-43-40-574Z.log b/.playwright-mcp/console-2026-06-10T08-43-40-574Z.log new file mode 100644 index 00000000..a19fe375 --- /dev/null +++ b/.playwright-mcp/console-2026-06-10T08-43-40-574Z.log @@ -0,0 +1 @@ +[ 95ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/discounts:52 diff --git a/.playwright-mcp/console-2026-06-10T08-43-45-766Z.log b/.playwright-mcp/console-2026-06-10T08-43-45-766Z.log new file mode 100644 index 00000000..74bd5826 --- /dev/null +++ b/.playwright-mcp/console-2026-06-10T08-43-45-766Z.log @@ -0,0 +1 @@ +[ 126ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/settings/shipping:52 diff --git a/.playwright-mcp/console-2026-06-10T08-43-51-359Z.log b/.playwright-mcp/console-2026-06-10T08-43-51-359Z.log new file mode 100644 index 00000000..3ec016e0 --- /dev/null +++ b/.playwright-mcp/console-2026-06-10T08-43-51-359Z.log @@ -0,0 +1 @@ +[ 88ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/analytics:52 diff --git a/.playwright-mcp/console-2026-06-10T08-43-56-790Z.log b/.playwright-mcp/console-2026-06-10T08-43-56-790Z.log new file mode 100644 index 00000000..c353ecae --- /dev/null +++ b/.playwright-mcp/console-2026-06-10T08-43-56-790Z.log @@ -0,0 +1,2 @@ +[ 137ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/themes/1/editor:52 +[ 260ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/:56 diff --git a/.playwright-mcp/console-2026-06-10T08-44-03-720Z.log b/.playwright-mcp/console-2026-06-10T08-44-03-720Z.log new file mode 100644 index 00000000..24b3e113 --- /dev/null +++ b/.playwright-mcp/console-2026-06-10T08-44-03-720Z.log @@ -0,0 +1 @@ +[ 109ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/developers:52 diff --git a/.playwright-mcp/page-2026-06-10T08-32-30-919Z.yml b/.playwright-mcp/page-2026-06-10T08-32-30-919Z.yml new file mode 100644 index 00000000..3961bd32 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-32-30-919Z.yml @@ -0,0 +1,188 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "Log in" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e34]: 0 items in cart + - main [ref=e35]: + - generic [ref=e36]: + - generic [ref=e39]: + - heading "Welcome to Acme Fashion" [level=1] [ref=e40] + - paragraph [ref=e41]: Discover our curated collection of modern essentials + - link "Shop New Arrivals" [ref=e42] [cursor=pointer]: + - /url: /collections/new-arrivals + - region "Shop by collection" [ref=e43]: + - heading "Shop by collection" [level=2] [ref=e44] + - generic [ref=e45]: + - link "New Arrivals Shop now →" [ref=e46] [cursor=pointer]: + - /url: http://shop.test/collections/new-arrivals + - generic [ref=e49]: + - heading "New Arrivals" [level=3] [ref=e50] + - generic [ref=e51]: Shop now → + - link "T-Shirts Shop now →" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/collections/t-shirts + - generic [ref=e55]: + - heading "T-Shirts" [level=3] [ref=e56] + - generic [ref=e57]: Shop now → + - link "Sale Shop now →" [ref=e58] [cursor=pointer]: + - /url: http://shop.test/collections/sale + - generic [ref=e61]: + - heading "Sale" [level=3] [ref=e62] + - generic [ref=e63]: Shop now → + - region "Featured products" [ref=e64]: + - heading "Featured products" [level=2] [ref=e65] + - generic [ref=e66]: + - article [ref=e67]: + - img [ref=e70] + - generic [ref=e72]: + - heading "Classic Cotton T-Shirt" [level=3] [ref=e73]: + - link "Classic Cotton T-Shirt" [ref=e74] [cursor=pointer]: + - /url: http://shop.test/products/classic-cotton-t-shirt + - generic [ref=e76]: 24.99 EUR + - generic [ref=e77]: Choose options + - article [ref=e78]: + - generic [ref=e79]: + - img [ref=e81] + - generic "On sale" [ref=e84]: Sale + - generic [ref=e85]: + - heading "Premium Slim Fit Jeans" [level=3] [ref=e86]: + - link "Premium Slim Fit Jeans" [ref=e87] [cursor=pointer]: + - /url: http://shop.test/products/premium-slim-fit-jeans + - generic [ref=e88]: + - generic [ref=e89]: 79.99 EUR + - generic [ref=e90]: + - generic [ref=e91]: "Original price:" + - text: 99.99 EUR + - generic [ref=e92]: Choose options + - article [ref=e93]: + - img [ref=e96] + - generic [ref=e98]: + - heading "Organic Hoodie" [level=3] [ref=e99]: + - link "Organic Hoodie" [ref=e100] [cursor=pointer]: + - /url: http://shop.test/products/organic-hoodie + - generic [ref=e102]: 59.99 EUR + - generic [ref=e103]: Choose options + - article [ref=e104]: + - img [ref=e107] + - generic [ref=e109]: + - heading "Running Sneakers" [level=3] [ref=e110]: + - link "Running Sneakers" [ref=e111] [cursor=pointer]: + - /url: http://shop.test/products/running-sneakers + - generic [ref=e113]: 119.99 EUR + - generic [ref=e114]: Choose options + - article [ref=e115]: + - img [ref=e118] + - generic [ref=e120]: + - heading "Chino Shorts" [level=3] [ref=e121]: + - link "Chino Shorts" [ref=e122] [cursor=pointer]: + - /url: http://shop.test/products/chino-shorts + - generic [ref=e124]: 39.99 EUR + - generic [ref=e125]: Choose options + - article [ref=e126]: + - img [ref=e129] + - generic [ref=e131]: + - heading "Bucket Hat" [level=3] [ref=e132]: + - link "Bucket Hat" [ref=e133] [cursor=pointer]: + - /url: http://shop.test/products/bucket-hat + - generic [ref=e135]: 24.99 EUR + - generic [ref=e136]: Choose options + - article [ref=e137]: + - img [ref=e140] + - generic [ref=e142]: + - heading "Cashmere Overcoat" [level=3] [ref=e143]: + - link "Cashmere Overcoat" [ref=e144] [cursor=pointer]: + - /url: http://shop.test/products/cashmere-overcoat + - generic [ref=e146]: 499.99 EUR + - generic [ref=e147]: Choose options + - region "Stay in the loop" [ref=e148]: + - generic [ref=e149]: + - heading "Stay in the loop" [level=2] [ref=e150] + - paragraph [ref=e151]: Subscribe for exclusive offers and updates. + - generic [ref=e153]: + - generic [ref=e154]: Email address + - textbox "Email address" [ref=e155]: + - /placeholder: Enter your email + - button "Subscribe" [ref=e156] + - contentinfo [ref=e157]: + - generic [ref=e158]: + - generic [ref=e159]: + - generic [ref=e160]: + - heading "Shop" [level=2] [ref=e161] + - list [ref=e162]: + - listitem [ref=e163]: + - link "All collections" [ref=e164] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e165]: + - link "Home" [ref=e166] [cursor=pointer]: + - /url: / + - listitem [ref=e167]: + - link "New Arrivals" [ref=e168] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e169]: + - link "T-Shirts" [ref=e170] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e171]: + - link "Pants & Jeans" [ref=e172] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e173]: + - link "Sale" [ref=e174] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e175]: + - heading "Information" [level=2] [ref=e176] + - list [ref=e177]: + - listitem [ref=e178]: + - link "About Us" [ref=e179] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e180]: + - link "FAQ" [ref=e181] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e182]: + - link "Shipping & Returns" [ref=e183] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e184]: + - link "Privacy Policy" [ref=e185] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e186]: + - link "Terms of Service" [ref=e187] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e188]: + - heading "Acme Fashion" [level=2] [ref=e189] + - paragraph [ref=e190]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e191]: + - paragraph [ref=e192]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e193]: + - listitem [ref=e194]: Visa + - listitem [ref=e195]: Mastercard + - listitem [ref=e196]: Amex + - listitem [ref=e197]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-33-02-030Z.yml b/.playwright-mcp/page-2026-06-10T08-33-02-030Z.yml new file mode 100644 index 00000000..c15dda55 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-33-02-030Z.yml @@ -0,0 +1,192 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "Log in" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e34]: 0 items in cart + - main [ref=e35]: + - generic [ref=e36]: + - navigation "Breadcrumb" [ref=e37]: + - list [ref=e38]: + - listitem [ref=e39]: + - link "Home" [ref=e40] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e41] + - listitem [ref=e43]: + - link "Collections" [ref=e44] [cursor=pointer]: + - /url: http://shop.test/collections + - img [ref=e45] + - listitem [ref=e47]: + - generic [ref=e48]: T-Shirts + - generic [ref=e49]: + - heading "T-Shirts" [level=1] [ref=e50] + - paragraph [ref=e52]: Premium cotton tees for every occasion. + - generic [ref=e53]: + - paragraph [ref=e54]: 4 products + - generic [ref=e55]: + - generic [ref=e56]: Sort by + - combobox "Sort by" [ref=e57]: + - option "Featured" [selected] + - 'option "Price: Low to High"' + - 'option "Price: High to Low"' + - option "Newest" + - generic [ref=e58]: + - complementary "Product filters" [ref=e59]: + - generic [ref=e60]: + - group "Availability" [ref=e61]: + - button "Availability" [expanded] [ref=e63]: + - text: Availability + - img [ref=e64] + - generic [ref=e67] [cursor=pointer]: + - checkbox "In stock" [ref=e68] + - text: In stock + - group "Price" [ref=e69]: + - button "Price" [expanded] [ref=e71]: + - text: Price + - img [ref=e72] + - generic [ref=e74]: + - generic [ref=e75]: + - generic [ref=e76]: Minimum price + - generic: € + - spinbutton "Minimum price" [ref=e77] + - generic [ref=e78]: – + - generic [ref=e79]: + - generic [ref=e80]: Maximum price + - generic: € + - spinbutton "Maximum price" [ref=e81] + - group "Product type" [ref=e82]: + - button "Product type" [expanded] [ref=e84]: + - text: Product type + - img [ref=e85] + - generic [ref=e88] [cursor=pointer]: + - checkbox "T-Shirts" [ref=e89] + - text: T-Shirts + - group "Vendor" [ref=e90]: + - button "Vendor" [expanded] [ref=e92]: + - text: Vendor + - img [ref=e93] + - generic [ref=e96] [cursor=pointer]: + - checkbox "Acme Basics" [ref=e97] + - text: Acme Basics + - generic [ref=e99]: + - article [ref=e100]: + - img [ref=e103] + - generic [ref=e105]: + - heading "Classic Cotton T-Shirt" [level=3] [ref=e106]: + - link "Classic Cotton T-Shirt" [ref=e107] [cursor=pointer]: + - /url: http://shop.test/products/classic-cotton-t-shirt + - generic [ref=e109]: 24.99 EUR + - generic [ref=e110]: Choose options + - article [ref=e111]: + - img [ref=e114] + - generic [ref=e116]: + - heading "Graphic Print Tee" [level=3] [ref=e117]: + - link "Graphic Print Tee" [ref=e118] [cursor=pointer]: + - /url: http://shop.test/products/graphic-print-tee + - generic [ref=e120]: 29.99 EUR + - generic [ref=e121]: Choose options + - article [ref=e122]: + - img [ref=e125] + - generic [ref=e127]: + - heading "V-Neck Linen Tee" [level=3] [ref=e128]: + - link "V-Neck Linen Tee" [ref=e129] [cursor=pointer]: + - /url: http://shop.test/products/v-neck-linen-tee + - generic [ref=e131]: 34.99 EUR + - generic [ref=e132]: Choose options + - article [ref=e133]: + - generic [ref=e134]: + - img [ref=e136] + - generic "On sale" [ref=e139]: Sale + - generic [ref=e140]: + - heading "Striped Polo Shirt" [level=3] [ref=e141]: + - link "Striped Polo Shirt" [ref=e142] [cursor=pointer]: + - /url: http://shop.test/products/striped-polo-shirt + - generic [ref=e143]: + - generic [ref=e144]: 27.99 EUR + - generic [ref=e145]: + - generic [ref=e146]: "Original price:" + - text: 39.99 EUR + - generic [ref=e147]: Choose options + - contentinfo [ref=e148]: + - generic [ref=e149]: + - generic [ref=e150]: + - generic [ref=e151]: + - heading "Shop" [level=2] [ref=e152] + - list [ref=e153]: + - listitem [ref=e154]: + - link "All collections" [ref=e155] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e156]: + - link "Home" [ref=e157] [cursor=pointer]: + - /url: / + - listitem [ref=e158]: + - link "New Arrivals" [ref=e159] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e160]: + - link "T-Shirts" [ref=e161] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e162]: + - link "Pants & Jeans" [ref=e163] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e164]: + - link "Sale" [ref=e165] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e166]: + - heading "Information" [level=2] [ref=e167] + - list [ref=e168]: + - listitem [ref=e169]: + - link "About Us" [ref=e170] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e171]: + - link "FAQ" [ref=e172] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e173]: + - link "Shipping & Returns" [ref=e174] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e175]: + - link "Privacy Policy" [ref=e176] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e177]: + - link "Terms of Service" [ref=e178] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e179]: + - heading "Acme Fashion" [level=2] [ref=e180] + - paragraph [ref=e181]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e182]: + - paragraph [ref=e183]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e184]: + - listitem [ref=e185]: Visa + - listitem [ref=e186]: Mastercard + - listitem [ref=e187]: Amex + - listitem [ref=e188]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-33-10-751Z.yml b/.playwright-mcp/page-2026-06-10T08-33-10-751Z.yml new file mode 100644 index 00000000..4174bbe4 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-33-10-751Z.yml @@ -0,0 +1,157 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "Log in" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e34]: 0 items in cart + - main [ref=e35]: + - generic [ref=e36]: + - navigation "Breadcrumb" [ref=e37]: + - list [ref=e38]: + - listitem [ref=e39]: + - link "Home" [ref=e40] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e41] + - listitem [ref=e43]: + - link "New Arrivals" [ref=e44] [cursor=pointer]: + - /url: http://shop.test/collections/new-arrivals + - img [ref=e45] + - listitem [ref=e47]: + - generic [ref=e48]: Classic Cotton T-Shirt + - generic [ref=e49]: + - region "Product images" [ref=e50]: + - img [ref=e53] + - region "Product information" [ref=e55]: + - heading "Classic Cotton T-Shirt" [level=1] [ref=e56] + - paragraph [ref=e57]: Acme Basics + - generic [ref=e60]: 24.99 EUR + - generic [ref=e61]: + - group "Size" [ref=e62]: + - generic [ref=e63]: Size + - generic [ref=e64]: + - generic [ref=e65] [cursor=pointer]: + - radio "S" [checked] [ref=e66] + - generic [ref=e67]: S + - generic [ref=e68] [cursor=pointer]: + - radio "M" [ref=e69] + - generic [ref=e70]: M + - generic [ref=e71] [cursor=pointer]: + - radio "L" [ref=e72] + - generic [ref=e73]: L + - generic [ref=e74] [cursor=pointer]: + - radio "XL" [ref=e75] + - generic [ref=e76]: XL + - group "Color" [ref=e77]: + - generic [ref=e78]: Color + - generic [ref=e79]: + - generic "White" [ref=e80] [cursor=pointer]: + - radio "White" [checked] [ref=e81] + - generic [ref=e83]: White + - generic "Black" [ref=e84] [cursor=pointer]: + - radio "Black" [ref=e85] + - generic [ref=e87]: Black + - generic "Navy" [ref=e88] [cursor=pointer]: + - radio "Navy" [ref=e89] + - generic [ref=e91]: Navy + - paragraph [ref=e93]: + - img [ref=e94] + - text: In stock + - generic [ref=e96]: + - generic [ref=e97]: + - generic [ref=e98]: Quantity + - generic [ref=e99]: + - button "Decrease quantity" [disabled] [ref=e100]: + - img [ref=e101] + - spinbutton "Quantity" [ref=e102]: "1" + - button "Increase quantity" [ref=e103]: + - img [ref=e104] + - button "Add to cart" [ref=e106]: + - generic [ref=e107]: Add to cart + - separator [ref=e108] + - paragraph [ref=e110]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - generic [ref=e111]: + - generic [ref=e112]: new + - generic [ref=e113]: popular + - contentinfo [ref=e114]: + - generic [ref=e115]: + - generic [ref=e116]: + - generic [ref=e117]: + - heading "Shop" [level=2] [ref=e118] + - list [ref=e119]: + - listitem [ref=e120]: + - link "All collections" [ref=e121] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e122]: + - link "Home" [ref=e123] [cursor=pointer]: + - /url: / + - listitem [ref=e124]: + - link "New Arrivals" [ref=e125] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e126]: + - link "T-Shirts" [ref=e127] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e128]: + - link "Pants & Jeans" [ref=e129] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e130]: + - link "Sale" [ref=e131] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e132]: + - heading "Information" [level=2] [ref=e133] + - list [ref=e134]: + - listitem [ref=e135]: + - link "About Us" [ref=e136] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e137]: + - link "FAQ" [ref=e138] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e139]: + - link "Shipping & Returns" [ref=e140] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e141]: + - link "Privacy Policy" [ref=e142] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e143]: + - link "Terms of Service" [ref=e144] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e145]: + - heading "Acme Fashion" [level=2] [ref=e146] + - paragraph [ref=e147]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e148]: + - paragraph [ref=e149]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e150]: + - listitem [ref=e151]: Visa + - listitem [ref=e152]: Mastercard + - listitem [ref=e153]: Amex + - listitem [ref=e154]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-33-36-050Z.yml b/.playwright-mcp/page-2026-06-10T08-33-36-050Z.yml new file mode 100644 index 00000000..2f4eb480 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-33-36-050Z.yml @@ -0,0 +1,157 @@ +- generic [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "Log in" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e34]: 0 items in cart + - main [ref=e35]: + - generic [ref=e36]: + - navigation "Breadcrumb" [ref=e37]: + - list [ref=e38]: + - listitem [ref=e39]: + - link "Home" [ref=e40] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e41] + - listitem [ref=e43]: + - link "New Arrivals" [ref=e44] [cursor=pointer]: + - /url: http://shop.test/collections/new-arrivals + - img [ref=e45] + - listitem [ref=e47]: + - generic [ref=e48]: Classic Cotton T-Shirt + - generic [ref=e49]: + - region "Product images" [ref=e50]: + - img [ref=e53] + - region "Product information" [ref=e55]: + - heading "Classic Cotton T-Shirt" [level=1] [ref=e56] + - paragraph [ref=e57]: Acme Basics + - generic [ref=e60]: 24.99 EUR + - generic [ref=e61]: + - group "Size" [ref=e62]: + - generic [ref=e63]: Size + - generic [ref=e64]: + - generic [ref=e65] [cursor=pointer]: + - radio "S" [ref=e66] + - generic [ref=e67]: S + - generic [ref=e68] [cursor=pointer]: + - radio "M" [checked] [active] [ref=e69] + - generic [ref=e70]: M + - generic [ref=e71] [cursor=pointer]: + - radio "L" [ref=e72] + - generic [ref=e73]: L + - generic [ref=e74] [cursor=pointer]: + - radio "XL" [ref=e75] + - generic [ref=e76]: XL + - group "Color" [ref=e77]: + - generic [ref=e78]: Color + - generic [ref=e79]: + - generic "White" [ref=e80] [cursor=pointer]: + - radio "White" [checked] [ref=e81] + - generic [ref=e83]: White + - generic "Black" [ref=e84] [cursor=pointer]: + - radio "Black" [ref=e85] + - generic [ref=e87]: Black + - generic "Navy" [ref=e88] [cursor=pointer]: + - radio "Navy" [ref=e89] + - generic [ref=e91]: Navy + - paragraph [ref=e93]: + - img [ref=e94] + - text: In stock + - generic [ref=e96]: + - generic [ref=e97]: + - generic [ref=e98]: Quantity + - generic [ref=e99]: + - button "Decrease quantity" [disabled] [ref=e100]: + - img [ref=e101] + - spinbutton "Quantity" [ref=e102]: "1" + - button "Increase quantity" [ref=e103]: + - img [ref=e104] + - button "Add to cart" [ref=e106]: + - generic [ref=e107]: Add to cart + - separator [ref=e108] + - paragraph [ref=e110]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - generic [ref=e111]: + - generic [ref=e112]: new + - generic [ref=e113]: popular + - contentinfo [ref=e114]: + - generic [ref=e115]: + - generic [ref=e116]: + - generic [ref=e117]: + - heading "Shop" [level=2] [ref=e118] + - list [ref=e119]: + - listitem [ref=e120]: + - link "All collections" [ref=e121] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e122]: + - link "Home" [ref=e123] [cursor=pointer]: + - /url: / + - listitem [ref=e124]: + - link "New Arrivals" [ref=e125] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e126]: + - link "T-Shirts" [ref=e127] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e128]: + - link "Pants & Jeans" [ref=e129] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e130]: + - link "Sale" [ref=e131] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e132]: + - heading "Information" [level=2] [ref=e133] + - list [ref=e134]: + - listitem [ref=e135]: + - link "About Us" [ref=e136] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e137]: + - link "FAQ" [ref=e138] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e139]: + - link "Shipping & Returns" [ref=e140] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e141]: + - link "Privacy Policy" [ref=e142] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e143]: + - link "Terms of Service" [ref=e144] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e145]: + - heading "Acme Fashion" [level=2] [ref=e146] + - paragraph [ref=e147]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e148]: + - paragraph [ref=e149]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e150]: + - listitem [ref=e151]: Visa + - listitem [ref=e152]: Mastercard + - listitem [ref=e153]: Amex + - listitem [ref=e154]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-33-44-220Z.yml b/.playwright-mcp/page-2026-06-10T08-33-44-220Z.yml new file mode 100644 index 00000000..40b8d560 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-33-44-220Z.yml @@ -0,0 +1,157 @@ +- generic [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "Log in" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e34]: 0 items in cart + - main [ref=e35]: + - generic [ref=e36]: + - navigation "Breadcrumb" [ref=e37]: + - list [ref=e38]: + - listitem [ref=e39]: + - link "Home" [ref=e40] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e41] + - listitem [ref=e43]: + - link "New Arrivals" [ref=e44] [cursor=pointer]: + - /url: http://shop.test/collections/new-arrivals + - img [ref=e45] + - listitem [ref=e47]: + - generic [ref=e48]: Classic Cotton T-Shirt + - generic [ref=e49]: + - region "Product images" [ref=e50]: + - img [ref=e53] + - region "Product information" [ref=e55]: + - heading "Classic Cotton T-Shirt" [level=1] [ref=e56] + - paragraph [ref=e57]: Acme Basics + - generic [ref=e60]: 24.99 EUR + - generic [ref=e61]: + - group "Size" [ref=e62]: + - generic [ref=e63]: Size + - generic [ref=e64]: + - generic [ref=e65] [cursor=pointer]: + - radio "S" [ref=e66] + - generic [ref=e67]: S + - generic [ref=e68] [cursor=pointer]: + - radio "M" [checked] [ref=e69] + - generic [ref=e70]: M + - generic [ref=e71] [cursor=pointer]: + - radio "L" [ref=e72] + - generic [ref=e73]: L + - generic [ref=e74] [cursor=pointer]: + - radio "XL" [ref=e75] + - generic [ref=e76]: XL + - group "Color" [ref=e77]: + - generic [ref=e78]: Color + - generic [ref=e79]: + - generic "White" [ref=e80] [cursor=pointer]: + - radio "White" [ref=e81] + - generic [ref=e83]: White + - generic "Black" [ref=e84] [cursor=pointer]: + - radio "Black" [ref=e85] + - generic [ref=e87]: Black + - generic "Navy" [ref=e88] [cursor=pointer]: + - radio "Navy" [checked] [active] [ref=e89] + - generic [ref=e91]: Navy + - paragraph [ref=e93]: + - img [ref=e94] + - text: In stock + - generic [ref=e96]: + - generic [ref=e97]: + - generic [ref=e98]: Quantity + - generic [ref=e99]: + - button "Decrease quantity" [disabled] [ref=e100]: + - img [ref=e101] + - spinbutton "Quantity" [ref=e102]: "1" + - button "Increase quantity" [ref=e103]: + - img [ref=e104] + - button "Add to cart" [ref=e106]: + - generic [ref=e107]: Add to cart + - separator [ref=e108] + - paragraph [ref=e110]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - generic [ref=e111]: + - generic [ref=e112]: new + - generic [ref=e113]: popular + - contentinfo [ref=e114]: + - generic [ref=e115]: + - generic [ref=e116]: + - generic [ref=e117]: + - heading "Shop" [level=2] [ref=e118] + - list [ref=e119]: + - listitem [ref=e120]: + - link "All collections" [ref=e121] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e122]: + - link "Home" [ref=e123] [cursor=pointer]: + - /url: / + - listitem [ref=e124]: + - link "New Arrivals" [ref=e125] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e126]: + - link "T-Shirts" [ref=e127] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e128]: + - link "Pants & Jeans" [ref=e129] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e130]: + - link "Sale" [ref=e131] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e132]: + - heading "Information" [level=2] [ref=e133] + - list [ref=e134]: + - listitem [ref=e135]: + - link "About Us" [ref=e136] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e137]: + - link "FAQ" [ref=e138] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e139]: + - link "Shipping & Returns" [ref=e140] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e141]: + - link "Privacy Policy" [ref=e142] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e143]: + - link "Terms of Service" [ref=e144] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e145]: + - heading "Acme Fashion" [level=2] [ref=e146] + - paragraph [ref=e147]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e148]: + - paragraph [ref=e149]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e150]: + - listitem [ref=e151]: Visa + - listitem [ref=e152]: Mastercard + - listitem [ref=e153]: Amex + - listitem [ref=e154]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-33-49-387Z.yml b/.playwright-mcp/page-2026-06-10T08-33-49-387Z.yml new file mode 100644 index 00000000..d56b8696 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-33-49-387Z.yml @@ -0,0 +1,157 @@ +- generic [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "Log in" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e34]: 0 items in cart + - main [ref=e35]: + - generic [ref=e36]: + - navigation "Breadcrumb" [ref=e37]: + - list [ref=e38]: + - listitem [ref=e39]: + - link "Home" [ref=e40] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e41] + - listitem [ref=e43]: + - link "New Arrivals" [ref=e44] [cursor=pointer]: + - /url: http://shop.test/collections/new-arrivals + - img [ref=e45] + - listitem [ref=e47]: + - generic [ref=e48]: Classic Cotton T-Shirt + - generic [ref=e49]: + - region "Product images" [ref=e50]: + - img [ref=e53] + - region "Product information" [ref=e55]: + - heading "Classic Cotton T-Shirt" [level=1] [ref=e56] + - paragraph [ref=e57]: Acme Basics + - generic [ref=e60]: 24.99 EUR + - generic [ref=e61]: + - group "Size" [ref=e62]: + - generic [ref=e63]: Size + - generic [ref=e64]: + - generic [ref=e65] [cursor=pointer]: + - radio "S" [ref=e66] + - generic [ref=e67]: S + - generic [ref=e68] [cursor=pointer]: + - radio "M" [checked] [ref=e69] + - generic [ref=e70]: M + - generic [ref=e71] [cursor=pointer]: + - radio "L" [ref=e72] + - generic [ref=e73]: L + - generic [ref=e74] [cursor=pointer]: + - radio "XL" [ref=e75] + - generic [ref=e76]: XL + - group "Color" [ref=e77]: + - generic [ref=e78]: Color + - generic [ref=e79]: + - generic "White" [ref=e80] [cursor=pointer]: + - radio "White" [ref=e81] + - generic [ref=e83]: White + - generic "Black" [ref=e84] [cursor=pointer]: + - radio "Black" [ref=e85] + - generic [ref=e87]: Black + - generic "Navy" [ref=e88] [cursor=pointer]: + - radio "Navy" [checked] [ref=e89] + - generic [ref=e91]: Navy + - paragraph [ref=e93]: + - img [ref=e94] + - text: In stock + - generic [ref=e96]: + - generic [ref=e97]: + - generic [ref=e98]: Quantity + - generic [ref=e99]: + - button "Decrease quantity" [ref=e100]: + - img [ref=e101] + - spinbutton "Quantity" [ref=e102]: "2" + - button "Increase quantity" [active] [ref=e103]: + - img [ref=e104] + - button "Add to cart" [ref=e106]: + - generic [ref=e107]: Add to cart + - separator [ref=e108] + - paragraph [ref=e110]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - generic [ref=e111]: + - generic [ref=e112]: new + - generic [ref=e113]: popular + - contentinfo [ref=e114]: + - generic [ref=e115]: + - generic [ref=e116]: + - generic [ref=e117]: + - heading "Shop" [level=2] [ref=e118] + - list [ref=e119]: + - listitem [ref=e120]: + - link "All collections" [ref=e121] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e122]: + - link "Home" [ref=e123] [cursor=pointer]: + - /url: / + - listitem [ref=e124]: + - link "New Arrivals" [ref=e125] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e126]: + - link "T-Shirts" [ref=e127] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e128]: + - link "Pants & Jeans" [ref=e129] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e130]: + - link "Sale" [ref=e131] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e132]: + - heading "Information" [level=2] [ref=e133] + - list [ref=e134]: + - listitem [ref=e135]: + - link "About Us" [ref=e136] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e137]: + - link "FAQ" [ref=e138] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e139]: + - link "Shipping & Returns" [ref=e140] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e141]: + - link "Privacy Policy" [ref=e142] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e143]: + - link "Terms of Service" [ref=e144] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e145]: + - heading "Acme Fashion" [level=2] [ref=e146] + - paragraph [ref=e147]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e148]: + - paragraph [ref=e149]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e150]: + - listitem [ref=e151]: Visa + - listitem [ref=e152]: Mastercard + - listitem [ref=e153]: Amex + - listitem [ref=e154]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-33-55-934Z.yml b/.playwright-mcp/page-2026-06-10T08-33-55-934Z.yml new file mode 100644 index 00000000..3bb31d5b --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-33-55-934Z.yml @@ -0,0 +1,201 @@ +- generic [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "Log in" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e155]: "2" + - generic [ref=e34]: 2 items in cart + - main [ref=e35]: + - generic [ref=e36]: + - navigation "Breadcrumb" [ref=e37]: + - list [ref=e38]: + - listitem [ref=e39]: + - link "Home" [ref=e40] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e41] + - listitem [ref=e43]: + - link "New Arrivals" [ref=e44] [cursor=pointer]: + - /url: http://shop.test/collections/new-arrivals + - img [ref=e45] + - listitem [ref=e47]: + - generic [ref=e48]: Classic Cotton T-Shirt + - generic [ref=e49]: + - region "Product images" [ref=e50]: + - img [ref=e53] + - region "Product information" [ref=e55]: + - heading "Classic Cotton T-Shirt" [level=1] [ref=e56] + - paragraph [ref=e57]: Acme Basics + - generic [ref=e60]: 24.99 EUR + - generic [ref=e61]: + - group "Size" [ref=e62]: + - generic [ref=e63]: Size + - generic [ref=e64]: + - generic [ref=e65] [cursor=pointer]: + - radio "S" [ref=e66] + - generic [ref=e67]: S + - generic [ref=e68] [cursor=pointer]: + - radio "M" [checked] [ref=e69] + - generic [ref=e70]: M + - generic [ref=e71] [cursor=pointer]: + - radio "L" [ref=e72] + - generic [ref=e73]: L + - generic [ref=e74] [cursor=pointer]: + - radio "XL" [ref=e75] + - generic [ref=e76]: XL + - group "Color" [ref=e77]: + - generic [ref=e78]: Color + - generic [ref=e79]: + - generic "White" [ref=e80] [cursor=pointer]: + - radio "White" [ref=e81] + - generic [ref=e83]: White + - generic "Black" [ref=e84] [cursor=pointer]: + - radio "Black" [ref=e85] + - generic [ref=e87]: Black + - generic "Navy" [ref=e88] [cursor=pointer]: + - radio "Navy" [checked] [ref=e89] + - generic [ref=e91]: Navy + - paragraph [ref=e93]: + - img [ref=e94] + - text: In stock + - generic [ref=e96]: + - generic [ref=e97]: + - generic [ref=e98]: Quantity + - generic [ref=e99]: + - button "Decrease quantity" [ref=e100]: + - img [ref=e101] + - spinbutton "Quantity" [ref=e102]: "2" + - button "Increase quantity" [ref=e103]: + - img [ref=e104] + - button "Add to cart" [ref=e106]: + - generic [ref=e107]: Add to cart + - status [ref=e157]: + - img [ref=e158] + - text: Added to cart + - separator [ref=e108] + - paragraph [ref=e110]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - generic [ref=e111]: + - generic [ref=e112]: new + - generic [ref=e113]: popular + - contentinfo [ref=e114]: + - generic [ref=e115]: + - generic [ref=e116]: + - generic [ref=e117]: + - heading "Shop" [level=2] [ref=e118] + - list [ref=e119]: + - listitem [ref=e120]: + - link "All collections" [ref=e121] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e122]: + - link "Home" [ref=e123] [cursor=pointer]: + - /url: / + - listitem [ref=e124]: + - link "New Arrivals" [ref=e125] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e126]: + - link "T-Shirts" [ref=e127] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e128]: + - link "Pants & Jeans" [ref=e129] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e130]: + - link "Sale" [ref=e131] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e132]: + - heading "Information" [level=2] [ref=e133] + - list [ref=e134]: + - listitem [ref=e135]: + - link "About Us" [ref=e136] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e137]: + - link "FAQ" [ref=e138] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e139]: + - link "Shipping & Returns" [ref=e140] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e141]: + - link "Privacy Policy" [ref=e142] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e143]: + - link "Terms of Service" [ref=e144] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e145]: + - heading "Acme Fashion" [level=2] [ref=e146] + - paragraph [ref=e147]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e148]: + - paragraph [ref=e149]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e150]: + - listitem [ref=e151]: Visa + - listitem [ref=e152]: Mastercard + - listitem [ref=e153]: Amex + - listitem [ref=e154]: PayPal + - generic: + - dialog "Shopping cart": + - generic [ref=e161]: + - generic [ref=e162]: + - heading "Your Cart (2)" [level=2] [ref=e163] + - button "Close cart" [active] [ref=e164]: + - img [ref=e165] + - list [ref=e167]: + - listitem [ref=e168]: + - generic [ref=e170]: + - paragraph [ref=e171]: Classic Cotton T-Shirt + - paragraph [ref=e172]: M / Navy + - generic [ref=e173]: + - generic [ref=e174]: + - button "Decrease quantity" [ref=e175]: + - img [ref=e176] + - generic [ref=e177]: "2" + - button "Increase quantity" [ref=e178]: + - img [ref=e179] + - generic [ref=e182]: 49.98 EUR + - button "Remove Classic Cotton T-Shirt from cart" [ref=e184]: + - img [ref=e185] + - generic [ref=e188]: + - generic [ref=e189]: Discount code + - textbox "Discount code" [ref=e190] + - button "Apply" [ref=e191] + - generic [ref=e192]: + - generic [ref=e193]: + - generic [ref=e194]: + - term [ref=e195]: Subtotal + - definition [ref=e196]: + - generic [ref=e198]: 49.98 EUR + - generic [ref=e199]: + - term [ref=e200]: Estimated total + - definition [ref=e201]: + - generic [ref=e203]: 49.98 EUR + - paragraph [ref=e204]: Shipping and taxes calculated at checkout + - link "Checkout" [ref=e205] [cursor=pointer]: + - /url: http://shop.test/checkout + - button "Continue shopping" [ref=e206] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-34-00-404Z.yml b/.playwright-mcp/page-2026-06-10T08-34-00-404Z.yml new file mode 100644 index 00000000..3bb31d5b --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-34-00-404Z.yml @@ -0,0 +1,201 @@ +- generic [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "Log in" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e155]: "2" + - generic [ref=e34]: 2 items in cart + - main [ref=e35]: + - generic [ref=e36]: + - navigation "Breadcrumb" [ref=e37]: + - list [ref=e38]: + - listitem [ref=e39]: + - link "Home" [ref=e40] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e41] + - listitem [ref=e43]: + - link "New Arrivals" [ref=e44] [cursor=pointer]: + - /url: http://shop.test/collections/new-arrivals + - img [ref=e45] + - listitem [ref=e47]: + - generic [ref=e48]: Classic Cotton T-Shirt + - generic [ref=e49]: + - region "Product images" [ref=e50]: + - img [ref=e53] + - region "Product information" [ref=e55]: + - heading "Classic Cotton T-Shirt" [level=1] [ref=e56] + - paragraph [ref=e57]: Acme Basics + - generic [ref=e60]: 24.99 EUR + - generic [ref=e61]: + - group "Size" [ref=e62]: + - generic [ref=e63]: Size + - generic [ref=e64]: + - generic [ref=e65] [cursor=pointer]: + - radio "S" [ref=e66] + - generic [ref=e67]: S + - generic [ref=e68] [cursor=pointer]: + - radio "M" [checked] [ref=e69] + - generic [ref=e70]: M + - generic [ref=e71] [cursor=pointer]: + - radio "L" [ref=e72] + - generic [ref=e73]: L + - generic [ref=e74] [cursor=pointer]: + - radio "XL" [ref=e75] + - generic [ref=e76]: XL + - group "Color" [ref=e77]: + - generic [ref=e78]: Color + - generic [ref=e79]: + - generic "White" [ref=e80] [cursor=pointer]: + - radio "White" [ref=e81] + - generic [ref=e83]: White + - generic "Black" [ref=e84] [cursor=pointer]: + - radio "Black" [ref=e85] + - generic [ref=e87]: Black + - generic "Navy" [ref=e88] [cursor=pointer]: + - radio "Navy" [checked] [ref=e89] + - generic [ref=e91]: Navy + - paragraph [ref=e93]: + - img [ref=e94] + - text: In stock + - generic [ref=e96]: + - generic [ref=e97]: + - generic [ref=e98]: Quantity + - generic [ref=e99]: + - button "Decrease quantity" [ref=e100]: + - img [ref=e101] + - spinbutton "Quantity" [ref=e102]: "2" + - button "Increase quantity" [ref=e103]: + - img [ref=e104] + - button "Add to cart" [ref=e106]: + - generic [ref=e107]: Add to cart + - status [ref=e157]: + - img [ref=e158] + - text: Added to cart + - separator [ref=e108] + - paragraph [ref=e110]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - generic [ref=e111]: + - generic [ref=e112]: new + - generic [ref=e113]: popular + - contentinfo [ref=e114]: + - generic [ref=e115]: + - generic [ref=e116]: + - generic [ref=e117]: + - heading "Shop" [level=2] [ref=e118] + - list [ref=e119]: + - listitem [ref=e120]: + - link "All collections" [ref=e121] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e122]: + - link "Home" [ref=e123] [cursor=pointer]: + - /url: / + - listitem [ref=e124]: + - link "New Arrivals" [ref=e125] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e126]: + - link "T-Shirts" [ref=e127] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e128]: + - link "Pants & Jeans" [ref=e129] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e130]: + - link "Sale" [ref=e131] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e132]: + - heading "Information" [level=2] [ref=e133] + - list [ref=e134]: + - listitem [ref=e135]: + - link "About Us" [ref=e136] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e137]: + - link "FAQ" [ref=e138] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e139]: + - link "Shipping & Returns" [ref=e140] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e141]: + - link "Privacy Policy" [ref=e142] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e143]: + - link "Terms of Service" [ref=e144] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e145]: + - heading "Acme Fashion" [level=2] [ref=e146] + - paragraph [ref=e147]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e148]: + - paragraph [ref=e149]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e150]: + - listitem [ref=e151]: Visa + - listitem [ref=e152]: Mastercard + - listitem [ref=e153]: Amex + - listitem [ref=e154]: PayPal + - generic: + - dialog "Shopping cart": + - generic [ref=e161]: + - generic [ref=e162]: + - heading "Your Cart (2)" [level=2] [ref=e163] + - button "Close cart" [active] [ref=e164]: + - img [ref=e165] + - list [ref=e167]: + - listitem [ref=e168]: + - generic [ref=e170]: + - paragraph [ref=e171]: Classic Cotton T-Shirt + - paragraph [ref=e172]: M / Navy + - generic [ref=e173]: + - generic [ref=e174]: + - button "Decrease quantity" [ref=e175]: + - img [ref=e176] + - generic [ref=e177]: "2" + - button "Increase quantity" [ref=e178]: + - img [ref=e179] + - generic [ref=e182]: 49.98 EUR + - button "Remove Classic Cotton T-Shirt from cart" [ref=e184]: + - img [ref=e185] + - generic [ref=e188]: + - generic [ref=e189]: Discount code + - textbox "Discount code" [ref=e190] + - button "Apply" [ref=e191] + - generic [ref=e192]: + - generic [ref=e193]: + - generic [ref=e194]: + - term [ref=e195]: Subtotal + - definition [ref=e196]: + - generic [ref=e198]: 49.98 EUR + - generic [ref=e199]: + - term [ref=e200]: Estimated total + - definition [ref=e201]: + - generic [ref=e203]: 49.98 EUR + - paragraph [ref=e204]: Shipping and taxes calculated at checkout + - link "Checkout" [ref=e205] [cursor=pointer]: + - /url: http://shop.test/checkout + - button "Continue shopping" [ref=e206] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-34-23-098Z.yml b/.playwright-mcp/page-2026-06-10T08-34-23-098Z.yml new file mode 100644 index 00000000..23e86e52 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-34-23-098Z.yml @@ -0,0 +1,203 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "Log in" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e155]: "2" + - generic [ref=e34]: 2 items in cart + - main [ref=e35]: + - generic [ref=e36]: + - navigation "Breadcrumb" [ref=e37]: + - list [ref=e38]: + - listitem [ref=e39]: + - link "Home" [ref=e40] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e41] + - listitem [ref=e43]: + - link "New Arrivals" [ref=e44] [cursor=pointer]: + - /url: http://shop.test/collections/new-arrivals + - img [ref=e45] + - listitem [ref=e47]: + - generic [ref=e48]: Classic Cotton T-Shirt + - generic [ref=e49]: + - region "Product images" [ref=e50]: + - img [ref=e53] + - region "Product information" [ref=e55]: + - heading "Classic Cotton T-Shirt" [level=1] [ref=e56] + - paragraph [ref=e57]: Acme Basics + - generic [ref=e60]: 24.99 EUR + - generic [ref=e61]: + - group "Size" [ref=e62]: + - generic [ref=e63]: Size + - generic [ref=e64]: + - generic [ref=e65] [cursor=pointer]: + - radio "S" [ref=e66] + - generic [ref=e67]: S + - generic [ref=e68] [cursor=pointer]: + - radio "M" [checked] [ref=e69] + - generic [ref=e70]: M + - generic [ref=e71] [cursor=pointer]: + - radio "L" [ref=e72] + - generic [ref=e73]: L + - generic [ref=e74] [cursor=pointer]: + - radio "XL" [ref=e75] + - generic [ref=e76]: XL + - group "Color" [ref=e77]: + - generic [ref=e78]: Color + - generic [ref=e79]: + - generic "White" [ref=e80] [cursor=pointer]: + - radio "White" [ref=e81] + - generic [ref=e83]: White + - generic "Black" [ref=e84] [cursor=pointer]: + - radio "Black" [ref=e85] + - generic [ref=e87]: Black + - generic "Navy" [ref=e88] [cursor=pointer]: + - radio "Navy" [checked] [ref=e89] + - generic [ref=e91]: Navy + - paragraph [ref=e93]: + - img [ref=e94] + - text: In stock + - generic [ref=e96]: + - generic [ref=e97]: + - generic [ref=e98]: Quantity + - generic [ref=e99]: + - button "Decrease quantity" [ref=e100]: + - img [ref=e101] + - spinbutton "Quantity" [ref=e102]: "2" + - button "Increase quantity" [ref=e103]: + - img [ref=e104] + - button "Add to cart" [ref=e106]: + - generic [ref=e107]: Add to cart + - status [ref=e157]: + - img [ref=e158] + - text: Added to cart + - separator [ref=e108] + - paragraph [ref=e110]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - generic [ref=e111]: + - generic [ref=e112]: new + - generic [ref=e113]: popular + - contentinfo [ref=e114]: + - generic [ref=e115]: + - generic [ref=e116]: + - generic [ref=e117]: + - heading "Shop" [level=2] [ref=e118] + - list [ref=e119]: + - listitem [ref=e120]: + - link "All collections" [ref=e121] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e122]: + - link "Home" [ref=e123] [cursor=pointer]: + - /url: / + - listitem [ref=e124]: + - link "New Arrivals" [ref=e125] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e126]: + - link "T-Shirts" [ref=e127] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e128]: + - link "Pants & Jeans" [ref=e129] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e130]: + - link "Sale" [ref=e131] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e132]: + - heading "Information" [level=2] [ref=e133] + - list [ref=e134]: + - listitem [ref=e135]: + - link "About Us" [ref=e136] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e137]: + - link "FAQ" [ref=e138] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e139]: + - link "Shipping & Returns" [ref=e140] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e141]: + - link "Privacy Policy" [ref=e142] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e143]: + - link "Terms of Service" [ref=e144] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e145]: + - heading "Acme Fashion" [level=2] [ref=e146] + - paragraph [ref=e147]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e148]: + - paragraph [ref=e149]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e150]: + - listitem [ref=e151]: Visa + - listitem [ref=e152]: Mastercard + - listitem [ref=e153]: Amex + - listitem [ref=e154]: PayPal + - generic: + - dialog "Shopping cart": + - generic [ref=e161]: + - generic [ref=e162]: + - heading "Your Cart (2)" [level=2] [ref=e163] + - button "Close cart" [ref=e164]: + - img [ref=e165] + - list [ref=e167]: + - listitem [ref=e168]: + - generic [ref=e170]: + - paragraph [ref=e171]: Classic Cotton T-Shirt + - paragraph [ref=e172]: M / Navy + - generic [ref=e173]: + - generic [ref=e174]: + - button "Decrease quantity" [ref=e175]: + - img [ref=e176] + - generic [ref=e177]: "2" + - button "Increase quantity" [ref=e178]: + - img [ref=e179] + - generic [ref=e182]: 49.98 EUR + - button "Remove Classic Cotton T-Shirt from cart" [ref=e184]: + - img [ref=e185] + - generic [ref=e207]: + - generic [ref=e208]: WELCOME10 (-4.99 EUR) + - button "Remove" [ref=e209] + - generic [ref=e192]: + - generic [ref=e193]: + - generic [ref=e194]: + - term [ref=e195]: Subtotal + - definition [ref=e196]: + - generic [ref=e198]: 49.98 EUR + - generic [ref=e210]: + - term [ref=e211]: Discount (WELCOME10) + - definition [ref=e212]: "-4.99 EUR" + - generic [ref=e199]: + - term [ref=e200]: Estimated total + - definition [ref=e201]: + - generic [ref=e203]: 44.99 EUR + - paragraph [ref=e204]: Shipping and taxes calculated at checkout + - link "Checkout" [ref=e205] [cursor=pointer]: + - /url: http://shop.test/checkout + - button "Continue shopping" [ref=e206] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-34-27-680Z.yml b/.playwright-mcp/page-2026-06-10T08-34-27-680Z.yml new file mode 100644 index 00000000..23e86e52 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-34-27-680Z.yml @@ -0,0 +1,203 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "Log in" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e155]: "2" + - generic [ref=e34]: 2 items in cart + - main [ref=e35]: + - generic [ref=e36]: + - navigation "Breadcrumb" [ref=e37]: + - list [ref=e38]: + - listitem [ref=e39]: + - link "Home" [ref=e40] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e41] + - listitem [ref=e43]: + - link "New Arrivals" [ref=e44] [cursor=pointer]: + - /url: http://shop.test/collections/new-arrivals + - img [ref=e45] + - listitem [ref=e47]: + - generic [ref=e48]: Classic Cotton T-Shirt + - generic [ref=e49]: + - region "Product images" [ref=e50]: + - img [ref=e53] + - region "Product information" [ref=e55]: + - heading "Classic Cotton T-Shirt" [level=1] [ref=e56] + - paragraph [ref=e57]: Acme Basics + - generic [ref=e60]: 24.99 EUR + - generic [ref=e61]: + - group "Size" [ref=e62]: + - generic [ref=e63]: Size + - generic [ref=e64]: + - generic [ref=e65] [cursor=pointer]: + - radio "S" [ref=e66] + - generic [ref=e67]: S + - generic [ref=e68] [cursor=pointer]: + - radio "M" [checked] [ref=e69] + - generic [ref=e70]: M + - generic [ref=e71] [cursor=pointer]: + - radio "L" [ref=e72] + - generic [ref=e73]: L + - generic [ref=e74] [cursor=pointer]: + - radio "XL" [ref=e75] + - generic [ref=e76]: XL + - group "Color" [ref=e77]: + - generic [ref=e78]: Color + - generic [ref=e79]: + - generic "White" [ref=e80] [cursor=pointer]: + - radio "White" [ref=e81] + - generic [ref=e83]: White + - generic "Black" [ref=e84] [cursor=pointer]: + - radio "Black" [ref=e85] + - generic [ref=e87]: Black + - generic "Navy" [ref=e88] [cursor=pointer]: + - radio "Navy" [checked] [ref=e89] + - generic [ref=e91]: Navy + - paragraph [ref=e93]: + - img [ref=e94] + - text: In stock + - generic [ref=e96]: + - generic [ref=e97]: + - generic [ref=e98]: Quantity + - generic [ref=e99]: + - button "Decrease quantity" [ref=e100]: + - img [ref=e101] + - spinbutton "Quantity" [ref=e102]: "2" + - button "Increase quantity" [ref=e103]: + - img [ref=e104] + - button "Add to cart" [ref=e106]: + - generic [ref=e107]: Add to cart + - status [ref=e157]: + - img [ref=e158] + - text: Added to cart + - separator [ref=e108] + - paragraph [ref=e110]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - generic [ref=e111]: + - generic [ref=e112]: new + - generic [ref=e113]: popular + - contentinfo [ref=e114]: + - generic [ref=e115]: + - generic [ref=e116]: + - generic [ref=e117]: + - heading "Shop" [level=2] [ref=e118] + - list [ref=e119]: + - listitem [ref=e120]: + - link "All collections" [ref=e121] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e122]: + - link "Home" [ref=e123] [cursor=pointer]: + - /url: / + - listitem [ref=e124]: + - link "New Arrivals" [ref=e125] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e126]: + - link "T-Shirts" [ref=e127] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e128]: + - link "Pants & Jeans" [ref=e129] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e130]: + - link "Sale" [ref=e131] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e132]: + - heading "Information" [level=2] [ref=e133] + - list [ref=e134]: + - listitem [ref=e135]: + - link "About Us" [ref=e136] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e137]: + - link "FAQ" [ref=e138] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e139]: + - link "Shipping & Returns" [ref=e140] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e141]: + - link "Privacy Policy" [ref=e142] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e143]: + - link "Terms of Service" [ref=e144] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e145]: + - heading "Acme Fashion" [level=2] [ref=e146] + - paragraph [ref=e147]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e148]: + - paragraph [ref=e149]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e150]: + - listitem [ref=e151]: Visa + - listitem [ref=e152]: Mastercard + - listitem [ref=e153]: Amex + - listitem [ref=e154]: PayPal + - generic: + - dialog "Shopping cart": + - generic [ref=e161]: + - generic [ref=e162]: + - heading "Your Cart (2)" [level=2] [ref=e163] + - button "Close cart" [ref=e164]: + - img [ref=e165] + - list [ref=e167]: + - listitem [ref=e168]: + - generic [ref=e170]: + - paragraph [ref=e171]: Classic Cotton T-Shirt + - paragraph [ref=e172]: M / Navy + - generic [ref=e173]: + - generic [ref=e174]: + - button "Decrease quantity" [ref=e175]: + - img [ref=e176] + - generic [ref=e177]: "2" + - button "Increase quantity" [ref=e178]: + - img [ref=e179] + - generic [ref=e182]: 49.98 EUR + - button "Remove Classic Cotton T-Shirt from cart" [ref=e184]: + - img [ref=e185] + - generic [ref=e207]: + - generic [ref=e208]: WELCOME10 (-4.99 EUR) + - button "Remove" [ref=e209] + - generic [ref=e192]: + - generic [ref=e193]: + - generic [ref=e194]: + - term [ref=e195]: Subtotal + - definition [ref=e196]: + - generic [ref=e198]: 49.98 EUR + - generic [ref=e210]: + - term [ref=e211]: Discount (WELCOME10) + - definition [ref=e212]: "-4.99 EUR" + - generic [ref=e199]: + - term [ref=e200]: Estimated total + - definition [ref=e201]: + - generic [ref=e203]: 44.99 EUR + - paragraph [ref=e204]: Shipping and taxes calculated at checkout + - link "Checkout" [ref=e205] [cursor=pointer]: + - /url: http://shop.test/checkout + - button "Continue shopping" [ref=e206] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-34-53-095Z.yml b/.playwright-mcp/page-2026-06-10T08-34-53-095Z.yml new file mode 100644 index 00000000..27eef9d5 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-34-53-095Z.yml @@ -0,0 +1,140 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "Log in" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e34]: "2" + - generic [ref=e35]: 2 items in cart + - main [ref=e36]: + - generic [ref=e37]: + - heading "Checkout" [level=1] [ref=e38] + - generic [ref=e39]: + - generic [ref=e40]: + - region "1. Contact information" [ref=e41]: + - heading "1. Contact information" [level=2] [ref=e43] + - generic [ref=e44]: + - generic [ref=e45]: + - generic [ref=e46]: Email * + - textbox "Email" [ref=e47] + - paragraph [ref=e48]: + - text: Already have an account? + - link "Log in" [ref=e49] [cursor=pointer]: + - /url: http://shop.test/account/login + - button "Continue" [ref=e50] + - region "2. Shipping address" [ref=e51]: + - heading "2. Shipping address" [level=2] [ref=e53] + - region "3. Shipping method" [ref=e54]: + - heading "3. Shipping method" [level=2] [ref=e56] + - region "4. Payment" [ref=e57]: + - heading "4. Payment" [level=2] [ref=e59] + - generic [ref=e60]: + - complementary "Order summary" [ref=e61]: + - heading "Order Summary" [level=2] [ref=e62] + - list [ref=e63]: + - listitem [ref=e64]: + - generic [ref=e66]: "2" + - generic [ref=e67]: + - paragraph [ref=e68]: Classic Cotton T-Shirt + - paragraph [ref=e69]: M / Navy + - generic [ref=e71]: 49.98 EUR + - generic [ref=e72]: + - generic [ref=e73]: + - term [ref=e74]: Subtotal + - definition [ref=e75]: + - generic [ref=e77]: 49.98 EUR + - generic [ref=e78]: + - term [ref=e79]: Discount (WELCOME10) + - definition [ref=e80]: "-4.99 EUR" + - generic [ref=e81]: + - term [ref=e82]: Shipping + - definition [ref=e83]: Calculated at next step + - generic [ref=e84]: + - term [ref=e85]: Total + - definition [ref=e86]: + - generic [ref=e88]: 44.99 EUR + - generic [ref=e90]: + - generic [ref=e91]: WELCOME10 + - button "Remove" [ref=e92] + - contentinfo [ref=e93]: + - generic [ref=e94]: + - generic [ref=e95]: + - generic [ref=e96]: + - heading "Shop" [level=2] [ref=e97] + - list [ref=e98]: + - listitem [ref=e99]: + - link "All collections" [ref=e100] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e101]: + - link "Home" [ref=e102] [cursor=pointer]: + - /url: / + - listitem [ref=e103]: + - link "New Arrivals" [ref=e104] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e105]: + - link "T-Shirts" [ref=e106] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e107]: + - link "Pants & Jeans" [ref=e108] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e109]: + - link "Sale" [ref=e110] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e111]: + - heading "Information" [level=2] [ref=e112] + - list [ref=e113]: + - listitem [ref=e114]: + - link "About Us" [ref=e115] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e116]: + - link "FAQ" [ref=e117] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e118]: + - link "Shipping & Returns" [ref=e119] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e120]: + - link "Privacy Policy" [ref=e121] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e122]: + - link "Terms of Service" [ref=e123] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e124]: + - heading "Acme Fashion" [level=2] [ref=e125] + - paragraph [ref=e126]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e127]: + - paragraph [ref=e128]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e129]: + - listitem [ref=e130]: Visa + - listitem [ref=e131]: Mastercard + - listitem [ref=e132]: Amex + - listitem [ref=e133]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-35-08-257Z.yml b/.playwright-mcp/page-2026-06-10T08-35-08-257Z.yml new file mode 100644 index 00000000..f3da1b96 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-35-08-257Z.yml @@ -0,0 +1,174 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "Log in" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e34]: "2" + - generic [ref=e35]: 2 items in cart + - main [ref=e36]: + - generic [ref=e37]: + - heading "Checkout" [level=1] [ref=e38] + - generic [ref=e39]: + - generic [ref=e40]: + - region "1. Contact information" [ref=e41]: + - generic [ref=e42]: + - heading "1. Contact information" [level=2] [ref=e43] + - button "Edit" [ref=e134] + - paragraph [ref=e135]: erika.musterfrau@example.com + - region "2. Shipping address" [ref=e51]: + - heading "2. Shipping address" [level=2] [ref=e53] + - generic [ref=e136]: + - generic [ref=e137]: + - generic [ref=e138]: + - generic [ref=e139]: First name * + - textbox "First name" [ref=e140] + - generic [ref=e141]: + - generic [ref=e142]: Last name * + - textbox "Last name" [ref=e143] + - generic [ref=e144]: + - generic [ref=e145]: Address line 1 * + - textbox "Address line 1" [ref=e146] + - generic [ref=e147]: + - generic [ref=e148]: Address line 2 + - textbox "Address line 2" [ref=e149] + - generic [ref=e150]: + - generic [ref=e151]: City * + - textbox "City" [ref=e152] + - generic [ref=e153]: + - generic [ref=e154]: State / Province + - textbox "State / Province" [ref=e155] + - generic [ref=e156]: + - generic [ref=e157]: Postal code * + - textbox "Postal code" [ref=e158] + - generic [ref=e159]: + - generic [ref=e160]: Country * + - combobox "Country" [ref=e161]: + - option "Select a country" [selected] + - option "Germany" + - option "Austria" + - option "Belgium" + - option "France" + - option "Italy" + - option "Netherlands" + - option "Spain" + - option "United Kingdom" + - option "United States" + - generic [ref=e162]: + - generic [ref=e163]: Phone + - textbox "Phone" [ref=e164] + - button "Continue" [ref=e165] + - region "3. Shipping method" [ref=e54]: + - heading "3. Shipping method" [level=2] [ref=e56] + - region "4. Payment" [ref=e57]: + - heading "4. Payment" [level=2] [ref=e59] + - generic [ref=e60]: + - complementary "Order summary" [ref=e61]: + - heading "Order Summary" [level=2] [ref=e62] + - list [ref=e63]: + - listitem [ref=e64]: + - generic [ref=e66]: "2" + - generic [ref=e67]: + - paragraph [ref=e68]: Classic Cotton T-Shirt + - paragraph [ref=e69]: M / Navy + - generic [ref=e71]: 49.98 EUR + - generic [ref=e72]: + - generic [ref=e73]: + - term [ref=e74]: Subtotal + - definition [ref=e75]: + - generic [ref=e77]: 49.98 EUR + - generic [ref=e78]: + - term [ref=e79]: Discount (WELCOME10) + - definition [ref=e80]: "-4.99 EUR" + - generic [ref=e81]: + - term [ref=e82]: Shipping + - definition [ref=e83]: Calculated at next step + - generic [ref=e84]: + - term [ref=e85]: Total + - definition [ref=e86]: + - generic [ref=e88]: 44.99 EUR + - generic [ref=e90]: + - generic [ref=e91]: WELCOME10 + - button "Remove" [ref=e92] + - contentinfo [ref=e93]: + - generic [ref=e94]: + - generic [ref=e95]: + - generic [ref=e96]: + - heading "Shop" [level=2] [ref=e97] + - list [ref=e98]: + - listitem [ref=e99]: + - link "All collections" [ref=e100] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e101]: + - link "Home" [ref=e102] [cursor=pointer]: + - /url: / + - listitem [ref=e103]: + - link "New Arrivals" [ref=e104] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e105]: + - link "T-Shirts" [ref=e106] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e107]: + - link "Pants & Jeans" [ref=e108] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e109]: + - link "Sale" [ref=e110] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e111]: + - heading "Information" [level=2] [ref=e112] + - list [ref=e113]: + - listitem [ref=e114]: + - link "About Us" [ref=e115] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e116]: + - link "FAQ" [ref=e117] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e118]: + - link "Shipping & Returns" [ref=e119] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e120]: + - link "Privacy Policy" [ref=e121] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e122]: + - link "Terms of Service" [ref=e123] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e124]: + - heading "Acme Fashion" [level=2] [ref=e125] + - paragraph [ref=e126]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e127]: + - paragraph [ref=e128]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e129]: + - listitem [ref=e130]: Visa + - listitem [ref=e131]: Mastercard + - listitem [ref=e132]: Amex + - listitem [ref=e133]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-35-25-425Z.yml b/.playwright-mcp/page-2026-06-10T08-35-25-425Z.yml new file mode 100644 index 00000000..02a4e117 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-35-25-425Z.yml @@ -0,0 +1,156 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "Log in" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e34]: "2" + - generic [ref=e35]: 2 items in cart + - main [ref=e36]: + - generic [ref=e37]: + - heading "Checkout" [level=1] [ref=e38] + - generic [ref=e39]: + - generic [ref=e40]: + - region "1. Contact information" [ref=e41]: + - generic [ref=e42]: + - heading "1. Contact information" [level=2] [ref=e43] + - button "Edit" [ref=e134] + - paragraph [ref=e135]: erika.musterfrau@example.com + - region "2. Shipping address" [ref=e51]: + - generic [ref=e52]: + - heading "2. Shipping address" [level=2] [ref=e53] + - button "Edit" [ref=e166] + - paragraph [ref=e167]: Erika Musterfrau, Musterstrasse 12, 10115 Berlin, DE + - region "3. Shipping method" [ref=e54]: + - heading "3. Shipping method" [level=2] [ref=e56] + - generic [ref=e168]: + - group "Shipping method" [ref=e169]: + - generic [ref=e170]: Shipping method + - generic [ref=e171]: + - generic [ref=e172] [cursor=pointer]: + - generic [ref=e173]: + - radio "Standard Shipping 4.99 EUR" [ref=e174] + - generic [ref=e175]: Standard Shipping + - generic [ref=e177]: 4.99 EUR + - generic [ref=e178] [cursor=pointer]: + - generic [ref=e179]: + - radio "Express Shipping 9.99 EUR" [ref=e180] + - generic [ref=e181]: Express Shipping + - generic [ref=e183]: 9.99 EUR + - button "Continue" [disabled] [ref=e184] + - region "4. Payment" [ref=e57]: + - heading "4. Payment" [level=2] [ref=e59] + - generic [ref=e60]: + - complementary "Order summary" [ref=e61]: + - heading "Order Summary" [level=2] [ref=e62] + - list [ref=e63]: + - listitem [ref=e64]: + - generic [ref=e66]: "2" + - generic [ref=e67]: + - paragraph [ref=e68]: Classic Cotton T-Shirt + - paragraph [ref=e69]: M / Navy + - generic [ref=e71]: 49.98 EUR + - generic [ref=e72]: + - generic [ref=e73]: + - term [ref=e74]: Subtotal + - definition [ref=e75]: + - generic [ref=e77]: 49.98 EUR + - generic [ref=e78]: + - term [ref=e79]: Discount (WELCOME10) + - definition [ref=e80]: "-4.99 EUR" + - generic [ref=e81]: + - term [ref=e82]: Shipping + - definition [ref=e83]: Calculated at next step + - generic [ref=e185]: + - term [ref=e186]: Tax + - definition [ref=e187]: + - generic [ref=e189]: 7.19 EUR + - generic [ref=e84]: + - term [ref=e85]: Total + - definition [ref=e86]: + - generic [ref=e88]: 44.99 EUR + - generic [ref=e90]: + - generic [ref=e91]: WELCOME10 + - button "Remove" [ref=e92] + - contentinfo [ref=e93]: + - generic [ref=e94]: + - generic [ref=e95]: + - generic [ref=e96]: + - heading "Shop" [level=2] [ref=e97] + - list [ref=e98]: + - listitem [ref=e99]: + - link "All collections" [ref=e100] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e101]: + - link "Home" [ref=e102] [cursor=pointer]: + - /url: / + - listitem [ref=e103]: + - link "New Arrivals" [ref=e104] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e105]: + - link "T-Shirts" [ref=e106] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e107]: + - link "Pants & Jeans" [ref=e108] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e109]: + - link "Sale" [ref=e110] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e111]: + - heading "Information" [level=2] [ref=e112] + - list [ref=e113]: + - listitem [ref=e114]: + - link "About Us" [ref=e115] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e116]: + - link "FAQ" [ref=e117] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e118]: + - link "Shipping & Returns" [ref=e119] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e120]: + - link "Privacy Policy" [ref=e121] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e122]: + - link "Terms of Service" [ref=e123] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e124]: + - heading "Acme Fashion" [level=2] [ref=e125] + - paragraph [ref=e126]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e127]: + - paragraph [ref=e128]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e129]: + - listitem [ref=e130]: Visa + - listitem [ref=e131]: Mastercard + - listitem [ref=e132]: Amex + - listitem [ref=e133]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-35-46-396Z.yml b/.playwright-mcp/page-2026-06-10T08-35-46-396Z.yml new file mode 100644 index 00000000..66236fb5 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-35-46-396Z.yml @@ -0,0 +1,156 @@ +- generic [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "Log in" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e34]: "2" + - generic [ref=e35]: 2 items in cart + - main [ref=e36]: + - generic [ref=e37]: + - heading "Checkout" [level=1] [ref=e38] + - generic [ref=e39]: + - generic [ref=e40]: + - region "1. Contact information" [ref=e41]: + - generic [ref=e42]: + - heading "1. Contact information" [level=2] [ref=e43] + - button "Edit" [ref=e134] + - paragraph [ref=e135]: erika.musterfrau@example.com + - region "2. Shipping address" [ref=e51]: + - generic [ref=e52]: + - heading "2. Shipping address" [level=2] [ref=e53] + - button "Edit" [ref=e166] + - paragraph [ref=e167]: Erika Musterfrau, Musterstrasse 12, 10115 Berlin, DE + - region "3. Shipping method" [ref=e54]: + - heading "3. Shipping method" [level=2] [ref=e56] + - generic [ref=e168]: + - group "Shipping method" [ref=e169]: + - generic [ref=e170]: Shipping method + - generic [ref=e171]: + - generic [ref=e172] [cursor=pointer]: + - generic [ref=e173]: + - radio "Standard Shipping 4.99 EUR" [checked] [active] [ref=e174] + - generic [ref=e175]: Standard Shipping + - generic [ref=e177]: 4.99 EUR + - generic [ref=e178] [cursor=pointer]: + - generic [ref=e179]: + - radio "Express Shipping 9.99 EUR" [ref=e180] + - generic [ref=e181]: Express Shipping + - generic [ref=e183]: 9.99 EUR + - button "Continue" [ref=e184] + - region "4. Payment" [ref=e57]: + - heading "4. Payment" [level=2] [ref=e59] + - generic [ref=e60]: + - complementary "Order summary" [ref=e61]: + - heading "Order Summary" [level=2] [ref=e62] + - list [ref=e63]: + - listitem [ref=e64]: + - generic [ref=e66]: "2" + - generic [ref=e67]: + - paragraph [ref=e68]: Classic Cotton T-Shirt + - paragraph [ref=e69]: M / Navy + - generic [ref=e71]: 49.98 EUR + - generic [ref=e72]: + - generic [ref=e73]: + - term [ref=e74]: Subtotal + - definition [ref=e75]: + - generic [ref=e77]: 49.98 EUR + - generic [ref=e78]: + - term [ref=e79]: Discount (WELCOME10) + - definition [ref=e80]: "-4.99 EUR" + - generic [ref=e81]: + - term [ref=e82]: Shipping + - definition [ref=e83]: Calculated at next step + - generic [ref=e185]: + - term [ref=e186]: Tax + - definition [ref=e187]: + - generic [ref=e189]: 7.19 EUR + - generic [ref=e84]: + - term [ref=e85]: Total + - definition [ref=e86]: + - generic [ref=e88]: 44.99 EUR + - generic [ref=e90]: + - generic [ref=e91]: WELCOME10 + - button "Remove" [ref=e92] + - contentinfo [ref=e93]: + - generic [ref=e94]: + - generic [ref=e95]: + - generic [ref=e96]: + - heading "Shop" [level=2] [ref=e97] + - list [ref=e98]: + - listitem [ref=e99]: + - link "All collections" [ref=e100] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e101]: + - link "Home" [ref=e102] [cursor=pointer]: + - /url: / + - listitem [ref=e103]: + - link "New Arrivals" [ref=e104] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e105]: + - link "T-Shirts" [ref=e106] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e107]: + - link "Pants & Jeans" [ref=e108] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e109]: + - link "Sale" [ref=e110] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e111]: + - heading "Information" [level=2] [ref=e112] + - list [ref=e113]: + - listitem [ref=e114]: + - link "About Us" [ref=e115] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e116]: + - link "FAQ" [ref=e117] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e118]: + - link "Shipping & Returns" [ref=e119] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e120]: + - link "Privacy Policy" [ref=e121] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e122]: + - link "Terms of Service" [ref=e123] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e124]: + - heading "Acme Fashion" [level=2] [ref=e125] + - paragraph [ref=e126]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e127]: + - paragraph [ref=e128]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e129]: + - listitem [ref=e130]: Visa + - listitem [ref=e131]: Mastercard + - listitem [ref=e132]: Amex + - listitem [ref=e133]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-35-51-681Z.yml b/.playwright-mcp/page-2026-06-10T08-35-51-681Z.yml new file mode 100644 index 00000000..66236fb5 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-35-51-681Z.yml @@ -0,0 +1,156 @@ +- generic [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "Log in" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e34]: "2" + - generic [ref=e35]: 2 items in cart + - main [ref=e36]: + - generic [ref=e37]: + - heading "Checkout" [level=1] [ref=e38] + - generic [ref=e39]: + - generic [ref=e40]: + - region "1. Contact information" [ref=e41]: + - generic [ref=e42]: + - heading "1. Contact information" [level=2] [ref=e43] + - button "Edit" [ref=e134] + - paragraph [ref=e135]: erika.musterfrau@example.com + - region "2. Shipping address" [ref=e51]: + - generic [ref=e52]: + - heading "2. Shipping address" [level=2] [ref=e53] + - button "Edit" [ref=e166] + - paragraph [ref=e167]: Erika Musterfrau, Musterstrasse 12, 10115 Berlin, DE + - region "3. Shipping method" [ref=e54]: + - heading "3. Shipping method" [level=2] [ref=e56] + - generic [ref=e168]: + - group "Shipping method" [ref=e169]: + - generic [ref=e170]: Shipping method + - generic [ref=e171]: + - generic [ref=e172] [cursor=pointer]: + - generic [ref=e173]: + - radio "Standard Shipping 4.99 EUR" [checked] [active] [ref=e174] + - generic [ref=e175]: Standard Shipping + - generic [ref=e177]: 4.99 EUR + - generic [ref=e178] [cursor=pointer]: + - generic [ref=e179]: + - radio "Express Shipping 9.99 EUR" [ref=e180] + - generic [ref=e181]: Express Shipping + - generic [ref=e183]: 9.99 EUR + - button "Continue" [ref=e184] + - region "4. Payment" [ref=e57]: + - heading "4. Payment" [level=2] [ref=e59] + - generic [ref=e60]: + - complementary "Order summary" [ref=e61]: + - heading "Order Summary" [level=2] [ref=e62] + - list [ref=e63]: + - listitem [ref=e64]: + - generic [ref=e66]: "2" + - generic [ref=e67]: + - paragraph [ref=e68]: Classic Cotton T-Shirt + - paragraph [ref=e69]: M / Navy + - generic [ref=e71]: 49.98 EUR + - generic [ref=e72]: + - generic [ref=e73]: + - term [ref=e74]: Subtotal + - definition [ref=e75]: + - generic [ref=e77]: 49.98 EUR + - generic [ref=e78]: + - term [ref=e79]: Discount (WELCOME10) + - definition [ref=e80]: "-4.99 EUR" + - generic [ref=e81]: + - term [ref=e82]: Shipping + - definition [ref=e83]: Calculated at next step + - generic [ref=e185]: + - term [ref=e186]: Tax + - definition [ref=e187]: + - generic [ref=e189]: 7.19 EUR + - generic [ref=e84]: + - term [ref=e85]: Total + - definition [ref=e86]: + - generic [ref=e88]: 44.99 EUR + - generic [ref=e90]: + - generic [ref=e91]: WELCOME10 + - button "Remove" [ref=e92] + - contentinfo [ref=e93]: + - generic [ref=e94]: + - generic [ref=e95]: + - generic [ref=e96]: + - heading "Shop" [level=2] [ref=e97] + - list [ref=e98]: + - listitem [ref=e99]: + - link "All collections" [ref=e100] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e101]: + - link "Home" [ref=e102] [cursor=pointer]: + - /url: / + - listitem [ref=e103]: + - link "New Arrivals" [ref=e104] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e105]: + - link "T-Shirts" [ref=e106] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e107]: + - link "Pants & Jeans" [ref=e108] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e109]: + - link "Sale" [ref=e110] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e111]: + - heading "Information" [level=2] [ref=e112] + - list [ref=e113]: + - listitem [ref=e114]: + - link "About Us" [ref=e115] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e116]: + - link "FAQ" [ref=e117] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e118]: + - link "Shipping & Returns" [ref=e119] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e120]: + - link "Privacy Policy" [ref=e121] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e122]: + - link "Terms of Service" [ref=e123] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e124]: + - heading "Acme Fashion" [level=2] [ref=e125] + - paragraph [ref=e126]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e127]: + - paragraph [ref=e128]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e129]: + - listitem [ref=e130]: Visa + - listitem [ref=e131]: Mastercard + - listitem [ref=e132]: Amex + - listitem [ref=e133]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-35-53-988Z.yml b/.playwright-mcp/page-2026-06-10T08-35-53-988Z.yml new file mode 100644 index 00000000..ce924038 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-35-53-988Z.yml @@ -0,0 +1,177 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "Log in" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e34]: "2" + - generic [ref=e35]: 2 items in cart + - main [ref=e36]: + - generic [ref=e37]: + - heading "Checkout" [level=1] [ref=e38] + - generic [ref=e39]: + - generic [ref=e40]: + - region "1. Contact information" [ref=e41]: + - generic [ref=e42]: + - heading "1. Contact information" [level=2] [ref=e43] + - button "Edit" [ref=e134] + - paragraph [ref=e135]: erika.musterfrau@example.com + - region "2. Shipping address" [ref=e51]: + - generic [ref=e52]: + - heading "2. Shipping address" [level=2] [ref=e53] + - button "Edit" [ref=e166] + - paragraph [ref=e167]: Erika Musterfrau, Musterstrasse 12, 10115 Berlin, DE + - region "3. Shipping method" [ref=e54]: + - generic [ref=e55]: + - heading "3. Shipping method" [level=2] [ref=e56] + - button "Edit" [ref=e190] + - paragraph [ref=e191]: Standard Shipping + - region "4. Payment" [ref=e57]: + - heading "4. Payment" [level=2] [ref=e59] + - generic [ref=e192]: + - group "Select a payment method" [ref=e193]: + - generic [ref=e194]: Select a payment method + - generic [ref=e195]: + - generic [ref=e196] [cursor=pointer]: + - radio "Credit Card" [checked] [ref=e197] + - generic [ref=e198]: Credit Card + - generic [ref=e199] [cursor=pointer]: + - radio "PayPal" [ref=e200] + - generic [ref=e201]: PayPal + - generic [ref=e202] [cursor=pointer]: + - radio "Bank Transfer" [ref=e203] + - generic [ref=e204]: Bank Transfer + - generic [ref=e205]: + - generic [ref=e206]: + - generic [ref=e207]: Card number * + - textbox "Card number" [ref=e208]: + - /placeholder: 4242 4242 4242 4242 + - generic [ref=e209]: + - generic [ref=e210]: Cardholder name * + - textbox "Cardholder name" [ref=e211] + - generic [ref=e212]: + - generic [ref=e213]: + - generic [ref=e214]: Expiry * + - textbox "Expiry" [ref=e215]: + - /placeholder: MM/YY + - generic [ref=e216]: + - generic [ref=e217]: CVC * + - textbox "CVC" [ref=e218]: + - /placeholder: "123" + - button "Pay now - 49.98 EUR" [ref=e219]: + - generic [ref=e220]: Pay now - 49.98 EUR + - generic [ref=e60]: + - complementary "Order summary" [ref=e61]: + - heading "Order Summary" [level=2] [ref=e62] + - list [ref=e63]: + - listitem [ref=e64]: + - generic [ref=e66]: "2" + - generic [ref=e67]: + - paragraph [ref=e68]: Classic Cotton T-Shirt + - paragraph [ref=e69]: M / Navy + - generic [ref=e71]: 49.98 EUR + - generic [ref=e72]: + - generic [ref=e73]: + - term [ref=e74]: Subtotal + - definition [ref=e75]: + - generic [ref=e77]: 49.98 EUR + - generic [ref=e78]: + - term [ref=e79]: Discount (WELCOME10) + - definition [ref=e80]: "-4.99 EUR" + - generic [ref=e81]: + - term [ref=e82]: Shipping + - definition [ref=e83]: + - generic [ref=e222]: 4.99 EUR + - generic [ref=e185]: + - term [ref=e186]: Tax + - definition [ref=e187]: + - generic [ref=e189]: 7.98 EUR + - generic [ref=e84]: + - term [ref=e85]: Total + - definition [ref=e86]: + - generic [ref=e88]: 49.98 EUR + - generic [ref=e90]: + - generic [ref=e91]: WELCOME10 + - button "Remove" [ref=e92] + - contentinfo [ref=e93]: + - generic [ref=e94]: + - generic [ref=e95]: + - generic [ref=e96]: + - heading "Shop" [level=2] [ref=e97] + - list [ref=e98]: + - listitem [ref=e99]: + - link "All collections" [ref=e100] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e101]: + - link "Home" [ref=e102] [cursor=pointer]: + - /url: / + - listitem [ref=e103]: + - link "New Arrivals" [ref=e104] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e105]: + - link "T-Shirts" [ref=e106] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e107]: + - link "Pants & Jeans" [ref=e108] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e109]: + - link "Sale" [ref=e110] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e111]: + - heading "Information" [level=2] [ref=e112] + - list [ref=e113]: + - listitem [ref=e114]: + - link "About Us" [ref=e115] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e116]: + - link "FAQ" [ref=e117] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e118]: + - link "Shipping & Returns" [ref=e119] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e120]: + - link "Privacy Policy" [ref=e121] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e122]: + - link "Terms of Service" [ref=e123] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e124]: + - heading "Acme Fashion" [level=2] [ref=e125] + - paragraph [ref=e126]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e127]: + - paragraph [ref=e128]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e129]: + - listitem [ref=e130]: Visa + - listitem [ref=e131]: Mastercard + - listitem [ref=e132]: Amex + - listitem [ref=e133]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-36-19-922Z.yml b/.playwright-mcp/page-2026-06-10T08-36-19-922Z.yml new file mode 100644 index 00000000..eaab638e --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-36-19-922Z.yml @@ -0,0 +1,488 @@ +- generic [active] [ref=e1]: + - dialog [ref=e223]: + - iframe [ref=e224]: + - generic [ref=f4e2]: + - generic [ref=f4e4]: + - generic [ref=f4e5]: + - img [ref=f4e7] + - generic [ref=f4e10]: Internal Server Error + - button "Copy as Markdown" [ref=f4e11] [cursor=pointer]: + - img [ref=f4e12] + - generic [ref=f4e15]: Copy as Markdown + - generic [ref=f4e18]: + - generic [ref=f4e19]: + - heading "RuntimeException" [level=1] [ref=f4e20] + - generic [ref=f4e22]: app/Jobs/DeliverWebhook.php:121 + - paragraph [ref=f4e23]: Webhook delivery 5 to https://loyalty-rewards.example.test/webhooks/orders failed with status connection error. + - generic [ref=f4e24]: + - generic [ref=f4e25]: + - generic [ref=f4e26]: + - generic [ref=f4e27]: LARAVEL + - generic [ref=f4e28]: 12.51.0 + - generic [ref=f4e29]: + - generic [ref=f4e30]: PHP + - generic [ref=f4e31]: 8.4.17 + - generic [ref=f4e32]: + - img [ref=f4e33] + - text: UNHANDLED + - generic [ref=f4e36]: CODE 0 + - generic [ref=f4e38]: + - generic [ref=f4e39]: + - img [ref=f4e40] + - text: "500" + - generic [ref=f4e43]: + - img [ref=f4e44] + - text: POST + - generic [ref=f4e47]: http://shop.test/livewire-6701cc17/update + - button [ref=f4e48] [cursor=pointer]: + - img [ref=f4e49] + - generic [ref=f4e53]: + - generic [ref=f4e54]: + - generic [ref=f4e55]: + - img [ref=f4e57] + - heading "Exception trace" [level=3] [ref=f4e60] + - generic [ref=f4e61]: + - generic [ref=f4e62]: + - generic [ref=f4e63] [cursor=pointer]: + - generic [ref=f4e66]: + - code [ref=f4e70]: + - generic [ref=f4e71]: App\Jobs\DeliverWebhook->handle(object(App\Services\WebhookService)) + - generic [ref=f4e73]: app/Jobs/DeliverWebhook.php:121 + - button [ref=f4e75]: + - img [ref=f4e76] + - code [ref=f4e84]: + - generic [ref=f4e85]: 116 'response_code' => $responseCode, + - generic [ref=f4e86]: 117 ]); + - generic [ref=f4e87]: "118 }" + - generic [ref=f4e88]: "119" + - generic [ref=f4e89]: "120 if (! $exhausted) {" + - generic [ref=f4e90]: 121 throw new RuntimeException(sprintf( + - generic [ref=f4e91]: 122 'Webhook delivery %d to %s failed with status %s.', + - generic [ref=f4e92]: 123 $delivery->getKey(), + - generic [ref=f4e93]: 124 $subscription->target_url, + - generic [ref=f4e94]: 125 $responseCode ?? 'connection error', + - generic [ref=f4e95]: 126 )); + - generic [ref=f4e96]: "127 }" + - generic [ref=f4e97]: "128 }" + - generic [ref=f4e98]: "129" + - generic [ref=f4e99]: 130 /** + - generic [ref=f4e100]: 131 * POST the signed payload and normalize the outcome to a response code + - generic [ref=f4e101]: 132 * (null on connection failure) and a truncated body snippet. + - generic [ref=f4e102]: "133" + - generic [ref=f4e104] [cursor=pointer]: + - img [ref=f4e105] + - generic [ref=f4e109]: 22 vendor frames + - button [ref=f4e110]: + - img [ref=f4e111] + - generic [ref=f4e116] [cursor=pointer]: + - generic [ref=f4e119]: + - code [ref=f4e123]: + - generic [ref=f4e124]: App\Services\WebhookService->dispatch(object(App\Models\Store), string, array) + - generic [ref=f4e126]: app/Services/WebhookService.php:81 + - button [ref=f4e128]: + - img [ref=f4e129] + - generic [ref=f4e134] [cursor=pointer]: + - generic [ref=f4e137]: + - code [ref=f4e141]: + - generic [ref=f4e142]: App\Listeners\DispatchWebhooks->dispatchOrderEvent(string, object(App\Models\Order)) + - generic [ref=f4e144]: app/Listeners/DispatchWebhooks.php:54 + - button [ref=f4e146]: + - img [ref=f4e147] + - generic [ref=f4e152] [cursor=pointer]: + - generic [ref=f4e155]: + - code [ref=f4e159]: + - generic [ref=f4e160]: App\Listeners\DispatchWebhooks->handle(object(App\Events\OrderCreated)) + - generic [ref=f4e162]: app/Listeners/DispatchWebhooks.php:28 + - button [ref=f4e164]: + - img [ref=f4e165] + - generic [ref=f4e170] [cursor=pointer]: + - img [ref=f4e171] + - generic [ref=f4e175]: 4 vendor frames + - button [ref=f4e176]: + - img [ref=f4e177] + - generic [ref=f4e182] [cursor=pointer]: + - generic [ref=f4e185]: + - code [ref=f4e189]: + - generic [ref=f4e190]: "App\\Services\\OrderService->{closure:App\\Services\\OrderService::createFromCheckout():48}(object(Illuminate\\Database\\SQLiteConnection))" + - generic [ref=f4e192]: app/Services/OrderService.php:104 + - button [ref=f4e194]: + - img [ref=f4e195] + - generic [ref=f4e200] [cursor=pointer]: + - img [ref=f4e201] + - generic [ref=f4e205]: 3 vendor frames + - button [ref=f4e206]: + - img [ref=f4e207] + - generic [ref=f4e212] [cursor=pointer]: + - generic [ref=f4e215]: + - code [ref=f4e219]: + - generic [ref=f4e220]: App\Services\OrderService->createFromCheckout(object(App\Models\Checkout), object(App\ValueObjects\PaymentResult)) + - generic [ref=f4e222]: app/Services/OrderService.php:48 + - button [ref=f4e224]: + - img [ref=f4e225] + - generic [ref=f4e230] [cursor=pointer]: + - generic [ref=f4e233]: + - code [ref=f4e237]: + - generic [ref=f4e238]: "App\\Services\\CheckoutService->{closure:App\\Services\\CheckoutService::completeCheckout():253}(object(Illuminate\\Database\\SQLiteConnection))" + - generic [ref=f4e240]: app/Services/CheckoutService.php:254 + - button [ref=f4e242]: + - img [ref=f4e243] + - generic [ref=f4e248] [cursor=pointer]: + - img [ref=f4e249] + - generic [ref=f4e253]: 3 vendor frames + - button [ref=f4e254]: + - img [ref=f4e255] + - generic [ref=f4e260] [cursor=pointer]: + - generic [ref=f4e263]: + - code [ref=f4e267]: + - generic [ref=f4e268]: App\Services\CheckoutService->completeCheckout(object(App\Models\Checkout), array) + - generic [ref=f4e270]: app/Services/CheckoutService.php:253 + - button [ref=f4e272]: + - img [ref=f4e273] + - generic [ref=f4e278] [cursor=pointer]: + - generic [ref=f4e281]: + - code [ref=f4e285]: + - generic [ref=f4e286]: App\Livewire\Storefront\Checkout\Show->payNow(object(App\Services\CheckoutService)) + - generic [ref=f4e288]: app/Livewire/Storefront/Checkout/Show.php:261 + - button [ref=f4e290]: + - img [ref=f4e291] + - generic [ref=f4e296] [cursor=pointer]: + - img [ref=f4e297] + - generic [ref=f4e301]: 58 vendor frames + - button [ref=f4e302]: + - img [ref=f4e303] + - generic [ref=f4e308] [cursor=pointer]: + - generic [ref=f4e311]: + - code [ref=f4e315]: + - generic [ref=f4e316]: public/index.php + - generic [ref=f4e318]: public/index.php:20 + - button [ref=f4e320]: + - img [ref=f4e321] + - generic [ref=f4e326] [cursor=pointer]: + - img [ref=f4e327] + - generic [ref=f4e331]: 1 vendor frame + - button [ref=f4e332]: + - img [ref=f4e333] + - generic [ref=f4e337]: + - generic [ref=f4e338]: + - generic [ref=f4e339]: + - img [ref=f4e341] + - heading "Queries" [level=3] [ref=f4e343] + - generic [ref=f4e345]: 1-7 of 7 + - generic [ref=f4e346]: + - generic [ref=f4e347]: + - generic [ref=f4e348]: + - generic [ref=f4e349]: + - img [ref=f4e350] + - generic [ref=f4e352]: sqlite + - code [ref=f4e356]: + - generic [ref=f4e357]: select * from "webhook_deliveries" where "webhook_deliveries"."id" = 5 limit 1 + - generic [ref=f4e358]: 0.05ms + - generic [ref=f4e359]: + - generic [ref=f4e360]: + - generic [ref=f4e361]: + - img [ref=f4e362] + - generic [ref=f4e364]: sqlite + - code [ref=f4e368]: + - generic [ref=f4e369]: select * from "webhook_deliveries" where "id" = 5 limit 1 + - generic [ref=f4e370]: 0.02ms + - generic [ref=f4e371]: + - generic [ref=f4e372]: + - generic [ref=f4e373]: + - img [ref=f4e374] + - generic [ref=f4e376]: sqlite + - code [ref=f4e380]: + - generic [ref=f4e381]: select * from "webhook_subscriptions" where "webhook_subscriptions"."id" = 1 limit 1 + - generic [ref=f4e382]: 0.04ms + - generic [ref=f4e383]: + - generic [ref=f4e384]: + - generic [ref=f4e385]: + - img [ref=f4e386] + - generic [ref=f4e388]: sqlite + - code [ref=f4e392]: + - generic [ref=f4e393]: "update \"webhook_deliveries\" set \"attempt_count\" = 1, \"last_attempt_at\" = '2026-06-10 08:36:17', \"response_body_snippet\" = 'cURL error 7: Failed to connect to loyalty-rewards.example.test port 443 after 4 ms: Could not connect to server (see https://curl.haxx.se/libcurl/c/libcurl-errors.html) for https://loyalty-rewards.example.test/webhooks/orders' where \"id\" = 5" + - generic [ref=f4e394]: 0.05ms + - generic [ref=f4e395]: + - generic [ref=f4e396]: + - generic [ref=f4e397]: + - img [ref=f4e398] + - generic [ref=f4e400]: sqlite + - code [ref=f4e404]: + - generic [ref=f4e405]: update "webhook_subscriptions" set "consecutive_failures" = 1 where "id" = 1 + - generic [ref=f4e406]: 0.05ms + - generic [ref=f4e407]: + - generic [ref=f4e408]: + - generic [ref=f4e409]: + - img [ref=f4e410] + - generic [ref=f4e412]: sqlite + - code [ref=f4e416]: + - generic [ref=f4e417]: select * from "webhook_deliveries" where "webhook_deliveries"."id" = 5 limit 1 + - generic [ref=f4e418]: 0.02ms + - generic [ref=f4e419]: + - generic [ref=f4e420]: + - generic [ref=f4e421]: + - img [ref=f4e422] + - generic [ref=f4e424]: sqlite + - code [ref=f4e428]: + - generic [ref=f4e429]: select * from "users" where "id" = 1 limit 1 + - generic [ref=f4e430]: 0.05ms + - generic [ref=f4e432]: + - generic [ref=f4e433]: + - heading "Headers" [level=2] [ref=f4e434] + - generic [ref=f4e435]: + - generic [ref=f4e436]: + - generic [ref=f4e437]: cookie + - generic [ref=f4e439]: XSRF-TOKEN=eyJpdiI6IlZpdWNGWVd1ODFWRDB2VXQ0VTMwTUE9PSIsInZhbHVlIjoiNjJicDRyWThoY0ZhZkFIRG8yb3BpSmx6YWxzL1BJMThNSWpSTEQ0ODhwUmQ2Ym5TVkdkR2swMUJ1UmNKWTBYN0xYVnJRdnJuRGFScTZMTXlxRHVjcmpabUw2bTAvUmJqVElZTkpkWGVsTU9tbkkyQ1BYdDJNMmZLdDZ2am9lZXciLCJtYWMiOiIwMWI1NzcxZDdlMDExMmFmNDhmNDU5M2U2MjIwZTkxNTg1ODkwYWI1ODYyNjUzMmYwZDIxOTM5MGZiY2JhYmNkIiwidGFnIjoiIn0%3D; shop_session=eyJpdiI6Ikl5ZlJ0ck1kc0JUU29wcEhuNmc4aGc9PSIsInZhbHVlIjoidG1LOVRoZ0QzTm5VYWxCV0ZENmRmNWtRWkxUdStvbTZTYkZkNEwxeFpRaU5aNVI5d09tTHkyN1hSM1JvYnJaenlIV1VVV3VWU3A0MmhOSVl2cXk3dU1mQTVPL2ZZZnVzdU1yMzNsZk9SNkI0T0lMRW5WZWFEQ1RSOThkcGpLajAiLCJtYWMiOiIwMjUwMDg2Njg3MTZhN2YzMDkxZTI0ZTJjMDdlZTU4OGFjZWY4NzM0YmFlYWEzOTA4ZmQ0ODkzNGFhYzMxMDA3IiwidGFnIjoiIn0%3D + - generic [ref=f4e440]: + - generic [ref=f4e441]: accept-language + - generic [ref=f4e443]: en-GB,en-US;q=0.9,en;q=0.8 + - generic [ref=f4e444]: + - generic [ref=f4e445]: accept-encoding + - generic [ref=f4e447]: gzip, deflate + - generic [ref=f4e448]: + - generic [ref=f4e449]: referer + - generic [ref=f4e451]: http://shop.test/checkout + - generic [ref=f4e452]: + - generic [ref=f4e453]: origin + - generic [ref=f4e455]: http://shop.test + - generic [ref=f4e456]: + - generic [ref=f4e457]: accept + - generic [ref=f4e459]: "*/*" + - generic [ref=f4e460]: + - generic [ref=f4e461]: x-livewire + - generic [ref=f4e463]: "1" + - generic [ref=f4e464]: + - generic [ref=f4e465]: content-type + - generic [ref=f4e467]: application/json + - generic [ref=f4e468]: + - generic [ref=f4e469]: user-agent + - generic [ref=f4e471]: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 + - generic [ref=f4e472]: + - generic [ref=f4e473]: content-length + - generic [ref=f4e475]: "1157" + - generic [ref=f4e476]: + - generic [ref=f4e477]: connection + - generic [ref=f4e479]: keep-alive + - generic [ref=f4e480]: + - generic [ref=f4e481]: host + - generic [ref=f4e483]: shop.test + - generic [ref=f4e484]: + - heading "Body" [level=2] [ref=f4e485] + - code [ref=f4e490]: + - generic [ref=f4e491]: "{" + - generic [ref=f4e492]: "\"_token\": \"o3vYJFxePP6moAD4FpsNFN09adJ61xsr4WN4oEVC\"," + - generic [ref=f4e493]: "\"components\": [" + - generic [ref=f4e494]: "{" + - generic [ref=f4e495]: "\"snapshot\": \"{\"data\":{\"checkoutId\":1,\"step\":4,\"email\":\"erika.musterfrau@example.com\",\"shipping\":[{\"first_name\":\"Erika\",\"last_name\":\"Musterfrau\",\"address1\":\"Musterstrasse 12\",\"address2\":\"\",\"city\":\"Berlin\",\"province\":\"\",\"postal_code\":\"10115\",\"country_code\":\"DE\",\"phone\":\"\"},{\"s\":\"arr\"}],\"savedAddressId\":\"\",\"selectedRateId\":1,\"paymentMethod\":\"credit_card\",\"cardNumber\":\"\",\"cardName\":\"\",\"cardExpiry\":\"\",\"cardCvc\":\"\",\"shippingError\":null,\"paymentError\":null,\"discountCode\":\"\",\"discountError\":null,\"cartError\":null},\"memo\":{\"id\":\"PNG7TqFAp3JsarQtudg4\",\"name\":\"storefront.checkout.show\",\"path\":\"checkout\",\"method\":\"GET\",\"release\":\"a-a-a\",\"children\":[],\"scripts\":[],\"assets\":[],\"errors\":[],\"locale\":\"en\",\"islands\":[]},\"checksum\":\"f01ca0c874a9fbe7523d6e77fc90457dc31e4f0b3c7bf09bdd8599756c03690a\"}\"," + - generic [ref=f4e496]: "\"updates\": {" + - generic [ref=f4e497]: "\"cardNumber\": \"4242424242424242\"," + - generic [ref=f4e498]: "\"cardName\": \"Erika Musterfrau\"," + - generic [ref=f4e499]: "\"cardExpiry\": \"12/28\"," + - generic [ref=f4e500]: "\"cardCvc\": \"123\"" + - generic [ref=f4e501]: "}," + - generic [ref=f4e502]: "\"calls\": [" + - generic [ref=f4e503]: "{" + - generic [ref=f4e504]: "\"method\": \"payNow\"," + - generic [ref=f4e505]: "\"params\": []," + - generic [ref=f4e506]: "\"metadata\": []" + - generic [ref=f4e507]: "}" + - generic [ref=f4e508]: "]" + - generic [ref=f4e509]: "}" + - generic [ref=f4e510]: "]" + - generic [ref=f4e511]: "}" + - generic [ref=f4e512]: + - heading "Routing" [level=2] [ref=f4e513] + - generic [ref=f4e514]: + - generic [ref=f4e515]: + - generic [ref=f4e516]: controller + - generic [ref=f4e518]: Livewire\Mechanisms\HandleRequests\HandleRequests@handleUpdate + - generic [ref=f4e519]: + - generic [ref=f4e520]: route name + - generic [ref=f4e522]: default-livewire.update + - generic [ref=f4e523]: + - generic [ref=f4e524]: middleware + - generic [ref=f4e526]: web + - generic [ref=f4e527]: + - heading "Routing parameters" [level=2] [ref=f4e528] + - generic [ref=f4e529]: // No routing parameters + - generic [ref=f4e532]: + - img [ref=f4e534] + - img [ref=f4e3572] + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "Log in" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e34]: "2" + - generic [ref=e35]: 2 items in cart + - main [ref=e36]: + - generic [ref=e37]: + - heading "Checkout" [level=1] [ref=e38] + - generic [ref=e39]: + - generic [ref=e40]: + - region "1. Contact information" [ref=e41]: + - generic [ref=e42]: + - heading "1. Contact information" [level=2] [ref=e43] + - button "Edit" [ref=e134] + - paragraph [ref=e135]: erika.musterfrau@example.com + - region "2. Shipping address" [ref=e51]: + - generic [ref=e52]: + - heading "2. Shipping address" [level=2] [ref=e53] + - button "Edit" [ref=e166] + - paragraph [ref=e167]: Erika Musterfrau, Musterstrasse 12, 10115 Berlin, DE + - region "3. Shipping method" [ref=e54]: + - generic [ref=e55]: + - heading "3. Shipping method" [level=2] [ref=e56] + - button "Edit" [ref=e190] + - paragraph [ref=e191]: Standard Shipping + - region "4. Payment" [ref=e57]: + - heading "4. Payment" [level=2] [ref=e59] + - generic [ref=e192]: + - group "Select a payment method" [ref=e193]: + - generic [ref=e194]: Select a payment method + - generic [ref=e195]: + - generic [ref=e196] [cursor=pointer]: + - radio "Credit Card" [checked] [ref=e197] + - generic [ref=e198]: Credit Card + - generic [ref=e199] [cursor=pointer]: + - radio "PayPal" [ref=e200] + - generic [ref=e201]: PayPal + - generic [ref=e202] [cursor=pointer]: + - radio "Bank Transfer" [ref=e203] + - generic [ref=e204]: Bank Transfer + - generic [ref=e205]: + - generic [ref=e206]: + - generic [ref=e207]: Card number * + - textbox "Card number" [ref=e208]: + - /placeholder: 4242 4242 4242 4242 + - text: "4242424242424242" + - generic [ref=e209]: + - generic [ref=e210]: Cardholder name * + - textbox "Cardholder name" [ref=e211]: Erika Musterfrau + - generic [ref=e212]: + - generic [ref=e213]: + - generic [ref=e214]: Expiry * + - textbox "Expiry" [ref=e215]: + - /placeholder: MM/YY + - text: 12/28 + - generic [ref=e216]: + - generic [ref=e217]: CVC * + - textbox "CVC" [ref=e218]: + - /placeholder: "123" + - text: "123" + - button "Pay now - 49.98 EUR" [ref=e219]: + - generic [ref=e220]: Pay now - 49.98 EUR + - generic [ref=e60]: + - complementary "Order summary" [ref=e61]: + - heading "Order Summary" [level=2] [ref=e62] + - list [ref=e63]: + - listitem [ref=e64]: + - generic [ref=e66]: "2" + - generic [ref=e67]: + - paragraph [ref=e68]: Classic Cotton T-Shirt + - paragraph [ref=e69]: M / Navy + - generic [ref=e71]: 49.98 EUR + - generic [ref=e72]: + - generic [ref=e73]: + - term [ref=e74]: Subtotal + - definition [ref=e75]: + - generic [ref=e77]: 49.98 EUR + - generic [ref=e78]: + - term [ref=e79]: Discount (WELCOME10) + - definition [ref=e80]: "-4.99 EUR" + - generic [ref=e81]: + - term [ref=e82]: Shipping + - definition [ref=e83]: + - generic [ref=e222]: 4.99 EUR + - generic [ref=e185]: + - term [ref=e186]: Tax + - definition [ref=e187]: + - generic [ref=e189]: 7.98 EUR + - generic [ref=e84]: + - term [ref=e85]: Total + - definition [ref=e86]: + - generic [ref=e88]: 49.98 EUR + - generic [ref=e90]: + - generic [ref=e91]: WELCOME10 + - button "Remove" [ref=e92] + - contentinfo [ref=e93]: + - generic [ref=e94]: + - generic [ref=e95]: + - generic [ref=e96]: + - heading "Shop" [level=2] [ref=e97] + - list [ref=e98]: + - listitem [ref=e99]: + - link "All collections" [ref=e100] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e101]: + - link "Home" [ref=e102] [cursor=pointer]: + - /url: / + - listitem [ref=e103]: + - link "New Arrivals" [ref=e104] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e105]: + - link "T-Shirts" [ref=e106] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e107]: + - link "Pants & Jeans" [ref=e108] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e109]: + - link "Sale" [ref=e110] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e111]: + - heading "Information" [level=2] [ref=e112] + - list [ref=e113]: + - listitem [ref=e114]: + - link "About Us" [ref=e115] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e116]: + - link "FAQ" [ref=e117] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e118]: + - link "Shipping & Returns" [ref=e119] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e120]: + - link "Privacy Policy" [ref=e121] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e122]: + - link "Terms of Service" [ref=e123] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e124]: + - heading "Acme Fashion" [level=2] [ref=e125] + - paragraph [ref=e126]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e127]: + - paragraph [ref=e128]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e129]: + - listitem [ref=e130]: Visa + - listitem [ref=e131]: Mastercard + - listitem [ref=e132]: Amex + - listitem [ref=e133]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-39-38-980Z.yml b/.playwright-mcp/page-2026-06-10T08-39-38-980Z.yml new file mode 100644 index 00000000..1a70d00b --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-39-38-980Z.yml @@ -0,0 +1,171 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "Log in" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e34]: "2" + - generic [ref=e35]: 2 items in cart + - main [ref=e36]: + - generic [ref=e37]: + - heading "Checkout" [level=1] [ref=e38] + - generic [ref=e39]: + - generic [ref=e40]: + - region "1. Contact information" [ref=e41]: + - heading "1. Contact information" [level=2] [ref=e43] + - paragraph [ref=e44]: erika.musterfrau@example.com + - region "2. Shipping address" [ref=e45]: + - heading "2. Shipping address" [level=2] [ref=e47] + - paragraph [ref=e48]: Erika Musterfrau, Musterstrasse 12, 10115 Berlin, DE + - region "3. Shipping method" [ref=e49]: + - heading "3. Shipping method" [level=2] [ref=e51] + - paragraph [ref=e52]: Standard Shipping + - region "4. Payment" [ref=e53]: + - heading "4. Payment" [level=2] [ref=e55] + - generic [ref=e56]: + - group "Select a payment method" [ref=e57]: + - generic [ref=e58]: Select a payment method + - generic [ref=e59]: + - generic [ref=e60] [cursor=pointer]: + - radio "Credit Card" [checked] [ref=e61] + - generic [ref=e62]: Credit Card + - generic [ref=e63] [cursor=pointer]: + - radio "PayPal" [ref=e64] + - generic [ref=e65]: PayPal + - generic [ref=e66] [cursor=pointer]: + - radio "Bank Transfer" [ref=e67] + - generic [ref=e68]: Bank Transfer + - generic [ref=e69]: + - generic [ref=e70]: + - generic [ref=e71]: Card number * + - textbox "Card number" [ref=e72]: + - /placeholder: 4242 4242 4242 4242 + - generic [ref=e73]: + - generic [ref=e74]: Cardholder name * + - textbox "Cardholder name" [ref=e75] + - generic [ref=e76]: + - generic [ref=e77]: + - generic [ref=e78]: Expiry * + - textbox "Expiry" [ref=e79]: + - /placeholder: MM/YY + - generic [ref=e80]: + - generic [ref=e81]: CVC * + - textbox "CVC" [ref=e82]: + - /placeholder: "123" + - button "Pay now - 49.98 EUR" [ref=e83]: + - generic [ref=e84]: Pay now - 49.98 EUR + - generic [ref=e85]: + - complementary "Order summary" [ref=e86]: + - heading "Order Summary" [level=2] [ref=e87] + - list [ref=e88]: + - listitem [ref=e89]: + - generic [ref=e91]: "2" + - generic [ref=e92]: + - paragraph [ref=e93]: Classic Cotton T-Shirt + - paragraph [ref=e94]: M / Navy + - generic [ref=e96]: 49.98 EUR + - generic [ref=e97]: + - generic [ref=e98]: + - term [ref=e99]: Subtotal + - definition [ref=e100]: + - generic [ref=e102]: 49.98 EUR + - generic [ref=e103]: + - term [ref=e104]: Discount (WELCOME10) + - definition [ref=e105]: "-4.99 EUR" + - generic [ref=e106]: + - term [ref=e107]: Shipping + - definition [ref=e108]: + - generic [ref=e110]: 4.99 EUR + - generic [ref=e111]: + - term [ref=e112]: Tax + - definition [ref=e113]: + - generic [ref=e115]: 7.98 EUR + - generic [ref=e116]: + - term [ref=e117]: Total + - definition [ref=e118]: + - generic [ref=e120]: 49.98 EUR + - generic [ref=e122]: + - generic [ref=e123]: WELCOME10 + - button "Remove" [ref=e124] + - contentinfo [ref=e125]: + - generic [ref=e126]: + - generic [ref=e127]: + - generic [ref=e128]: + - heading "Shop" [level=2] [ref=e129] + - list [ref=e130]: + - listitem [ref=e131]: + - link "All collections" [ref=e132] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e133]: + - link "Home" [ref=e134] [cursor=pointer]: + - /url: / + - listitem [ref=e135]: + - link "New Arrivals" [ref=e136] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e137]: + - link "T-Shirts" [ref=e138] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e139]: + - link "Pants & Jeans" [ref=e140] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e141]: + - link "Sale" [ref=e142] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e143]: + - heading "Information" [level=2] [ref=e144] + - list [ref=e145]: + - listitem [ref=e146]: + - link "About Us" [ref=e147] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e148]: + - link "FAQ" [ref=e149] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e150]: + - link "Shipping & Returns" [ref=e151] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e152]: + - link "Privacy Policy" [ref=e153] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e154]: + - link "Terms of Service" [ref=e155] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e156]: + - heading "Acme Fashion" [level=2] [ref=e157] + - paragraph [ref=e158]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e159]: + - paragraph [ref=e160]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e161]: + - listitem [ref=e162]: Visa + - listitem [ref=e163]: Mastercard + - listitem [ref=e164]: Amex + - listitem [ref=e165]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-39-55-872Z.yml b/.playwright-mcp/page-2026-06-10T08-39-55-872Z.yml new file mode 100644 index 00000000..97389616 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-39-55-872Z.yml @@ -0,0 +1,138 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "Log in" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e34]: 0 items in cart + - main [ref=e35]: + - generic [ref=e36]: + - generic [ref=e37]: + - img [ref=e39] + - heading "Thank you for your order!" [level=1] [ref=e41] + - paragraph [ref=e42]: "Order #1016" + - paragraph [ref=e43]: We've sent a confirmation to erika.musterfrau@example.com + - region "Order summary" [ref=e44]: + - heading "Order summary" [level=2] [ref=e45] + - list [ref=e46]: + - listitem [ref=e47]: + - img [ref=e49] + - generic [ref=e51]: + - generic [ref=e52]: Classic Cotton T-Shirt (M / Navy) + - text: "Qty: 2" + - generic [ref=e54]: 44.99 EUR + - generic [ref=e55]: + - generic [ref=e56]: + - heading "Shipping address" [level=2] [ref=e57] + - paragraph [ref=e58]: + - text: Erika Musterfrau + - text: Musterstrasse 12 + - text: 10115 Berlin, DE + - generic [ref=e59]: + - heading "Payment method" [level=2] [ref=e60] + - paragraph [ref=e61]: Credit Card + - region "Order totals" [ref=e62]: + - generic [ref=e63]: + - generic [ref=e64]: + - term [ref=e65]: Subtotal + - definition [ref=e66]: + - generic [ref=e68]: 49.98 EUR + - generic [ref=e69]: + - term [ref=e70]: Discount + - definition [ref=e71]: "-4.99 EUR" + - generic [ref=e72]: + - term [ref=e73]: Shipping + - definition [ref=e74]: + - generic [ref=e76]: 4.99 EUR + - generic [ref=e77]: + - term [ref=e78]: Tax + - definition [ref=e79]: + - generic [ref=e81]: 7.98 EUR + - generic [ref=e82]: + - term [ref=e83]: Total + - definition [ref=e84]: + - generic [ref=e86]: 49.98 EUR + - link "Continue shopping" [ref=e88] [cursor=pointer]: + - /url: http://shop.test + - contentinfo [ref=e89]: + - generic [ref=e90]: + - generic [ref=e91]: + - generic [ref=e92]: + - heading "Shop" [level=2] [ref=e93] + - list [ref=e94]: + - listitem [ref=e95]: + - link "All collections" [ref=e96] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e97]: + - link "Home" [ref=e98] [cursor=pointer]: + - /url: / + - listitem [ref=e99]: + - link "New Arrivals" [ref=e100] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e101]: + - link "T-Shirts" [ref=e102] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e103]: + - link "Pants & Jeans" [ref=e104] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e105]: + - link "Sale" [ref=e106] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e107]: + - heading "Information" [level=2] [ref=e108] + - list [ref=e109]: + - listitem [ref=e110]: + - link "About Us" [ref=e111] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e112]: + - link "FAQ" [ref=e113] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e114]: + - link "Shipping & Returns" [ref=e115] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e116]: + - link "Privacy Policy" [ref=e117] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e118]: + - link "Terms of Service" [ref=e119] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e120]: + - heading "Acme Fashion" [level=2] [ref=e121] + - paragraph [ref=e122]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e123]: + - paragraph [ref=e124]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e125]: + - listitem [ref=e126]: Visa + - listitem [ref=e127]: Mastercard + - listitem [ref=e128]: Amex + - listitem [ref=e129]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-40-27-955Z.yml b/.playwright-mcp/page-2026-06-10T08-40-27-955Z.yml new file mode 100644 index 00000000..5d94cdef --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-40-27-955Z.yml @@ -0,0 +1,148 @@ +- generic [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "Log in" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e34]: 0 items in cart + - main [ref=e35]: + - generic [ref=e36]: + - generic [ref=e37]: + - img [ref=e39] + - heading "Thank you for your order!" [level=1] [ref=e41] + - paragraph [ref=e42]: "Order #1016" + - paragraph [ref=e43]: We've sent a confirmation to erika.musterfrau@example.com + - region "Order summary" [ref=e44]: + - heading "Order summary" [level=2] [ref=e45] + - list [ref=e46]: + - listitem [ref=e47]: + - img [ref=e49] + - generic [ref=e51]: + - generic [ref=e52]: Classic Cotton T-Shirt (M / Navy) + - text: "Qty: 2" + - generic [ref=e54]: 44.99 EUR + - generic [ref=e55]: + - generic [ref=e56]: + - heading "Shipping address" [level=2] [ref=e57] + - paragraph [ref=e58]: + - text: Erika Musterfrau + - text: Musterstrasse 12 + - text: 10115 Berlin, DE + - generic [ref=e59]: + - heading "Payment method" [level=2] [ref=e60] + - paragraph [ref=e61]: Credit Card + - region "Order totals" [ref=e62]: + - generic [ref=e63]: + - generic [ref=e64]: + - term [ref=e65]: Subtotal + - definition [ref=e66]: + - generic [ref=e68]: 49.98 EUR + - generic [ref=e69]: + - term [ref=e70]: Discount + - definition [ref=e71]: "-4.99 EUR" + - generic [ref=e72]: + - term [ref=e73]: Shipping + - definition [ref=e74]: + - generic [ref=e76]: 4.99 EUR + - generic [ref=e77]: + - term [ref=e78]: Tax + - definition [ref=e79]: + - generic [ref=e81]: 7.98 EUR + - generic [ref=e82]: + - term [ref=e83]: Total + - definition [ref=e84]: + - generic [ref=e86]: 49.98 EUR + - link "Continue shopping" [ref=e88] [cursor=pointer]: + - /url: http://shop.test + - contentinfo [ref=e89]: + - generic [ref=e90]: + - generic [ref=e91]: + - generic [ref=e92]: + - heading "Shop" [level=2] [ref=e93] + - list [ref=e94]: + - listitem [ref=e95]: + - link "All collections" [ref=e96] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e97]: + - link "Home" [ref=e98] [cursor=pointer]: + - /url: / + - listitem [ref=e99]: + - link "New Arrivals" [ref=e100] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e101]: + - link "T-Shirts" [ref=e102] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e103]: + - link "Pants & Jeans" [ref=e104] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e105]: + - link "Sale" [ref=e106] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e107]: + - heading "Information" [level=2] [ref=e108] + - list [ref=e109]: + - listitem [ref=e110]: + - link "About Us" [ref=e111] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e112]: + - link "FAQ" [ref=e113] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e114]: + - link "Shipping & Returns" [ref=e115] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e116]: + - link "Privacy Policy" [ref=e117] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e118]: + - link "Terms of Service" [ref=e119] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e120]: + - heading "Acme Fashion" [level=2] [ref=e121] + - paragraph [ref=e122]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e123]: + - paragraph [ref=e124]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e125]: + - listitem [ref=e126]: Visa + - listitem [ref=e127]: Mastercard + - listitem [ref=e128]: Amex + - listitem [ref=e129]: PayPal + - generic: + - dialog "Search": + - generic [ref=e132]: + - generic [ref=e133]: + - img [ref=e134] + - searchbox "Search products" [active] [ref=e136] + - button "Close search" [ref=e137]: + - img [ref=e138] + - listbox "Search results" [ref=e140]: + - paragraph [ref=e142]: Start typing to search products and collections. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-40-38-797Z.yml b/.playwright-mcp/page-2026-06-10T08-40-38-797Z.yml new file mode 100644 index 00000000..1816c641 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-40-38-797Z.yml @@ -0,0 +1,156 @@ +- generic [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "Log in" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e34]: 0 items in cart + - main [ref=e35]: + - generic [ref=e36]: + - generic [ref=e37]: + - img [ref=e39] + - heading "Thank you for your order!" [level=1] [ref=e41] + - paragraph [ref=e42]: "Order #1016" + - paragraph [ref=e43]: We've sent a confirmation to erika.musterfrau@example.com + - region "Order summary" [ref=e44]: + - heading "Order summary" [level=2] [ref=e45] + - list [ref=e46]: + - listitem [ref=e47]: + - img [ref=e49] + - generic [ref=e51]: + - generic [ref=e52]: Classic Cotton T-Shirt (M / Navy) + - text: "Qty: 2" + - generic [ref=e54]: 44.99 EUR + - generic [ref=e55]: + - generic [ref=e56]: + - heading "Shipping address" [level=2] [ref=e57] + - paragraph [ref=e58]: + - text: Erika Musterfrau + - text: Musterstrasse 12 + - text: 10115 Berlin, DE + - generic [ref=e59]: + - heading "Payment method" [level=2] [ref=e60] + - paragraph [ref=e61]: Credit Card + - region "Order totals" [ref=e62]: + - generic [ref=e63]: + - generic [ref=e64]: + - term [ref=e65]: Subtotal + - definition [ref=e66]: + - generic [ref=e68]: 49.98 EUR + - generic [ref=e69]: + - term [ref=e70]: Discount + - definition [ref=e71]: "-4.99 EUR" + - generic [ref=e72]: + - term [ref=e73]: Shipping + - definition [ref=e74]: + - generic [ref=e76]: 4.99 EUR + - generic [ref=e77]: + - term [ref=e78]: Tax + - definition [ref=e79]: + - generic [ref=e81]: 7.98 EUR + - generic [ref=e82]: + - term [ref=e83]: Total + - definition [ref=e84]: + - generic [ref=e86]: 49.98 EUR + - link "Continue shopping" [ref=e88] [cursor=pointer]: + - /url: http://shop.test + - contentinfo [ref=e89]: + - generic [ref=e90]: + - generic [ref=e91]: + - generic [ref=e92]: + - heading "Shop" [level=2] [ref=e93] + - list [ref=e94]: + - listitem [ref=e95]: + - link "All collections" [ref=e96] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e97]: + - link "Home" [ref=e98] [cursor=pointer]: + - /url: / + - listitem [ref=e99]: + - link "New Arrivals" [ref=e100] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e101]: + - link "T-Shirts" [ref=e102] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e103]: + - link "Pants & Jeans" [ref=e104] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e105]: + - link "Sale" [ref=e106] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e107]: + - heading "Information" [level=2] [ref=e108] + - list [ref=e109]: + - listitem [ref=e110]: + - link "About Us" [ref=e111] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e112]: + - link "FAQ" [ref=e113] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e114]: + - link "Shipping & Returns" [ref=e115] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e116]: + - link "Privacy Policy" [ref=e117] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e118]: + - link "Terms of Service" [ref=e119] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e120]: + - heading "Acme Fashion" [level=2] [ref=e121] + - paragraph [ref=e122]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e123]: + - paragraph [ref=e124]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e125]: + - listitem [ref=e126]: Visa + - listitem [ref=e127]: Mastercard + - listitem [ref=e128]: Amex + - listitem [ref=e129]: PayPal + - generic: + - dialog "Search": + - generic [ref=e132]: + - generic [ref=e133]: + - img [ref=e134] + - searchbox "Search products" [active] [ref=e136]: hoodie + - button "Close search" [ref=e137]: + - img [ref=e138] + - listbox "Search results" [ref=e140]: + - generic [ref=e141]: + - paragraph [ref=e143]: Products + - option "Organic Hoodie 59.99 EUR" [ref=e144] [cursor=pointer]: + - img [ref=e146] + - generic [ref=e148]: Organic Hoodie + - generic [ref=e149]: 59.99 EUR + - option "View all 1 result" [ref=e151] [cursor=pointer]: + - text: View all 1 result + - img [ref=e152] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-40-44-689Z.yml b/.playwright-mcp/page-2026-06-10T08-40-44-689Z.yml new file mode 100644 index 00000000..d55dfc8a --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-40-44-689Z.yml @@ -0,0 +1,111 @@ +- generic [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "Log in" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e34]: 0 items in cart + - main [ref=e35]: + - generic [ref=e36]: + - heading "Create an account" [level=1] [ref=e37] + - generic [ref=e38]: + - generic [ref=e39]: + - generic [ref=e40]: Name + - textbox "Name" [active] [ref=e42] + - generic [ref=e43]: + - generic [ref=e44]: Email address + - textbox "Email address" [ref=e46] + - generic [ref=e47]: + - generic [ref=e48]: Password + - textbox "Password" [ref=e50] + - generic [ref=e51]: + - generic [ref=e52]: Confirm password + - textbox "Confirm password" [ref=e54] + - generic [ref=e55]: + - checkbox "Send me product news and offers" [ref=e56] + - generic [ref=e58]: Send me product news and offers + - button "Create account" [ref=e59]: + - img [ref=e61] + - generic [ref=e64]: Create account + - contentinfo [ref=e65]: + - generic [ref=e66]: + - generic [ref=e67]: + - generic [ref=e68]: + - heading "Shop" [level=2] [ref=e69] + - list [ref=e70]: + - listitem [ref=e71]: + - link "All collections" [ref=e72] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e73]: + - link "Home" [ref=e74] [cursor=pointer]: + - /url: / + - listitem [ref=e75]: + - link "New Arrivals" [ref=e76] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e77]: + - link "T-Shirts" [ref=e78] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e79]: + - link "Pants & Jeans" [ref=e80] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e81]: + - link "Sale" [ref=e82] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e83]: + - heading "Information" [level=2] [ref=e84] + - list [ref=e85]: + - listitem [ref=e86]: + - link "About Us" [ref=e87] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e88]: + - link "FAQ" [ref=e89] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e90]: + - link "Shipping & Returns" [ref=e91] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e92]: + - link "Privacy Policy" [ref=e93] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e94]: + - link "Terms of Service" [ref=e95] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e96]: + - heading "Acme Fashion" [level=2] [ref=e97] + - paragraph [ref=e98]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e99]: + - paragraph [ref=e100]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e101]: + - listitem [ref=e102]: Visa + - listitem [ref=e103]: Mastercard + - listitem [ref=e104]: Amex + - listitem [ref=e105]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-41-00-694Z.yml b/.playwright-mcp/page-2026-06-10T08-41-00-694Z.yml new file mode 100644 index 00000000..b9da5098 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-41-00-694Z.yml @@ -0,0 +1,138 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e4]: + - paragraph [ref=e5]: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e6]: + - img [ref=e7] + - banner [ref=e9]: + - generic [ref=e10]: + - link "Acme Fashion" [ref=e11] [cursor=pointer]: + - /url: http://shop.test + - navigation "Main navigation" [ref=e12]: + - list [ref=e13]: + - listitem [ref=e14]: + - link "Home" [ref=e15] [cursor=pointer]: + - /url: / + - listitem [ref=e16]: + - link "New Arrivals" [ref=e17] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e18]: + - link "T-Shirts" [ref=e19] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e20]: + - link "Pants & Jeans" [ref=e21] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e22]: + - link "Sale" [ref=e23] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e24]: + - button "Search" [ref=e25]: + - img [ref=e26] + - link "My account" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account + - img [ref=e29] + - button "Open cart" [ref=e31]: + - img [ref=e32] + - generic [ref=e34]: 0 items in cart + - main [ref=e35]: + - generic [ref=e36]: + - heading "Welcome back, Erika Musterfrau!" [level=1] [ref=e37] + - navigation "Account navigation" [ref=e38]: + - generic [ref=e39]: + - list [ref=e40]: + - listitem [ref=e41]: + - link "My Account" [ref=e42] [cursor=pointer]: + - /url: http://shop.test/account + - listitem [ref=e43]: + - link "Orders" [ref=e44] [cursor=pointer]: + - /url: http://shop.test/account/orders + - listitem [ref=e45]: + - link "Addresses" [ref=e46] [cursor=pointer]: + - /url: http://shop.test/account/addresses + - button "Log out" [ref=e48]: + - img [ref=e49] + - text: Log out + - generic [ref=e51]: + - link "Order history View all your orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/account/orders + - img [ref=e53] + - generic [ref=e55]: Order history + - generic [ref=e56]: View all your orders + - link "Addresses Manage your addresses" [ref=e57] [cursor=pointer]: + - /url: http://shop.test/account/addresses + - img [ref=e58] + - generic [ref=e61]: Addresses + - generic [ref=e62]: Manage your addresses + - button "Log out End your session securely" [ref=e64] [cursor=pointer]: + - img [ref=e65] + - generic [ref=e67]: Log out + - generic [ref=e68]: End your session securely + - region "Recent Orders" [ref=e69]: + - heading "Recent Orders" [level=2] [ref=e71] + - paragraph [ref=e72]: You haven't placed any orders yet. + - region "Profile" [ref=e73]: + - heading "Profile" [level=2] [ref=e74] + - generic [ref=e75]: + - generic [ref=e76]: + - generic [ref=e77]: Name * + - textbox "Name" [ref=e78]: Erika Musterfrau + - generic [ref=e79]: + - paragraph [ref=e80]: Email + - paragraph [ref=e81]: erika.musterfrau@example.com + - generic [ref=e82]: + - checkbox "Subscribe to marketing emails" [ref=e83] + - text: Subscribe to marketing emails + - button "Save changes" [ref=e84] + - contentinfo [ref=e85]: + - generic [ref=e86]: + - generic [ref=e87]: + - generic [ref=e88]: + - heading "Shop" [level=2] [ref=e89] + - list [ref=e90]: + - listitem [ref=e91]: + - link "All collections" [ref=e92] [cursor=pointer]: + - /url: http://shop.test/collections + - listitem [ref=e93]: + - link "Home" [ref=e94] [cursor=pointer]: + - /url: / + - listitem [ref=e95]: + - link "New Arrivals" [ref=e96] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=e97]: + - link "T-Shirts" [ref=e98] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=e99]: + - link "Pants & Jeans" [ref=e100] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=e101]: + - link "Sale" [ref=e102] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e103]: + - heading "Information" [level=2] [ref=e104] + - list [ref=e105]: + - listitem [ref=e106]: + - link "About Us" [ref=e107] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=e108]: + - link "FAQ" [ref=e109] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=e110]: + - link "Shipping & Returns" [ref=e111] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=e112]: + - link "Privacy Policy" [ref=e113] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=e114]: + - link "Terms of Service" [ref=e115] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=e116]: + - heading "Acme Fashion" [level=2] [ref=e117] + - paragraph [ref=e118]: 2025 Acme Fashion. All rights reserved. + - generic [ref=e119]: + - paragraph [ref=e120]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=e121]: + - listitem [ref=e122]: Visa + - listitem [ref=e123]: Mastercard + - listitem [ref=e124]: Amex + - listitem [ref=e125]: PayPal \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-41-14-380Z.yml b/.playwright-mcp/page-2026-06-10T08-41-14-380Z.yml new file mode 100644 index 00000000..c74fed2e --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-41-14-380Z.yml @@ -0,0 +1,256 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - complementary "Admin navigation" [ref=e3]: + - link "Shop" [ref=e5] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e8] + - generic [ref=e10]: Shop + - navigation [ref=e11]: + - link "Dashboard" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e13] + - text: Dashboard + - paragraph [ref=e15]: Products + - link "Products" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e17] + - text: Products + - link "Collections" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e20] + - text: Collections + - link "Inventory" [ref=e22] [cursor=pointer]: + - /url: http://shop.test/admin/inventory + - img [ref=e23] + - text: Inventory + - paragraph [ref=e25]: Orders + - link "Orders" [ref=e26] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e27] + - text: Orders + - paragraph [ref=e29]: Customers + - link "Customers" [ref=e30] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e31] + - text: Customers + - paragraph [ref=e33]: Discounts + - link "Discounts" [ref=e34] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e35] + - text: Discounts + - paragraph [ref=e38]: Content + - link "Pages" [ref=e39] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e40] + - text: Pages + - link "Navigation" [ref=e42] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e43] + - text: Navigation + - link "Themes" [ref=e45] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e46] + - text: Themes + - link "Analytics" [ref=e48] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e49] + - text: Analytics + - link "Settings" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e53] + - text: Settings + - link "Apps" [ref=e56] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e57] + - text: Apps + - link "Developers" [ref=e59] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e60] + - text: Developers + - generic [ref=e62]: + - banner [ref=e63]: + - button "Acme Fashion" [ref=e65]: + - generic [ref=e66]: Acme Fashion + - img [ref=e67] + - button "AU Admin User" [ref=e70]: + - generic [ref=e73]: AU + - generic [ref=e74]: Admin User + - img [ref=e76] + - main [ref=e78]: + - generic [ref=e79]: + - generic [ref=e80]: + - generic [ref=e81]: + - link "Home" [ref=e82] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e83] + - generic [ref=e86]: Dashboard + - generic [ref=e87]: + - heading "Dashboard" [level=1] [ref=e88] + - combobox [ref=e89]: + - option "Last 7 days" + - option "Last 30 days" [selected] + - option "Last 90 days" + - generic [ref=e90]: + - generic [ref=e91]: + - paragraph [ref=e92]: Total sales + - generic [ref=e93]: 1,537.12 EUR + - generic [ref=e94]: + - generic [ref=e95]: +100.0% + - img [ref=e96] + - paragraph [ref=e98]: vs previous period + - generic [ref=e99]: + - paragraph [ref=e100]: Orders + - generic [ref=e101]: "15" + - generic [ref=e102]: + - generic [ref=e103]: +100.0% + - img [ref=e104] + - paragraph [ref=e106]: vs previous period + - generic [ref=e107]: + - paragraph [ref=e108]: Average order value + - generic [ref=e109]: 102.47 EUR + - generic [ref=e110]: + - generic [ref=e111]: +100.0% + - img [ref=e112] + - paragraph [ref=e114]: vs previous period + - generic [ref=e115]: + - paragraph [ref=e116]: Conversion rate + - generic [ref=e117]: 0.5% + - generic [ref=e118]: + - generic [ref=e119]: +100.0% + - img [ref=e120] + - paragraph [ref=e122]: vs previous period + - generic [ref=e123]: + - generic [ref=e124]: + - generic [ref=e125]: Orders over time + - paragraph [ref=e126]: "Peak: 3 orders/day" + - generic [ref=e127]: + - img "Daily order counts" [ref=e128] + - generic [ref=e131]: + - generic [ref=e132]: May 12 + - generic [ref=e133]: Jun 10 + - generic [ref=e134]: + - generic [ref=e135]: + - generic [ref=e136]: Recent orders + - link "View all" [ref=e137] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - table [ref=e139]: + - rowgroup [ref=e140]: + - row "Order Date Customer Payment Fulfillment Total" [ref=e141]: + - columnheader "Order" [ref=e142] + - columnheader "Date" [ref=e143] + - columnheader "Customer" [ref=e144] + - columnheader "Payment" [ref=e145] + - columnheader "Fulfillment" [ref=e146] + - columnheader "Total" [ref=e147] + - rowgroup [ref=e148]: + - row "#1016 Jun 10, 8:39 AM Guest Paid Unfulfilled 49.98 EUR" [ref=e149]: + - cell "#1016" [ref=e150]: + - link "#1016" [ref=e151] [cursor=pointer]: + - /url: http://shop.test/admin/orders/19 + - cell "Jun 10, 8:39 AM" [ref=e152] + - cell "Guest" [ref=e153] + - cell "Paid" [ref=e154]: + - generic [ref=e155]: Paid + - cell "Unfulfilled" [ref=e156]: + - generic [ref=e157]: Unfulfilled + - cell "49.98 EUR" [ref=e158] + - row "#1015 Jun 10, 8:32 AM John Doe Paid Unfulfilled 54.47 EUR" [ref=e159]: + - cell "#1015" [ref=e160]: + - link "#1015" [ref=e161] [cursor=pointer]: + - /url: http://shop.test/admin/orders/15 + - cell "Jun 10, 8:32 AM" [ref=e162] + - cell "John Doe" [ref=e163] + - cell "Paid" [ref=e164]: + - generic [ref=e165]: Paid + - cell "Unfulfilled" [ref=e166]: + - generic [ref=e167]: Unfulfilled + - cell "54.47 EUR" [ref=e168] + - row "#1005 Jun 10, 6:32 AM Jane Smith Pending Unfulfilled 39.98 EUR" [ref=e169]: + - cell "#1005" [ref=e170]: + - link "#1005" [ref=e171] [cursor=pointer]: + - /url: http://shop.test/admin/orders/5 + - cell "Jun 10, 6:32 AM" [ref=e172] + - cell "Jane Smith" [ref=e173] + - cell "Pending" [ref=e174]: + - generic [ref=e175]: Pending + - cell "Unfulfilled" [ref=e176]: + - generic [ref=e177]: Unfulfilled + - cell "39.98 EUR" [ref=e178] + - row "#1013 Jun 9, 8:32 AM Robert Martinez Paid Unfulfilled 84.97 EUR" [ref=e179]: + - cell "#1013" [ref=e180]: + - link "#1013" [ref=e181] [cursor=pointer]: + - /url: http://shop.test/admin/orders/13 + - cell "Jun 9, 8:32 AM" [ref=e182] + - cell "Robert Martinez" [ref=e183] + - cell "Paid" [ref=e184]: + - generic [ref=e185]: Paid + - cell "Unfulfilled" [ref=e186]: + - generic [ref=e187]: Unfulfilled + - cell "84.97 EUR" [ref=e188] + - row "#1010 Jun 9, 8:32 AM John Doe Paid Unfulfilled 504.98 EUR" [ref=e189]: + - cell "#1010" [ref=e190]: + - link "#1010" [ref=e191] [cursor=pointer]: + - /url: http://shop.test/admin/orders/10 + - cell "Jun 9, 8:32 AM" [ref=e192] + - cell "John Doe" [ref=e193] + - cell "Paid" [ref=e194]: + - generic [ref=e195]: Paid + - cell "Unfulfilled" [ref=e196]: + - generic [ref=e197]: Unfulfilled + - cell "504.98 EUR" [ref=e198] + - row "#1006 Jun 9, 8:32 AM Michael Brown Paid Unfulfilled 124.98 EUR" [ref=e199]: + - cell "#1006" [ref=e200]: + - link "#1006" [ref=e201] [cursor=pointer]: + - /url: http://shop.test/admin/orders/6 + - cell "Jun 9, 8:32 AM" [ref=e202] + - cell "Michael Brown" [ref=e203] + - cell "Paid" [ref=e204]: + - generic [ref=e205]: Paid + - cell "Unfulfilled" [ref=e206]: + - generic [ref=e207]: Unfulfilled + - cell "124.98 EUR" [ref=e208] + - row "#1001 Jun 8, 8:32 AM John Doe Paid Unfulfilled 54.97 EUR" [ref=e209]: + - cell "#1001" [ref=e210]: + - link "#1001" [ref=e211] [cursor=pointer]: + - /url: http://shop.test/admin/orders/1 + - cell "Jun 8, 8:32 AM" [ref=e212] + - cell "John Doe" [ref=e213] + - cell "Paid" [ref=e214]: + - generic [ref=e215]: Paid + - cell "Unfulfilled" [ref=e216]: + - generic [ref=e217]: Unfulfilled + - cell "54.97 EUR" [ref=e218] + - row "#1009 Jun 7, 8:32 AM Emma Garcia Paid Unfulfilled 49.97 EUR" [ref=e219]: + - cell "#1009" [ref=e220]: + - link "#1009" [ref=e221] [cursor=pointer]: + - /url: http://shop.test/admin/orders/9 + - cell "Jun 7, 8:32 AM" [ref=e222] + - cell "Emma Garcia" [ref=e223] + - cell "Paid" [ref=e224]: + - generic [ref=e225]: Paid + - cell "Unfulfilled" [ref=e226]: + - generic [ref=e227]: Unfulfilled + - cell "49.97 EUR" [ref=e228] + - row "#1012 Jun 6, 8:32 AM Lisa Anderson Paid Unfulfilled 84.97 EUR" [ref=e229]: + - cell "#1012" [ref=e230]: + - link "#1012" [ref=e231] [cursor=pointer]: + - /url: http://shop.test/admin/orders/12 + - cell "Jun 6, 8:32 AM" [ref=e232] + - cell "Lisa Anderson" [ref=e233] + - cell "Paid" [ref=e234]: + - generic [ref=e235]: Paid + - cell "Unfulfilled" [ref=e236]: + - generic [ref=e237]: Unfulfilled + - cell "84.97 EUR" [ref=e238] + - row "#1003 Jun 5, 8:32 AM Jane Smith Paid Partial 119.97 EUR" [ref=e239]: + - cell "#1003" [ref=e240]: + - link "#1003" [ref=e241] [cursor=pointer]: + - /url: http://shop.test/admin/orders/3 + - cell "Jun 5, 8:32 AM" [ref=e242] + - cell "Jane Smith" [ref=e243] + - cell "Paid" [ref=e244]: + - generic [ref=e245]: Paid + - cell "Partial" [ref=e246]: + - generic [ref=e247]: Partial + - cell "119.97 EUR" [ref=e248] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-41-42-411Z.yml b/.playwright-mcp/page-2026-06-10T08-41-42-411Z.yml new file mode 100644 index 00000000..ae813f73 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-41-42-411Z.yml @@ -0,0 +1,178 @@ +- generic [active] [ref=e249]: + - link "Skip to main content" [ref=e250] [cursor=pointer]: + - /url: "#main-content" + - complementary "Admin navigation" [ref=e251]: + - link "Shop" [ref=e253] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e256] + - generic [ref=e258]: Shop + - navigation [ref=e259]: + - link "Dashboard" [ref=e260] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e261] + - text: Dashboard + - paragraph [ref=e263]: Products + - link "Products" [ref=e264] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e265] + - text: Products + - link "Collections" [ref=e267] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e268] + - text: Collections + - link "Inventory" [ref=e270] [cursor=pointer]: + - /url: http://shop.test/admin/inventory + - img [ref=e271] + - text: Inventory + - paragraph [ref=e273]: Orders + - link "Orders" [ref=e274] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e275] + - text: Orders + - paragraph [ref=e277]: Customers + - link "Customers" [ref=e278] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e279] + - text: Customers + - paragraph [ref=e281]: Discounts + - link "Discounts" [ref=e282] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e283] + - text: Discounts + - paragraph [ref=e286]: Content + - link "Pages" [ref=e287] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e288] + - text: Pages + - link "Navigation" [ref=e290] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e291] + - text: Navigation + - link "Themes" [ref=e293] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e294] + - text: Themes + - link "Analytics" [ref=e296] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e297] + - text: Analytics + - link "Settings" [ref=e300] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e301] + - text: Settings + - link "Apps" [ref=e304] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e305] + - text: Apps + - link "Developers" [ref=e307] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e308] + - text: Developers + - generic [ref=e310]: + - banner [ref=e311]: + - button "Acme Fashion" [ref=e313]: + - generic [ref=e314]: Acme Fashion + - img [ref=e315] + - button "AU Admin User" [ref=e318]: + - generic [ref=e321]: AU + - generic [ref=e322]: Admin User + - img [ref=e324] + - main [ref=e326]: + - generic [ref=e327]: + - generic [ref=e328]: + - generic [ref=e329]: + - link "Home" [ref=e330] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e331] + - generic [ref=e333]: + - link "Orders" [ref=e334] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e335] + - generic [ref=e338]: "#1016" + - generic [ref=e339]: + - generic [ref=e340]: + - generic [ref=e341]: + - generic [ref=e342]: + - heading "#1016" [level=1] [ref=e343] + - generic [ref=e344]: Paid + - generic [ref=e345]: Unfulfilled + - paragraph [ref=e346]: Jun 10, 2026 8:39 AM + - generic [ref=e347]: + - button "Create fulfillment" [ref=e349] + - button "Refund" [ref=e351] + - button "Cancel order" [ref=e353] + - generic [ref=e354]: + - generic [ref=e355]: Timeline + - list [ref=e356]: + - listitem [ref=e357]: + - paragraph [ref=e359]: Order placed + - paragraph [ref=e360]: Jun 10, 2026 8:39 AM + - listitem [ref=e361]: + - paragraph [ref=e363]: Payment received + - paragraph [ref=e364]: Paid via credit card + - paragraph [ref=e365]: Jun 10, 2026 8:39 AM + - generic [ref=e366]: + - generic [ref=e368]: Order lines + - table [ref=e370]: + - rowgroup [ref=e371]: + - row "Image Product Fulfillment Qty Unit price Total" [ref=e372]: + - columnheader "Image" [ref=e373] + - columnheader "Product" [ref=e374] + - columnheader "Fulfillment" [ref=e375] + - columnheader "Qty" [ref=e376] + - columnheader "Unit price" [ref=e377] + - columnheader "Total" [ref=e378] + - rowgroup [ref=e379]: + - 'row "Classic Cotton T-Shirt (M / Navy) SKU: ACME-CTSH-M-NAVY Unfulfilled 2 24.99 EUR 44.99 EUR" [ref=e380]': + - cell [ref=e381]: + - img [ref=e383] + - 'cell "Classic Cotton T-Shirt (M / Navy) SKU: ACME-CTSH-M-NAVY" [ref=e385]': + - paragraph [ref=e386]: Classic Cotton T-Shirt (M / Navy) + - paragraph [ref=e387]: "SKU: ACME-CTSH-M-NAVY" + - cell "Unfulfilled" [ref=e388]: + - generic [ref=e389]: Unfulfilled + - cell "2" [ref=e390] + - cell "24.99 EUR" [ref=e391] + - cell "44.99 EUR" [ref=e392] + - generic [ref=e394]: + - generic [ref=e395]: + - term [ref=e396]: Subtotal + - definition [ref=e397]: 49.98 EUR + - generic [ref=e398]: + - term [ref=e399]: Discount + - definition [ref=e400]: "-4.99 EUR" + - generic [ref=e401]: + - term [ref=e402]: Shipping + - definition [ref=e403]: 4.99 EUR + - generic [ref=e404]: + - term [ref=e405]: Tax + - definition [ref=e406]: 7.98 EUR + - generic [ref=e407]: + - term [ref=e408]: Total + - definition [ref=e409]: 49.98 EUR + - generic [ref=e410]: + - generic [ref=e411]: Payment details + - generic [ref=e413]: + - generic [ref=e414]: + - paragraph [ref=e415]: Credit Card + - paragraph [ref=e416]: "49.98 EUR - Ref: mock_kfkqps3gwqkjvtny" + - generic [ref=e417]: Captured + - generic [ref=e418]: + - generic [ref=e419]: + - generic [ref=e420]: Customer + - paragraph [ref=e421]: Guest + - paragraph [ref=e422]: erika.musterfrau@example.com + - generic [ref=e423]: + - generic [ref=e424]: Shipping address + - generic [ref=e425]: + - paragraph [ref=e426]: Erika Musterfrau + - paragraph [ref=e427]: Musterstrasse 12 + - paragraph [ref=e428]: 10115 Berlin + - paragraph [ref=e429]: Germany + - generic [ref=e430]: + - generic [ref=e431]: Billing address + - generic [ref=e432]: + - paragraph [ref=e433]: Erika Musterfrau + - paragraph [ref=e434]: Musterstrasse 12 + - paragraph [ref=e435]: 10115 Berlin + - paragraph [ref=e436]: Germany \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-41-54-075Z.yml b/.playwright-mcp/page-2026-06-10T08-41-54-075Z.yml new file mode 100644 index 00000000..561a73c6 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-41-54-075Z.yml @@ -0,0 +1,205 @@ +- generic [active] [ref=e249]: + - link "Skip to main content" [ref=e250] [cursor=pointer]: + - /url: "#main-content" + - complementary "Admin navigation" [ref=e251]: + - link "Shop" [ref=e253] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e256] + - generic [ref=e258]: Shop + - navigation [ref=e259]: + - link "Dashboard" [ref=e260] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e261] + - text: Dashboard + - paragraph [ref=e263]: Products + - link "Products" [ref=e264] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e265] + - text: Products + - link "Collections" [ref=e267] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e268] + - text: Collections + - link "Inventory" [ref=e270] [cursor=pointer]: + - /url: http://shop.test/admin/inventory + - img [ref=e271] + - text: Inventory + - paragraph [ref=e273]: Orders + - link "Orders" [ref=e274] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e275] + - text: Orders + - paragraph [ref=e277]: Customers + - link "Customers" [ref=e278] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e279] + - text: Customers + - paragraph [ref=e281]: Discounts + - link "Discounts" [ref=e282] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e283] + - text: Discounts + - paragraph [ref=e286]: Content + - link "Pages" [ref=e287] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e288] + - text: Pages + - link "Navigation" [ref=e290] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e291] + - text: Navigation + - link "Themes" [ref=e293] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e294] + - text: Themes + - link "Analytics" [ref=e296] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e297] + - text: Analytics + - link "Settings" [ref=e300] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e301] + - text: Settings + - link "Apps" [ref=e304] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e305] + - text: Apps + - link "Developers" [ref=e307] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e308] + - text: Developers + - generic [ref=e310]: + - banner [ref=e311]: + - button "Acme Fashion" [ref=e313]: + - generic [ref=e314]: Acme Fashion + - img [ref=e315] + - button "AU Admin User" [ref=e318]: + - generic [ref=e321]: AU + - generic [ref=e322]: Admin User + - img [ref=e324] + - main [ref=e326]: + - generic [ref=e327]: + - generic [ref=e328]: + - generic [ref=e329]: + - link "Home" [ref=e330] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e331] + - generic [ref=e333]: + - link "Orders" [ref=e334] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e335] + - generic [ref=e338]: "#1016" + - generic [ref=e339]: + - generic [ref=e340]: + - generic [ref=e341]: + - generic [ref=e342]: + - heading "#1016" [level=1] [ref=e343] + - generic [ref=e344]: Paid + - generic [ref=e345]: Unfulfilled + - paragraph [ref=e346]: Jun 10, 2026 8:39 AM + - generic [ref=e347]: + - button "Create fulfillment" [ref=e349] + - button "Refund" [ref=e351] + - button "Cancel order" [ref=e353] + - generic [ref=e354]: + - generic [ref=e355]: Timeline + - list [ref=e356]: + - listitem [ref=e357]: + - paragraph [ref=e359]: Order placed + - paragraph [ref=e360]: Jun 10, 2026 8:39 AM + - listitem [ref=e361]: + - paragraph [ref=e363]: Payment received + - paragraph [ref=e364]: Paid via credit card + - paragraph [ref=e365]: Jun 10, 2026 8:39 AM + - generic [ref=e366]: + - generic [ref=e368]: Order lines + - table [ref=e370]: + - rowgroup [ref=e371]: + - row "Image Product Fulfillment Qty Unit price Total" [ref=e372]: + - columnheader "Image" [ref=e373] + - columnheader "Product" [ref=e374] + - columnheader "Fulfillment" [ref=e375] + - columnheader "Qty" [ref=e376] + - columnheader "Unit price" [ref=e377] + - columnheader "Total" [ref=e378] + - rowgroup [ref=e379]: + - 'row "Classic Cotton T-Shirt (M / Navy) SKU: ACME-CTSH-M-NAVY Unfulfilled 2 24.99 EUR 44.99 EUR" [ref=e380]': + - cell [ref=e381]: + - img [ref=e383] + - 'cell "Classic Cotton T-Shirt (M / Navy) SKU: ACME-CTSH-M-NAVY" [ref=e385]': + - paragraph [ref=e386]: Classic Cotton T-Shirt (M / Navy) + - paragraph [ref=e387]: "SKU: ACME-CTSH-M-NAVY" + - cell "Unfulfilled" [ref=e388]: + - generic [ref=e389]: Unfulfilled + - cell "2" [ref=e390] + - cell "24.99 EUR" [ref=e391] + - cell "44.99 EUR" [ref=e392] + - generic [ref=e394]: + - generic [ref=e395]: + - term [ref=e396]: Subtotal + - definition [ref=e397]: 49.98 EUR + - generic [ref=e398]: + - term [ref=e399]: Discount + - definition [ref=e400]: "-4.99 EUR" + - generic [ref=e401]: + - term [ref=e402]: Shipping + - definition [ref=e403]: 4.99 EUR + - generic [ref=e404]: + - term [ref=e405]: Tax + - definition [ref=e406]: 7.98 EUR + - generic [ref=e407]: + - term [ref=e408]: Total + - definition [ref=e409]: 49.98 EUR + - generic [ref=e410]: + - generic [ref=e411]: Payment details + - generic [ref=e413]: + - generic [ref=e414]: + - paragraph [ref=e415]: Credit Card + - paragraph [ref=e416]: "49.98 EUR - Ref: mock_kfkqps3gwqkjvtny" + - generic [ref=e417]: Captured + - generic [ref=e418]: + - generic [ref=e419]: + - generic [ref=e420]: Customer + - paragraph [ref=e421]: Guest + - paragraph [ref=e422]: erika.musterfrau@example.com + - generic [ref=e423]: + - generic [ref=e424]: Shipping address + - generic [ref=e425]: + - paragraph [ref=e426]: Erika Musterfrau + - paragraph [ref=e427]: Musterstrasse 12 + - paragraph [ref=e428]: 10115 Berlin + - paragraph [ref=e429]: Germany + - generic [ref=e430]: + - generic [ref=e431]: Billing address + - generic [ref=e432]: + - paragraph [ref=e433]: Erika Musterfrau + - paragraph [ref=e434]: Musterstrasse 12 + - paragraph [ref=e435]: 10115 Berlin + - paragraph [ref=e436]: Germany + - dialog [ref=e437]: + - generic [ref=e438]: + - generic [ref=e439]: Create fulfillment + - generic [ref=e441]: + - checkbox [ref=e442] + - generic [ref=e444]: + - paragraph [ref=e445]: Classic Cotton T-Shirt (M / Navy) + - paragraph [ref=e446]: 2 unfulfilled + - spinbutton [ref=e448]: "2" + - generic [ref=e449]: + - generic [ref=e450]: Tracking company + - textbox "Tracking company" [ref=e452]: + - /placeholder: UPS, FedEx, DHL... + - generic [ref=e453]: + - generic [ref=e454]: Tracking number + - textbox "Tracking number" [ref=e456] + - generic [ref=e457]: + - generic [ref=e458]: Tracking URL + - textbox "Tracking URL" [ref=e460]: + - /placeholder: https:// + - generic [ref=e461]: + - button "Cancel" [ref=e463] + - button "Create fulfillment" [ref=e464]: + - img [ref=e466] + - generic [ref=e469]: Create fulfillment + - button "Close modal" [ref=e472]: + - img [ref=e473] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-42-13-396Z.yml b/.playwright-mcp/page-2026-06-10T08-42-13-396Z.yml new file mode 100644 index 00000000..d9e54469 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-42-13-396Z.yml @@ -0,0 +1,206 @@ +- generic [ref=e249]: + - link "Skip to main content" [ref=e250] [cursor=pointer]: + - /url: "#main-content" + - complementary "Admin navigation" [ref=e251]: + - link "Shop" [ref=e253] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e256] + - generic [ref=e258]: Shop + - navigation [ref=e259]: + - link "Dashboard" [ref=e260] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e261] + - text: Dashboard + - paragraph [ref=e263]: Products + - link "Products" [ref=e264] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e265] + - text: Products + - link "Collections" [ref=e267] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e268] + - text: Collections + - link "Inventory" [ref=e270] [cursor=pointer]: + - /url: http://shop.test/admin/inventory + - img [ref=e271] + - text: Inventory + - paragraph [ref=e273]: Orders + - link "Orders" [ref=e274] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e275] + - text: Orders + - paragraph [ref=e277]: Customers + - link "Customers" [ref=e278] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e279] + - text: Customers + - paragraph [ref=e281]: Discounts + - link "Discounts" [ref=e282] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e283] + - text: Discounts + - paragraph [ref=e286]: Content + - link "Pages" [ref=e287] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e288] + - text: Pages + - link "Navigation" [ref=e290] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e291] + - text: Navigation + - link "Themes" [ref=e293] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e294] + - text: Themes + - link "Analytics" [ref=e296] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e297] + - text: Analytics + - link "Settings" [ref=e300] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e301] + - text: Settings + - link "Apps" [ref=e304] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e305] + - text: Apps + - link "Developers" [ref=e307] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e308] + - text: Developers + - generic [ref=e310]: + - banner [ref=e311]: + - button "Acme Fashion" [ref=e313]: + - generic [ref=e314]: Acme Fashion + - img [ref=e315] + - button "AU Admin User" [ref=e318]: + - generic [ref=e321]: AU + - generic [ref=e322]: Admin User + - img [ref=e324] + - main [ref=e326]: + - generic [ref=e327]: + - generic [ref=e328]: + - generic [ref=e329]: + - link "Home" [ref=e330] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e331] + - generic [ref=e333]: + - link "Orders" [ref=e334] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e335] + - generic [ref=e338]: "#1016" + - generic [ref=e339]: + - generic [ref=e340]: + - generic [ref=e341]: + - generic [ref=e342]: + - heading "#1016" [level=1] [ref=e343] + - generic [ref=e344]: Paid + - generic [ref=e345]: Unfulfilled + - paragraph [ref=e346]: Jun 10, 2026 8:39 AM + - generic [ref=e347]: + - button "Create fulfillment" [ref=e349] + - button "Refund" [ref=e351] + - button "Cancel order" [ref=e353] + - generic [ref=e354]: + - generic [ref=e355]: Timeline + - list [ref=e356]: + - listitem [ref=e357]: + - paragraph [ref=e359]: Order placed + - paragraph [ref=e360]: Jun 10, 2026 8:39 AM + - listitem [ref=e361]: + - paragraph [ref=e363]: Payment received + - paragraph [ref=e364]: Paid via credit card + - paragraph [ref=e365]: Jun 10, 2026 8:39 AM + - generic [ref=e366]: + - generic [ref=e368]: Order lines + - table [ref=e370]: + - rowgroup [ref=e371]: + - row "Image Product Fulfillment Qty Unit price Total" [ref=e372]: + - columnheader "Image" [ref=e373] + - columnheader "Product" [ref=e374] + - columnheader "Fulfillment" [ref=e375] + - columnheader "Qty" [ref=e376] + - columnheader "Unit price" [ref=e377] + - columnheader "Total" [ref=e378] + - rowgroup [ref=e379]: + - 'row "Classic Cotton T-Shirt (M / Navy) SKU: ACME-CTSH-M-NAVY Unfulfilled 2 24.99 EUR 44.99 EUR" [ref=e380]': + - cell [ref=e381]: + - img [ref=e383] + - 'cell "Classic Cotton T-Shirt (M / Navy) SKU: ACME-CTSH-M-NAVY" [ref=e385]': + - paragraph [ref=e386]: Classic Cotton T-Shirt (M / Navy) + - paragraph [ref=e387]: "SKU: ACME-CTSH-M-NAVY" + - cell "Unfulfilled" [ref=e388]: + - generic [ref=e389]: Unfulfilled + - cell "2" [ref=e390] + - cell "24.99 EUR" [ref=e391] + - cell "44.99 EUR" [ref=e392] + - generic [ref=e394]: + - generic [ref=e395]: + - term [ref=e396]: Subtotal + - definition [ref=e397]: 49.98 EUR + - generic [ref=e398]: + - term [ref=e399]: Discount + - definition [ref=e400]: "-4.99 EUR" + - generic [ref=e401]: + - term [ref=e402]: Shipping + - definition [ref=e403]: 4.99 EUR + - generic [ref=e404]: + - term [ref=e405]: Tax + - definition [ref=e406]: 7.98 EUR + - generic [ref=e407]: + - term [ref=e408]: Total + - definition [ref=e409]: 49.98 EUR + - generic [ref=e410]: + - generic [ref=e411]: Payment details + - generic [ref=e413]: + - generic [ref=e414]: + - paragraph [ref=e415]: Credit Card + - paragraph [ref=e416]: "49.98 EUR - Ref: mock_kfkqps3gwqkjvtny" + - generic [ref=e417]: Captured + - generic [ref=e418]: + - generic [ref=e419]: + - generic [ref=e420]: Customer + - paragraph [ref=e421]: Guest + - paragraph [ref=e422]: erika.musterfrau@example.com + - generic [ref=e423]: + - generic [ref=e424]: Shipping address + - generic [ref=e425]: + - paragraph [ref=e426]: Erika Musterfrau + - paragraph [ref=e427]: Musterstrasse 12 + - paragraph [ref=e428]: 10115 Berlin + - paragraph [ref=e429]: Germany + - generic [ref=e430]: + - generic [ref=e431]: Billing address + - generic [ref=e432]: + - paragraph [ref=e433]: Erika Musterfrau + - paragraph [ref=e434]: Musterstrasse 12 + - paragraph [ref=e435]: 10115 Berlin + - paragraph [ref=e436]: Germany + - dialog [ref=e437]: + - generic [ref=e438]: + - generic [ref=e439]: Create fulfillment + - generic [ref=e441]: + - checkbox [checked] [active] [ref=e442]: + - img [ref=e475] + - generic [ref=e444]: + - paragraph [ref=e445]: Classic Cotton T-Shirt (M / Navy) + - paragraph [ref=e446]: 2 unfulfilled + - spinbutton [ref=e448]: "2" + - generic [ref=e449]: + - generic [ref=e450]: Tracking company + - textbox "Tracking company" [ref=e452]: + - /placeholder: UPS, FedEx, DHL... + - generic [ref=e453]: + - generic [ref=e454]: Tracking number + - textbox "Tracking number" [ref=e456] + - generic [ref=e457]: + - generic [ref=e458]: Tracking URL + - textbox "Tracking URL" [ref=e460]: + - /placeholder: https:// + - generic [ref=e461]: + - button "Cancel" [ref=e463] + - button "Create fulfillment" [ref=e464]: + - img [ref=e466] + - generic [ref=e469]: Create fulfillment + - button "Close modal" [ref=e472]: + - img [ref=e473] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-42-26-136Z.yml b/.playwright-mcp/page-2026-06-10T08-42-26-136Z.yml new file mode 100644 index 00000000..a9f5f21a --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-42-26-136Z.yml @@ -0,0 +1,194 @@ +- generic [active] [ref=e249]: + - link "Skip to main content" [ref=e250] [cursor=pointer]: + - /url: "#main-content" + - complementary "Admin navigation" [ref=e251]: + - link "Shop" [ref=e253] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e256] + - generic [ref=e258]: Shop + - navigation [ref=e259]: + - link "Dashboard" [ref=e260] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e261] + - text: Dashboard + - paragraph [ref=e263]: Products + - link "Products" [ref=e264] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e265] + - text: Products + - link "Collections" [ref=e267] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e268] + - text: Collections + - link "Inventory" [ref=e270] [cursor=pointer]: + - /url: http://shop.test/admin/inventory + - img [ref=e271] + - text: Inventory + - paragraph [ref=e273]: Orders + - link "Orders" [ref=e274] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e275] + - text: Orders + - paragraph [ref=e277]: Customers + - link "Customers" [ref=e278] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e279] + - text: Customers + - paragraph [ref=e281]: Discounts + - link "Discounts" [ref=e282] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e283] + - text: Discounts + - paragraph [ref=e286]: Content + - link "Pages" [ref=e287] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e288] + - text: Pages + - link "Navigation" [ref=e290] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e291] + - text: Navigation + - link "Themes" [ref=e293] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e294] + - text: Themes + - link "Analytics" [ref=e296] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e297] + - text: Analytics + - link "Settings" [ref=e300] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e301] + - text: Settings + - link "Apps" [ref=e304] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e305] + - text: Apps + - link "Developers" [ref=e307] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e308] + - text: Developers + - generic [ref=e310]: + - banner [ref=e311]: + - button "Acme Fashion" [ref=e313]: + - generic [ref=e314]: Acme Fashion + - img [ref=e315] + - button "AU Admin User" [ref=e318]: + - generic [ref=e321]: AU + - generic [ref=e322]: Admin User + - img [ref=e324] + - main [ref=e326]: + - generic [ref=e327]: + - generic [ref=e328]: + - generic [ref=e329]: + - link "Home" [ref=e330] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e331] + - generic [ref=e333]: + - link "Orders" [ref=e334] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e335] + - generic [ref=e338]: "#1016" + - generic [ref=e339]: + - generic [ref=e340]: + - generic [ref=e341]: + - generic [ref=e342]: + - heading "#1016" [level=1] [ref=e343] + - generic [ref=e344]: Paid + - generic [ref=e345]: Fulfilled + - paragraph [ref=e346]: Jun 10, 2026 8:39 AM + - button "Refund" [ref=e351] + - generic [ref=e354]: + - generic [ref=e355]: Timeline + - list [ref=e356]: + - listitem [ref=e357]: + - paragraph [ref=e359]: Order placed + - paragraph [ref=e360]: Jun 10, 2026 8:39 AM + - listitem [ref=e361]: + - paragraph [ref=e363]: Payment received + - paragraph [ref=e364]: Paid via credit card + - paragraph [ref=e365]: Jun 10, 2026 8:39 AM + - listitem [ref=e477]: + - paragraph [ref=e479]: Fulfillment created + - paragraph [ref=e480]: "Tracking: DHL JD014600003828332174" + - paragraph [ref=e481]: Jun 10, 2026 8:42 AM + - generic [ref=e482]: + - generic [ref=e483]: + - generic [ref=e484]: + - generic [ref=e485]: "Fulfillment #8" + - generic [ref=e486]: Pending + - button "Mark as shipped" [ref=e488]: + - img [ref=e490] + - generic [ref=e493]: Mark as shipped + - paragraph [ref=e494]: DHL JD014600003828332174 + - list [ref=e495]: + - listitem [ref=e496]: 2 x Classic Cotton T-Shirt (M / Navy) + - generic [ref=e366]: + - generic [ref=e368]: Order lines + - table [ref=e370]: + - rowgroup [ref=e371]: + - row "Image Product Fulfillment Qty Unit price Total" [ref=e372]: + - columnheader "Image" [ref=e373] + - columnheader "Product" [ref=e374] + - columnheader "Fulfillment" [ref=e375] + - columnheader "Qty" [ref=e376] + - columnheader "Unit price" [ref=e377] + - columnheader "Total" [ref=e378] + - rowgroup [ref=e379]: + - 'row "Classic Cotton T-Shirt (M / Navy) SKU: ACME-CTSH-M-NAVY Fulfilled 2 24.99 EUR 44.99 EUR" [ref=e497]': + - cell [ref=e381]: + - img [ref=e383] + - 'cell "Classic Cotton T-Shirt (M / Navy) SKU: ACME-CTSH-M-NAVY" [ref=e385]': + - paragraph [ref=e386]: Classic Cotton T-Shirt (M / Navy) + - paragraph [ref=e387]: "SKU: ACME-CTSH-M-NAVY" + - cell "Fulfilled" [ref=e498]: + - generic [ref=e389]: Fulfilled + - cell "2" [ref=e390] + - cell "24.99 EUR" [ref=e391] + - cell "44.99 EUR" [ref=e392] + - generic [ref=e394]: + - generic [ref=e395]: + - term [ref=e396]: Subtotal + - definition [ref=e397]: 49.98 EUR + - generic [ref=e398]: + - term [ref=e399]: Discount + - definition [ref=e400]: "-4.99 EUR" + - generic [ref=e401]: + - term [ref=e402]: Shipping + - definition [ref=e403]: 4.99 EUR + - generic [ref=e404]: + - term [ref=e405]: Tax + - definition [ref=e406]: 7.98 EUR + - generic [ref=e407]: + - term [ref=e408]: Total + - definition [ref=e409]: 49.98 EUR + - generic [ref=e410]: + - generic [ref=e411]: Payment details + - generic [ref=e413]: + - generic [ref=e414]: + - paragraph [ref=e415]: Credit Card + - paragraph [ref=e416]: "49.98 EUR - Ref: mock_kfkqps3gwqkjvtny" + - generic [ref=e417]: Captured + - generic [ref=e418]: + - generic [ref=e419]: + - generic [ref=e420]: Customer + - paragraph [ref=e421]: Guest + - paragraph [ref=e422]: erika.musterfrau@example.com + - generic [ref=e423]: + - generic [ref=e424]: Shipping address + - generic [ref=e425]: + - paragraph [ref=e426]: Erika Musterfrau + - paragraph [ref=e427]: Musterstrasse 12 + - paragraph [ref=e428]: 10115 Berlin + - paragraph [ref=e429]: Germany + - generic [ref=e430]: + - generic [ref=e431]: Billing address + - generic [ref=e432]: + - paragraph [ref=e433]: Erika Musterfrau + - paragraph [ref=e434]: Musterstrasse 12 + - paragraph [ref=e435]: 10115 Berlin + - paragraph [ref=e436]: Germany + - generic [ref=e499]: + - paragraph [ref=e500]: Fulfillment created + - button "Dismiss" [ref=e501] [cursor=pointer]: + - img [ref=e502] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-42-31-688Z.yml b/.playwright-mcp/page-2026-06-10T08-42-31-688Z.yml new file mode 100644 index 00000000..9e925574 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-42-31-688Z.yml @@ -0,0 +1,190 @@ +- generic [active] [ref=e249]: + - link "Skip to main content" [ref=e250] [cursor=pointer]: + - /url: "#main-content" + - complementary "Admin navigation" [ref=e251]: + - link "Shop" [ref=e253] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e256] + - generic [ref=e258]: Shop + - navigation [ref=e259]: + - link "Dashboard" [ref=e260] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e261] + - text: Dashboard + - paragraph [ref=e263]: Products + - link "Products" [ref=e264] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e265] + - text: Products + - link "Collections" [ref=e267] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e268] + - text: Collections + - link "Inventory" [ref=e270] [cursor=pointer]: + - /url: http://shop.test/admin/inventory + - img [ref=e271] + - text: Inventory + - paragraph [ref=e273]: Orders + - link "Orders" [ref=e274] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e275] + - text: Orders + - paragraph [ref=e277]: Customers + - link "Customers" [ref=e278] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e279] + - text: Customers + - paragraph [ref=e281]: Discounts + - link "Discounts" [ref=e282] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e283] + - text: Discounts + - paragraph [ref=e286]: Content + - link "Pages" [ref=e287] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e288] + - text: Pages + - link "Navigation" [ref=e290] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e291] + - text: Navigation + - link "Themes" [ref=e293] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e294] + - text: Themes + - link "Analytics" [ref=e296] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e297] + - text: Analytics + - link "Settings" [ref=e300] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e301] + - text: Settings + - link "Apps" [ref=e304] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e305] + - text: Apps + - link "Developers" [ref=e307] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e308] + - text: Developers + - generic [ref=e310]: + - banner [ref=e311]: + - button "Acme Fashion" [ref=e313]: + - generic [ref=e314]: Acme Fashion + - img [ref=e315] + - button "AU Admin User" [ref=e318]: + - generic [ref=e321]: AU + - generic [ref=e322]: Admin User + - img [ref=e324] + - main [ref=e326]: + - generic [ref=e327]: + - generic [ref=e328]: + - generic [ref=e329]: + - link "Home" [ref=e330] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e331] + - generic [ref=e333]: + - link "Orders" [ref=e334] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e335] + - generic [ref=e338]: "#1016" + - generic [ref=e339]: + - generic [ref=e340]: + - generic [ref=e341]: + - generic [ref=e342]: + - heading "#1016" [level=1] [ref=e343] + - generic [ref=e344]: Paid + - generic [ref=e345]: Fulfilled + - paragraph [ref=e346]: Jun 10, 2026 8:39 AM + - button "Refund" [ref=e351] + - generic [ref=e354]: + - generic [ref=e355]: Timeline + - list [ref=e356]: + - listitem [ref=e357]: + - paragraph [ref=e359]: Order placed + - paragraph [ref=e360]: Jun 10, 2026 8:39 AM + - listitem [ref=e361]: + - paragraph [ref=e363]: Payment received + - paragraph [ref=e364]: Paid via credit card + - paragraph [ref=e365]: Jun 10, 2026 8:39 AM + - listitem [ref=e477]: + - paragraph [ref=e479]: Fulfillment created + - paragraph [ref=e480]: "Tracking: DHL JD014600003828332174" + - paragraph [ref=e481]: Jun 10, 2026 8:42 AM + - generic [ref=e482]: + - generic [ref=e483]: + - generic [ref=e484]: + - generic [ref=e485]: "Fulfillment #8" + - generic [ref=e486]: Pending + - button "Mark as shipped" [ref=e488]: + - img [ref=e490] + - generic [ref=e493]: Mark as shipped + - paragraph [ref=e494]: DHL JD014600003828332174 + - list [ref=e495]: + - listitem [ref=e496]: 2 x Classic Cotton T-Shirt (M / Navy) + - generic [ref=e366]: + - generic [ref=e368]: Order lines + - table [ref=e370]: + - rowgroup [ref=e371]: + - row "Image Product Fulfillment Qty Unit price Total" [ref=e372]: + - columnheader "Image" [ref=e373] + - columnheader "Product" [ref=e374] + - columnheader "Fulfillment" [ref=e375] + - columnheader "Qty" [ref=e376] + - columnheader "Unit price" [ref=e377] + - columnheader "Total" [ref=e378] + - rowgroup [ref=e379]: + - 'row "Classic Cotton T-Shirt (M / Navy) SKU: ACME-CTSH-M-NAVY Fulfilled 2 24.99 EUR 44.99 EUR" [ref=e497]': + - cell [ref=e381]: + - img [ref=e383] + - 'cell "Classic Cotton T-Shirt (M / Navy) SKU: ACME-CTSH-M-NAVY" [ref=e385]': + - paragraph [ref=e386]: Classic Cotton T-Shirt (M / Navy) + - paragraph [ref=e387]: "SKU: ACME-CTSH-M-NAVY" + - cell "Fulfilled" [ref=e498]: + - generic [ref=e389]: Fulfilled + - cell "2" [ref=e390] + - cell "24.99 EUR" [ref=e391] + - cell "44.99 EUR" [ref=e392] + - generic [ref=e394]: + - generic [ref=e395]: + - term [ref=e396]: Subtotal + - definition [ref=e397]: 49.98 EUR + - generic [ref=e398]: + - term [ref=e399]: Discount + - definition [ref=e400]: "-4.99 EUR" + - generic [ref=e401]: + - term [ref=e402]: Shipping + - definition [ref=e403]: 4.99 EUR + - generic [ref=e404]: + - term [ref=e405]: Tax + - definition [ref=e406]: 7.98 EUR + - generic [ref=e407]: + - term [ref=e408]: Total + - definition [ref=e409]: 49.98 EUR + - generic [ref=e410]: + - generic [ref=e411]: Payment details + - generic [ref=e413]: + - generic [ref=e414]: + - paragraph [ref=e415]: Credit Card + - paragraph [ref=e416]: "49.98 EUR - Ref: mock_kfkqps3gwqkjvtny" + - generic [ref=e417]: Captured + - generic [ref=e418]: + - generic [ref=e419]: + - generic [ref=e420]: Customer + - paragraph [ref=e421]: Guest + - paragraph [ref=e422]: erika.musterfrau@example.com + - generic [ref=e423]: + - generic [ref=e424]: Shipping address + - generic [ref=e425]: + - paragraph [ref=e426]: Erika Musterfrau + - paragraph [ref=e427]: Musterstrasse 12 + - paragraph [ref=e428]: 10115 Berlin + - paragraph [ref=e429]: Germany + - generic [ref=e430]: + - generic [ref=e431]: Billing address + - generic [ref=e432]: + - paragraph [ref=e433]: Erika Musterfrau + - paragraph [ref=e434]: Musterstrasse 12 + - paragraph [ref=e435]: 10115 Berlin + - paragraph [ref=e436]: Germany \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-43-00-698Z.yml b/.playwright-mcp/page-2026-06-10T08-43-00-698Z.yml new file mode 100644 index 00000000..2e262c65 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-43-00-698Z.yml @@ -0,0 +1,183 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - complementary "Admin navigation" [ref=e3]: + - link "Shop" [ref=e5] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e8] + - generic [ref=e10]: Shop + - navigation [ref=e11]: + - link "Dashboard" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e13] + - text: Dashboard + - paragraph [ref=e15]: Products + - link "Products" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e17] + - text: Products + - link "Collections" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e20] + - text: Collections + - link "Inventory" [ref=e22] [cursor=pointer]: + - /url: http://shop.test/admin/inventory + - img [ref=e23] + - text: Inventory + - paragraph [ref=e25]: Orders + - link "Orders" [ref=e26] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e27] + - text: Orders + - paragraph [ref=e29]: Customers + - link "Customers" [ref=e30] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e31] + - text: Customers + - paragraph [ref=e33]: Discounts + - link "Discounts" [ref=e34] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e35] + - text: Discounts + - paragraph [ref=e38]: Content + - link "Pages" [ref=e39] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e40] + - text: Pages + - link "Navigation" [ref=e42] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e43] + - text: Navigation + - link "Themes" [ref=e45] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e46] + - text: Themes + - link "Analytics" [ref=e48] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e49] + - text: Analytics + - link "Settings" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e53] + - text: Settings + - link "Apps" [ref=e56] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e57] + - text: Apps + - link "Developers" [ref=e59] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e60] + - text: Developers + - generic [ref=e62]: + - banner [ref=e63]: + - button "Acme Fashion" [ref=e65]: + - generic [ref=e66]: Acme Fashion + - img [ref=e67] + - button "AU Admin User" [ref=e70]: + - generic [ref=e73]: AU + - generic [ref=e74]: Admin User + - img [ref=e76] + - main [ref=e78]: + - generic [ref=e79]: + - generic [ref=e80]: + - generic [ref=e81]: + - link "Home" [ref=e82] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e83] + - generic [ref=e85]: + - link "Orders" [ref=e86] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e87] + - generic [ref=e90]: "#1005" + - generic [ref=e91]: + - generic [ref=e92]: + - generic [ref=e93]: + - generic [ref=e94]: + - heading "#1005" [level=1] [ref=e95] + - generic [ref=e96]: Pending + - generic [ref=e97]: Unfulfilled + - paragraph [ref=e98]: Jun 10, 2026 6:32 AM + - generic [ref=e99]: + - button "Confirm payment" [ref=e100]: + - img [ref=e102] + - generic [ref=e105]: Confirm payment + - button "Cancel order" [ref=e107] + - generic [ref=e108]: + - img [ref=e110] + - generic [ref=e113]: + - generic [ref=e114]: Cannot create fulfillment. + - generic [ref=e115]: "Payment must be confirmed before items can be fulfilled. Current financial status: pending." + - generic [ref=e116]: + - generic [ref=e117]: Timeline + - list [ref=e118]: + - listitem [ref=e119]: + - paragraph [ref=e121]: Order placed + - paragraph [ref=e122]: Jun 10, 2026 6:32 AM + - generic [ref=e123]: + - generic [ref=e125]: Order lines + - table [ref=e127]: + - rowgroup [ref=e128]: + - row "Image Product Fulfillment Qty Unit price Total" [ref=e129]: + - columnheader "Image" [ref=e130] + - columnheader "Product" [ref=e131] + - columnheader "Fulfillment" [ref=e132] + - columnheader "Qty" [ref=e133] + - columnheader "Unit price" [ref=e134] + - columnheader "Total" [ref=e135] + - rowgroup [ref=e136]: + - 'row "Leather Belt (S/M / Black) SKU: ACME-BELT-SM-BLACK Unfulfilled 1 34.99 EUR 34.99 EUR" [ref=e137]': + - cell [ref=e138]: + - img [ref=e140] + - 'cell "Leather Belt (S/M / Black) SKU: ACME-BELT-SM-BLACK" [ref=e142]': + - paragraph [ref=e143]: Leather Belt (S/M / Black) + - paragraph [ref=e144]: "SKU: ACME-BELT-SM-BLACK" + - cell "Unfulfilled" [ref=e145]: + - generic [ref=e146]: Unfulfilled + - cell "1" [ref=e147] + - cell "34.99 EUR" [ref=e148] + - cell "34.99 EUR" [ref=e149] + - generic [ref=e151]: + - generic [ref=e152]: + - term [ref=e153]: Subtotal + - definition [ref=e154]: 34.99 EUR + - generic [ref=e155]: + - term [ref=e156]: Shipping + - definition [ref=e157]: 4.99 EUR + - generic [ref=e158]: + - term [ref=e159]: Tax + - definition [ref=e160]: 5.59 EUR + - generic [ref=e161]: + - term [ref=e162]: Total + - definition [ref=e163]: 39.98 EUR + - generic [ref=e164]: + - generic [ref=e165]: Payment details + - generic [ref=e166]: + - generic [ref=e167]: + - generic [ref=e168]: + - paragraph [ref=e169]: Bank Transfer + - paragraph [ref=e170]: "39.98 EUR - Ref: mock_test_order1005" + - generic [ref=e171]: Pending + - button "Confirm payment" [ref=e172]: + - img [ref=e174] + - generic [ref=e177]: Confirm payment + - generic [ref=e178]: + - generic [ref=e179]: + - generic [ref=e180]: Customer + - paragraph [ref=e181]: Jane Smith + - paragraph [ref=e182]: jane@example.com + - link "View customer" [ref=e183] [cursor=pointer]: + - /url: http://shop.test/admin/customers/2 + - generic [ref=e184]: + - generic [ref=e185]: Shipping address + - generic [ref=e186]: + - paragraph [ref=e187]: Jane Smith + - paragraph [ref=e188]: Schillerstrasse 45 + - paragraph [ref=e189]: 80336 Munich , Bavaria + - paragraph [ref=e190]: Germany + - generic [ref=e191]: + - generic [ref=e192]: Billing address + - generic [ref=e193]: + - paragraph [ref=e194]: Jane Smith + - paragraph [ref=e195]: Schillerstrasse 45 + - paragraph [ref=e196]: 80336 Munich , Bavaria + - paragraph [ref=e197]: Germany \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-43-16-021Z.yml b/.playwright-mcp/page-2026-06-10T08-43-16-021Z.yml new file mode 100644 index 00000000..94219927 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-43-16-021Z.yml @@ -0,0 +1,181 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - complementary "Admin navigation" [ref=e3]: + - link "Shop" [ref=e5] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e8] + - generic [ref=e10]: Shop + - navigation [ref=e11]: + - link "Dashboard" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e13] + - text: Dashboard + - paragraph [ref=e15]: Products + - link "Products" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e17] + - text: Products + - link "Collections" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e20] + - text: Collections + - link "Inventory" [ref=e22] [cursor=pointer]: + - /url: http://shop.test/admin/inventory + - img [ref=e23] + - text: Inventory + - paragraph [ref=e25]: Orders + - link "Orders" [ref=e26] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e27] + - text: Orders + - paragraph [ref=e29]: Customers + - link "Customers" [ref=e30] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e31] + - text: Customers + - paragraph [ref=e33]: Discounts + - link "Discounts" [ref=e34] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e35] + - text: Discounts + - paragraph [ref=e38]: Content + - link "Pages" [ref=e39] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e40] + - text: Pages + - link "Navigation" [ref=e42] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e43] + - text: Navigation + - link "Themes" [ref=e45] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e46] + - text: Themes + - link "Analytics" [ref=e48] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e49] + - text: Analytics + - link "Settings" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e53] + - text: Settings + - link "Apps" [ref=e56] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e57] + - text: Apps + - link "Developers" [ref=e59] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e60] + - text: Developers + - generic [ref=e62]: + - banner [ref=e63]: + - button "Acme Fashion" [ref=e65]: + - generic [ref=e66]: Acme Fashion + - img [ref=e67] + - button "AU Admin User" [ref=e70]: + - generic [ref=e73]: AU + - generic [ref=e74]: Admin User + - img [ref=e76] + - main [ref=e78]: + - generic [ref=e79]: + - generic [ref=e80]: + - generic [ref=e81]: + - link "Home" [ref=e82] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e83] + - generic [ref=e85]: + - link "Orders" [ref=e86] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e87] + - generic [ref=e90]: "#1005" + - generic [ref=e91]: + - generic [ref=e92]: + - generic [ref=e93]: + - generic [ref=e94]: + - heading "#1005" [level=1] [ref=e95] + - generic [ref=e96]: Paid + - generic [ref=e97]: Unfulfilled + - paragraph [ref=e98]: Jun 10, 2026 6:32 AM + - generic [ref=e99]: + - button "Create fulfillment" [ref=e199] + - button "Refund" [ref=e201] + - button "Cancel order" [ref=e107] + - generic [ref=e116]: + - generic [ref=e117]: Timeline + - list [ref=e118]: + - listitem [ref=e119]: + - paragraph [ref=e121]: Order placed + - paragraph [ref=e122]: Jun 10, 2026 6:32 AM + - listitem [ref=e202]: + - paragraph [ref=e204]: Payment received + - paragraph [ref=e205]: Paid via bank transfer + - paragraph [ref=e206]: Jun 10, 2026 8:32 AM + - generic [ref=e123]: + - generic [ref=e125]: Order lines + - table [ref=e127]: + - rowgroup [ref=e128]: + - row "Image Product Fulfillment Qty Unit price Total" [ref=e129]: + - columnheader "Image" [ref=e130] + - columnheader "Product" [ref=e131] + - columnheader "Fulfillment" [ref=e132] + - columnheader "Qty" [ref=e133] + - columnheader "Unit price" [ref=e134] + - columnheader "Total" [ref=e135] + - rowgroup [ref=e136]: + - 'row "Leather Belt (S/M / Black) SKU: ACME-BELT-SM-BLACK Unfulfilled 1 34.99 EUR 34.99 EUR" [ref=e137]': + - cell [ref=e138]: + - img [ref=e140] + - 'cell "Leather Belt (S/M / Black) SKU: ACME-BELT-SM-BLACK" [ref=e142]': + - paragraph [ref=e143]: Leather Belt (S/M / Black) + - paragraph [ref=e144]: "SKU: ACME-BELT-SM-BLACK" + - cell "Unfulfilled" [ref=e145]: + - generic [ref=e146]: Unfulfilled + - cell "1" [ref=e147] + - cell "34.99 EUR" [ref=e148] + - cell "34.99 EUR" [ref=e149] + - generic [ref=e151]: + - generic [ref=e152]: + - term [ref=e153]: Subtotal + - definition [ref=e154]: 34.99 EUR + - generic [ref=e155]: + - term [ref=e156]: Shipping + - definition [ref=e157]: 4.99 EUR + - generic [ref=e158]: + - term [ref=e159]: Tax + - definition [ref=e160]: 5.59 EUR + - generic [ref=e161]: + - term [ref=e162]: Total + - definition [ref=e163]: 39.98 EUR + - generic [ref=e164]: + - generic [ref=e165]: Payment details + - generic [ref=e167]: + - generic [ref=e168]: + - paragraph [ref=e169]: Bank Transfer + - paragraph [ref=e170]: "39.98 EUR - Ref: mock_test_order1005" + - generic [ref=e171]: Captured + - generic [ref=e178]: + - generic [ref=e179]: + - generic [ref=e180]: Customer + - paragraph [ref=e181]: Jane Smith + - paragraph [ref=e182]: jane@example.com + - link "View customer" [ref=e183] [cursor=pointer]: + - /url: http://shop.test/admin/customers/2 + - generic [ref=e184]: + - generic [ref=e185]: Shipping address + - generic [ref=e186]: + - paragraph [ref=e187]: Jane Smith + - paragraph [ref=e188]: Schillerstrasse 45 + - paragraph [ref=e189]: 80336 Munich , Bavaria + - paragraph [ref=e190]: Germany + - generic [ref=e191]: + - generic [ref=e192]: Billing address + - generic [ref=e193]: + - paragraph [ref=e194]: Jane Smith + - paragraph [ref=e195]: Schillerstrasse 45 + - paragraph [ref=e196]: 80336 Munich , Bavaria + - paragraph [ref=e197]: Germany + - generic [ref=e207]: + - paragraph [ref=e208]: Payment confirmed + - button "Dismiss" [ref=e209] [cursor=pointer]: + - img [ref=e210] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-43-22-515Z.yml b/.playwright-mcp/page-2026-06-10T08-43-22-515Z.yml new file mode 100644 index 00000000..cc24dfc6 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-43-22-515Z.yml @@ -0,0 +1,177 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - complementary "Admin navigation" [ref=e3]: + - link "Shop" [ref=e5] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e8] + - generic [ref=e10]: Shop + - navigation [ref=e11]: + - link "Dashboard" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e13] + - text: Dashboard + - paragraph [ref=e15]: Products + - link "Products" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e17] + - text: Products + - link "Collections" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e20] + - text: Collections + - link "Inventory" [ref=e22] [cursor=pointer]: + - /url: http://shop.test/admin/inventory + - img [ref=e23] + - text: Inventory + - paragraph [ref=e25]: Orders + - link "Orders" [ref=e26] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e27] + - text: Orders + - paragraph [ref=e29]: Customers + - link "Customers" [ref=e30] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e31] + - text: Customers + - paragraph [ref=e33]: Discounts + - link "Discounts" [ref=e34] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e35] + - text: Discounts + - paragraph [ref=e38]: Content + - link "Pages" [ref=e39] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e40] + - text: Pages + - link "Navigation" [ref=e42] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e43] + - text: Navigation + - link "Themes" [ref=e45] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e46] + - text: Themes + - link "Analytics" [ref=e48] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e49] + - text: Analytics + - link "Settings" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e53] + - text: Settings + - link "Apps" [ref=e56] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e57] + - text: Apps + - link "Developers" [ref=e59] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e60] + - text: Developers + - generic [ref=e62]: + - banner [ref=e63]: + - button "Acme Fashion" [ref=e65]: + - generic [ref=e66]: Acme Fashion + - img [ref=e67] + - button "AU Admin User" [ref=e70]: + - generic [ref=e73]: AU + - generic [ref=e74]: Admin User + - img [ref=e76] + - main [ref=e78]: + - generic [ref=e79]: + - generic [ref=e80]: + - generic [ref=e81]: + - link "Home" [ref=e82] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e83] + - generic [ref=e85]: + - link "Orders" [ref=e86] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e87] + - generic [ref=e90]: "#1005" + - generic [ref=e91]: + - generic [ref=e92]: + - generic [ref=e93]: + - generic [ref=e94]: + - heading "#1005" [level=1] [ref=e95] + - generic [ref=e96]: Paid + - generic [ref=e97]: Unfulfilled + - paragraph [ref=e98]: Jun 10, 2026 6:32 AM + - generic [ref=e99]: + - button "Create fulfillment" [ref=e199] + - button "Refund" [ref=e201] + - button "Cancel order" [ref=e107] + - generic [ref=e116]: + - generic [ref=e117]: Timeline + - list [ref=e118]: + - listitem [ref=e119]: + - paragraph [ref=e121]: Order placed + - paragraph [ref=e122]: Jun 10, 2026 6:32 AM + - listitem [ref=e202]: + - paragraph [ref=e204]: Payment received + - paragraph [ref=e205]: Paid via bank transfer + - paragraph [ref=e206]: Jun 10, 2026 8:32 AM + - generic [ref=e123]: + - generic [ref=e125]: Order lines + - table [ref=e127]: + - rowgroup [ref=e128]: + - row "Image Product Fulfillment Qty Unit price Total" [ref=e129]: + - columnheader "Image" [ref=e130] + - columnheader "Product" [ref=e131] + - columnheader "Fulfillment" [ref=e132] + - columnheader "Qty" [ref=e133] + - columnheader "Unit price" [ref=e134] + - columnheader "Total" [ref=e135] + - rowgroup [ref=e136]: + - 'row "Leather Belt (S/M / Black) SKU: ACME-BELT-SM-BLACK Unfulfilled 1 34.99 EUR 34.99 EUR" [ref=e137]': + - cell [ref=e138]: + - img [ref=e140] + - 'cell "Leather Belt (S/M / Black) SKU: ACME-BELT-SM-BLACK" [ref=e142]': + - paragraph [ref=e143]: Leather Belt (S/M / Black) + - paragraph [ref=e144]: "SKU: ACME-BELT-SM-BLACK" + - cell "Unfulfilled" [ref=e145]: + - generic [ref=e146]: Unfulfilled + - cell "1" [ref=e147] + - cell "34.99 EUR" [ref=e148] + - cell "34.99 EUR" [ref=e149] + - generic [ref=e151]: + - generic [ref=e152]: + - term [ref=e153]: Subtotal + - definition [ref=e154]: 34.99 EUR + - generic [ref=e155]: + - term [ref=e156]: Shipping + - definition [ref=e157]: 4.99 EUR + - generic [ref=e158]: + - term [ref=e159]: Tax + - definition [ref=e160]: 5.59 EUR + - generic [ref=e161]: + - term [ref=e162]: Total + - definition [ref=e163]: 39.98 EUR + - generic [ref=e164]: + - generic [ref=e165]: Payment details + - generic [ref=e167]: + - generic [ref=e168]: + - paragraph [ref=e169]: Bank Transfer + - paragraph [ref=e170]: "39.98 EUR - Ref: mock_test_order1005" + - generic [ref=e171]: Captured + - generic [ref=e178]: + - generic [ref=e179]: + - generic [ref=e180]: Customer + - paragraph [ref=e181]: Jane Smith + - paragraph [ref=e182]: jane@example.com + - link "View customer" [ref=e183] [cursor=pointer]: + - /url: http://shop.test/admin/customers/2 + - generic [ref=e184]: + - generic [ref=e185]: Shipping address + - generic [ref=e186]: + - paragraph [ref=e187]: Jane Smith + - paragraph [ref=e188]: Schillerstrasse 45 + - paragraph [ref=e189]: 80336 Munich , Bavaria + - paragraph [ref=e190]: Germany + - generic [ref=e191]: + - generic [ref=e192]: Billing address + - generic [ref=e193]: + - paragraph [ref=e194]: Jane Smith + - paragraph [ref=e195]: Schillerstrasse 45 + - paragraph [ref=e196]: 80336 Munich , Bavaria + - paragraph [ref=e197]: Germany \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-43-30-273Z.yml b/.playwright-mcp/page-2026-06-10T08-43-30-273Z.yml new file mode 100644 index 00000000..6319f15b --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-43-30-273Z.yml @@ -0,0 +1,351 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - complementary "Admin navigation" [ref=e3]: + - link "Shop" [ref=e5] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e8] + - generic [ref=e10]: Shop + - navigation [ref=e11]: + - link "Dashboard" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e13] + - text: Dashboard + - paragraph [ref=e15]: Products + - link "Products" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e17] + - text: Products + - link "Collections" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e20] + - text: Collections + - link "Inventory" [ref=e22] [cursor=pointer]: + - /url: http://shop.test/admin/inventory + - img [ref=e23] + - text: Inventory + - paragraph [ref=e25]: Orders + - link "Orders" [ref=e26] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e27] + - text: Orders + - paragraph [ref=e29]: Customers + - link "Customers" [ref=e30] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e31] + - text: Customers + - paragraph [ref=e33]: Discounts + - link "Discounts" [ref=e34] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e35] + - text: Discounts + - paragraph [ref=e38]: Content + - link "Pages" [ref=e39] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e40] + - text: Pages + - link "Navigation" [ref=e42] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e43] + - text: Navigation + - link "Themes" [ref=e45] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e46] + - text: Themes + - link "Analytics" [ref=e48] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e49] + - text: Analytics + - link "Settings" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e53] + - text: Settings + - link "Apps" [ref=e56] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e57] + - text: Apps + - link "Developers" [ref=e59] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e60] + - text: Developers + - generic [ref=e62]: + - banner [ref=e63]: + - button "Acme Fashion" [ref=e65]: + - generic [ref=e66]: Acme Fashion + - img [ref=e67] + - button "AU Admin User" [ref=e70]: + - generic [ref=e73]: AU + - generic [ref=e74]: Admin User + - img [ref=e76] + - main [ref=e78]: + - generic [ref=e79]: + - generic [ref=e80]: + - generic [ref=e81]: + - link "Home" [ref=e82] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e83] + - generic [ref=e86]: Products + - generic [ref=e87]: + - heading "Products" [level=1] [ref=e88] + - link "Add product" [ref=e89] [cursor=pointer]: + - /url: http://shop.test/admin/products/create + - img [ref=e90] + - generic [ref=e92]: Add product + - tablist [ref=e93]: + - tab "All" [selected] [ref=e94] [cursor=pointer] + - tab "Draft" [ref=e95] [cursor=pointer] + - tab "Active" [ref=e96] [cursor=pointer] + - tab "Archived" [ref=e97] [cursor=pointer] + - generic [ref=e98]: + - generic [ref=e99]: + - generic: + - img + - textbox "Search products..." [ref=e100] + - combobox [ref=e102]: + - 'option "Type: All" [selected]' + - option "Accessories" + - option "Gift Cards" + - option "Hoodies" + - option "Jackets" + - option "Pants" + - option "Shoes" + - option "T-Shirts" + - generic [ref=e103]: + - table [ref=e105]: + - rowgroup [ref=e106]: + - row "Select all Image Title Status Inventory Type Vendor Updated" [ref=e107]: + - columnheader "Select all" [ref=e108]: + - checkbox "Select all" [ref=e109] + - columnheader "Image" [ref=e111] + - columnheader "Title" [ref=e112]: + - button "Title" [ref=e113] [cursor=pointer] + - columnheader "Status" [ref=e114] + - columnheader "Inventory" [ref=e115]: + - button "Inventory" [ref=e116] [cursor=pointer] + - columnheader "Type" [ref=e117] + - columnheader "Vendor" [ref=e118] + - columnheader "Updated" [ref=e119]: + - button "Updated" [ref=e120] [cursor=pointer]: + - text: Updated + - img [ref=e121] + - rowgroup [ref=e123]: + - row "Select Leather Belt Leather Belt Active 99 Accessories Acme Accessories 11m ago" [ref=e124]: + - cell "Select Leather Belt" [ref=e125]: + - checkbox "Select Leather Belt" [ref=e126] + - cell [ref=e128]: + - img [ref=e130] + - cell "Leather Belt" [ref=e132]: + - link "Leather Belt" [ref=e133] [cursor=pointer]: + - /url: http://shop.test/admin/products/4/edit + - cell "Active" [ref=e134]: + - generic [ref=e135]: Active + - cell "99" [ref=e136] + - cell "Accessories" [ref=e137] + - cell "Acme Accessories" [ref=e138] + - cell "11m ago" [ref=e139] + - row "Select Wool Scarf Wool Scarf Active 90 Accessories Acme Accessories 11m ago" [ref=e140]: + - cell "Select Wool Scarf" [ref=e141]: + - checkbox "Select Wool Scarf" [ref=e142] + - cell [ref=e144]: + - img [ref=e146] + - cell "Wool Scarf" [ref=e148]: + - link "Wool Scarf" [ref=e149] [cursor=pointer]: + - /url: http://shop.test/admin/products/12/edit + - cell "Active" [ref=e150]: + - generic [ref=e151]: Active + - cell "90" [ref=e152] + - cell "Accessories" [ref=e153] + - cell "Acme Accessories" [ref=e154] + - cell "11m ago" [ref=e155] + - row "Select Canvas Tote Bag Canvas Tote Bag Active 80 Accessories Acme Accessories 11m ago" [ref=e156]: + - cell "Select Canvas Tote Bag" [ref=e157]: + - checkbox "Select Canvas Tote Bag" [ref=e158] + - cell [ref=e160]: + - img [ref=e162] + - cell "Canvas Tote Bag" [ref=e164]: + - link "Canvas Tote Bag" [ref=e165] [cursor=pointer]: + - /url: http://shop.test/admin/products/13/edit + - cell "Active" [ref=e166]: + - generic [ref=e167]: Active + - cell "80" [ref=e168] + - cell "Accessories" [ref=e169] + - cell "Acme Accessories" [ref=e170] + - cell "11m ago" [ref=e171] + - row "Select Bucket Hat Bucket Hat Active 132 Accessories Acme Accessories 11m ago" [ref=e172]: + - cell "Select Bucket Hat" [ref=e173]: + - checkbox "Select Bucket Hat" [ref=e174] + - cell [ref=e176]: + - img [ref=e178] + - cell "Bucket Hat" [ref=e180]: + - link "Bucket Hat" [ref=e181] [cursor=pointer]: + - /url: http://shop.test/admin/products/14/edit + - cell "Active" [ref=e182]: + - generic [ref=e183]: Active + - cell "132" [ref=e184] + - cell "Accessories" [ref=e185] + - cell "Acme Accessories" [ref=e186] + - cell "11m ago" [ref=e187] + - row "Select Gift Card Gift Card Active 29,997 Gift Cards Acme Fashion 11m ago" [ref=e188]: + - cell "Select Gift Card" [ref=e189]: + - checkbox "Select Gift Card" [ref=e190] + - cell [ref=e192]: + - img [ref=e194] + - cell "Gift Card" [ref=e196]: + - link "Gift Card" [ref=e197] [cursor=pointer]: + - /url: http://shop.test/admin/products/19/edit + - cell "Active" [ref=e198]: + - generic [ref=e199]: Active + - cell "29,997" [ref=e200] + - cell "Gift Cards" [ref=e201] + - cell "Acme Fashion" [ref=e202] + - cell "11m ago" [ref=e203] + - row "Select Organic Hoodie Organic Hoodie Active 80 Hoodies Acme Basics 11m ago" [ref=e204]: + - cell "Select Organic Hoodie" [ref=e205]: + - checkbox "Select Organic Hoodie" [ref=e206] + - cell [ref=e208]: + - img [ref=e210] + - cell "Organic Hoodie" [ref=e212]: + - link "Organic Hoodie" [ref=e213] [cursor=pointer]: + - /url: http://shop.test/admin/products/3/edit + - cell "Active" [ref=e214]: + - generic [ref=e215]: Active + - cell "80" [ref=e216] + - cell "Hoodies" [ref=e217] + - cell "Acme Basics" [ref=e218] + - cell "11m ago" [ref=e219] + - row "Select Unreleased Winter Jacket Unreleased Winter Jacket Draft 0 Jackets Acme Outerwear 11m ago" [ref=e220]: + - cell "Select Unreleased Winter Jacket" [ref=e221]: + - checkbox "Select Unreleased Winter Jacket" [ref=e222] + - cell [ref=e224]: + - img [ref=e226] + - cell "Unreleased Winter Jacket" [ref=e228]: + - link "Unreleased Winter Jacket" [ref=e229] [cursor=pointer]: + - /url: http://shop.test/admin/products/15/edit + - cell "Draft" [ref=e230]: + - generic [ref=e231]: Draft + - cell "0" [ref=e232] + - cell "Jackets" [ref=e233] + - cell "Acme Outerwear" [ref=e234] + - cell "11m ago" [ref=e235] + - row "Select Discontinued Raincoat Discontinued Raincoat Archived 6 Jackets Acme Outerwear 11m ago" [ref=e236]: + - cell "Select Discontinued Raincoat" [ref=e237]: + - checkbox "Select Discontinued Raincoat" [ref=e238] + - cell [ref=e240]: + - img [ref=e242] + - cell "Discontinued Raincoat" [ref=e244]: + - link "Discontinued Raincoat" [ref=e245] [cursor=pointer]: + - /url: http://shop.test/admin/products/16/edit + - cell "Archived" [ref=e246]: + - generic [ref=e247]: Archived + - cell "6" [ref=e248] + - cell "Jackets" [ref=e249] + - cell "Acme Outerwear" [ref=e250] + - cell "11m ago" [ref=e251] + - row "Select Backorder Denim Jacket Backorder Denim Jacket Active 0 Jackets Acme Denim 11m ago" [ref=e252]: + - cell "Select Backorder Denim Jacket" [ref=e253]: + - checkbox "Select Backorder Denim Jacket" [ref=e254] + - cell [ref=e256]: + - img [ref=e258] + - cell "Backorder Denim Jacket" [ref=e260]: + - link "Backorder Denim Jacket" [ref=e261] [cursor=pointer]: + - /url: http://shop.test/admin/products/18/edit + - cell "Active" [ref=e262]: + - generic [ref=e263]: Active + - cell "0" [ref=e264] + - cell "Jackets" [ref=e265] + - cell "Acme Denim" [ref=e266] + - cell "11m ago" [ref=e267] + - row "Select Cashmere Overcoat Cashmere Overcoat Active 18 Jackets Acme Premium 11m ago" [ref=e268]: + - cell "Select Cashmere Overcoat" [ref=e269]: + - checkbox "Select Cashmere Overcoat" [ref=e270] + - cell [ref=e272]: + - img [ref=e274] + - cell "Cashmere Overcoat" [ref=e276]: + - link "Cashmere Overcoat" [ref=e277] [cursor=pointer]: + - /url: http://shop.test/admin/products/20/edit + - cell "Active" [ref=e278]: + - generic [ref=e279]: Active + - cell "18" [ref=e280] + - cell "Jackets" [ref=e281] + - cell "Acme Premium" [ref=e282] + - cell "11m ago" [ref=e283] + - row "Select Premium Slim Fit Jeans Premium Slim Fit Jeans Active 80 Pants Acme Denim 11m ago" [ref=e284]: + - cell "Select Premium Slim Fit Jeans" [ref=e285]: + - checkbox "Select Premium Slim Fit Jeans" [ref=e286] + - cell [ref=e288]: + - img [ref=e290] + - cell "Premium Slim Fit Jeans" [ref=e292]: + - link "Premium Slim Fit Jeans" [ref=e293] [cursor=pointer]: + - /url: http://shop.test/admin/products/2/edit + - cell "Active" [ref=e294]: + - generic [ref=e295]: Active + - cell "80" [ref=e296] + - cell "Pants" [ref=e297] + - cell "Acme Denim" [ref=e298] + - cell "11m ago" [ref=e299] + - row "Select Cargo Pants Cargo Pants Active 168 Pants Acme Workwear 11m ago" [ref=e300]: + - cell "Select Cargo Pants" [ref=e301]: + - checkbox "Select Cargo Pants" [ref=e302] + - cell [ref=e304]: + - img [ref=e306] + - cell "Cargo Pants" [ref=e308]: + - link "Cargo Pants" [ref=e309] [cursor=pointer]: + - /url: http://shop.test/admin/products/9/edit + - cell "Active" [ref=e310]: + - generic [ref=e311]: Active + - cell "168" [ref=e312] + - cell "Pants" [ref=e313] + - cell "Acme Workwear" [ref=e314] + - cell "11m ago" [ref=e315] + - row "Select Chino Shorts Chino Shorts Active 128 Pants Acme Basics 11m ago" [ref=e316]: + - cell "Select Chino Shorts" [ref=e317]: + - checkbox "Select Chino Shorts" [ref=e318] + - cell [ref=e320]: + - img [ref=e322] + - cell "Chino Shorts" [ref=e324]: + - link "Chino Shorts" [ref=e325] [cursor=pointer]: + - /url: http://shop.test/admin/products/10/edit + - cell "Active" [ref=e326]: + - generic [ref=e327]: Active + - cell "128" [ref=e328] + - cell "Pants" [ref=e329] + - cell "Acme Basics" [ref=e330] + - cell "11m ago" [ref=e331] + - row "Select Wide Leg Trousers Wide Leg Trousers Active 21 Pants Acme Denim 11m ago" [ref=e332]: + - cell "Select Wide Leg Trousers" [ref=e333]: + - checkbox "Select Wide Leg Trousers" [ref=e334] + - cell [ref=e336]: + - img [ref=e338] + - cell "Wide Leg Trousers" [ref=e340]: + - link "Wide Leg Trousers" [ref=e341] [cursor=pointer]: + - /url: http://shop.test/admin/products/11/edit + - cell "Active" [ref=e342]: + - generic [ref=e343]: Active + - cell "21" [ref=e344] + - cell "Pants" [ref=e345] + - cell "Acme Denim" [ref=e346] + - cell "11m ago" [ref=e347] + - row "Select Running Sneakers Running Sneakers Active 70 Shoes Acme Sport 11m ago" [ref=e348]: + - cell "Select Running Sneakers" [ref=e349]: + - checkbox "Select Running Sneakers" [ref=e350] + - cell [ref=e352]: + - img [ref=e354] + - cell "Running Sneakers" [ref=e356]: + - link "Running Sneakers" [ref=e357] [cursor=pointer]: + - /url: http://shop.test/admin/products/5/edit + - cell "Active" [ref=e358]: + - generic [ref=e359]: Active + - cell "70" [ref=e360] + - cell "Shoes" [ref=e361] + - cell "Acme Sport" [ref=e362] + - cell "11m ago" [ref=e363] + - navigation "Pagination Navigation" [ref=e366]: + - generic [ref=e367]: + - paragraph [ref=e369]: Showing 1 to 15 of 20 results + - generic [ref=e371]: + - generic "« Previous" [ref=e373]: + - img [ref=e375] + - generic [ref=e379]: "1" + - button "Go to page 2" [ref=e381]: "2" + - button "Next »" [ref=e383]: + - img [ref=e384] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-43-35-385Z.yml b/.playwright-mcp/page-2026-06-10T08-43-35-385Z.yml new file mode 100644 index 00000000..02fcef0f --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-43-35-385Z.yml @@ -0,0 +1,422 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - complementary "Admin navigation" [ref=e3]: + - link "Shop" [ref=e5] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e8] + - generic [ref=e10]: Shop + - navigation [ref=e11]: + - link "Dashboard" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e13] + - text: Dashboard + - paragraph [ref=e15]: Products + - link "Products" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e17] + - text: Products + - link "Collections" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e20] + - text: Collections + - link "Inventory" [ref=e22] [cursor=pointer]: + - /url: http://shop.test/admin/inventory + - img [ref=e23] + - text: Inventory + - paragraph [ref=e25]: Orders + - link "Orders" [ref=e26] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e27] + - text: Orders + - paragraph [ref=e29]: Customers + - link "Customers" [ref=e30] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e31] + - text: Customers + - paragraph [ref=e33]: Discounts + - link "Discounts" [ref=e34] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e35] + - text: Discounts + - paragraph [ref=e38]: Content + - link "Pages" [ref=e39] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e40] + - text: Pages + - link "Navigation" [ref=e42] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e43] + - text: Navigation + - link "Themes" [ref=e45] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e46] + - text: Themes + - link "Analytics" [ref=e48] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e49] + - text: Analytics + - link "Settings" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e53] + - text: Settings + - link "Apps" [ref=e56] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e57] + - text: Apps + - link "Developers" [ref=e59] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e60] + - text: Developers + - generic [ref=e62]: + - banner [ref=e63]: + - button "Acme Fashion" [ref=e65]: + - generic [ref=e66]: Acme Fashion + - img [ref=e67] + - button "AU Admin User" [ref=e70]: + - generic [ref=e73]: AU + - generic [ref=e74]: Admin User + - img [ref=e76] + - main [ref=e78]: + - generic [ref=e79]: + - generic [ref=e80]: + - generic [ref=e81]: + - link "Home" [ref=e82] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e83] + - generic [ref=e85]: + - link "Products" [ref=e86] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e87] + - generic [ref=e90]: Classic Cotton T-Shirt + - generic [ref=e91]: + - heading "Classic Cotton T-Shirt" [level=1] [ref=e92] + - button "Delete" [ref=e94] + - generic [ref=e95]: + - generic [ref=e96]: + - generic [ref=e97]: + - generic [ref=e98]: + - generic [ref=e99]: Title + - textbox "Title" [ref=e101]: + - /placeholder: Short Sleeve T-Shirt + - text: Classic Cotton T-Shirt + - generic [ref=e102]: + - generic [ref=e103]: Description + - textbox "Description" [ref=e104]: + - /placeholder: Describe your product... + - text:

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

+ - generic [ref=e105]: + - generic [ref=e106]: Media + - generic [ref=e107] [cursor=pointer]: + - img [ref=e108] + - paragraph [ref=e110]: Drag and drop images or click to upload + - button "Drag and drop images or click to upload" [ref=e111] + - generic [ref=e112]: + - generic [ref=e113]: Variants + - generic [ref=e114]: + - generic [ref=e115]: + - generic [ref=e116]: + - generic [ref=e117]: Option name + - textbox "Option name" [ref=e119]: + - /placeholder: Size + - text: Size + - generic [ref=e121]: + - generic [ref=e122]: Values + - textbox "Values" [ref=e124]: + - /placeholder: S, M, L, XL + - text: S, M, L, XL + - generic [ref=e126]: Separate values with commas + - button "Remove option" [ref=e127]: + - img [ref=e129] + - img [ref=e132] + - generic [ref=e134]: + - generic [ref=e135]: + - generic [ref=e136]: Option name + - textbox "Option name" [ref=e138]: + - /placeholder: Size + - text: Color + - generic [ref=e140]: + - generic [ref=e141]: Values + - textbox "Values" [ref=e143]: + - /placeholder: S, M, L, XL + - text: White, Black, Navy + - generic [ref=e145]: Separate values with commas + - button "Remove option" [ref=e146]: + - img [ref=e148] + - img [ref=e151] + - button "Add another option" [ref=e153]: + - img [ref=e155] + - img [ref=e158] + - generic [ref=e160]: Add another option + - table [ref=e162]: + - rowgroup [ref=e163]: + - row "Variant SKU Barcode Price Compare at Weight (g) Qty Ship" [ref=e164]: + - columnheader "Variant" [ref=e165] + - columnheader "SKU" [ref=e166] + - columnheader "Barcode" [ref=e167] + - columnheader "Price" [ref=e168] + - columnheader "Compare at" [ref=e169] + - columnheader "Weight (g)" [ref=e170] + - columnheader "Qty" [ref=e171] + - columnheader "Ship" [ref=e172] + - rowgroup [ref=e173]: + - row "S / White ACME-CTSH-S-WHITE" [ref=e174]: + - cell "S / White" [ref=e175] + - cell "ACME-CTSH-S-WHITE" [ref=e176]: + - textbox [ref=e178]: ACME-CTSH-S-WHITE + - cell [ref=e179]: + - textbox [ref=e181] + - cell [ref=e182]: + - spinbutton [ref=e184]: "24.99" + - cell [ref=e185]: + - spinbutton [ref=e187] + - cell [ref=e188]: + - spinbutton [ref=e190]: "200" + - cell [ref=e191]: + - spinbutton [ref=e193]: "15" + - cell [ref=e194]: + - checkbox [checked] [ref=e195]: + - img [ref=e197] + - row "S / Black ACME-CTSH-S-BLACK" [ref=e199]: + - cell "S / Black" [ref=e200] + - cell "ACME-CTSH-S-BLACK" [ref=e201]: + - textbox [ref=e203]: ACME-CTSH-S-BLACK + - cell [ref=e204]: + - textbox [ref=e206] + - cell [ref=e207]: + - spinbutton [ref=e209]: "24.99" + - cell [ref=e210]: + - spinbutton [ref=e212] + - cell [ref=e213]: + - spinbutton [ref=e215]: "200" + - cell [ref=e216]: + - spinbutton [ref=e218]: "15" + - cell [ref=e219]: + - checkbox [checked] [ref=e220]: + - img [ref=e222] + - row "S / Navy ACME-CTSH-S-NAVY" [ref=e224]: + - cell "S / Navy" [ref=e225] + - cell "ACME-CTSH-S-NAVY" [ref=e226]: + - textbox [ref=e228]: ACME-CTSH-S-NAVY + - cell [ref=e229]: + - textbox [ref=e231] + - cell [ref=e232]: + - spinbutton [ref=e234]: "24.99" + - cell [ref=e235]: + - spinbutton [ref=e237] + - cell [ref=e238]: + - spinbutton [ref=e240]: "200" + - cell [ref=e241]: + - spinbutton [ref=e243]: "15" + - cell [ref=e244]: + - checkbox [checked] [ref=e245]: + - img [ref=e247] + - row "M / White ACME-CTSH-M-WHITE" [ref=e249]: + - cell "M / White" [ref=e250] + - cell "ACME-CTSH-M-WHITE" [ref=e251]: + - textbox [ref=e253]: ACME-CTSH-M-WHITE + - cell [ref=e254]: + - textbox [ref=e256] + - cell [ref=e257]: + - spinbutton [ref=e259]: "24.99" + - cell [ref=e260]: + - spinbutton [ref=e262] + - cell [ref=e263]: + - spinbutton [ref=e265]: "200" + - cell [ref=e266]: + - spinbutton [ref=e268]: "15" + - cell [ref=e269]: + - checkbox [checked] [ref=e270]: + - img [ref=e272] + - row "M / Black ACME-CTSH-M-BLACK" [ref=e274]: + - cell "M / Black" [ref=e275] + - cell "ACME-CTSH-M-BLACK" [ref=e276]: + - textbox [ref=e278]: ACME-CTSH-M-BLACK + - cell [ref=e279]: + - textbox [ref=e281] + - cell [ref=e282]: + - spinbutton [ref=e284]: "24.99" + - cell [ref=e285]: + - spinbutton [ref=e287] + - cell [ref=e288]: + - spinbutton [ref=e290]: "200" + - cell [ref=e291]: + - spinbutton [ref=e293]: "15" + - cell [ref=e294]: + - checkbox [checked] [ref=e295]: + - img [ref=e297] + - row "M / Navy ACME-CTSH-M-NAVY" [ref=e299]: + - cell "M / Navy" [ref=e300] + - cell "ACME-CTSH-M-NAVY" [ref=e301]: + - textbox [ref=e303]: ACME-CTSH-M-NAVY + - cell [ref=e304]: + - textbox [ref=e306] + - cell [ref=e307]: + - spinbutton [ref=e309]: "24.99" + - cell [ref=e310]: + - spinbutton [ref=e312] + - cell [ref=e313]: + - spinbutton [ref=e315]: "200" + - cell [ref=e316]: + - spinbutton [ref=e318]: "13" + - cell [ref=e319]: + - checkbox [checked] [ref=e320]: + - img [ref=e322] + - row "L / White ACME-CTSH-L-WHITE" [ref=e324]: + - cell "L / White" [ref=e325] + - cell "ACME-CTSH-L-WHITE" [ref=e326]: + - textbox [ref=e328]: ACME-CTSH-L-WHITE + - cell [ref=e329]: + - textbox [ref=e331] + - cell [ref=e332]: + - spinbutton [ref=e334]: "24.99" + - cell [ref=e335]: + - spinbutton [ref=e337] + - cell [ref=e338]: + - spinbutton [ref=e340]: "200" + - cell [ref=e341]: + - spinbutton [ref=e343]: "15" + - cell [ref=e344]: + - checkbox [checked] [ref=e345]: + - img [ref=e347] + - row "L / Black ACME-CTSH-L-BLACK" [ref=e349]: + - cell "L / Black" [ref=e350] + - cell "ACME-CTSH-L-BLACK" [ref=e351]: + - textbox [ref=e353]: ACME-CTSH-L-BLACK + - cell [ref=e354]: + - textbox [ref=e356] + - cell [ref=e357]: + - spinbutton [ref=e359]: "24.99" + - cell [ref=e360]: + - spinbutton [ref=e362] + - cell [ref=e363]: + - spinbutton [ref=e365]: "200" + - cell [ref=e366]: + - spinbutton [ref=e368]: "15" + - cell [ref=e369]: + - checkbox [checked] [ref=e370]: + - img [ref=e372] + - row "L / Navy ACME-CTSH-L-NAVY" [ref=e374]: + - cell "L / Navy" [ref=e375] + - cell "ACME-CTSH-L-NAVY" [ref=e376]: + - textbox [ref=e378]: ACME-CTSH-L-NAVY + - cell [ref=e379]: + - textbox [ref=e381] + - cell [ref=e382]: + - spinbutton [ref=e384]: "24.99" + - cell [ref=e385]: + - spinbutton [ref=e387] + - cell [ref=e388]: + - spinbutton [ref=e390]: "200" + - cell [ref=e391]: + - spinbutton [ref=e393]: "15" + - cell [ref=e394]: + - checkbox [checked] [ref=e395]: + - img [ref=e397] + - row "XL / White ACME-CTSH-XL-WHITE" [ref=e399]: + - cell "XL / White" [ref=e400] + - cell "ACME-CTSH-XL-WHITE" [ref=e401]: + - textbox [ref=e403]: ACME-CTSH-XL-WHITE + - cell [ref=e404]: + - textbox [ref=e406] + - cell [ref=e407]: + - spinbutton [ref=e409]: "24.99" + - cell [ref=e410]: + - spinbutton [ref=e412] + - cell [ref=e413]: + - spinbutton [ref=e415]: "200" + - cell [ref=e416]: + - spinbutton [ref=e418]: "15" + - cell [ref=e419]: + - checkbox [checked] [ref=e420]: + - img [ref=e422] + - row "XL / Black ACME-CTSH-XL-BLACK" [ref=e424]: + - cell "XL / Black" [ref=e425] + - cell "ACME-CTSH-XL-BLACK" [ref=e426]: + - textbox [ref=e428]: ACME-CTSH-XL-BLACK + - cell [ref=e429]: + - textbox [ref=e431] + - cell [ref=e432]: + - spinbutton [ref=e434]: "24.99" + - cell [ref=e435]: + - spinbutton [ref=e437] + - cell [ref=e438]: + - spinbutton [ref=e440]: "200" + - cell [ref=e441]: + - spinbutton [ref=e443]: "15" + - cell [ref=e444]: + - checkbox [checked] [ref=e445]: + - img [ref=e447] + - row "XL / Navy ACME-CTSH-XL-NAVY" [ref=e449]: + - cell "XL / Navy" [ref=e450] + - cell "ACME-CTSH-XL-NAVY" [ref=e451]: + - textbox [ref=e453]: ACME-CTSH-XL-NAVY + - cell [ref=e454]: + - textbox [ref=e456] + - cell [ref=e457]: + - spinbutton [ref=e459]: "24.99" + - cell [ref=e460]: + - spinbutton [ref=e462] + - cell [ref=e463]: + - spinbutton [ref=e465]: "200" + - cell [ref=e466]: + - spinbutton [ref=e468]: "15" + - cell [ref=e469]: + - checkbox [checked] [ref=e470]: + - img [ref=e472] + - button "Search engine listing" [ref=e475] [cursor=pointer]: + - img [ref=e476] + - generic [ref=e478]: Search engine listing + - generic [ref=e479]: + - generic [ref=e480]: + - generic [ref=e481]: Status + - combobox [ref=e483]: + - option "Draft" + - option "Active" [selected] + - option "Archived" + - generic [ref=e484]: + - generic [ref=e485]: Publishing + - generic [ref=e486]: + - generic [ref=e487]: Published at + - textbox "Published at" [ref=e489]: 2026-06-10T08:32 + - generic [ref=e490]: + - generic [ref=e491]: Product organization + - generic [ref=e492]: + - generic [ref=e493]: Vendor + - textbox "Vendor" [ref=e495]: + - /placeholder: Nike + - text: Acme Basics + - generic [ref=e496]: + - generic [ref=e497]: Product type + - textbox "Product type" [ref=e499]: + - /placeholder: T-Shirts + - text: T-Shirts + - generic [ref=e500]: + - generic [ref=e501]: Tags + - textbox "Tags" [ref=e503]: + - /placeholder: summer, cotton, sale + - text: new, popular + - generic [ref=e504]: Separate tags with commas + - generic [ref=e505]: + - generic [ref=e506]: Collections + - generic [ref=e507]: + - generic [ref=e508]: + - checkbox "New Arrivals" [checked] [ref=e509]: + - img [ref=e511] + - generic [ref=e513]: New Arrivals + - generic [ref=e514]: + - checkbox "Pants & Jeans" [ref=e515] + - generic [ref=e517]: Pants & Jeans + - generic [ref=e518]: + - checkbox "Sale" [ref=e519] + - generic [ref=e521]: Sale + - generic [ref=e522]: + - checkbox "T-Shirts" [checked] [ref=e523]: + - img [ref=e525] + - generic [ref=e527]: T-Shirts + - generic [ref=e529]: + - link "Discard" [ref=e530] [cursor=pointer]: + - /url: http://shop.test/admin/products + - button "Save" [ref=e531]: + - img [ref=e533] + - generic [ref=e536]: Save \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-43-40-730Z.yml b/.playwright-mcp/page-2026-06-10T08-43-40-730Z.yml new file mode 100644 index 00000000..e647fed6 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-43-40-730Z.yml @@ -0,0 +1,173 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - complementary "Admin navigation" [ref=e3]: + - link "Shop" [ref=e5] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e8] + - generic [ref=e10]: Shop + - navigation [ref=e11]: + - link "Dashboard" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e13] + - text: Dashboard + - paragraph [ref=e15]: Products + - link "Products" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e17] + - text: Products + - link "Collections" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e20] + - text: Collections + - link "Inventory" [ref=e22] [cursor=pointer]: + - /url: http://shop.test/admin/inventory + - img [ref=e23] + - text: Inventory + - paragraph [ref=e25]: Orders + - link "Orders" [ref=e26] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e27] + - text: Orders + - paragraph [ref=e29]: Customers + - link "Customers" [ref=e30] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e31] + - text: Customers + - paragraph [ref=e33]: Discounts + - link "Discounts" [ref=e34] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e35] + - text: Discounts + - paragraph [ref=e38]: Content + - link "Pages" [ref=e39] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e40] + - text: Pages + - link "Navigation" [ref=e42] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e43] + - text: Navigation + - link "Themes" [ref=e45] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e46] + - text: Themes + - link "Analytics" [ref=e48] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e49] + - text: Analytics + - link "Settings" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e53] + - text: Settings + - link "Apps" [ref=e56] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e57] + - text: Apps + - link "Developers" [ref=e59] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e60] + - text: Developers + - generic [ref=e62]: + - banner [ref=e63]: + - button "Acme Fashion" [ref=e65]: + - generic [ref=e66]: Acme Fashion + - img [ref=e67] + - button "AU Admin User" [ref=e70]: + - generic [ref=e73]: AU + - generic [ref=e74]: Admin User + - img [ref=e76] + - main [ref=e78]: + - generic [ref=e79]: + - generic [ref=e80]: + - generic [ref=e81]: + - link "Home" [ref=e82] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e83] + - generic [ref=e86]: Discounts + - generic [ref=e87]: + - heading "Discounts" [level=1] [ref=e88] + - link "Create discount" [ref=e89] [cursor=pointer]: + - /url: http://shop.test/admin/discounts/create + - img [ref=e90] + - generic [ref=e92]: Create discount + - generic [ref=e93]: + - generic [ref=e94]: + - generic: + - img + - textbox "Search by code..." [ref=e95] + - combobox [ref=e97]: + - 'option "Status: All" [selected]' + - option "Active" + - option "Scheduled" + - option "Expired" + - option "Disabled" + - combobox [ref=e98]: + - 'option "Type: All" [selected]' + - option "Code" + - option "Automatic" + - table [ref=e101]: + - rowgroup [ref=e102]: + - row "Code Type Value Usage Status Dates" [ref=e103]: + - columnheader "Code" [ref=e104] + - columnheader "Type" [ref=e105] + - columnheader "Value" [ref=e106] + - columnheader "Usage" [ref=e107] + - columnheader "Status" [ref=e108] + - columnheader "Dates" [ref=e109] + - rowgroup [ref=e110]: + - row "WELCOME10 Code 10% 4 / unlimited Active Jan 1, 2025 - Dec 31, 2027" [ref=e111]: + - cell "WELCOME10" [ref=e112]: + - link "WELCOME10" [ref=e113] [cursor=pointer]: + - /url: http://shop.test/admin/discounts/1/edit + - cell "Code" [ref=e114]: + - generic [ref=e115]: Code + - cell "10%" [ref=e116] + - cell "4 / unlimited" [ref=e117] + - cell "Active" [ref=e118]: + - generic [ref=e119]: Active + - cell "Jan 1, 2025 - Dec 31, 2027" [ref=e120] + - row "FLAT5 Code 5.00 EUR 0 / unlimited Active Jan 1, 2025 - Dec 31, 2027" [ref=e121]: + - cell "FLAT5" [ref=e122]: + - link "FLAT5" [ref=e123] [cursor=pointer]: + - /url: http://shop.test/admin/discounts/2/edit + - cell "Code" [ref=e124]: + - generic [ref=e125]: Code + - cell "5.00 EUR" [ref=e126] + - cell "0 / unlimited" [ref=e127] + - cell "Active" [ref=e128]: + - generic [ref=e129]: Active + - cell "Jan 1, 2025 - Dec 31, 2027" [ref=e130] + - row "FREESHIP Code Free shipping 1 / unlimited Active Jan 1, 2025 - Dec 31, 2027" [ref=e131]: + - cell "FREESHIP" [ref=e132]: + - link "FREESHIP" [ref=e133] [cursor=pointer]: + - /url: http://shop.test/admin/discounts/3/edit + - cell "Code" [ref=e134]: + - generic [ref=e135]: Code + - cell "Free shipping" [ref=e136] + - cell "1 / unlimited" [ref=e137] + - cell "Active" [ref=e138]: + - generic [ref=e139]: Active + - cell "Jan 1, 2025 - Dec 31, 2027" [ref=e140] + - row "EXPIRED20 Code 20% 0 / unlimited Expired Jan 1, 2024 - Dec 31, 2024" [ref=e141]: + - cell "EXPIRED20" [ref=e142]: + - link "EXPIRED20" [ref=e143] [cursor=pointer]: + - /url: http://shop.test/admin/discounts/4/edit + - cell "Code" [ref=e144]: + - generic [ref=e145]: Code + - cell "20%" [ref=e146] + - cell "0 / unlimited" [ref=e147] + - cell "Expired" [ref=e148]: + - generic [ref=e149]: Expired + - cell "Jan 1, 2024 - Dec 31, 2024" [ref=e150] + - row "MAXED Code 10% 5 / 5 Active Jan 1, 2025 - Dec 31, 2027" [ref=e151]: + - cell "MAXED" [ref=e152]: + - link "MAXED" [ref=e153] [cursor=pointer]: + - /url: http://shop.test/admin/discounts/5/edit + - cell "Code" [ref=e154]: + - generic [ref=e155]: Code + - cell "10%" [ref=e156] + - cell "5 / 5" [ref=e157] + - cell "Active" [ref=e158]: + - generic [ref=e159]: Active + - cell "Jan 1, 2025 - Dec 31, 2027" [ref=e160] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-43-45-967Z.yml b/.playwright-mcp/page-2026-06-10T08-43-45-967Z.yml new file mode 100644 index 00000000..336e9991 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-43-45-967Z.yml @@ -0,0 +1,282 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - complementary "Admin navigation" [ref=e3]: + - link "Shop" [ref=e5] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e8] + - generic [ref=e10]: Shop + - navigation [ref=e11]: + - link "Dashboard" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e13] + - text: Dashboard + - paragraph [ref=e15]: Products + - link "Products" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e17] + - text: Products + - link "Collections" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e20] + - text: Collections + - link "Inventory" [ref=e22] [cursor=pointer]: + - /url: http://shop.test/admin/inventory + - img [ref=e23] + - text: Inventory + - paragraph [ref=e25]: Orders + - link "Orders" [ref=e26] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e27] + - text: Orders + - paragraph [ref=e29]: Customers + - link "Customers" [ref=e30] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e31] + - text: Customers + - paragraph [ref=e33]: Discounts + - link "Discounts" [ref=e34] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e35] + - text: Discounts + - paragraph [ref=e38]: Content + - link "Pages" [ref=e39] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e40] + - text: Pages + - link "Navigation" [ref=e42] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e43] + - text: Navigation + - link "Themes" [ref=e45] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e46] + - text: Themes + - link "Analytics" [ref=e48] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e49] + - text: Analytics + - link "Settings" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e53] + - text: Settings + - link "Apps" [ref=e56] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e57] + - text: Apps + - link "Developers" [ref=e59] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e60] + - text: Developers + - generic [ref=e62]: + - banner [ref=e63]: + - button "Acme Fashion" [ref=e65]: + - generic [ref=e66]: Acme Fashion + - img [ref=e67] + - button "AU Admin User" [ref=e70]: + - generic [ref=e73]: AU + - generic [ref=e74]: Admin User + - img [ref=e76] + - main [ref=e78]: + - generic [ref=e79]: + - generic [ref=e80]: + - generic [ref=e81]: + - link "Home" [ref=e82] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e83] + - generic [ref=e85]: + - link "Settings" [ref=e86] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e87] + - generic [ref=e90]: Shipping + - heading "Settings" [level=1] [ref=e91] + - tablist [ref=e92]: + - tab "General" [ref=e93] [cursor=pointer] + - tab "Domains" [ref=e94] [cursor=pointer] + - tab "Shipping" [selected] [ref=e95] [cursor=pointer] + - tab "Taxes" [ref=e96] [cursor=pointer] + - tab "Checkout" [ref=e97] [cursor=pointer] + - tab "Notifications" [ref=e98] [cursor=pointer] + - generic [ref=e99]: + - generic [ref=e100]: Shipping + - button "Add zone" [ref=e101]: + - img [ref=e103] + - img [ref=e106] + - generic [ref=e108]: Add zone + - generic [ref=e109]: + - generic [ref=e110]: + - generic [ref=e111]: + - generic [ref=e112]: Domestic + - paragraph [ref=e113]: "Countries: DE" + - generic [ref=e114]: + - button "Edit" [ref=e115]: + - img [ref=e117] + - generic [ref=e120]: Edit + - button "Delete zone Domestic" [ref=e121]: + - img [ref=e123] + - img [ref=e126] + - table [ref=e129]: + - rowgroup [ref=e130]: + - row "Name Type Config Active Actions" [ref=e131]: + - columnheader "Name" [ref=e132] + - columnheader "Type" [ref=e133] + - columnheader "Config" [ref=e134] + - columnheader "Active" [ref=e135] + - columnheader "Actions" [ref=e136] + - rowgroup [ref=e137]: + - row "Standard Shipping flat 4.99 EUR Toggle Standard Shipping Edit Delete rate Standard Shipping" [ref=e138]: + - cell "Standard Shipping" [ref=e139] + - cell "flat" [ref=e140]: + - generic [ref=e141]: flat + - cell "4.99 EUR" [ref=e142] + - cell "Toggle Standard Shipping" [ref=e143]: + - switch "Toggle Standard Shipping" [checked] [ref=e144] + - cell "Edit Delete rate Standard Shipping" [ref=e146]: + - generic [ref=e147]: + - button "Edit" [ref=e148]: + - img [ref=e150] + - generic [ref=e153]: Edit + - button "Delete rate Standard Shipping" [ref=e154]: + - img [ref=e156] + - img [ref=e159] + - row "Express Shipping flat 9.99 EUR Toggle Express Shipping Edit Delete rate Express Shipping" [ref=e161]: + - cell "Express Shipping" [ref=e162] + - cell "flat" [ref=e163]: + - generic [ref=e164]: flat + - cell "9.99 EUR" [ref=e165] + - cell "Toggle Express Shipping" [ref=e166]: + - switch "Toggle Express Shipping" [checked] [ref=e167] + - cell "Edit Delete rate Express Shipping" [ref=e169]: + - generic [ref=e170]: + - button "Edit" [ref=e171]: + - img [ref=e173] + - generic [ref=e176]: Edit + - button "Delete rate Express Shipping" [ref=e177]: + - img [ref=e179] + - img [ref=e182] + - button "Add rate" [ref=e184]: + - img [ref=e186] + - img [ref=e189] + - generic [ref=e191]: Add rate + - generic [ref=e192]: + - generic [ref=e193]: + - generic [ref=e194]: + - generic [ref=e195]: EU + - paragraph [ref=e196]: "Countries: AT, FR, IT, ES, NL, BE, PL" + - generic [ref=e197]: + - button "Edit" [ref=e198]: + - img [ref=e200] + - generic [ref=e203]: Edit + - button "Delete zone EU" [ref=e204]: + - img [ref=e206] + - img [ref=e209] + - table [ref=e212]: + - rowgroup [ref=e213]: + - row "Name Type Config Active Actions" [ref=e214]: + - columnheader "Name" [ref=e215] + - columnheader "Type" [ref=e216] + - columnheader "Config" [ref=e217] + - columnheader "Active" [ref=e218] + - columnheader "Actions" [ref=e219] + - rowgroup [ref=e220]: + - row "EU Standard flat 8.99 EUR Toggle EU Standard Edit Delete rate EU Standard" [ref=e221]: + - cell "EU Standard" [ref=e222] + - cell "flat" [ref=e223]: + - generic [ref=e224]: flat + - cell "8.99 EUR" [ref=e225] + - cell "Toggle EU Standard" [ref=e226]: + - switch "Toggle EU Standard" [checked] [ref=e227] + - cell "Edit Delete rate EU Standard" [ref=e229]: + - generic [ref=e230]: + - button "Edit" [ref=e231]: + - img [ref=e233] + - generic [ref=e236]: Edit + - button "Delete rate EU Standard" [ref=e237]: + - img [ref=e239] + - img [ref=e242] + - button "Add rate" [ref=e244]: + - img [ref=e246] + - img [ref=e249] + - generic [ref=e251]: Add rate + - generic [ref=e252]: + - generic [ref=e253]: + - generic [ref=e254]: + - generic [ref=e255]: Rest of World + - paragraph [ref=e256]: "Countries: US, GB, CA, AU" + - generic [ref=e257]: + - button "Edit" [ref=e258]: + - img [ref=e260] + - generic [ref=e263]: Edit + - button "Delete zone Rest of World" [ref=e264]: + - img [ref=e266] + - img [ref=e269] + - table [ref=e272]: + - rowgroup [ref=e273]: + - row "Name Type Config Active Actions" [ref=e274]: + - columnheader "Name" [ref=e275] + - columnheader "Type" [ref=e276] + - columnheader "Config" [ref=e277] + - columnheader "Active" [ref=e278] + - columnheader "Actions" [ref=e279] + - rowgroup [ref=e280]: + - row "International flat 14.99 EUR Toggle International Edit Delete rate International" [ref=e281]: + - cell "International" [ref=e282] + - cell "flat" [ref=e283]: + - generic [ref=e284]: flat + - cell "14.99 EUR" [ref=e285] + - cell "Toggle International" [ref=e286]: + - switch "Toggle International" [checked] [ref=e287] + - cell "Edit Delete rate International" [ref=e289]: + - generic [ref=e290]: + - button "Edit" [ref=e291]: + - img [ref=e293] + - generic [ref=e296]: Edit + - button "Delete rate International" [ref=e297]: + - img [ref=e299] + - img [ref=e302] + - button "Add rate" [ref=e304]: + - img [ref=e306] + - img [ref=e309] + - generic [ref=e311]: Add rate + - generic [ref=e312]: + - generic [ref=e313]: Test shipping address + - paragraph [ref=e314]: Enter an address to see which shipping zone and rates match. + - generic [ref=e315]: + - generic [ref=e316]: + - generic [ref=e317]: Country + - combobox "Country" [ref=e318]: + - option "Austria" + - option "Australia" + - option "Belgium" + - option "Canada" + - option "Switzerland" + - option "Czechia" + - option "Germany" [selected] + - option "Denmark" + - option "Spain" + - option "Finland" + - option "France" + - option "United Kingdom" + - option "Ireland" + - option "Italy" + - option "Japan" + - option "Luxembourg" + - option "Netherlands" + - option "Norway" + - option "Poland" + - option "Portugal" + - option "Sweden" + - option "United States" + - generic [ref=e319]: + - generic [ref=e320]: State / Region + - textbox "State / Region" [ref=e322]: + - /placeholder: CA + - generic [ref=e323]: + - generic [ref=e324]: City + - textbox "City" [ref=e326] + - generic [ref=e327]: + - generic [ref=e328]: ZIP / Postal code + - textbox "ZIP / Postal code" [ref=e330] + - button "Test" [ref=e331]: + - img [ref=e333] + - generic [ref=e336]: Test \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-43-51-508Z.yml b/.playwright-mcp/page-2026-06-10T08-43-51-508Z.yml new file mode 100644 index 00000000..9b7d8247 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-43-51-508Z.yml @@ -0,0 +1,253 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - complementary "Admin navigation" [ref=e3]: + - link "Shop" [ref=e5] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e8] + - generic [ref=e10]: Shop + - navigation [ref=e11]: + - link "Dashboard" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e13] + - text: Dashboard + - paragraph [ref=e15]: Products + - link "Products" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e17] + - text: Products + - link "Collections" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e20] + - text: Collections + - link "Inventory" [ref=e22] [cursor=pointer]: + - /url: http://shop.test/admin/inventory + - img [ref=e23] + - text: Inventory + - paragraph [ref=e25]: Orders + - link "Orders" [ref=e26] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e27] + - text: Orders + - paragraph [ref=e29]: Customers + - link "Customers" [ref=e30] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e31] + - text: Customers + - paragraph [ref=e33]: Discounts + - link "Discounts" [ref=e34] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e35] + - text: Discounts + - paragraph [ref=e38]: Content + - link "Pages" [ref=e39] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e40] + - text: Pages + - link "Navigation" [ref=e42] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e43] + - text: Navigation + - link "Themes" [ref=e45] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e46] + - text: Themes + - link "Analytics" [ref=e48] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e49] + - text: Analytics + - link "Settings" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e53] + - text: Settings + - link "Apps" [ref=e56] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e57] + - text: Apps + - link "Developers" [ref=e59] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e60] + - text: Developers + - generic [ref=e62]: + - banner [ref=e63]: + - button "Acme Fashion" [ref=e65]: + - generic [ref=e66]: Acme Fashion + - img [ref=e67] + - button "AU Admin User" [ref=e70]: + - generic [ref=e73]: AU + - generic [ref=e74]: Admin User + - img [ref=e76] + - main [ref=e78]: + - generic [ref=e79]: + - generic [ref=e80]: + - generic [ref=e81]: + - link "Home" [ref=e82] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e83] + - generic [ref=e86]: Analytics + - generic [ref=e87]: + - heading "Analytics" [level=1] [ref=e88] + - combobox [ref=e90]: + - option "Today" + - option "Last 7 days" + - option "Last 30 days" [selected] + - option "Custom range" + - generic [ref=e91]: + - generic [ref=e92]: + - paragraph [ref=e93]: Total sales + - generic [ref=e94]: 10,925.24 EUR + - generic [ref=e95]: + - generic [ref=e96]: +3,369.7% + - img [ref=e97] + - paragraph [ref=e99]: vs previous period + - generic [ref=e100]: + - paragraph [ref=e101]: Orders + - generic [ref=e102]: "156" + - generic [ref=e103]: + - generic [ref=e104]: +3,800.0% + - img [ref=e105] + - paragraph [ref=e107]: vs previous period + - generic [ref=e108]: + - paragraph [ref=e109]: Average order value + - generic [ref=e110]: 70.03 EUR + - generic [ref=e111]: + - generic [ref=e112]: "-11.0%" + - img [ref=e113] + - paragraph [ref=e115]: vs previous period + - generic [ref=e116]: + - paragraph [ref=e117]: Conversion rate + - generic [ref=e118]: 4.6% + - generic [ref=e119]: + - generic [ref=e120]: "-23.3%" + - img [ref=e121] + - paragraph [ref=e123]: vs previous period + - generic [ref=e124]: + - generic [ref=e125]: + - generic [ref=e126]: Sales over time + - paragraph [ref=e127]: "Peak: 803.34 EUR/day" + - generic [ref=e128]: + - img "Daily revenue" [ref=e129] + - generic [ref=e132]: + - generic [ref=e133]: May 12 + - generic [ref=e134]: Jun 10 + - generic [ref=e135]: + - generic [ref=e136]: + - generic [ref=e137]: Conversion funnel + - generic [ref=e138]: + - generic [ref=e140]: + - generic [ref=e141]: Page views + - generic [ref=e142]: "88" + - generic [ref=e146]: + - generic [ref=e147]: Product views + - generic [ref=e148]: "56" + - generic [ref=e152]: + - generic [ref=e153]: Add to cart + - generic [ref=e154]: "34" + - generic [ref=e158]: + - generic [ref=e159]: Checkout started + - generic [ref=e160]: "23" + - generic [ref=e164]: + - generic [ref=e165]: Checkout completed + - generic [ref=e166]: "12" + - paragraph [ref=e169]: 3,394 unique visits in this period + - generic [ref=e170]: + - generic [ref=e172]: Top referrers + - table [ref=e174]: + - rowgroup [ref=e175]: + - row "Source Sessions Orders Conversion" [ref=e176]: + - columnheader "Source" [ref=e177] + - columnheader "Sessions" [ref=e178] + - columnheader "Orders" [ref=e179] + - columnheader "Conversion" [ref=e180] + - rowgroup [ref=e181]: + - row "Direct 15 4 26.67%" [ref=e182]: + - cell "Direct" [ref=e183] + - cell "15" [ref=e184] + - cell "4" [ref=e185] + - cell "26.67%" [ref=e186] + - row "www.google.com 8 3 37.50%" [ref=e187]: + - cell "www.google.com" [ref=e188] + - cell "8" [ref=e189] + - cell "3" [ref=e190] + - cell "37.50%" [ref=e191] + - row "www.instagram.com 6 2 33.33%" [ref=e192]: + - cell "www.instagram.com" [ref=e193] + - cell "6" [ref=e194] + - cell "2" [ref=e195] + - cell "33.33%" [ref=e196] + - row "news.example.com 5 0 0.00%" [ref=e197]: + - cell "news.example.com" [ref=e198] + - cell "5" [ref=e199] + - cell "0" [ref=e200] + - cell "0.00%" [ref=e201] + - generic [ref=e202]: + - generic [ref=e204]: Top products + - table [ref=e206]: + - rowgroup [ref=e207]: + - row "Rank Product Units sold Revenue % of total" [ref=e208]: + - columnheader "Rank" [ref=e209] + - columnheader "Product" [ref=e210] + - columnheader "Units sold" [ref=e211] + - columnheader "Revenue" [ref=e212] + - columnheader "% of total" [ref=e213] + - rowgroup [ref=e214]: + - row "1 Cashmere Overcoat (M / Camel) 1 499.99 EUR 44.1%" [ref=e215]: + - cell "1" [ref=e216] + - cell "Cashmere Overcoat (M / Camel)" [ref=e217] + - cell "1" [ref=e218] + - cell "499.99 EUR" [ref=e219] + - cell "44.1%" [ref=e220] + - row "2 Running Sneakers (EU 42 / Black) 1 119.99 EUR 10.6%" [ref=e221]: + - cell "2" [ref=e222] + - cell "Running Sneakers (EU 42 / Black)" [ref=e223] + - cell "1" [ref=e224] + - cell "119.99 EUR" [ref=e225] + - cell "10.6%" [ref=e226] + - row "3 Premium Slim Fit Jeans (32 / Blue) 1 79.99 EUR 7.0%" [ref=e227]: + - cell "3" [ref=e228] + - cell "Premium Slim Fit Jeans (32 / Blue)" [ref=e229] + - cell "1" [ref=e230] + - cell "79.99 EUR" [ref=e231] + - cell "7.0%" [ref=e232] + - row "4 Chino Shorts (34 / Navy) 2 79.98 EUR 7.0%" [ref=e233]: + - cell "4" [ref=e234] + - cell "Chino Shorts (34 / Navy)" [ref=e235] + - cell "2" [ref=e236] + - cell "79.98 EUR" [ref=e237] + - cell "7.0%" [ref=e238] + - row "5 V-Neck Linen Tee (M / Beige) 2 69.98 EUR 6.2%" [ref=e239]: + - cell "5" [ref=e240] + - cell "V-Neck Linen Tee (M / Beige)" [ref=e241] + - cell "2" [ref=e242] + - cell "69.98 EUR" [ref=e243] + - cell "6.2%" [ref=e244] + - row "6 Classic Cotton T-Shirt (M / Navy) 3 69.98 EUR 6.2%" [ref=e245]: + - cell "6" [ref=e246] + - cell "Classic Cotton T-Shirt (M / Navy)" [ref=e247] + - cell "3" [ref=e248] + - cell "69.98 EUR" [ref=e249] + - cell "6.2%" [ref=e250] + - row "7 Organic Hoodie (M) 1 59.99 EUR 5.3%" [ref=e251]: + - cell "7" [ref=e252] + - cell "Organic Hoodie (M)" [ref=e253] + - cell "1" [ref=e254] + - cell "59.99 EUR" [ref=e255] + - cell "5.3%" [ref=e256] + - row "8 Cargo Pants (32 / Khaki) 1 54.99 EUR 4.8%" [ref=e257]: + - cell "8" [ref=e258] + - cell "Cargo Pants (32 / Khaki)" [ref=e259] + - cell "1" [ref=e260] + - cell "54.99 EUR" [ref=e261] + - cell "4.8%" [ref=e262] + - row "9 Gift Card (50 EUR) 1 50.00 EUR 4.4%" [ref=e263]: + - cell "9" [ref=e264] + - cell "Gift Card (50 EUR)" [ref=e265] + - cell "1" [ref=e266] + - cell "50.00 EUR" [ref=e267] + - cell "4.4%" [ref=e268] + - row "10 Wide Leg Trousers (M) 1 49.99 EUR 4.4%" [ref=e269]: + - cell "10" [ref=e270] + - cell "Wide Leg Trousers (M)" [ref=e271] + - cell "1" [ref=e272] + - cell "49.99 EUR" [ref=e273] + - cell "4.4%" [ref=e274] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-43-57-143Z.yml b/.playwright-mcp/page-2026-06-10T08-43-57-143Z.yml new file mode 100644 index 00000000..2127fd59 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-43-57-143Z.yml @@ -0,0 +1,333 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - complementary "Admin navigation" [ref=e3]: + - link "Shop" [ref=e5] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e8] + - generic [ref=e10]: Shop + - navigation [ref=e11]: + - link "Dashboard" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e13] + - text: Dashboard + - paragraph [ref=e15]: Products + - link "Products" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e17] + - text: Products + - link "Collections" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e20] + - text: Collections + - link "Inventory" [ref=e22] [cursor=pointer]: + - /url: http://shop.test/admin/inventory + - img [ref=e23] + - text: Inventory + - paragraph [ref=e25]: Orders + - link "Orders" [ref=e26] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e27] + - text: Orders + - paragraph [ref=e29]: Customers + - link "Customers" [ref=e30] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e31] + - text: Customers + - paragraph [ref=e33]: Discounts + - link "Discounts" [ref=e34] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e35] + - text: Discounts + - paragraph [ref=e38]: Content + - link "Pages" [ref=e39] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e40] + - text: Pages + - link "Navigation" [ref=e42] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e43] + - text: Navigation + - link "Themes" [ref=e45] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e46] + - text: Themes + - link "Analytics" [ref=e48] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e49] + - text: Analytics + - link "Settings" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e53] + - text: Settings + - link "Apps" [ref=e56] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e57] + - text: Apps + - link "Developers" [ref=e59] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e60] + - text: Developers + - generic [ref=e62]: + - banner [ref=e63]: + - button "Acme Fashion" [ref=e65]: + - generic [ref=e66]: Acme Fashion + - img [ref=e67] + - button "AU Admin User" [ref=e70]: + - generic [ref=e73]: AU + - generic [ref=e74]: Admin User + - img [ref=e76] + - main [ref=e78]: + - generic [ref=e79]: + - generic [ref=e80]: + - generic [ref=e81]: + - link "Home" [ref=e82] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e83] + - generic [ref=e85]: + - link "Themes" [ref=e86] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e87] + - generic [ref=e90]: Default Theme + - generic [ref=e91]: + - link "Back to themes" [ref=e92] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e93] + - generic [ref=e95]: Back to themes + - generic [ref=e96]: + - generic [ref=e97]: Published + - button "Save" [ref=e98]: + - img [ref=e100] + - generic [ref=e103]: Save + - button "Save & publish" [ref=e104]: + - img [ref=e106] + - generic [ref=e109]: Save & publish + - generic [ref=e110]: + - generic [ref=e111]: + - generic [ref=e112]: Theme settings + - generic [ref=e113]: + - button "Header" [ref=e114] [cursor=pointer] + - button "Colors & typography" [ref=e115] [cursor=pointer] + - button "Product catalog" [ref=e116] [cursor=pointer] + - button "Footer" [ref=e117] [cursor=pointer] + - generic [ref=e118]: Home page sections + - paragraph [ref=e119]: Drag to reorder, toggle to show or hide. + - generic [ref=e120]: + - generic [ref=e121]: + - img [ref=e122] + - button "Hero" [ref=e124] [cursor=pointer] + - button "Toggle Hero" [ref=e125] [cursor=pointer]: + - img [ref=e126] + - generic [ref=e129]: + - img [ref=e130] + - button "Featured collections" [ref=e132] [cursor=pointer] + - button "Toggle Featured collections" [ref=e133] [cursor=pointer]: + - img [ref=e134] + - generic [ref=e137]: + - img [ref=e138] + - button "Featured products" [ref=e140] [cursor=pointer] + - button "Toggle Featured products" [ref=e141] [cursor=pointer]: + - img [ref=e142] + - generic [ref=e145]: + - img [ref=e146] + - button "Newsletter" [ref=e148] [cursor=pointer] + - button "Toggle Newsletter" [ref=e149] [cursor=pointer]: + - img [ref=e150] + - generic [ref=e153]: + - img [ref=e154] + - button "Rich text" [ref=e156] [cursor=pointer] + - button "Toggle Rich text" [ref=e157] [cursor=pointer]: + - img [ref=e158] + - iframe [ref=e162]: + - 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]: + - img [ref=f5e7] + - banner [ref=f5e9]: + - generic [ref=f5e10]: + - button "Open navigation menu" [ref=f5e11]: + - img [ref=f5e12] + - link "Acme Fashion" [ref=f5e14] [cursor=pointer]: + - /url: http://acme-fashion.test + - generic [ref=f5e15]: + - button "Search" [ref=f5e16]: + - img [ref=f5e17] + - button "Open cart" [ref=f5e19]: + - img [ref=f5e20] + - generic [ref=f5e22]: 0 items in cart + - main [ref=f5e23]: + - generic [ref=f5e24]: + - generic [ref=f5e27]: + - heading "Welcome to Acme Fashion" [level=1] [ref=f5e28] + - paragraph [ref=f5e29]: Discover our curated collection of modern essentials + - link "Shop New Arrivals" [ref=f5e30] [cursor=pointer]: + - /url: /collections/new-arrivals + - region "Shop by collection" [ref=f5e31]: + - heading "Shop by collection" [level=2] [ref=f5e32] + - generic [ref=f5e33]: + - link "New Arrivals Shop now →" [ref=f5e34] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - generic [ref=f5e37]: + - heading "New Arrivals" [level=3] [ref=f5e38] + - generic [ref=f5e39]: Shop now → + - link "T-Shirts Shop now →" [ref=f5e40] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - generic [ref=f5e43]: + - heading "T-Shirts" [level=3] [ref=f5e44] + - generic [ref=f5e45]: Shop now → + - link "Sale Shop now →" [ref=f5e46] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/sale + - generic [ref=f5e49]: + - heading "Sale" [level=3] [ref=f5e50] + - generic [ref=f5e51]: Shop now → + - region "Featured products" [ref=f5e52]: + - heading "Featured products" [level=2] [ref=f5e53] + - generic [ref=f5e54]: + - article [ref=f5e55]: + - img [ref=f5e58] + - generic [ref=f5e60]: + - heading "Classic Cotton T-Shirt" [level=3] [ref=f5e61]: + - link "Classic Cotton T-Shirt" [ref=f5e62] [cursor=pointer]: + - /url: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f5e64]: 24.99 EUR + - generic [ref=f5e65]: Choose options + - article [ref=f5e66]: + - generic [ref=f5e67]: + - img [ref=f5e69] + - generic "On sale" [ref=f5e72]: Sale + - generic [ref=f5e73]: + - heading "Premium Slim Fit Jeans" [level=3] [ref=f5e74]: + - link "Premium Slim Fit Jeans" [ref=f5e75] [cursor=pointer]: + - /url: http://acme-fashion.test/products/premium-slim-fit-jeans + - generic [ref=f5e76]: + - generic [ref=f5e77]: 79.99 EUR + - generic [ref=f5e78]: + - generic [ref=f5e79]: "Original price:" + - text: 99.99 EUR + - generic [ref=f5e80]: Choose options + - article [ref=f5e81]: + - img [ref=f5e84] + - generic [ref=f5e86]: + - heading "Organic Hoodie" [level=3] [ref=f5e87]: + - link "Organic Hoodie" [ref=f5e88] [cursor=pointer]: + - /url: http://acme-fashion.test/products/organic-hoodie + - generic [ref=f5e90]: 59.99 EUR + - generic [ref=f5e91]: Choose options + - article [ref=f5e92]: + - img [ref=f5e95] + - generic [ref=f5e97]: + - heading "Running Sneakers" [level=3] [ref=f5e98]: + - link "Running Sneakers" [ref=f5e99] [cursor=pointer]: + - /url: http://acme-fashion.test/products/running-sneakers + - generic [ref=f5e101]: 119.99 EUR + - generic [ref=f5e102]: Choose options + - article [ref=f5e103]: + - img [ref=f5e106] + - generic [ref=f5e108]: + - heading "Chino Shorts" [level=3] [ref=f5e109]: + - link "Chino Shorts" [ref=f5e110] [cursor=pointer]: + - /url: http://acme-fashion.test/products/chino-shorts + - generic [ref=f5e112]: 39.99 EUR + - generic [ref=f5e113]: Choose options + - article [ref=f5e114]: + - img [ref=f5e117] + - generic [ref=f5e119]: + - heading "Bucket Hat" [level=3] [ref=f5e120]: + - link "Bucket Hat" [ref=f5e121] [cursor=pointer]: + - /url: http://acme-fashion.test/products/bucket-hat + - generic [ref=f5e123]: 24.99 EUR + - generic [ref=f5e124]: Choose options + - article [ref=f5e125]: + - img [ref=f5e128] + - generic [ref=f5e130]: + - heading "Cashmere Overcoat" [level=3] [ref=f5e131]: + - link "Cashmere Overcoat" [ref=f5e132] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cashmere-overcoat + - generic [ref=f5e134]: 499.99 EUR + - generic [ref=f5e135]: Choose options + - region "Stay in the loop" [ref=f5e136]: + - generic [ref=f5e137]: + - heading "Stay in the loop" [level=2] [ref=f5e138] + - paragraph [ref=f5e139]: Subscribe for exclusive offers and updates. + - generic [ref=f5e141]: + - generic [ref=f5e142]: Email address + - textbox "Email address" [ref=f5e143]: + - /placeholder: Enter your email + - button "Subscribe" [ref=f5e144] + - contentinfo [ref=f5e145]: + - generic [ref=f5e146]: + - generic [ref=f5e147]: + - generic [ref=f5e148]: + - heading "Shop" [level=2] [ref=f5e149] + - list [ref=f5e150]: + - listitem [ref=f5e151]: + - link "All collections" [ref=f5e152] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - listitem [ref=f5e153]: + - link "Home" [ref=f5e154] [cursor=pointer]: + - /url: / + - listitem [ref=f5e155]: + - link "New Arrivals" [ref=f5e156] [cursor=pointer]: + - /url: /collections/new-arrivals + - listitem [ref=f5e157]: + - link "T-Shirts" [ref=f5e158] [cursor=pointer]: + - /url: /collections/t-shirts + - listitem [ref=f5e159]: + - link "Pants & Jeans" [ref=f5e160] [cursor=pointer]: + - /url: /collections/pants-jeans + - listitem [ref=f5e161]: + - link "Sale" [ref=f5e162] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=f5e163]: + - heading "Information" [level=2] [ref=f5e164] + - list [ref=f5e165]: + - listitem [ref=f5e166]: + - link "About Us" [ref=f5e167] [cursor=pointer]: + - /url: /pages/about + - listitem [ref=f5e168]: + - link "FAQ" [ref=f5e169] [cursor=pointer]: + - /url: /pages/faq + - listitem [ref=f5e170]: + - link "Shipping & Returns" [ref=f5e171] [cursor=pointer]: + - /url: /pages/shipping-returns + - listitem [ref=f5e172]: + - link "Privacy Policy" [ref=f5e173] [cursor=pointer]: + - /url: /pages/privacy-policy + - listitem [ref=f5e174]: + - link "Terms of Service" [ref=f5e175] [cursor=pointer]: + - /url: /pages/terms + - generic [ref=f5e176]: + - heading "Acme Fashion" [level=2] [ref=f5e177] + - paragraph [ref=f5e178]: 2025 Acme Fashion. All rights reserved. + - generic [ref=f5e179]: + - paragraph [ref=f5e180]: © 2026 Acme Fashion. All rights reserved. + - list "Accepted payment methods" [ref=f5e181]: + - listitem [ref=f5e182]: Visa + - listitem [ref=f5e183]: Mastercard + - listitem [ref=f5e184]: Amex + - listitem [ref=f5e185]: PayPal + - generic [ref=e163]: + - generic [ref=e164]: Colors & typography settings + - generic [ref=e165]: + - generic [ref=e167]: + - generic [ref=e168]: Primary color + - textbox "Primary color" [ref=e169] [cursor=pointer]: "#1a1a2e" + - generic [ref=e171]: + - generic [ref=e172]: Secondary color + - textbox "Secondary color" [ref=e173] [cursor=pointer]: "#e94560" + - generic [ref=e175]: + - generic [ref=e176]: Font family + - combobox "Font family" [ref=e177]: + - option "Instrument Sans" + - option "Inter" [selected] + - option "Georgia" + - option "Menlo" + - generic [ref=e179]: + - generic [ref=e180]: Dark mode + - combobox "Dark mode" [ref=e181]: + - option "Follow system" [selected] + - option "Light" + - option "Dark" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-06-10T08-44-03-884Z.yml b/.playwright-mcp/page-2026-06-10T08-44-03-884Z.yml new file mode 100644 index 00000000..927207e3 --- /dev/null +++ b/.playwright-mcp/page-2026-06-10T08-44-03-884Z.yml @@ -0,0 +1,212 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - complementary "Admin navigation" [ref=e3]: + - link "Shop" [ref=e5] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e8] + - generic [ref=e10]: Shop + - navigation [ref=e11]: + - link "Dashboard" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e13] + - text: Dashboard + - paragraph [ref=e15]: Products + - link "Products" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e17] + - text: Products + - link "Collections" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e20] + - text: Collections + - link "Inventory" [ref=e22] [cursor=pointer]: + - /url: http://shop.test/admin/inventory + - img [ref=e23] + - text: Inventory + - paragraph [ref=e25]: Orders + - link "Orders" [ref=e26] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e27] + - text: Orders + - paragraph [ref=e29]: Customers + - link "Customers" [ref=e30] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e31] + - text: Customers + - paragraph [ref=e33]: Discounts + - link "Discounts" [ref=e34] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e35] + - text: Discounts + - paragraph [ref=e38]: Content + - link "Pages" [ref=e39] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e40] + - text: Pages + - link "Navigation" [ref=e42] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e43] + - text: Navigation + - link "Themes" [ref=e45] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e46] + - text: Themes + - link "Analytics" [ref=e48] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e49] + - text: Analytics + - link "Settings" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e53] + - text: Settings + - link "Apps" [ref=e56] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e57] + - text: Apps + - link "Developers" [ref=e59] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e60] + - text: Developers + - generic [ref=e62]: + - banner [ref=e63]: + - button "Acme Fashion" [ref=e65]: + - generic [ref=e66]: Acme Fashion + - img [ref=e67] + - button "AU Admin User" [ref=e70]: + - generic [ref=e73]: AU + - generic [ref=e74]: Admin User + - img [ref=e76] + - main [ref=e78]: + - generic [ref=e79]: + - generic [ref=e80]: + - generic [ref=e81]: + - link "Home" [ref=e82] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e83] + - generic [ref=e86]: Developers + - generic [ref=e87]: + - heading "Developers" [level=1] [ref=e88] + - button "Generate new token" [ref=e90]: + - img [ref=e91] + - generic [ref=e93]: Generate new token + - generic [ref=e94]: + - generic [ref=e95]: API tokens + - paragraph [ref=e96]: Manage personal access tokens for the Admin API. Tokens are sent as a Bearer header and expire after one year. + - table [ref=e99]: + - rowgroup [ref=e100]: + - row "Name Abilities Last used Created Actions" [ref=e101]: + - columnheader "Name" [ref=e102] + - columnheader "Abilities" [ref=e103] + - columnheader "Last used" [ref=e104] + - columnheader "Created" [ref=e105] + - columnheader "Actions" [ref=e106] + - rowgroup [ref=e107]: + - row "No API tokens yet. Generate one to access the Admin API." [ref=e108]: + - cell "No API tokens yet. Generate one to access the Admin API." [ref=e109]: + - paragraph [ref=e110]: No API tokens yet. Generate one to access the Admin API. + - generic [ref=e111]: + - generic [ref=e112]: + - generic [ref=e113]: Webhooks + - paragraph [ref=e114]: Manage webhook subscriptions for real-time event notifications. + - button "Add webhook" [ref=e115]: + - img [ref=e117] + - img [ref=e120] + - generic [ref=e122]: Add webhook + - table [ref=e125]: + - rowgroup [ref=e126]: + - row "Event type URL Status Last delivery Actions" [ref=e127]: + - columnheader "Event type" [ref=e128] + - columnheader "URL" [ref=e129] + - columnheader "Status" [ref=e130] + - columnheader "Last delivery" [ref=e131] + - columnheader "Actions" [ref=e132] + - rowgroup [ref=e133]: + - row "order.created https://loyalty-rewards.example.test/webhooks/orders Active 4 minutes ago Pause Edit webhook Delete webhook" [ref=e134]: + - cell "order.created" [ref=e135] + - cell "https://loyalty-rewards.example.test/webhooks/orders" [ref=e136] + - cell "Active" [ref=e137]: + - generic [ref=e138]: Active + - cell "4 minutes ago" [ref=e139] + - cell "Pause Edit webhook Delete webhook" [ref=e140]: + - generic [ref=e141]: + - button "Pause" [ref=e142]: + - img [ref=e144] + - generic [ref=e147]: Pause + - button "Edit webhook" [ref=e148]: + - img [ref=e150] + - img [ref=e153] + - button "Delete webhook" [ref=e156]: + - img [ref=e158] + - img [ref=e161] + - row "order.paid https://erp.acme-fashion.example.test/hooks/payments Active 49 seconds ago Pause Edit webhook Delete webhook" [ref=e163]: + - cell "order.paid" [ref=e164] + - cell "https://erp.acme-fashion.example.test/hooks/payments" [ref=e165] + - cell "Active" [ref=e166]: + - generic [ref=e167]: Active + - cell "49 seconds ago" [ref=e168] + - cell "Pause Edit webhook Delete webhook" [ref=e169]: + - generic [ref=e170]: + - button "Pause" [ref=e171]: + - img [ref=e173] + - generic [ref=e176]: Pause + - button "Edit webhook" [ref=e177]: + - img [ref=e179] + - img [ref=e182] + - button "Delete webhook" [ref=e185]: + - img [ref=e187] + - img [ref=e190] + - generic [ref=e192]: + - generic [ref=e193]: Recent deliveries + - paragraph [ref=e194]: The latest webhook delivery attempts across all subscriptions. + - table [ref=e197]: + - rowgroup [ref=e198]: + - row "Event type Status Response Attempts Last attempt" [ref=e199]: + - columnheader "Event type" [ref=e200] + - columnheader "Status" [ref=e201] + - columnheader "Response" [ref=e202] + - columnheader "Attempts" [ref=e203] + - columnheader "Last attempt" [ref=e204] + - rowgroup [ref=e205]: + - row "order.paid Failed No response 1 49 seconds ago" [ref=e206]: + - cell "order.paid" [ref=e207] + - cell "Failed" [ref=e208]: + - generic [ref=e209]: Failed + - cell "No response" [ref=e210] + - cell "1" [ref=e211] + - cell "49 seconds ago" [ref=e212] + - row "order.created Failed No response 1 4 minutes ago" [ref=e213]: + - cell "order.created" [ref=e214] + - cell "Failed" [ref=e215]: + - generic [ref=e216]: Failed + - cell "No response" [ref=e217] + - cell "1" [ref=e218] + - cell "4 minutes ago" [ref=e219] + - row "order.paid Pending 500 2 16 minutes ago" [ref=e220]: + - cell "order.paid" [ref=e221] + - cell "Pending" [ref=e222]: + - generic [ref=e223]: Pending + - cell "500" [ref=e224] + - cell "2" [ref=e225] + - cell "16 minutes ago" [ref=e226] + - row "order.paid Success 200 1 16 minutes ago" [ref=e227]: + - cell "order.paid" [ref=e228] + - cell "Success" [ref=e229]: + - generic [ref=e230]: Success + - cell "200" [ref=e231] + - cell "1" [ref=e232] + - cell "16 minutes ago" [ref=e233] + - row "order.created Success 200 1 16 minutes ago" [ref=e234]: + - cell "order.created" [ref=e235] + - cell "Success" [ref=e236]: + - generic [ref=e237]: Success + - cell "200" [ref=e238] + - cell "1" [ref=e239] + - cell "16 minutes ago" [ref=e240] + - row "order.created Success 200 1 16 minutes ago" [ref=e241]: + - cell "order.created" [ref=e242] + - cell "Success" [ref=e243]: + - generic [ref=e244]: Success + - cell "200" [ref=e245] + - cell "1" [ref=e246] + - cell "16 minutes ago" [ref=e247] \ No newline at end of file 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..d5ffb3f2 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +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. diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 3c7c00c8..a99e46c1 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -27,7 +27,7 @@ public function create(array $input): User return User::create([ 'name' => $input['name'], 'email' => $input['email'], - 'password' => $input['password'], + 'password_hash' => $input['password'], ]); } } diff --git a/app/Actions/Fortify/ResetUserPassword.php b/app/Actions/Fortify/ResetUserPassword.php index 8fda5ddd..f273ca81 100644 --- a/app/Actions/Fortify/ResetUserPassword.php +++ b/app/Actions/Fortify/ResetUserPassword.php @@ -23,7 +23,7 @@ public function reset(User $user, array $input): void ])->validate(); $user->forceFill([ - 'password' => $input['password'], + 'password_hash' => $input['password'], ])->save(); } } diff --git a/app/Auth/CustomerUserProvider.php b/app/Auth/CustomerUserProvider.php new file mode 100644 index 00000000..9b939994 --- /dev/null +++ b/app/Auth/CustomerUserProvider.php @@ -0,0 +1,60 @@ +createModel(); + + $query = $this->newModelQuery($model) + ->where($model->getAuthIdentifierName(), $identifier); + + if ($storeId = $this->currentStoreId()) { + $query->where('store_id', $storeId); + } + + return $query->first(); + } + + /** + * Remember-me tokens are not supported for customers. + */ + public function retrieveByToken($identifier, #[\SensitiveParameter] $token): ?Authenticatable + { + return null; + } + + /** + * Retrieve a customer by the given credentials, always scoped to the current store. + * + * @param array $credentials + */ + public function retrieveByCredentials(#[\SensitiveParameter] array $credentials): ?Authenticatable + { + if (empty($credentials)) { + return null; + } + + if ($storeId = $this->currentStoreId()) { + $credentials['store_id'] = $storeId; + } + + return parent::retrieveByCredentials($credentials); + } + + /** + * Resolve the id of the current store bound in the container, if any. + */ + protected function currentStoreId(): ?int + { + return app()->bound('current_store') ? app('current_store')->getKey() : null; + } +} diff --git a/app/Contracts/PaymentProvider.php b/app/Contracts/PaymentProvider.php new file mode 100644 index 00000000..97f75c25 --- /dev/null +++ b/app/Contracts/PaymentProvider.php @@ -0,0 +1,26 @@ + $details Method-specific details, e.g. card number + */ + public function charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult; + + /** + * Issue a refund against a captured payment. + */ + public function refund(Payment $payment, int $amount): RefundResult; +} diff --git a/app/Enums/AppInstallationStatus.php b/app/Enums/AppInstallationStatus.php new file mode 100644 index 00000000..d4a43b46 --- /dev/null +++ b/app/Enums/AppInstallationStatus.php @@ -0,0 +1,10 @@ + __('Credit Card'), + self::Paypal => __('PayPal'), + self::BankTransfer => __('Bank Transfer'), + }; + } +} diff --git a/app/Enums/PaymentStatus.php b/app/Enums/PaymentStatus.php new file mode 100644 index 00000000..63a3e3bb --- /dev/null +++ b/app/Enums/PaymentStatus.php @@ -0,0 +1,11 @@ +value}\").", + ); + } +} diff --git a/app/Exceptions/InsufficientInventoryException.php b/app/Exceptions/InsufficientInventoryException.php new file mode 100644 index 00000000..3f070726 --- /dev/null +++ b/app/Exceptions/InsufficientInventoryException.php @@ -0,0 +1,13 @@ +value}\" state."); + } +} diff --git a/app/Exceptions/InvalidDiscountException.php b/app/Exceptions/InvalidDiscountException.php new file mode 100644 index 00000000..cf98197b --- /dev/null +++ b/app/Exceptions/InvalidDiscountException.php @@ -0,0 +1,50 @@ +value} to {$to->value}: {$reason}"); + } +} diff --git a/app/Exceptions/InvalidShippingRateException.php b/app/Exceptions/InvalidShippingRateException.php new file mode 100644 index 00000000..477ff92f --- /dev/null +++ b/app/Exceptions/InvalidShippingRateException.php @@ -0,0 +1,13 @@ + 'Payment declined: your card was declined.', + 'insufficient_funds' => 'Payment declined: insufficient funds.', + default => "Payment failed ({$errorCode}).", + }); + } +} diff --git a/app/Exceptions/ProductDeletionException.php b/app/Exceptions/ProductDeletionException.php new file mode 100644 index 00000000..dd60bad8 --- /dev/null +++ b/app/Exceptions/ProductDeletionException.php @@ -0,0 +1,7 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + ]); + + $attempted = Auth::guard('web')->attempt([ + 'email' => $validated['email'], + 'password' => $validated['password'], + 'status' => 'active', + ], $request->boolean('remember')); + + if (! $attempted) { + throw ValidationException::withMessages([ + 'email' => __('Invalid credentials'), + ]); + } + + $request->session()->regenerate(); + + $user = $request->user(); + $user->forceFill(['last_login_at' => now()])->save(); + + if (! $request->session()->has('current_store_id')) { + $firstStoreId = $user->stores()->value('stores.id'); + + if ($firstStoreId !== null) { + $request->session()->put('current_store_id', $firstStoreId); + } + } + + return redirect()->intended(route('admin.dashboard')); + } + + /** + * Log the admin out and invalidate the session. + */ + public function destroy(Request $request): RedirectResponse + { + Auth::guard('web')->logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('admin.login'); + } +} diff --git a/app/Http/Controllers/Api/Admin/OrderController.php b/app/Http/Controllers/Api/Admin/OrderController.php new file mode 100644 index 00000000..63c0f50e --- /dev/null +++ b/app/Http/Controllers/Api/Admin/OrderController.php @@ -0,0 +1,80 @@ +validate([ + 'status' => ['nullable', Rule::in(['pending', 'paid', 'fulfilled', 'cancelled', 'refunded'])], + 'financial_status' => ['nullable', Rule::in(['pending', 'paid', 'partially_refunded', 'refunded'])], + 'fulfillment_status' => ['nullable', Rule::in(['unfulfilled', 'partial', 'fulfilled'])], + 'customer_id' => ['nullable', 'integer'], + 'created_after' => ['nullable', 'date'], + 'created_before' => ['nullable', 'date'], + 'query' => ['nullable', 'string', 'max:255'], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + 'sort' => ['nullable', Rule::in(['placed_at_desc', 'placed_at_asc', 'total_desc', 'total_asc'])], + ]); + + [$sortColumn, $sortDirection] = match ($validated['sort'] ?? 'placed_at_desc') { + 'placed_at_asc' => ['placed_at', 'asc'], + 'total_desc' => ['total_amount', 'desc'], + 'total_asc' => ['total_amount', 'asc'], + default => ['placed_at', 'desc'], + }; + + $orders = Order::query() + ->with('customer') + ->withCount('lines') + ->when(isset($validated['status']), fn ($query) => $query->where('status', $validated['status'])) + ->when(isset($validated['financial_status']), fn ($query) => $query->where('financial_status', $validated['financial_status'])) + ->when(isset($validated['fulfillment_status']), fn ($query) => $query->where('fulfillment_status', $validated['fulfillment_status'])) + ->when(isset($validated['customer_id']), fn ($query) => $query->where('customer_id', $validated['customer_id'])) + ->when(isset($validated['created_after']), fn ($query) => $query->where('placed_at', '>=', $validated['created_after'])) + ->when(isset($validated['created_before']), fn ($query) => $query->where('placed_at', '<=', $validated['created_before'])) + ->when(filled($validated['query'] ?? null), function ($query) use ($validated): void { + $term = '%'.$validated['query'].'%'; + $query->where(fn ($inner) => $inner + ->where('order_number', 'like', $term) + ->orWhere('email', 'like', $term)); + }) + ->orderBy($sortColumn, $sortDirection) + ->paginate(perPage: (int) ($validated['per_page'] ?? 25)); + + return response()->json([ + 'data' => OrderListResource::collection($orders->items())->resolve(), + 'meta' => [ + 'current_page' => $orders->currentPage(), + 'per_page' => $orders->perPage(), + 'total' => $orders->total(), + 'last_page' => $orders->lastPage(), + ], + ]); + } + + /** + * GET /api/admin/v1/stores/{storeId}/orders/{orderId} + */ + public function show(int $storeId, int $orderId): OrderResource + { + return new OrderResource( + Order::query() + ->with(['customer', 'lines', 'payments', 'fulfillments.lines', 'refunds']) + ->findOrFail($orderId), + ); + } +} diff --git a/app/Http/Controllers/Api/Admin/OrderFulfillmentController.php b/app/Http/Controllers/Api/Admin/OrderFulfillmentController.php new file mode 100644 index 00000000..a6e1c647 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/OrderFulfillmentController.php @@ -0,0 +1,59 @@ +findOrFail($orderId); + + $validated = $request->validate([ + 'tracking_company' => ['nullable', 'string', 'max:255'], + 'tracking_number' => ['nullable', 'string', 'max:255'], + 'tracking_url' => ['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' => ['nullable', 'boolean'], + ]); + + $lines = collect($validated['line_items']) + ->mapWithKeys(fn (array $line): array => [(int) $line['order_line_id'] => (int) $line['quantity']]) + ->all(); + + $tracking = [ + 'tracking_company' => $validated['tracking_company'] ?? null, + 'tracking_number' => $validated['tracking_number'] ?? null, + 'tracking_url' => $validated['tracking_url'] ?? null, + ]; + + try { + $fulfillment = $this->fulfillmentService->create($order, $lines, $tracking); + } catch (FulfillmentGuardException $exception) { + return response()->json(['message' => $exception->getMessage()], 409); + } + + $this->fulfillmentService->markAsShipped($fulfillment, $tracking); + + return (new FulfillmentResource($fulfillment->refresh()->load('lines'))) + ->response() + ->setStatusCode(201); + } +} diff --git a/app/Http/Controllers/Api/Admin/OrderRefundController.php b/app/Http/Controllers/Api/Admin/OrderRefundController.php new file mode 100644 index 00000000..c245f27c --- /dev/null +++ b/app/Http/Controllers/Api/Admin/OrderRefundController.php @@ -0,0 +1,55 @@ +findOrFail($orderId); + + $validated = $request->validate([ + 'amount' => ['required', 'integer', 'min:1'], + 'reason' => ['nullable', 'string', 'max:1000'], + 'line_items' => ['nullable', 'array'], + 'line_items.*.order_line_id' => ['required', 'integer'], + 'line_items.*.quantity' => ['required', 'integer', 'min:1'], + 'notify_customer' => ['nullable', 'boolean'], + 'restock' => ['nullable', 'boolean'], + ]); + + $payment = $order->payments() + ->whereIn('status', [PaymentStatus::Captured, PaymentStatus::Refunded]) + ->latest('id') + ->first(); + + if ($payment === null || $order->remainingRefundableAmount() < 1) { + return response()->json([ + 'message' => __('This order cannot be refunded.'), + ], 409); + } + + $refund = $this->refundService->create( + $order, + $payment, + $validated['amount'], + $validated['reason'] ?? null, + (bool) ($validated['restock'] ?? false), + ); + + return (new RefundResource($refund))->response()->setStatusCode(201); + } +} diff --git a/app/Http/Controllers/Api/Admin/ProductController.php b/app/Http/Controllers/Api/Admin/ProductController.php new file mode 100644 index 00000000..2476b288 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/ProductController.php @@ -0,0 +1,392 @@ +validate([ + 'status' => ['nullable', Rule::in(['draft', 'active', 'archived'])], + 'query' => ['nullable', 'string', 'max:255'], + 'collection_id' => ['nullable', 'integer'], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + 'sort' => ['nullable', Rule::in(['title_asc', 'title_desc', 'created_at_asc', 'created_at_desc', 'updated_at_desc'])], + ]); + + [$sortColumn, $sortDirection] = match ($validated['sort'] ?? 'updated_at_desc') { + 'title_asc' => ['title', 'asc'], + 'title_desc' => ['title', 'desc'], + 'created_at_asc' => ['created_at', 'asc'], + 'created_at_desc' => ['created_at', 'desc'], + default => ['updated_at', 'desc'], + }; + + $products = Product::query() + ->with('media') + ->withCount('variants') + ->addSelect([ + 'total_inventory' => InventoryItem::query() + ->withoutGlobalScopes() + ->join('product_variants', 'product_variants.id', '=', 'inventory_items.variant_id') + ->whereColumn('product_variants.product_id', 'products.id') + ->selectRaw('coalesce(sum(inventory_items.quantity_on_hand), 0)'), + ]) + ->when(isset($validated['status']), fn ($query) => $query->where('status', $validated['status'])) + ->when(filled($validated['query'] ?? null), function ($query) use ($validated): void { + $term = '%'.$validated['query'].'%'; + $query->where(fn ($inner) => $inner + ->where('title', 'like', $term) + ->orWhere('vendor', 'like', $term) + ->orWhereHas('variants', fn ($variants) => $variants->where('sku', 'like', $term))); + }) + ->when(isset($validated['collection_id']), fn ($query) => $query + ->whereHas('collections', fn ($collections) => $collections->whereKey($validated['collection_id']))) + ->orderBy($sortColumn, $sortDirection) + ->paginate(perPage: (int) ($validated['per_page'] ?? 15)); + + return response()->json([ + 'data' => ProductListResource::collection($products->items())->resolve(), + 'meta' => [ + 'current_page' => $products->currentPage(), + 'per_page' => $products->perPage(), + 'total' => $products->total(), + 'last_page' => $products->lastPage(), + ], + ]); + } + + /** + * POST /api/admin/v1/stores/{storeId}/products + */ + public function store(Request $request): JsonResponse + { + $validated = $this->validateProductPayload($request, creating: true); + + $product = DB::transaction(function () use ($validated): Product { + $store = app('current_store'); + + $product = new Product([ + 'title' => $validated['title'], + 'handle' => $validated['handle'] + ?? $this->handleGenerator->generate($validated['title'], 'products', $store->getKey()), + 'description_html' => $validated['description_html'] ?? null, + 'vendor' => $validated['vendor'] ?? null, + 'product_type' => $validated['product_type'] ?? null, + 'status' => $validated['status'] ?? ProductStatus::Draft->value, + 'tags' => $validated['tags'] ?? [], + ]); + $product->store_id = $store->getKey(); + + if ($product->status === ProductStatus::Active) { + $product->published_at = now(); + } + + $product->save(); + + $optionValueIds = $this->createOptions($product, $validated); + + foreach ($validated['variants'] as $position => $variantPayload) { + $this->createVariantFromPayload($product, $variantPayload, $position + 1, $optionValueIds); + } + + $this->ensureSingleDefaultVariant($product); + + if (! empty($validated['collections'])) { + $product->collections()->sync($validated['collections']); + } + + return $product; + }); + + return (new ProductResource($product->load(self::DETAIL_RELATIONS))) + ->response() + ->setStatusCode(201); + } + + /** + * GET /api/admin/v1/stores/{storeId}/products/{productId} + */ + public function show(int $storeId, int $productId): ProductResource + { + return new ProductResource( + Product::query()->with(self::DETAIL_RELATIONS)->findOrFail($productId), + ); + } + + /** + * PUT /api/admin/v1/stores/{storeId}/products/{productId} + */ + public function update(Request $request, int $storeId, int $productId): ProductResource + { + $product = Product::query()->findOrFail($productId); + + $validated = $this->validateProductPayload($request, creating: false, product: $product); + + DB::transaction(function () use ($product, $validated): void { + $fields = array_intersect_key($validated, array_flip([ + 'title', 'handle', 'description_html', 'vendor', 'product_type', 'tags', + ])); + + if ($fields !== []) { + $this->productService->update($product, $fields); + } + + if (isset($validated['status'])) { + $this->transitionStatus($product, ProductStatus::from($validated['status'])); + } + + foreach ($validated['variants'] ?? [] as $variantPayload) { + $this->upsertVariant($product, $variantPayload); + } + + if (array_key_exists('collections', $validated)) { + $product->collections()->sync($validated['collections'] ?? []); + } + }); + + return new ProductResource($product->refresh()->load(self::DETAIL_RELATIONS)); + } + + /** + * DELETE /api/admin/v1/stores/{storeId}/products/{productId} + * + * Archives the product (soft delete, spec 02 section 3.2). + */ + public function destroy(int $storeId, int $productId): JsonResponse + { + $product = Product::query()->findOrFail($productId); + + $this->transitionStatus($product, ProductStatus::Archived); + + return response()->json([ + 'data' => [ + 'id' => $product->getKey(), + 'status' => $product->status->value, + 'updated_at' => $product->updated_at?->toIso8601String(), + ], + ]); + } + + /** + * @return array + */ + protected function validateProductPayload(Request $request, bool $creating, ?Product $product = null): array + { + $storeId = app('current_store')->getKey(); + + return $request->validate([ + 'title' => [$creating ? 'required' : 'sometimes', 'string', 'max:255'], + 'handle' => [ + 'nullable', 'string', 'max:255', + Rule::unique('products', 'handle') + ->where('store_id', $storeId) + ->ignore($product?->getKey()), + ], + 'description_html' => ['nullable', 'string', 'max:65535'], + 'vendor' => ['nullable', 'string', 'max:255'], + 'product_type' => ['nullable', 'string', 'max:255'], + 'status' => ['nullable', Rule::in($creating ? ['draft', 'active'] : ['draft', 'active', 'archived'])], + 'tags' => ['nullable', 'array', 'max:50'], + 'tags.*' => ['string', 'max:255'], + 'options' => ['nullable', 'array', 'max:3'], + 'options.*.name' => ['required', 'string', 'max:255'], + 'options.*.position' => ['nullable', 'integer', 'min:1', 'max:3'], + 'variants' => [$creating ? 'required' : 'sometimes', 'array', 'min:1', 'max:100'], + 'variants.*.id' => ['nullable', 'integer'], + 'variants.*.sku' => [$creating ? 'required' : 'sometimes', 'string', 'max:255'], + 'variants.*.barcode' => ['nullable', 'string', 'max:255'], + 'variants.*.price_amount' => [$creating ? 'required' : 'sometimes', 'integer', 'min:0'], + 'variants.*.compare_at_amount' => ['nullable', 'integer', 'min:0'], + 'variants.*.currency' => ['nullable', 'string', 'size:3'], + 'variants.*.weight_g' => ['nullable', 'integer', 'min:0'], + 'variants.*.requires_shipping' => ['nullable', 'boolean'], + 'variants.*.is_default' => ['nullable', 'boolean'], + 'variants.*.position' => ['nullable', 'integer', 'min:1'], + 'variants.*.status' => ['nullable', Rule::in(['active', 'archived'])], + 'variants.*.option_values' => ['nullable', 'array'], + 'variants.*.option_values.*.option_name' => ['required', 'string', 'max:255'], + 'variants.*.option_values.*.value' => ['required', 'string', 'max:255'], + 'variants.*.inventory' => ['nullable', 'array'], + 'variants.*.inventory.quantity_on_hand' => ['nullable', 'integer', 'min:0'], + 'variants.*.inventory.policy' => ['nullable', Rule::in(['deny', 'continue'])], + 'collections' => ['nullable', 'array'], + 'collections.*' => ['integer', Rule::exists('collections', 'id')->where('store_id', $storeId)], + ]); + } + + /** + * Create the product's options; values are collected from the variants' + * option_values in order of first appearance. Returns option value ids + * keyed by "Option name|Value". + * + * @param array $validated + * @return array + */ + protected function createOptions(Product $product, array $validated): array + { + $optionValueIds = []; + + $options = collect($validated['options'] ?? []) + ->sortBy(fn (array $option, int $index): int => (int) ($option['position'] ?? $index + 1)) + ->values(); + + foreach ($options as $position => $option) { + $productOption = $product->options()->create([ + 'name' => $option['name'], + 'position' => $position, + ]); + + $values = collect($validated['variants']) + ->flatMap(fn (array $variant) => collect($variant['option_values'] ?? []) + ->filter(fn (array $value): bool => $value['option_name'] === $option['name']) + ->pluck('value')) + ->unique() + ->values(); + + foreach ($values as $valuePosition => $value) { + $optionValue = $productOption->values()->create([ + 'value' => $value, + 'position' => $valuePosition, + ]); + + $optionValueIds[$option['name'].'|'.$value] = $optionValue->getKey(); + } + } + + return $optionValueIds; + } + + /** + * Create a variant with its inventory item and option value links. + * + * @param array $payload + * @param array $optionValueIds + */ + protected function createVariantFromPayload(Product $product, array $payload, int $position, array $optionValueIds): ProductVariant + { + $variant = $this->productService->createVariant($product, [ + 'sku' => $payload['sku'] ?? null, + 'barcode' => $payload['barcode'] ?? null, + 'price_amount' => $payload['price_amount'] ?? 0, + 'compare_at_amount' => $payload['compare_at_amount'] ?? null, + 'weight_g' => $payload['weight_g'] ?? null, + 'requires_shipping' => $payload['requires_shipping'] ?? true, + 'is_default' => $payload['is_default'] ?? false, + 'status' => $payload['status'] ?? 'active', + 'position' => $payload['position'] ?? $position, + ]); + + $valueIds = collect($payload['option_values'] ?? []) + ->map(fn (array $value): ?int => $optionValueIds[$value['option_name'].'|'.$value['value']] ?? null) + ->filter() + ->all(); + + if ($valueIds !== []) { + $variant->optionValues()->sync($valueIds); + } + + $inventory = $payload['inventory'] ?? null; + + if ($inventory !== null) { + $variant->inventoryItem?->update([ + 'quantity_on_hand' => $inventory['quantity_on_hand'] ?? 0, + 'policy' => $inventory['policy'] ?? InventoryPolicy::Deny->value, + ]); + } + + return $variant; + } + + /** + * Update an existing variant by id or create a new one (spec 02 + * section 3.2: variants can be added or updated by ID). + * + * @param array $payload + */ + protected function upsertVariant(Product $product, array $payload): void + { + $variant = isset($payload['id']) + ? $product->variants()->findOrFail($payload['id']) + : null; + + if ($variant === null) { + $position = (int) $product->variants()->max('position') + 1; + $this->createVariantFromPayload($product, $payload, $position, []); + + return; + } + + $variant->fill(array_intersect_key($payload, array_flip([ + 'sku', 'barcode', 'price_amount', 'compare_at_amount', 'weight_g', + 'requires_shipping', 'is_default', 'position', 'status', + ])))->save(); + + $inventory = $payload['inventory'] ?? null; + + if ($inventory !== null) { + $variant->inventoryItem?->update(array_intersect_key($inventory, array_flip([ + 'quantity_on_hand', 'policy', + ]))); + } + } + + /** + * Exactly one variant must be the default; fall back to the first one. + */ + protected function ensureSingleDefaultVariant(Product $product): void + { + if (! $product->variants()->where('is_default', true)->exists()) { + $product->variants()->orderBy('position')->limit(1)->update(['is_default' => true]); + } + } + + /** + * Run the product status state machine, mapping guard failures to 422. + */ + protected function transitionStatus(Product $product, ProductStatus $status): void + { + try { + $this->productService->transitionStatus($product, $status); + } catch (InvalidProductTransitionException $exception) { + throw ValidationException::withMessages(['status' => $exception->getMessage()]); + } + } +} diff --git a/app/Http/Controllers/Api/Storefront/AnalyticsEventController.php b/app/Http/Controllers/Api/Storefront/AnalyticsEventController.php new file mode 100644 index 00000000..b070f188 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/AnalyticsEventController.php @@ -0,0 +1,65 @@ +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.*.properties' => ['sometimes', 'array'], + 'events.*.occurred_at' => [ + 'required', + 'date', + 'after_or_equal:'.now()->subHour()->toIso8601String(), + 'before_or_equal:'.now()->addHour()->toIso8601String(), + ], + ]); + + $store = app('current_store'); + $customerId = $request->user('customer')?->getKey(); + + $accepted = 0; + $rejected = 0; + + foreach ($validated['events'] as $event) { + $tracked = $this->analytics->track( + $store, + $event['type'], + $event['properties'] ?? [], + $event['session_id'], + $customerId, + $event['client_event_id'], + Carbon::parse($event['occurred_at'])->toIso8601String(), + ); + + $tracked !== null ? $accepted++ : $rejected++; + } + + return response()->json([ + 'accepted' => $accepted, + 'rejected' => $rejected, + ], 202); + } +} diff --git a/app/Http/Controllers/Api/Storefront/CartController.php b/app/Http/Controllers/Api/Storefront/CartController.php new file mode 100644 index 00000000..92424e14 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/CartController.php @@ -0,0 +1,152 @@ +validate([ + 'currency' => ['nullable', 'string', 'size:3'], + ]); + + $cart = $this->cartService->create(app('current_store')); + + if (isset($validated['currency'])) { + $cart->forceFill(['currency' => strtoupper($validated['currency'])])->save(); + } + + return (new CartResource($cart))->response()->setStatusCode(201); + } + + /** + * GET /api/storefront/v1/carts/{cartId} + */ + public function show(int $cartId): CartResource + { + return new CartResource($this->findCart($cartId)); + } + + /** + * POST /api/storefront/v1/carts/{cartId}/lines + */ + public function storeLine(Request $request, int $cartId): CartResource|JsonResponse + { + $cart = $this->findCart($cartId); + + $validated = $request->validate([ + 'variant_id' => ['required', 'integer', Rule::exists('product_variants', 'id')], + 'quantity' => ['required', 'integer', 'min:1', 'max:9999'], + 'cart_version' => ['nullable', 'integer'], + ]); + + try { + $this->assertVersionWhenProvided($cart, $request); + $this->cartService->addLine($cart, $validated['variant_id'], $validated['quantity']); + } catch (CartVersionMismatchException) { + return $this->versionConflictResponse($cart); + } + + return new CartResource($cart->refresh()); + } + + /** + * PUT /api/storefront/v1/carts/{cartId}/lines/{lineId} + */ + public function updateLine(Request $request, int $cartId, int $lineId): CartResource|JsonResponse + { + $cart = $this->findCart($cartId); + + $validated = $request->validate([ + 'quantity' => ['required', 'integer', 'min:1', 'max:9999'], + 'cart_version' => ['required_without:expected_version', 'integer'], + 'expected_version' => ['nullable', 'integer'], + ]); + + try { + $this->cartService->assertVersion($cart, $this->expectedVersion($request)); + $this->cartService->updateLineQuantity($cart, $lineId, $validated['quantity']); + } catch (CartVersionMismatchException) { + return $this->versionConflictResponse($cart); + } + + return new CartResource($cart->refresh()); + } + + /** + * DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId} + */ + public function destroyLine(Request $request, int $cartId, int $lineId): CartResource|JsonResponse + { + $cart = $this->findCart($cartId); + + $request->validate([ + 'cart_version' => ['required_without:expected_version', 'integer'], + 'expected_version' => ['nullable', 'integer'], + ]); + + try { + $this->cartService->assertVersion($cart, $this->expectedVersion($request)); + $this->cartService->removeLine($cart, $lineId); + } catch (CartVersionMismatchException) { + return $this->versionConflictResponse($cart); + } + + return new CartResource($cart->refresh()); + } + + /** + * Resolve an active cart for the current store or fail with 404. + */ + protected function findCart(int $cartId): Cart + { + return Cart::query()->active()->findOrFail($cartId); + } + + /** + * The optimistic concurrency version the client last saw. Spec 02 names + * the field "cart_version"; the roadmap test table uses + * "expected_version", so both are accepted. + */ + protected function expectedVersion(Request $request): int + { + return (int) $request->input('cart_version', $request->input('expected_version')); + } + + /** + * @throws CartVersionMismatchException + */ + protected function assertVersionWhenProvided(Cart $cart, Request $request): void + { + if ($request->filled('cart_version') || $request->filled('expected_version')) { + $this->cartService->assertVersion($cart, $this->expectedVersion($request)); + } + } + + /** + * 409 conflict envelope with the current cart state (spec 02 section 10). + */ + protected function versionConflictResponse(Cart $cart): JsonResponse + { + return response()->json([ + 'message' => __('The cart has been modified. Please refresh and try again.'), + 'error_code' => 'version_conflict', + 'current_version' => $cart->cart_version, + 'cart' => (new CartResource($cart))->resolve(), + ], 409); + } +} diff --git a/app/Http/Controllers/Api/Storefront/CheckoutController.php b/app/Http/Controllers/Api/Storefront/CheckoutController.php new file mode 100644 index 00000000..65e91fa3 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/CheckoutController.php @@ -0,0 +1,250 @@ +validate([ + 'cart_id' => ['required', 'integer'], + 'email' => ['required', 'email'], + ]); + + $cart = Cart::query()->active()->findOrFail($validated['cart_id']); + + $checkout = $this->checkoutService->createFromCart($cart); + $checkout->forceFill(['email' => $validated['email']])->save(); + + return (new CheckoutResource($checkout->refresh()))->response()->setStatusCode(201); + } + + /** + * GET /api/storefront/v1/checkouts/{checkoutId} + */ + public function show(int $checkoutId): CheckoutResource + { + return new CheckoutResource($this->findCheckout($checkoutId)); + } + + /** + * PUT /api/storefront/v1/checkouts/{checkoutId}/address + */ + public function updateAddress(Request $request, int $checkoutId): CheckoutResource + { + $checkout = $this->findCheckout($checkoutId); + + $payload = [ + 'email' => $request->input('email', $checkout->email), + 'shipping_address' => $request->input('shipping_address'), + 'billing_address' => $request->boolean('use_shipping_as_billing', true) + ? null + : $request->input('billing_address'), + ]; + + try { + $checkout = $this->checkoutService->setAddress($checkout, $payload); + } catch (InvalidCheckoutTransitionException $exception) { + throw ValidationException::withMessages(['checkout' => $exception->getMessage()]); + } + + return new CheckoutResource($checkout); + } + + /** + * PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method + */ + public function updateShippingMethod(Request $request, int $checkoutId): CheckoutResource + { + $checkout = $this->findCheckout($checkoutId); + + $validated = $request->validate([ + 'shipping_method_id' => ['nullable', 'integer'], + ]); + + try { + $checkout = $this->checkoutService->setShippingMethod($checkout, $validated['shipping_method_id'] ?? null); + } catch (InvalidShippingRateException|InvalidCheckoutTransitionException $exception) { + throw ValidationException::withMessages(['shipping_method_id' => $exception->getMessage()]); + } + + return new CheckoutResource($checkout); + } + + /** + * POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount + */ + public function applyDiscount(Request $request, int $checkoutId): CheckoutResource|JsonResponse + { + $checkout = $this->findCheckout($checkoutId); + + $validated = $request->validate([ + 'code' => ['required', 'string', 'max:50'], + ]); + + $cart = Cart::query()->withoutGlobalScopes()->findOrFail($checkout->cart_id); + + try { + $discount = $this->discountService->validate($validated['code'], $checkout->store, $cart); + } catch (InvalidDiscountException $exception) { + return $this->discountErrorResponse($exception); + } + + $checkout->forceFill(['discount_code' => $discount->code])->save(); + $this->checkoutService->recalculate($checkout); + + return new CheckoutResource($checkout->refresh()); + } + + /** + * DELETE /api/storefront/v1/checkouts/{checkoutId}/discount + */ + public function removeDiscount(int $checkoutId): CheckoutResource + { + $checkout = $this->findCheckout($checkoutId); + + if (blank($checkout->discount_code)) { + abort(404, 'No discount is applied to this checkout.'); + } + + $checkout->forceFill(['discount_code' => null])->save(); + $this->checkoutService->recalculate($checkout); + + return new CheckoutResource($checkout->refresh()); + } + + /** + * PUT /api/storefront/v1/checkouts/{checkoutId}/payment-method + */ + public function updatePaymentMethod(Request $request, int $checkoutId): CheckoutResource + { + $checkout = $this->findCheckout($checkoutId); + + $validated = $request->validate([ + 'payment_method' => ['required', 'string', 'in:credit_card,paypal,bank_transfer'], + ]); + + try { + $checkout = $this->checkoutService->selectPaymentMethod($checkout, $validated['payment_method']); + } catch (InvalidCheckoutTransitionException $exception) { + throw ValidationException::withMessages(['payment_method' => $exception->getMessage()]); + } + + return new CheckoutResource($checkout); + } + + /** + * POST /api/storefront/v1/checkouts/{checkoutId}/pay + */ + public function pay(Request $request, int $checkoutId): JsonResponse + { + $checkout = $this->findCheckout($checkoutId); + + $validated = $request->validate([ + 'payment_method' => ['required', 'string', 'in:credit_card,paypal,bank_transfer'], + 'card_number' => ['required_if:payment_method,credit_card', 'string', 'max:32'], + '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'], + ]); + + try { + $order = $this->checkoutService->completeCheckout($checkout, $validated); + } catch (InvalidCheckoutTransitionException $exception) { + return response()->json(['message' => $exception->getMessage()], 409); + } catch (PaymentFailedException $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + 'error_code' => $exception->errorCode, + ], 422); + } + + $payload = [ + 'checkout_id' => $checkout->getKey(), + 'status' => CheckoutStatus::Completed->value, + 'order' => [ + 'id' => $order->getKey(), + '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' => PriceFormatter::format($order->total_amount, $order->currency), + ]; + } + + return response()->json($payload); + } + + /** + * Resolve a checkout for the current store: 404 when unknown, 410 when + * expired (spec 02 section 2.2). + */ + protected function findCheckout(int $checkoutId): Checkout + { + $checkout = Checkout::query()->findOrFail($checkoutId); + + $isExpired = $checkout->status === CheckoutStatus::Expired + || ($checkout->status !== CheckoutStatus::Completed && $checkout->expires_at?->isPast() === true); + + if ($isExpired) { + abort(410, 'This checkout has expired.'); + } + + return $checkout; + } + + /** + * Map discount validation failures to the spec 02 error envelope: + * expired and usage-exceeded codes are business errors (400), everything + * else is a validation error (422). + */ + protected function discountErrorResponse(InvalidDiscountException $exception): JsonResponse + { + [$status, $errorCode] = match ($exception->reason) { + 'expired' => [400, 'discount_expired'], + 'usage_limit_reached' => [400, 'discount_usage_exceeded'], + default => [422, 'discount_'.$exception->reason], + }; + + return response()->json([ + 'message' => $exception->getMessage(), + 'error_code' => $errorCode, + ], $status); + } +} diff --git a/app/Http/Controllers/Api/Storefront/SearchController.php b/app/Http/Controllers/Api/Storefront/SearchController.php new file mode 100644 index 00000000..dfd1d135 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/SearchController.php @@ -0,0 +1,214 @@ +validate([ + 'q' => ['required', 'string', 'min:1', 'max:200'], + 'filters' => ['sometimes', 'json'], + 'sort' => ['sometimes', 'string', 'in:relevance,price_asc,price_desc,newest,best_selling'], + 'page' => ['sometimes', 'integer', 'min:1'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:50'], + ]); + + $store = app('current_store'); + $filters = $this->normalizeFilters(json_decode($validated['filters'] ?? '{}', true) ?: []); + + $results = $this->search->search( + $store, + $validated['q'], + $filters, + (int) ($validated['per_page'] ?? 24), + $validated['sort'] ?? 'relevance', + 'page', + isset($validated['page']) ? (int) $validated['page'] : null, + ); + + /** @var \Illuminate\Database\Eloquent\Collection $products */ + $products = $results->getCollection(); + + return response()->json([ + 'query' => $validated['q'], + 'results' => $products->map(fn (Product $product): array => $this->serializeProduct($product))->values(), + 'facets' => $this->facets($products), + 'pagination' => [ + 'current_page' => $results->currentPage(), + 'per_page' => $results->perPage(), + 'total' => $results->total(), + 'last_page' => $results->lastPage(), + ], + ]); + } + + /** + * GET /api/storefront/v1/search/suggest (spec 02 section 2.5). + */ + public function suggest(Request $request): JsonResponse + { + $validated = $request->validate([ + 'q' => ['required', 'string', 'min:1', 'max:100'], + 'limit' => ['sometimes', 'integer', 'min:1', 'max:10'], + ]); + + $store = app('current_store'); + $limit = (int) ($validated['limit'] ?? 5); + + $products = $this->search->autocomplete($store, $validated['q'], $limit); + + $collections = Collection::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->published() + ->where('title', 'like', trim($validated['q']).'%') + ->limit($limit) + ->get(); + + $suggestions = $products + ->map(fn (Product $product): array => [ + 'type' => 'product', + 'title' => $product->title, + 'handle' => $product->handle, + 'image_url' => $this->imageUrl($product), + 'price_amount' => $this->displayVariant($product)?->price_amount ?? 0, + 'currency' => $this->currency($product), + ]) + ->concat($collections->map(fn (Collection $collection): array => [ + 'type' => 'collection', + 'title' => $collection->title, + 'handle' => $collection->handle, + 'image_url' => null, + ])) + ->values(); + + return response()->json([ + 'query' => $validated['q'], + 'suggestions' => $suggestions, + ]); + } + + /** + * Map the spec 02 filters JSON schema onto SearchService filter keys. + * + * @param array $filters + * @return array + */ + protected function normalizeFilters(array $filters): array + { + return array_filter([ + 'vendor' => $filters['vendor'] ?? null, + 'collection_id' => $filters['collection_id'] ?? null, + 'price_min' => $filters['price_min'] ?? null, + 'price_max' => $filters['price_max'] ?? null, + 'in_stock' => ($filters['in_stock'] ?? false) === true ? true : null, + 'tags' => $filters['tags'] ?? null, + ], fn (mixed $value): bool => $value !== null); + } + + /** + * @return array + */ + protected function serializeProduct(Product $product): array + { + $variant = $this->displayVariant($product); + + return [ + 'id' => $product->getKey(), + 'title' => $product->title, + 'handle' => $product->handle, + 'vendor' => $product->vendor, + 'product_type' => $product->product_type, + 'price_amount' => $variant?->price_amount ?? 0, + 'compare_at_amount' => $variant?->compare_at_amount, + 'currency' => $this->currency($product), + 'image_url' => $this->imageUrl($product), + 'in_stock' => $this->isInStock($product), + 'tags' => $product->tags ?? [], + ]; + } + + /** + * Facets over the current result page: vendor counts, tag counts, and + * the price range across default variants. + * + * @param \Illuminate\Database\Eloquent\Collection $products + * @return array + */ + protected function facets($products): array + { + $vendors = $products + ->filter(fn (Product $product): bool => filled($product->vendor)) + ->countBy('vendor') + ->map(fn (int $count, string $vendor): array => ['value' => $vendor, 'count' => $count]) + ->values(); + + $tags = $products + ->flatMap(fn (Product $product): array => $product->tags ?? []) + ->countBy() + ->map(fn (int $count, string $tag): array => ['value' => $tag, 'count' => $count]) + ->values(); + + $prices = $products + ->map(fn (Product $product): ?int => $this->displayVariant($product)?->price_amount) + ->filter(fn (?int $price): bool => $price !== null); + + return [ + 'vendors' => $vendors, + 'tags' => $tags, + 'price_range' => [ + 'min' => $prices->isEmpty() ? 0 : $prices->min(), + 'max' => $prices->isEmpty() ? 0 : $prices->max(), + ], + ]; + } + + protected function displayVariant(Product $product): ?ProductVariant + { + return $product->variants->firstWhere('is_default', true) ?? $product->variants->first(); + } + + protected function currency(Product $product): string + { + return $this->displayVariant($product)?->currency + ?? (app('current_store')->default_currency ?? 'EUR'); + } + + protected function imageUrl(Product $product): ?string + { + $media = $product->media->first(); + + return $media !== null ? Storage::disk('public')->url($media->storage_key) : null; + } + + protected function isInStock(Product $product): bool + { + if ($product->variants->isEmpty()) { + return false; + } + + return $product->variants->contains(function (ProductVariant $variant): bool { + $inventory = $variant->inventoryItem; + + return $inventory === null + || $inventory->availableQuantity() > 0 + || $inventory->policy === InventoryPolicy::Continue; + }); + } +} diff --git a/app/Http/Controllers/Storefront/Auth/CustomerLoginController.php b/app/Http/Controllers/Storefront/Auth/CustomerLoginController.php new file mode 100644 index 00000000..e8babd38 --- /dev/null +++ b/app/Http/Controllers/Storefront/Auth/CustomerLoginController.php @@ -0,0 +1,101 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + ]); + + $attempted = Auth::guard('customer')->attempt([ + 'email' => $validated['email'], + 'password' => $validated['password'], + ]); + + if (! $attempted) { + throw ValidationException::withMessages([ + 'email' => __('Invalid credentials'), + ]); + } + + $request->session()->regenerate(); + + $this->mergeGuestCart($request); + + return redirect()->intended(route('storefront.account.index')); + } + + /** + * Log the customer out of the current store session. + */ + public function destroy(Request $request): RedirectResponse + { + Auth::guard('customer')->logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('storefront.account.login'); + } + + /** + * Merge the session guest cart into the customer's cart on login + * (spec 05 section 4.1). Without an existing customer cart the guest + * cart is simply claimed by the customer. + */ + protected function mergeGuestCart(Request $request): void + { + /** @var Customer $customer */ + $customer = Auth::guard('customer')->user(); + + $guestCartId = $request->session()->get(CartService::SESSION_KEY); + + if ($guestCartId === null) { + return; + } + + $guestCart = Cart::query() + ->whereNull('customer_id') + ->where('status', CartStatus::Active) + ->find($guestCartId); + + if ($guestCart === null) { + return; + } + + $customerCart = Cart::query() + ->where('customer_id', $customer->getKey()) + ->where('status', CartStatus::Active) + ->latest('id') + ->first(); + + if ($customerCart === null) { + $guestCart->update(['customer_id' => $customer->getKey()]); + + return; + } + + $this->cartService->mergeOnLogin($guestCart, $customerCart); + + $request->session()->put(CartService::SESSION_KEY, $customerCart->getKey()); + } +} diff --git a/app/Http/Controllers/Storefront/Auth/CustomerRegisterController.php b/app/Http/Controllers/Storefront/Auth/CustomerRegisterController.php new file mode 100644 index 00000000..e5ff269e --- /dev/null +++ b/app/Http/Controllers/Storefront/Auth/CustomerRegisterController.php @@ -0,0 +1,47 @@ +validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => [ + 'required', + 'email', + 'max:255', + Rule::unique('customers', 'email')->where('store_id', $store->getKey()), + ], + 'password' => ['required', 'string', 'min:8', 'confirmed'], + 'marketing_opt_in' => ['nullable', 'boolean'], + ]); + + $customer = Customer::query()->create([ + 'store_id' => $store->getKey(), + 'name' => $validated['name'], + 'email' => $validated['email'], + 'password_hash' => $validated['password'], + 'marketing_opt_in' => $request->boolean('marketing_opt_in'), + ]); + + Auth::guard('customer')->login($customer); + + $request->session()->regenerate(); + + return redirect()->route('storefront.account.index'); + } +} diff --git a/app/Http/Middleware/ResolveStore.php b/app/Http/Middleware/ResolveStore.php new file mode 100644 index 00000000..af89d68a --- /dev/null +++ b/app/Http/Middleware/ResolveStore.php @@ -0,0 +1,129 @@ + $this->resolveFromSession($request), + 'api-admin' => $this->resolveFromRouteParameter($request), + default => $this->resolveFromHostname($request), + }; + + app()->instance('current_store', $store); + View::share('currentStore', $store); + + return $next($request); + } + + /** + * Resolve the store from the request hostname via the store_domains table. + */ + protected function resolveFromHostname(Request $request): Store + { + $hostname = strtolower($request->getHost()); + + $storeId = Cache::remember( + "store_domain:{$hostname}", + now()->addMinutes(5), + fn (): ?int => StoreDomain::query()->where('hostname', $hostname)->value('store_id'), + ); + + if ($storeId === null) { + abort(404, 'Store not found.'); + } + + $store = Store::query()->find($storeId); + + if ($store === null) { + abort(404, 'Store not found.'); + } + + if ($store->isSuspended()) { + abort(503, 'This store is currently unavailable.'); + } + + return $store; + } + + /** + * Resolve the store from the {storeId} route parameter for admin API + * requests (spec 02 section 6.2) and verify the Sanctum-authenticated + * user is a member of that store. + */ + protected function resolveFromRouteParameter(Request $request): Store + { + $store = Store::query()->find((int) $request->route('storeId')); + + if ($store === null) { + abort(404, 'The requested resource was not found.'); + } + + $user = $request->user(); + + if ($user === null || ! $user->stores()->whereKey($store->getKey())->exists()) { + abort(403, 'You do not have permission to perform this action.'); + } + + if ($store->isSuspended() && ! $request->isMethodSafe()) { + abort(403, 'This store is currently suspended.'); + } + + return $store; + } + + /** + * Resolve the store from the session for admin requests and verify membership. + */ + protected function resolveFromSession(Request $request): Store + { + $user = $request->user(); + + if ($user === null) { + abort(403, 'You do not have access to this store.'); + } + + $storeId = $request->session()->get('current_store_id'); + + if ($storeId === null) { + $storeId = $user->stores()->value('stores.id'); + + if ($storeId === null) { + abort(403, 'You do not have access to this store.'); + } + + $request->session()->put('current_store_id', $storeId); + } + + $store = Store::query()->find($storeId); + + if ($store === null) { + abort(404, 'Store not found.'); + } + + if (! $user->stores()->whereKey($store->getKey())->exists()) { + abort(403, 'You do not have access to this store.'); + } + + if ($store->isSuspended() && ! $request->isMethodSafe()) { + abort(403, 'This store is currently suspended.'); + } + + return $store; + } +} diff --git a/app/Http/Resources/Admin/FulfillmentResource.php b/app/Http/Resources/Admin/FulfillmentResource.php new file mode 100644 index 00000000..e743bc3e --- /dev/null +++ b/app/Http/Resources/Admin/FulfillmentResource.php @@ -0,0 +1,35 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->getKey(), + '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?->toIso8601String(), + 'delivered_at' => $this->delivered_at?->toIso8601String(), + 'line_items' => $this->lines->map(fn (FulfillmentLine $line): array => [ + 'order_line_id' => $line->order_line_id, + 'quantity' => $line->quantity, + ])->all(), + ]; + } +} diff --git a/app/Http/Resources/Admin/OrderListResource.php b/app/Http/Resources/Admin/OrderListResource.php new file mode 100644 index 00000000..e1934f6f --- /dev/null +++ b/app/Http/Resources/Admin/OrderListResource.php @@ -0,0 +1,41 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->getKey(), + '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 ? null : [ + 'id' => $this->customer->getKey(), + 'name' => $this->customer->name, + 'email' => $this->customer->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, + 'line_count' => (int) $this->lines_count, + 'placed_at' => $this->placed_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Admin/OrderResource.php b/app/Http/Resources/Admin/OrderResource.php new file mode 100644 index 00000000..c09b4e93 --- /dev/null +++ b/app/Http/Resources/Admin/OrderResource.php @@ -0,0 +1,71 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->getKey(), + '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 ? null : [ + 'id' => $this->customer->getKey(), + 'name' => $this->customer->name, + 'email' => $this->customer->email, + ], + '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->getKey(), + '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, + ])->all(), + 'payments' => $this->payments->map(fn (Payment $payment): array => [ + 'id' => $payment->getKey(), + '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?->toIso8601String(), + ])->all(), + 'fulfillments' => FulfillmentResource::collection($this->fulfillments)->resolve(), + 'refunds' => RefundResource::collection($this->refunds)->resolve(), + 'placed_at' => $this->placed_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Admin/ProductListResource.php b/app/Http/Resources/Admin/ProductListResource.php new file mode 100644 index 00000000..38119537 --- /dev/null +++ b/app/Http/Resources/Admin/ProductListResource.php @@ -0,0 +1,42 @@ + + */ + public function toArray(Request $request): array + { + $featuredImage = $this->media->first(); + + return [ + 'id' => $this->getKey(), + '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' => (int) $this->variants_count, + 'total_inventory' => (int) ($this->total_inventory ?? 0), + 'published_at' => $this->published_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + 'featured_image' => $featuredImage === null ? null : [ + 'url' => Storage::disk('public')->url($featuredImage->storage_key), + 'alt_text' => $featuredImage->alt_text, + ], + ]; + } +} diff --git a/app/Http/Resources/Admin/ProductResource.php b/app/Http/Resources/Admin/ProductResource.php new file mode 100644 index 00000000..e0188f5d --- /dev/null +++ b/app/Http/Resources/Admin/ProductResource.php @@ -0,0 +1,98 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->getKey(), + '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?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + 'options' => $this->options->map(fn (ProductOption $option): array => [ + 'id' => $option->getKey(), + 'name' => $option->name, + 'position' => $option->position, + 'values' => $option->values->map(fn (ProductOptionValue $value): array => [ + 'id' => $value->getKey(), + 'value' => $value->value, + 'position' => $value->position, + ])->all(), + ])->all(), + 'variants' => $this->variants->map(fn (ProductVariant $variant): array => $this->variantToArray($variant))->all(), + 'media' => $this->media->map(fn (ProductMedia $media): array => [ + 'id' => $media->getKey(), + 'type' => $media->type->value, + 'storage_key' => $media->storage_key, + 'url' => Storage::disk('public')->url($media->storage_key), + '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, + ])->all(), + 'collections' => $this->collections->map(fn (Collection $collection): array => [ + 'id' => $collection->getKey(), + 'title' => $collection->title, + 'handle' => $collection->handle, + ])->all(), + ]; + } + + /** + * @return array + */ + protected function variantToArray(ProductVariant $variant): array + { + return [ + 'id' => $variant->getKey(), + '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' => $variant->requires_shipping, + 'is_default' => $variant->is_default, + 'position' => $variant->position, + 'status' => $variant->status->value, + 'option_values' => $variant->optionValues->map(fn (ProductOptionValue $value): array => [ + 'option_name' => $value->option?->name, + 'value' => $value->value, + ])->all(), + 'inventory' => $variant->inventoryItem === null ? null : [ + 'quantity_on_hand' => $variant->inventoryItem->quantity_on_hand, + 'quantity_reserved' => $variant->inventoryItem->quantity_reserved, + 'policy' => $variant->inventoryItem->policy->value, + ], + ]; + } +} diff --git a/app/Http/Resources/Admin/RefundResource.php b/app/Http/Resources/Admin/RefundResource.php new file mode 100644 index 00000000..dfc8228a --- /dev/null +++ b/app/Http/Resources/Admin/RefundResource.php @@ -0,0 +1,30 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->getKey(), + '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?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Storefront/CartResource.php b/app/Http/Resources/Storefront/CartResource.php new file mode 100644 index 00000000..fb45eb69 --- /dev/null +++ b/app/Http/Resources/Storefront/CartResource.php @@ -0,0 +1,78 @@ + + */ + public function toArray(Request $request): array + { + $lines = $this->lines() + ->with(['variant.product.media', 'variant.optionValues', 'variant.inventoryItem']) + ->get(); + + return [ + 'id' => $this->getKey(), + 'store_id' => $this->store_id, + 'customer_id' => $this->customer_id, + 'currency' => $this->currency, + 'cart_version' => $this->cart_version, + 'status' => $this->status->value, + 'lines' => $lines->map(fn (CartLine $line): array => $this->lineToArray($line))->all(), + 'totals' => [ + 'subtotal' => (int) $lines->sum('line_subtotal_amount'), + 'discount' => (int) $lines->sum('line_discount_amount'), + 'total' => (int) $lines->sum('line_total_amount'), + 'currency' => $this->currency, + 'line_count' => $lines->count(), + 'item_count' => (int) $lines->sum('quantity'), + ], + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } + + /** + * @return array + */ + protected function lineToArray(CartLine $line): array + { + $variant = $line->variant; + $image = $variant?->product?->media->first(); + + return [ + 'id' => $line->getKey(), + 'variant_id' => $line->variant_id, + 'product_title' => $variant?->product?->title, + 'variant_title' => $variant?->optionValues->pluck('value')->implode(' / ') ?: null, + 'sku' => $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' => $image !== null ? Storage::disk('public')->url($image->storage_key) : null, + 'requires_shipping' => (bool) ($variant?->requires_shipping ?? false), + 'available_quantity' => $variant?->inventoryItem?->availableQuantity(), + ]; + } +} diff --git a/app/Http/Resources/Storefront/CheckoutResource.php b/app/Http/Resources/Storefront/CheckoutResource.php new file mode 100644 index 00000000..4244c5f4 --- /dev/null +++ b/app/Http/Resources/Storefront/CheckoutResource.php @@ -0,0 +1,96 @@ + + */ + public function toArray(Request $request): array + { + $cart = Cart::query()->withoutGlobalScopes()->find($this->cart_id); + $lines = $cart?->lines()->with(['variant.product', 'variant.optionValues'])->get() ?? collect(); + $totals = $this->totals_json ?? []; + + return [ + 'id' => $this->getKey(), + '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, + 'discount_code' => $this->discount_code, + 'lines' => $lines->map(fn (CartLine $line): array => [ + 'variant_id' => $line->variant_id, + 'product_title' => $line->variant?->product?->title, + 'variant_title' => $line->variant?->optionValues->pluck('value')->implode(' / ') ?: null, + 'sku' => $line->variant?->sku, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'line_total_amount' => $line->line_total_amount, + ])->all(), + 'totals' => [ + 'subtotal' => (int) ($totals['subtotal'] ?? 0), + 'discount' => (int) ($totals['discount'] ?? 0), + 'shipping' => (int) ($totals['shipping'] ?? 0), + 'tax' => (int) ($totals['tax'] ?? 0), + 'total' => (int) ($totals['total'] ?? 0), + 'currency' => $totals['currency'] ?? $cart?->currency, + ], + 'tax_provider_snapshot_json' => $this->tax_provider_snapshot_json, + 'available_shipping_methods' => $this->availableShippingMethods(), + 'expires_at' => $this->expires_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } + + /** + * Shipping rates available for the checkout's shipping address, empty + * until an address has been provided. + * + * @return list> + */ + protected function availableShippingMethods(): array + { + $address = $this->shipping_address_json; + + if (blank($address)) { + return []; + } + + return app(ShippingCalculator::class) + ->getAvailableRates($this->store, $address) + ->map(fn (ShippingRate $rate): array => [ + 'id' => $rate->getKey(), + 'name' => $rate->name, + 'type' => $rate->type->value, + 'price_amount' => (int) ($rate->config_json['amount'] ?? 0), + 'currency' => $this->store->default_currency, + ]) + ->all(); + } +} diff --git a/app/Jobs/AggregateAnalytics.php b/app/Jobs/AggregateAnalytics.php new file mode 100644 index 00000000..33ac79e2 --- /dev/null +++ b/app/Jobs/AggregateAnalytics.php @@ -0,0 +1,86 @@ +date ?? now()->subDay()->toDateString())->startOfDay(); + + Store::query()->each(function (Store $store) use ($day): void { + $this->aggregateStoreDay($store, $day); + }); + } + + protected function aggregateStoreDay(Store $store, CarbonImmutable $day): void + { + $events = AnalyticsEvent::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('created_at', '>=', $day) + ->where('created_at', '<', $day->addDay()); + + $countsByType = (clone $events) + ->selectRaw('type, count(*) as total') + ->groupBy('type') + ->pluck('total', 'type'); + + $visitsCount = (clone $events) + ->where('type', 'page_view') + ->distinct() + ->count('session_id'); + + $ordersCount = (int) ($countsByType['checkout_completed'] ?? 0); + + $revenueAmount = (clone $events) + ->where('type', 'checkout_completed') + ->get() + ->sum(fn (AnalyticsEvent $event): int => (int) ($event->properties_json['total_amount'] ?? 0)); + + if ($ordersCount === 0 && $visitsCount === 0 && $countsByType->isEmpty()) { + return; + } + + AnalyticsDaily::query()->upsert( + [[ + 'store_id' => $store->getKey(), + 'date' => $day->toDateString(), + 'orders_count' => $ordersCount, + 'revenue_amount' => $revenueAmount, + 'aov_amount' => $ordersCount > 0 ? intdiv($revenueAmount, $ordersCount) : 0, + 'visits_count' => $visitsCount, + 'add_to_cart_count' => (int) ($countsByType['add_to_cart'] ?? 0), + 'checkout_started_count' => (int) ($countsByType['checkout_started'] ?? 0), + 'checkout_completed_count' => (int) ($countsByType['checkout_completed'] ?? 0), + ]], + ['store_id', 'date'], + [ + 'orders_count', 'revenue_amount', 'aov_amount', 'visits_count', + 'add_to_cart_count', 'checkout_started_count', 'checkout_completed_count', + ], + ); + } +} diff --git a/app/Jobs/CancelUnpaidBankTransferOrders.php b/app/Jobs/CancelUnpaidBankTransferOrders.php new file mode 100644 index 00000000..d8f78290 --- /dev/null +++ b/app/Jobs/CancelUnpaidBankTransferOrders.php @@ -0,0 +1,44 @@ +withoutGlobalScopes() + ->where('payment_method', PaymentMethod::BankTransfer) + ->where('financial_status', FinancialStatus::Pending) + ->whereNotNull('placed_at') + ->with('store.settings') + ->each(function (Order $order) use ($orderService): void { + $cancelDays = (int) ($order->store->settings?->settings_json['bank_transfer_cancel_days'] + ?? self::DEFAULT_CANCEL_DAYS); + + if ($order->placed_at->lt(now()->subDays($cancelDays))) { + $orderService->cancel($order, 'Unpaid bank transfer timeout'); + } + }); + } +} diff --git a/app/Jobs/CleanupAbandonedCarts.php b/app/Jobs/CleanupAbandonedCarts.php new file mode 100644 index 00000000..c483684d --- /dev/null +++ b/app/Jobs/CleanupAbandonedCarts.php @@ -0,0 +1,30 @@ +withoutGlobalScopes() + ->where('status', CartStatus::Active) + ->where('updated_at', '<', now()->subDays(self::INACTIVE_DAYS)) + ->update(['status' => CartStatus::Abandoned]); + } +} diff --git a/app/Jobs/DeliverWebhook.php b/app/Jobs/DeliverWebhook.php new file mode 100644 index 00000000..14aa4bd2 --- /dev/null +++ b/app/Jobs/DeliverWebhook.php @@ -0,0 +1,208 @@ + + */ + public array $backoff = [60, 300, 1800, 7200, 43200]; + + public const int MAX_ATTEMPTS = 6; + + /** + * Consecutive failed attempts before the subscription is paused + * (spec 05 section 13.4). + */ + public const int CIRCUIT_BREAKER_THRESHOLD = 5; + + public const int RESPONSE_SNIPPET_LENGTH = 500; + + /** + * @param array $payload + */ + public function __construct( + public WebhookDelivery $delivery, + public array $payload, + public int $eventTimestamp, + ) {} + + public function handle(WebhookService $webhooks): void + { + $delivery = $this->delivery->fresh(); + + if ($delivery === null || $delivery->status !== WebhookDeliveryStatus::Pending) { + return; + } + + $subscription = WebhookSubscription::query() + ->withoutGlobalScope(StoreScope::class) + ->find($delivery->subscription_id); + + if ($subscription === null || $subscription->status !== WebhookSubscriptionStatus::Active) { + $delivery->update(['status' => WebhookDeliveryStatus::Failed]); + + return; + } + + $body = json_encode($this->payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + [$responseCode, $responseSnippet] = $this->attemptDelivery($subscription, $webhooks, $body); + + $attempt = $delivery->attempt_count + 1; + $succeeded = $responseCode !== null && $responseCode >= 200 && $responseCode < 300; + $exhausted = $attempt >= self::MAX_ATTEMPTS || ! $this->supportsBackgroundRetries(); + + $delivery->update([ + 'attempt_count' => $attempt, + 'status' => match (true) { + $succeeded => WebhookDeliveryStatus::Success, + $exhausted => WebhookDeliveryStatus::Failed, + default => WebhookDeliveryStatus::Pending, + }, + 'last_attempt_at' => now(), + 'response_code' => $responseCode, + 'response_body_snippet' => $responseSnippet, + ]); + + if ($succeeded) { + $this->recordSuccess($subscription); + + return; + } + + $this->recordFailure($subscription); + + if ($exhausted) { + Log::channel('structured')->warning('webhook.delivery_failed', [ + 'event' => 'business', + 'delivery_id' => $delivery->getKey(), + 'subscription_id' => $subscription->getKey(), + 'store_id' => $subscription->store_id, + 'event_type' => $subscription->event_type, + 'target_url' => $subscription->target_url, + 'attempt_count' => $attempt, + 'response_code' => $responseCode, + ]); + } + + if (! $exhausted) { + throw new RuntimeException(sprintf( + 'Webhook delivery %d to %s failed with status %s.', + $delivery->getKey(), + $subscription->target_url, + $responseCode ?? 'connection error', + )); + } + } + + /** + * Retry-by-throwing only works on a real background queue. On the sync + * queue the exception would propagate into the request that triggered + * the webhook (e.g. a customer checkout), so inline execution + * dead-letters after the first failed attempt instead. + */ + protected function supportsBackgroundRetries(): bool + { + return ! ($this->job instanceof SyncJob); + } + + /** + * POST the signed payload and normalize the outcome to a response code + * (null on connection failure) and a truncated body snippet. + * + * @return array{0: int|null, 1: string|null} + */ + protected function attemptDelivery(WebhookSubscription $subscription, WebhookService $webhooks, string $body): array + { + try { + $response = Http::timeout(10) + ->withHeaders([ + 'X-Platform-Signature' => $webhooks->sign($body, $subscription->signing_secret_encrypted), + 'X-Platform-Event' => $subscription->event_type, + 'X-Platform-Delivery-Id' => (string) Str::uuid(), + 'X-Platform-Timestamp' => (string) $this->eventTimestamp, + ]) + ->withBody($body, 'application/json') + ->post($subscription->target_url); + + return [ + $response->status(), + Str::limit($response->body(), self::RESPONSE_SNIPPET_LENGTH, ''), + ]; + } catch (ConnectionException $exception) { + return [ + null, + Str::limit($exception->getMessage(), self::RESPONSE_SNIPPET_LENGTH, ''), + ]; + } + } + + /** + * A successful delivery resets the circuit breaker counter + * (spec 05 section 13.4). + */ + protected function recordSuccess(WebhookSubscription $subscription): void + { + if ($subscription->consecutive_failures > 0) { + $subscription->update(['consecutive_failures' => 0]); + } + } + + /** + * Each failed attempt increments the circuit breaker counter; at the + * threshold the subscription is paused until a merchant resumes it + * manually through the admin UI (spec 05 section 13.4). + */ + protected function recordFailure(WebhookSubscription $subscription): void + { + $failures = $subscription->consecutive_failures + 1; + + $attributes = ['consecutive_failures' => $failures]; + + if ($failures >= self::CIRCUIT_BREAKER_THRESHOLD) { + $attributes['status'] = WebhookSubscriptionStatus::Paused; + + Log::warning('Webhook subscription paused by circuit breaker after consecutive failures.', [ + 'subscription_id' => $subscription->getKey(), + 'store_id' => $subscription->store_id, + 'target_url' => $subscription->target_url, + 'consecutive_failures' => $failures, + ]); + } + + $subscription->update($attributes); + } +} diff --git a/app/Jobs/ExpireAbandonedCheckouts.php b/app/Jobs/ExpireAbandonedCheckouts.php new file mode 100644 index 00000000..377a02e7 --- /dev/null +++ b/app/Jobs/ExpireAbandonedCheckouts.php @@ -0,0 +1,26 @@ +withoutGlobalScopes() + ->whereNotIn('status', ['completed', 'expired']) + ->whereNotNull('expires_at') + ->where('expires_at', '<', now()) + ->each(fn (Checkout $checkout) => $checkoutService->expireCheckout($checkout)); + } +} diff --git a/app/Jobs/ProcessMediaUpload.php b/app/Jobs/ProcessMediaUpload.php new file mode 100644 index 00000000..00e6c2b3 --- /dev/null +++ b/app/Jobs/ProcessMediaUpload.php @@ -0,0 +1,127 @@ + maximum dimension in pixels. + * + * @var array + */ + public const array SIZES = [ + 'thumbnail' => 150, + 'medium' => 600, + 'large' => 1200, + ]; + + public function __construct(public ProductMedia $media) {} + + /** + * Resize the original upload into the standard derived sizes using GD and + * flip the media status from processing to ready (or failed on error). + */ + public function handle(): void + { + try { + $this->process(); + + $this->media->update(['status' => MediaStatus::Ready]); + } catch (Throwable $exception) { + $this->media->update(['status' => MediaStatus::Failed]); + + report($exception); + } + } + + private function process(): void + { + $disk = Storage::disk('public'); + + $contents = $disk->get($this->media->storage_key); + + if ($contents === null) { + throw new RuntimeException("Original media file [{$this->media->storage_key}] not found."); + } + + $source = @imagecreatefromstring($contents); + + if ($source === false) { + throw new RuntimeException("Could not decode image [{$this->media->storage_key}]."); + } + + $width = imagesx($source); + $height = imagesy($source); + + foreach (self::SIZES as $size => $maxDimension) { + $resized = $this->resizeToFit($source, $width, $height, $maxDimension); + + $disk->put( + $this->media->derivedStorageKey($size), + $this->encode($resized), + ); + + imagedestroy($resized); + } + + imagedestroy($source); + + $this->media->forceFill([ + 'width' => $width, + 'height' => $height, + ])->save(); + } + + /** + * Scale the image so its longest edge fits within the given dimension, + * preserving aspect ratio. Images smaller than the target are kept as-is. + */ + private function resizeToFit(GdImage $source, int $width, int $height, int $maxDimension): GdImage + { + $scale = min(1.0, $maxDimension / max($width, $height)); + + $targetWidth = max(1, (int) round($width * $scale)); + + $resized = imagescale($source, $targetWidth, -1, IMG_BICUBIC); + + if ($resized === false) { + throw new RuntimeException('Failed to resize image.'); + } + + return $resized; + } + + private function encode(GdImage $image): string + { + $extension = strtolower(pathinfo($this->media->storage_key, PATHINFO_EXTENSION)); + + ob_start(); + + $encoded = match ($extension) { + 'png' => imagepng($image), + 'gif' => imagegif($image), + 'webp' => imagewebp($image), + default => imagejpeg($image, null, 85), + }; + + $contents = ob_get_clean(); + + if ($encoded === false || $contents === false) { + throw new RuntimeException('Failed to encode resized image.'); + } + + return $contents; + } +} diff --git a/app/Listeners/DispatchWebhooks.php b/app/Listeners/DispatchWebhooks.php new file mode 100644 index 00000000..6ef49832 --- /dev/null +++ b/app/Listeners/DispatchWebhooks.php @@ -0,0 +1,95 @@ + $this->dispatchOrderEvent('order.created', $event->order), + OrderPaid::class => $this->dispatchOrderEvent('order.paid', $event->order), + OrderFulfilled::class => $this->dispatchOrderEvent('order.fulfilled', $event->order), + OrderCancelled::class => $this->dispatchOrderEvent('order.cancelled', $event->order), + OrderRefunded::class => $this->dispatchOrderEvent('order.refunded', $event->order, $event->refund), + CheckoutCompleted::class => $this->webhooks->dispatch( + $event->order->store, + 'checkout.completed', + $this->checkoutPayload($event), + ), + }; + } + + protected function dispatchOrderEvent(string $eventType, Order $order, ?Refund $refund = null): void + { + $payload = $this->orderPayload($order); + + if ($refund !== null) { + $payload['refund'] = [ + 'id' => $refund->getKey(), + 'amount' => $refund->amount, + 'reason' => $refund->reason, + 'status' => $refund->status?->value, + ]; + } + + $this->webhooks->dispatch($order->store, $eventType, $payload); + } + + /** + * @return array + */ + protected function orderPayload(Order $order): array + { + return [ + 'id' => $order->getKey(), + 'order_number' => $order->order_number, + 'status' => $order->status?->value, + 'financial_status' => $order->financial_status?->value, + 'fulfillment_status' => $order->fulfillment_status?->value, + 'payment_method' => $order->payment_method?->value, + 'currency' => $order->currency, + '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, + 'email' => $order->email, + 'customer_id' => $order->customer_id, + 'placed_at' => $order->placed_at?->toIso8601String(), + ]; + } + + /** + * @return array + */ + protected function checkoutPayload(CheckoutCompleted $event): array + { + return [ + 'checkout_id' => $event->checkout->getKey(), + 'order_id' => $event->order->getKey(), + 'order_number' => $event->order->order_number, + 'email' => $event->order->email, + 'currency' => $event->order->currency, + 'total_amount' => $event->order->total_amount, + ]; + } +} diff --git a/app/Listeners/LogOrderAnalyticsEvent.php b/app/Listeners/LogOrderAnalyticsEvent.php new file mode 100644 index 00000000..1a6026c3 --- /dev/null +++ b/app/Listeners/LogOrderAnalyticsEvent.php @@ -0,0 +1,34 @@ + log analytics event). The + * event feeds the daily revenue/AOV aggregation. + */ +class LogOrderAnalyticsEvent +{ + public function __construct(protected AnalyticsService $analytics) {} + + public function handle(OrderCreated $event): void + { + $order = $event->order; + + $this->analytics->track( + $order->store, + 'checkout_completed', + [ + 'order_id' => $order->getKey(), + 'order_number' => $order->order_number, + 'total_amount' => $order->total_amount, + 'currency' => $order->currency, + ], + session()->isStarted() ? session()->getId() : null, + $order->customer_id, + ); + } +} diff --git a/app/Listeners/LogStructuredBusinessEvent.php b/app/Listeners/LogStructuredBusinessEvent.php new file mode 100644 index 00000000..c0c627e0 --- /dev/null +++ b/app/Listeners/LogStructuredBusinessEvent.php @@ -0,0 +1,56 @@ + 'order.created', + OrderPaid::class => 'order.paid', + OrderCancelled::class => 'order.cancelled', + OrderRefunded::class => 'order.refunded', + }; + + $context = $this->orderContext($event->order); + + if ($event instanceof OrderRefunded) { + $context['refund_id'] = $event->refund->getKey(); + $context['refund_amount'] = $event->refund->amount; + } + + Log::channel('structured')->info($eventType, $context); + } + + /** + * @return array + */ + protected function orderContext(Order $order): array + { + return [ + 'event' => 'business', + 'order_id' => $order->getKey(), + 'order_number' => $order->order_number, + 'store_id' => $order->store_id, + 'customer_id' => $order->customer_id, + 'payment_method' => $order->payment_method?->value, + 'currency' => $order->currency, + 'total_amount' => $order->total_amount, + 'status' => $order->status?->value, + 'financial_status' => $order->financial_status?->value, + ]; + } +} diff --git a/app/Livewire/Admin/Analytics/Index.php b/app/Livewire/Admin/Analytics/Index.php new file mode 100644 index 00000000..27fd18f3 --- /dev/null +++ b/app/Livewire/Admin/Analytics/Index.php @@ -0,0 +1,285 @@ +authorize('viewAnalytics', $this->store()); + } + + public function render(AnalyticsService $analytics): View + { + $this->authorize('viewAnalytics', $this->store()); + + [$start, $end] = $this->range(); + $days = (int) $start->diffInDays($end) + 1; + [$previousStart, $previousEnd] = [$start->subDays($days), $start->subDay()]; + + $current = $this->kpisBetween($analytics, $start, $end); + $previous = $this->kpisBetween($analytics, $previousStart, $previousEnd); + $funnel = $analytics->eventCountsBetween($this->store(), $start->toDateTimeString(), $end->addDay()->toDateTimeString()); + + return view('livewire.admin.analytics.index', [ + 'totalSales' => $current['total_sales'], + 'ordersCount' => $current['orders_count'], + 'averageOrderValue' => $current['average_order_value'], + 'conversionRate' => $current['conversion_rate'], + 'salesChange' => $this->percentChange($previous['total_sales'], $current['total_sales']), + 'ordersChange' => $this->percentChange($previous['orders_count'], $current['orders_count']), + 'aovChange' => $this->percentChange($previous['average_order_value'], $current['average_order_value']), + 'conversionChange' => $this->percentChange($previous['conversion_rate'], $current['conversion_rate']), + 'formattedTotalSales' => PriceFormatter::format($current['total_sales'], $this->currency()), + 'formattedAov' => PriceFormatter::format($current['average_order_value'], $this->currency()), + 'chart' => $this->salesChart($analytics, $start, $end), + 'funnel' => $this->funnelSteps($funnel), + 'topProducts' => $this->topProducts($start, $end), + 'topReferrers' => $this->topReferrers($start, $end), + 'visitsCount' => $current['visits_count'], + ])->title(__('Analytics')); + } + + /** + * KPIs from analytics_daily over an inclusive date range. + * + * @return array{total_sales: int, orders_count: int, average_order_value: int, visits_count: int, conversion_rate: float} + */ + protected function kpisBetween(AnalyticsService $analytics, CarbonImmutable $start, CarbonImmutable $end): array + { + $rows = $analytics->getDailyMetrics($this->store(), $start->toDateString(), $end->toDateString()); + + $totalSales = (int) $rows->sum('revenue_amount'); + $ordersCount = (int) $rows->sum('orders_count'); + $visitsCount = (int) $rows->sum('visits_count'); + + return [ + 'total_sales' => $totalSales, + 'orders_count' => $ordersCount, + 'average_order_value' => $ordersCount > 0 ? intdiv($totalSales, $ordersCount) : 0, + 'visits_count' => $visitsCount, + 'conversion_rate' => $visitsCount > 0 ? round($ordersCount / $visitsCount * 100, 1) : 0.0, + ]; + } + + /** + * Daily revenue with precomputed SVG polyline geometry (same + * dependency-free chart approach as the Dashboard). + * + * @return array{days: list, max: int, points: string, area: string} + */ + protected function salesChart(AnalyticsService $analytics, CarbonImmutable $start, CarbonImmutable $end): array + { + $amountsByDay = $analytics->getDailyMetrics($this->store(), $start->toDateString(), $end->toDateString()) + ->pluck('revenue_amount', 'date'); + + $days = []; + + for ($date = $start; $date <= $end; $date = $date->addDay()) { + $days[] = [ + 'date' => $date->toDateString(), + 'amount' => (int) ($amountsByDay[$date->toDateString()] ?? 0), + ]; + } + + $max = max(1, ...array_column($days, 'amount')); + + $width = 600; + $height = 180; + $stepX = count($days) > 1 ? $width / (count($days) - 1) : $width; + + $points = []; + + foreach ($days as $index => $day) { + $x = round($index * $stepX, 1); + $y = round($height - ($day['amount'] / $max) * ($height - 10) - 5, 1); + $points[] = "{$x},{$y}"; + } + + $polyline = implode(' ', $points); + + return [ + 'days' => $days, + 'max' => $max, + 'points' => $polyline, + 'area' => "0,{$height} {$polyline} {$width},{$height}", + ]; + } + + /** + * The conversion funnel steps with widths proportional to the first + * step (page_view -> product_view -> add_to_cart -> checkout_started + * -> checkout_completed). + * + * @param array $counts + * @return list + */ + protected function funnelSteps(array $counts): array + { + $steps = [ + ['label' => __('Page views'), 'count' => $counts['page_view']], + ['label' => __('Product views'), 'count' => $counts['product_view']], + ['label' => __('Add to cart'), 'count' => $counts['add_to_cart']], + ['label' => __('Checkout started'), 'count' => $counts['checkout_started']], + ['label' => __('Checkout completed'), 'count' => $counts['checkout_completed']], + ]; + + $base = max(1, $steps[0]['count']); + + return array_map(fn (array $step): array => [ + ...$step, + 'percent' => round($step['count'] / $base * 100, 1), + ], $steps); + } + + /** + * Top 10 products by revenue within the range, with share of total. + * + * @return list + */ + protected function topProducts(CarbonImmutable $start, CarbonImmutable $end): array + { + $rows = DB::table('order_lines') + ->join('orders', 'orders.id', '=', 'order_lines.order_id') + ->where('orders.store_id', $this->store()->getKey()) + ->where('orders.placed_at', '>=', $start) + ->where('orders.placed_at', '<', $end->addDay()) + ->groupBy('order_lines.title_snapshot') + ->selectRaw('order_lines.title_snapshot as title, SUM(order_lines.quantity) as units_sold, SUM(order_lines.total_amount) as revenue') + ->orderByDesc('revenue') + ->limit(10) + ->get(); + + $totalRevenue = max(1, (int) $rows->sum('revenue')); + + return $rows->map(fn (object $row): array => [ + 'title' => $row->title, + 'units_sold' => (int) $row->units_sold, + 'revenue' => (int) $row->revenue, + 'share' => round((int) $row->revenue / $totalRevenue * 100, 1), + ])->all(); + } + + /** + * Traffic sources: sessions grouped by the referrer host of their + * page_view events, with per-source order conversion. + * + * @return list + */ + protected function topReferrers(CarbonImmutable $start, CarbonImmutable $end): array + { + $events = AnalyticsEvent::query() + ->withoutGlobalScopes() + ->where('store_id', $this->store()->getKey()) + ->whereIn('type', ['page_view', 'checkout_completed']) + ->where('created_at', '>=', $start) + ->where('created_at', '<', $end->addDay()) + ->get(['type', 'session_id', 'properties_json']); + + $sourceBySession = []; + $completedSessions = []; + + foreach ($events as $event) { + if ($event->type === 'checkout_completed') { + $completedSessions[$event->session_id] = true; + + continue; + } + + $referrer = $event->properties_json['referrer'] ?? null; + $source = filled($referrer) ? (parse_url($referrer, PHP_URL_HOST) ?: $referrer) : __('Direct'); + + $sourceBySession[$event->session_id] ??= $source; + } + + $sources = []; + + foreach ($sourceBySession as $sessionId => $source) { + $sources[$source] ??= ['source' => $source, 'sessions' => 0, 'orders' => 0]; + $sources[$source]['sessions']++; + + if (isset($completedSessions[$sessionId])) { + $sources[$source]['orders']++; + } + } + + usort($sources, fn (array $a, array $b): int => $b['sessions'] <=> $a['sessions']); + + return array_map(fn (array $source): array => [ + ...$source, + 'conversion' => $source['sessions'] > 0 ? round($source['orders'] / $source['sessions'] * 100, 2) : 0.0, + ], array_slice(array_values($sources), 0, 10)); + } + + /** + * The inclusive [start, end] date range for the selected preset. + * + * @return array{0: CarbonImmutable, 1: CarbonImmutable} + */ + protected function range(): array + { + $today = now()->startOfDay(); + + if ($this->dateRange === 'custom' && $this->customStartDate !== '' && $this->customEndDate !== '') { + $start = CarbonImmutable::parse($this->customStartDate)->startOfDay(); + $end = CarbonImmutable::parse($this->customEndDate)->startOfDay(); + + return $start <= $end ? [$start, $end] : [$end, $start]; + } + + return match ($this->dateRange) { + 'today' => [$today, $today], + 'last_7_days' => [$today->subDays(6), $today], + default => [$today->subDays(29), $today], + }; + } + + protected function percentChange(int|float $previous, int|float $current): float + { + if ((float) $previous === 0.0) { + return $current > 0 ? 100.0 : 0.0; + } + + return round(($current - $previous) / $previous * 100, 1); + } + + protected function currency(): string + { + return $this->store()->default_currency ?? 'EUR'; + } + + protected function store(): Store + { + return app('current_store'); + } +} diff --git a/app/Livewire/Admin/Apps/Index.php b/app/Livewire/Admin/Apps/Index.php new file mode 100644 index 00000000..6a7ae319 --- /dev/null +++ b/app/Livewire/Admin/Apps/Index.php @@ -0,0 +1,132 @@ +authorize('manageApps', $this->store()); + } + + public function installApp(int $appId): void + { + $this->authorize('manageApps', $this->store()); + + $app = AppModel::query() + ->where('status', AppStatus::Active) + ->findOrFail($appId); + + $installation = AppInstallation::query() + ->where('store_id', $this->store()->getKey()) + ->where('app_id', $app->getKey()) + ->first(); + + if ($installation !== null && $installation->status !== AppInstallationStatus::Uninstalled) { + $this->toast(__('This app is already installed.'), 'error'); + + return; + } + + if ($installation !== null) { + $installation->update([ + 'status' => AppInstallationStatus::Active, + 'installed_at' => now(), + ]); + } else { + AppInstallation::query()->create([ + 'store_id' => $this->store()->getKey(), + 'app_id' => $app->getKey(), + 'scopes_json' => ['read-products', 'read-orders', 'read-customers'], + 'status' => AppInstallationStatus::Active, + 'installed_at' => now(), + ]); + } + + unset($this->installedApps, $this->availableApps); + + $this->toast(__(':app installed.', ['app' => $app->name])); + } + + public function uninstallApp(int $installationId): void + { + $this->authorize('manageApps', $this->store()); + + $installation = AppInstallation::query() + ->where('store_id', $this->store()->getKey()) + ->findOrFail($installationId); + + $installation->update(['status' => AppInstallationStatus::Uninstalled]); + + $installation->webhookSubscriptions()->update([ + 'status' => WebhookSubscriptionStatus::Disabled, + ]); + + unset($this->installedApps, $this->availableApps); + + $this->toast(__(':app uninstalled.', ['app' => $installation->app->name])); + } + + /** + * @return Collection + */ + #[Computed] + public function installedApps(): Collection + { + return AppInstallation::query() + ->with('app') + ->where('store_id', $this->store()->getKey()) + ->where('status', '!=', AppInstallationStatus::Uninstalled) + ->orderBy('installed_at', 'desc') + ->get(); + } + + /** + * @return Collection + */ + #[Computed] + public function availableApps(): Collection + { + $installedAppIds = AppInstallation::query() + ->where('store_id', $this->store()->getKey()) + ->where('status', '!=', AppInstallationStatus::Uninstalled) + ->pluck('app_id'); + + return AppModel::query() + ->where('status', AppStatus::Active) + ->whereNotIn('id', $installedAppIds) + ->orderBy('name') + ->get(); + } + + public function render(): View + { + return view('livewire.admin.apps.index')->title(__('Apps')); + } + + protected function store(): Store + { + return app('current_store'); + } +} diff --git a/app/Livewire/Admin/Apps/Show.php b/app/Livewire/Admin/Apps/Show.php new file mode 100644 index 00000000..e3349e26 --- /dev/null +++ b/app/Livewire/Admin/Apps/Show.php @@ -0,0 +1,75 @@ +installationId = $installation; + + $this->authorize('manageApps', $this->store()); + + $this->installation(); + } + + public function uninstallApp(): void + { + $this->authorize('manageApps', $this->store()); + + $installation = $this->installation(); + + $installation->update(['status' => AppInstallationStatus::Uninstalled]); + + $installation->webhookSubscriptions()->update([ + 'status' => WebhookSubscriptionStatus::Disabled, + ]); + + $this->flashToast(__(':app uninstalled.', ['app' => $installation->app->name])); + + $this->redirectRoute('admin.apps.index', navigate: true); + } + + #[Computed] + public function installation(): AppInstallation + { + return AppInstallation::query() + ->with(['app', 'webhookSubscriptions.latestDelivery', 'oauthTokens']) + ->where('store_id', $this->store()->getKey()) + ->where('status', '!=', AppInstallationStatus::Uninstalled) + ->findOrFail($this->installationId); + } + + public function render(): View + { + return view('livewire.admin.apps.show')->title($this->installation->app->name); + } + + protected function store(): Store + { + return app('current_store'); + } +} diff --git a/app/Livewire/Admin/Auth/Login.php b/app/Livewire/Admin/Auth/Login.php new file mode 100644 index 00000000..12f18dd7 --- /dev/null +++ b/app/Livewire/Admin/Auth/Login.php @@ -0,0 +1,12 @@ + + */ + public array $assignedProductIds = []; + + public function mount(?int $collectionId = null): void + { + if ($collectionId !== null) { + $this->collection = Collection::query()->findOrFail($collectionId); + + $this->authorize('view', $this->collection); + $this->fillFromCollection(); + + return; + } + + $this->authorize('create', Collection::class); + } + + public function addProduct(int $productId): void + { + if (in_array($productId, $this->assignedProductIds, true)) { + return; + } + + Product::query()->findOrFail($productId); + + $this->assignedProductIds[] = $productId; + $this->productSearch = ''; + } + + public function removeProduct(int $productId): void + { + $this->assignedProductIds = array_values( + array_filter($this->assignedProductIds, fn (int $id): bool => $id !== $productId), + ); + } + + /** + * Drag-to-reorder handler (wire:sort): move a product to a position. + */ + public function reorderProducts(int $productId, int $position): void + { + $currentIndex = array_search($productId, $this->assignedProductIds, true); + + if ($currentIndex === false) { + return; + } + + array_splice($this->assignedProductIds, $currentIndex, 1); + array_splice($this->assignedProductIds, $position, 0, [$productId]); + } + + public function save(): void + { + if ($this->isEditing) { + $this->authorize('update', $this->collection); + } else { + $this->authorize('create', Collection::class); + } + + $this->validate(); + + $isCreating = ! $this->isEditing; + + DB::transaction(function (): void { + $attributes = [ + 'title' => $this->title, + 'handle' => $this->resolvedHandle(), + 'description_html' => $this->descriptionHtml !== '' ? $this->descriptionHtml : null, + 'status' => $this->status, + ]; + + if ($this->isEditing) { + $this->collection->update($attributes); + } else { + $this->collection = Collection::query()->create($attributes + ['type' => 'manual']); + } + + $this->syncProducts(); + }); + + if ($isCreating) { + $this->flashToast(__('Collection saved')); + $this->redirect(route('admin.collections.edit', $this->collection), navigate: true); + + return; + } + + $this->collection->refresh(); + $this->fillFromCollection(); + $this->toast(__('Collection saved')); + } + + public function deleteCollection(): void + { + $this->authorize('delete', $this->collection); + + $this->collection->products()->detach(); + $this->collection->delete(); + + $this->flashToast(__('Collection deleted.')); + $this->redirect(route('admin.collections.index'), navigate: true); + } + + #[Computed] + public function isEditing(): bool + { + return $this->collection !== null; + } + + /** + * Products matching the search input that are not already assigned. + * + * @return \Illuminate\Database\Eloquent\Collection + */ + #[Computed] + public function searchResults(): \Illuminate\Database\Eloquent\Collection + { + if (trim($this->productSearch) === '') { + return new \Illuminate\Database\Eloquent\Collection; + } + + return Product::query() + ->where('title', 'like', '%'.trim($this->productSearch).'%') + ->whereNotIn('id', $this->assignedProductIds) + ->orderBy('title') + ->limit(8) + ->get(); + } + + /** + * Assigned products in their current display order. + * + * @return \Illuminate\Support\Collection + */ + #[Computed] + public function assignedProducts(): \Illuminate\Support\Collection + { + $products = Product::query() + ->with(['media' => fn ($query) => $query->limit(1)]) + ->whereIn('id', $this->assignedProductIds) + ->get() + ->keyBy('id'); + + return collect($this->assignedProductIds) + ->map(fn (int $id): ?Product => $products->get($id)) + ->filter() + ->values(); + } + + public function render(): View + { + return view('livewire.admin.collections.form') + ->title($this->isEditing ? $this->collection->title : __('Add collection')); + } + + /** + * @return array> + */ + protected function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:255'], + 'handle' => [ + 'nullable', + 'string', + 'max:255', + Rule::unique('collections', 'handle') + ->where('store_id', app('current_store')->getKey()) + ->ignore($this->collection?->getKey()), + ], + 'descriptionHtml' => ['nullable', 'string', 'max:65535'], + 'status' => ['required', 'in:draft,active,archived'], + ]; + } + + protected function syncProducts(): void + { + $validIds = Product::query() + ->whereIn('id', $this->assignedProductIds) + ->pluck('id') + ->all(); + + $sync = []; + + foreach (array_values(array_intersect($this->assignedProductIds, $validIds)) as $position => $productId) { + $sync[$productId] = ['position' => $position]; + } + + $this->collection->products()->sync($sync); + } + + protected function resolvedHandle(): string + { + $handle = trim($this->handle) !== '' ? trim($this->handle) : $this->title; + + return Str::slug($handle); + } + + protected function fillFromCollection(): void + { + $this->title = $this->collection->title; + $this->handle = $this->collection->handle; + $this->descriptionHtml = (string) $this->collection->description_html; + $this->status = $this->collection->status->value; + $this->assignedProductIds = $this->collection->products()->pluck('products.id')->all(); + } +} diff --git a/app/Livewire/Admin/Collections/Index.php b/app/Livewire/Admin/Collections/Index.php new file mode 100644 index 00000000..e4a0ada1 --- /dev/null +++ b/app/Livewire/Admin/Collections/Index.php @@ -0,0 +1,92 @@ +authorize('viewAny', Collection::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function confirmDelete(int $collectionId): void + { + $this->deletingCollectionId = $collectionId; + + Flux::modal('confirm-delete-collection')->show(); + } + + public function deleteCollection(): void + { + $collection = Collection::query()->findOrFail($this->deletingCollectionId); + + $this->authorize('delete', $collection); + + $collection->products()->detach(); + $collection->delete(); + + Flux::modal('confirm-delete-collection')->close(); + + $this->deletingCollectionId = null; + $this->toast(__('Collection deleted.')); + $this->resetPage(); + } + + /** + * @return LengthAwarePaginator + */ + #[Computed] + public function collections(): LengthAwarePaginator + { + return Collection::query() + ->withCount('products') + ->when($this->search !== '', fn ($query) => $query->where('title', 'like', '%'.$this->search.'%')) + ->when($this->statusFilter !== 'all', fn ($query) => $query->where('status', $this->statusFilter)) + ->orderByDesc('updated_at') + ->paginate(15); + } + + #[Computed] + public function hasAnyCollections(): bool + { + return Collection::query()->exists(); + } + + public function render(): View + { + return view('livewire.admin.collections.index')->title(__('Collections')); + } +} diff --git a/app/Livewire/Admin/Concerns/SendsToasts.php b/app/Livewire/Admin/Concerns/SendsToasts.php new file mode 100644 index 00000000..8dfdcb1c --- /dev/null +++ b/app/Livewire/Admin/Concerns/SendsToasts.php @@ -0,0 +1,22 @@ +dispatch('toast', type: $type, message: $message); + } + + /** + * Flash a toast into the session for display after a full redirect. + */ + protected function flashToast(string $message, string $type = 'success'): void + { + session()->flash('toast', ['type' => $type, 'message' => $message]); + } +} diff --git a/app/Livewire/Admin/Customers/Index.php b/app/Livewire/Admin/Customers/Index.php new file mode 100644 index 00000000..3be9b409 --- /dev/null +++ b/app/Livewire/Admin/Customers/Index.php @@ -0,0 +1,56 @@ +authorize('viewAny', Customer::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + /** + * @return LengthAwarePaginator + */ + #[Computed] + public function customers(): LengthAwarePaginator + { + return Customer::query() + ->withCount('orders') + ->withSum('orders', 'total_amount') + ->when($this->search !== '', function ($query): void { + $query->where(function ($query): void { + $query->where('name', 'like', '%'.$this->search.'%') + ->orWhere('email', 'like', '%'.$this->search.'%'); + }); + }) + ->orderByDesc('created_at') + ->paginate(15); + } + + public function render(): View + { + return view('livewire.admin.customers.index')->title(__('Customers')); + } +} diff --git a/app/Livewire/Admin/Customers/Show.php b/app/Livewire/Admin/Customers/Show.php new file mode 100644 index 00000000..6b3498b0 --- /dev/null +++ b/app/Livewire/Admin/Customers/Show.php @@ -0,0 +1,172 @@ + '', + 'last_name' => '', + 'address1' => '', + 'address2' => '', + 'city' => '', + 'province' => '', + 'zip' => '', + 'country_code' => 'DE', + ]; + + public function mount(int $customer): void + { + $this->customerId = $customer; + + $this->authorize('view', $this->customer); + } + + #[Computed] + public function customer(): Customer + { + return Customer::query() + ->withCount('orders') + ->withSum('orders', 'total_amount') + ->with('addresses') + ->findOrFail($this->customerId); + } + + /** + * @return LengthAwarePaginator + */ + #[Computed] + public function orders(): LengthAwarePaginator + { + return $this->customer->orders() + ->orderByDesc('placed_at') + ->paginate(10); + } + + public function openAddressForm(?int $addressId = null): void + { + $this->resetErrorBag(); + $this->editingAddressId = $addressId; + + if ($addressId !== null) { + $address = $this->customer->addresses()->findOrFail($addressId); + $json = $address->address_json ?? []; + + $this->addressLabel = (string) ($address->label ?? ''); + $this->addressFields = [ + 'first_name' => (string) ($json['first_name'] ?? ''), + 'last_name' => (string) ($json['last_name'] ?? ''), + 'address1' => (string) ($json['address1'] ?? ''), + 'address2' => (string) ($json['address2'] ?? ''), + 'city' => (string) ($json['city'] ?? ''), + 'province' => (string) ($json['province'] ?? ''), + 'zip' => (string) ($json['zip'] ?? ''), + 'country_code' => (string) ($json['country_code'] ?? 'DE'), + ]; + } else { + $this->addressLabel = ''; + $this->addressFields = [ + 'first_name' => '', + 'last_name' => '', + 'address1' => '', + 'address2' => '', + 'city' => '', + 'province' => '', + 'zip' => '', + 'country_code' => 'DE', + ]; + } + + Flux::modal('address-form')->show(); + } + + public function saveAddress(): void + { + $this->authorize('update', $this->customer); + + $this->validate([ + 'addressLabel' => ['nullable', 'string', 'max:255'], + 'addressFields.address1' => ['required', 'string', 'max:255'], + 'addressFields.city' => ['required', 'string', 'max:255'], + 'addressFields.zip' => ['required', 'string', 'max:32'], + 'addressFields.country_code' => ['required', 'string', 'size:2'], + ]); + + $payload = [ + 'label' => $this->addressLabel !== '' ? $this->addressLabel : null, + 'address_json' => array_filter($this->addressFields, fn (string $value): bool => trim($value) !== ''), + ]; + + if ($this->editingAddressId !== null) { + $this->customer->addresses()->findOrFail($this->editingAddressId)->update($payload); + } else { + $this->customer->addresses()->create($payload + [ + 'is_default' => $this->customer->addresses()->count() === 0, + ]); + } + + Flux::modal('address-form')->close(); + + unset($this->customer); + $this->toast(__('Customer saved')); + } + + public function deleteAddress(int $addressId): void + { + $this->authorize('update', $this->customer); + + $address = $this->customer->addresses()->findOrFail($addressId); + $wasDefault = $address->is_default; + + $address->delete(); + + if ($wasDefault) { + $this->customer->addresses()->orderByDesc('id')->first()?->update(['is_default' => true]); + } + + unset($this->customer); + $this->toast(__('Address removed.')); + } + + public function setDefaultAddress(int $addressId): void + { + $this->authorize('update', $this->customer); + + $address = $this->customer->addresses()->findOrFail($addressId); + + $this->customer->addresses()->whereKeyNot($address->getKey())->update(['is_default' => false]); + $address->update(['is_default' => true]); + + unset($this->customer); + $this->toast(__('Default address updated.')); + } + + public function render(): View + { + return view('livewire.admin.customers.show') + ->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..9215c22d --- /dev/null +++ b/app/Livewire/Admin/Dashboard.php @@ -0,0 +1,175 @@ +rangeDays(); + $end = now(); + $start = $end->startOfDay()->subDays($days - 1); + $previousStart = $start->subDays($days); + + $current = $this->kpisBetween($start, $end); + $previous = $this->kpisBetween($previousStart, $start); + + return view('livewire.admin.dashboard', [ + 'totalSales' => $current['total_sales'], + 'ordersCount' => $current['orders_count'], + 'averageOrderValue' => $current['average_order_value'], + 'conversionRate' => $current['conversion_rate'], + 'salesChange' => $this->percentChange($previous['total_sales'], $current['total_sales']), + 'ordersChange' => $this->percentChange($previous['orders_count'], $current['orders_count']), + 'aovChange' => $this->percentChange($previous['average_order_value'], $current['average_order_value']), + 'conversionChange' => $this->percentChange($previous['conversion_rate'], $current['conversion_rate']), + 'formattedTotalSales' => PriceFormatter::format($current['total_sales'], $this->currency()), + 'formattedAov' => PriceFormatter::format($current['average_order_value'], $this->currency()), + 'chart' => $this->ordersChart($start, $end), + 'recentOrders' => $this->recentOrders(), + ])->title(__('Dashboard')); + } + + /** + * Aggregate order KPIs for the half-open interval [start, end). + * Cancelled orders are excluded from revenue figures. + * + * The conversion rate uses tracked storefront visits from the Phase 9 + * analytics pipeline (sum of analytics_daily.visits_count for the + * range). Stores without analytics data fall back to the Phase 7a + * approximation of orders placed / carts created. + * + * @return array{total_sales: int, orders_count: int, average_order_value: int, conversion_rate: float} + */ + protected function kpisBetween(CarbonImmutable $start, CarbonImmutable $end): array + { + $orders = Order::query() + ->where('placed_at', '>=', $start) + ->where('placed_at', '<', $end) + ->where('status', '!=', OrderStatus::Cancelled); + + $ordersCount = (clone $orders)->count(); + $totalSales = (int) (clone $orders)->sum('total_amount'); + + $visitsCount = (int) AnalyticsDaily::query() + ->where('store_id', app('current_store')->getKey()) + ->where('date', '>=', $start->toDateString()) + ->where('date', '<', $end->toDateString()) + ->sum('visits_count'); + + if ($visitsCount === 0) { + $visitsCount = Cart::query() + ->where('created_at', '>=', $start) + ->where('created_at', '<', $end) + ->count(); + } + + return [ + 'total_sales' => $totalSales, + 'orders_count' => $ordersCount, + 'average_order_value' => $ordersCount > 0 ? intdiv($totalSales, $ordersCount) : 0, + 'conversion_rate' => $visitsCount > 0 ? round($ordersCount / $visitsCount * 100, 1) : 0.0, + ]; + } + + /** + * Daily order counts plus the precomputed SVG polyline geometry for the + * dependency-free inline line chart. + * + * @return array{days: list, max: int, points: string, area: string} + */ + protected function ordersChart(CarbonImmutable $start, CarbonImmutable $end): array + { + $countsByDay = Order::query() + ->where('placed_at', '>=', $start) + ->where('placed_at', '<', $end) + ->where('status', '!=', OrderStatus::Cancelled) + ->selectRaw('date(placed_at) as day, count(*) as total') + ->groupBy('day') + ->pluck('total', 'day'); + + $days = []; + + for ($date = $start; $date < $end; $date = $date->addDay()) { + $days[] = [ + 'date' => $date->format('Y-m-d'), + 'count' => (int) ($countsByDay[$date->format('Y-m-d')] ?? 0), + ]; + } + + $max = max(1, ...array_column($days, 'count')); + + $width = 600; + $height = 180; + $stepX = count($days) > 1 ? $width / (count($days) - 1) : $width; + + $points = []; + + foreach ($days as $index => $day) { + $x = round($index * $stepX, 1); + $y = round($height - ($day['count'] / $max) * ($height - 10) - 5, 1); + $points[] = "{$x},{$y}"; + } + + $polyline = implode(' ', $points); + $area = "0,{$height} ".$polyline." {$width},{$height}"; + + return [ + 'days' => $days, + 'max' => $max, + 'points' => $polyline, + 'area' => $area, + ]; + } + + /** + * The ten most recently placed orders for the recent orders table. + * + * @return \Illuminate\Database\Eloquent\Collection + */ + protected function recentOrders(): \Illuminate\Database\Eloquent\Collection + { + return Order::query() + ->with('customer') + ->orderByDesc('placed_at') + ->limit(10) + ->get(); + } + + protected function rangeDays(): int + { + return in_array($this->dateRange, ['7', '30', '90'], true) ? (int) $this->dateRange : 30; + } + + protected function currency(): string + { + return app('current_store')->default_currency ?? 'EUR'; + } + + protected function percentChange(int|float $previous, int|float $current): float + { + if ((float) $previous === 0.0) { + return $current > 0 ? 100.0 : 0.0; + } + + return round(($current - $previous) / $previous * 100, 1); + } +} diff --git a/app/Livewire/Admin/Developers/Index.php b/app/Livewire/Admin/Developers/Index.php new file mode 100644 index 00000000..f6e7f374 --- /dev/null +++ b/app/Livewire/Admin/Developers/Index.php @@ -0,0 +1,256 @@ + */ + public array $newTokenAbilities = []; + + public ?string $generatedToken = null; + + public ?int $editingWebhookId = null; + + public string $webhookEventType = 'order.created'; + + public string $webhookUrl = ''; + + public ?string $generatedWebhookSecret = null; + + public function mount(): void + { + $this->authorize('manageDevelopers', $this->store()); + } + + public function generateToken(): void + { + $this->authorize('manageDevelopers', $this->store()); + + $this->validate([ + 'newTokenName' => ['required', 'string', 'max:255'], + 'newTokenAbilities' => ['required', 'array', 'min:1'], + 'newTokenAbilities.*' => [Rule::in(TokenAbilities::names())], + ], [], [ + 'newTokenName' => __('token name'), + 'newTokenAbilities' => __('abilities'), + ]); + + $token = auth()->user()->createToken($this->newTokenName, $this->newTokenAbilities); + + $this->generatedToken = $token->plainTextToken; + + Flux::modal('generate-token')->close(); + + $this->reset('newTokenName', 'newTokenAbilities'); + unset($this->tokens); + + $this->toast(__('API token created.')); + } + + public function revokeToken(int $tokenId): void + { + $this->authorize('manageDevelopers', $this->store()); + + auth()->user()->tokens()->whereKey($tokenId)->delete(); + + unset($this->tokens); + + $this->toast(__('API token revoked.')); + } + + public function openWebhookModal(?int $webhookId = null): void + { + $this->authorize('manageDevelopers', $this->store()); + + $this->resetErrorBag(); + + if ($webhookId !== null) { + $webhook = WebhookSubscription::query()->findOrFail($webhookId); + + $this->editingWebhookId = $webhook->getKey(); + $this->webhookEventType = $webhook->event_type; + $this->webhookUrl = $webhook->target_url; + } else { + $this->editingWebhookId = null; + $this->webhookEventType = 'order.created'; + $this->webhookUrl = ''; + } + + Flux::modal('webhook-form')->show(); + } + + public function saveWebhook(): void + { + $this->authorize('manageDevelopers', $this->store()); + + $this->validate([ + 'webhookEventType' => ['required', Rule::in(WebhookService::EVENT_TYPES)], + 'webhookUrl' => ['required', 'url:https,http', 'max:2048'], + ], [], [ + 'webhookEventType' => __('event type'), + 'webhookUrl' => __('endpoint URL'), + ]); + + if ($this->editingWebhookId !== null) { + $webhook = WebhookSubscription::query()->findOrFail($this->editingWebhookId); + + $webhook->update([ + 'event_type' => $this->webhookEventType, + 'target_url' => $this->webhookUrl, + ]); + + $this->toast(__('Webhook updated.')); + } else { + $secret = 'whsec_'.Str::random(32); + + WebhookSubscription::query()->create([ + 'store_id' => $this->store()->getKey(), + 'event_type' => $this->webhookEventType, + 'target_url' => $this->webhookUrl, + 'signing_secret_encrypted' => $secret, + 'status' => WebhookSubscriptionStatus::Active, + ]); + + $this->generatedWebhookSecret = $secret; + + $this->toast(__('Webhook created.')); + } + + Flux::modal('webhook-form')->close(); + + $this->reset('editingWebhookId', 'webhookUrl'); + $this->webhookEventType = 'order.created'; + unset($this->webhooks, $this->recentDeliveries); + } + + /** + * Pause an active subscription or resume a paused one. Resuming resets + * the circuit breaker counter (spec 05 section 13.4: manual re-enable). + */ + public function toggleWebhookStatus(int $webhookId): void + { + $this->authorize('manageDevelopers', $this->store()); + + $webhook = WebhookSubscription::query()->findOrFail($webhookId); + + if ($webhook->status === WebhookSubscriptionStatus::Active) { + $webhook->update(['status' => WebhookSubscriptionStatus::Paused]); + + $this->toast(__('Webhook paused.')); + } else { + $webhook->update([ + 'status' => WebhookSubscriptionStatus::Active, + 'consecutive_failures' => 0, + ]); + + $this->toast(__('Webhook resumed.')); + } + + unset($this->webhooks); + } + + public function deleteWebhook(int $webhookId): void + { + $this->authorize('manageDevelopers', $this->store()); + + WebhookSubscription::query()->whereKey($webhookId)->delete(); + + unset($this->webhooks, $this->recentDeliveries); + + $this->toast(__('Webhook deleted.')); + } + + /** + * @return Collection + */ + #[Computed] + public function tokens(): Collection + { + return auth()->user()->tokens()->latest('id')->get(); + } + + /** + * @return array + */ + #[Computed] + public function availableAbilities(): array + { + return TokenAbilities::all(); + } + + /** + * @return Collection + */ + #[Computed] + public function webhooks(): Collection + { + return WebhookSubscription::query() + ->with('latestDelivery') + ->orderBy('id') + ->get(); + } + + /** + * The ten most recent delivery attempts across all of the store's + * subscriptions, for the recent-deliveries panel. + * + * @return Collection + */ + #[Computed] + public function recentDeliveries(): Collection + { + return WebhookDelivery::query() + ->whereHas('subscription') + ->with('subscription') + ->whereNotNull('last_attempt_at') + ->orderByDesc('id') + ->limit(10) + ->get(); + } + + /** + * @return list + */ + #[Computed] + public function webhookEventTypes(): array + { + return WebhookService::EVENT_TYPES; + } + + public function render(): View + { + return view('livewire.admin.developers.index')->title(__('Developers')); + } + + protected function store(): Store + { + return app('current_store'); + } +} diff --git a/app/Livewire/Admin/Discounts/Form.php b/app/Livewire/Admin/Discounts/Form.php new file mode 100644 index 00000000..5ca25b47 --- /dev/null +++ b/app/Livewire/Admin/Discounts/Form.php @@ -0,0 +1,319 @@ + */ + public array $specificProductIds = []; + + /** @var list */ + public array $specificCollectionIds = []; + + public string $usageLimit = ''; + + public bool $onePerCustomer = false; + + public string $startsAt = ''; + + public string $endsAt = ''; + + public bool $isActive = true; + + public string $productSearch = ''; + + public string $collectionSearch = ''; + + public function mount(?int $discountId = null): void + { + if ($discountId !== null) { + $this->discount = Discount::query()->findOrFail($discountId); + + $this->authorize('view', $this->discount); + $this->fillFromDiscount(); + + return; + } + + $this->authorize('create', Discount::class); + $this->startsAt = now()->format('Y-m-d\TH:i'); + } + + public function generateCode(): void + { + $this->code = strtoupper(Str::random(8)); + } + + public function addProduct(int $productId): void + { + if (! in_array($productId, $this->specificProductIds, true)) { + $this->specificProductIds[] = $productId; + } + + $this->productSearch = ''; + } + + public function removeProduct(int $productId): void + { + $this->specificProductIds = array_values( + array_filter($this->specificProductIds, fn (int $id): bool => $id !== $productId), + ); + } + + public function addCollection(int $collectionId): void + { + if (! in_array($collectionId, $this->specificCollectionIds, true)) { + $this->specificCollectionIds[] = $collectionId; + } + + $this->collectionSearch = ''; + } + + public function removeCollection(int $collectionId): void + { + $this->specificCollectionIds = array_values( + array_filter($this->specificCollectionIds, fn (int $id): bool => $id !== $collectionId), + ); + } + + public function save(): void + { + if ($this->isEditing) { + $this->authorize('update', $this->discount); + } else { + $this->authorize('create', Discount::class); + } + + $this->validate(); + + $attributes = [ + 'type' => $this->type, + 'code' => $this->type === DiscountType::Code->value ? strtoupper(trim($this->code)) : null, + 'value_type' => $this->valueType, + 'value_amount' => $this->resolvedValueAmount(), + 'starts_at' => Carbon::parse($this->startsAt), + 'ends_at' => trim($this->endsAt) !== '' ? Carbon::parse($this->endsAt) : null, + 'usage_limit' => trim($this->usageLimit) !== '' ? (int) $this->usageLimit : null, + 'rules_json' => $this->buildRules(), + 'status' => $this->isActive ? DiscountStatus::Active : DiscountStatus::Disabled, + ]; + + if ($this->isEditing) { + $this->discount->update($attributes); + $this->discount->refresh(); + $this->fillFromDiscount(); + $this->toast(__('Discount saved')); + + return; + } + + $this->discount = Discount::query()->create($attributes); + + $this->flashToast(__('Discount saved')); + $this->redirect(route('admin.discounts.edit', $this->discount), navigate: true); + } + + public function deleteDiscount(): void + { + $this->authorize('delete', $this->discount); + + $this->discount->delete(); + + $this->flashToast(__('Discount deleted.')); + $this->redirect(route('admin.discounts.index'), navigate: true); + } + + #[Computed] + public function isEditing(): bool + { + return $this->discount !== null; + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + #[Computed] + public function productSearchResults(): \Illuminate\Database\Eloquent\Collection + { + if (trim($this->productSearch) === '') { + return new \Illuminate\Database\Eloquent\Collection; + } + + return Product::query() + ->where('title', 'like', '%'.trim($this->productSearch).'%') + ->whereNotIn('id', $this->specificProductIds) + ->orderBy('title') + ->limit(8) + ->get(); + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + #[Computed] + public function collectionSearchResults(): \Illuminate\Database\Eloquent\Collection + { + if (trim($this->collectionSearch) === '') { + return new \Illuminate\Database\Eloquent\Collection; + } + + return Collection::query() + ->where('title', 'like', '%'.trim($this->collectionSearch).'%') + ->whereNotIn('id', $this->specificCollectionIds) + ->orderBy('title') + ->limit(8) + ->get(); + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + #[Computed] + public function selectedProducts(): \Illuminate\Database\Eloquent\Collection + { + return Product::query()->whereIn('id', $this->specificProductIds)->get(); + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + #[Computed] + public function selectedCollections(): \Illuminate\Database\Eloquent\Collection + { + return Collection::query()->whereIn('id', $this->specificCollectionIds)->get(); + } + + public function render(): View + { + return view('livewire.admin.discounts.form') + ->title($this->isEditing ? ($this->discount->code ?? __('Automatic discount')) : __('Create discount')); + } + + /** + * @return array> + */ + protected function rules(): array + { + return [ + 'type' => ['required', 'in:code,automatic'], + 'code' => [ + Rule::requiredIf($this->type === DiscountType::Code->value), + 'nullable', + 'string', + 'max:255', + Rule::unique('discounts', 'code') + ->where('store_id', app('current_store')->getKey()) + ->ignore($this->discount?->getKey()), + ], + 'valueType' => ['required', 'in:percent,fixed,free_shipping'], + 'valueAmount' => [ + Rule::requiredIf($this->valueType !== DiscountValueType::FreeShipping->value), + 'nullable', + 'numeric', + 'min:0', + ...($this->valueType === DiscountValueType::Percent->value ? ['max:100'] : []), + ], + 'minimumPurchaseAmount' => ['nullable', 'numeric', 'min:0'], + 'usageLimit' => ['nullable', 'integer', 'min:1'], + 'startsAt' => ['required', 'date'], + 'endsAt' => ['nullable', 'date', 'after:startsAt'], + ]; + } + + /** + * Percent values are stored as whole numbers, fixed values in minor + * units, free shipping ignores the amount (spec 01 discounts notes). + */ + protected function resolvedValueAmount(): int + { + return match (DiscountValueType::from($this->valueType)) { + DiscountValueType::Percent => (int) round((float) $this->valueAmount), + DiscountValueType::Fixed => (int) round((float) str_replace(',', '.', $this->valueAmount) * 100), + DiscountValueType::FreeShipping => 0, + }; + } + + /** + * @return array + */ + protected function buildRules(): array + { + $rules = []; + + if (trim($this->minimumPurchaseAmount) !== '') { + $rules['min_purchase_amount'] = (int) round((float) str_replace(',', '.', $this->minimumPurchaseAmount) * 100); + } + + if ($this->specificProductIds !== []) { + $rules['applicable_product_ids'] = array_values($this->specificProductIds); + } + + if ($this->specificCollectionIds !== []) { + $rules['applicable_collection_ids'] = array_values($this->specificCollectionIds); + } + + if ($this->onePerCustomer) { + $rules['one_per_customer'] = true; + } + + return $rules; + } + + protected function fillFromDiscount(): void + { + $this->type = $this->discount->type->value; + $this->code = (string) $this->discount->code; + $this->valueType = $this->discount->value_type->value; + $this->valueAmount = match ($this->discount->value_type) { + DiscountValueType::Percent => (string) $this->discount->value_amount, + DiscountValueType::Fixed => number_format($this->discount->value_amount / 100, 2, '.', ''), + DiscountValueType::FreeShipping => '', + }; + + $minimum = $this->discount->minimumPurchaseAmount(); + $this->minimumPurchaseAmount = $minimum !== null ? number_format($minimum / 100, 2, '.', '') : ''; + + $this->specificProductIds = array_map('intval', $this->discount->rules_json['applicable_product_ids'] ?? []); + $this->specificCollectionIds = array_map('intval', $this->discount->rules_json['applicable_collection_ids'] ?? []); + $this->onePerCustomer = (bool) ($this->discount->rules_json['one_per_customer'] ?? false); + $this->usageLimit = $this->discount->usage_limit !== null ? (string) $this->discount->usage_limit : ''; + $this->startsAt = $this->discount->starts_at?->format('Y-m-d\TH:i') ?? now()->format('Y-m-d\TH:i'); + $this->endsAt = $this->discount->ends_at?->format('Y-m-d\TH:i') ?? ''; + $this->isActive = $this->discount->status === DiscountStatus::Active; + } +} diff --git a/app/Livewire/Admin/Discounts/Index.php b/app/Livewire/Admin/Discounts/Index.php new file mode 100644 index 00000000..abcecb8b --- /dev/null +++ b/app/Livewire/Admin/Discounts/Index.php @@ -0,0 +1,107 @@ +authorize('viewAny', Discount::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function updatedTypeFilter(): void + { + $this->resetPage(); + } + + /** + * @return LengthAwarePaginator + */ + #[Computed] + public function discounts(): LengthAwarePaginator + { + return Discount::query() + ->when($this->search !== '', fn ($query) => $query->where('code', 'like', '%'.$this->search.'%')) + ->when($this->typeFilter !== 'all', fn ($query) => $query->where('type', $this->typeFilter)) + ->when($this->statusFilter !== 'all', function ($query): void { + match ($this->statusFilter) { + 'scheduled' => $query + ->where('status', DiscountStatus::Active) + ->where('starts_at', '>', now()), + 'expired' => $query->where(function ($query): void { + $query->where('status', DiscountStatus::Expired) + ->orWhere(fn ($query) => $query->whereNotNull('ends_at')->where('ends_at', '<', now())); + }), + 'active' => $query + ->where('status', DiscountStatus::Active) + ->where('starts_at', '<=', now()) + ->where(fn ($query) => $query->whereNull('ends_at')->orWhere('ends_at', '>=', now())), + default => $query->where('status', $this->statusFilter), + }; + }) + ->orderByDesc('updated_at') + ->paginate(15); + } + + #[Computed] + public function hasAnyDiscounts(): bool + { + return Discount::query()->exists(); + } + + /** + * The effective display status of a discount: schedule-aware variant of + * the stored status (spec 03 section 10.1 badge colors). + */ + public function displayStatus(Discount $discount): string + { + if ($discount->status === DiscountStatus::Active && $discount->starts_at !== null && $discount->starts_at->isFuture()) { + return 'scheduled'; + } + + if ($discount->status === DiscountStatus::Active && $discount->ends_at !== null && $discount->ends_at->isPast()) { + return 'expired'; + } + + return $discount->status->value; + } + + public function render(): View + { + return view('livewire.admin.discounts.index')->title(__('Discounts')); + } +} diff --git a/app/Livewire/Admin/Inventory/Index.php b/app/Livewire/Admin/Inventory/Index.php new file mode 100644 index 00000000..a17b5176 --- /dev/null +++ b/app/Livewire/Admin/Inventory/Index.php @@ -0,0 +1,100 @@ +authorize('viewAny', Product::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStockFilter(): void + { + $this->resetPage(); + } + + /** + * Inline on-hand quantity adjustment (spec 03 section 6). Inventory + * adjustments follow the product update permission (owner/admin/staff). + */ + public function updateQuantity(int $itemId, mixed $quantity): void + { + $item = InventoryItem::query()->with('variant.product')->findOrFail($itemId); + + $this->authorize('update', $item->variant->product); + + $quantity = max(0, (int) $quantity); + + $item->update(['quantity_on_hand' => $quantity]); + + $this->toast(__('Inventory updated.')); + } + + /** + * @return LengthAwarePaginator + */ + #[Computed] + public function inventoryItems(): LengthAwarePaginator + { + return InventoryItem::query() + ->with('variant.product', 'variant.optionValues') + ->whereHas('variant') + ->when($this->search !== '', function ($query): void { + $search = '%'.$this->search.'%'; + + $query->where(function ($query) use ($search): void { + $query->whereHas('variant', fn ($query) => $query->where('sku', 'like', $search)) + ->orWhereHas('variant.product', fn ($query) => $query->where('title', 'like', $search)); + }); + }) + ->when($this->stockFilter !== 'all', function ($query): void { + match ($this->stockFilter) { + 'in_stock' => $query->whereRaw('quantity_on_hand - quantity_reserved > 0'), + 'low_stock' => $query + ->whereRaw('quantity_on_hand - quantity_reserved > 0') + ->whereRaw('quantity_on_hand - quantity_reserved <= ?', [self::LOW_STOCK_THRESHOLD]), + 'out_of_stock' => $query->whereRaw('quantity_on_hand - quantity_reserved <= 0'), + default => null, + }; + }) + ->orderBy('id') + ->paginate(20); + } + + public function render(): View + { + return view('livewire.admin.inventory.index')->title(__('Inventory')); + } +} diff --git a/app/Livewire/Admin/Layout/Sidebar.php b/app/Livewire/Admin/Layout/Sidebar.php new file mode 100644 index 00000000..b869f21f --- /dev/null +++ b/app/Livewire/Admin/Layout/Sidebar.php @@ -0,0 +1,87 @@ + $this->navigationGroups(), + ]); + } + + /** + * The sidebar navigation structure (spec 03 section 1.2). Items whose + * route is not registered yet (later phases) render as disabled entries + * and become active automatically once the route exists. + * + * @return list}> + */ + protected function navigationGroups(): array + { + $groups = [ + [ + 'label' => null, + 'items' => [ + ['label' => __('Dashboard'), 'icon' => 'chart-bar', 'route' => 'admin.dashboard', 'active' => 'admin.dashboard'], + ], + ], + [ + 'label' => __('Products'), + 'items' => [ + ['label' => __('Products'), 'icon' => 'cube', 'route' => 'admin.products.index', 'active' => 'admin.products.*'], + ['label' => __('Collections'), 'icon' => 'rectangle-stack', 'route' => 'admin.collections.index', 'active' => 'admin.collections.*'], + ['label' => __('Inventory'), 'icon' => 'archive-box', 'route' => 'admin.inventory.index', 'active' => 'admin.inventory.*'], + ], + ], + [ + 'label' => __('Orders'), + 'items' => [ + ['label' => __('Orders'), 'icon' => 'shopping-bag', 'route' => 'admin.orders.index', 'active' => 'admin.orders.*'], + ], + ], + [ + 'label' => __('Customers'), + 'items' => [ + ['label' => __('Customers'), 'icon' => 'users', 'route' => 'admin.customers.index', 'active' => 'admin.customers.*'], + ], + ], + [ + 'label' => __('Discounts'), + 'items' => [ + ['label' => __('Discounts'), 'icon' => 'tag', 'route' => 'admin.discounts.index', 'active' => 'admin.discounts.*'], + ], + ], + [ + 'label' => __('Content'), + 'items' => [ + ['label' => __('Pages'), 'icon' => 'document-text', 'route' => 'admin.pages.index', 'active' => 'admin.pages.*'], + ['label' => __('Navigation'), 'icon' => 'bars-3', 'route' => 'admin.navigation.index', 'active' => 'admin.navigation.*'], + ['label' => __('Themes'), 'icon' => 'paint-brush', 'route' => 'admin.themes.index', 'active' => 'admin.themes.*'], + ], + ], + [ + 'label' => null, + 'items' => [ + ['label' => __('Analytics'), 'icon' => 'chart-pie', 'route' => 'admin.analytics.index', 'active' => 'admin.analytics.*'], + ['label' => __('Settings'), 'icon' => 'cog-6-tooth', 'route' => 'admin.settings.index', 'active' => 'admin.settings.*'], + ['label' => __('Apps'), 'icon' => 'squares-2x2', 'route' => 'admin.apps.index', 'active' => 'admin.apps.*'], + ['label' => __('Developers'), 'icon' => 'code-bracket', 'route' => 'admin.developers.index', 'active' => 'admin.developers.*'], + ], + ], + ]; + + foreach ($groups as $groupIndex => $group) { + foreach ($group['items'] as $itemIndex => $item) { + $groups[$groupIndex]['items'][$itemIndex]['enabled'] = Route::has($item['route']); + } + } + + return $groups; + } +} diff --git a/app/Livewire/Admin/Layout/TopBar.php b/app/Livewire/Admin/Layout/TopBar.php new file mode 100644 index 00000000..22c43094 --- /dev/null +++ b/app/Livewire/Admin/Layout/TopBar.php @@ -0,0 +1,36 @@ +user(); + + abort_unless($user->stores()->whereKey($storeId)->exists(), 403); + + session(['current_store_id' => $storeId]); + + $this->redirect(route('admin.dashboard')); + } + + public function render(): View + { + /** @var User $user */ + $user = auth()->user(); + + return view('livewire.admin.layout.top-bar', [ + 'stores' => $user->stores()->orderBy('name')->get(), + ]); + } +} diff --git a/app/Livewire/Admin/Navigation/Index.php b/app/Livewire/Admin/Navigation/Index.php new file mode 100644 index 00000000..1af801a5 --- /dev/null +++ b/app/Livewire/Admin/Navigation/Index.php @@ -0,0 +1,277 @@ + + */ + public array $menuItems = []; + + public ?int $editingItemIndex = null; + + public string $itemLabel = ''; + + public string $itemType = 'link'; + + public string $itemUrl = ''; + + /** Selected resource id for page/collection/product items (select value). */ + public string $itemResourceId = ''; + + public function mount(): void + { + $this->authorize('viewAny', NavigationMenu::class); + } + + public function selectMenu(int $menuId): void + { + $menu = NavigationMenu::query()->with('items')->findOrFail($menuId); + + $this->authorize('view', $menu); + + $this->editingMenuId = $menu->getKey(); + $this->menuItems = $menu->items + ->map(fn ($item): array => [ + 'id' => $item->getKey(), + 'label' => $item->label, + 'type' => $item->type->value, + 'url' => $item->url, + 'resourceId' => $item->resource_id, + ]) + ->values() + ->all(); + } + + public function addItem(): void + { + $this->authorize('update', $this->editingMenu()); + $this->resetErrorBag(); + + $this->editingItemIndex = null; + $this->itemLabel = ''; + $this->itemType = 'link'; + $this->itemUrl = ''; + $this->itemResourceId = ''; + + Flux::modal('item-form')->show(); + } + + public function editItem(int $index): void + { + $this->authorize('update', $this->editingMenu()); + $this->resetErrorBag(); + + $item = $this->menuItems[$index] ?? null; + + if ($item === null) { + return; + } + + $this->editingItemIndex = $index; + $this->itemLabel = $item['label']; + $this->itemType = $item['type']; + $this->itemUrl = (string) ($item['url'] ?? ''); + $this->itemResourceId = $item['resourceId'] !== null ? (string) $item['resourceId'] : ''; + + Flux::modal('item-form')->show(); + } + + public function saveItem(): void + { + $this->authorize('update', $this->editingMenu()); + + $this->validate([ + 'itemLabel' => ['required', 'string', 'max:255'], + 'itemType' => ['required', 'in:link,page,collection,product'], + 'itemUrl' => [$this->itemType === 'link' ? 'required' : 'nullable', 'string', 'max:2048'], + 'itemResourceId' => [$this->itemType !== 'link' ? 'required' : 'nullable', 'integer'], + ]); + + $isLink = $this->itemType === NavigationItemType::Link->value; + + $item = [ + 'id' => $this->editingItemIndex !== null ? ($this->menuItems[$this->editingItemIndex]['id'] ?? null) : null, + 'label' => $this->itemLabel, + 'type' => $this->itemType, + 'url' => $isLink ? $this->itemUrl : null, + 'resourceId' => $isLink ? null : (int) $this->itemResourceId, + ]; + + if ($this->editingItemIndex !== null) { + $this->menuItems[$this->editingItemIndex] = $item; + } else { + $this->menuItems[] = $item; + } + + Flux::modal('item-form')->close(); + + $this->editingItemIndex = null; + } + + public function removeItem(int $index): void + { + $this->authorize('update', $this->editingMenu()); + + unset($this->menuItems[$index]); + $this->menuItems = array_values($this->menuItems); + } + + /** + * Drag-to-reorder handler (wire:sort): the sort key is the item index. + */ + public function reorderItems(int $index, int $position): void + { + $this->authorize('update', $this->editingMenu()); + + $item = $this->menuItems[$index] ?? null; + + if ($item === null) { + return; + } + + array_splice($this->menuItems, $index, 1); + array_splice($this->menuItems, $position, 0, [$item]); + $this->menuItems = array_values($this->menuItems); + } + + /** + * Persist the buffered items and invalidate the cached navigation tree. + */ + public function saveMenu(): void + { + $menu = $this->editingMenu(); + + $this->authorize('update', $menu); + + DB::transaction(function () use ($menu): void { + $keptIds = array_values(array_filter(array_column($this->menuItems, 'id'))); + + $menu->items()->when( + $keptIds !== [], + fn ($query) => $query->whereNotIn('id', $keptIds), + )->delete(); + + foreach ($this->menuItems as $position => $item) { + $attributes = [ + 'label' => $item['label'], + 'type' => $item['type'], + 'url' => $item['url'], + 'resource_id' => $item['resourceId'], + 'position' => $position, + ]; + + if ($item['id'] !== null) { + $menu->items()->whereKey($item['id'])->update($attributes); + } else { + $menu->items()->create($attributes); + } + } + }); + + app(NavigationService::class)->forget($menu->store_id, $menu->handle); + + $this->selectMenu($menu->getKey()); + $this->toast(__('Navigation saved')); + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + #[Computed] + public function menus(): \Illuminate\Database\Eloquent\Collection + { + return NavigationMenu::query()->withCount('items')->orderBy('title')->get(); + } + + /** + * Pages available for the page item type picker. + * + * @return \Illuminate\Support\Collection + */ + #[Computed] + public function availablePages(): \Illuminate\Support\Collection + { + return Page::query()->orderBy('title')->get(['id', 'title']) + ->map(fn (Page $page): array => ['id' => $page->getKey(), 'title' => $page->title]); + } + + /** + * @return \Illuminate\Support\Collection + */ + #[Computed] + public function availableCollections(): \Illuminate\Support\Collection + { + return Collection::query()->orderBy('title')->get(['id', 'title']) + ->map(fn (Collection $collection): array => ['id' => $collection->getKey(), 'title' => $collection->title]); + } + + /** + * @return \Illuminate\Support\Collection + */ + #[Computed] + public function availableProducts(): \Illuminate\Support\Collection + { + return Product::query()->orderBy('title')->get(['id', 'title']) + ->map(fn (Product $product): array => ['id' => $product->getKey(), 'title' => $product->title]); + } + + /** + * Short human readable target description for an item row. + * + * @param array{id: int|null, label: string, type: string, url: string|null, resourceId: int|null} $item + */ + public function describeItem(array $item): string + { + if ($item['type'] === NavigationItemType::Link->value) { + return __('link: :url', ['url' => $item['url'] ?? '/']); + } + + $title = match ($item['type']) { + NavigationItemType::Page->value => $this->availablePages->firstWhere('id', $item['resourceId'])['title'] ?? null, + NavigationItemType::Collection->value => $this->availableCollections->firstWhere('id', $item['resourceId'])['title'] ?? null, + NavigationItemType::Product->value => $this->availableProducts->firstWhere('id', $item['resourceId'])['title'] ?? null, + default => null, + }; + + return $item['type'].': '.($title ?? __('(missing)')); + } + + public function render(): View + { + return view('livewire.admin.navigation.index')->title(__('Navigation')); + } + + protected function editingMenu(): NavigationMenu + { + return NavigationMenu::query()->findOrFail($this->editingMenuId); + } +} diff --git a/app/Livewire/Admin/Orders/Index.php b/app/Livewire/Admin/Orders/Index.php new file mode 100644 index 00000000..4d4adbde --- /dev/null +++ b/app/Livewire/Admin/Orders/Index.php @@ -0,0 +1,106 @@ +authorize('viewAny', Order::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function updatedDateFrom(): void + { + $this->resetPage(); + } + + public function updatedDateTo(): void + { + $this->resetPage(); + } + + public function setStatusFilter(string $status): void + { + $this->statusFilter = $status; + $this->resetPage(); + } + + public function sortBy(string $field): void + { + if (! in_array($field, ['placed_at', 'total_amount'], true)) { + return; + } + + if ($this->sortField === $field) { + $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortField = $field; + $this->sortDirection = 'desc'; + } + + $this->resetPage(); + } + + /** + * @return LengthAwarePaginator + */ + #[Computed] + public function orders(): LengthAwarePaginator + { + return Order::query() + ->with('customer') + ->when($this->search !== '', function ($query): void { + $query->where(function ($query): void { + $query->where('order_number', 'like', '%'.$this->search.'%') + ->orWhere('email', 'like', '%'.$this->search.'%'); + }); + }) + ->when($this->statusFilter !== 'all', fn ($query) => $query->where('status', $this->statusFilter)) + ->when(filled($this->dateFrom), fn ($query) => $query->whereDate('placed_at', '>=', $this->dateFrom)) + ->when(filled($this->dateTo), fn ($query) => $query->whereDate('placed_at', '<=', $this->dateTo)) + ->orderBy($this->sortField, $this->sortDirection) + ->paginate(15); + } + + public function render(): View + { + return view('livewire.admin.orders.index')->title(__('Orders')); + } +} diff --git a/app/Livewire/Admin/Orders/Show.php b/app/Livewire/Admin/Orders/Show.php new file mode 100644 index 00000000..c36144c0 --- /dev/null +++ b/app/Livewire/Admin/Orders/Show.php @@ -0,0 +1,359 @@ + + */ + public array $fulfillmentLines = []; + + public string $trackingCompany = ''; + + public string $trackingNumber = ''; + + public string $trackingUrl = ''; + + /** + * Refund modal state keyed by order line id. + * + * @var array + */ + public array $refundLines = []; + + public string $refundAmount = ''; + + public string $refundReason = ''; + + public bool $refundRestock = false; + + public string $cancelReason = ''; + + public function mount(int $order): void + { + $this->orderId = $order; + + $this->authorize('view', $this->order); + + $this->prepareModalState(); + } + + #[Computed] + public function order(): Order + { + return Order::query() + ->with([ + 'customer', + 'lines.variant.product.media', + 'lines.fulfillmentLines', + 'payments', + 'refunds', + 'fulfillments.lines.orderLine', + ]) + ->findOrFail($this->orderId); + } + + /** + * Admin "Confirm Payment" for pending bank transfer orders. + */ + public function confirmPayment(): void + { + $this->authorize('update', $this->order); + + try { + app(OrderService::class)->confirmBankTransferPayment($this->order); + } catch (ValidationException $exception) { + $this->toast($this->firstError($exception), 'error'); + + return; + } + + $this->refreshOrder(); + $this->toast(__('Payment confirmed')); + } + + public function createFulfillment(): void + { + $this->authorize('createFulfillment', $this->order); + + $lines = collect($this->fulfillmentLines) + ->filter(fn (array $line): bool => $line['selected'] && (int) $line['quantity'] > 0) + ->map(fn (array $line): int => (int) $line['quantity']) + ->all(); + + try { + app(FulfillmentService::class)->create($this->order, $lines, [ + 'tracking_company' => $this->trackingCompany !== '' ? $this->trackingCompany : null, + 'tracking_number' => $this->trackingNumber !== '' ? $this->trackingNumber : null, + 'tracking_url' => $this->trackingUrl !== '' ? $this->trackingUrl : null, + ]); + } catch (FulfillmentGuardException $exception) { + $this->toast($exception->getMessage(), 'error'); + + return; + } catch (ValidationException $exception) { + $this->toast($this->firstError($exception), 'error'); + + return; + } + + Flux::modal('create-fulfillment')->close(); + + $this->reset('trackingCompany', 'trackingNumber', 'trackingUrl'); + $this->refreshOrder(); + $this->toast(__('Fulfillment created')); + } + + public function markAsShipped(int $fulfillmentId): void + { + $fulfillment = $this->order->fulfillments()->findOrFail($fulfillmentId); + + $this->authorize('update', $fulfillment); + + app(FulfillmentService::class)->markAsShipped($fulfillment); + + $this->refreshOrder(); + $this->toast(__('Fulfillment marked as shipped')); + } + + public function markAsDelivered(int $fulfillmentId): void + { + $fulfillment = $this->order->fulfillments()->findOrFail($fulfillmentId); + + $this->authorize('update', $fulfillment); + + app(FulfillmentService::class)->markAsDelivered($fulfillment); + + $this->refreshOrder(); + $this->toast(__('Fulfillment marked as delivered')); + } + + /** + * Refund a custom amount or the total of the selected lines. + */ + public function createRefund(): void + { + $this->authorize('createRefund', $this->order); + + $amount = $this->resolveRefundAmount(); + + if ($amount < 1) { + $this->toast(__('Select lines or enter a refund amount.'), 'error'); + + return; + } + + $payment = $this->order->payments->firstWhere('status', PaymentStatus::Captured); + + if ($payment === null) { + $this->toast(__('No captured payment is available to refund.'), 'error'); + + return; + } + + try { + app(RefundService::class)->create( + $this->order, + $payment, + $amount, + $this->refundReason !== '' ? $this->refundReason : null, + $this->refundRestock, + ); + } catch (ValidationException $exception) { + $this->toast($this->firstError($exception), 'error'); + + return; + } + + Flux::modal('create-refund')->close(); + + $this->reset('refundAmount', 'refundReason', 'refundRestock'); + $this->refreshOrder(); + $this->toast(__('Refund processed')); + } + + public function cancelOrder(): void + { + $this->authorize('cancel', $this->order); + + try { + app(OrderService::class)->cancel($this->order, $this->cancelReason !== '' ? $this->cancelReason : null); + } catch (ValidationException $exception) { + $this->toast($this->firstError($exception), 'error'); + + return; + } + + Flux::modal('cancel-order')->close(); + + $this->refreshOrder(); + $this->toast(__('Order cancelled')); + } + + /** + * Chronological order history: placed, payment, fulfillments, refunds, + * cancellation (spec 03 section 8 timeline). + * + * @return list + */ + #[Computed] + public function timeline(): array + { + $order = $this->order; + $events = []; + + if ($order->placed_at !== null) { + $events[] = ['label' => __('Order placed'), 'description' => null, 'timestamp' => $order->placed_at]; + } + + $capturedPayment = $order->payments->firstWhere('status', PaymentStatus::Captured) + ?? $order->payments->firstWhere('status', PaymentStatus::Refunded); + + if ($capturedPayment !== null) { + $events[] = [ + 'label' => __('Payment received'), + 'description' => __('Paid via :method', ['method' => str_replace('_', ' ', $capturedPayment->method->value)]), + 'timestamp' => $capturedPayment->updated_at ?? $capturedPayment->created_at, + ]; + } + + foreach ($order->fulfillments as $fulfillment) { + $events[] = [ + 'label' => __('Fulfillment created'), + 'description' => filled($fulfillment->tracking_number) + ? __('Tracking: :tracking', ['tracking' => trim(($fulfillment->tracking_company ?? '').' '.$fulfillment->tracking_number)]) + : null, + 'timestamp' => $fulfillment->created_at, + ]; + + if ($fulfillment->shipped_at !== null) { + $events[] = ['label' => __('Shipped'), 'description' => null, 'timestamp' => $fulfillment->shipped_at]; + } + + if ($fulfillment->delivered_at !== null) { + $events[] = ['label' => __('Delivered'), 'description' => null, 'timestamp' => $fulfillment->delivered_at]; + } + } + + foreach ($order->refunds as $refund) { + if ($refund->status !== RefundStatus::Processed) { + continue; + } + + $events[] = [ + 'label' => __('Refunded'), + 'description' => \App\Support\Storefront\PriceFormatter::format($refund->amount, $order->currency) + .(filled($refund->reason) ? ' - '.$refund->reason : ''), + 'timestamp' => $refund->created_at, + ]; + } + + if ($order->status === OrderStatus::Cancelled) { + $events[] = ['label' => __('Order cancelled'), 'description' => null, 'timestamp' => $order->updated_at]; + } + + usort($events, fn (array $a, array $b): int => $a['timestamp'] <=> $b['timestamp']); + + return $events; + } + + #[Computed] + public function canCreateFulfillment(): bool + { + return $this->order->financial_status->allowsFulfillment() + && $this->order->status !== OrderStatus::Cancelled + && $this->order->lines->contains(fn (OrderLine $line): bool => $line->unfulfilledQuantity() > 0); + } + + public function render(): View + { + return view('livewire.admin.orders.show') + ->title(__('Order :number', ['number' => $this->order->order_number])); + } + + protected function refreshOrder(): void + { + unset($this->order, $this->timeline, $this->canCreateFulfillment); + + $this->prepareModalState(); + } + + /** + * Seed the fulfillment and refund modal line selections from the order. + */ + protected function prepareModalState(): void + { + $this->fulfillmentLines = []; + $this->refundLines = []; + + foreach ($this->order->lines as $line) { + $unfulfilled = $line->unfulfilledQuantity(); + + if ($unfulfilled > 0) { + $this->fulfillmentLines[$line->getKey()] = [ + 'selected' => false, + 'quantity' => $unfulfilled, + 'max' => $unfulfilled, + 'title' => $line->title_snapshot, + ]; + } + + $this->refundLines[$line->getKey()] = [ + 'selected' => false, + 'quantity' => $line->quantity, + 'max' => $line->quantity, + 'title' => $line->title_snapshot, + 'unit' => $line->unit_price_amount, + ]; + } + } + + /** + * The custom refund amount in minor units, or the sum of the selected + * refund lines when no custom amount was entered. + */ + protected function resolveRefundAmount(): int + { + if (trim($this->refundAmount) !== '') { + return (int) round((float) str_replace(',', '.', $this->refundAmount) * 100); + } + + return (int) collect($this->refundLines) + ->filter(fn (array $line): bool => $line['selected'] && (int) $line['quantity'] > 0) + ->sum(fn (array $line): int => min((int) $line['quantity'], $line['max']) * $line['unit']); + } + + protected function firstError(ValidationException $exception): string + { + return collect($exception->errors())->flatten()->first() ?? __('Something went wrong. Please try again.'); + } +} diff --git a/app/Livewire/Admin/Pages/Form.php b/app/Livewire/Admin/Pages/Form.php new file mode 100644 index 00000000..255276e5 --- /dev/null +++ b/app/Livewire/Admin/Pages/Form.php @@ -0,0 +1,149 @@ +page = Page::query()->findOrFail($pageId); + + $this->authorize('view', $this->page); + $this->fillFromPage(); + + return; + } + + $this->authorize('create', Page::class); + } + + public function save(): void + { + if ($this->isEditing) { + $this->authorize('update', $this->page); + } else { + $this->authorize('create', Page::class); + } + + $this->validate(); + + $publishedAt = filled($this->publishedAt) ? Carbon::parse($this->publishedAt) : null; + + if ($this->status === PageStatus::Published->value && $publishedAt === null) { + $publishedAt = now(); + } + + $attributes = [ + 'title' => $this->title, + 'handle' => $this->resolvedHandle(), + 'body_html' => $this->bodyHtml !== '' ? $this->bodyHtml : null, + 'status' => $this->status, + 'published_at' => $publishedAt, + ]; + + if ($this->isEditing) { + $this->page->update($attributes); + $this->page->refresh(); + $this->fillFromPage(); + $this->toast(__('Page saved')); + + return; + } + + $this->page = Page::query()->create($attributes); + + $this->flashToast(__('Page saved')); + $this->redirect(route('admin.pages.edit', $this->page), navigate: true); + } + + public function deletePage(): void + { + $this->authorize('delete', $this->page); + + $this->page->delete(); + + $this->flashToast(__('Page deleted.')); + $this->redirect(route('admin.pages.index'), navigate: true); + } + + #[Computed] + public function isEditing(): bool + { + return $this->page !== null; + } + + public function render(): View + { + return view('livewire.admin.pages.form') + ->title($this->isEditing ? $this->page->title : __('Add page')); + } + + /** + * @return array> + */ + protected function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:255'], + 'handle' => [ + 'nullable', + 'string', + 'max:255', + Rule::unique('pages', 'handle') + ->where('store_id', app('current_store')->getKey()) + ->ignore($this->page?->getKey()), + ], + 'bodyHtml' => ['nullable', 'string', 'max:16777215'], + 'status' => ['required', 'in:draft,published,archived'], + 'publishedAt' => ['nullable', 'date'], + ]; + } + + protected function resolvedHandle(): string + { + $handle = trim($this->handle) !== '' ? trim($this->handle) : $this->title; + + return Str::slug($handle); + } + + protected function fillFromPage(): void + { + $this->title = $this->page->title; + $this->handle = $this->page->handle; + $this->bodyHtml = (string) $this->page->body_html; + $this->status = $this->page->status->value; + $this->publishedAt = $this->page->published_at?->format('Y-m-d\TH:i'); + } +} diff --git a/app/Livewire/Admin/Pages/Index.php b/app/Livewire/Admin/Pages/Index.php new file mode 100644 index 00000000..3e513f54 --- /dev/null +++ b/app/Livewire/Admin/Pages/Index.php @@ -0,0 +1,56 @@ +authorize('viewAny', Page::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + /** + * @return LengthAwarePaginator + */ + #[Computed] + public function pages(): LengthAwarePaginator + { + return Page::query() + ->when($this->search !== '', fn ($query) => $query->where('title', 'like', '%'.$this->search.'%')) + ->orderByDesc('updated_at') + ->paginate(15); + } + + #[Computed] + public function hasAnyPages(): bool + { + return Page::query()->exists(); + } + + public function render(): View + { + return view('livewire.admin.pages.index')->title(__('Pages')); + } +} diff --git a/app/Livewire/Admin/Products/Form.php b/app/Livewire/Admin/Products/Form.php new file mode 100644 index 00000000..13bf4c56 --- /dev/null +++ b/app/Livewire/Admin/Products/Form.php @@ -0,0 +1,611 @@ + */ + public array $collectionIds = []; + + /** + * Option rows: name plus comma-separated values (spec 03 section 4). + * + * @var list + */ + public array $options = []; + + /** + * Variant matrix rows keyed by their option value combination. + * + * @var list + */ + public array $variants = []; + + /** @var array */ + public array $newMedia = []; + + /** + * Uploads held until save in create mode (no product exists yet). + * + * @var array + */ + public array $pendingMedia = []; + + public function mount(?int $productId = null): void + { + if ($productId !== null) { + $this->product = Product::query() + ->with(['options.values', 'variants.optionValues', 'variants.inventoryItem', 'collections']) + ->findOrFail($productId); + + $this->authorize('view', $this->product); + $this->fillFromProduct(); + + return; + } + + $this->authorize('create', Product::class); + $this->generateVariants(); + } + + public function addOption(): void + { + $this->options[] = ['name' => '', 'values' => '']; + } + + public function removeOption(int $index): void + { + unset($this->options[$index]); + $this->options = array_values($this->options); + + $this->generateVariants(); + } + + /** + * Regenerate the variant matrix preview from the current options, + * preserving values already entered for combinations that still exist. + */ + public function generateVariants(): void + { + $existingByKey = collect($this->variants)->keyBy('key'); + $optionSets = array_column($this->parsedOptions(), 'values'); + + $combinations = $optionSets === [] ? [[]] : $this->cartesianProduct($optionSets); + + $this->variants = array_map(function (array $combination) use ($existingByKey): array { + $key = $this->combinationKey($combination); + + /** @var array{key: string, label: string, sku: string, barcode: string, price: string, compareAtPrice: string, weight: string, quantity: int|string, requiresShipping: bool} $row */ + $row = $existingByKey->get($key, [ + 'key' => $key, + 'label' => $combination === [] ? __('Default') : implode(' / ', $combination), + 'sku' => '', + 'barcode' => '', + 'price' => '0.00', + 'compareAtPrice' => '', + 'weight' => '', + 'quantity' => 0, + 'requiresShipping' => true, + ]); + + $row['label'] = $combination === [] ? __('Default') : implode(' / ', $combination); + + return $row; + }, $combinations); + } + + public function updated(string $property): void + { + if (str_starts_with($property, 'options.')) { + $this->generateVariants(); + } + } + + public function updatedNewMedia(): void + { + $this->validate([ + 'newMedia.*' => ['image', 'max:5120'], + ]); + + if ($this->isEditing) { + $mediaService = app(MediaService::class); + + foreach ($this->newMedia as $file) { + $mediaService->attach($this->product, $file); + } + + $this->newMedia = []; + $this->toast(__('Media uploaded.')); + + return; + } + + $this->pendingMedia = [...$this->pendingMedia, ...$this->newMedia]; + $this->newMedia = []; + } + + public function removePendingMedia(int $index): void + { + unset($this->pendingMedia[$index]); + $this->pendingMedia = array_values($this->pendingMedia); + } + + public function removeMedia(int $mediaId): void + { + $this->authorize('update', $this->product); + + $media = $this->product->media()->findOrFail($mediaId); + + app(MediaService::class)->delete($media); + + $this->toast(__('Media removed.')); + } + + public function updateMediaAlt(int $mediaId, ?string $altText): void + { + $this->authorize('update', $this->product); + + $media = $this->product->media()->findOrFail($mediaId); + + app(MediaService::class)->updateAltText($media, $altText !== null && trim($altText) !== '' ? trim($altText) : null); + + $this->toast(__('Alt text saved.')); + } + + /** + * Drag-to-reorder handler (wire:sort): move a media item to a position. + */ + public function reorderMedia(int $mediaId, int $position): void + { + $this->authorize('update', $this->product); + + $orderedIds = $this->product->media()->pluck('id')->all(); + $currentIndex = array_search($mediaId, $orderedIds, true); + + if ($currentIndex === false) { + return; + } + + array_splice($orderedIds, $currentIndex, 1); + array_splice($orderedIds, $position, 0, [$mediaId]); + + app(MediaService::class)->reorder($this->product, $orderedIds); + } + + public function save(): void + { + if ($this->isEditing) { + $this->authorize('update', $this->product); + } else { + $this->authorize('create', Product::class); + } + + $this->validate(); + $this->assertSkusAreDistinct(); + + $isCreating = ! $this->isEditing; + + DB::transaction(function (): void { + $this->isEditing ? $this->updateProduct() : $this->createProduct(); + }); + + if ($isCreating) { + $this->flashToast(__('Product saved')); + $this->redirect(route('admin.products.edit', $this->product), navigate: true); + + return; + } + + $this->product->refresh()->load(['options.values', 'variants.optionValues', 'variants.inventoryItem', 'collections']); + $this->fillFromProduct(); + $this->toast(__('Product saved')); + } + + /** + * Archive the product (spec 03 section 4 delete modal: products are + * archived to preserve order history). + */ + public function deleteProduct(): void + { + $this->authorize('delete', $this->product); + + try { + app(ProductService::class)->transitionStatus($this->product, ProductStatus::Archived); + } catch (InvalidProductTransitionException $exception) { + $this->toast($exception->getMessage(), 'error'); + + return; + } + + $this->flashToast(__('Product archived')); + $this->redirect(route('admin.products.index'), navigate: true); + } + + #[Computed] + public function isEditing(): bool + { + return $this->product !== null; + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + #[Computed] + public function availableCollections(): \Illuminate\Database\Eloquent\Collection + { + return Collection::query()->orderBy('title')->get(); + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + #[Computed] + public function mediaItems(): \Illuminate\Database\Eloquent\Collection + { + if (! $this->isEditing) { + return new \Illuminate\Database\Eloquent\Collection; + } + + return $this->product->media()->get(); + } + + public function render(): View + { + return view('livewire.admin.products.form') + ->title($this->isEditing ? $this->product->title : __('Add product')); + } + + /** + * @return array> + */ + protected function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:255'], + 'descriptionHtml' => ['nullable', 'string', 'max:65535'], + 'status' => ['required', 'in:draft,active,archived'], + 'vendor' => ['nullable', 'string', 'max:255'], + 'productType' => ['nullable', 'string', 'max:255'], + 'tags' => ['nullable', 'string'], + 'handle' => ['nullable', 'string', 'max:255'], + 'publishedAt' => ['nullable', 'date'], + 'collectionIds' => ['array'], + 'collectionIds.*' => ['integer'], + 'options.*.name' => ['nullable', 'string', 'max:255'], + 'options.*.values' => ['nullable', 'string'], + 'variants.*.sku' => ['nullable', 'string', 'max:255'], + 'variants.*.barcode' => ['nullable', 'string', 'max:255'], + 'variants.*.price' => ['required', 'numeric', 'min:0'], + 'variants.*.compareAtPrice' => ['nullable', 'numeric', 'min:0'], + 'variants.*.weight' => ['nullable', 'integer', 'min:0'], + 'variants.*.quantity' => ['required', 'integer', 'min:0'], + ]; + } + + protected function createProduct(): void + { + $store = app('current_store'); + + $this->product = app(ProductService::class)->create($store, array_filter([ + 'title' => $this->title, + 'handle' => trim($this->handle) !== '' ? trim($this->handle) : null, + 'status' => $this->status, + 'description_html' => $this->descriptionHtml !== '' ? $this->descriptionHtml : null, + 'vendor' => $this->vendor !== '' ? $this->vendor : null, + 'product_type' => $this->productType !== '' ? $this->productType : null, + 'tags' => $this->parsedTags(), + 'options' => $this->parsedOptions(), + ], fn (mixed $value): bool => $value !== null)); + + $this->applyVariantRows(); + $this->syncCollections(); + $this->applyPublishedAt(); + $this->attachPendingMedia(); + } + + protected function updateProduct(): void + { + $productService = app(ProductService::class); + + $productService->update($this->product, [ + 'title' => $this->title, + 'handle' => trim($this->handle) !== '' ? trim($this->handle) : $this->title, + 'description_html' => $this->descriptionHtml !== '' ? $this->descriptionHtml : null, + 'vendor' => $this->vendor !== '' ? $this->vendor : null, + 'product_type' => $this->productType !== '' ? $this->productType : null, + 'tags' => $this->parsedTags(), + ]); + + $newStatus = ProductStatus::from($this->status); + + if ($this->product->status !== $newStatus) { + $productService->transitionStatus($this->product, $newStatus); + } + + $this->syncOptions(); + + app(VariantMatrixService::class)->rebuildMatrix($this->product); + + $this->applyVariantRows(); + $this->syncCollections(); + $this->applyPublishedAt(); + } + + /** + * Diff-sync the product's options and values against the form state. + * Values are matched case-insensitively so untouched combinations keep + * their variants (and inventory) through the matrix rebuild. + */ + protected function syncOptions(): void + { + $existingOptions = $this->product->options()->with('values')->get()->values(); + $formOptions = $this->parsedOptions(); + + foreach ($formOptions as $index => $formOption) { + $option = $existingOptions->get($index); + + if ($option === null) { + $option = $this->product->options()->create([ + 'name' => $formOption['name'], + 'position' => $index, + ]); + } else { + $option->update(['name' => $formOption['name'], 'position' => $index]); + } + + $existingValues = $option->values()->get()->keyBy(fn ($value) => mb_strtolower($value->value)); + + foreach ($formOption['values'] as $valueIndex => $value) { + $existing = $existingValues->pull(mb_strtolower($value)); + + if ($existing !== null) { + $existing->update(['value' => $value, 'position' => $valueIndex]); + } else { + $option->values()->create(['value' => $value, 'position' => $valueIndex]); + } + } + + foreach ($existingValues as $orphanValue) { + $orphanValue->delete(); + } + } + + foreach ($existingOptions->slice(count($formOptions)) as $orphanOption) { + $orphanOption->delete(); + } + } + + /** + * Apply the per-variant form rows (price, SKU, barcode, weight, shipping, + * inventory quantity) to the persisted variants, matched by their option + * value combination. + */ + protected function applyVariantRows(): void + { + $this->product->refresh()->load(['variants.optionValues', 'variants.inventoryItem']); + + $rowsByKey = collect($this->variants)->keyBy('key'); + + foreach ($this->product->variants as $variant) { + $key = $this->combinationKey($variant->optionValues->pluck('value')->all()); + $row = $rowsByKey->get($key); + + if ($row === null) { + continue; + } + + $variant->update([ + 'sku' => trim((string) $row['sku']) !== '' ? trim((string) $row['sku']) : null, + 'barcode' => trim((string) $row['barcode']) !== '' ? trim((string) $row['barcode']) : null, + 'price_amount' => $this->toMinorUnits((string) $row['price']), + 'compare_at_amount' => trim((string) $row['compareAtPrice']) !== '' ? $this->toMinorUnits((string) $row['compareAtPrice']) : null, + 'weight_g' => trim((string) $row['weight']) !== '' ? (int) $row['weight'] : null, + 'requires_shipping' => (bool) $row['requiresShipping'], + ]); + + $variant->inventoryItem?->update(['quantity_on_hand' => (int) $row['quantity']]); + } + } + + protected function syncCollections(): void + { + $validIds = Collection::query()->whereIn('id', $this->collectionIds)->pluck('id')->all(); + + $this->product->collections()->sync($validIds); + } + + protected function applyPublishedAt(): void + { + if (filled($this->publishedAt)) { + $this->product->forceFill(['published_at' => Carbon::parse($this->publishedAt)])->save(); + } + } + + protected function attachPendingMedia(): void + { + $mediaService = app(MediaService::class); + + foreach ($this->pendingMedia as $file) { + $mediaService->attach($this->product, $file); + } + + $this->pendingMedia = []; + } + + protected function fillFromProduct(): void + { + $this->title = $this->product->title; + $this->descriptionHtml = (string) $this->product->description_html; + $this->status = $this->product->status->value; + $this->vendor = (string) $this->product->vendor; + $this->productType = (string) $this->product->product_type; + $this->tags = implode(', ', $this->product->tags ?? []); + $this->handle = $this->product->handle; + $this->publishedAt = $this->product->published_at?->format('Y-m-d\TH:i'); + $this->collectionIds = $this->product->collections->pluck('id')->all(); + + $this->options = $this->product->options + ->map(fn ($option): array => [ + 'name' => $option->name, + 'values' => $option->values->pluck('value')->implode(', '), + ]) + ->all(); + + $this->variants = $this->product->variants + ->filter(fn (ProductVariant $variant): bool => $variant->status !== \App\Enums\VariantStatus::Archived) + ->map(fn (ProductVariant $variant): array => [ + 'key' => $this->combinationKey($variant->optionValues->pluck('value')->all()), + 'label' => $variant->optionValues->isEmpty() ? __('Default') : $variant->optionValues->pluck('value')->implode(' / '), + 'sku' => (string) $variant->sku, + 'barcode' => (string) $variant->barcode, + 'price' => number_format($variant->price_amount / 100, 2, '.', ''), + 'compareAtPrice' => $variant->compare_at_amount !== null ? number_format($variant->compare_at_amount / 100, 2, '.', '') : '', + 'weight' => $variant->weight_g !== null ? (string) $variant->weight_g : '', + 'quantity' => $variant->inventoryItem?->quantity_on_hand ?? 0, + 'requiresShipping' => $variant->requires_shipping, + ]) + ->values() + ->all(); + } + + /** + * Options parsed into name + value lists, skipping incomplete rows. + * + * @return list}> + */ + protected function parsedOptions(): array + { + $parsed = []; + + foreach ($this->options as $option) { + $name = trim($option['name'] ?? ''); + $values = collect(explode(',', $option['values'] ?? '')) + ->map(fn (string $value): string => trim($value)) + ->filter(fn (string $value): bool => $value !== '') + ->unique(fn (string $value): string => mb_strtolower($value)) + ->values() + ->all(); + + if ($name === '' || $values === []) { + continue; + } + + $parsed[] = ['name' => $name, 'values' => $values]; + } + + return $parsed; + } + + /** + * @return list + */ + protected function parsedTags(): array + { + return collect(explode(',', $this->tags)) + ->map(fn (string $tag): string => trim($tag)) + ->filter(fn (string $tag): bool => $tag !== '') + ->values() + ->all(); + } + + /** + * Order-independent, case-insensitive key for an option value combination. + * + * @param list $values + */ + protected function combinationKey(array $values): string + { + $normalized = array_map(fn (string $value): string => mb_strtolower($value), $values); + sort($normalized); + + return implode('|', $normalized); + } + + /** + * @param list> $sets + * @return list> + */ + protected function cartesianProduct(array $sets): array + { + $combinations = [[]]; + + foreach ($sets as $set) { + $next = []; + + foreach ($combinations as $combination) { + foreach ($set as $value) { + $next[] = [...$combination, $value]; + } + } + + $combinations = $next; + } + + return $combinations; + } + + /** + * Reject duplicate SKUs within the submitted variant rows. + */ + protected function assertSkusAreDistinct(): void + { + $skus = collect($this->variants) + ->map(fn (array $row): string => mb_strtolower(trim((string) $row['sku']))) + ->filter(fn (string $sku): bool => $sku !== ''); + + if ($skus->count() !== $skus->unique()->count()) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'variants' => __('Each variant SKU must be unique.'), + ]); + } + } + + protected function toMinorUnits(string $value): int + { + return (int) round((float) str_replace(',', '.', $value) * 100); + } +} diff --git a/app/Livewire/Admin/Products/Index.php b/app/Livewire/Admin/Products/Index.php new file mode 100644 index 00000000..a1658c9f --- /dev/null +++ b/app/Livewire/Admin/Products/Index.php @@ -0,0 +1,230 @@ + */ + public array $selectedIds = []; + + public bool $selectAll = false; + + public string $sortField = 'updated_at'; + + public string $sortDirection = 'desc'; + + public function mount(): void + { + $this->authorize('viewAny', Product::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + $this->clearSelection(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + $this->clearSelection(); + } + + public function updatedTypeFilter(): void + { + $this->resetPage(); + $this->clearSelection(); + } + + public function setStatusFilter(string $status): void + { + $this->statusFilter = $status; + $this->updatedStatusFilter(); + } + + public function sortBy(string $field): void + { + if (! in_array($field, ['title', 'inventory_quantity', 'updated_at'], true)) { + return; + } + + if ($this->sortField === $field) { + $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortField = $field; + $this->sortDirection = 'asc'; + } + + $this->resetPage(); + } + + public function updatedSelectAll(bool $value): void + { + $this->selectedIds = $value + ? $this->products->getCollection()->pluck('id')->all() + : []; + } + + public function bulkSetActive(): void + { + $this->applyBulkTransition(ProductStatus::Active, __(':count product(s) set to active.')); + } + + public function bulkArchive(): void + { + $this->applyBulkTransition(ProductStatus::Archived, __(':count product(s) archived.')); + } + + /** + * Bulk delete: drafts without order references are hard-deleted, anything + * else falls back to archiving (spec 03 section 3 delete modal copy). + */ + public function bulkDelete(): void + { + $products = $this->selectedProducts(); + $service = app(ProductService::class); + + foreach ($products as $product) { + $this->authorize('delete', $product); + } + + foreach ($products as $product) { + try { + $service->delete($product); + } catch (ProductDeletionException) { + try { + $service->transitionStatus($product, ProductStatus::Archived); + } catch (InvalidProductTransitionException) { + // Already archived or otherwise untransitionable; skip. + } + } + } + + Flux::modal('confirm-bulk-delete')->close(); + + $this->toast(__(':count product(s) deleted or archived.', ['count' => $products->count()])); + $this->clearSelection(); + $this->resetPage(); + } + + /** + * @return LengthAwarePaginator + */ + #[Computed] + public function products(): LengthAwarePaginator + { + return Product::query() + ->with(['media' => fn ($query) => $query->limit(1)]) + ->withCount('variants') + ->addSelect([ + 'inventory_quantity' => InventoryItem::query() + ->withoutGlobalScopes() + ->join('product_variants', 'product_variants.id', '=', 'inventory_items.variant_id') + ->whereColumn('product_variants.product_id', 'products.id') + ->selectRaw('coalesce(sum(inventory_items.quantity_on_hand), 0)'), + ]) + ->when($this->search !== '', fn ($query) => $query->where('title', 'like', '%'.$this->search.'%')) + ->when($this->statusFilter !== 'all', fn ($query) => $query->where('status', $this->statusFilter)) + ->when($this->typeFilter !== 'all', fn ($query) => $query->where('product_type', $this->typeFilter)) + ->orderBy($this->sortField, $this->sortDirection) + ->paginate(15); + } + + /** + * Distinct product types for the type filter dropdown. + * + * @return list + */ + #[Computed] + public function productTypes(): array + { + return Product::query() + ->whereNotNull('product_type') + ->where('product_type', '!=', '') + ->distinct() + ->orderBy('product_type') + ->pluck('product_type') + ->all(); + } + + #[Computed] + public function hasAnyProducts(): bool + { + return Product::query()->exists(); + } + + public function render(): View + { + return view('livewire.admin.products.index')->title(__('Products')); + } + + protected function applyBulkTransition(ProductStatus $status, string $message): void + { + $products = $this->selectedProducts(); + $service = app(ProductService::class); + $count = 0; + + foreach ($products as $product) { + $this->authorize($status === ProductStatus::Archived ? 'archive' : 'update', $product); + } + + foreach ($products as $product) { + try { + $service->transitionStatus($product, $status); + $count++; + } catch (InvalidProductTransitionException $exception) { + $this->toast($exception->getMessage(), 'error'); + } + } + + if ($count > 0) { + $this->toast(str_replace(':count', (string) $count, $message)); + } + + $this->clearSelection(); + } + + /** + * @return Collection + */ + protected function selectedProducts(): Collection + { + return Product::query()->whereIn('id', $this->selectedIds)->get(); + } + + protected function clearSelection(): void + { + $this->selectedIds = []; + $this->selectAll = false; + } +} diff --git a/app/Livewire/Admin/Search/Settings.php b/app/Livewire/Admin/Search/Settings.php new file mode 100644 index 00000000..471a3999 --- /dev/null +++ b/app/Livewire/Admin/Search/Settings.php @@ -0,0 +1,136 @@ + Each group is a comma-separated string of equivalent terms. */ + public array $synonymGroups = []; + + public string $stopWords = ''; + + public ?string $lastIndexedAt = null; + + public function mount(SearchService $search): void + { + $store = $this->store(); + + $this->authorize('viewSettings', $store); + + $settings = SearchSettings::query()->find($store->getKey()); + + $this->synonymGroups = array_map( + fn (array $group): string => implode(', ', $group), + $settings?->synonymGroups() ?? [], + ); + + $this->stopWords = implode(', ', $settings?->stopWords() ?? []); + $this->lastIndexedAt = $search->lastReindexedAt($store); + } + + public function addSynonymGroup(): void + { + $this->synonymGroups[] = ''; + } + + public function removeSynonymGroup(int $index): void + { + unset($this->synonymGroups[$index]); + + $this->synonymGroups = array_values($this->synonymGroups); + } + + public function save(): void + { + $store = $this->store(); + + $this->authorize('updateSettings', $store); + + $this->validate([ + 'synonymGroups' => ['array'], + 'synonymGroups.*' => ['nullable', 'string', 'max:500'], + 'stopWords' => ['nullable', 'string', 'max:2000'], + ]); + + SearchSettings::query()->updateOrCreate( + ['store_id' => $store->getKey()], + [ + 'synonyms_json' => $this->parsedSynonymGroups(), + 'stop_words_json' => $this->parsedStopWords(), + ], + ); + + $this->toast(__('Settings saved')); + } + + public function triggerReindex(SearchService $search): void + { + $store = $this->store(); + + $this->authorize('updateSettings', $store); + + $search->reindexStore($store); + + $this->lastIndexedAt = $search->lastReindexedAt($store); + + $this->toast(__('Search index rebuilt')); + } + + public function render(): View + { + return view('livewire.admin.search.settings')->title(__('Search settings')); + } + + /** + * @return list> + */ + protected function parsedSynonymGroups(): array + { + return array_values(array_filter(array_map( + fn (string $group): array => $this->splitCommaList($group), + $this->synonymGroups, + ), fn (array $group): bool => count($group) > 1)); + } + + /** + * @return list + */ + protected function parsedStopWords(): array + { + return $this->splitCommaList($this->stopWords); + } + + /** + * @return list + */ + protected function splitCommaList(string $value): array + { + $items = array_map( + fn (string $item): string => mb_strtolower(trim($item)), + explode(',', $value), + ); + + return array_values(array_unique(array_filter($items, fn (string $item): bool => $item !== ''))); + } + + protected function store(): Store + { + return app('current_store'); + } +} diff --git a/app/Livewire/Admin/Settings/Checkout.php b/app/Livewire/Admin/Settings/Checkout.php new file mode 100644 index 00000000..91762a87 --- /dev/null +++ b/app/Livewire/Admin/Settings/Checkout.php @@ -0,0 +1,67 @@ +store(); + + $this->authorize('viewSettings', $store); + + $settings = $store->settings?->settings_json ?? []; + + $this->guestCheckoutEnabled = (bool) ($settings['guest_checkout_enabled'] ?? true); + $this->bankTransferCancelDays = (int) ($settings['bank_transfer_cancel_days'] ?? 7); + } + + public function save(): void + { + $store = $this->store(); + + $this->authorize('updateSettings', $store); + + $this->validate([ + 'bankTransferCancelDays' => ['required', 'integer', 'min:1', 'max:60'], + ]); + + $settings = StoreSettings::query()->firstOrNew(['store_id' => $store->getKey()]); + + $settings->settings_json = array_merge($settings->settings_json ?? [], [ + 'guest_checkout_enabled' => $this->guestCheckoutEnabled, + 'bank_transfer_cancel_days' => $this->bankTransferCancelDays, + ]); + + $settings->save(); + + $this->toast(__('Settings saved')); + } + + public function render(): View + { + return view('livewire.admin.settings.checkout'); + } + + protected function store(): Store + { + return app('current_store'); + } +} diff --git a/app/Livewire/Admin/Settings/Domains.php b/app/Livewire/Admin/Settings/Domains.php new file mode 100644 index 00000000..be4f583f --- /dev/null +++ b/app/Livewire/Admin/Settings/Domains.php @@ -0,0 +1,119 @@ +authorize('viewSettings', $this->store()); + } + + public function addDomain(): void + { + $this->authorize('updateSettings', $this->store()); + + $this->validate([ + 'newHostname' => [ + 'required', + 'string', + 'max:255', + 'regex:/^(?!-)[a-z0-9-]+(\.[a-z0-9-]+)+$/i', + Rule::unique('store_domains', 'hostname'), + ], + 'newType' => ['required', 'in:storefront,admin,api'], + ]); + + $this->store()->domains()->create([ + 'hostname' => strtolower(trim($this->newHostname)), + 'type' => $this->newType, + 'is_primary' => ! $this->store()->domains()->where('type', $this->newType)->exists(), + 'tls_mode' => 'managed', + ]); + + Flux::modal('add-domain')->close(); + + $this->reset('newHostname', 'newType'); + $this->toast(__('Domain added.')); + } + + public function removeDomain(int $domainId): void + { + $this->authorize('updateSettings', $this->store()); + + $domain = $this->store()->domains()->findOrFail($domainId); + + if ($this->store()->domains()->count() <= 1) { + $this->toast(__('A store must keep at least one domain.'), 'error'); + + return; + } + + $domain->delete(); + + if ($domain->is_primary) { + $this->store()->domains() + ->where('type', $domain->type) + ->orderBy('id') + ->first() + ?->update(['is_primary' => true]); + } + + $this->toast(__('Domain removed.')); + } + + public function setPrimary(int $domainId): void + { + $this->authorize('updateSettings', $this->store()); + + $domain = $this->store()->domains()->findOrFail($domainId); + + $this->store()->domains() + ->where('type', $domain->type) + ->whereKeyNot($domain->getKey()) + ->update(['is_primary' => false]); + + $domain->update(['is_primary' => true]); + + $this->toast(__('Primary domain updated.')); + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + #[Computed] + public function domains(): \Illuminate\Database\Eloquent\Collection + { + return $this->store()->domains()->orderBy('type')->orderByDesc('is_primary')->get(); + } + + public function render(): View + { + return view('livewire.admin.settings.domains'); + } + + protected function store(): Store + { + return app('current_store'); + } +} diff --git a/app/Livewire/Admin/Settings/General.php b/app/Livewire/Admin/Settings/General.php new file mode 100644 index 00000000..891c61cc --- /dev/null +++ b/app/Livewire/Admin/Settings/General.php @@ -0,0 +1,108 @@ +store(); + + $this->authorize('viewSettings', $store); + + $settings = $store->settings?->settings_json ?? []; + + $this->storeName = $store->name; + $this->storeHandle = $store->handle; + $this->defaultCurrency = $store->default_currency ?? 'EUR'; + $this->defaultLocale = $store->default_locale ?? 'en'; + $this->timezone = $store->timezone ?? 'UTC'; + $this->contactEmail = (string) ($settings['contact_email'] ?? ''); + $this->orderNumberPrefix = (string) ($settings['order_number_prefix'] ?? '#'); + } + + public function save(): void + { + $store = $this->store(); + + $this->authorize('updateSettings', $store); + + $this->validate([ + 'storeName' => ['required', 'string', 'max:255'], + 'defaultCurrency' => ['required', 'string', 'size:3'], + 'defaultLocale' => ['required', 'string', 'max:10'], + 'timezone' => ['required', 'timezone'], + 'contactEmail' => ['nullable', 'email', 'max:255'], + 'orderNumberPrefix' => ['nullable', 'string', 'max:10'], + ]); + + $store->update([ + 'name' => $this->storeName, + 'default_currency' => strtoupper($this->defaultCurrency), + 'default_locale' => $this->defaultLocale, + 'timezone' => $this->timezone, + ]); + + $this->mergeSettings($store, [ + 'store_name' => $this->storeName, + 'contact_email' => $this->contactEmail !== '' ? $this->contactEmail : null, + 'order_number_prefix' => $this->orderNumberPrefix !== '' ? $this->orderNumberPrefix : '#', + ]); + + $this->toast(__('Settings saved')); + } + + public function render(): View + { + return view('livewire.admin.settings.general', [ + 'timezones' => \DateTimeZone::listIdentifiers(), + ]); + } + + protected function store(): Store + { + return app('current_store'); + } + + /** + * @param array $values + */ + protected function mergeSettings(Store $store, array $values): void + { + $settings = StoreSettings::query()->firstOrNew(['store_id' => $store->getKey()]); + + $settings->settings_json = array_filter( + array_merge($settings->settings_json ?? [], $values), + fn (mixed $value): bool => $value !== null, + ); + + $settings->save(); + } +} diff --git a/app/Livewire/Admin/Settings/Index.php b/app/Livewire/Admin/Settings/Index.php new file mode 100644 index 00000000..f2bcc69a --- /dev/null +++ b/app/Livewire/Admin/Settings/Index.php @@ -0,0 +1,37 @@ +authorize('viewSettings', app('current_store')); + + if (! in_array($this->tab, ['general', 'domains', 'checkout', 'notifications'], true)) { + $this->tab = 'general'; + } + } + + public function render(): View + { + return view('livewire.admin.settings.index')->title(__('Settings')); + } +} diff --git a/app/Livewire/Admin/Settings/Notifications.php b/app/Livewire/Admin/Settings/Notifications.php new file mode 100644 index 00000000..b73de87a --- /dev/null +++ b/app/Livewire/Admin/Settings/Notifications.php @@ -0,0 +1,75 @@ +store(); + + $this->authorize('viewSettings', $store); + + $settings = $store->settings?->settings_json ?? []; + + $this->notificationEmail = (string) ($settings['notification_email'] ?? $settings['contact_email'] ?? ''); + $this->sendOrderConfirmation = (bool) ($settings['send_order_confirmation'] ?? true); + $this->sendShippingConfirmation = (bool) ($settings['send_shipping_confirmation'] ?? true); + $this->notifyOnNewOrder = (bool) ($settings['notify_on_new_order'] ?? true); + } + + public function save(): void + { + $store = $this->store(); + + $this->authorize('updateSettings', $store); + + $this->validate([ + 'notificationEmail' => ['nullable', 'email', 'max:255'], + ]); + + $settings = StoreSettings::query()->firstOrNew(['store_id' => $store->getKey()]); + + $settings->settings_json = array_merge($settings->settings_json ?? [], [ + 'notification_email' => $this->notificationEmail !== '' ? $this->notificationEmail : null, + 'send_order_confirmation' => $this->sendOrderConfirmation, + 'send_shipping_confirmation' => $this->sendShippingConfirmation, + 'notify_on_new_order' => $this->notifyOnNewOrder, + ]); + + $settings->save(); + + $this->toast(__('Settings saved')); + } + + public function render(): View + { + return view('livewire.admin.settings.notifications'); + } + + protected function store(): Store + { + return app('current_store'); + } +} diff --git a/app/Livewire/Admin/Settings/Shipping.php b/app/Livewire/Admin/Settings/Shipping.php new file mode 100644 index 00000000..2dff0936 --- /dev/null +++ b/app/Livewire/Admin/Settings/Shipping.php @@ -0,0 +1,437 @@ + 'Austria', + 'AU' => 'Australia', + 'BE' => 'Belgium', + 'CA' => 'Canada', + 'CH' => 'Switzerland', + 'CZ' => 'Czechia', + 'DE' => 'Germany', + 'DK' => 'Denmark', + 'ES' => 'Spain', + 'FI' => 'Finland', + 'FR' => 'France', + 'GB' => 'United Kingdom', + 'IE' => 'Ireland', + 'IT' => 'Italy', + 'JP' => 'Japan', + 'LU' => 'Luxembourg', + 'NL' => 'Netherlands', + 'NO' => 'Norway', + 'PL' => 'Poland', + 'PT' => 'Portugal', + 'SE' => 'Sweden', + 'US' => 'United States', + ]; + + public ?int $editingZoneId = null; + + public string $zoneName = ''; + + /** @var list */ + public array $zoneCountries = []; + + public string $zoneRegions = ''; + + public ?int $editingRateId = null; + + public ?int $rateZoneId = null; + + public string $rateName = ''; + + public string $rateType = 'flat'; + + public string $rateFlatAmount = ''; + + /** + * Range rows for weight and price based rates. + * + * @var list + */ + public array $rateRanges = []; + + public bool $rateActive = true; + + /** @var array{country_code: string, province_code: string, city: string, postal_code: string} */ + public array $testAddress = [ + 'country_code' => 'DE', + 'province_code' => '', + 'city' => '', + 'postal_code' => '', + ]; + + /** @var array{zone: string, rates: list}|false|null */ + public array|false|null $testResult = null; + + public function mount(): void + { + $this->authorize('viewSettings', $this->store()); + } + + /* + |-------------------------------------------------------------------------- + | Zones + |-------------------------------------------------------------------------- + */ + + public function openZoneModal(?int $zoneId = null): void + { + $this->authorize('updateSettings', $this->store()); + $this->resetErrorBag(); + + $this->editingZoneId = $zoneId; + + if ($zoneId !== null) { + $zone = ShippingZone::query()->findOrFail($zoneId); + + $this->zoneName = $zone->name; + $this->zoneCountries = $zone->countries_json ?? []; + $this->zoneRegions = implode(', ', $zone->regions_json ?? []); + } else { + $this->zoneName = ''; + $this->zoneCountries = []; + $this->zoneRegions = ''; + } + + Flux::modal('zone-form')->show(); + } + + public function saveZone(): void + { + $this->authorize('updateSettings', $this->store()); + + $this->validate([ + 'zoneName' => ['required', 'string', 'max:255'], + 'zoneCountries' => ['required', 'array', 'min:1'], + 'zoneCountries.*' => ['string', 'size:2'], + ]); + + $attributes = [ + 'name' => $this->zoneName, + 'countries_json' => array_values(array_map('strtoupper', $this->zoneCountries)), + 'regions_json' => $this->parsedRegions(), + ]; + + if ($this->editingZoneId !== null) { + ShippingZone::query()->findOrFail($this->editingZoneId)->update($attributes); + } else { + ShippingZone::query()->create($attributes); + } + + Flux::modal('zone-form')->close(); + + $this->editingZoneId = null; + $this->toast(__('Settings saved')); + } + + public function deleteZone(int $zoneId): void + { + $this->authorize('updateSettings', $this->store()); + + $zone = ShippingZone::query()->findOrFail($zoneId); + + $zone->rates()->delete(); + $zone->delete(); + + $this->toast(__('Shipping zone deleted.')); + } + + /* + |-------------------------------------------------------------------------- + | Rates + |-------------------------------------------------------------------------- + */ + + public function openRateModal(int $zoneId, ?int $rateId = null): void + { + $this->authorize('updateSettings', $this->store()); + $this->resetErrorBag(); + + ShippingZone::query()->findOrFail($zoneId); + + $this->rateZoneId = $zoneId; + $this->editingRateId = $rateId; + + if ($rateId !== null) { + $rate = ShippingRate::query()->whereRelation('zone', 'store_id', $this->store()->getKey())->findOrFail($rateId); + + $this->rateName = $rate->name; + $this->rateType = $rate->type->value; + $this->rateActive = $rate->is_active; + $this->rateFlatAmount = $rate->type === ShippingRateType::Flat + ? number_format(((int) ($rate->config_json['amount'] ?? 0)) / 100, 2, '.', '') + : ''; + $this->rateRanges = $this->rangesFromConfig($rate); + } else { + $this->rateName = ''; + $this->rateType = 'flat'; + $this->rateFlatAmount = ''; + $this->rateRanges = [['min' => '', 'max' => '', 'amount' => '']]; + $this->rateActive = true; + } + + Flux::modal('rate-form')->show(); + } + + public function addRateRange(): void + { + $this->rateRanges[] = ['min' => '', 'max' => '', 'amount' => '']; + } + + public function removeRateRange(int $index): void + { + unset($this->rateRanges[$index]); + $this->rateRanges = array_values($this->rateRanges); + } + + public function saveRate(): void + { + $this->authorize('updateSettings', $this->store()); + + $this->validate([ + 'rateName' => ['required', 'string', 'max:255'], + 'rateType' => ['required', 'in:flat,weight,price'], + 'rateFlatAmount' => [$this->rateType === 'flat' ? 'required' : 'nullable', 'numeric', 'min:0'], + 'rateRanges' => [$this->rateType === 'flat' ? 'nullable' : 'required', 'array', ...($this->rateType === 'flat' ? [] : ['min:1'])], + 'rateRanges.*.min' => ['nullable', 'numeric', 'min:0'], + 'rateRanges.*.max' => ['nullable', 'numeric', 'min:0'], + 'rateRanges.*.amount' => [$this->rateType === 'flat' ? 'nullable' : 'required', 'numeric', 'min:0'], + ]); + + $zone = ShippingZone::query()->findOrFail($this->rateZoneId); + + $attributes = [ + 'name' => $this->rateName, + 'type' => $this->rateType, + 'config_json' => $this->buildRateConfig(), + 'is_active' => $this->rateActive, + ]; + + if ($this->editingRateId !== null) { + $rate = ShippingRate::query()->whereRelation('zone', 'store_id', $this->store()->getKey())->findOrFail($this->editingRateId); + $rate->update($attributes + ['zone_id' => $zone->getKey()]); + } else { + $zone->rates()->create($attributes); + } + + Flux::modal('rate-form')->close(); + + $this->editingRateId = null; + $this->toast(__('Shipping rate saved')); + } + + public function deleteRate(int $rateId): void + { + $this->authorize('updateSettings', $this->store()); + + ShippingRate::query() + ->whereRelation('zone', 'store_id', $this->store()->getKey()) + ->findOrFail($rateId) + ->delete(); + + $this->toast(__('Shipping rate deleted.')); + } + + public function toggleRateActive(int $rateId): void + { + $this->authorize('updateSettings', $this->store()); + + $rate = ShippingRate::query() + ->whereRelation('zone', 'store_id', $this->store()->getKey()) + ->findOrFail($rateId); + + $rate->update(['is_active' => ! $rate->is_active]); + + $this->toast(__('Settings saved')); + } + + /* + |-------------------------------------------------------------------------- + | Test address tool + |-------------------------------------------------------------------------- + */ + + public function testShippingAddress(): void + { + $calculator = app(ShippingCalculator::class); + + $rates = $calculator->getAvailableRates($this->store(), $this->testAddress); + + if ($rates->isEmpty()) { + $this->testResult = false; + + return; + } + + $firstRate = $rates->first(); + $zoneName = ShippingZone::query()->withoutGlobalScopes()->find($firstRate->zone_id)?->name ?? ''; + + $this->testResult = [ + 'zone' => $zoneName, + 'rates' => $rates + ->map(fn (ShippingRate $rate): string => $rate->name.' - '.$this->describeRateConfig($rate)) + ->all(), + ]; + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + #[Computed] + public function zones(): \Illuminate\Database\Eloquent\Collection + { + return ShippingZone::query()->with('rates')->orderBy('name')->get(); + } + + /** + * Human readable summary of a rate's configuration for table display. + */ + public function describeRateConfig(ShippingRate $rate): string + { + $currency = $this->store()->default_currency ?? 'EUR'; + + if ($rate->type === ShippingRateType::Flat) { + return PriceFormatter::format((int) ($rate->config_json['amount'] ?? 0), $currency); + } + + $ranges = $rate->config_json['ranges'] ?? []; + $isWeight = $rate->type === ShippingRateType::Weight; + + $parts = array_map(function (array $range) use ($currency, $isWeight): string { + $min = (int) ($range[$isWeight ? 'min_g' : 'min_amount'] ?? 0); + $max = $range[$isWeight ? 'max_g' : 'max_amount'] ?? null; + $amount = PriceFormatter::format((int) ($range['amount'] ?? 0), $currency); + + $bounds = $isWeight + ? $min.'g - '.($max !== null ? $max.'g' : '...') + : PriceFormatter::format($min, $currency).' - '.($max !== null ? PriceFormatter::format((int) $max, $currency) : '...'); + + return $bounds.': '.$amount; + }, $ranges); + + return implode(' / ', $parts); + } + + public function render(): View + { + return view('livewire.admin.settings.shipping')->title(__('Shipping')); + } + + protected function store(): Store + { + return app('current_store'); + } + + /** + * @return list + */ + protected function parsedRegions(): array + { + return collect(explode(',', $this->zoneRegions)) + ->map(fn (string $region): string => strtoupper(trim($region))) + ->filter(fn (string $region): bool => $region !== '') + ->unique() + ->values() + ->all(); + } + + /** + * @return array + */ + protected function buildRateConfig(): array + { + if ($this->rateType === 'flat') { + return ['amount' => $this->toMinorUnits($this->rateFlatAmount)]; + } + + $isWeight = $this->rateType === 'weight'; + $minKey = $isWeight ? 'min_g' : 'min_amount'; + $maxKey = $isWeight ? 'max_g' : 'max_amount'; + + $ranges = []; + + foreach ($this->rateRanges as $range) { + if (trim((string) $range['amount']) === '') { + continue; + } + + $min = trim((string) $range['min']) !== '' + ? ($isWeight ? (int) $range['min'] : $this->toMinorUnits((string) $range['min'])) + : 0; + + $max = trim((string) $range['max']) !== '' + ? ($isWeight ? (int) $range['max'] : $this->toMinorUnits((string) $range['max'])) + : null; + + $row = [$minKey => $min, 'amount' => $this->toMinorUnits((string) $range['amount'])]; + + if ($max !== null) { + $row[$maxKey] = $max; + } + + $ranges[] = $row; + } + + return ['ranges' => $ranges]; + } + + /** + * @return list + */ + protected function rangesFromConfig(ShippingRate $rate): array + { + if ($rate->type === ShippingRateType::Flat) { + return [['min' => '', 'max' => '', 'amount' => '']]; + } + + $isWeight = $rate->type === ShippingRateType::Weight; + $minKey = $isWeight ? 'min_g' : 'min_amount'; + $maxKey = $isWeight ? 'max_g' : 'max_amount'; + + $ranges = array_map(function (array $range) use ($isWeight, $minKey, $maxKey): array { + $format = fn (mixed $value): string => $value === null || $value === '' + ? '' + : ($isWeight ? (string) (int) $value : number_format(((int) $value) / 100, 2, '.', '')); + + return [ + 'min' => $format($range[$minKey] ?? 0), + 'max' => $format($range[$maxKey] ?? null), + 'amount' => number_format(((int) ($range['amount'] ?? 0)) / 100, 2, '.', ''), + ]; + }, $rate->config_json['ranges'] ?? []); + + return $ranges !== [] ? $ranges : [['min' => '', 'max' => '', 'amount' => '']]; + } + + protected function toMinorUnits(string $value): int + { + return (int) round((float) str_replace(',', '.', $value) * 100); + } +} diff --git a/app/Livewire/Admin/Settings/Taxes.php b/app/Livewire/Admin/Settings/Taxes.php new file mode 100644 index 00000000..4d920c3b --- /dev/null +++ b/app/Livewire/Admin/Settings/Taxes.php @@ -0,0 +1,102 @@ +store(); + + $this->authorize('viewSettings', $store); + + $settings = TaxSettings::query()->find($store->getKey()); + + if ($settings !== null) { + $this->mode = $settings->mode->value; + $this->manualRate = number_format($settings->defaultRateBasisPoints() / 100, 2, '.', ''); + $this->taxName = $settings->taxName(); + $this->pricesIncludeTax = $settings->prices_include_tax; + $this->shippingTaxable = $settings->shippingTaxable(); + $this->provider = $settings->provider ?? 'none'; + $this->providerApiKey = (string) ($settings->config_json['provider_api_key'] ?? ''); + } + } + + public function save(): void + { + $store = $this->store(); + + $this->authorize('updateSettings', $store); + + $this->validate([ + 'mode' => ['required', 'in:manual,provider'], + 'manualRate' => ['required_if:mode,manual', 'nullable', 'numeric', 'min:0', 'max:100'], + 'taxName' => ['nullable', 'string', 'max:255'], + 'provider' => ['required_if:mode,provider', 'nullable', 'string', 'max:255'], + 'providerApiKey' => ['nullable', 'string', 'max:255'], + ]); + + $config = [ + 'default_rate_bps' => (int) round((float) str_replace(',', '.', $this->manualRate) * 100), + 'shipping_taxable' => $this->shippingTaxable, + 'tax_name' => trim($this->taxName) !== '' ? trim($this->taxName) : 'Tax', + ]; + + if ($this->mode === 'provider' && trim($this->providerApiKey) !== '') { + $config['provider_api_key'] = trim($this->providerApiKey); + } + + TaxSettings::query()->updateOrCreate( + ['store_id' => $store->getKey()], + [ + 'mode' => $this->mode, + 'provider' => $this->mode === 'provider' ? $this->provider : 'none', + 'prices_include_tax' => $this->pricesIncludeTax, + 'config_json' => $config, + ], + ); + + $this->toast(__('Tax settings saved')); + } + + public function render(): View + { + return view('livewire.admin.settings.taxes')->title(__('Taxes')); + } + + protected function store(): Store + { + return app('current_store'); + } +} diff --git a/app/Livewire/Admin/Themes/Editor.php b/app/Livewire/Admin/Themes/Editor.php new file mode 100644 index 00000000..9a43952c --- /dev/null +++ b/app/Livewire/Admin/Themes/Editor.php @@ -0,0 +1,292 @@ + + */ + public array $settings = []; + + /** + * Home page section order (subset of the orderable sections). + * + * @var list + */ + public array $sectionOrder = []; + + /** + * Whether each orderable home section is enabled. + * + * @var array + */ + public array $enabledSections = []; + + public function mount(int $themeId): void + { + $this->theme = Theme::query()->with('settings')->findOrFail($themeId); + + $this->authorize('update', $this->theme); + + $stored = array_replace( + ThemeSettingsService::defaults(), + $this->theme->settings?->settings_json ?? [], + ); + + $enabled = $stored['sections']; + $this->sectionOrder = [...$enabled, ...array_diff(array_keys($this->orderableSections()), $enabled)]; + $this->enabledSections = array_map( + fn (string $key): bool => in_array($key, $enabled, true), + array_combine(array_keys($this->orderableSections()), array_keys($this->orderableSections())), + ); + + unset($stored['sections']); + $stored['featured_collection_handles'] = implode(', ', $stored['featured_collection_handles'] ?? []); + + $this->settings = $stored; + } + + public function selectSection(string $sectionKey): void + { + if (array_key_exists($sectionKey, $this->sections())) { + $this->selectedSection = $sectionKey; + } + } + + public function toggleSection(string $sectionKey): void + { + if (array_key_exists($sectionKey, $this->orderableSections())) { + $this->enabledSections[$sectionKey] = ! ($this->enabledSections[$sectionKey] ?? false); + } + } + + /** + * Drag-to-reorder handler (wire:sort) for the home page section list. + */ + public function reorderSections(string $sectionKey, int $position): void + { + $currentIndex = array_search($sectionKey, $this->sectionOrder, true); + + if ($currentIndex === false) { + return; + } + + array_splice($this->sectionOrder, $currentIndex, 1); + array_splice($this->sectionOrder, $position, 0, [$sectionKey]); + } + + public function save(): void + { + $this->authorize('update', $this->theme); + + $this->theme->settings()->updateOrCreate( + ['theme_id' => $this->theme->getKey()], + ['settings_json' => $this->buildSettingsJson()], + ); + + $this->toast(__('Theme settings saved.')); + } + + /** + * Save and publish in one step ("Save & publish" toolbar button). + */ + public function publish(): void + { + $this->authorize('publish', $this->theme); + + $this->save(); + + DB::transaction(function (): void { + Theme::query() + ->whereKeyNot($this->theme->getKey()) + ->where('status', ThemeStatus::Published) + ->update(['status' => ThemeStatus::Draft]); + + $this->theme->update([ + 'status' => ThemeStatus::Published, + 'published_at' => now(), + ]); + }); + + app(ThemeSettingsService::class)->forget($this->theme->store_id); + + $this->toast(__('Theme published')); + } + + /** + * All editor sections: fixed settings groups plus orderable home + * sections. Field types map to spec 03 section 12.2 input kinds. + * + * @return array}>}> + */ + public function sections(): array + { + return [ + 'header' => [ + 'label' => __('Header'), + 'fields' => [ + ['key' => 'logo_url', 'label' => __('Logo URL'), 'type' => 'text'], + ['key' => 'sticky_header', 'label' => __('Sticky header'), 'type' => 'checkbox'], + ['key' => 'show_announcement_bar', 'label' => __('Show announcement bar'), 'type' => 'checkbox'], + ['key' => 'announcement_text', 'label' => __('Announcement text'), 'type' => 'text'], + ['key' => 'announcement_link', 'label' => __('Announcement link'), 'type' => 'text'], + ], + ], + 'colors' => [ + 'label' => __('Colors & typography'), + 'fields' => [ + ['key' => 'primary_color', 'label' => __('Primary color'), 'type' => 'color'], + ['key' => 'secondary_color', 'label' => __('Secondary color'), 'type' => 'color'], + ['key' => 'font_family', 'label' => __('Font family'), 'type' => 'select', 'options' => [ + 'Instrument Sans, sans-serif' => 'Instrument Sans', + 'Inter, sans-serif' => 'Inter', + 'Georgia, serif' => 'Georgia', + 'Menlo, monospace' => 'Menlo', + ]], + ['key' => 'dark_mode', 'label' => __('Dark mode'), 'type' => 'select', 'options' => [ + 'system' => __('Follow system'), + 'light' => __('Light'), + 'dark' => __('Dark'), + ]], + ], + ], + 'catalog' => [ + 'label' => __('Product catalog'), + 'fields' => [ + ['key' => 'products_per_page', 'label' => __('Products per page'), 'type' => 'number'], + ['key' => 'show_vendor', 'label' => __('Show vendor'), 'type' => 'checkbox'], + ['key' => 'show_quantity_selector', 'label' => __('Show quantity selector'), 'type' => 'checkbox'], + ], + ], + 'footer' => [ + 'label' => __('Footer'), + 'fields' => [ + ['key' => 'footer_text', 'label' => __('Footer text'), 'type' => 'text'], + ], + ], + ...$this->orderableSections(), + ]; + } + + /** + * Home page sections that can be reordered and toggled (the persisted + * "sections" list in theme settings). + * + * @return array}>}> + */ + public function orderableSections(): array + { + return [ + 'hero' => [ + 'label' => __('Hero'), + 'fields' => [ + ['key' => 'hero_heading', 'label' => __('Heading'), 'type' => 'text'], + ['key' => 'hero_subheading', 'label' => __('Subheading'), 'type' => 'textarea'], + ['key' => 'hero_cta_text', 'label' => __('Button text'), 'type' => 'text'], + ['key' => 'hero_cta_link', 'label' => __('Button link'), 'type' => 'text'], + ['key' => 'hero_image_url', 'label' => __('Image URL'), 'type' => 'text'], + ], + ], + 'featured-collections' => [ + 'label' => __('Featured collections'), + 'fields' => [ + ['key' => 'featured_collection_handles', 'label' => __('Collection handles'), 'type' => 'text'], + ], + ], + 'featured-products' => [ + 'label' => __('Featured products'), + 'fields' => [ + ['key' => 'featured_products_count', 'label' => __('Number of products'), 'type' => 'number'], + ['key' => 'featured_products_collection_handle', 'label' => __('Collection handle'), 'type' => 'text'], + ], + ], + 'newsletter' => [ + 'label' => __('Newsletter'), + 'fields' => [ + ['key' => 'show_newsletter', 'label' => __('Show newsletter signup'), 'type' => 'checkbox'], + ], + ], + 'rich-text' => [ + 'label' => __('Rich text'), + 'fields' => [ + ['key' => 'rich_text_html', 'label' => __('Content (HTML)'), 'type' => 'textarea'], + ], + ], + ]; + } + + /** + * Storefront home URL for the live preview iframe, based on the store's + * primary storefront domain. + */ + #[Computed] + public function previewUrl(): ?string + { + $hostname = $this->theme->store() + ->withoutGlobalScopes() + ->first() + ?->domains() + ->where('type', StoreDomainType::Storefront) + ->orderByDesc('is_primary') + ->value('hostname'); + + return $hostname !== null ? request()->getScheme().'://'.$hostname : null; + } + + public function render(): View + { + return view('livewire.admin.themes.editor')->title($this->theme->name); + } + + /** + * @return array + */ + protected function buildSettingsJson(): array + { + $settings = $this->settings; + + $settings['featured_collection_handles'] = collect(explode(',', (string) ($settings['featured_collection_handles'] ?? ''))) + ->map(fn (string $handle): string => trim($handle)) + ->filter(fn (string $handle): bool => $handle !== '') + ->values() + ->all(); + + foreach (['products_per_page', 'featured_products_count'] as $numericKey) { + $settings[$numericKey] = max(1, (int) ($settings[$numericKey] ?? 1)); + } + + $settings['sections'] = array_values(array_filter( + $this->sectionOrder, + fn (string $key): bool => $this->enabledSections[$key] ?? false, + )); + + return $settings; + } +} diff --git a/app/Livewire/Admin/Themes/Index.php b/app/Livewire/Admin/Themes/Index.php new file mode 100644 index 00000000..521a0273 --- /dev/null +++ b/app/Livewire/Admin/Themes/Index.php @@ -0,0 +1,114 @@ +authorize('viewAny', Theme::class); + } + + /** + * Publish a theme: it becomes the single published theme for the store + * and the cached storefront settings are invalidated. + */ + public function publishTheme(int $themeId): void + { + $theme = Theme::query()->findOrFail($themeId); + + $this->authorize('publish', $theme); + + DB::transaction(function () use ($theme): void { + Theme::query() + ->whereKeyNot($theme->getKey()) + ->where('status', ThemeStatus::Published) + ->update(['status' => ThemeStatus::Draft]); + + $theme->update([ + 'status' => ThemeStatus::Published, + 'published_at' => now(), + ]); + }); + + app(ThemeSettingsService::class)->forget($theme->store_id); + + $this->toast(__('Theme published')); + } + + /** + * Duplicate a theme (including its settings) as a draft copy. + */ + public function duplicateTheme(int $themeId): void + { + $theme = Theme::query()->with('settings')->findOrFail($themeId); + + $this->authorize('create', Theme::class); + + DB::transaction(function () use ($theme): void { + $copy = Theme::query()->create([ + 'store_id' => $theme->store_id, + 'name' => __(':name (Copy)', ['name' => $theme->name]), + 'version' => $theme->version, + 'status' => ThemeStatus::Draft, + 'published_at' => null, + ]); + + if ($theme->settings !== null) { + $copy->settings()->create(['settings_json' => $theme->settings->settings_json]); + } + }); + + $this->toast(__('Theme duplicated.')); + } + + public function deleteTheme(int $themeId): void + { + $theme = Theme::query()->findOrFail($themeId); + + $this->authorize('delete', $theme); + + if ($theme->status === ThemeStatus::Published) { + $this->toast(__('The published theme cannot be deleted. Publish another theme first.'), 'error'); + + return; + } + + $theme->settings()->delete(); + $theme->files()->delete(); + $theme->delete(); + + $this->toast(__('Theme deleted.')); + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + #[Computed] + public function themes(): \Illuminate\Database\Eloquent\Collection + { + return Theme::query() + ->orderByDesc('status') + ->orderByDesc('updated_at') + ->get(); + } + + public function render(): View + { + return view('livewire.admin.themes.index')->title(__('Themes')); + } +} 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..21c868d0 --- /dev/null +++ b/app/Livewire/Storefront/Account/Addresses/Index.php @@ -0,0 +1,211 @@ + */ + public array $form = self::BLANK_FORM; + + /** @var array */ + private const array BLANK_FORM = [ + 'first_name' => '', + 'last_name' => '', + 'address1' => '', + 'address2' => '', + 'city' => '', + 'province' => '', + 'postal_code' => '', + 'country_code' => '', + 'phone' => '', + ]; + + /** + * Open the modal with a blank form to add a new address. + */ + public function create(): void + { + $this->resetValidation(); + $this->statusMessage = null; + $this->editingId = null; + $this->label = ''; + $this->form = self::BLANK_FORM; + $this->showForm = true; + } + + /** + * Open the modal pre-filled with an existing address. + */ + public function edit(int $addressId): void + { + $address = $this->findAddress($addressId); + + $this->resetValidation(); + $this->statusMessage = null; + $this->editingId = $address->getKey(); + $this->label = (string) $address->label; + $this->form = array_merge(self::BLANK_FORM, $address->toCheckoutAddress()); + $this->showForm = true; + } + + /** + * Create or update the address being edited. The first address a + * customer saves automatically becomes the default. + */ + public function save(): void + { + $this->validate( + [ + 'label' => ['nullable', 'string', 'max:255'], + 'form.first_name' => ['required', 'string', 'max:255'], + 'form.last_name' => ['required', 'string', 'max:255'], + 'form.address1' => ['required', 'string', 'max:255'], + 'form.address2' => ['nullable', 'string', 'max:255'], + 'form.city' => ['required', 'string', 'max:255'], + 'form.province' => ['nullable', 'string', 'max:255'], + 'form.postal_code' => ['required', 'string', 'max:32'], + 'form.country_code' => ['required', 'string', 'size:2'], + 'form.phone' => ['nullable', 'string', 'max:64'], + ], + [], + [ + 'form.first_name' => __('first name'), + 'form.last_name' => __('last name'), + 'form.address1' => __('address line 1'), + 'form.address2' => __('address line 2'), + 'form.city' => __('city'), + 'form.province' => __('state / province'), + 'form.postal_code' => __('postal code'), + 'form.country_code' => __('country'), + 'form.phone' => __('phone'), + ], + ); + + $attributes = [ + 'label' => trim($this->label), + 'address_json' => $this->addressJson(), + ]; + + if ($this->editingId !== null) { + $this->findAddress($this->editingId)->update($attributes); + } else { + $this->addresses()->create($attributes + [ + 'is_default' => ! $this->addresses()->where('is_default', true)->exists(), + ]); + } + + $this->showForm = false; + $this->editingId = null; + $this->statusMessage = __('Address saved'); + } + + /** + * Delete an address. When the default address is removed, the most + * recently added remaining address becomes the new default. + */ + public function delete(int $addressId): void + { + $address = $this->findAddress($addressId); + $wasDefault = $address->is_default; + + $address->delete(); + + if ($wasDefault) { + $this->addresses()->latest('id')->first()?->update(['is_default' => true]); + } + } + + /** + * Mark an address as the default, flipping all others off. + */ + public function setDefault(int $addressId): void + { + $address = $this->findAddress($addressId); + + DB::transaction(function () use ($address): void { + $this->addresses()->whereKeyNot($address->getKey())->update(['is_default' => false]); + + $address->update(['is_default' => true]); + }); + } + + public function render(): View + { + $addresses = $this->addresses() + ->orderByDesc('is_default') + ->orderBy('id') + ->get(); + + return view('livewire.storefront.account.addresses.index', [ + 'addresses' => $addresses, + ])->title(__('Your Addresses')); + } + + /** + * The authenticated customer's addresses; addresses of other customers + * can never be resolved (404). + */ + protected function addresses(): HasMany + { + /** @var Customer $customer */ + $customer = auth('customer')->user(); + + return $customer->addresses(); + } + + protected function findAddress(int $addressId): CustomerAddress + { + /** @var CustomerAddress|null $address */ + $address = $this->addresses()->find($addressId); + + abort_if($address === null, 404); + + return $address; + } + + /** + * Map the form fields to the spec 01 address JSON shape ("zip" key), + * preserving keys the form does not manage (company, province_code). + * + * @return array + */ + protected function addressJson(): array + { + $existing = $this->editingId !== null + ? ($this->findAddress($this->editingId)->address_json ?? []) + : []; + + return [ + 'first_name' => trim($this->form['first_name']), + 'last_name' => trim($this->form['last_name']), + 'company' => (string) ($existing['company'] ?? ''), + 'address1' => trim($this->form['address1']), + 'address2' => trim($this->form['address2']), + 'city' => trim($this->form['city']), + 'province' => trim($this->form['province']), + 'province_code' => (string) ($existing['province_code'] ?? ''), + 'country' => Countries::name($this->form['country_code']), + 'country_code' => strtoupper(trim($this->form['country_code'])), + 'zip' => trim($this->form['postal_code']), + 'phone' => trim($this->form['phone']), + ]; + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Login.php b/app/Livewire/Storefront/Account/Auth/Login.php new file mode 100644 index 00000000..776cf3dc --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Login.php @@ -0,0 +1,12 @@ +customer(); + + $this->name = (string) $customer->name; + $this->marketingOptIn = (bool) $customer->marketing_opt_in; + } + + /** + * Update the customer's profile (name and marketing preference). + */ + public function updateProfile(): void + { + $this->validate( + [ + 'name' => ['required', 'string', 'max:255'], + 'marketingOptIn' => ['boolean'], + ], + [], + ['name' => __('name')], + ); + + $this->customer()->update([ + 'name' => $this->name, + 'marketing_opt_in' => $this->marketingOptIn, + ]); + + session()->flash('profile-updated', __('Your profile has been updated.')); + } + + public function render(): View + { + $recentOrders = $this->customer() + ->orders() + ->latest('placed_at') + ->latest('id') + ->limit(5) + ->get(); + + return view('livewire.storefront.account.dashboard', [ + 'customer' => $this->customer(), + 'recentOrders' => $recentOrders, + ])->title(__('My account')); + } + + protected function customer(): Customer + { + /** @var Customer $customer */ + $customer = auth('customer')->user(); + + return $customer; + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Index.php b/app/Livewire/Storefront/Account/Orders/Index.php new file mode 100644 index 00000000..e074069b --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Index.php @@ -0,0 +1,30 @@ +user(); + + $orders = $customer->orders() + ->latest('placed_at') + ->latest('id') + ->paginate(10); + + return view('livewire.storefront.account.orders.index', [ + 'orders' => $orders, + ])->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..f0c23891 --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Show.php @@ -0,0 +1,141 @@ +orderNumber = $orderNumber; + + $this->order(); + } + + public function render(): View + { + $order = $this->order(); + + return view('livewire.storefront.account.orders.show', [ + 'order' => $order, + 'lines' => $this->lineData($order), + 'timeline' => $this->timeline($order), + ])->title(__('Order :number', ['number' => $order->order_number])); + } + + /** + * The customer's order matching the route's order number. Order numbers + * are stored with a configurable prefix (e.g. "#1042") that cannot + * appear in a URL path, so the bare number is matched too. Orders of + * other customers are never found (404). + */ + protected function order(): Order + { + /** @var Customer $customer */ + $customer = auth('customer')->user(); + + return $customer->orders() + ->whereIn('order_number', [$this->orderNumber, '#'.$this->orderNumber]) + ->with(['lines.variant.product.media', 'lines.variant.optionValues', 'payments', 'fulfillments']) + ->firstOrFail(); + } + + /** + * Presentation data for the order's line items. + * + * @return list + */ + protected function lineData(Order $order): array + { + return $order->lines + ->map(function (OrderLine $line): array { + $media = $line->variant?->product?->media->first(); + + return [ + 'title' => $line->title_snapshot, + 'variant_label' => $line->variant?->optionValues->pluck('value')->implode(' / ') ?? '', + 'sku' => $line->sku_snapshot, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'total_amount' => $line->total_amount, + 'image_url' => $media !== null ? Storage::disk('public')->url($media->storage_key) : null, + ]; + }) + ->all(); + } + + /** + * Chronological order history built from the order itself (placed, + * cancelled), its payments (paid), and its fulfillments (fulfilled, + * delivered). + * + * @return list + */ + protected function timeline(Order $order): array + { + $events = []; + + if ($order->placed_at !== null) { + $events[] = [ + 'label' => __('Order placed'), + 'description' => null, + 'timestamp' => $order->placed_at, + ]; + } + + $capturedPayment = $order->payments->firstWhere('status', PaymentStatus::Captured); + + if ($capturedPayment !== null) { + $events[] = [ + 'label' => __('Payment received'), + 'description' => __('Paid via :method', ['method' => str_replace('_', ' ', $capturedPayment->method->value)]), + 'timestamp' => $capturedPayment->created_at, + ]; + } + + foreach ($order->fulfillments as $fulfillment) { + $tracking = filled($fulfillment->tracking_number) + ? trim(($fulfillment->tracking_company ?? '').' '.$fulfillment->tracking_number) + : null; + + $events[] = [ + 'label' => __('Items fulfilled'), + 'description' => $tracking !== null ? __('Tracking: :tracking', ['tracking' => $tracking]) : null, + 'timestamp' => $fulfillment->shipped_at ?? $fulfillment->created_at, + ]; + + if ($fulfillment->delivered_at !== null) { + $events[] = [ + 'label' => __('Delivered'), + 'description' => null, + 'timestamp' => $fulfillment->delivered_at, + ]; + } + } + + if ($order->status === OrderStatus::Cancelled) { + $events[] = [ + 'label' => __('Order cancelled'), + 'description' => null, + 'timestamp' => $order->updated_at, + ]; + } + + usort($events, fn (array $a, array $b): int => $a['timestamp'] <=> $b['timestamp']); + + return $events; + } +} diff --git a/app/Livewire/Storefront/Cart/Show.php b/app/Livewire/Storefront/Cart/Show.php new file mode 100644 index 00000000..5571411c --- /dev/null +++ b/app/Livewire/Storefront/Cart/Show.php @@ -0,0 +1,67 @@ +currentCart(); + + if ($this->estimateCountry === '' || $cart === null || ! $cart->requiresShipping()) { + return null; + } + + $calculator = app(ShippingCalculator::class); + + return $calculator + ->getAvailableRates($this->currentStore(), ['country_code' => $this->estimateCountry]) + ->map(fn (ShippingRate $rate): array => [ + 'name' => $rate->name, + 'amount' => $calculator->calculate($rate, $cart), + ]) + ->sortBy('amount') + ->first(); + } + + public function render(): View + { + $cart = $this->currentCart(); + $discount = $this->appliedDiscount($cart); + $subtotal = $cart?->subtotalAmount() ?? 0; + + return view('livewire.storefront.cart.show', [ + 'lines' => $this->cartLineData($cart), + 'currency' => $cart?->currency ?? $this->currentStore()->default_currency, + 'subtotalAmount' => $subtotal, + 'discount' => $discount, + 'estimatedTotal' => max(0, $subtotal - ($discount['amount'] ?? 0)), + 'shippingEstimate' => $this->shippingEstimate(), + 'requiresShipping' => $cart?->requiresShipping() ?? false, + ])->title(__('Your Cart')); + } +} diff --git a/app/Livewire/Storefront/CartDrawer.php b/app/Livewire/Storefront/CartDrawer.php new file mode 100644 index 00000000..0a1a79fa --- /dev/null +++ b/app/Livewire/Storefront/CartDrawer.php @@ -0,0 +1,49 @@ +open = true; + } + + #[On('cart-updated')] + public function refreshAndOpen(): void + { + $this->open = true; + } + + public function closeDrawer(): void + { + $this->open = false; + } + + public function render(): View + { + $cart = $this->currentCart(); + $lines = $this->cartLineData($cart); + $discount = $this->appliedDiscount($cart); + $subtotal = $cart?->subtotalAmount() ?? 0; + + return view('livewire.storefront.cart-drawer', [ + 'lines' => $lines, + 'itemCount' => $cart?->itemCount() ?? 0, + 'currency' => $cart?->currency ?? $this->currentStore()->default_currency, + 'subtotalAmount' => $subtotal, + 'discount' => $discount, + 'estimatedTotal' => max(0, $subtotal - ($discount['amount'] ?? 0)), + ]); + } +} diff --git a/app/Livewire/Storefront/Checkout/Confirmation.php b/app/Livewire/Storefront/Checkout/Confirmation.php new file mode 100644 index 00000000..c717e7f6 --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Confirmation.php @@ -0,0 +1,55 @@ +checkoutId = $checkoutId; + } + + public function render(): View + { + $order = Order::query() + ->with(['lines.variant.product.media']) + ->where('checkout_id', $this->checkoutId) + ->firstOrFail(); + + return view('livewire.storefront.checkout.confirmation', [ + 'order' => $order, + 'items' => $this->itemData($order), + ])->title(__('Order confirmation')); + } + + /** + * Presentation data for the order's lines. + * + * @return list + */ + protected function itemData(Order $order): array + { + return $order->lines + ->map(function (OrderLine $line): array { + $media = $line->variant?->product?->media->first(); + + return [ + 'title' => $line->title_snapshot, + 'quantity' => $line->quantity, + 'total_amount' => $line->total_amount, + 'image_url' => $media !== null ? Storage::disk('public')->url($media->storage_key) : null, + ]; + }) + ->all(); + } +} diff --git a/app/Livewire/Storefront/Checkout/Show.php b/app/Livewire/Storefront/Checkout/Show.php new file mode 100644 index 00000000..f9384e7a --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Show.php @@ -0,0 +1,434 @@ + */ + public array $shipping = [ + 'first_name' => '', + 'last_name' => '', + 'address1' => '', + 'address2' => '', + 'city' => '', + 'province' => '', + 'postal_code' => '', + 'country_code' => '', + 'phone' => '', + ]; + + /** + * Saved-address picker for logged-in customers: an address id, "new" + * for a blank form, or "" when nothing is selected. + */ + public string $savedAddressId = ''; + + public ?int $selectedRateId = null; + + public string $paymentMethod = 'credit_card'; + + public string $cardNumber = ''; + + public string $cardName = ''; + + public string $cardExpiry = ''; + + public string $cardCvc = ''; + + public ?string $shippingError = null; + + public ?string $paymentError = null; + + /** + * Human-readable attribute names for validation messages. + * + * @return array + */ + protected function validationAttributes(): array + { + return [ + 'shipping.first_name' => __('first name'), + 'shipping.last_name' => __('last name'), + 'shipping.address1' => __('address'), + 'shipping.city' => __('city'), + 'shipping.postal_code' => __('postal code'), + 'shipping.country_code' => __('country'), + ]; + } + + public function mount(): void + { + $cart = $this->currentCart(); + + if ($cart === null || ! $cart->lines()->exists()) { + $this->redirectRoute('storefront.cart'); + + return; + } + + $checkout = null; + + if (Session::has('checkout_id')) { + $checkout = Checkout::query() + ->where('cart_id', $cart->getKey()) + ->whereNotIn('status', [CheckoutStatus::Completed, CheckoutStatus::Expired]) + ->find(Session::get('checkout_id')); + } + + if ($checkout === null) { + $checkout = app(CheckoutService::class)->createFromCart( + $cart, + $this->currentCustomer(), + Session::get('cart_discount_code'), + ); + + Session::put('checkout_id', $checkout->getKey()); + } + + $this->checkoutId = $checkout->getKey(); + $this->email = $checkout->email ?? $this->currentCustomer()?->email ?? ''; + $this->shipping = array_merge($this->shipping, array_map( + fn ($value): string => (string) $value, + $checkout->shipping_address_json ?? [], + )); + + $this->prefillFromDefaultAddress($checkout); + $this->selectedRateId = $checkout->shipping_method_id; + $this->paymentMethod = $checkout->payment_method ?? 'credit_card'; + + $this->step = match ($checkout->status) { + CheckoutStatus::Started => 1, + CheckoutStatus::Addressed => 3, + CheckoutStatus::ShippingSelected => 4, + default => 5, + }; + } + + /** + * Populate the address form when a saved address is picked from the + * dropdown; "new" resets the form to a blank address (spec 04). + */ + public function updatedSavedAddressId(string $value): void + { + if ($value === '') { + return; + } + + $blank = array_fill_keys(array_keys($this->shipping), ''); + + if ($value === 'new') { + $this->shipping = $blank; + + return; + } + + $address = $this->currentCustomer()?->addresses()->find((int) $value); + + if ($address !== null) { + $this->shipping = array_merge($blank, $address->toCheckoutAddress()); + } + } + + public function saveContact(): void + { + $this->validate(['email' => ['required', 'email']]); + + $this->step = max($this->step, 2); + } + + public function saveAddress(CheckoutService $checkoutService): void + { + $postalCodeRules = ['required', 'string', 'max:32']; + + if (($this->shipping['country_code'] ?? '') === 'DE') { + $postalCodeRules[] = 'regex:/^\d{5}$/'; + } + + $this->validate( + [ + 'email' => ['required', 'email'], + 'shipping.first_name' => ['required', 'string', 'max:255'], + 'shipping.last_name' => ['required', 'string', 'max:255'], + 'shipping.address1' => ['required', 'string', 'max:255'], + 'shipping.city' => ['required', 'string', 'max:255'], + 'shipping.postal_code' => $postalCodeRules, + 'shipping.country_code' => ['required', 'string', 'size:2'], + ], + ['shipping.postal_code.regex' => __('The postal code format is invalid for the selected country.')], + ); + + $checkout = $checkoutService->setAddress($this->checkout(), [ + 'email' => $this->email, + 'shipping_address' => array_filter($this->shipping, fn (string $value): bool => $value !== ''), + ]); + + $this->shippingError = null; + $this->selectedRateId = null; + + $cart = $this->currentCart(); + + if ($cart !== null && ! $cart->requiresShipping()) { + $checkoutService->setShippingMethod($checkout); + $this->step = 4; + + return; + } + + $this->step = 3; + } + + public function saveShipping(CheckoutService $checkoutService): void + { + $this->shippingError = null; + + try { + $checkoutService->setShippingMethod($this->checkout(), $this->selectedRateId); + } catch (InvalidShippingRateException) { + $this->shippingError = __('Please choose one of the available shipping methods.'); + + return; + } + + $this->step = 4; + } + + /** + * Charge the mock PSP and create the order; on success redirect to the + * confirmation page. Declines keep the customer on the payment step. + * The selected payment method is persisted to the checkout first, so + * switching the radio buttons right before paying always takes effect. + */ + public function payNow(CheckoutService $checkoutService): void + { + $this->paymentError = null; + + if ($this->paymentMethod === 'credit_card') { + $this->validate([ + 'cardNumber' => ['required', 'string', 'regex:/^[\d ]{12,23}$/'], + 'cardName' => ['required', 'string', 'max:255'], + 'cardExpiry' => ['required', 'string', 'max:7'], + 'cardCvc' => ['required', 'string', 'min:3', 'max:4'], + ]); + } + + $checkout = $this->checkout(); + + try { + if ($checkout->status === CheckoutStatus::ShippingSelected) { + $checkoutService->selectPaymentMethod($checkout, $this->paymentMethod); + } elseif ($checkout->payment_method !== $this->paymentMethod) { + $checkout->forceFill(['payment_method' => $this->paymentMethod])->save(); + } + } catch (InsufficientInventoryException) { + $this->paymentError = __('Some items in your cart are no longer in stock.'); + + return; + } + + try { + $order = $checkoutService->completeCheckout($this->checkout(), [ + 'card_number' => $this->cardNumber, + 'card_name' => $this->cardName, + 'card_expiry' => $this->cardExpiry, + 'card_cvc' => $this->cardCvc, + ]); + } catch (PaymentFailedException $exception) { + $this->paymentError = __($exception->getMessage()); + + return; + } catch (InvalidCheckoutTransitionException) { + $this->paymentError = __('This checkout can no longer be completed. Please start over from your cart.'); + + return; + } + + Session::forget(['checkout_id', 'cart_id', 'cart_discount_code']); + + $this->redirectRoute('storefront.checkout.confirmation', ['checkoutId' => $order->checkout_id]); + } + + public function editStep(int $step): void + { + if ($step >= 1 && $step < $this->step && $this->step < 5) { + $this->step = $step; + } + } + + /** + * Apply a discount code directly to the checkout and recalculate. + */ + public function applyDiscount(): void + { + $this->discountError = null; + + $cart = $this->currentCart(); + $code = trim($this->discountCode); + + if ($cart === null || $code === '') { + return; + } + + try { + $discount = app(DiscountService::class)->validate($code, $this->currentStore(), $cart); + } catch (InvalidDiscountException $exception) { + $this->discountError = $exception->getMessage(); + + return; + } + + Session::put('cart_discount_code', $discount->code); + + $checkout = $this->checkout(); + $checkout->forceFill(['discount_code' => $discount->code])->save(); + + app(CheckoutService::class)->recalculate($checkout); + + $this->discountCode = ''; + } + + /** + * Remove the applied discount code and recalculate. + */ + public function removeDiscount(): void + { + Session::forget('cart_discount_code'); + + $checkout = $this->checkout(); + $checkout->forceFill(['discount_code' => null])->save(); + + app(CheckoutService::class)->recalculate($checkout); + } + + public function render(): View + { + $checkout = $this->checkout(); + $cart = $this->currentCart(); + $totals = $checkout->totals_json ?? []; + + return view('livewire.storefront.checkout.show', [ + 'checkout' => $checkout, + 'lines' => $this->cartLineData($cart), + 'currency' => $totals['currency'] ?? $cart?->currency ?? $this->currentStore()->default_currency, + 'totals' => $totals, + 'availableRates' => $this->availableRates($checkout), + 'requiresShipping' => $cart?->requiresShipping() ?? false, + 'savedAddresses' => $this->savedAddresses(), + ])->title(__('Checkout')); + } + + protected function checkout(): Checkout + { + return Checkout::query()->findOrFail($this->checkoutId); + } + + /** + * Prefill the address step from the logged-in customer's default + * address when the checkout has no address yet (spec 04 section 9). + */ + protected function prefillFromDefaultAddress(Checkout $checkout): void + { + if (($checkout->shipping_address_json ?? []) !== []) { + return; + } + + $default = $this->currentCustomer() + ?->addresses() + ->where('is_default', true) + ->first(); + + if ($default !== null) { + $this->shipping = array_merge($this->shipping, $default->toCheckoutAddress()); + $this->savedAddressId = (string) $default->getKey(); + } + } + + /** + * The logged-in customer's saved addresses for the address picker. + * + * @return list + */ + protected function savedAddresses(): array + { + $customer = $this->currentCustomer(); + + if ($customer === null) { + return []; + } + + return $customer->addresses() + ->orderByDesc('is_default') + ->orderBy('id') + ->get() + ->map(fn (CustomerAddress $address): array => [ + 'id' => $address->getKey(), + 'label' => (string) $address->label, + 'summary' => $address->summaryLine(), + ]) + ->all(); + } + + /** + * Available shipping rates for the checkout address with calculated costs. + * + * @return list + */ + protected function availableRates(Checkout $checkout): array + { + $cart = $this->currentCart(); + + if ($cart === null || $checkout->shipping_address_json === null) { + return []; + } + + $calculator = app(ShippingCalculator::class); + + return $calculator + ->getAvailableRates($this->currentStore(), $checkout->shipping_address_json) + ->map(function (ShippingRate $rate) use ($calculator, $cart): ?array { + try { + return [ + 'id' => $rate->getKey(), + 'name' => $rate->name, + 'amount' => $calculator->calculate($rate, $cart), + ]; + } catch (RuntimeException) { + return null; + } + }) + ->filter() + ->values() + ->all(); + } +} diff --git a/app/Livewire/Storefront/Collections/Index.php b/app/Livewire/Storefront/Collections/Index.php new file mode 100644 index 00000000..d679bf22 --- /dev/null +++ b/app/Livewire/Storefront/Collections/Index.php @@ -0,0 +1,26 @@ +published() + ->with(['products' => fn ($query) => $query->published()->with('media')->limit(1)]) + ->withCount('products') + ->orderBy('title') + ->get(); + + return view('livewire.storefront.collections.index', [ + 'collections' => $collections, + ])->title(__('Collections')); + } +} diff --git a/app/Livewire/Storefront/Collections/Show.php b/app/Livewire/Storefront/Collections/Show.php new file mode 100644 index 00000000..57404cd0 --- /dev/null +++ b/app/Livewire/Storefront/Collections/Show.php @@ -0,0 +1,152 @@ + */ + #[Url(except: [])] + public array $productTypes = []; + + /** @var list */ + #[Url(except: [])] + public array $vendors = []; + + public function mount(string $handle): void + { + $this->collection = Collection::query() + ->published() + ->where('handle', $handle) + ->firstOrFail(); + } + + /** + * Reset pagination whenever a filter or the sort order changes. + */ + public function updated(string $property): void + { + if (in_array(str($property)->before('.')->value(), ['sort', 'inStock', 'priceMin', 'priceMax', 'productTypes', 'vendors'], true)) { + $this->resetPage(); + } + } + + public function clearFilters(): void + { + $this->reset('inStock', 'priceMin', 'priceMax', 'productTypes', 'vendors'); + $this->resetPage(); + } + + public function hasActiveFilters(): bool + { + return $this->inStock + || $this->priceMin !== '' + || $this->priceMax !== '' + || $this->productTypes !== [] + || $this->vendors !== []; + } + + public function render(ThemeSettingsService $themeSettings): View + { + return view('livewire.storefront.collections.show', [ + 'products' => $this->products((int) $themeSettings->get('products_per_page', 12)), + 'availableProductTypes' => $this->facetValues('product_type'), + 'availableVendors' => $this->facetValues('vendor'), + 'hasActiveFilters' => $this->hasActiveFilters(), + ])->title($this->collection->title); + } + + /** + * @return LengthAwarePaginator + */ + protected function products(int $perPage): LengthAwarePaginator + { + $query = $this->collection->products() + ->published() + ->with(['variants.inventoryItem', 'media']) + ->reorder(); + + if ($this->inStock) { + $query->whereHas('variants.inventoryItem', function (Builder $inventory): void { + $inventory->whereRaw('quantity_on_hand - quantity_reserved > 0'); + }); + } + + if ($this->priceMin !== '' && is_numeric($this->priceMin)) { + $query->whereHas('variants', fn (Builder $variants) => $variants->where('price_amount', '>=', (int) round((float) $this->priceMin * 100))); + } + + if ($this->priceMax !== '' && is_numeric($this->priceMax)) { + $query->whereHas('variants', fn (Builder $variants) => $variants->where('price_amount', '<=', (int) round((float) $this->priceMax * 100))); + } + + if ($this->productTypes !== []) { + $query->whereIn('product_type', $this->productTypes); + } + + if ($this->vendors !== []) { + $query->whereIn('vendor', $this->vendors); + } + + $defaultVariantPrice = ProductVariant::query() + ->select('price_amount') + ->whereColumn('product_id', 'products.id') + ->orderByDesc('is_default') + ->orderBy('position') + ->limit(1); + + match ($this->sort) { + 'price_asc' => $query->orderBy($defaultVariantPrice), + 'price_desc' => $query->orderByDesc($defaultVariantPrice), + 'newest' => $query->orderByDesc('products.created_at')->orderByDesc('products.id'), + default => $query->orderByPivot('position'), + }; + + return $query->paginate($perPage)->withQueryString(); + } + + /** + * Distinct facet values (product types or vendors) within the collection. + * + * @return list + */ + protected function facetValues(string $column): array + { + return $this->collection->products() + ->published() + ->whereNotNull($column) + ->where($column, '!=', '') + ->reorder() + ->distinct() + ->orderBy($column) + ->pluck($column) + ->all(); + } +} diff --git a/app/Livewire/Storefront/Concerns/InteractsWithCart.php b/app/Livewire/Storefront/Concerns/InteractsWithCart.php new file mode 100644 index 00000000..350e39e1 --- /dev/null +++ b/app/Livewire/Storefront/Concerns/InteractsWithCart.php @@ -0,0 +1,193 @@ +currentCart(); + + if ($cart === null) { + return; + } + + $this->cartError = null; + + try { + app(CartService::class)->updateLineQuantity($cart, $lineId, max(0, $quantity)); + } catch (InsufficientInventoryException) { + $this->cartError = __('Not enough stock available for the requested quantity.'); + } + + $this->dispatchCartUpdated($cart); + } + + public function removeLine(int $lineId): void + { + $cart = $this->currentCart(); + + if ($cart === null) { + return; + } + + $line = $cart->lines()->whereKey($lineId)->first(); + + app(CartService::class)->removeLine($cart, $lineId); + + if ($line !== null) { + app(AnalyticsService::class)->track( + $this->currentStore(), + 'remove_from_cart', + ['variant_id' => $line->variant_id, 'quantity' => $line->quantity], + session()->isStarted() ? session()->getId() : null, + $this->currentCustomer()?->getKey(), + ); + } + + $this->dispatchCartUpdated($cart); + } + + public function applyDiscount(): void + { + $this->discountError = null; + + $cart = $this->currentCart(); + $code = trim($this->discountCode); + + if ($cart === null || $code === '') { + return; + } + + try { + $discount = app(DiscountService::class)->validate($code, $this->currentStore(), $cart); + } catch (InvalidDiscountException $exception) { + $this->discountError = $exception->getMessage(); + + return; + } + + Session::put('cart_discount_code', $discount->code); + + $this->discountCode = ''; + $this->dispatchCartUpdated($cart); + } + + public function removeDiscount(): void + { + Session::forget('cart_discount_code'); + + $this->discountError = null; + + if (($cart = $this->currentCart()) !== null) { + $this->dispatchCartUpdated($cart); + } + } + + protected function currentStore(): Store + { + return app('current_store'); + } + + protected function currentCustomer(): ?Customer + { + return auth('customer')->user(); + } + + protected function currentCart(): ?Cart + { + return app(CartService::class)->findFor($this->currentStore(), $this->currentCustomer()); + } + + protected function dispatchCartUpdated(Cart $cart): void + { + $this->dispatch('cart-updated', cartId: $cart->getKey(), itemCount: $cart->itemCount()); + } + + /** + * The validated session discount code with its calculated amount, or + * null when no valid code is applied to the cart. + * + * @return array{code: string, amount: int, free_shipping: bool}|null + */ + protected function appliedDiscount(?Cart $cart): ?array + { + $code = Session::get('cart_discount_code'); + + if ($cart === null || blank($code)) { + return null; + } + + try { + $discount = app(DiscountService::class)->validate($code, $this->currentStore(), $cart); + } catch (InvalidDiscountException) { + return null; + } + + $lines = $cart->lines()->with('variant.product')->get()->all(); + $result = app(DiscountService::class)->calculate($discount, $cart->subtotalAmount(), $lines); + + return [ + 'code' => $discount->code, + 'amount' => $result->amount, + 'free_shipping' => $result->freeShipping, + ]; + } + + /** + * Presentation data for the cart's lines. + * + * @return list + */ + protected function cartLineData(?Cart $cart): array + { + if ($cart === null) { + return []; + } + + return $cart->lines() + ->with(['variant.product.media', 'variant.optionValues']) + ->get() + ->map(function (CartLine $line): array { + $variant = $line->variant; + $product = $variant?->product; + $media = $product?->media->first(); + + return [ + 'id' => $line->getKey(), + 'title' => $product?->title ?? __('Unavailable product'), + 'variant_label' => $variant?->optionValues->pluck('value')->implode(' / ') ?? '', + 'handle' => $product?->handle, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'line_total_amount' => $line->line_subtotal_amount, + 'image_url' => $media !== null ? Storage::disk('public')->url($media->storage_key) : null, + ]; + }) + ->all(); + } +} diff --git a/app/Livewire/Storefront/Home.php b/app/Livewire/Storefront/Home.php new file mode 100644 index 00000000..4ddf13fc --- /dev/null +++ b/app/Livewire/Storefront/Home.php @@ -0,0 +1,76 @@ +all(); + + return view('livewire.storefront.home', [ + 'settings' => $settings, + 'sections' => $settings['sections'], + 'featuredCollections' => $this->featuredCollections($settings), + 'featuredProducts' => $this->featuredProducts($settings), + ]); + } + + /** + * The collections featured on the home page, configured via the + * featured_collection_handles theme setting (max 4). + * + * @param array $settings + * @return SupportCollection + */ + protected function featuredCollections(array $settings): SupportCollection + { + $handles = $settings['featured_collection_handles']; + + $query = Collection::query() + ->published() + ->with(['products' => fn ($query) => $query->published()->with('media')->limit(1)]); + + $collections = $handles === [] + ? $query->orderBy('title')->limit(4)->get() + : $query->whereIn('handle', $handles)->get() + ->sortBy(fn (Collection $collection): int => (int) array_search($collection->handle, $handles, true)) + ->values(); + + return $collections->take(4); + } + + /** + * The products featured on the home page, sourced from a configured + * collection or falling back to the latest published products. + * + * @param array $settings + * @return SupportCollection + */ + protected function featuredProducts(array $settings): SupportCollection + { + $count = max(4, min(8, (int) $settings['featured_products_count'])); + + $sourceCollection = filled($settings['featured_products_collection_handle']) + ? Collection::query()->published()->where('handle', $settings['featured_products_collection_handle'])->first() + : null; + + $query = $sourceCollection !== null + ? $sourceCollection->products()->published() + : Product::query()->published()->orderByDesc('published_at')->orderByDesc('id'); + + return $query + ->with(['variants.inventoryItem', 'media']) + ->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..245d3b24 --- /dev/null +++ b/app/Livewire/Storefront/Pages/Show.php @@ -0,0 +1,32 @@ +page = Page::query() + ->published() + ->where('handle', $handle) + ->firstOrFail(); + } + + public function render(): View + { + return view('livewire.storefront.pages.show') + ->layout('layouts::storefront', [ + 'metaDescription' => Str::limit(trim(strip_tags((string) $this->page->body_html)), 160, ''), + ]) + ->title($this->page->title); + } +} diff --git a/app/Livewire/Storefront/Products/Show.php b/app/Livewire/Storefront/Products/Show.php new file mode 100644 index 00000000..fcfe2295 --- /dev/null +++ b/app/Livewire/Storefront/Products/Show.php @@ -0,0 +1,174 @@ + Selected option values, keyed by option name. */ + public array $selectedOptions = []; + + public int $quantity = 1; + + public int $activeImageIndex = 0; + + public bool $addedToCart = false; + + public function mount(string $handle, AnalyticsService $analytics): void + { + $this->product = Product::query() + ->published() + ->where('handle', $handle) + ->with(['options.values', 'variants.optionValues', 'variants.inventoryItem', 'media']) + ->firstOrFail(); + + $analytics->track( + app('current_store'), + 'product_view', + [ + 'product_id' => $this->product->getKey(), + 'product_title' => $this->product->title, + 'url' => '/products/'.$this->product->handle, + ], + session()->isStarted() ? session()->getId() : null, + auth('customer')->id(), + ); + + $initialVariant = $this->defaultVariant(); + + foreach ($this->product->options as $option) { + $value = $initialVariant?->optionValues->firstWhere('product_option_id', $option->getKey()); + + $this->selectedOptions[$option->name] = $value->value + ?? $option->values->first()?->value + ?? ''; + } + } + + public function updatedSelectedOptions(): void + { + $this->addedToCart = false; + $this->quantity = 1; + $this->activeImageIndex = 0; + + $variant = $this->selectedVariant(); + + $this->dispatch( + 'variant-changed', + variantId: $variant?->getKey(), + price: $variant?->price_amount, + stock: $variant?->inventoryItem?->availableQuantity(), + ); + } + + /** + * The variant matching every currently selected option value, or the + * default variant for products without options. + */ + public function selectedVariant(): ?ProductVariant + { + if ($this->product->options->isEmpty()) { + return $this->defaultVariant(); + } + + return $this->product->variants->first(function (ProductVariant $variant): bool { + foreach ($this->product->options as $option) { + $variantValue = $variant->optionValues + ->firstWhere('product_option_id', $option->getKey()) + ?->value; + + if ($variantValue !== ($this->selectedOptions[$option->name] ?? null)) { + return false; + } + } + + return true; + }); + } + + /** + * Whether the selected variant can be purchased right now. + */ + public function isPurchasable(): bool + { + $variant = $this->selectedVariant(); + + if ($variant === null) { + return false; + } + + $inventory = $variant->inventoryItem; + + if ($inventory === null) { + return true; + } + + return $inventory->availableQuantity() > 0 || $inventory->policy === InventoryPolicy::Continue; + } + + public function addToCart(CartService $cartService, AnalyticsService $analytics): void + { + $variant = $this->selectedVariant(); + + if ($variant === null || ! $this->isPurchasable()) { + return; + } + + $this->quantity = max(1, $this->quantity); + + $cart = $cartService->getOrCreateForSession(app('current_store'), auth('customer')->user()); + + try { + $cartService->addLine($cart, $variant->getKey(), $this->quantity); + } catch (InsufficientInventoryException) { + $this->addError('quantity', __('Not enough stock available for the requested quantity.')); + + return; + } + + $analytics->track( + app('current_store'), + 'add_to_cart', + [ + 'product_id' => $this->product->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity' => $this->quantity, + 'price_amount' => $variant->price_amount, + ], + session()->isStarted() ? session()->getId() : null, + auth('customer')->id(), + ); + + $this->dispatch('cart-updated', cartId: $cart->getKey(), itemCount: $cart->itemCount()); + + $this->addedToCart = true; + } + + public function render(ThemeSettingsService $themeSettings): View + { + return view('livewire.storefront.products.show', [ + 'selectedVariant' => $this->selectedVariant(), + 'isPurchasable' => $this->isPurchasable(), + 'settings' => $themeSettings->all(), + ])->title($this->product->title); + } + + protected function defaultVariant(): ?ProductVariant + { + return $this->product->variants->firstWhere('is_default', true) + ?? $this->product->variants->first(); + } +} diff --git a/app/Livewire/Storefront/Search/Index.php b/app/Livewire/Storefront/Search/Index.php new file mode 100644 index 00000000..1d1464c8 --- /dev/null +++ b/app/Livewire/Storefront/Search/Index.php @@ -0,0 +1,167 @@ + */ + #[Url(except: [])] + public array $productTypes = []; + + /** @var list */ + #[Url(except: [])] + public array $vendors = []; + + /** + * Whether the next render should log the query (only logged on initial + * load and when the query text changes, not for filter/page updates). + */ + protected bool $shouldLogQuery = false; + + public function mount(): void + { + $this->shouldLogQuery = trim($this->query) !== ''; + } + + /** + * Reset pagination whenever the query, a filter, or the sort changes. + */ + public function updated(string $property): void + { + $watched = ['query', 'sort', 'inStock', 'priceMin', 'priceMax', 'productTypes', 'vendors']; + + if (in_array(str($property)->before('.')->value(), $watched, true)) { + $this->resetPage(); + } + + if ($property === 'query') { + $this->shouldLogQuery = trim($this->query) !== ''; + } + } + + public function clearFilters(): void + { + $this->reset('inStock', 'priceMin', 'priceMax', 'productTypes', 'vendors'); + $this->resetPage(); + } + + public function hasActiveFilters(): bool + { + return $this->inStock + || $this->priceMin !== '' + || $this->priceMax !== '' + || $this->productTypes !== [] + || $this->vendors !== []; + } + + public function render(SearchService $search, ThemeSettingsService $themeSettings): View + { + $perPage = (int) $themeSettings->get('products_per_page', 12); + $products = $this->results($search, $perPage); + $facets = trim($this->query) !== '' + ? $search->facetValues($this->store(), $this->query) + : ['vendors' => [], 'product_types' => []]; + + return view('livewire.storefront.search.index', [ + 'products' => $products, + 'availableProductTypes' => $facets['product_types'], + 'availableVendors' => $facets['vendors'], + 'hasActiveFilters' => $this->hasActiveFilters(), + ])->title(__('Search results')); + } + + /** + * @return LengthAwarePaginator + */ + protected function results(SearchService $search, int $perPage): LengthAwarePaginator + { + if (trim($this->query) === '') { + return new Paginator([], 0, $perPage, 1); + } + + $results = $search->search( + $this->store(), + $this->query, + $this->filters(), + $perPage, + $this->sort, + logQuery: $this->shouldLogQuery, + ); + + $this->shouldLogQuery = false; + + return $results; + } + + /** + * Map UI filter state to SearchService filters. Price inputs are in + * major units (EUR) and converted to minor units. + * + * @return array + */ + protected function filters(): array + { + $filters = []; + + if ($this->vendors !== []) { + $filters['vendors'] = $this->vendors; + } + + if ($this->productTypes !== []) { + $filters['product_types'] = $this->productTypes; + } + + if ($this->inStock) { + $filters['in_stock'] = true; + } + + if ($this->priceMin !== '' && is_numeric($this->priceMin)) { + $filters['price_min'] = (int) round((float) $this->priceMin * 100); + } + + if ($this->priceMax !== '' && is_numeric($this->priceMax)) { + $filters['price_max'] = (int) round((float) $this->priceMax * 100); + } + + return $filters; + } + + protected function store(): Store + { + return app('current_store'); + } +} diff --git a/app/Livewire/Storefront/Search/Modal.php b/app/Livewire/Storefront/Search/Modal.php new file mode 100644 index 00000000..835ed5a8 --- /dev/null +++ b/app/Livewire/Storefront/Search/Modal.php @@ -0,0 +1,78 @@ +query)) >= SearchService::MIN_PREFIX_LENGTH; + + return view('livewire.storefront.search.modal', [ + 'hasQuery' => $hasQuery, + 'products' => $hasQuery ? $this->productSuggestions($search) : [], + 'collections' => $hasQuery ? $this->collectionSuggestions() : [], + 'totalResults' => $hasQuery ? $search->countMatches($this->store(), $this->query) : 0, + ]); + } + + /** + * @return list + */ + protected function productSuggestions(SearchService $search): array + { + return $search->autocomplete($this->store(), $this->query, 5) + ->map(function (Product $product): array { + $variant = $product->variants->firstWhere('is_default', true) ?? $product->variants->first(); + $media = $product->media->first(); + + return [ + 'title' => $product->title, + 'handle' => $product->handle, + 'price_amount' => $variant?->price_amount ?? 0, + 'currency' => $variant?->currency ?? ($this->store()->default_currency ?? 'EUR'), + 'image_url' => $media !== null ? Storage::disk('public')->url($media->storage_key) : null, + ]; + }) + ->all(); + } + + /** + * @return list + */ + protected function collectionSuggestions(): array + { + return Collection::query() + ->published() + ->where('title', 'like', trim($this->query).'%') + ->orderBy('title') + ->limit(5) + ->get() + ->map(fn (Collection $collection): array => [ + 'title' => $collection->title, + 'handle' => $collection->handle, + ]) + ->all(); + } + + protected function store(): Store + { + return app('current_store'); + } +} diff --git a/app/Models/AnalyticsDaily.php b/app/Models/AnalyticsDaily.php new file mode 100644 index 00000000..00625ee1 --- /dev/null +++ b/app/Models/AnalyticsDaily.php @@ -0,0 +1,82 @@ + */ + use HasFactory; + + protected $table = 'analytics_daily'; + + protected $primaryKey = null; + + public $incrementing = false; + + 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. + * + * @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', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * Scope the query to a store and inclusive ISO date range. + * + * @param Builder $query + * @return Builder + */ + public function scopeForStoreBetween(Builder $query, Store $store, string $startDate, string $endDate): Builder + { + return $query + ->where('store_id', $store->getKey()) + ->where('date', '>=', $startDate) + ->where('date', '<=', $endDate) + ->orderBy('date'); + } +} diff --git a/app/Models/AnalyticsEvent.php b/app/Models/AnalyticsEvent.php new file mode 100644 index 00000000..179f8122 --- /dev/null +++ b/app/Models/AnalyticsEvent.php @@ -0,0 +1,54 @@ + */ + use BelongsToStore, HasFactory; + + /** + * Events are append-only and never updated. + */ + public const ?string 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', + 'created_at', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'properties_json' => 'array', + 'occurred_at' => 'datetime', + 'created_at' => 'datetime', + ]; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/App.php b/app/Models/App.php new file mode 100644 index 00000000..ab0d66fd --- /dev/null +++ b/app/Models/App.php @@ -0,0 +1,65 @@ + */ + use HasFactory; + + /** + * Explicit table name to keep the model unambiguous despite the + * collision-prone class name. + */ + protected $table = 'apps'; + + /** + * The apps table only tracks a creation timestamp. + */ + public const ?string UPDATED_AT = null; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'name', + 'status', + 'created_at', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'status' => AppStatus::class, + 'created_at' => 'datetime', + ]; + } + + public function installations(): HasMany + { + return $this->hasMany(AppInstallation::class); + } + + 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..640b4a9c --- /dev/null +++ b/app/Models/AppInstallation.php @@ -0,0 +1,67 @@ + */ + use HasFactory; + + /** + * The table only tracks an installation timestamp. + */ + 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', + 'status' => AppInstallationStatus::class, + 'installed_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function app(): BelongsTo + { + return $this->belongsTo(App::class); + } + + public function webhookSubscriptions(): HasMany + { + return $this->hasMany(WebhookSubscription::class); + } + + 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..6a01f3b4 --- /dev/null +++ b/app/Models/Cart.php @@ -0,0 +1,107 @@ + */ + 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 [ + 'cart_version' => 'integer', + 'status' => CartStatus::class, + ]; + } + + /** + * Scope the query to active carts. + * + * @param Builder $query + * @return Builder + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('status', CartStatus::Active); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function lines(): HasMany + { + return $this->hasMany(CartLine::class); + } + + public function checkouts(): HasMany + { + return $this->hasMany(Checkout::class); + } + + /** + * Sum of all line subtotals in minor units. + */ + public function subtotalAmount(): 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'); + } + + /** + * Whether any line's variant requires physical shipping. + */ + public function requiresShipping(): bool + { + return $this->lines() + ->whereHas('variant', fn (Builder $query) => $query->where('requires_shipping', true)) + ->exists(); + } + + /** + * Total shippable weight in grams across all lines. + */ + public function totalWeightGrams(): int + { + return $this->lines() + ->with('variant') + ->get() + ->filter(fn (CartLine $line): bool => (bool) $line->variant?->requires_shipping) + ->sum(fn (CartLine $line): int => (int) ($line->variant->weight_g ?? 0) * $line->quantity); + } +} diff --git a/app/Models/CartLine.php b/app/Models/CartLine.php new file mode 100644 index 00000000..31db55f3 --- /dev/null +++ b/app/Models/CartLine.php @@ -0,0 +1,68 @@ + */ + use HasFactory; + + /** + * The cart_lines table has no timestamp columns. + */ + 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', + ]; + } + + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + /** + * Recalculate the derived amounts from quantity and unit price. + */ + public function recalculateAmounts(): void + { + $this->line_subtotal_amount = $this->unit_price_amount * $this->quantity; + $this->line_total_amount = $this->line_subtotal_amount - $this->line_discount_amount; + } +} diff --git a/app/Models/Checkout.php b/app/Models/Checkout.php new file mode 100644 index 00000000..a67b321b --- /dev/null +++ b/app/Models/Checkout.php @@ -0,0 +1,69 @@ + */ + 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, + 'shipping_address_json' => 'array', + 'billing_address_json' => 'array', + 'shipping_method_id' => 'integer', + 'tax_provider_snapshot_json' => 'array', + 'totals_json' => 'array', + 'expires_at' => 'datetime', + ]; + } + + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function shippingRate(): BelongsTo + { + return $this->belongsTo(ShippingRate::class, 'shipping_method_id'); + } +} diff --git a/app/Models/Collection.php b/app/Models/Collection.php new file mode 100644 index 00000000..3d5eaed7 --- /dev/null +++ b/app/Models/Collection.php @@ -0,0 +1,60 @@ + */ + 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 [ + 'status' => CollectionStatus::class, + ]; + } + + /** + * Scope the query to published (active) collections. + * + * @param Builder $query + * @return Builder + */ + public function scopePublished(Builder $query): Builder + { + return $query->where('status', CollectionStatus::Active); + } + + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'collection_products') + ->withPivot('position') + ->orderByPivot('position'); + } +} diff --git a/app/Models/Concerns/BelongsToStore.php b/app/Models/Concerns/BelongsToStore.php new file mode 100644 index 00000000..80f85653 --- /dev/null +++ b/app/Models/Concerns/BelongsToStore.php @@ -0,0 +1,30 @@ +getAttribute('store_id') === null && app()->bound('current_store')) { + $model->setAttribute('store_id', app('current_store')->getKey()); + } + }); + } + + 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..0534c42d --- /dev/null +++ b/app/Models/Customer.php @@ -0,0 +1,89 @@ + */ + use BelongsToStore, HasFactory, Notifiable; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'email', + 'password_hash', + 'name', + 'marketing_opt_in', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var list + */ + protected $hidden = [ + 'password_hash', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'marketing_opt_in' => 'boolean', + 'password_hash' => 'hashed', + ]; + } + + /** + * Get the name of the password attribute for the customer. + */ + public function getAuthPasswordName(): string + { + return 'password_hash'; + } + + /** + * Get the password for the customer. + */ + public function getAuthPassword(): ?string + { + return $this->password_hash; + } + + /** + * The customers table has no remember_token column, so remember-me is disabled. + */ + public function getRememberTokenName(): string + { + return ''; + } + + public function addresses(): HasMany + { + return $this->hasMany(CustomerAddress::class); + } + + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + 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..4e87d3e6 --- /dev/null +++ b/app/Models/CustomerAddress.php @@ -0,0 +1,87 @@ + */ + use HasFactory; + + /** + * Indicates if the model should be timestamped. + * + * @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', + ]; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** + * Map the stored address JSON (spec 01 shape, "zip" key) to the checkout + * shipping address shape ("postal_code" key) used by the address form. + * + * @return array + */ + public function toCheckoutAddress(): array + { + $address = $this->address_json ?? []; + + return array_filter([ + 'first_name' => (string) ($address['first_name'] ?? ''), + 'last_name' => (string) ($address['last_name'] ?? ''), + 'address1' => (string) ($address['address1'] ?? ''), + 'address2' => (string) ($address['address2'] ?? ''), + 'city' => (string) ($address['city'] ?? ''), + 'province' => (string) ($address['province'] ?? ''), + 'postal_code' => (string) ($address['zip'] ?? ''), + 'country_code' => (string) ($address['country_code'] ?? ''), + 'phone' => (string) ($address['phone'] ?? ''), + ], fn (string $value): bool => $value !== ''); + } + + /** + * One-line summary for saved-address pickers, e.g. + * "Jane Doe, Musterstrasse 1, 10115 Berlin". + */ + public function summaryLine(): string + { + $address = $this->address_json ?? []; + + $name = trim(($address['first_name'] ?? '').' '.($address['last_name'] ?? '')); + $cityLine = trim(($address['zip'] ?? '').' '.($address['city'] ?? '')); + + return implode(', ', array_filter([$name, $address['address1'] ?? '', $cityLine])); + } +} diff --git a/app/Models/Discount.php b/app/Models/Discount.php new file mode 100644 index 00000000..5febd271 --- /dev/null +++ b/app/Models/Discount.php @@ -0,0 +1,65 @@ + */ + 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, + 'value_amount' => 'integer', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'usage_limit' => 'integer', + 'usage_count' => 'integer', + 'rules_json' => 'array', + 'status' => DiscountStatus::class, + ]; + } + + /** + * The minimum purchase amount rule in minor units, or null when unset. + */ + public function minimumPurchaseAmount(): ?int + { + $minimum = $this->rules_json['min_purchase_amount'] ?? null; + + return $minimum === null ? null : (int) $minimum; + } +} diff --git a/app/Models/Fulfillment.php b/app/Models/Fulfillment.php new file mode 100644 index 00000000..bde568ac --- /dev/null +++ b/app/Models/Fulfillment.php @@ -0,0 +1,59 @@ + */ + use HasFactory; + + /** + * The fulfillments table only has a created_at timestamp. + */ + public const ?string UPDATED_AT = null; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'order_id', + 'status', + 'tracking_company', + 'tracking_number', + 'tracking_url', + 'shipped_at', + 'delivered_at', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'status' => FulfillmentShipmentStatus::class, + 'shipped_at' => 'datetime', + 'delivered_at' => 'datetime', + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + 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..c72baaac --- /dev/null +++ b/app/Models/FulfillmentLine.php @@ -0,0 +1,51 @@ + */ + use HasFactory; + + /** + * The fulfillment_lines table has no timestamp columns. + */ + 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', + ]; + } + + public function fulfillment(): BelongsTo + { + return $this->belongsTo(Fulfillment::class); + } + + 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..9828b233 --- /dev/null +++ b/app/Models/InventoryItem.php @@ -0,0 +1,60 @@ + */ + use BelongsToStore, HasFactory; + + /** + * The inventory_items table has no timestamp columns. + */ + 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 [ + 'quantity_on_hand' => 'integer', + 'quantity_reserved' => 'integer', + 'policy' => InventoryPolicy::class, + ]; + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + /** + * Available stock: on hand minus reserved. + */ + public function availableQuantity(): 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..d97bb5cf --- /dev/null +++ b/app/Models/NavigationItem.php @@ -0,0 +1,52 @@ + */ + use HasFactory; + + /** + * The navigation_items table has no timestamp columns. + */ + 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, + 'resource_id' => 'integer', + 'position' => 'integer', + ]; + } + + public function menu(): BelongsTo + { + return $this->belongsTo(NavigationMenu::class, 'menu_id'); + } +} diff --git a/app/Models/NavigationMenu.php b/app/Models/NavigationMenu.php new file mode 100644 index 00000000..a9bddb97 --- /dev/null +++ b/app/Models/NavigationMenu.php @@ -0,0 +1,30 @@ + */ + use BelongsToStore, HasFactory; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'handle', + 'title', + ]; + + 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..4b2ec536 --- /dev/null +++ b/app/Models/OauthClient.php @@ -0,0 +1,50 @@ + */ + use HasFactory; + + 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', + ]; + } + + 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..4322b305 --- /dev/null +++ b/app/Models/OauthToken.php @@ -0,0 +1,49 @@ + */ + use HasFactory; + + 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', + ]; + } + + 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..11115d07 --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,133 @@ + */ + use BelongsToStore, HasFactory; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'customer_id', + 'checkout_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 [ + 'payment_method' => PaymentMethod::class, + 'status' => OrderStatus::class, + 'financial_status' => FinancialStatus::class, + 'fulfillment_status' => FulfillmentStatus::class, + 'subtotal_amount' => 'integer', + 'discount_amount' => 'integer', + 'shipping_amount' => 'integer', + 'tax_amount' => 'integer', + 'total_amount' => 'integer', + 'billing_address_json' => 'array', + 'shipping_address_json' => 'array', + 'placed_at' => 'datetime', + ]; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function checkout(): BelongsTo + { + return $this->belongsTo(Checkout::class); + } + + public function lines(): HasMany + { + return $this->hasMany(OrderLine::class); + } + + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } + + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } + + public function fulfillments(): HasMany + { + return $this->hasMany(Fulfillment::class); + } + + /** + * Total amount already refunded (processed refunds only) in minor units. + */ + public function refundedAmount(): int + { + return (int) $this->refunds() + ->where('status', RefundStatus::Processed) + ->sum('amount'); + } + + /** + * Remaining amount that may still be refunded in minor units. + */ + public function remainingRefundableAmount(): int + { + return max(0, $this->total_amount - $this->refundedAmount()); + } + + /** + * Whether every order line references a digital (non-shippable) variant. + */ + public function isFullyDigital(): bool + { + $lines = $this->lines()->with('variant')->get(); + + if ($lines->isEmpty()) { + return false; + } + + return $lines->every( + fn (OrderLine $line): bool => $line->variant !== null && ! $line->variant->requires_shipping, + ); + } +} diff --git a/app/Models/OrderLine.php b/app/Models/OrderLine.php new file mode 100644 index 00000000..699fb5a5 --- /dev/null +++ b/app/Models/OrderLine.php @@ -0,0 +1,89 @@ + */ + use HasFactory; + + /** + * The order_lines table has no timestamp columns. + */ + 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 [ + 'quantity' => 'integer', + 'unit_price_amount' => 'integer', + 'total_amount' => 'integer', + 'tax_lines_json' => 'array', + 'discount_allocations_json' => 'array', + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + public function fulfillmentLines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } + + /** + * Units of this line already included in fulfillments. + */ + public function fulfilledQuantity(): int + { + return (int) $this->fulfillmentLines()->sum('quantity'); + } + + /** + * Units of this line not yet fulfilled. + */ + public function unfulfilledQuantity(): int + { + return max(0, $this->quantity - $this->fulfilledQuantity()); + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php new file mode 100644 index 00000000..5b84828e --- /dev/null +++ b/app/Models/Organization.php @@ -0,0 +1,28 @@ + */ + use HasFactory; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'name', + 'billing_email', + ]; + + 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..a9e20edd --- /dev/null +++ b/app/Models/Page.php @@ -0,0 +1,53 @@ + */ + 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', + ]; + } + + /** + * Scope the query to published pages. + * + * @param Builder $query + * @return Builder + */ + public function scopePublished(Builder $query): Builder + { + return $query->where('status', PageStatus::Published); + } +} diff --git a/app/Models/Payment.php b/app/Models/Payment.php new file mode 100644 index 00000000..3d9b1b7c --- /dev/null +++ b/app/Models/Payment.php @@ -0,0 +1,62 @@ + */ + use HasFactory; + + /** + * The payments table only has a created_at timestamp. + */ + public const ?string 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', + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + 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..7c71a1cf --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,85 @@ + */ + 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', + ]; + } + + /** + * Scope the query to published (active) products. + * + * @param Builder $query + * @return Builder + */ + public function scopePublished(Builder $query): Builder + { + return $query->where('status', ProductStatus::Active); + } + + public function options(): HasMany + { + return $this->hasMany(ProductOption::class)->orderBy('position'); + } + + public function variants(): HasMany + { + return $this->hasMany(ProductVariant::class)->orderBy('position'); + } + + public function media(): HasMany + { + return $this->hasMany(ProductMedia::class)->orderBy('position'); + } + + public function collections(): BelongsToMany + { + return $this->belongsToMany(Collection::class, 'collection_products') + ->withPivot('position'); + } + + public function defaultVariant(): ?ProductVariant + { + return $this->variants()->where('is_default', true)->first(); + } +} diff --git a/app/Models/ProductMedia.php b/app/Models/ProductMedia.php new file mode 100644 index 00000000..3fda28ad --- /dev/null +++ b/app/Models/ProductMedia.php @@ -0,0 +1,82 @@ + */ + use HasFactory; + + /** + * The product_media table has no updated_at column. + */ + public const ?string UPDATED_AT = null; + + /** + * The table associated with the model. + */ + protected $table = 'product_media'; + + /** + * 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, + 'width' => 'integer', + 'height' => 'integer', + 'byte_size' => 'integer', + 'position' => 'integer', + ]; + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * Storage key for a derived (resized) version of this media file, e.g. + * "media/products/1/photo.jpg" with size "thumbnail" becomes + * "media/products/1/photo_thumbnail.jpg". + */ + public function derivedStorageKey(string $size): string + { + $directory = pathinfo($this->storage_key, PATHINFO_DIRNAME); + $filename = pathinfo($this->storage_key, PATHINFO_FILENAME); + $extension = pathinfo($this->storage_key, PATHINFO_EXTENSION); + + $prefix = $directory === '.' ? '' : "{$directory}/"; + $suffix = $extension === '' ? '' : ".{$extension}"; + + return "{$prefix}{$filename}_{$size}{$suffix}"; + } +} diff --git a/app/Models/ProductOption.php b/app/Models/ProductOption.php new file mode 100644 index 00000000..427d0eb9 --- /dev/null +++ b/app/Models/ProductOption.php @@ -0,0 +1,52 @@ + */ + use HasFactory; + + /** + * The product_options table has no timestamp columns. + */ + public $timestamps = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'product_id', + 'name', + 'position', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'position' => 'integer', + ]; + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + 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..361496e9 --- /dev/null +++ b/app/Models/ProductOptionValue.php @@ -0,0 +1,46 @@ + */ + use HasFactory; + + /** + * The product_option_values table has no timestamp columns. + */ + public $timestamps = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'product_option_id', + 'value', + 'position', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'position' => 'integer', + ]; + } + + 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..dccf4cf6 --- /dev/null +++ b/app/Models/ProductVariant.php @@ -0,0 +1,73 @@ + */ + 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 [ + 'price_amount' => 'integer', + 'compare_at_amount' => 'integer', + 'weight_g' => 'integer', + 'requires_shipping' => 'boolean', + 'is_default' => 'boolean', + 'position' => 'integer', + 'status' => VariantStatus::class, + ]; + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function inventoryItem(): HasOne + { + return $this->hasOne(InventoryItem::class, 'variant_id'); + } + + public function optionValues(): BelongsToMany + { + return $this->belongsToMany( + ProductOptionValue::class, + 'variant_option_values', + 'variant_id', + 'product_option_value_id', + ); + } +} diff --git a/app/Models/Refund.php b/app/Models/Refund.php new file mode 100644 index 00000000..aa7058d1 --- /dev/null +++ b/app/Models/Refund.php @@ -0,0 +1,56 @@ + */ + use HasFactory; + + /** + * The refunds table only has a created_at timestamp. + */ + public const ?string 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 [ + 'amount' => 'integer', + 'status' => RefundStatus::class, + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + 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..f891f0bf --- /dev/null +++ b/app/Models/Scopes/StoreScope.php @@ -0,0 +1,20 @@ +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..d170eacd --- /dev/null +++ b/app/Models/SearchQuery.php @@ -0,0 +1,45 @@ + */ + use BelongsToStore, HasFactory; + + /** + * Log rows are append-only and never updated. + */ + public const ?string UPDATED_AT = null; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'store_id', + 'query', + 'filters_json', + 'results_count', + 'created_at', + ]; + + /** + * 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..ea873cbe --- /dev/null +++ b/app/Models/SearchSettings.php @@ -0,0 +1,76 @@ + */ + use HasFactory; + + /** + * The table only carries an updated_at timestamp. + */ + public const ?string CREATED_AT = null; + + /** + * The table is keyed by store_id (one-to-one with stores). + */ + protected $table = 'search_settings'; + + protected $primaryKey = 'store_id'; + + 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', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * Synonym groups, each a list of equivalent terms. + * + * @return list> + */ + public function synonymGroups(): array + { + return $this->synonyms_json ?? []; + } + + /** + * Words excluded from search queries. + * + * @return list + */ + public function stopWords(): array + { + return $this->stop_words_json ?? []; + } +} diff --git a/app/Models/ShippingRate.php b/app/Models/ShippingRate.php new file mode 100644 index 00000000..608b5996 --- /dev/null +++ b/app/Models/ShippingRate.php @@ -0,0 +1,51 @@ + */ + use HasFactory; + + /** + * The shipping_rates table has no timestamp columns. + */ + 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', + ]; + } + + 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..dfd7acd3 --- /dev/null +++ b/app/Models/ShippingZone.php @@ -0,0 +1,49 @@ + */ + use BelongsToStore, HasFactory; + + /** + * The shipping_zones table has no timestamp columns. + */ + 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', + ]; + } + + 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..199e01fb --- /dev/null +++ b/app/Models/Store.php @@ -0,0 +1,71 @@ + */ + 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, + ]; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } + + public function domains(): HasMany + { + return $this->hasMany(StoreDomain::class); + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'store_users') + ->using(StoreUser::class) + ->withPivot('role'); + } + + public function settings(): HasOne + { + return $this->hasOne(StoreSettings::class); + } + + public function isSuspended(): bool + { + return $this->status === StoreStatus::Suspended; + } +} diff --git a/app/Models/StoreDomain.php b/app/Models/StoreDomain.php new file mode 100644 index 00000000..349ba13f --- /dev/null +++ b/app/Models/StoreDomain.php @@ -0,0 +1,47 @@ + */ + use HasFactory; + + 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', + ]; + } + + 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..d31f1292 --- /dev/null +++ b/app/Models/StoreSettings.php @@ -0,0 +1,63 @@ + */ + use HasFactory; + + public const CREATED_AT = null; + + /** + * The table associated with the model. + * + * @var string + */ + protected $table = 'store_settings'; + + /** + * The primary key associated with the table. + * + * @var string + */ + protected $primaryKey = 'store_id'; + + /** + * Indicates if the IDs are 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', + ]; + } + + 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..6de02397 --- /dev/null +++ b/app/Models/StoreUser.php @@ -0,0 +1,52 @@ + + */ + protected $fillable = [ + 'store_id', + 'user_id', + 'role', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'role' => StoreUserRole::class, + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/TaxSettings.php b/app/Models/TaxSettings.php new file mode 100644 index 00000000..c5f8027c --- /dev/null +++ b/app/Models/TaxSettings.php @@ -0,0 +1,81 @@ + */ + use HasFactory; + + /** + * The table is keyed by store_id (one-to-one with stores). + */ + protected $table = 'tax_settings'; + + protected $primaryKey = 'store_id'; + + public $incrementing = false; + + public $timestamps = 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', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * The configured manual tax rate in basis points (1900 = 19%). + */ + public function defaultRateBasisPoints(): int + { + return (int) ($this->config_json['default_rate_bps'] ?? 0); + } + + /** + * Whether shipping is subject to tax (defaults to true). + */ + public function shippingTaxable(): bool + { + return (bool) ($this->config_json['shipping_taxable'] ?? true); + } + + /** + * Display name for the tax line. + */ + public function taxName(): string + { + return (string) ($this->config_json['tax_name'] ?? 'Tax'); + } +} diff --git a/app/Models/Theme.php b/app/Models/Theme.php new file mode 100644 index 00000000..ad85977e --- /dev/null +++ b/app/Models/Theme.php @@ -0,0 +1,52 @@ + */ + 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', + ]; + } + + public function files(): HasMany + { + return $this->hasMany(ThemeFile::class); + } + + public function settings(): HasOne + { + return $this->hasOne(ThemeSettings::class); + } +} diff --git a/app/Models/ThemeFile.php b/app/Models/ThemeFile.php new file mode 100644 index 00000000..763bb739 --- /dev/null +++ b/app/Models/ThemeFile.php @@ -0,0 +1,48 @@ + */ + use HasFactory; + + /** + * The theme_files table has no timestamp columns. + */ + public $timestamps = false; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'theme_id', + 'path', + 'storage_key', + 'sha256', + 'byte_size', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'byte_size' => 'integer', + ]; + } + + 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..61be5c36 --- /dev/null +++ b/app/Models/ThemeSettings.php @@ -0,0 +1,75 @@ + */ + use HasFactory; + + /** + * The table associated with the model. + */ + protected $table = 'theme_settings'; + + /** + * The primary key is the owning theme's id (one-to-one). + */ + protected $primaryKey = 'theme_id'; + + public $incrementing = false; + + /** + * The theme_settings table only has an updated_at column. + */ + public const ?string 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', + ]; + } + + /** + * Invalidate the cached theme settings for the owning store on write. + */ + protected static function booted(): void + { + $invalidate = function (ThemeSettings $settings): void { + $storeId = $settings->theme()->withoutGlobalScopes()->value('store_id'); + + if ($storeId !== null) { + app(ThemeSettingsService::class)->forget((int) $storeId); + } + }; + + static::saved($invalidate); + static::deleted($invalidate); + } + + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 214bea4e..7b209112 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\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 { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable, TwoFactorAuthenticatable; + use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; /** * The attributes that are mass assignable. @@ -22,7 +24,9 @@ class User extends Authenticatable protected $fillable = [ 'name', 'email', - 'password', + 'password_hash', + 'status', + 'last_login_at', ]; /** @@ -31,7 +35,7 @@ class User extends Authenticatable * @var list */ protected $hidden = [ - 'password', + 'password_hash', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token', @@ -46,10 +50,46 @@ protected function casts(): array { return [ 'email_verified_at' => 'datetime', - 'password' => 'hashed', + 'last_login_at' => 'datetime', + 'password_hash' => 'hashed', ]; } + /** + * Get the name of the password attribute for the user. + */ + public function getAuthPasswordName(): string + { + return 'password_hash'; + } + + /** + * Get the password for the user. + */ + public function getAuthPassword(): string + { + return $this->password_hash; + } + + public function stores(): BelongsToMany + { + return $this->belongsToMany(Store::class, 'store_users') + ->using(StoreUser::class) + ->withPivot('role'); + } + + /** + * Get the user's role for the given store, or null when not a member. + */ + public function roleForStore(Store $store): ?StoreUserRole + { + return StoreUser::query() + ->where('store_id', $store->getKey()) + ->where('user_id', $this->getKey()) + ->first() + ?->role; + } + /** * Get the user's initials */ diff --git a/app/Models/WebhookDelivery.php b/app/Models/WebhookDelivery.php new file mode 100644 index 00000000..dbfbc7bd --- /dev/null +++ b/app/Models/WebhookDelivery.php @@ -0,0 +1,51 @@ + */ + use HasFactory; + + 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 [ + 'attempt_count' => 'integer', + 'status' => WebhookDeliveryStatus::class, + 'last_attempt_at' => 'datetime', + 'response_code' => 'integer', + ]; + } + + 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..93342583 --- /dev/null +++ b/app/Models/WebhookSubscription.php @@ -0,0 +1,63 @@ + */ + use BelongsToStore, HasFactory; + + 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', + 'consecutive_failures', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'signing_secret_encrypted' => 'encrypted', + 'status' => WebhookSubscriptionStatus::class, + 'consecutive_failures' => 'integer', + ]; + } + + public function appInstallation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class); + } + + public function deliveries(): HasMany + { + return $this->hasMany(WebhookDelivery::class, 'subscription_id'); + } + + public function latestDelivery(): HasOne + { + return $this->hasOne(WebhookDelivery::class, 'subscription_id')->ofMany('id', 'max'); + } +} diff --git a/app/Observers/ProductObserver.php b/app/Observers/ProductObserver.php new file mode 100644 index 00000000..f62b1216 --- /dev/null +++ b/app/Observers/ProductObserver.php @@ -0,0 +1,64 @@ +search->syncProduct($product); + + $this->webhooks->dispatch($product->store, 'product.created', $this->productPayload($product)); + } + + public function updated(Product $product): void + { + $this->search->syncProduct($product); + + $eventType = $product->wasChanged('status') && $product->status === ProductStatus::Archived + ? 'product.deleted' + : 'product.updated'; + + $this->webhooks->dispatch($product->store, $eventType, $this->productPayload($product)); + } + + public function deleted(Product $product): void + { + $this->search->removeProduct($product->getKey()); + + $this->webhooks->dispatch($product->store, 'product.deleted', $this->productPayload($product)); + } + + /** + * @return array + */ + protected function productPayload(Product $product): array + { + return [ + 'id' => $product->getKey(), + 'title' => $product->title, + 'handle' => $product->handle, + 'status' => $product->status?->value, + 'vendor' => $product->vendor, + 'product_type' => $product->product_type, + 'tags' => $product->tags ?? [], + 'published_at' => $product->published_at?->toIso8601String(), + ]; + } +} diff --git a/app/Policies/CollectionPolicy.php b/app/Policies/CollectionPolicy.php new file mode 100644 index 00000000..f14be3e9 --- /dev/null +++ b/app/Policies/CollectionPolicy.php @@ -0,0 +1,37 @@ +isAnyRole($user, $this->currentStoreId()); + } + + public function view(User $user, Collection $collection): bool + { + return $this->isAnyRole($user, $collection->store_id); + } + + public function create(User $user): bool + { + return $this->isOwnerAdminOrStaff($user, $this->currentStoreId()); + } + + 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..a4fb02dd --- /dev/null +++ b/app/Policies/CustomerPolicy.php @@ -0,0 +1,27 @@ +isAnyRole($user, $this->currentStoreId()); + } + + 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..42b66f98 --- /dev/null +++ b/app/Policies/DiscountPolicy.php @@ -0,0 +1,37 @@ +isAnyRole($user, $this->currentStoreId()); + } + + public function view(User $user, Discount $discount): bool + { + return $this->isAnyRole($user, $discount->store_id); + } + + public function create(User $user): bool + { + return $this->isOwnerAdminOrStaff($user, $this->currentStoreId()); + } + + 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..316d792d --- /dev/null +++ b/app/Policies/FulfillmentPolicy.php @@ -0,0 +1,28 @@ +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..b7bbd5e8 --- /dev/null +++ b/app/Policies/NavigationMenuPolicy.php @@ -0,0 +1,27 @@ +isOwnerAdminOrStaff($user, $this->currentStoreId()); + } + + public function view(User $user, NavigationMenu $menu): bool + { + return $this->isOwnerAdminOrStaff($user, $menu->store_id); + } + + public function update(User $user, NavigationMenu $menu): bool + { + return $this->isOwnerOrAdmin($user, $menu->store_id); + } +} diff --git a/app/Policies/OrderPolicy.php b/app/Policies/OrderPolicy.php new file mode 100644 index 00000000..ea51645e --- /dev/null +++ b/app/Policies/OrderPolicy.php @@ -0,0 +1,42 @@ +isAnyRole($user, $this->currentStoreId()); + } + + 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..bae6d766 --- /dev/null +++ b/app/Policies/PagePolicy.php @@ -0,0 +1,37 @@ +isOwnerAdminOrStaff($user, $this->currentStoreId()); + } + + public function view(User $user, Page $page): bool + { + return $this->isOwnerAdminOrStaff($user, $page->store_id); + } + + public function create(User $user): bool + { + return $this->isOwnerAdminOrStaff($user, $this->currentStoreId()); + } + + 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..a9e74c56 --- /dev/null +++ b/app/Policies/ProductPolicy.php @@ -0,0 +1,47 @@ +isAnyRole($user, $this->currentStoreId()); + } + + public function view(User $user, Product $product): bool + { + return $this->isAnyRole($user, $product->store_id); + } + + public function create(User $user): bool + { + return $this->isOwnerAdminOrStaff($user, $this->currentStoreId()); + } + + 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..c922d5b2 --- /dev/null +++ b/app/Policies/RefundPolicy.php @@ -0,0 +1,17 @@ +isOwnerOrAdmin($user, $order->store_id); + } +} diff --git a/app/Policies/StorePolicy.php b/app/Policies/StorePolicy.php new file mode 100644 index 00000000..649e0d44 --- /dev/null +++ b/app/Policies/StorePolicy.php @@ -0,0 +1,54 @@ +isOwnerOrAdmin($user, $store->getKey()); + } + + /** + * View analytics (spec 05 section 2 role matrix: Owner, Admin, and + * Staff may view analytics; Support may not). + */ + public function viewAnalytics(User $user, Store $store): bool + { + return $this->isOwnerAdminOrStaff($user, $store->getKey()); + } + + public function updateSettings(User $user, Store $store): bool + { + return $this->isOwnerOrAdmin($user, $store->getKey()); + } + + /** + * Create and revoke API tokens (spec 06 section 2.3: manage-developers + * is granted to Owner and Admin). + */ + public function manageDevelopers(User $user, Store $store): bool + { + return $this->isOwnerOrAdmin($user, $store->getKey()); + } + + /** + * Install and uninstall apps (spec 05 section 1.3 role matrix: manage + * apps is granted to Owner and Admin). + */ + public function manageApps(User $user, Store $store): bool + { + return $this->isOwnerOrAdmin($user, $store->getKey()); + } + + public function delete(User $user, Store $store): bool + { + return $this->isOwner($user, $store->getKey()); + } +} diff --git a/app/Policies/ThemePolicy.php b/app/Policies/ThemePolicy.php new file mode 100644 index 00000000..ee1ec285 --- /dev/null +++ b/app/Policies/ThemePolicy.php @@ -0,0 +1,42 @@ +isOwnerOrAdmin($user, $this->currentStoreId()); + } + + public function view(User $user, Theme $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } + + public function create(User $user): bool + { + return $this->isOwnerOrAdmin($user, $this->currentStoreId()); + } + + 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..13c8d31a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,11 +2,26 @@ namespace App\Providers; +use App\Auth\CustomerUserProvider; +use App\Contracts\PaymentProvider; +use App\Http\Middleware\ResolveStore; +use App\Models\Product; +use App\Observers\ProductObserver; +use App\Services\NavigationService; +use App\Services\Payments\MockPaymentProvider; +use App\Services\ThemeSettingsService; use Carbon\CarbonImmutable; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; +use Laravel\Sanctum\PersonalAccessToken; +use Livewire\Livewire; class AppServiceProvider extends ServiceProvider { @@ -15,7 +30,9 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->singleton(ThemeSettingsService::class); + $this->app->singleton(NavigationService::class); + $this->app->bind(PaymentProvider::class, MockPaymentProvider::class); } /** @@ -24,6 +41,31 @@ public function register(): void public function boot(): void { $this->configureDefaults(); + $this->configureAuth(); + $this->configureRateLimiting(); + $this->configureLivewire(); + $this->configureObservers(); + } + + /** + * Model observers: products are mirrored into the FTS5 search index + * (spec 05 section 16.2). + */ + protected function configureObservers(): void + { + Product::observe(ProductObserver::class); + } + + /** + * Keep the current store resolved on Livewire update requests so that + * store-scoped queries inside interactive storefront components stay + * tenant-isolated between page loads. + */ + protected function configureLivewire(): void + { + Livewire::addPersistentMiddleware([ + ResolveStore::class, + ]); } /** @@ -47,4 +89,71 @@ protected function configureDefaults(): void : null ); } + + /** + * Register the store-scoped customer user provider. + */ + protected function configureAuth(): void + { + Auth::provider('customer-eloquent', function ($app, array $config): CustomerUserProvider { + return new CustomerUserProvider($app['hash'], $config['model']); + }); + } + + /** + * Register the application's rate limiters (spec 02 section 7). + */ + protected function configureRateLimiting(): void + { + RateLimiter::for('login', function (Request $request): Limit { + return Limit::perMinute(5)->by($request->ip()); + }); + + RateLimiter::for('api.admin', function (Request $request): Limit { + $token = $request->user()?->currentAccessToken(); + + $key = $token instanceof PersonalAccessToken + ? 'token:'.$token->getKey() + : $request->ip(); + + return Limit::perMinute(60)->by($key)->response($this->rateLimitResponse(...)); + }); + + RateLimiter::for('api.storefront', function (Request $request): Limit { + return Limit::perMinute(120)->by($request->ip())->response($this->rateLimitResponse(...)); + }); + + RateLimiter::for('checkout', function (Request $request): Limit { + $key = $request->hasSession() && $request->session()->isStarted() + ? 'session:'.$request->session()->getId() + : $request->ip(); + + return Limit::perMinute(10)->by($key)->response($this->rateLimitResponse(...)); + }); + + RateLimiter::for('search', function (Request $request): Limit { + return Limit::perMinute(30)->by($request->ip())->response($this->rateLimitResponse(...)); + }); + + RateLimiter::for('analytics', function (Request $request): Limit { + return Limit::perMinute(60)->by($request->ip())->response($this->rateLimitResponse(...)); + }); + + RateLimiter::for('webhooks', function (Request $request): Limit { + return Limit::perMinute(100)->by($request->ip())->response($this->rateLimitResponse(...)); + }); + } + + /** + * The 429 response body required by spec 02 section 7. + * + * @param array $headers + */ + protected function rateLimitResponse(Request $request, array $headers): JsonResponse + { + return response()->json([ + 'message' => __('Too many requests. Please try again later.'), + 'retry_after' => (int) ($headers['Retry-After'] ?? 60), + ], 429, $headers); + } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 44e57aa0..b2ae8056 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -8,7 +8,6 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; -use Illuminate\Support\Str; use Laravel\Fortify\Fortify; class FortifyServiceProvider extends ServiceProvider @@ -18,7 +17,7 @@ class FortifyServiceProvider extends ServiceProvider */ public function register(): void { - // + Fortify::ignoreRoutes(); } /** @@ -27,7 +26,6 @@ public function register(): void public function boot(): void { $this->configureActions(); - $this->configureViews(); $this->configureRateLimiting(); } @@ -40,33 +38,13 @@ private function configureActions(): void 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) { + RateLimiter::for('two-factor', function (Request $request): Limit { 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..672a5f7f --- /dev/null +++ b/app/Services/AnalyticsService.php @@ -0,0 +1,113 @@ + + */ + public const array EVENT_TYPES = [ + 'page_view', + 'product_view', + 'add_to_cart', + 'remove_from_cart', + 'checkout_started', + 'checkout_completed', + 'search', + ]; + + /** + * Record a raw analytics event. Events carrying a client_event_id that + * was already recorded for the store are silently dropped (deduplication + * via the unique index on store_id + client_event_id). + * + * @param array $properties + */ + public function track( + Store $store, + string $type, + array $properties = [], + ?string $sessionId = null, + ?int $customerId = null, + ?string $clientEventId = null, + ?string $occurredAt = null, + ): ?AnalyticsEvent { + if (! in_array($type, self::EVENT_TYPES, true)) { + return null; + } + + try { + return AnalyticsEvent::query()->create([ + 'store_id' => $store->getKey(), + 'type' => $type, + 'session_id' => $sessionId, + 'customer_id' => $customerId, + 'properties_json' => $properties, + 'client_event_id' => $clientEventId, + 'occurred_at' => $occurredAt ?? now(), + 'created_at' => now(), + ]); + } catch (UniqueConstraintViolationException) { + return null; + } + } + + /** + * Read pre-aggregated daily metrics for an inclusive ISO date range. + * + * @return Collection + */ + public function getDailyMetrics(Store $store, string $startDate, string $endDate): Collection + { + return AnalyticsDaily::query() + ->forStoreBetween($store, $startDate, $endDate) + ->get(); + } + + /** + * Count events per type for a store within a created_at range. Used by + * the conversion funnel visualizations. + * + * @return array + */ + public function eventCountsBetween(Store $store, string $start, string $end): array + { + $counts = AnalyticsEvent::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('created_at', '>=', $start) + ->where('created_at', '<', $end) + ->selectRaw('type, count(*) as total') + ->groupBy('type') + ->pluck('total', 'type'); + + $visits = AnalyticsEvent::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('type', 'page_view') + ->where('created_at', '>=', $start) + ->where('created_at', '<', $end) + ->distinct() + ->count('session_id'); + + return [ + 'visits' => $visits, + 'page_view' => (int) ($counts['page_view'] ?? 0), + 'product_view' => (int) ($counts['product_view'] ?? 0), + 'add_to_cart' => (int) ($counts['add_to_cart'] ?? 0), + 'remove_from_cart' => (int) ($counts['remove_from_cart'] ?? 0), + 'checkout_started' => (int) ($counts['checkout_started'] ?? 0), + 'checkout_completed' => (int) ($counts['checkout_completed'] ?? 0), + 'search' => (int) ($counts['search'] ?? 0), + ]; + } +} diff --git a/app/Services/CartService.php b/app/Services/CartService.php new file mode 100644 index 00000000..5cb8d023 --- /dev/null +++ b/app/Services/CartService.php @@ -0,0 +1,305 @@ +create([ + 'store_id' => $store->getKey(), + 'customer_id' => $customer?->getKey(), + 'currency' => $store->default_currency, + 'cart_version' => 1, + 'status' => CartStatus::Active, + ]); + } + + /** + * Add a variant to the cart, incrementing an existing line for the same + * variant instead of creating a duplicate. + * + * @throws ValidationException + * @throws InsufficientInventoryException + */ + public function addLine(Cart $cart, int $variantId, int $quantity): CartLine + { + if ($quantity < 1) { + throw ValidationException::withMessages(['quantity' => __('Quantity must be at least 1.')]); + } + + $variant = $this->resolvePurchasableVariant($cart, $variantId); + + return DB::transaction(function () use ($cart, $variant, $quantity): CartLine { + $line = $cart->lines()->where('variant_id', $variant->getKey())->first(); + + $newQuantity = ($line?->quantity ?? 0) + $quantity; + + $this->assertInventoryAllows($variant, $newQuantity); + + if ($line === null) { + $line = new CartLine([ + 'cart_id' => $cart->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity' => $quantity, + 'unit_price_amount' => $variant->price_amount, + 'line_discount_amount' => 0, + ]); + } else { + $line->quantity = $newQuantity; + } + + $line->recalculateAmounts(); + $line->save(); + + $this->bumpVersion($cart); + + return $line; + }); + } + + /** + * Update a line's quantity, removing the line entirely when set to zero. + * + * @throws InsufficientInventoryException + */ + public function updateLineQuantity(Cart $cart, int $lineId, int $quantity): ?CartLine + { + if ($quantity < 0) { + throw ValidationException::withMessages(['quantity' => __('Quantity may not be negative.')]); + } + + if ($quantity === 0) { + $this->removeLine($cart, $lineId); + + return null; + } + + $line = $cart->lines()->with('variant')->findOrFail($lineId); + + $this->assertInventoryAllows($line->variant, $quantity); + + return DB::transaction(function () use ($cart, $line, $quantity): CartLine { + $line->quantity = $quantity; + $line->recalculateAmounts(); + $line->save(); + + $this->bumpVersion($cart); + + return $line; + }); + } + + /** + * Remove a line from the cart. + */ + public function removeLine(Cart $cart, int $lineId): void + { + $line = $cart->lines()->findOrFail($lineId); + + DB::transaction(function () use ($cart, $line): void { + $line->delete(); + + $this->bumpVersion($cart); + }); + } + + /** + * Resolve the cart for the current session, preferring the customer's + * active cart, then the session-bound guest cart, then a fresh cart. + */ + public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart + { + if ($customer !== null) { + $customerCart = Cart::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('customer_id', $customer->getKey()) + ->where('status', CartStatus::Active) + ->latest('id') + ->first(); + + if ($customerCart !== null) { + Session::put(self::SESSION_KEY, $customerCart->getKey()); + + return $customerCart; + } + } + + $sessionCart = $this->findSessionCart($store); + + if ($sessionCart !== null) { + if ($customer !== null && $sessionCart->customer_id === null) { + $sessionCart->update(['customer_id' => $customer->getKey()]); + } + + return $sessionCart; + } + + $cart = $this->create($store, $customer); + + Session::put(self::SESSION_KEY, $cart->getKey()); + + return $cart; + } + + /** + * The current cart without creating one: the customer's active cart when + * logged in, otherwise the session-bound guest cart. + */ + public function findFor(Store $store, ?Customer $customer = null): ?Cart + { + if ($customer !== null) { + $customerCart = Cart::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('customer_id', $customer->getKey()) + ->where('status', CartStatus::Active) + ->latest('id') + ->first(); + + if ($customerCart !== null) { + return $customerCart; + } + } + + return $this->findSessionCart($store); + } + + /** + * The session-bound active cart for the store, if any. + */ + public function findSessionCart(Store $store): ?Cart + { + $cartId = Session::get(self::SESSION_KEY); + + if ($cartId === null) { + return null; + } + + return Cart::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('status', CartStatus::Active) + ->find($cartId); + } + + /** + * Merge the guest cart's lines into the customer cart, summing quantities + * for duplicate variants. The guest cart is marked abandoned. + */ + public function mergeOnLogin(Cart $guestCart, Cart $customerCart): Cart + { + return DB::transaction(function () use ($guestCart, $customerCart): Cart { + foreach ($guestCart->lines()->get() as $guestLine) { + $existingLine = $customerCart->lines() + ->where('variant_id', $guestLine->variant_id) + ->first(); + + if ($existingLine !== null) { + $existingLine->quantity += $guestLine->quantity; + $existingLine->recalculateAmounts(); + $existingLine->save(); + $guestLine->delete(); + } else { + $guestLine->update(['cart_id' => $customerCart->getKey()]); + } + } + + $guestCart->update(['status' => CartStatus::Abandoned]); + + $this->bumpVersion($customerCart); + + return $customerCart->refresh(); + }); + } + + /** + * Guard for optimistic concurrency: API clients send the cart version + * they last saw and receive a conflict when it has moved on. + * + * @throws CartVersionMismatchException + */ + public function assertVersion(Cart $cart, int $expectedVersion): void + { + if ($cart->cart_version !== $expectedVersion) { + throw CartVersionMismatchException::forVersions($expectedVersion, $cart->cart_version); + } + } + + /** + * Resolve an active variant of an active product within the cart's store. + * + * @throws ValidationException + */ + protected function resolvePurchasableVariant(Cart $cart, int $variantId): ProductVariant + { + $variant = ProductVariant::query() + ->with(['product' => fn ($query) => $query->withoutGlobalScopes(), 'inventoryItem']) + ->find($variantId); + + if ($variant === null || $variant->product?->store_id !== $cart->store_id) { + throw (new ModelNotFoundException)->setModel(ProductVariant::class, [$variantId]); + } + + if ($variant->product->status !== ProductStatus::Active) { + throw ValidationException::withMessages(['variant' => __('This product is not available.')]); + } + + if ($variant->status !== VariantStatus::Active) { + throw ValidationException::withMessages(['variant' => __('This variant is not available.')]); + } + + return $variant; + } + + /** + * Enforce the deny inventory policy for the requested total quantity. + * + * @throws InsufficientInventoryException + */ + protected function assertInventoryAllows(?ProductVariant $variant, int $quantity): void + { + $inventory = $variant?->inventoryItem; + + if ($inventory === null || $inventory->policy === InventoryPolicy::Continue) { + return; + } + + if ($inventory->availableQuantity() < $quantity) { + throw InsufficientInventoryException::forQuantity($quantity, $inventory->availableQuantity()); + } + } + + /** + * Every cart mutation increments the optimistic concurrency version. + */ + protected function bumpVersion(Cart $cart): void + { + $cart->increment('cart_version'); + } +} diff --git a/app/Services/CheckoutService.php b/app/Services/CheckoutService.php new file mode 100644 index 00000000..3b4466a2 --- /dev/null +++ b/app/Services/CheckoutService.php @@ -0,0 +1,323 @@ +lines()->exists()) { + throw ValidationException::withMessages([ + 'cart' => __('Cannot start a checkout for an empty cart.'), + ]); + } + + $checkout = Checkout::query()->create([ + 'store_id' => $cart->store_id, + 'cart_id' => $cart->getKey(), + 'customer_id' => $customer?->getKey() ?? $cart->customer_id, + 'status' => CheckoutStatus::Started, + 'email' => $customer?->email, + 'discount_code' => $discountCode, + ]); + + $this->pricingEngine->calculate($checkout); + + $this->analytics->track( + $checkout->store, + 'checkout_started', + [ + 'checkout_id' => $checkout->getKey(), + 'cart_id' => $cart->getKey(), + 'item_count' => $cart->itemCount(), + ], + session()->isStarted() ? session()->getId() : null, + $checkout->customer_id, + ); + + return $checkout->refresh(); + } + + /** + * Transition started -> addressed. Re-addressing resets any selected + * shipping method and recalculates pricing (zones and tax may change). + * + * @param array $data + * + * @throws ValidationException + * @throws InvalidCheckoutTransitionException + */ + public function setAddress(Checkout $checkout, array $data): Checkout + { + $this->assertStatusIn($checkout, [ + CheckoutStatus::Started, + CheckoutStatus::Addressed, + CheckoutStatus::ShippingSelected, + ], 'set the address on'); + + $validated = Validator::make($data, [ + 'email' => ['required', 'email'], + '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:255'], + 'shipping_address.address2' => ['nullable', 'string', 'max:255'], + '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:32'], + 'shipping_address.country_code' => ['required', 'string', 'size:2'], + 'shipping_address.postal_code' => ['required', 'string', 'max:32'], + 'shipping_address.phone' => ['nullable', 'string', 'max:64'], + 'billing_address' => ['nullable', 'array'], + ])->validate(); + + $checkout->forceFill([ + 'email' => $validated['email'], + 'shipping_address_json' => $validated['shipping_address'], + 'billing_address_json' => $validated['billing_address'] ?? $validated['shipping_address'], + 'shipping_method_id' => null, + 'status' => CheckoutStatus::Addressed, + ])->save(); + + $this->pricingEngine->calculate($checkout); + + return $checkout->refresh(); + } + + /** + * Transition addressed -> shipping_selected. When nothing in the cart + * requires shipping the step is skipped with a null rate and zero cost. + * + * @throws InvalidShippingRateException + * @throws InvalidCheckoutTransitionException + */ + public function setShippingMethod(Checkout $checkout, ?int $rateId = null): Checkout + { + $this->assertStatusIn($checkout, [ + CheckoutStatus::Addressed, + CheckoutStatus::ShippingSelected, + ], 'select a shipping method for'); + + $cart = Cart::query()->withoutGlobalScopes()->findOrFail($checkout->cart_id); + + if (! $cart->requiresShipping()) { + $checkout->forceFill([ + 'shipping_method_id' => null, + 'status' => CheckoutStatus::ShippingSelected, + ])->save(); + + $this->pricingEngine->calculate($checkout); + + return $checkout->refresh(); + } + + $rate = $rateId === null ? null : ShippingRate::query()->find($rateId); + + if ($rate === null || ! $this->shippingCalculator->rateMatchesAddress( + $rate, + $checkout->store, + $checkout->shipping_address_json ?? [], + )) { + throw InvalidShippingRateException::notApplicable((int) $rateId); + } + + $checkout->forceFill([ + 'shipping_method_id' => $rate->getKey(), + 'status' => CheckoutStatus::ShippingSelected, + ])->save(); + + $this->pricingEngine->calculate($checkout); + + return $checkout->refresh(); + } + + /** + * Transition shipping_selected -> payment_selected: stores the payment + * method, reserves inventory, and starts the 24 hour expiry clock. + * + * @throws ValidationException + * @throws InsufficientInventoryException + * @throws InvalidCheckoutTransitionException + */ + public function selectPaymentMethod(Checkout $checkout, string $paymentMethod): Checkout + { + $this->assertStatusIn($checkout, [CheckoutStatus::ShippingSelected], 'select a payment method for'); + + if (! in_array($paymentMethod, ['credit_card', 'paypal', 'bank_transfer'], true)) { + throw ValidationException::withMessages([ + 'payment_method' => __('Unsupported payment method.'), + ]); + } + + return DB::transaction(function () use ($checkout, $paymentMethod): Checkout { + $this->eachLineWithInventory($checkout, function (CartLine $line): void { + $this->inventoryService->reserve($line->variant->inventoryItem, $line->quantity); + }); + + $checkout->forceFill([ + 'payment_method' => $paymentMethod, + 'expires_at' => now()->addHours(self::EXPIRY_HOURS), + 'status' => CheckoutStatus::PaymentSelected, + ])->save(); + + return $checkout->refresh(); + }); + } + + /** + * Transition payment_selected -> completed: charges the mock PSP and + * creates the order. Idempotent: when an order already exists for this + * checkout it is returned without charging again (spec 05 section 6.2). + * + * @param array $paymentMethodData + * + * @throws InvalidCheckoutTransitionException + * @throws PaymentFailedException + */ + public function completeCheckout(Checkout $checkout, array $paymentMethodData = []): Order + { + $existingOrder = Order::query() + ->withoutGlobalScopes() + ->where('checkout_id', $checkout->getKey()) + ->first(); + + if ($existingOrder !== null) { + return $existingOrder; + } + + $this->assertStatusIn($checkout, [CheckoutStatus::PaymentSelected], 'complete'); + + $result = $this->paymentProvider->charge( + $checkout, + PaymentMethod::from($checkout->payment_method), + $paymentMethodData, + ); + + if (! $result->success) { + Log::channel('structured')->warning('payment.failed', [ + 'event' => 'business', + 'checkout_id' => $checkout->getKey(), + 'store_id' => $checkout->store_id, + 'payment_method' => $checkout->payment_method, + 'error_code' => $result->errorCode ?? 'payment_failed', + ]); + + /* + * The reservation made at payment selection is kept so a retry + * with corrected details stays consistent; the 24 hour checkout + * expiry releases it if the customer abandons the checkout. + */ + throw new PaymentFailedException($result->errorCode ?? 'payment_failed'); + } + + return DB::transaction(function () use ($checkout, $result): Order { + $order = $this->orderService->createFromCheckout($checkout, $result); + + $checkout->forceFill(['status' => CheckoutStatus::Completed])->save(); + + event(new CheckoutCompleted($checkout, $order)); + + return $order; + }); + } + + /** + * Transition any active state -> expired, releasing inventory that was + * reserved at payment selection. + */ + public function expireCheckout(Checkout $checkout): void + { + if (! $checkout->status->isActive()) { + return; + } + + DB::transaction(function () use ($checkout): void { + if ($checkout->status === CheckoutStatus::PaymentSelected) { + $this->eachLineWithInventory($checkout, function (CartLine $line): void { + $this->inventoryService->release($line->variant->inventoryItem, $line->quantity); + }); + } + + $checkout->forceFill(['status' => CheckoutStatus::Expired])->save(); + }); + } + + /** + * Recalculate and snapshot the checkout totals. + */ + public function recalculate(Checkout $checkout): PricingResult + { + return $this->pricingEngine->calculate($checkout); + } + + /** + * Run a callback for every cart line whose variant tracks inventory. + * + * @param callable(CartLine): void $callback + */ + protected function eachLineWithInventory(Checkout $checkout, callable $callback): void + { + $lines = CartLine::query() + ->where('cart_id', $checkout->cart_id) + ->with('variant.inventoryItem') + ->get(); + + foreach ($lines as $line) { + if ($line->variant?->inventoryItem !== null) { + $callback($line); + } + } + } + + /** + * @param list $allowed + * + * @throws InvalidCheckoutTransitionException + */ + protected function assertStatusIn(Checkout $checkout, array $allowed, string $action): void + { + if (! in_array($checkout->status, $allowed, true)) { + throw InvalidCheckoutTransitionException::fromStatus($checkout->status, $action); + } + } +} diff --git a/app/Services/DiscountService.php b/app/Services/DiscountService.php new file mode 100644 index 00000000..cb291b95 --- /dev/null +++ b/app/Services/DiscountService.php @@ -0,0 +1,146 @@ +withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->whereRaw('LOWER(code) = ?', [mb_strtolower(trim($code))]) + ->first(); + + if ($discount === null) { + throw InvalidDiscountException::notFound(); + } + + if (in_array($discount->status, [DiscountStatus::Draft, DiscountStatus::Disabled], true)) { + throw InvalidDiscountException::disabled(); + } + + if ($discount->starts_at !== null && $discount->starts_at->isFuture()) { + throw InvalidDiscountException::notYetActive(); + } + + if ($discount->status === DiscountStatus::Expired + || ($discount->ends_at !== null && $discount->ends_at->isPast())) { + throw InvalidDiscountException::expired(); + } + + if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) { + throw InvalidDiscountException::usageLimitReached(); + } + + $minimum = $discount->minimumPurchaseAmount(); + + if ($minimum !== null && $cart->subtotalAmount() < $minimum) { + throw InvalidDiscountException::minimumNotMet($minimum); + } + + return $discount; + } + + /** + * Calculate the discount amount and its proportional allocation across + * qualifying lines. The rounding remainder goes to the last qualifying + * line (largest-remainder method, spec 05 section 7.6). + * + * @param list $lines + */ + public function calculate(Discount $discount, int $subtotal, array $lines): DiscountResult + { + if ($discount->value_type === DiscountValueType::FreeShipping) { + return new DiscountResult(0, true); + } + + $qualifyingLines = $this->qualifyingLines($discount, $lines); + + $qualifyingSubtotal = 0; + + foreach ($qualifyingLines as $line) { + $qualifyingSubtotal += $line->line_subtotal_amount; + } + + if ($qualifyingLines === [] || $qualifyingSubtotal <= 0) { + return DiscountResult::none(); + } + + $totalDiscount = match ($discount->value_type) { + DiscountValueType::Percent => intdiv($qualifyingSubtotal * $discount->value_amount, 100), + DiscountValueType::Fixed => min($discount->value_amount, $qualifyingSubtotal), + DiscountValueType::FreeShipping => 0, + }; + + $allocations = []; + $remaining = $totalDiscount; + $lastIndex = count($qualifyingLines) - 1; + + foreach ($qualifyingLines as $index => $line) { + if ($index === $lastIndex) { + $lineDiscount = $remaining; + } else { + $lineDiscount = (int) round($totalDiscount * $line->line_subtotal_amount / $qualifyingSubtotal); + $remaining -= $lineDiscount; + } + + $allocations[$line->getKey() ?? $index] = $lineDiscount; + } + + return new DiscountResult($totalDiscount, false, $allocations); + } + + /** + * Filter lines by the discount's product/collection restrictions. A line + * qualifies when it matches either restriction (union). With no + * restrictions, every line qualifies. + * + * @param list $lines + * @return list + */ + protected function qualifyingLines(Discount $discount, array $lines): array + { + $productIds = $discount->rules_json['applicable_product_ids'] ?? null; + $collectionIds = $discount->rules_json['applicable_collection_ids'] ?? null; + + if (blank($productIds) && blank($collectionIds)) { + return array_values($lines); + } + + return array_values(array_filter($lines, function (CartLine $line) use ($productIds, $collectionIds): bool { + $product = $line->variant?->product; + + if ($product === null) { + return false; + } + + if (filled($productIds) && in_array($product->getKey(), $productIds, false)) { + return true; + } + + if (filled($collectionIds)) { + return $product->collections() + ->withoutGlobalScopes() + ->whereIn('collections.id', $collectionIds) + ->exists(); + } + + return false; + })); + } +} diff --git a/app/Services/FulfillmentService.php b/app/Services/FulfillmentService.php new file mode 100644 index 00000000..d965332a --- /dev/null +++ b/app/Services/FulfillmentService.php @@ -0,0 +1,188 @@ + $lines Quantity to fulfill keyed by order line id + * @param array{tracking_company?: string|null, tracking_number?: string|null, tracking_url?: string|null}|null $tracking + * + * @throws FulfillmentGuardException + * @throws ValidationException + */ + public function create(Order $order, array $lines, ?array $tracking = null): Fulfillment + { + $this->assertFulfillmentAllowed($order); + + $lines = array_filter($lines, fn (int $quantity): bool => $quantity > 0); + + if ($lines === []) { + throw ValidationException::withMessages([ + 'lines' => __('A fulfillment requires at least one line.'), + ]); + } + + $orderLines = $order->lines()->whereIn('id', array_keys($lines))->get()->keyBy('id'); + + foreach ($lines as $orderLineId => $quantity) { + $orderLine = $orderLines->get($orderLineId); + + if ($orderLine === null) { + throw ValidationException::withMessages([ + 'lines' => __('Order line :id does not belong to this order.', ['id' => $orderLineId]), + ]); + } + + if ($quantity > $orderLine->unfulfilledQuantity()) { + throw ValidationException::withMessages([ + 'lines' => __('Cannot fulfill more units than remain unfulfilled for ":title".', [ + 'title' => $orderLine->title_snapshot, + ]), + ]); + } + } + + return DB::transaction(function () use ($order, $lines, $tracking): Fulfillment { + $fulfillment = Fulfillment::query()->create([ + 'order_id' => $order->getKey(), + '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) { + FulfillmentLine::query()->create([ + 'fulfillment_id' => $fulfillment->getKey(), + 'order_line_id' => $orderLineId, + 'quantity' => $quantity, + ]); + } + + $this->refreshOrderFulfillmentStatus($order); + + return $fulfillment; + }); + } + + /** + * Transition pending -> shipped, recording 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(array_filter([ + 'tracking_company' => $tracking['tracking_company'] ?? null, + 'tracking_number' => $tracking['tracking_number'] ?? null, + 'tracking_url' => $tracking['tracking_url'] ?? null, + ], fn (?string $value): bool => $value !== null)) + ->forceFill([ + 'status' => FulfillmentShipmentStatus::Shipped, + 'shipped_at' => now(), + ]) + ->save(); + } + + /** + * Transition shipped -> delivered, recording delivered_at. + */ + public function markAsDelivered(Fulfillment $fulfillment): void + { + $fulfillment->forceFill([ + 'status' => FulfillmentShipmentStatus::Delivered, + 'delivered_at' => now(), + ])->save(); + + event(new FulfillmentDelivered($fulfillment)); + } + + /** + * Auto-fulfill an order whose lines are all digital (spec 05 section + * 11.7): one delivered fulfillment covering every line. Called after a + * payment is captured (instant or admin-confirmed bank transfer). + */ + public function autoFulfillDigital(Order $order): ?Fulfillment + { + if (! $order->isFullyDigital()) { + return null; + } + + return DB::transaction(function () use ($order): Fulfillment { + $fulfillment = Fulfillment::query()->create([ + 'order_id' => $order->getKey(), + 'status' => FulfillmentShipmentStatus::Delivered, + 'shipped_at' => now(), + 'delivered_at' => now(), + ]); + + foreach ($order->lines as $orderLine) { + FulfillmentLine::query()->create([ + 'fulfillment_id' => $fulfillment->getKey(), + 'order_line_id' => $orderLine->getKey(), + 'quantity' => $orderLine->quantity, + ]); + } + + $this->refreshOrderFulfillmentStatus($order); + + event(new FulfillmentDelivered($fulfillment)); + + return $fulfillment; + }); + } + + /** + * Recompute the order's fulfillment status from its fulfillment lines. + * When every line is fully fulfilled the order itself becomes fulfilled + * and OrderFulfilled is dispatched. + */ + protected function refreshOrderFulfillmentStatus(Order $order): void + { + $allFulfilled = $order->lines() + ->get() + ->every(fn (OrderLine $line): bool => $line->unfulfilledQuantity() === 0); + + if ($allFulfilled) { + $order->forceFill([ + 'fulfillment_status' => FulfillmentStatus::Fulfilled, + 'status' => OrderStatus::Fulfilled, + ])->save(); + + event(new OrderFulfilled($order)); + + return; + } + + $order->forceFill(['fulfillment_status' => FulfillmentStatus::Partial])->save(); + } + + /** + * Fulfillment guard (spec 05 section 11.5): only paid or partially + * refunded orders may be fulfilled. + * + * @throws FulfillmentGuardException + */ + protected function assertFulfillmentAllowed(Order $order): void + { + if (! $order->financial_status->allowsFulfillment()) { + throw FulfillmentGuardException::forFinancialStatus($order->financial_status); + } + } +} diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php new file mode 100644 index 00000000..24093717 --- /dev/null +++ b/app/Services/InventoryService.php @@ -0,0 +1,68 @@ +availableQuantity() >= $quantity; + } + + /** + * Reserve stock for an active checkout or pending order. + * + * @throws InsufficientInventoryException + */ + public function reserve(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity): void { + $item->refresh(); + + if ($item->policy === InventoryPolicy::Deny && ! $this->checkAvailability($item, $quantity)) { + throw InsufficientInventoryException::forQuantity($quantity, $item->availableQuantity()); + } + + $item->increment('quantity_reserved', $quantity); + }); + } + + /** + * Release a reservation when a checkout expires or is abandoned. + */ + public function release(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity): void { + $item->decrement('quantity_reserved', $quantity); + }); + } + + /** + * Commit reserved stock when payment is confirmed and the order created. + */ + public function commit(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity): void { + $item->decrement('quantity_on_hand', $quantity); + $item->decrement('quantity_reserved', $quantity); + }); + } + + /** + * Return stock when a refund is processed with the restock flag. + */ + public function restock(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity): void { + $item->increment('quantity_on_hand', $quantity); + }); + } +} diff --git a/app/Services/MediaService.php b/app/Services/MediaService.php new file mode 100644 index 00000000..19a024f4 --- /dev/null +++ b/app/Services/MediaService.php @@ -0,0 +1,94 @@ +getMimeType(), 'image/')) { + throw ValidationException::withMessages([ + 'file' => 'Only image uploads are supported.', + ]); + } + + $storageKey = $file->store("media/products/{$product->getKey()}", 'public'); + + $media = $product->media()->create([ + 'type' => MediaType::Image, + 'storage_key' => $storageKey, + 'mime_type' => $file->getMimeType(), + 'byte_size' => $file->getSize(), + 'position' => $this->nextPosition($product), + 'status' => MediaStatus::Processing, + ]); + + ProcessMediaUpload::dispatch($media); + + return $media; + } + + public function updateAltText(ProductMedia $media, ?string $altText): ProductMedia + { + $media->update(['alt_text' => $altText]); + + return $media; + } + + /** + * Reorder the product's media by the given ordered list of media ids. + * + * @param list $orderedMediaIds + */ + public function reorder(Product $product, array $orderedMediaIds): void + { + foreach ($orderedMediaIds as $position => $mediaId) { + $product->media() + ->whereKey($mediaId) + ->update(['position' => $position]); + } + } + + /** + * Delete the media record and remove the original plus all derived files + * from storage. + */ + public function delete(ProductMedia $media): void + { + $disk = Storage::disk('public'); + + $keys = [ + $media->storage_key, + ...array_map( + fn (string $size): string => $media->derivedStorageKey($size), + array_keys(ProcessMediaUpload::SIZES), + ), + ]; + + $disk->delete($keys); + + $media->delete(); + } + + private function nextPosition(Product $product): int + { + $maxPosition = $product->media()->max('position'); + + return $maxPosition === null ? 0 : (int) $maxPosition + 1; + } +} diff --git a/app/Services/NavigationService.php b/app/Services/NavigationService.php new file mode 100644 index 00000000..e4992bf9 --- /dev/null +++ b/app/Services/NavigationService.php @@ -0,0 +1,171 @@ +}> + */ + public function tree(string $handle): array + { + if (! app()->bound('current_store')) { + return []; + } + + $storeId = app('current_store')->getKey(); + + return Cache::remember( + "navigation_tree:{$storeId}:{$handle}", + now()->addMinutes(self::CACHE_TTL_MINUTES), + function () use ($storeId, $handle): array { + $menu = NavigationMenu::query() + ->withoutGlobalScopes() + ->where('store_id', $storeId) + ->where('handle', $handle) + ->first(); + + return $menu === null ? [] : $this->buildTree($menu); + }, + ); + } + + /** + * Build the navigation tree for a menu. Items whose linked resource no + * longer exists are omitted. Resource handles are resolved in bulk to + * avoid per-item queries. + * + * @return list}> + */ + public function buildTree(NavigationMenu $menu): array + { + $items = $menu->items()->get(); + + $handleMaps = $this->resourceHandleMaps($menu, $items->all()); + + $tree = []; + + foreach ($items as $item) { + $url = $this->resolveUrlUsing($item, $handleMaps); + + if ($url === null) { + continue; + } + + $tree[] = [ + 'id' => $item->getKey(), + 'label' => $item->label, + 'url' => $url, + 'type' => $item->type->value, + 'children' => [], + ]; + } + + return $tree; + } + + /** + * Resolve the URL for a single navigation item. Returns "#" when the + * linked resource no longer exists. + */ + public function resolveUrl(NavigationItem $item): string + { + $menu = $item->menu()->withoutGlobalScopes()->firstOrFail(); + + return $this->resolveUrlUsing($item, $this->resourceHandleMaps($menu, [$item])) ?? '#'; + } + + /** + * Forget the cached tree for a store's menu handle. + */ + public function forget(int $storeId, string $handle): void + { + Cache::forget("navigation_tree:{$storeId}:{$handle}"); + } + + /** + * Resolve an item URL using prefetched handle maps. Returns null when the + * linked resource is missing. + * + * @param array> $handleMaps + */ + protected function resolveUrlUsing(NavigationItem $item, array $handleMaps): ?string + { + if ($item->type === NavigationItemType::Link) { + return $item->url ?? '/'; + } + + $handle = $handleMaps[$item->type->value][$item->resource_id] ?? null; + + if ($handle === null) { + return null; + } + + return match ($item->type) { + NavigationItemType::Page => "/pages/{$handle}", + NavigationItemType::Collection => "/collections/{$handle}", + NavigationItemType::Product => "/products/{$handle}", + NavigationItemType::Link => $item->url ?? '/', + }; + } + + /** + * Bulk-load resource id => handle maps for the given items, scoped to the + * menu's store. + * + * @param array $items + * @return array> + */ + protected function resourceHandleMaps(NavigationMenu $menu, array $items): array + { + $idsByType = [ + NavigationItemType::Page->value => [], + NavigationItemType::Collection->value => [], + NavigationItemType::Product->value => [], + ]; + + foreach ($items as $item) { + if ($item->type !== NavigationItemType::Link && $item->resource_id !== null) { + $idsByType[$item->type->value][] = $item->resource_id; + } + } + + $maps = []; + + $queries = [ + NavigationItemType::Page->value => Page::query(), + NavigationItemType::Collection->value => Collection::query(), + NavigationItemType::Product->value => Product::query(), + ]; + + foreach ($queries as $type => $query) { + $maps[$type] = $idsByType[$type] === [] + ? [] + : $query->withoutGlobalScopes() + ->where('store_id', $menu->store_id) + ->whereIn('id', $idsByType[$type]) + ->pluck('handle', 'id') + ->all(); + } + + return $maps; + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php new file mode 100644 index 00000000..e0bda02c --- /dev/null +++ b/app/Services/OrderService.php @@ -0,0 +1,298 @@ +withoutGlobalScopes()->findOrFail($checkout->cart_id); + $cartLines = CartLine::query() + ->where('cart_id', $cart->getKey()) + ->with(['variant.product', 'variant.optionValues', 'variant.inventoryItem']) + ->get(); + + $totals = $checkout->totals_json ?? []; + $captured = $paymentResult->status === PaymentStatus::Captured; + + $order = Order::query()->create([ + 'store_id' => $checkout->store_id, + 'customer_id' => $checkout->customer_id, + 'checkout_id' => $checkout->getKey(), + 'order_number' => $this->generateOrderNumber($checkout->store), + 'payment_method' => $checkout->payment_method, + 'status' => $captured ? OrderStatus::Paid : OrderStatus::Pending, + 'financial_status' => $captured ? FinancialStatus::Paid : FinancialStatus::Pending, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled, + 'currency' => $totals['currency'] ?? $cart->currency, + 'subtotal_amount' => $totals['subtotal'] ?? 0, + 'discount_amount' => $totals['discount'] ?? 0, + 'shipping_amount' => $totals['shipping'] ?? 0, + 'tax_amount' => $totals['tax'] ?? 0, + 'total_amount' => $totals['total'] ?? 0, + 'email' => $checkout->email, + 'billing_address_json' => $checkout->billing_address_json, + 'shipping_address_json' => $checkout->shipping_address_json, + 'placed_at' => now(), + ]); + + $discount = $this->resolveDiscount($checkout); + + foreach ($cartLines as $cartLine) { + $this->createOrderLine($order, $cartLine, $discount); + } + + Payment::query()->create([ + 'order_id' => $order->getKey(), + 'provider' => 'mock', + 'method' => $checkout->payment_method, + 'provider_payment_id' => $paymentResult->providerPaymentId, + 'status' => $paymentResult->status, + 'amount' => $order->total_amount, + 'currency' => $order->currency, + 'raw_json_encrypted' => $paymentResult->raw, + ]); + + if ($captured) { + $this->commitInventory($cartLines); + } + + $discount?->increment('usage_count'); + + $cart->forceFill(['status' => CartStatus::Converted])->save(); + + event(new OrderCreated($order)); + + if ($captured) { + $this->fulfillmentService->autoFulfillDigital($order); + } + + return $order->refresh(); + }); + } + + /** + * Next sequential order number for the store, e.g. #1001, #1002. Runs + * inside the order creation transaction (SQLite serializes writers) and + * the unique (store_id, order_number) index backstops duplicates. + */ + public function generateOrderNumber(Store $store): string + { + $prefix = $store->settings?->settings_json['order_number_prefix'] ?? '#'; + + $highest = Order::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->pluck('order_number') + ->map(fn (string $number): int => (int) preg_replace('/\D/', '', $number)) + ->max(); + + $next = max(($highest ?? 0) + 1, self::FIRST_ORDER_NUMBER); + + return $prefix.$next; + } + + /** + * Cancel an unfulfilled order. Pending (bank transfer) orders release + * their reservation and void the payment; paid orders restock committed + * inventory (spec 05 sections 10.8 and 11.3). + * + * @throws ValidationException + */ + public function cancel(Order $order, ?string $reason = null): void + { + if ($order->status === OrderStatus::Cancelled) { + return; + } + + if ($order->fulfillment_status !== FulfillmentStatus::Unfulfilled) { + throw ValidationException::withMessages([ + 'order' => __('Only unfulfilled orders can be cancelled.'), + ]); + } + + DB::transaction(function () use ($order): void { + $wasPending = $order->financial_status === FinancialStatus::Pending; + + foreach ($order->lines()->with('variant.inventoryItem')->get() as $line) { + $item = $line->variant?->inventoryItem; + + if ($item === null) { + continue; + } + + if ($wasPending) { + $this->inventoryService->release($item, $line->quantity); + } elseif ($order->financial_status === FinancialStatus::Paid) { + $this->inventoryService->restock($item, $line->quantity); + } + } + + if ($wasPending) { + $order->payments() + ->where('status', PaymentStatus::Pending) + ->update(['status' => PaymentStatus::Failed]); + + $order->forceFill(['financial_status' => FinancialStatus::Voided]); + } + + $order->forceFill(['status' => OrderStatus::Cancelled])->save(); + + event(new OrderCancelled($order)); + }); + } + + /** + * Admin "Confirm Payment" action for bank transfer orders (spec 05 + * section 10.7): capture the payment, mark the order paid, commit the + * reserved inventory, and auto-fulfill fully digital orders. + * + * @throws ValidationException + */ + public function confirmBankTransferPayment(Order $order): Order + { + if ($order->payment_method !== PaymentMethod::BankTransfer) { + throw ValidationException::withMessages([ + 'order' => __('Only bank transfer orders can be confirmed manually.'), + ]); + } + + if ($order->financial_status !== FinancialStatus::Pending) { + throw ValidationException::withMessages([ + 'order' => __('This order\'s payment has already been confirmed.'), + ]); + } + + return DB::transaction(function () use ($order): Order { + $order->payments() + ->where('status', PaymentStatus::Pending) + ->update(['status' => PaymentStatus::Captured]); + + $order->forceFill([ + 'financial_status' => FinancialStatus::Paid, + 'status' => OrderStatus::Paid, + ])->save(); + + foreach ($order->lines()->with('variant.inventoryItem')->get() as $line) { + if ($line->variant?->inventoryItem !== null) { + $this->inventoryService->commit($line->variant->inventoryItem, $line->quantity); + } + } + + $this->fulfillmentService->autoFulfillDigital($order); + + event(new OrderPaid($order)); + + return $order->refresh(); + }); + } + + /** + * Snapshot a cart line into an order line so order history survives + * product and variant deletion. + */ + protected function createOrderLine(Order $order, CartLine $cartLine, ?Discount $discount): OrderLine + { + $variant = $cartLine->variant; + $product = $variant?->product; + + $title = $product?->title ?? __('Unavailable product'); + $optionLabels = $variant?->optionValues->pluck('value')->implode(' / ') ?? ''; + + if ($optionLabels !== '') { + $title .= " ({$optionLabels})"; + } + + $allocations = []; + + if ($discount !== null && $cartLine->line_discount_amount > 0) { + $allocations[] = [ + 'discount_id' => $discount->getKey(), + 'amount' => $cartLine->line_discount_amount, + ]; + } + + return OrderLine::query()->create([ + 'order_id' => $order->getKey(), + 'product_id' => $product?->getKey(), + 'variant_id' => $variant?->getKey(), + 'title_snapshot' => $title, + 'sku_snapshot' => $variant?->sku, + 'quantity' => $cartLine->quantity, + 'unit_price_amount' => $cartLine->unit_price_amount, + 'total_amount' => $cartLine->line_total_amount, + 'tax_lines_json' => [], + 'discount_allocations_json' => $allocations, + ]); + } + + /** + * Convert the reservation into a committed sale for every tracked line. + * + * @param \Illuminate\Support\Collection $cartLines + */ + protected function commitInventory($cartLines): void + { + foreach ($cartLines as $cartLine) { + if ($cartLine->variant?->inventoryItem !== null) { + $this->inventoryService->commit($cartLine->variant->inventoryItem, $cartLine->quantity); + } + } + } + + /** + * The discount applied to the checkout, if its code still exists. + */ + protected function resolveDiscount(Checkout $checkout): ?Discount + { + if (blank($checkout->discount_code)) { + return null; + } + + return Discount::query() + ->withoutGlobalScopes() + ->where('store_id', $checkout->store_id) + ->where('code', $checkout->discount_code) + ->first(); + } +} diff --git a/app/Services/Payments/MockPaymentProvider.php b/app/Services/Payments/MockPaymentProvider.php new file mode 100644 index 00000000..629de214 --- /dev/null +++ b/app/Services/Payments/MockPaymentProvider.php @@ -0,0 +1,94 @@ + $details + */ + public function charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult + { + return match ($method) { + PaymentMethod::CreditCard => $this->chargeCreditCard($details), + PaymentMethod::Paypal => PaymentResult::captured($this->referenceId(), [ + 'provider' => 'mock', + 'method' => 'paypal', + 'outcome' => 'captured', + ]), + PaymentMethod::BankTransfer => PaymentResult::pending($this->referenceId(), [ + 'provider' => 'mock', + 'method' => 'bank_transfer', + 'outcome' => 'pending', + ]), + }; + } + + /** + * Mock refunds always succeed. + */ + public function refund(Payment $payment, int $amount): RefundResult + { + return RefundResult::processed('mock_re_'.Str::lower(Str::random(16)), [ + 'provider' => 'mock', + 'payment_reference' => $payment->provider_payment_id, + 'amount' => $amount, + 'outcome' => 'processed', + ]); + } + + /** + * Inspect the magic card number to determine the outcome (spec 05 + * section 10.3). Expiry, CVC, and cardholder name are ignored. + * + * @param array $details + */ + protected function chargeCreditCard(array $details): PaymentResult + { + $cardNumber = preg_replace('/\D/', '', (string) ($details['card_number'] ?? '')); + + return match ($cardNumber) { + self::CARD_DECLINED => PaymentResult::failed('card_declined', [ + 'provider' => 'mock', + 'method' => 'credit_card', + 'outcome' => 'declined', + ]), + self::CARD_INSUFFICIENT_FUNDS => PaymentResult::failed('insufficient_funds', [ + 'provider' => 'mock', + 'method' => 'credit_card', + 'outcome' => 'declined', + ]), + default => PaymentResult::captured($this->referenceId(), [ + 'provider' => 'mock', + 'method' => 'credit_card', + 'outcome' => 'captured', + 'card_last4' => substr($cardNumber, -4), + ]), + }; + } + + /** + * Generate a mock payment reference ID. + */ + protected function referenceId(): string + { + return 'mock_'.Str::lower(Str::random(16)); + } +} diff --git a/app/Services/PricingEngine.php b/app/Services/PricingEngine.php new file mode 100644 index 00000000..faacf40b --- /dev/null +++ b/app/Services/PricingEngine.php @@ -0,0 +1,131 @@ + discount -> discounted subtotal -> shipping -> tax -> total. + * The result is snapshotted to checkouts.totals_json. + */ + public function calculate(Checkout $checkout): PricingResult + { + $cart = Cart::query()->withoutGlobalScopes()->findOrFail($checkout->cart_id); + $lines = $cart->lines()->with('variant.product')->get()->all(); + + $subtotal = 0; + + foreach ($lines as $line) { + $subtotal += $line->line_subtotal_amount; + } + + $discountResult = $this->resolveDiscount($checkout, $cart, $subtotal, $lines); + $this->applyDiscountAllocations($lines, $discountResult); + + $discountedSubtotal = max(0, $subtotal - $discountResult->amount); + + $shipping = $this->resolveShipping($checkout, $cart, $discountResult->freeShipping); + + $settings = TaxSettings::query()->where('store_id', $checkout->store_id)->first(); + + $taxableBase = $discountedSubtotal + + (($settings?->shippingTaxable() ?? true) ? $shipping : 0); + + $taxResult = $this->taxCalculator->calculate($taxableBase, $settings, $checkout->shipping_address_json ?? []); + + $total = $settings?->prices_include_tax === true + ? $discountedSubtotal + $shipping + : $discountedSubtotal + $shipping + $taxResult->taxTotal; + + $result = new PricingResult( + subtotal: $subtotal, + discount: $discountResult->amount, + shipping: $shipping, + taxLines: $taxResult->taxLines, + taxTotal: $taxResult->taxTotal, + total: $total, + currency: $cart->currency, + ); + + $checkout->forceFill(['totals_json' => $result->toArray()])->save(); + + return $result; + } + + /** + * Validate and calculate the checkout's discount code. A code that became + * invalid since it was applied yields no discount rather than an error. + * + * @param list $lines + */ + protected function resolveDiscount(Checkout $checkout, Cart $cart, int $subtotal, array $lines): DiscountResult + { + if (blank($checkout->discount_code) || $subtotal <= 0) { + return DiscountResult::none(); + } + + try { + $discount = $this->discountService->validate($checkout->discount_code, $checkout->store, $cart); + } catch (InvalidDiscountException) { + return DiscountResult::none(); + } + + return $this->discountService->calculate($discount, $subtotal, $lines); + } + + /** + * Persist per-line discount allocations so each line carries its rounded + * share (spec 05 section 5.3). + * + * @param list $lines + */ + protected function applyDiscountAllocations(array $lines, DiscountResult $discountResult): void + { + foreach ($lines as $line) { + $allocation = $discountResult->allocations[$line->getKey()] ?? 0; + + if ($line->line_discount_amount === $allocation) { + continue; + } + + $line->line_discount_amount = $allocation; + $line->line_total_amount = $line->line_subtotal_amount - $allocation; + $line->save(); + } + } + + /** + * Shipping per the selected rate; zero when nothing requires shipping, + * no rate is selected yet, or a free shipping discount applies. + */ + protected function resolveShipping(Checkout $checkout, Cart $cart, bool $freeShipping): int + { + if ($freeShipping || ! $cart->requiresShipping() || $checkout->shipping_method_id === null) { + return 0; + } + + $rate = ShippingRate::query()->find($checkout->shipping_method_id); + + if ($rate === null) { + return 0; + } + + return $this->shippingCalculator->calculate($rate, $cart); + } +} diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php new file mode 100644 index 00000000..82a4ca99 --- /dev/null +++ b/app/Services/ProductService.php @@ -0,0 +1,238 @@ +, + * price_amount?: int, + * options?: list}> + * } $data + */ + public function create(Store $store, array $data): Product + { + return DB::transaction(function () use ($store, $data): Product { + $product = new Product([ + 'title' => $data['title'], + 'handle' => $data['handle'] + ?? $this->handleGenerator->generate($data['title'], 'products', $store->getKey()), + 'status' => $data['status'] ?? ProductStatus::Draft, + 'description_html' => $data['description_html'] ?? null, + 'vendor' => $data['vendor'] ?? null, + 'product_type' => $data['product_type'] ?? null, + 'tags' => $data['tags'] ?? [], + ]); + $product->store_id = $store->getKey(); + + if ($product->status === ProductStatus::Active) { + $product->published_at = now(); + } + + $product->save(); + + foreach ($data['options'] ?? [] as $optionPosition => $option) { + $productOption = $product->options()->create([ + 'name' => $option['name'], + 'position' => $optionPosition, + ]); + + foreach ($option['values'] as $valuePosition => $value) { + $productOption->values()->create([ + 'value' => $value, + 'position' => $valuePosition, + ]); + } + } + + $this->variantMatrixService->rebuildMatrix($product); + + if (isset($data['price_amount'])) { + $product->variants()->update(['price_amount' => $data['price_amount']]); + } + + return $product->load(['options.values', 'variants.inventoryItem']); + }); + } + + /** + * Update product attributes. A supplied handle is re-validated for + * store-scoped uniqueness; the existing handle is kept otherwise. + * + * @param array $data + */ + public function update(Product $product, array $data): Product + { + if (array_key_exists('handle', $data)) { + $data['handle'] = $this->handleGenerator->generate( + $data['handle'] ?? $data['title'] ?? $product->title, + 'products', + $product->store_id, + $product->getKey(), + ); + } + + $product->fill($data); + $product->save(); + + return $product; + } + + /** + * Transition the product through its status state machine. + * + * @throws InvalidProductTransitionException + */ + public function transitionStatus(Product $product, ProductStatus $newStatus): void + { + $currentStatus = $product->status; + + if ($currentStatus === $newStatus) { + return; + } + + if ($newStatus === ProductStatus::Active) { + $this->assertCanActivate($product); + } + + if ($newStatus === ProductStatus::Draft && $this->orderReferences->productHasOrderReferences($product)) { + throw InvalidProductTransitionException::between( + $currentStatus, + $newStatus, + 'order lines reference this product.', + ); + } + + $product->status = $newStatus; + + if ($newStatus === ProductStatus::Active && $product->published_at === null) { + $product->published_at = now(); + } + + $product->save(); + + event(new ProductStatusChanged($product, $currentStatus, $newStatus)); + } + + /** + * Hard delete a product. Only draft products without order references may + * be deleted; anything else must be archived to preserve order history. + * + * @throws ProductDeletionException + */ + public function delete(Product $product): void + { + if ($product->status !== ProductStatus::Draft) { + throw new ProductDeletionException('Only draft products may be deleted. Archive the product instead.'); + } + + if ($this->orderReferences->productHasOrderReferences($product)) { + throw new ProductDeletionException('Products referenced by orders cannot be deleted. Archive the product instead.'); + } + + $product->delete(); + } + + /** + * Create a single variant with its inventory item, enforcing store-scoped + * SKU uniqueness (null or empty SKUs are exempt). + * + * @param array $data + * + * @throws ValidationException + */ + public function createVariant(Product $product, array $data): ProductVariant + { + $this->assertSkuIsUnique($product, $data['sku'] ?? null); + + return DB::transaction(function () use ($product, $data): ProductVariant { + $variant = $product->variants()->create($data + [ + 'currency' => $product->store->default_currency, + 'position' => (int) $product->variants()->max('position') + ($product->variants()->exists() ? 1 : 0), + ]); + + $variant->inventoryItem()->create([ + 'store_id' => $product->store_id, + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ]); + + return $variant; + }); + } + + /** + * @throws InvalidProductTransitionException + */ + private function assertCanActivate(Product $product): void + { + if (trim($product->title) === '') { + throw InvalidProductTransitionException::between( + $product->status, + ProductStatus::Active, + 'the product title must not be empty.', + ); + } + + if (! $product->variants()->where('price_amount', '>', 0)->exists()) { + throw InvalidProductTransitionException::between( + $product->status, + ProductStatus::Active, + 'at least one variant with a price greater than zero is required.', + ); + } + } + + /** + * @throws ValidationException + */ + private function assertSkuIsUnique(Product $product, ?string $sku, ?int $excludeVariantId = null): void + { + if ($sku === null || $sku === '') { + return; + } + + $exists = ProductVariant::query() + ->where('sku', $sku) + ->whereHas('product', fn ($query) => $query->where('store_id', $product->store_id)) + ->when($excludeVariantId !== null, fn ($query) => $query->where('id', '!=', $excludeVariantId)) + ->exists(); + + if ($exists) { + throw ValidationException::withMessages([ + 'sku' => "The SKU \"{$sku}\" is already in use in this store.", + ]); + } + } +} diff --git a/app/Services/RefundService.php b/app/Services/RefundService.php new file mode 100644 index 00000000..3e1cb30f --- /dev/null +++ b/app/Services/RefundService.php @@ -0,0 +1,94 @@ +remainingRefundableAmount(); + + if ($amount < 1 || $amount > $refundable) { + throw ValidationException::withMessages([ + 'amount' => __('The refund amount must be between 1 and :refundable.', [ + 'refundable' => $refundable, + ]), + ]); + } + + return DB::transaction(function () use ($order, $payment, $amount, $reason, $restock): Refund { + $refund = Refund::query()->create([ + 'order_id' => $order->getKey(), + 'payment_id' => $payment->getKey(), + 'amount' => $amount, + 'reason' => $reason, + 'status' => RefundStatus::Pending, + ]); + + $result = $this->paymentProvider->refund($payment, $amount); + + $refund->forceFill([ + 'status' => $result->success ? RefundStatus::Processed : RefundStatus::Failed, + 'provider_refund_id' => $result->providerRefundId, + ])->save(); + + $totalRefunded = $order->refundedAmount(); + + 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) { + $this->restockOrderLines($order); + } + + event(new OrderRefunded($order, $refund)); + + return $refund->refresh(); + }); + } + + /** + * Return each refunded line's quantity to on-hand stock. + */ + protected function restockOrderLines(Order $order): void + { + foreach ($order->lines()->with('variant.inventoryItem')->get() as $line) { + if ($line->variant?->inventoryItem !== null) { + $this->inventoryService->restock($line->variant->inventoryItem, $line->quantity); + } + } + } +} diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php new file mode 100644 index 00000000..f9cbcc6f --- /dev/null +++ b/app/Services/SearchService.php @@ -0,0 +1,456 @@ + + */ + protected array $settingsByStore = []; + + public function __construct(protected AnalyticsService $analytics) {} + + /** + * Full search with filters, sorting, and pagination. Every search is + * logged to search_queries and tracked as a "search" analytics event. + * + * @param array{vendor?: string|null, vendors?: list, product_types?: list, collection_id?: int|null, price_min?: int|null, price_max?: int|null, in_stock?: bool, tags?: list} $filters + * @return LengthAwarePaginator + */ + public function search( + Store $store, + string $query, + array $filters = [], + int $perPage = 24, + string $sort = 'relevance', + string $pageName = 'page', + ?int $page = null, + bool $logQuery = true, + ): LengthAwarePaginator { + $match = $this->buildMatchExpression($store, $query, prefixLastToken: true); + + if ($match === null) { + $results = new Paginator([], 0, max(1, $perPage), 1, ['pageName' => $pageName]); + } else { + $productQuery = $this->matchedProductsQuery($store, $match) + ->with(['variants.inventoryItem', 'media']); + + $this->applyFilters($productQuery, $filters); + $this->applySort($productQuery, $sort); + + $results = $productQuery->paginate($perPage, ['products.*'], $pageName, $page); + } + + if ($logQuery) { + $this->logSearch($store, $query, $filters, $results->total()); + } + + return $results; + } + + /** + * Prefix-matching suggestions for search-as-you-type. Returns published + * products ordered by FTS5 relevance. Prefixes shorter than the minimum + * length yield no results. + * + * @return EloquentCollection + */ + public function autocomplete(Store $store, string $prefix, int $limit = 5): EloquentCollection + { + if (mb_strlen(trim($prefix)) < self::MIN_PREFIX_LENGTH) { + return new EloquentCollection; + } + + $match = $this->buildMatchExpression($store, $prefix, prefixLastToken: true); + + if ($match === null) { + return new EloquentCollection; + } + + return $this->matchedProductsQuery($store, $match) + ->with(['variants.inventoryItem', 'media']) + ->orderBy('fts.rank') + ->limit($limit) + ->get(); + } + + /** + * Total number of published products matching the query, without + * pagination or logging. Used for "View all X results" links. + */ + public function countMatches(Store $store, string $query): int + { + $match = $this->buildMatchExpression($store, $query, prefixLastToken: true); + + if ($match === null) { + return 0; + } + + return $this->matchedProductsQuery($store, $match)->count(); + } + + /** + * Distinct vendor and product type values across the matched, published + * products. Feeds the search results page filter sidebar. + * + * @return array{vendors: list, product_types: list} + */ + public function facetValues(Store $store, string $query): array + { + $match = $this->buildMatchExpression($store, $query, prefixLastToken: true); + + if ($match === null) { + return ['vendors' => [], 'product_types' => []]; + } + + $facet = fn (string $column): array => $this->matchedProductsQuery($store, $match) + ->whereNotNull($column) + ->where($column, '!=', '') + ->distinct() + ->orderBy($column) + ->pluck($column) + ->all(); + + return [ + 'vendors' => $facet('vendor'), + 'product_types' => $facet('product_type'), + ]; + } + + /** + * Upsert a product into the FTS5 index. FTS5 does not support UPDATE, + * so the existing row (rowid = product id) is deleted first. + */ + public function syncProduct(Product $product): void + { + if (! $this->indexAvailable()) { + return; + } + + $this->removeProduct($product->getKey()); + + DB::insert( + 'INSERT INTO products_fts (rowid, store_id, product_id, title, description, vendor, product_type, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [ + $product->getKey(), + $product->store_id, + $product->getKey(), + $product->title ?? '', + $this->plainTextDescription($product), + $product->vendor ?? '', + $product->product_type ?? '', + implode(' ', $product->tags ?? []), + ], + ); + } + + /** + * Remove a product from the FTS5 index. + */ + public function removeProduct(int $productId): void + { + if (! $this->indexAvailable()) { + return; + } + + DB::delete('DELETE FROM products_fts WHERE rowid = ?', [$productId]); + } + + /** + * Rebuild the FTS5 index for a store from scratch. + */ + public function reindexStore(Store $store): void + { + if (! $this->indexAvailable()) { + return; + } + + DB::delete('DELETE FROM products_fts WHERE CAST(store_id AS INTEGER) = ?', [$store->getKey()]); + + Product::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->each(fn (Product $product) => $this->syncProduct($product)); + + Cache::put(self::REINDEXED_AT_CACHE_KEY.':'.$store->getKey(), now()->toIso8601String()); + } + + /** + * When the store's index was last fully rebuilt, if known. + */ + public function lastReindexedAt(Store $store): ?string + { + return Cache::get(self::REINDEXED_AT_CACHE_KEY.':'.$store->getKey()); + } + + /** + * Base query for products matching an FTS5 expression: store-scoped, + * published (active + published_at set), joined to the index so that + * fts.rank is available for relevance ordering. + * + * @return Builder + */ + protected function matchedProductsQuery(Store $store, string $match): Builder + { + $ftsSub = DB::table('products_fts') + ->selectRaw('rowid AS product_id, rank') + ->whereRaw('products_fts MATCH ?', [$match]) + ->whereRaw('CAST(store_id AS INTEGER) = ?', [$store->getKey()]); + + return Product::query() + ->withoutGlobalScopes() + ->joinSub($ftsSub, 'fts', 'fts.product_id', '=', 'products.id') + ->where('products.store_id', $store->getKey()) + ->published() + ->whereNotNull('products.published_at') + ->select('products.*'); + } + + /** + * @param Builder $query + * @param array $filters + */ + protected function applyFilters(Builder $query, array $filters): void + { + $vendors = array_values(array_filter(array_merge( + (array) ($filters['vendors'] ?? []), + filled($filters['vendor'] ?? null) ? [$filters['vendor']] : [], + ))); + + if ($vendors !== []) { + $query->whereIn('products.vendor', $vendors); + } + + if (($filters['product_types'] ?? []) !== []) { + $query->whereIn('products.product_type', $filters['product_types']); + } + + if (filled($filters['collection_id'] ?? null)) { + $query->whereHas('collections', function (Builder $collections) use ($filters): void { + $collections->where('collections.id', (int) $filters['collection_id']); + }); + } + + if (filled($filters['price_min'] ?? null)) { + $query->whereHas('variants', fn (Builder $variants) => $variants->where('price_amount', '>=', (int) $filters['price_min'])); + } + + if (filled($filters['price_max'] ?? null)) { + $query->whereHas('variants', fn (Builder $variants) => $variants->where('price_amount', '<=', (int) $filters['price_max'])); + } + + if (($filters['in_stock'] ?? false) === true) { + $query->whereHas('variants.inventoryItem', function (Builder $inventory): void { + $inventory->whereRaw('quantity_on_hand - quantity_reserved > 0'); + }); + } + + foreach ((array) ($filters['tags'] ?? []) as $tag) { + $query->whereJsonContains('products.tags', $tag); + } + } + + /** + * @param Builder $query + */ + protected function applySort(Builder $query, string $sort): void + { + $defaultVariantPrice = ProductVariant::query() + ->select('price_amount') + ->whereColumn('product_id', 'products.id') + ->orderByDesc('is_default') + ->orderBy('position') + ->limit(1); + + match ($sort) { + 'price_asc' => $query->orderBy($defaultVariantPrice), + 'price_desc' => $query->orderByDesc($defaultVariantPrice), + 'newest' => $query->orderByDesc('products.created_at')->orderByDesc('products.id'), + 'best_selling' => $query->orderByDesc( + DB::table('order_lines')->selectRaw('COALESCE(SUM(quantity), 0)')->whereColumn('order_lines.product_id', 'products.id'), + ), + default => $query->orderBy('fts.rank'), + }; + } + + /** + * Build the FTS5 MATCH expression: tokenize, drop stop words, expand + * synonyms into OR groups, and append a prefix wildcard to the last + * token (spec 05 section 16.3). Returns null for unsearchable input. + */ + protected function buildMatchExpression(Store $store, string $query, bool $prefixLastToken): ?string + { + $tokens = $this->tokenize($query); + + if ($tokens === []) { + return null; + } + + $withoutStopWords = array_values(array_diff($tokens, $this->stopWords($store))); + + if ($withoutStopWords !== []) { + $tokens = $withoutStopWords; + } + + $groups = []; + $lastIndex = count($tokens) - 1; + + foreach ($tokens as $index => $token) { + $alternatives = [$this->quoteToken($token, $prefixLastToken && $index === $lastIndex)]; + + foreach ($this->synonymsFor($store, $token) as $synonym) { + $alternatives[] = $this->quoteToken($synonym, false); + } + + $groups[] = count($alternatives) > 1 + ? '('.implode(' OR ', array_unique($alternatives)).')' + : $alternatives[0]; + } + + return implode(' ', $groups); + } + + /** + * Lowercased alphanumeric tokens; all FTS5 special characters are + * discarded by splitting on anything that is not a letter or digit. + * + * @return list + */ + protected function tokenize(string $text): array + { + $tokens = preg_split('/[^\p{L}\p{N}]+/u', mb_strtolower($text), -1, PREG_SPLIT_NO_EMPTY) ?: []; + + return array_values(array_filter($tokens, fn (string $token): bool => $token !== '')); + } + + /** + * Quote a (possibly multi-word) term as an FTS5 phrase, optionally as a + * prefix query. Token content is already sanitized to letters/digits. + */ + protected function quoteToken(string $term, bool $prefix): string + { + $phrase = '"'.implode(' ', $this->tokenize($term)).'"'; + + return $prefix ? $phrase.' *' : $phrase; + } + + /** + * Synonyms configured for the token via the store's synonym groups + * (every other member of any group containing the token). + * + * @return list + */ + protected function synonymsFor(Store $store, string $token): array + { + $synonyms = []; + + foreach ($this->settings($store)?->synonymGroups() ?? [] as $group) { + $normalized = array_map(fn (string $word): string => mb_strtolower(trim($word)), $group); + $tokenizedMembers = array_map(fn (string $word): string => implode(' ', $this->tokenize($word)), $normalized); + + if (in_array($token, $tokenizedMembers, true)) { + foreach ($normalized as $member) { + if (implode(' ', $this->tokenize($member)) !== $token) { + $synonyms[] = $member; + } + } + } + } + + return array_values(array_unique($synonyms)); + } + + /** + * @return list + */ + protected function stopWords(Store $store): array + { + return array_map( + fn (string $word): string => mb_strtolower(trim($word)), + $this->settings($store)?->stopWords() ?? [], + ); + } + + protected function settings(Store $store): ?SearchSettings + { + return $this->settingsByStore[$store->getKey()] ??= SearchSettings::query()->find($store->getKey()); + } + + /** + * Log the query to search_queries (spec 05 section 16.4) and emit a + * "search" analytics event. + * + * @param array $filters + */ + protected function logSearch(Store $store, string $query, array $filters, int $resultsCount): void + { + $query = Str::limit(trim($query), 200, ''); + + if ($query === '') { + return; + } + + SearchQuery::query()->create([ + 'store_id' => $store->getKey(), + 'query' => $query, + 'filters_json' => $filters === [] ? null : $filters, + 'results_count' => $resultsCount, + 'created_at' => now(), + ]); + + $this->analytics->track( + $store, + 'search', + ['query' => $query, 'results_count' => $resultsCount], + session()->isStarted() ? session()->getId() : null, + auth('customer')->id(), + ); + } + + /** + * Strip HTML from the product description for indexing. + */ + protected function plainTextDescription(Product $product): string + { + return trim(preg_replace('/\s+/', ' ', strip_tags($product->description_html ?? '')) ?? ''); + } + + /** + * The FTS5 index only exists on SQLite connections. + */ + protected function indexAvailable(): bool + { + return DB::getDriverName() === 'sqlite'; + } +} diff --git a/app/Services/ShippingCalculator.php b/app/Services/ShippingCalculator.php new file mode 100644 index 00000000..e5fd06f0 --- /dev/null +++ b/app/Services/ShippingCalculator.php @@ -0,0 +1,120 @@ + $address + * @return Collection + */ + public function getAvailableRates(Store $store, array $address): Collection + { + return $this->matchingZones($store, $address) + ->flatMap(fn (ShippingZone $zone) => $zone->rates()->where('is_active', true)->get()) + ->values(); + } + + /** + * Whether the selected rate's zone matches the address. + * + * @param array $address + */ + public function rateMatchesAddress(ShippingRate $rate, Store $store, array $address): bool + { + return $rate->is_active && $this->matchingZones($store, $address) + ->contains(fn (ShippingZone $zone): bool => $zone->getKey() === $rate->zone_id); + } + + /** + * Calculate the shipping cost for the rate against the cart contents. + * Returns 0 when nothing in the cart requires shipping. + */ + public function calculate(ShippingRate $rate, Cart $cart): int + { + if (! $cart->requiresShipping()) { + return 0; + } + + return match ($rate->type) { + ShippingRateType::Flat => (int) ($rate->config_json['amount'] ?? 0), + ShippingRateType::Weight => $this->matchRange( + $rate->config_json['ranges'] ?? [], + $cart->totalWeightGrams(), + 'min_g', + 'max_g', + ), + ShippingRateType::Price => $this->matchRange( + $rate->config_json['ranges'] ?? [], + $cart->subtotalAmount(), + 'min_amount', + 'max_amount', + ), + ShippingRateType::Carrier => throw new RuntimeException('Carrier-calculated rates are not implemented.'), + }; + } + + /** + * Zones matching the address, ordered by specificity (country + region + * before country-only) with lowest zone id as the tie-breaker. + * + * @param array $address + * @return Collection + */ + protected function matchingZones(Store $store, array $address): Collection + { + $countryCode = strtoupper((string) ($address['country_code'] ?? $address['country'] ?? '')); + $provinceCode = (string) ($address['province_code'] ?? ''); + + return ShippingZone::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->get() + ->map(function (ShippingZone $zone) use ($countryCode, $provinceCode): ?array { + $countryMatch = in_array($countryCode, $zone->countries_json ?? [], true); + $regionMatch = $provinceCode !== '' && in_array($provinceCode, $zone->regions_json ?? [], true); + + if (! $countryMatch && ! $regionMatch) { + return null; + } + + return ['zone' => $zone, 'specificity' => $countryMatch && $regionMatch ? 2 : 1]; + }) + ->filter() + ->sort(fn (array $a, array $b): int => ($b['specificity'] <=> $a['specificity']) + ?: ($a['zone']->getKey() <=> $b['zone']->getKey())) + ->map(fn (array $match): ShippingZone => $match['zone']) + ->values(); + } + + /** + * Find the matching tier amount for a value. A range without an upper + * bound matches everything above its minimum. + * + * @param list> $ranges + */ + protected function matchRange(array $ranges, int $value, string $minKey, string $maxKey): int + { + foreach ($ranges as $range) { + $min = (int) ($range[$minKey] ?? 0); + $max = $range[$maxKey] ?? null; + + if ($value >= $min && ($max === null || $value <= (int) $max)) { + return (int) ($range['amount'] ?? 0); + } + } + + throw new RuntimeException('No shipping range matches the cart for this rate.'); + } +} diff --git a/app/Services/TaxCalculator.php b/app/Services/TaxCalculator.php new file mode 100644 index 00000000..9b4a533d --- /dev/null +++ b/app/Services/TaxCalculator.php @@ -0,0 +1,64 @@ + $address + */ + public function calculate(int $amount, ?TaxSettings $settings, array $address = []): TaxResult + { + if ($settings === null || $amount <= 0) { + return TaxResult::zero(); + } + + $rate = $settings->defaultRateBasisPoints(); + + if ($rate <= 0) { + return TaxResult::zero(); + } + + $tax = $settings->prices_include_tax + ? $this->extractInclusive($amount, $rate) + : $this->addExclusive($amount, $rate); + + return new TaxResult([new TaxLine($settings->taxName(), $rate, $tax)], $tax); + } + + /** + * Extract the tax portion from a gross (tax-inclusive) amount using + * deterministic integer division: net = intdiv(gross * 10000, 10000 + rate). + */ + public function extractInclusive(int $grossAmount, int $rateBasisPoints): int + { + if ($grossAmount <= 0 || $rateBasisPoints <= 0) { + return 0; + } + + $net = intdiv($grossAmount * 10000, 10000 + $rateBasisPoints); + + return $grossAmount - $net; + } + + /** + * Tax to add on top of a net (tax-exclusive) amount. Uses integer + * division (truncation) for determinism, matching the spec's expected + * values (e.g. 5499 at 19% = 1044, 8999 at 7% = 629). + */ + public function addExclusive(int $netAmount, int $rateBasisPoints): int + { + if ($netAmount <= 0 || $rateBasisPoints <= 0) { + return 0; + } + + return intdiv($netAmount * $rateBasisPoints, 10000); + } +} diff --git a/app/Services/ThemeSettingsService.php b/app/Services/ThemeSettingsService.php new file mode 100644 index 00000000..25acb594 --- /dev/null +++ b/app/Services/ThemeSettingsService.php @@ -0,0 +1,132 @@ + + */ + public static function defaults(): array + { + return [ + 'primary_color' => '#1d4ed8', + 'secondary_color' => '#3b82f6', + 'font_family' => 'Instrument Sans, sans-serif', + 'logo_url' => null, + 'sticky_header' => true, + 'dark_mode' => 'system', + 'show_announcement_bar' => false, + 'announcement_text' => '', + 'announcement_link' => null, + 'hero_heading' => 'Welcome to our store', + 'hero_subheading' => 'Discover our latest products and collections.', + 'hero_cta_text' => 'Shop now', + 'hero_cta_link' => '/collections', + 'hero_image_url' => null, + 'featured_collection_handles' => [], + 'featured_products_count' => 8, + 'featured_products_collection_handle' => null, + 'show_newsletter' => true, + 'rich_text_html' => null, + 'footer_text' => null, + 'social_links' => [], + 'products_per_page' => 12, + 'show_vendor' => true, + 'show_quantity_selector' => true, + 'sections' => [ + 'hero', + 'featured-collections', + 'featured-products', + 'newsletter', + 'rich-text', + ], + ]; + } + + /** + * All effective settings for the given store (defaults merged with the + * active theme's stored settings). Falls back to plain defaults when no + * store is resolvable or the store has no published theme. + * + * @return array + */ + public function all(?Store $store = null): array + { + $store ??= $this->currentStore(); + + if ($store === null) { + return static::defaults(); + } + + $stored = Cache::remember( + $this->cacheKey($store->getKey()), + now()->addMinutes(self::CACHE_TTL_MINUTES), + fn (): array => $this->loadStoredSettings($store), + ); + + return array_replace(static::defaults(), $stored); + } + + /** + * Read a single setting using dot notation, e.g. get('hero_heading'). + */ + public function get(string $key, mixed $default = null): mixed + { + return Arr::get($this->all(), $key, $default); + } + + /** + * Forget the cached settings for a store. + */ + public function forget(int $storeId): void + { + Cache::forget($this->cacheKey($storeId)); + } + + /** + * Load the stored settings of the store's active (published) theme. + * + * @return array + */ + protected function loadStoredSettings(Store $store): array + { + $theme = Theme::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('status', ThemeStatus::Published) + ->orderByDesc('published_at') + ->with('settings') + ->first(); + + return $theme?->settings?->settings_json ?? []; + } + + protected function currentStore(): ?Store + { + return app()->bound('current_store') ? app('current_store') : null; + } + + protected function cacheKey(int $storeId): string + { + return "theme_settings:{$storeId}"; + } +} diff --git a/app/Services/VariantMatrixService.php b/app/Services/VariantMatrixService.php new file mode 100644 index 00000000..a8e9db85 --- /dev/null +++ b/app/Services/VariantMatrixService.php @@ -0,0 +1,170 @@ +load(['options.values', 'variants.optionValues']); + + $optionValueIdSets = $product->options + ->map(fn (ProductOption $option): array => $option->values->pluck('id')->all()) + ->filter(fn (array $ids): bool => $ids !== []) + ->values() + ->all(); + + if ($optionValueIdSets === []) { + $this->ensureDefaultVariant($product); + + return; + } + + $desiredCombinations = $this->cartesianProduct($optionValueIdSets); + + $existingByCombination = $product->variants->keyBy( + fn (ProductVariant $variant): string => $this->combinationKey($variant->optionValues->pluck('id')->all()), + ); + + $template = $product->variants->first(); + $nextPosition = $product->variants->isEmpty() ? 0 : (int) $product->variants->max('position') + 1; + $matchedKeys = []; + + foreach ($desiredCombinations as $combination) { + $key = $this->combinationKey($combination); + + if ($existingByCombination->has($key)) { + $matchedKeys[$key] = true; + + continue; + } + + $this->createVariantForCombination($product, $combination, $template, $nextPosition); + $nextPosition++; + } + + foreach ($existingByCombination as $key => $variant) { + if (isset($matchedKeys[$key])) { + continue; + } + + if ($this->orderReferences->variantHasOrderReferences($variant)) { + $variant->update(['status' => VariantStatus::Archived]); + } else { + $variant->delete(); + } + } + }); + } + + /** + * Auto-create the single default variant for a product without options. + */ + private function ensureDefaultVariant(Product $product): ProductVariant + { + $existing = $product->variants->first(); + + if ($existing !== null) { + return $existing; + } + + $variant = $product->variants()->create([ + 'price_amount' => 0, + 'currency' => $product->store->default_currency, + 'is_default' => true, + 'position' => 0, + 'status' => VariantStatus::Active, + ]); + + $this->createInventoryItem($product, $variant); + + return $variant; + } + + /** + * @param list $combination + */ + private function createVariantForCombination( + Product $product, + array $combination, + ?ProductVariant $template, + int $position, + ): void { + $variant = $product->variants()->create([ + 'price_amount' => $template?->price_amount ?? 0, + 'compare_at_amount' => $template?->compare_at_amount, + 'currency' => $template?->currency ?? $product->store->default_currency, + 'weight_g' => $template?->weight_g, + 'requires_shipping' => $template?->requires_shipping ?? true, + 'is_default' => false, + 'position' => $position, + 'status' => VariantStatus::Active, + ]); + + $variant->optionValues()->attach($combination); + + $this->createInventoryItem($product, $variant); + } + + private function createInventoryItem(Product $product, ProductVariant $variant): void + { + $variant->inventoryItem()->create([ + 'store_id' => $product->store_id, + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ]); + } + + /** + * @param list> $sets + * @return list> + */ + private function cartesianProduct(array $sets): array + { + $combinations = [[]]; + + foreach ($sets as $set) { + $next = []; + + foreach ($combinations as $combination) { + foreach ($set as $value) { + $next[] = [...$combination, $value]; + } + } + + $combinations = $next; + } + + return $combinations; + } + + /** + * @param list $optionValueIds + */ + private function combinationKey(array $optionValueIds): string + { + sort($optionValueIds); + + return implode('-', $optionValueIds); + } +} diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php new file mode 100644 index 00000000..f38b315b --- /dev/null +++ b/app/Services/WebhookService.php @@ -0,0 +1,100 @@ + + */ + public const array EVENT_TYPES = [ + 'order.created', + 'order.paid', + 'order.fulfilled', + 'order.cancelled', + 'order.refunded', + 'product.created', + 'product.updated', + 'product.deleted', + 'checkout.completed', + ]; + + /** + * The outbound payload schema version (spec 02 section 9). + */ + public const string API_VERSION = 'v1'; + + /** + * Find the store's active subscriptions for the event type, create a + * pending delivery record for each, and queue a DeliverWebhook job. + * + * @param array $payload + */ + public function dispatch(Store $store, string $eventType, array $payload): void + { + $subscriptions = WebhookSubscription::query() + ->withoutGlobalScope(StoreScope::class) + ->where('store_id', $store->getKey()) + ->where('event_type', $eventType) + ->where('status', WebhookSubscriptionStatus::Active) + ->get(); + + if ($subscriptions->isEmpty()) { + return; + } + + $eventId = (string) Str::uuid(); + $occurredAt = now(); + + $envelope = [ + 'id' => $eventId, + 'event' => $eventType, + 'api_version' => self::API_VERSION, + 'store_id' => $store->getKey(), + 'created_at' => $occurredAt->toIso8601String(), + 'data' => $payload, + ]; + + foreach ($subscriptions as $subscription) { + $delivery = WebhookDelivery::query()->create([ + 'subscription_id' => $subscription->getKey(), + 'event_id' => $eventId, + 'attempt_count' => 0, + 'status' => WebhookDeliveryStatus::Pending, + ]); + + DeliverWebhook::dispatch($delivery, $envelope, $occurredAt->getTimestamp()); + } + } + + /** + * HMAC-SHA256 hex digest of the raw payload body. + */ + public function sign(string $payload, string $secret): string + { + return hash_hmac('sha256', $payload, $secret); + } + + /** + * Timing-safe verification of an incoming webhook signature. + */ + public function verify(string $payload, string $signature, string $secret): bool + { + return hash_equals($this->sign($payload, $secret), $signature); + } +} diff --git a/app/Support/HandleGenerator.php b/app/Support/HandleGenerator.php new file mode 100644 index 00000000..6d0c2759 --- /dev/null +++ b/app/Support/HandleGenerator.php @@ -0,0 +1,42 @@ +handleExists($handle, $table, $storeId, $excludeId)) { + $suffix++; + $handle = "{$base}-{$suffix}"; + } + + return $handle; + } + + private function handleExists(string $handle, string $table, int $storeId, ?int $excludeId): bool + { + return DB::table($table) + ->where('store_id', $storeId) + ->where('handle', $handle) + ->when($excludeId !== null, fn ($query) => $query->where('id', '!=', $excludeId)) + ->exists(); + } +} diff --git a/app/Support/OrderReferenceChecker.php b/app/Support/OrderReferenceChecker.php new file mode 100644 index 00000000..7bfebe9d --- /dev/null +++ b/app/Support/OrderReferenceChecker.php @@ -0,0 +1,43 @@ +where('product_id', $product->getKey()) + ->orWhereIn('variant_id', $product->variants()->pluck('id')) + ->exists(); + } + + /** + * Whether any order line references the variant. + */ + public function variantHasOrderReferences(ProductVariant $variant): bool + { + if (! Schema::hasTable('order_lines')) { + return false; + } + + return DB::table('order_lines') + ->where('variant_id', $variant->getKey()) + ->exists(); + } +} diff --git a/app/Support/Storefront/Countries.php b/app/Support/Storefront/Countries.php new file mode 100644 index 00000000..aadf1ef8 --- /dev/null +++ b/app/Support/Storefront/Countries.php @@ -0,0 +1,28 @@ + */ + public const array OPTIONS = [ + 'DE' => 'Germany', + 'AT' => 'Austria', + 'BE' => 'Belgium', + 'FR' => 'France', + 'IT' => 'Italy', + 'NL' => 'Netherlands', + 'ES' => 'Spain', + 'GB' => 'United Kingdom', + 'US' => 'United States', + ]; + + public static function name(string $code): string + { + return self::OPTIONS[strtoupper($code)] ?? $code; + } +} diff --git a/app/Support/Storefront/PriceFormatter.php b/app/Support/Storefront/PriceFormatter.php new file mode 100644 index 00000000..0d001540 --- /dev/null +++ b/app/Support/Storefront/PriceFormatter.php @@ -0,0 +1,21 @@ + + */ + public static function all(): array + { + return [ + 'read-products' => __('List and view products'), + 'write-products' => __('Create, update, delete products'), + 'read-orders' => __('List and view orders'), + 'write-orders' => __('Update orders, create fulfillments'), + 'read-customers' => __('List and view customers'), + 'write-customers' => __('Update customers'), + 'read-collections' => __('List and view collections'), + 'write-collections' => __('Create, update, delete collections'), + 'read-discounts' => __('List and view discounts'), + 'write-discounts' => __('Create, update, delete discounts'), + 'read-analytics' => __('View analytics data'), + 'read-settings' => __('View store settings'), + 'write-settings' => __('Update store settings'), + 'read-themes' => __('View themes and theme files'), + 'write-themes' => __('Create, update, delete, publish themes'), + 'read-content' => __('View pages and navigation menus'), + 'write-content' => __('Create, update, delete pages and navigation items'), + 'manage-platform' => __('Platform-level management (super-admin only)'), + ]; + } + + /** + * The ability names only. + * + * @return list + */ + public static function names(): array + { + return array_keys(self::all()); + } +} diff --git a/app/Traits/ChecksStoreRole.php b/app/Traits/ChecksStoreRole.php new file mode 100644 index 00000000..17ace0a6 --- /dev/null +++ b/app/Traits/ChecksStoreRole.php @@ -0,0 +1,66 @@ +where('store_id', $storeId) + ->where('user_id', $user->getKey()) + ->first() + ?->role; + } + + /** + * Determine whether the user's role for the store is in the provided 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); + } + + protected function isOwner(User $user, ?int $storeId): bool + { + return $this->hasRole($user, $storeId, [StoreUserRole::Owner]); + } + + protected function isOwnerOrAdmin(User $user, ?int $storeId): bool + { + return $this->hasRole($user, $storeId, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + protected function isOwnerAdminOrStaff(User $user, ?int $storeId): bool + { + return $this->hasRole($user, $storeId, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + protected function isAnyRole(User $user, ?int $storeId): bool + { + return $this->getStoreRole($user, $storeId) !== null; + } + + /** + * Resolve the id of the current store bound in the container, if any. + */ + protected function currentStoreId(): ?int + { + return app()->bound('current_store') ? app('current_store')->getKey() : null; + } +} diff --git a/app/ValueObjects/DiscountResult.php b/app/ValueObjects/DiscountResult.php new file mode 100644 index 00000000..abe2cfc6 --- /dev/null +++ b/app/ValueObjects/DiscountResult.php @@ -0,0 +1,20 @@ + $allocations Discount amount in minor units keyed by cart line id + */ + public function __construct( + public int $amount, + public bool $freeShipping, + public array $allocations = [], + ) {} + + public static function none(): self + { + return new self(0, false); + } +} diff --git a/app/ValueObjects/PaymentResult.php b/app/ValueObjects/PaymentResult.php new file mode 100644 index 00000000..47be4c5a --- /dev/null +++ b/app/ValueObjects/PaymentResult.php @@ -0,0 +1,34 @@ + $raw Mock provider response payload + */ + public function __construct( + public bool $success, + public PaymentStatus $status, + public ?string $providerPaymentId = null, + public ?string $errorCode = null, + public array $raw = [], + ) {} + + public static function captured(string $providerPaymentId, array $raw = []): self + { + return new self(true, PaymentStatus::Captured, $providerPaymentId, null, $raw); + } + + public static function pending(string $providerPaymentId, array $raw = []): self + { + return new self(true, PaymentStatus::Pending, $providerPaymentId, null, $raw); + } + + public static function failed(string $errorCode, array $raw = []): self + { + return new self(false, PaymentStatus::Failed, null, $errorCode, $raw); + } +} diff --git a/app/ValueObjects/PricingResult.php b/app/ValueObjects/PricingResult.php new file mode 100644 index 00000000..6d4892ce --- /dev/null +++ b/app/ValueObjects/PricingResult.php @@ -0,0 +1,37 @@ + $taxLines + */ + public function __construct( + public int $subtotal, + public int $discount, + public int $shipping, + public array $taxLines, + public int $taxTotal, + public int $total, + public string $currency, + ) {} + + /** + * Snapshot structure persisted to checkouts.totals_json. + * + * @return array{subtotal: int, discount: int, shipping: int, tax_lines: list, tax: int, total: int, currency: string} + */ + public function toArray(): array + { + return [ + 'subtotal' => $this->subtotal, + 'discount' => $this->discount, + 'shipping' => $this->shipping, + 'tax_lines' => array_map(fn (TaxLine $line): array => $line->toArray(), $this->taxLines), + 'tax' => $this->taxTotal, + 'total' => $this->total, + 'currency' => $this->currency, + ]; + } +} diff --git a/app/ValueObjects/RefundResult.php b/app/ValueObjects/RefundResult.php new file mode 100644 index 00000000..c1c85b9e --- /dev/null +++ b/app/ValueObjects/RefundResult.php @@ -0,0 +1,21 @@ + $raw Mock provider response payload + */ + public function __construct( + public bool $success, + public ?string $providerRefundId = null, + public ?string $errorCode = null, + public array $raw = [], + ) {} + + public static function processed(string $providerRefundId, array $raw = []): self + { + return new self(true, $providerRefundId, null, $raw); + } +} diff --git a/app/ValueObjects/TaxLine.php b/app/ValueObjects/TaxLine.php new file mode 100644 index 00000000..863420c4 --- /dev/null +++ b/app/ValueObjects/TaxLine.php @@ -0,0 +1,24 @@ + $this->name, + 'rate' => $this->rate, + 'amount' => $this->amount, + ]; + } +} diff --git a/app/ValueObjects/TaxResult.php b/app/ValueObjects/TaxResult.php new file mode 100644 index 00000000..32507602 --- /dev/null +++ b/app/ValueObjects/TaxResult.php @@ -0,0 +1,30 @@ + $taxLines + */ + public function __construct( + public array $taxLines, + public int $taxTotal, + ) {} + + public static function zero(): self + { + return new self([], 0); + } + + /** + * @return array{tax_lines: list, tax_total: int} + */ + public function toArray(): array + { + return [ + 'tax_lines' => array_map(fn (TaxLine $line): array => $line->toArray(), $this->taxLines), + 'tax_total' => $this->taxTotal, + ]; + } +} diff --git a/boost.json b/boost.json new file mode 100644 index 00000000..145f9563 --- /dev/null +++ b/boost.json @@ -0,0 +1,18 @@ +{ + "agents": [ + "claude_code" + ], + "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..a0d32274 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,18 +1,99 @@ withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'store.resolve' => ResolveStore::class, + 'abilities' => CheckAbilities::class, + 'ability' => CheckForAnyAbility::class, + ]); + + $middleware->group('storefront', [ + ResolveStore::class.':storefront', + ]); + + $middleware->group('admin', [ + ResolveStore::class.':admin', + ]); + + $middleware->redirectGuestsTo(fn (Request $request): string => $request->is('admin', 'admin/*') + ? route('admin.login') + : route('storefront.account.login')); + + $middleware->redirectUsersTo('/admin'); }) ->withExceptions(function (Exceptions $exceptions): void { - // + /* + * Consistent JSON error envelopes for the REST APIs + * (spec 02 section 10). + */ + $exceptions->render(function (NotFoundHttpException|ModelNotFoundException $e, Request $request) { + return $request->is('api/*') + ? response()->json(['message' => __('The requested resource was not found.')], 404) + : null; + }); + + $exceptions->render(function (AuthorizationException|AccessDeniedHttpException $e, Request $request) { + return $request->is('api/*') + ? response()->json(['message' => __('You do not have permission to perform this action.')], 403) + : null; + }); + + $exceptions->render(function (InsufficientInventoryException $e, Request $request) { + return $request->is('api/*') + ? response()->json([ + 'message' => __('The given data was invalid.'), + 'errors' => ['quantity' => [$e->getMessage()]], + ], 422) + : null; + }); + + $exceptions->render(function (FulfillmentGuardException $e, Request $request) { + return $request->is('api/*') + ? response()->json(['message' => $e->getMessage()], 409) + : null; + }); + + $exceptions->render(function (NotFoundHttpException $e, Request $request) { + $isStorefrontRequest = app()->bound('current_store') + && ! $request->is('admin', 'admin/*') + && ! $request->is('api/*') + && ! $request->expectsJson(); + + return $isStorefrontRequest + ? response()->view('storefront.errors.404', [], 404) + : null; + }); + + $exceptions->render(function (HttpException $e, Request $request) { + $isMaintenanceResponse = $e->getStatusCode() === 503 + && ! $request->is('admin', 'admin/*') + && ! $request->is('api/*') + && ! $request->expectsJson(); + + return $isMaintenanceResponse + ? response()->view('storefront.errors.503', [], 503) + : null; + }); })->create(); diff --git a/composer.json b/composer.json index 1f848aaf..72e094db 100644 --- a/composer.json +++ b/composer.json @@ -12,19 +12,21 @@ "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..a7d974e5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "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": "842bb7ef40dbf5886b406e01f112bcc6", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1501,6 +1501,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", @@ -6429,56 +6492,37 @@ ], "packages-dev": [ { - "name": "brianium/paratest", - "version": "v7.17.0", + "name": "amphp/amp", + "version": "v3.1.1", "source": { "type": "git", - "url": "https://github.com/paratestphp/paratest.git", - "reference": "53cb90a6aa3ef3840458781600628ade058a18b9" + "url": "https://github.com/amphp/amp.git", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/53cb90a6aa3ef3840458781600628ade058a18b9", - "reference": "53cb90a6aa3ef3840458781600628ade058a18b9", + "url": "https://api.github.com/repos/amphp/amp/zipball/fa0ab33a6f47a82929c38d03ca47ebb71086a93f", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f", "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": "5.23.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/", @@ -6487,128 +6531,153 @@ ], "authors": [ { - "name": "Brian Scaturro", - "email": "scaturrob@gmail.com", - "role": "Developer" + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" }, { - "name": "Filippo Tessarotto", - "email": "zoeslam@gmail.com", - "role": "Developer" + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" } ], - "description": "Parallel testing for PHP", - "homepage": "https://github.com/paratestphp/paratest", + "description": "A non-blocking concurrency framework for PHP applications.", + "homepage": "https://amphp.org/amp", "keywords": [ - "concurrent", - "parallel", - "phpunit", - "testing" + "async", + "asynchronous", + "awaitable", + "concurrency", + "event", + "event-loop", + "future", + "non-blocking", + "promise" ], "support": { - "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.17.0" + "issues": "https://github.com/amphp/amp/issues", + "source": "https://github.com/amphp/amp/tree/v3.1.1" }, "funding": [ { - "url": "https://github.com/sponsors/Slamdunk", + "url": "https://github.com/amphp", "type": "github" - }, - { - "url": "https://paypal.me/filippotessarotto", - "type": "paypal" } ], - "time": "2026-02-05T09:14:44+00:00" + "time": "2025-08-27T21:42:00+00:00" }, { - "name": "doctrine/deprecations", - "version": "1.1.6", + "name": "amphp/byte-stream", + "version": "v2.1.2", "source": { "type": "git", - "url": "https://github.com/doctrine/deprecations.git", - "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + "url": "https://github.com/amphp/byte-stream.git", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", - "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "phpunit/phpunit": "<=7.5 || >=14" + "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": { - "doctrine/coding-standard": "^9 || ^12 || ^14", - "phpstan/phpstan": "1.4.10 || 2.1.30", - "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", - "psr/log": "^1 || ^2 || ^3" - }, - "suggest": { - "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + "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": { - "Doctrine\\Deprecations\\": "src" + "Amp\\ByteStream\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", - "homepage": "https://www.doctrine-project.org/", + "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/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + "issues": "https://github.com/amphp/byte-stream/issues", + "source": "https://github.com/amphp/byte-stream/tree/v2.1.2" }, - "time": "2026-02-07T07:09:04+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-03-16T17:10:27+00:00" }, { - "name": "fakerphp/faker", - "version": "v1.24.1", + "name": "amphp/cache", + "version": "v2.0.1", "source": { "type": "git", - "url": "https://github.com/FakerPHP/Faker.git", - "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + "url": "https://github.com/amphp/cache.git", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", - "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0", - "psr/container": "^1.0 || ^2.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" - }, - "conflict": { - "fzaninotto/faker": "*" + "amphp/amp": "^3", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.4.1", - "doctrine/persistence": "^1.3 || ^2.0", - "ext-intl": "*", - "phpunit/phpunit": "^9.5.26", - "symfony/phpunit-bridge": "^5.4.16" - }, - "suggest": { - "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", - "ext-curl": "Required by Faker\\Provider\\Image to download images.", - "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", - "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", - "ext-mbstring": "Required for multibyte Unicode string functionality." + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" }, "type": "library", "autoload": { "psr-4": { - "Faker\\": "src/Faker/" + "Amp\\Cache\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -6617,53 +6686,71 @@ ], "authors": [ { - "name": "François Zaninotto" + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" } ], - "description": "Faker is a PHP library that generates fake data for you.", - "keywords": [ - "data", - "faker", - "fixtures" - ], + "description": "A fiber-aware cache API based on Amp and Revolt.", + "homepage": "https://amphp.org/cache", "support": { - "issues": "https://github.com/FakerPHP/Faker/issues", - "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + "issues": "https://github.com/amphp/cache/issues", + "source": "https://github.com/amphp/cache/tree/v2.0.1" }, - "time": "2024-11-21T13:46:39+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:38:06+00:00" }, { - "name": "fidry/cpu-core-counter", - "version": "1.3.0", + "name": "amphp/dns", + "version": "v2.4.0", "source": { "type": "git", - "url": "https://github.com/theofidry/cpu-core-counter.git", - "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + "url": "https://github.com/amphp/dns.git", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", - "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "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": { - "fidry/makefile": "^0.2.0", - "fidry/php-cs-fixer-config": "^1.1.2", - "phpstan/extension-installer": "^1.2.0", - "phpstan/phpstan": "^2.0", - "phpstan/phpstan-deprecation-rules": "^2.0.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^8.5.31 || ^9.5.26", - "webmozarts/strict-phpunit": "^7.5" + "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": { - "Fidry\\CpuCoreCounter\\": "src/" + "Amp\\Dns\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -6672,58 +6759,1308 @@ ], "authors": [ { - "name": "Théo FIDRY", - "email": "theo.fidry@gmail.com" + "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": "Tiny utility to get the number of CPU cores.", + "description": "Async DNS resolution for Amp.", + "homepage": "https://github.com/amphp/dns", "keywords": [ - "CPU", - "core" + "amp", + "amphp", + "async", + "client", + "dns", + "resolve" ], "support": { - "issues": "https://github.com/theofidry/cpu-core-counter/issues", - "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + "issues": "https://github.com/amphp/dns/issues", + "source": "https://github.com/amphp/dns/tree/v2.4.0" }, "funding": [ { - "url": "https://github.com/theofidry", + "url": "https://github.com/amphp", "type": "github" } ], - "time": "2025-08-14T07:29:31+00:00" + "time": "2025-01-19T15:43:40+00:00" }, { - "name": "filp/whoops", - "version": "2.18.4", + "name": "amphp/hpack", + "version": "v3.2.2", "source": { "type": "git", - "url": "https://github.com/filp/whoops.git", - "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + "url": "https://github.com/amphp/hpack.git", + "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", - "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "url": "https://api.github.com/repos/amphp/hpack/zipball/291da27078e7e149a9bad4d08ff05bf7d81c89f4", + "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0", - "psr/log": "^1.0.1 || ^2.0 || ^3.0" + "php": ">=7.1" }, "require-dev": { - "mockery/mockery": "^1.0", - "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", - "symfony/var-dumper": "^4.0 || ^5.0" - }, - "suggest": { - "symfony/var-dumper": "Pretty print complex values better with var-dumper available", - "whoops/soap": "Formats errors as SOAP responses" + "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": "2.7-dev" + "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.5", + "source": { + "type": "git", + "url": "https://github.com/amphp/http-server.git", + "reference": "ae0fd01e16aba336247852df0c3f8c649a31896d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http-server/zipball/ae0fd01e16aba336247852df0c3f8c649a31896d", + "reference": "ae0fd01e16aba336247852df0c3f8c649a31896d", + "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.5" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-01T03:55:07+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.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/pipeline.git", + "reference": "a044733e080940d1483f56caff0c412ad6982776" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/a044733e080940d1483f56caff0c412ad6982776", + "reference": "a044733e080940d1483f56caff0c412ad6982776", + "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.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-06T05:37:57+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/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Scaturro", + "email": "scaturrob@gmail.com", + "role": "Developer" + }, + { + "name": "Filippo Tessarotto", + "email": "zoeslam@gmail.com", + "role": "Developer" + } + ], + "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": [ + "dns" + ], + "support": { + "issues": "https://github.com/DaveRandom/LibDNS/issues", + "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" + }, + "time": "2024-04-12T12:12:48+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" } }, "autoload": { @@ -6875,37 +8212,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.10", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab" + "reference": "080189f51c8d27c0792a03483a70adc7770f6eeb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", + "url": "https://api.github.com/repos/laravel/boost/zipball/080189f51c8d27c0792a03483a70adc7770f6eeb", + "reference": "080189f51c8d27c0792a03483a70adc7770f6eeb", "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", + "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 +8323,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 +8334,48 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2025-08-16T09:10:03+00:00" + "time": "2026-06-09T10:21:08+00:00" }, { "name": "laravel/mcp", - "version": "v0.1.1", + "version": "v0.8.0", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713" + "reference": "18221a07093d84153883bc956e5e213999549a4b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713", + "url": "https://api.github.com/repos/laravel/mcp/zipball/18221a07093d84153883bc956e5e213999549a4b", + "reference": "18221a07093d84153883bc956e5e213999549a4b", "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 +8385,6 @@ "autoload": { "psr-4": { "Laravel\\Mcp\\": "src/", - "Workbench\\App\\": "workbench/app/", - "Laravel\\Mcp\\Tests\\": "tests/", "Laravel\\Mcp\\Server\\": "src/Server/" } }, @@ -6991,10 +8392,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 +8408,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2025-08-16T09:50:43+00:00" + "time": "2026-06-08T13:48:51+00:00" }, { "name": "laravel/pail", @@ -7153,30 +8559,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 +8616,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 +8681,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 +9263,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 +10343,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 +11620,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..7ee3ad7d 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-eloquent', + '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/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/logging.php b/config/logging.php index 9e998a49..5497d405 100644 --- a/config/logging.php +++ b/config/logging.php @@ -73,6 +73,15 @@ 'replace_placeholders' => true, ], + 'structured' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/structured.log'), + 'level' => env('LOG_LEVEL', 'info'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'formatter' => Monolog\Formatter\JsonFormatter::class, + 'replace_placeholders' => true, + ], + 'slack' => [ 'driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 00000000..987f8b7e --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,85 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | Tokens expire after one year by default (spec 06 section 1.3). This + | value overrides any values set in the token's "expires_at" attribute, + | but first-party sessions are not affected. + | + */ + + 'expiration' => env('SANCTUM_TOKEN_EXPIRATION', 525600), + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | All personal access tokens are prefixed with "shop_" so they are + | recognizable and can be picked up by secret scanning tools + | (spec 06 section 1.3). + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', 'shop_'), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, + ], + +]; diff --git a/database/factories/AnalyticsDailyFactory.php b/database/factories/AnalyticsDailyFactory.php new file mode 100644 index 00000000..ce5f6aa2 --- /dev/null +++ b/database/factories/AnalyticsDailyFactory.php @@ -0,0 +1,48 @@ + + */ +class AnalyticsDailyFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $ordersCount = $this->faker->numberBetween(2, 8); + $aovAmount = $this->faker->numberBetween(4000, 9000); + $visitsCount = $this->faker->numberBetween(50, 190); + $addToCartCount = (int) round($visitsCount * 0.2); + $checkoutStartedCount = (int) round($addToCartCount * 0.5); + + return [ + 'store_id' => Store::factory(), + 'date' => $this->faker->unique()->dateTimeBetween('-90 days')->format('Y-m-d'), + 'orders_count' => $ordersCount, + 'revenue_amount' => $ordersCount * $aovAmount, + 'aov_amount' => $aovAmount, + 'visits_count' => $visitsCount, + 'add_to_cart_count' => $addToCartCount, + 'checkout_started_count' => $checkoutStartedCount, + 'checkout_completed_count' => $ordersCount, + ]; + } + + /** + * Pin the row to a specific ISO date. + */ + public function onDate(string $date): static + { + return $this->state(fn (array $attributes) => [ + 'date' => $date, + ]); + } +} diff --git a/database/factories/AnalyticsEventFactory.php b/database/factories/AnalyticsEventFactory.php new file mode 100644 index 00000000..13fee30c --- /dev/null +++ b/database/factories/AnalyticsEventFactory.php @@ -0,0 +1,65 @@ + + */ +class AnalyticsEventFactory extends Factory +{ + /** + * Define the model's default state (spec 07 section 2.26). + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'type' => $this->faker->randomElement([ + 'page_view', 'product_view', 'add_to_cart', 'remove_from_cart', + 'checkout_started', 'checkout_completed', 'search', + ]), + 'session_id' => $this->faker->uuid(), + 'customer_id' => null, + 'properties_json' => [ + 'url' => '/'.$this->faker->slug(), + 'referrer' => $this->faker->boolean(40) ? $this->faker->url() : null, + ], + 'created_at' => $this->faker->dateTimeBetween('-7 days'), + ]; + } + + public function pageView(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'page_view', + ]); + } + + public function productView(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'product_view', + 'properties_json' => [ + 'product_id' => $this->faker->numberBetween(1, 100), + 'product_title' => $this->faker->words(3, true), + 'url' => '/products/'.$this->faker->slug(), + ], + ]); + } + + public function addToCart(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'add_to_cart', + 'properties_json' => [ + 'variant_id' => $this->faker->numberBetween(1, 200), + 'quantity' => $this->faker->numberBetween(1, 3), + ], + ]); + } +} diff --git a/database/factories/AppFactory.php b/database/factories/AppFactory.php new file mode 100644 index 00000000..44e4242a --- /dev/null +++ b/database/factories/AppFactory.php @@ -0,0 +1,33 @@ + + */ +class AppFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => $this->faker->company().' '.$this->faker->randomElement(['Sync', 'Connect', 'Plugin', 'Integration']), + 'status' => AppStatus::Active, + 'created_at' => $this->faker->dateTimeBetween('-6 months'), + ]; + } + + public function disabled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => AppStatus::Disabled, + ]); + } +} diff --git a/database/factories/AppInstallationFactory.php b/database/factories/AppInstallationFactory.php new file mode 100644 index 00000000..c9a9f927 --- /dev/null +++ b/database/factories/AppInstallationFactory.php @@ -0,0 +1,37 @@ + + */ +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' => AppInstallationStatus::Active, + 'installed_at' => $this->faker->dateTimeBetween('-3 months'), + ]; + } + + public function uninstalled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => AppInstallationStatus::Uninstalled, + ]); + } +} diff --git a/database/factories/CartFactory.php b/database/factories/CartFactory.php new file mode 100644 index 00000000..67df7953 --- /dev/null +++ b/database/factories/CartFactory.php @@ -0,0 +1,60 @@ + + */ +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' => 'EUR', + 'cart_version' => 1, + 'status' => CartStatus::Active, + ]; + } + + /** + * Attach the cart to a new customer. + */ + public function forCustomer(): static + { + return $this->state(fn (array $attributes) => [ + 'customer_id' => Customer::factory(), + ]); + } + + /** + * Mark the cart as converted to an order. + */ + public function converted(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => CartStatus::Converted, + ]); + } + + /** + * Mark the cart as abandoned. + */ + public function abandoned(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => CartStatus::Abandoned, + ]); + } +} diff --git a/database/factories/CartLineFactory.php b/database/factories/CartLineFactory.php new file mode 100644 index 00000000..d14f273a --- /dev/null +++ b/database/factories/CartLineFactory.php @@ -0,0 +1,48 @@ + + */ +class CartLineFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $quantity = fake()->numberBetween(1, 5); + $unitPrice = fake()->numberBetween(999, 19999); + + return [ + 'cart_id' => Cart::factory(), + 'variant_id' => ProductVariant::factory(), + 'quantity' => $quantity, + 'unit_price_amount' => $unitPrice, + 'line_subtotal_amount' => $unitPrice * $quantity, + 'line_discount_amount' => 0, + 'line_total_amount' => $unitPrice * $quantity, + ]; + } + + /** + * Set an explicit quantity and unit price with consistent derived amounts. + */ + public function priced(int $unitPriceAmount, int $quantity = 1): static + { + return $this->state(fn (array $attributes) => [ + 'quantity' => $quantity, + 'unit_price_amount' => $unitPriceAmount, + 'line_subtotal_amount' => $unitPriceAmount * $quantity, + 'line_discount_amount' => 0, + 'line_total_amount' => $unitPriceAmount * $quantity, + ]); + } +} diff --git a/database/factories/CheckoutFactory.php b/database/factories/CheckoutFactory.php new file mode 100644 index 00000000..0fe1f8a5 --- /dev/null +++ b/database/factories/CheckoutFactory.php @@ -0,0 +1,96 @@ + + */ +class CheckoutFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'cart_id' => Cart::factory(), + 'customer_id' => null, + 'status' => CheckoutStatus::Started, + 'payment_method' => null, + 'email' => fake()->safeEmail(), + 'shipping_address_json' => [ + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'address1' => fake()->streetAddress(), + 'city' => fake()->city(), + 'country_code' => 'DE', + 'postal_code' => fake()->postcode(), + ], + 'billing_address_json' => null, + 'shipping_method_id' => null, + 'discount_code' => null, + 'tax_provider_snapshot_json' => null, + 'totals_json' => null, + 'expires_at' => now()->addDay(), + ]; + } + + /** + * Mark the checkout as completed. + */ + public function completed(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => CheckoutStatus::Completed, + ]); + } + + /** + * Mark the checkout as expired. + */ + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => CheckoutStatus::Expired, + 'expires_at' => now()->subHour(), + ]); + } + + /** + * Select credit card as the payment method. + */ + public function withCreditCard(): static + { + return $this->state(fn (array $attributes) => [ + 'payment_method' => 'credit_card', + ]); + } + + /** + * Select PayPal as the payment method. + */ + public function withPaypal(): static + { + return $this->state(fn (array $attributes) => [ + 'payment_method' => 'paypal', + ]); + } + + /** + * Select bank transfer as the payment method. + */ + public function withBankTransfer(): static + { + return $this->state(fn (array $attributes) => [ + 'payment_method' => 'bank_transfer', + ]); + } +} diff --git a/database/factories/CollectionFactory.php b/database/factories/CollectionFactory.php new file mode 100644 index 00000000..444032ca --- /dev/null +++ b/database/factories/CollectionFactory.php @@ -0,0 +1,53 @@ + + */ +class CollectionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $title = Str::title(fake()->unique()->words(2, true)); + + return [ + 'store_id' => Store::factory(), + 'title' => $title, + 'handle' => Str::slug($title), + 'description_html' => '

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

', + 'type' => 'manual', + 'status' => CollectionStatus::Active, + ]; + } + + /** + * Indicate that the collection is a draft. + */ + public function draft(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => CollectionStatus::Draft, + ]); + } + + /** + * Indicate that the collection is archived. + */ + public function archived(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => CollectionStatus::Archived, + ]); + } +} diff --git a/database/factories/CustomerAddressFactory.php b/database/factories/CustomerAddressFactory.php new file mode 100644 index 00000000..06388198 --- /dev/null +++ b/database/factories/CustomerAddressFactory.php @@ -0,0 +1,40 @@ + + */ +class CustomerAddressFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'customer_id' => Customer::factory(), + 'label' => 'Home', + 'address_json' => [ + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'company' => '', + 'address1' => fake()->streetAddress(), + 'address2' => '', + 'city' => fake()->city(), + 'province' => '', + 'province_code' => '', + 'country' => 'Germany', + 'country_code' => 'DE', + 'zip' => fake()->postcode(), + 'phone' => fake()->phoneNumber(), + ], + 'is_default' => true, + ]; + } +} diff --git a/database/factories/CustomerFactory.php b/database/factories/CustomerFactory.php new file mode 100644 index 00000000..11636d1f --- /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 is a guest checkout customer without a 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..6033ce97 --- /dev/null +++ b/database/factories/DiscountFactory.php @@ -0,0 +1,114 @@ + + */ +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('????##')), + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'starts_at' => now()->subMonth(), + 'ends_at' => now()->addYear(), + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [], + 'status' => DiscountStatus::Active, + ]; + } + + /** + * Use a fixed amount discount in minor units. + */ + public function fixed(int $amountCents): static + { + return $this->state(fn (array $attributes) => [ + 'value_type' => DiscountValueType::Fixed, + 'value_amount' => $amountCents, + ]); + } + + /** + * Use a free shipping discount. + */ + public function freeShipping(): static + { + return $this->state(fn (array $attributes) => [ + 'value_type' => DiscountValueType::FreeShipping, + 'value_amount' => 0, + ]); + } + + /** + * Mark the discount as past its end date. + */ + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'starts_at' => now()->subYear(), + 'ends_at' => now()->subDay(), + 'status' => DiscountStatus::Expired, + ]); + } + + /** + * Mark the discount as having reached its usage limit. + */ + public function maxedOut(): static + { + return $this->state(fn (array $attributes) => [ + 'usage_limit' => 5, + 'usage_count' => 5, + ]); + } + + /** + * Use an automatic (codeless) discount. + */ + public function automatic(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => DiscountType::Automatic, + 'code' => null, + ]); + } + + /** + * Mark the discount as a draft. + */ + public function draft(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => DiscountStatus::Draft, + ]); + } + + /** + * Mark the discount as manually disabled. + */ + public function disabled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => DiscountStatus::Disabled, + 'starts_at' => now()->subMonth(), + ]); + } +} diff --git a/database/factories/FulfillmentFactory.php b/database/factories/FulfillmentFactory.php new file mode 100644 index 00000000..1d80417e --- /dev/null +++ b/database/factories/FulfillmentFactory.php @@ -0,0 +1,50 @@ + + */ +class FulfillmentFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'status' => FulfillmentShipmentStatus::Pending, + ]; + } + + /** + * A shipped fulfillment with tracking data. + */ + public function shipped(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => FulfillmentShipmentStatus::Shipped, + 'tracking_company' => 'DHL', + 'tracking_number' => strtoupper(fake()->bothify('DHL##########')), + 'shipped_at' => now()->subDay(), + ]); + } + + /** + * A delivered fulfillment. + */ + public function delivered(): static + { + return $this->shipped()->state(fn (array $attributes) => [ + 'status' => FulfillmentShipmentStatus::Delivered, + 'delivered_at' => now(), + ]); + } +} diff --git a/database/factories/FulfillmentLineFactory.php b/database/factories/FulfillmentLineFactory.php new file mode 100644 index 00000000..ac74060b --- /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..5d4f487c --- /dev/null +++ b/database/factories/InventoryItemFactory.php @@ -0,0 +1,62 @@ + + */ +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' => 0, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ]; + } + + /** + * Tie the inventory item to an existing variant and its product's store. + */ + public function forVariant(ProductVariant $variant): static + { + return $this->state(fn (array $attributes) => [ + 'variant_id' => $variant->getKey(), + 'store_id' => $variant->product->store_id, + ]); + } + + /** + * Set the on-hand stock level. + */ + public function withStock(int $quantityOnHand, int $quantityReserved = 0): static + { + return $this->state(fn (array $attributes) => [ + 'quantity_on_hand' => $quantityOnHand, + 'quantity_reserved' => $quantityReserved, + ]); + } + + /** + * Use the "continue" oversell policy. + */ + public function continueSelling(): 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..cfd8596e --- /dev/null +++ b/database/factories/NavigationItemFactory.php @@ -0,0 +1,67 @@ + + */ +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' => Str::title(fake()->words(2, true)), + 'url' => '/', + 'resource_id' => null, + 'position' => 0, + ]; + } + + /** + * Indicate that the item links to a CMS page. + */ + public function page(int $pageId): static + { + return $this->state(fn (array $attributes) => [ + 'type' => NavigationItemType::Page, + 'url' => null, + 'resource_id' => $pageId, + ]); + } + + /** + * Indicate that the item links to a collection. + */ + public function collection(int $collectionId): static + { + return $this->state(fn (array $attributes) => [ + 'type' => NavigationItemType::Collection, + 'url' => null, + 'resource_id' => $collectionId, + ]); + } + + /** + * Indicate that the item links to a product. + */ + public function product(int $productId): static + { + return $this->state(fn (array $attributes) => [ + 'type' => NavigationItemType::Product, + 'url' => null, + 'resource_id' => $productId, + ]); + } +} diff --git a/database/factories/NavigationMenuFactory.php b/database/factories/NavigationMenuFactory.php new file mode 100644 index 00000000..9ecf9d12 --- /dev/null +++ b/database/factories/NavigationMenuFactory.php @@ -0,0 +1,29 @@ + + */ +class NavigationMenuFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $title = Str::title(fake()->unique()->words(2, true)); + + return [ + 'store_id' => Store::factory(), + 'handle' => Str::slug($title), + 'title' => $title, + ]; + } +} diff --git a/database/factories/OauthClientFactory.php b/database/factories/OauthClientFactory.php new file mode 100644 index 00000000..fa725996 --- /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' => (string) Str::uuid(), + 'client_secret_encrypted' => Str::random(40), + 'redirect_uris_json' => ['https://app.example.test/oauth/callback'], + ]; + } +} diff --git a/database/factories/OauthTokenFactory.php b/database/factories/OauthTokenFactory.php new file mode 100644 index 00000000..dc3b5dc5 --- /dev/null +++ b/database/factories/OauthTokenFactory.php @@ -0,0 +1,35 @@ + + */ +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' => hash('sha256', Str::random(40)), + 'expires_at' => now()->addHour(), + ]; + } + + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'expires_at' => now()->subDay(), + ]); + } +} diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php new file mode 100644 index 00000000..33b36695 --- /dev/null +++ b/database/factories/OrderFactory.php @@ -0,0 +1,100 @@ + + */ +class OrderFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'customer_id' => null, + 'checkout_id' => null, + 'order_number' => '#'.fake()->unique()->numberBetween(1001, 999999), + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled, + 'currency' => 'USD', + 'subtotal_amount' => 5000, + 'discount_amount' => 0, + 'shipping_amount' => 0, + 'tax_amount' => 0, + 'total_amount' => 5000, + 'email' => fake()->safeEmail(), + 'placed_at' => now(), + ]; + } + + /** + * A pending bank transfer order awaiting payment confirmation. + */ + public function pending(): static + { + return $this->state(fn (array $attributes) => [ + 'payment_method' => PaymentMethod::BankTransfer, + 'status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + ]); + } + + /** + * A paid order awaiting fulfillment. + */ + public function paid(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => OrderStatus::Paid, + 'financial_status' => FinancialStatus::Paid, + ]); + } + + /** + * A fully fulfilled, paid order. + */ + public function fulfilled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => OrderStatus::Fulfilled, + 'financial_status' => FinancialStatus::Paid, + 'fulfillment_status' => FulfillmentStatus::Fulfilled, + ]); + } + + /** + * A cancelled order. + */ + public function cancelled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => OrderStatus::Cancelled, + 'financial_status' => FinancialStatus::Voided, + ]); + } + + /** + * Set a specific monetary total (subtotal equals total). + */ + public function totaling(int $totalAmount): static + { + return $this->state(fn (array $attributes) => [ + 'subtotal_amount' => $totalAmount, + 'total_amount' => $totalAmount, + ]); + } +} diff --git a/database/factories/OrderLineFactory.php b/database/factories/OrderLineFactory.php new file mode 100644 index 00000000..c3901498 --- /dev/null +++ b/database/factories/OrderLineFactory.php @@ -0,0 +1,49 @@ + + */ +class OrderLineFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'product_id' => null, + 'variant_id' => null, + 'title_snapshot' => fake()->words(3, true), + 'sku_snapshot' => strtoupper(fake()->bothify('SKU-####')), + 'quantity' => 1, + 'unit_price_amount' => 2500, + 'total_amount' => 2500, + 'tax_lines_json' => [], + 'discount_allocations_json' => [], + ]; + } + + /** + * Reference an existing variant, snapshotting its product and SKU. + */ + public function forVariant(ProductVariant $variant): static + { + return $this->state(fn (array $attributes) => [ + 'product_id' => $variant->product_id, + 'variant_id' => $variant->getKey(), + 'title_snapshot' => $variant->product->title, + 'sku_snapshot' => $variant->sku, + 'unit_price_amount' => $variant->price_amount, + 'total_amount' => $variant->price_amount * ($attributes['quantity'] ?? 1), + ]); + } +} diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php new file mode 100644 index 00000000..1ce744ff --- /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()->companyEmail(), + ]; + } +} diff --git a/database/factories/PageFactory.php b/database/factories/PageFactory.php new file mode 100644 index 00000000..76f18763 --- /dev/null +++ b/database/factories/PageFactory.php @@ -0,0 +1,59 @@ + + */ +class PageFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $title = Str::title(fake()->unique()->words(3, true)); + + $body = '

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

' + .'

'.fake()->paragraph().'

' + .'

'.fake()->paragraph().'

' + .'

'.fake()->paragraph().'

'; + + return [ + 'store_id' => Store::factory(), + 'title' => $title, + 'handle' => Str::slug($title), + 'body_html' => $body, + 'status' => PageStatus::Published, + 'published_at' => now(), + ]; + } + + /** + * Indicate that the page is a draft. + */ + public function draft(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PageStatus::Draft, + 'published_at' => null, + ]); + } + + /** + * Indicate that the page is archived. + */ + public function archived(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PageStatus::Archived, + ]); + } +} diff --git a/database/factories/PaymentFactory.php b/database/factories/PaymentFactory.php new file mode 100644 index 00000000..dcedc079 --- /dev/null +++ b/database/factories/PaymentFactory.php @@ -0,0 +1,64 @@ + + */ +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::lower(Str::random(16)), + 'status' => PaymentStatus::Captured, + 'amount' => 5000, + 'currency' => 'USD', + ]; + } + + /** + * A captured (successful) payment. + */ + public function captured(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PaymentStatus::Captured, + ]); + } + + /** + * A pending payment (bank transfer awaiting confirmation). + */ + public function pending(): static + { + return $this->state(fn (array $attributes) => [ + 'method' => PaymentMethod::BankTransfer, + 'status' => PaymentStatus::Pending, + ]); + } + + /** + * A fully refunded payment. + */ + public function refunded(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PaymentStatus::Refunded, + ]); + } +} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php new file mode 100644 index 00000000..64de3632 --- /dev/null +++ b/database/factories/ProductFactory.php @@ -0,0 +1,68 @@ + + */ +class ProductFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $title = Str::title(fake()->unique()->words(3, true)); + + return [ + 'store_id' => Store::factory(), + 'title' => $title, + 'handle' => Str::slug($title), + 'status' => ProductStatus::Draft, + 'description_html' => '

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

', + 'vendor' => fake()->company(), + 'product_type' => fake()->word(), + 'tags' => [], + '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 a draft. + */ + public function draft(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => ProductStatus::Draft, + 'published_at' => null, + ]); + } + + /** + * Indicate that the product is archived. + */ + public function archived(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => ProductStatus::Archived, + ]); + } +} diff --git a/database/factories/ProductMediaFactory.php b/database/factories/ProductMediaFactory.php new file mode 100644 index 00000000..891a8d46 --- /dev/null +++ b/database/factories/ProductMediaFactory.php @@ -0,0 +1,48 @@ + + */ +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/'.Str::uuid().'.jpg', + 'alt_text' => null, + 'width' => null, + 'height' => null, + 'mime_type' => 'image/jpeg', + 'byte_size' => fake()->numberBetween(10_000, 500_000), + 'position' => 0, + 'status' => MediaStatus::Processing, + ]; + } + + /** + * Indicate that the media has finished processing. + */ + public function ready(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => MediaStatus::Ready, + 'width' => 1200, + 'height' => 800, + ]); + } +} diff --git a/database/factories/ProductOptionFactory.php b/database/factories/ProductOptionFactory.php new file mode 100644 index 00000000..a560784b --- /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..cf4b1afa --- /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..4b9eaffa --- /dev/null +++ b/database/factories/ProductVariantFactory.php @@ -0,0 +1,65 @@ + + */ +class ProductVariantFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'sku' => null, + 'barcode' => null, + 'price_amount' => fake()->numberBetween(500, 50000), + 'compare_at_amount' => null, + 'currency' => 'EUR', + 'weight_g' => fake()->numberBetween(50, 2000), + 'requires_shipping' => true, + 'is_default' => false, + 'position' => 0, + 'status' => VariantStatus::Active, + ]; + } + + /** + * Indicate that the variant is the product's default variant. + */ + public function asDefault(): 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, + ]); + } + + /** + * Set an explicit price in minor units. + */ + public function priced(int $priceAmount): static + { + return $this->state(fn (array $attributes) => [ + 'price_amount' => $priceAmount, + ]); + } +} diff --git a/database/factories/RefundFactory.php b/database/factories/RefundFactory.php new file mode 100644 index 00000000..3358e1ca --- /dev/null +++ b/database/factories/RefundFactory.php @@ -0,0 +1,43 @@ + + */ +class RefundFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'payment_id' => Payment::factory(), + 'amount' => 5000, + 'reason' => null, + 'status' => RefundStatus::Processed, + 'provider_refund_id' => 'mock_re_'.Str::lower(Str::random(16)), + ]; + } + + /** + * A refund still awaiting provider confirmation. + */ + public function pending(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => RefundStatus::Pending, + 'provider_refund_id' => null, + ]); + } +} diff --git a/database/factories/SearchQueryFactory.php b/database/factories/SearchQueryFactory.php new file mode 100644 index 00000000..2a045f49 --- /dev/null +++ b/database/factories/SearchQueryFactory.php @@ -0,0 +1,28 @@ + + */ +class SearchQueryFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'query' => $this->faker->words(2, true), + 'filters_json' => null, + 'results_count' => $this->faker->numberBetween(0, 30), + 'created_at' => $this->faker->dateTimeBetween('-7 days'), + ]; + } +} diff --git a/database/factories/SearchSettingsFactory.php b/database/factories/SearchSettingsFactory.php new file mode 100644 index 00000000..67e55f88 --- /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 explicit synonym groups. + * + * @param list> $groups + */ + public function withSynonyms(array $groups): static + { + return $this->state(fn (array $attributes) => [ + 'synonyms_json' => $groups, + ]); + } + + /** + * Set explicit 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..4d8d2f2d --- /dev/null +++ b/database/factories/ShippingRateFactory.php @@ -0,0 +1,67 @@ + + */ +class ShippingRateFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'zone_id' => ShippingZone::factory(), + 'name' => fake()->randomElement(['Standard', 'Express', 'Economy']), + 'type' => ShippingRateType::Flat, + 'config_json' => ['amount' => 499], + 'is_active' => true, + ]; + } + + /** + * Mark the rate as inactive. + */ + public function inactive(): static + { + return $this->state(fn (array $attributes) => [ + 'is_active' => false, + ]); + } + + /** + * Use a weight-based tier configuration. + */ + public function weightBased(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => ShippingRateType::Weight, + 'config_json' => [ + 'ranges' => [ + ['min_g' => 0, 'max_g' => 500, 'amount' => 399], + ['min_g' => 501, 'max_g' => 2000, 'amount' => 699], + ['min_g' => 2001, 'max_g' => 1000000, 'amount' => 1299], + ], + ], + ]); + } + + /** + * Use a flat rate with an explicit amount. + */ + public function flatAmount(int $amount): static + { + return $this->state(fn (array $attributes) => [ + 'type' => ShippingRateType::Flat, + 'config_json' => ['amount' => $amount], + ]); + } +} diff --git a/database/factories/ShippingZoneFactory.php b/database/factories/ShippingZoneFactory.php new file mode 100644 index 00000000..49ae6a20 --- /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()->country(), + 'countries_json' => ['DE'], + 'regions_json' => [], + ]; + } +} diff --git a/database/factories/StoreDomainFactory.php b/database/factories/StoreDomainFactory.php new file mode 100644 index 00000000..51329480 --- /dev/null +++ b/database/factories/StoreDomainFactory.php @@ -0,0 +1,58 @@ + + */ +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' => 'storefront', + 'is_primary' => true, + 'tls_mode' => 'managed', + ]; + } + + /** + * Indicate that the domain serves the admin panel. + */ + public function admin(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'admin', + ]); + } + + /** + * Indicate that the domain serves the API. + */ + public function api(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'api', + ]); + } + + /** + * Indicate that the domain is not the store's primary domain. + */ + public function secondary(): static + { + return $this->state(fn (array $attributes) => [ + 'is_primary' => false, + ]); + } +} diff --git a/database/factories/StoreFactory.php b/database/factories/StoreFactory.php new file mode 100644 index 00000000..aedebd15 --- /dev/null +++ b/database/factories/StoreFactory.php @@ -0,0 +1,41 @@ + + */ +class StoreFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'organization_id' => Organization::factory(), + 'name' => fake()->company().' Store', + 'handle' => Str::slug(fake()->unique()->words(2, true)), + 'status' => 'active', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ]; + } + + /** + * Indicate that the store is suspended. + */ + public function suspended(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'suspended', + ]); + } +} diff --git a/database/factories/StoreSettingsFactory.php b/database/factories/StoreSettingsFactory.php new file mode 100644 index 00000000..de48bbe6 --- /dev/null +++ b/database/factories/StoreSettingsFactory.php @@ -0,0 +1,30 @@ + + */ +class StoreSettingsFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'settings_json' => [ + 'store_name' => fake()->company(), + 'contact_email' => fake()->companyEmail(), + 'order_number_prefix' => '#', + 'order_number_start' => 1001, + ], + ]; + } +} diff --git a/database/factories/TaxSettingsFactory.php b/database/factories/TaxSettingsFactory.php new file mode 100644 index 00000000..9d0927ae --- /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], + ]; + } + + /** + * Use tax-inclusive pricing. + */ + public function pricesIncludeTax(): static + { + return $this->state(fn (array $attributes) => [ + 'prices_include_tax' => true, + ]); + } + + /** + * Set an explicit manual rate in basis points. + */ + public function rateBasisPoints(int $rateBasisPoints): static + { + return $this->state(fn (array $attributes) => [ + 'config_json' => ['default_rate_bps' => $rateBasisPoints], + ]); + } +} diff --git a/database/factories/ThemeFactory.php b/database/factories/ThemeFactory.php new file mode 100644 index 00000000..d84be3df --- /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' => 'Default Theme', + 'version' => '1.0.0', + 'status' => ThemeStatus::Published, + 'published_at' => now(), + ]; + } + + /** + * Indicate that the theme is a draft. + */ + public function draft(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => ThemeStatus::Draft, + 'published_at' => null, + ]); + } +} diff --git a/database/factories/ThemeFileFactory.php b/database/factories/ThemeFileFactory.php new file mode 100644 index 00000000..036c3e81 --- /dev/null +++ b/database/factories/ThemeFileFactory.php @@ -0,0 +1,31 @@ + + */ +class ThemeFileFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $path = 'templates/'.fake()->unique()->slug(2).'.blade.php'; + $contents = fake()->paragraph(); + + return [ + 'theme_id' => Theme::factory(), + 'path' => $path, + 'storage_key' => 'themes/'.$path, + 'sha256' => hash('sha256', $contents), + 'byte_size' => strlen($contents), + ]; + } +} diff --git a/database/factories/ThemeSettingsFactory.php b/database/factories/ThemeSettingsFactory.php new file mode 100644 index 00000000..bbd60557 --- /dev/null +++ b/database/factories/ThemeSettingsFactory.php @@ -0,0 +1,37 @@ + + */ +class ThemeSettingsFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'theme_id' => Theme::factory(), + 'settings_json' => [], + ]; + } + + /** + * Set explicit settings on the theme settings row. + * + * @param array $settings + */ + public function withSettings(array $settings): static + { + return $this->state(fn (array $attributes) => [ + 'settings_json' => $settings, + ]); + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 80da5ac7..02d45fd8 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -27,7 +27,9 @@ 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', + 'last_login_at' => fake()->dateTimeBetween('-30 days'), 'remember_token' => Str::random(10), 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, @@ -45,6 +47,16 @@ public function unverified(): static ]); } + /** + * Indicate that the user account is disabled. + */ + public function disabled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'disabled', + ]); + } + /** * Indicate that the model has two-factor authentication configured. */ diff --git a/database/factories/WebhookDeliveryFactory.php b/database/factories/WebhookDeliveryFactory.php new file mode 100644 index 00000000..dd83a369 --- /dev/null +++ b/database/factories/WebhookDeliveryFactory.php @@ -0,0 +1,54 @@ + + */ +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' => 0, + 'status' => WebhookDeliveryStatus::Pending, + 'last_attempt_at' => null, + 'response_code' => null, + 'response_body_snippet' => null, + ]; + } + + public function succeeded(): static + { + return $this->state(fn (array $attributes) => [ + 'attempt_count' => 1, + 'status' => WebhookDeliveryStatus::Success, + 'last_attempt_at' => now()->subMinutes(5), + 'response_code' => 200, + 'response_body_snippet' => '{"ok":true}', + ]); + } + + public function failed(): static + { + return $this->state(fn (array $attributes) => [ + 'attempt_count' => 6, + 'status' => WebhookDeliveryStatus::Failed, + 'last_attempt_at' => now()->subMinutes(5), + '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..dfadea42 --- /dev/null +++ b/database/factories/WebhookSubscriptionFactory.php @@ -0,0 +1,47 @@ + + */ +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' => $this->faker->randomElement(WebhookService::EVENT_TYPES), + 'target_url' => 'https://'.$this->faker->domainName().'/webhooks', + 'signing_secret_encrypted' => 'whsec_'.Str::random(32), + 'status' => WebhookSubscriptionStatus::Active, + 'consecutive_failures' => 0, + ]; + } + + public function paused(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => WebhookSubscriptionStatus::Paused, + ]); + } + + public function disabled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => WebhookSubscriptionStatus::Disabled, + ]); + } +} 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..167578fc 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,16 @@ public function up(): void { Schema::create('users', function (Blueprint $table) { $table->id(); - $table->string('name'); $table->string('email')->unique(); + $table->string('password_hash'); + $table->string('name'); + $table->enum('status', ['active', 'disabled'])->default('active'); $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); + $table->timestamp('last_login_at')->nullable(); $table->rememberToken(); $table->timestamps(); + + $table->index('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 index 187d974d..a008f488 100644 --- 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 @@ -12,7 +12,7 @@ public function up(): void { Schema::table('users', function (Blueprint $table) { - $table->text('two_factor_secret')->after('password')->nullable(); + $table->text('two_factor_secret')->after('password_hash')->nullable(); $table->text('two_factor_recovery_codes')->after('two_factor_secret')->nullable(); $table->timestamp('two_factor_confirmed_at')->after('two_factor_recovery_codes')->nullable(); }); diff --git a/database/migrations/2026_06_09_000001_create_organizations_table.php b/database/migrations/2026_06_09_000001_create_organizations_table.php new file mode 100644 index 00000000..1d1bf767 --- /dev/null +++ b/database/migrations/2026_06_09_000001_create_organizations_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name'); + $table->string('billing_email'); + $table->timestamps(); + + $table->index('billing_email'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('organizations'); + } +}; diff --git a/database/migrations/2026_06_09_000002_create_stores_table.php b/database/migrations/2026_06_09_000002_create_stores_table.php new file mode 100644 index 00000000..e64eb4a9 --- /dev/null +++ b/database/migrations/2026_06_09_000002_create_stores_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('organization_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('handle')->unique(); + $table->enum('status', ['active', 'suspended'])->default('active'); + $table->string('default_currency')->default('USD'); + $table->string('default_locale')->default('en'); + $table->string('timezone')->default('UTC'); + $table->timestamps(); + + $table->index('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('stores'); + } +}; diff --git a/database/migrations/2026_06_09_000003_create_store_domains_table.php b/database/migrations/2026_06_09_000003_create_store_domains_table.php new file mode 100644 index 00000000..b62d222f --- /dev/null +++ b/database/migrations/2026_06_09_000003_create_store_domains_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('hostname')->unique(); + $table->enum('type', ['storefront', 'admin', 'api'])->default('storefront'); + $table->boolean('is_primary')->default(false); + $table->enum('tls_mode', ['managed', 'bring_your_own'])->default('managed'); + $table->timestamp('created_at')->nullable(); + + $table->index(['store_id', 'is_primary']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('store_domains'); + } +}; diff --git a/database/migrations/2026_06_09_000004_create_store_users_table.php b/database/migrations/2026_06_09_000004_create_store_users_table.php new file mode 100644 index 00000000..8d8e602c --- /dev/null +++ b/database/migrations/2026_06_09_000004_create_store_users_table.php @@ -0,0 +1,33 @@ +foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->enum('role', ['owner', 'admin', 'staff', 'support'])->default('staff'); + $table->timestamp('created_at')->nullable(); + + $table->primary(['store_id', 'user_id']); + $table->index('user_id'); + $table->index(['store_id', 'role']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('store_users'); + } +}; diff --git a/database/migrations/2026_06_09_000005_create_store_settings_table.php b/database/migrations/2026_06_09_000005_create_store_settings_table.php new file mode 100644 index 00000000..33fc0d5f --- /dev/null +++ b/database/migrations/2026_06_09_000005_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_06_09_000006_create_customers_table.php b/database/migrations/2026_06_09_000006_create_customers_table.php new file mode 100644 index 00000000..3d97c9ce --- /dev/null +++ b/database/migrations/2026_06_09_000006_create_customers_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('email'); + $table->string('password_hash')->nullable(); + $table->string('name')->nullable(); + $table->boolean('marketing_opt_in')->default(false); + $table->timestamps(); + + $table->unique(['store_id', 'email']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('customers'); + } +}; diff --git a/database/migrations/2026_06_09_000007_create_customer_addresses_table.php b/database/migrations/2026_06_09_000007_create_customer_addresses_table.php new file mode 100644 index 00000000..905d3e72 --- /dev/null +++ b/database/migrations/2026_06_09_000007_create_customer_addresses_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('customer_id')->constrained()->cascadeOnDelete(); + $table->string('label')->nullable(); + $table->text('address_json')->default('{}'); + $table->boolean('is_default')->default(false); + + $table->index(['customer_id', 'is_default']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('customer_addresses'); + } +}; diff --git a/database/migrations/2026_06_09_000008_create_customer_password_reset_tokens_table.php b/database/migrations/2026_06_09_000008_create_customer_password_reset_tokens_table.php new file mode 100644 index 00000000..a9ff55e8 --- /dev/null +++ b/database/migrations/2026_06_09_000008_create_customer_password_reset_tokens_table.php @@ -0,0 +1,31 @@ +foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('email'); + $table->string('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_06_09_100001_create_products_table.php b/database/migrations/2026_06_09_100001_create_products_table.php new file mode 100644 index 00000000..9cb75808 --- /dev/null +++ b/database/migrations/2026_06_09_100001_create_products_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->enum('status', ['draft', 'active', 'archived'])->default('draft'); + $table->text('description_html')->nullable(); + $table->string('vendor')->nullable(); + $table->string('product_type')->nullable(); + $table->text('tags')->default('[]'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'handle']); + $table->index(['store_id', 'status']); + $table->index(['store_id', 'published_at']); + $table->index(['store_id', 'vendor']); + $table->index(['store_id', 'product_type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('products'); + } +}; diff --git a/database/migrations/2026_06_09_100002_create_product_options_table.php b/database/migrations/2026_06_09_100002_create_product_options_table.php new file mode 100644 index 00000000..21512f66 --- /dev/null +++ b/database/migrations/2026_06_09_100002_create_product_options_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->unsignedInteger('position')->default(0); + + $table->unique(['product_id', 'position']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_options'); + } +}; diff --git a/database/migrations/2026_06_09_100003_create_product_option_values_table.php b/database/migrations/2026_06_09_100003_create_product_option_values_table.php new file mode 100644 index 00000000..ff90ea1e --- /dev/null +++ b/database/migrations/2026_06_09_100003_create_product_option_values_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('product_option_id')->constrained()->cascadeOnDelete(); + $table->string('value'); + $table->unsignedInteger('position')->default(0); + + $table->unique(['product_option_id', 'position']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_option_values'); + } +}; diff --git a/database/migrations/2026_06_09_100004_create_product_variants_table.php b/database/migrations/2026_06_09_100004_create_product_variants_table.php new file mode 100644 index 00000000..73656050 --- /dev/null +++ b/database/migrations/2026_06_09_100004_create_product_variants_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('sku')->nullable(); + $table->string('barcode')->nullable(); + $table->integer('price_amount')->default(0); + $table->integer('compare_at_amount')->nullable(); + $table->string('currency', 3)->default('USD'); + $table->integer('weight_g')->nullable(); + $table->boolean('requires_shipping')->default(true); + $table->boolean('is_default')->default(false); + $table->unsignedInteger('position')->default(0); + $table->enum('status', ['active', 'archived'])->default('active'); + $table->timestamps(); + + $table->index('sku'); + $table->index('barcode'); + $table->index(['product_id', 'position']); + $table->index(['product_id', 'is_default']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_variants'); + } +}; diff --git a/database/migrations/2026_06_09_100005_create_variant_option_values_table.php b/database/migrations/2026_06_09_100005_create_variant_option_values_table.php new file mode 100644 index 00000000..14e99f4e --- /dev/null +++ b/database/migrations/2026_06_09_100005_create_variant_option_values_table.php @@ -0,0 +1,30 @@ +foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->foreignId('product_option_value_id')->constrained('product_option_values')->cascadeOnDelete(); + + $table->primary(['variant_id', 'product_option_value_id']); + $table->index('product_option_value_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('variant_option_values'); + } +}; diff --git a/database/migrations/2026_06_09_100006_create_inventory_items_table.php b/database/migrations/2026_06_09_100006_create_inventory_items_table.php new file mode 100644 index 00000000..91d07026 --- /dev/null +++ b/database/migrations/2026_06_09_100006_create_inventory_items_table.php @@ -0,0 +1,33 @@ +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->enum('policy', ['deny', 'continue'])->default('deny'); + + $table->unique('variant_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('inventory_items'); + } +}; diff --git a/database/migrations/2026_06_09_100007_create_collections_table.php b/database/migrations/2026_06_09_100007_create_collections_table.php new file mode 100644 index 00000000..1b5a10cd --- /dev/null +++ b/database/migrations/2026_06_09_100007_create_collections_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->text('description_html')->nullable(); + $table->enum('type', ['manual', 'automated'])->default('manual'); + $table->enum('status', ['draft', 'active', 'archived'])->default('active'); + $table->timestamps(); + + $table->unique(['store_id', 'handle']); + $table->index(['store_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('collections'); + } +}; diff --git a/database/migrations/2026_06_09_100008_create_collection_products_table.php b/database/migrations/2026_06_09_100008_create_collection_products_table.php new file mode 100644 index 00000000..3f884d42 --- /dev/null +++ b/database/migrations/2026_06_09_100008_create_collection_products_table.php @@ -0,0 +1,32 @@ +foreignId('collection_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('position')->default(0); + + $table->primary(['collection_id', 'product_id']); + $table->index('product_id'); + $table->index(['collection_id', 'position']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('collection_products'); + } +}; diff --git a/database/migrations/2026_06_09_100009_create_product_media_table.php b/database/migrations/2026_06_09_100009_create_product_media_table.php new file mode 100644 index 00000000..3f22eb3a --- /dev/null +++ b/database/migrations/2026_06_09_100009_create_product_media_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->enum('type', ['image', 'video'])->default('image'); + $table->string('storage_key'); + $table->string('alt_text')->nullable(); + $table->integer('width')->nullable(); + $table->integer('height')->nullable(); + $table->string('mime_type')->nullable(); + $table->integer('byte_size')->nullable(); + $table->unsignedInteger('position')->default(0); + $table->enum('status', ['processing', 'ready', 'failed'])->default('processing'); + $table->timestamp('created_at')->nullable(); + + $table->index(['product_id', 'position']); + $table->index('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_media'); + } +}; diff --git a/database/migrations/2026_06_09_200001_create_themes_table.php b/database/migrations/2026_06_09_200001_create_themes_table.php new file mode 100644 index 00000000..72925274 --- /dev/null +++ b/database/migrations/2026_06_09_200001_create_themes_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('version')->nullable(); + $table->enum('status', ['draft', 'published'])->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->index('store_id'); + $table->index(['store_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('themes'); + } +}; diff --git a/database/migrations/2026_06_09_200002_create_theme_files_table.php b/database/migrations/2026_06_09_200002_create_theme_files_table.php new file mode 100644 index 00000000..a0c7c8cb --- /dev/null +++ b/database/migrations/2026_06_09_200002_create_theme_files_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('theme_id')->constrained()->cascadeOnDelete(); + $table->string('path'); + $table->string('storage_key'); + $table->string('sha256'); + $table->unsignedBigInteger('byte_size')->default(0); + + $table->unique(['theme_id', 'path']); + $table->index('theme_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('theme_files'); + } +}; diff --git a/database/migrations/2026_06_09_200003_create_theme_settings_table.php b/database/migrations/2026_06_09_200003_create_theme_settings_table.php new file mode 100644 index 00000000..90a84785 --- /dev/null +++ b/database/migrations/2026_06_09_200003_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_06_09_200004_create_pages_table.php b/database/migrations/2026_06_09_200004_create_pages_table.php new file mode 100644 index 00000000..2e52d953 --- /dev/null +++ b/database/migrations/2026_06_09_200004_create_pages_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->text('body_html')->nullable(); + $table->enum('status', ['draft', 'published', 'archived'])->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'handle']); + $table->index('store_id'); + $table->index(['store_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('pages'); + } +}; diff --git a/database/migrations/2026_06_09_200005_create_navigation_menus_table.php b/database/migrations/2026_06_09_200005_create_navigation_menus_table.php new file mode 100644 index 00000000..bf2a3af0 --- /dev/null +++ b/database/migrations/2026_06_09_200005_create_navigation_menus_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('handle'); + $table->string('title'); + $table->timestamps(); + + $table->unique(['store_id', 'handle']); + $table->index('store_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('navigation_menus'); + } +}; diff --git a/database/migrations/2026_06_09_200006_create_navigation_items_table.php b/database/migrations/2026_06_09_200006_create_navigation_items_table.php new file mode 100644 index 00000000..62eed274 --- /dev/null +++ b/database/migrations/2026_06_09_200006_create_navigation_items_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('menu_id')->constrained('navigation_menus')->cascadeOnDelete(); + $table->enum('type', ['link', 'page', 'collection', 'product'])->default('link'); + $table->string('label'); + $table->string('url')->nullable(); + $table->unsignedBigInteger('resource_id')->nullable(); + $table->unsignedInteger('position')->default(0); + + $table->index('menu_id'); + $table->index(['menu_id', 'position']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('navigation_items'); + } +}; diff --git a/database/migrations/2026_06_09_300001_create_carts_table.php b/database/migrations/2026_06_09_300001_create_carts_table.php new file mode 100644 index 00000000..cf5dfd79 --- /dev/null +++ b/database/migrations/2026_06_09_300001_create_carts_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->string('currency', 3)->default('USD'); + $table->integer('cart_version')->default(1); + $table->enum('status', ['active', 'converted', 'abandoned'])->default('active'); + $table->timestamps(); + + $table->index(['store_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('carts'); + } +}; diff --git a/database/migrations/2026_06_09_300002_create_cart_lines_table.php b/database/migrations/2026_06_09_300002_create_cart_lines_table.php new file mode 100644 index 00000000..40ba6c3b --- /dev/null +++ b/database/migrations/2026_06_09_300002_create_cart_lines_table.php @@ -0,0 +1,35 @@ +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->unique(['cart_id', 'variant_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cart_lines'); + } +}; diff --git a/database/migrations/2026_06_09_300003_create_checkouts_table.php b/database/migrations/2026_06_09_300003_create_checkouts_table.php new file mode 100644 index 00000000..c268ef4d --- /dev/null +++ b/database/migrations/2026_06_09_300003_create_checkouts_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('cart_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->enum('status', ['started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired'])->default('started'); + $table->enum('payment_method', ['credit_card', 'paypal', 'bank_transfer'])->nullable(); + $table->string('email')->nullable(); + $table->text('shipping_address_json')->nullable(); + $table->text('billing_address_json')->nullable(); + $table->integer('shipping_method_id')->nullable(); + $table->string('discount_code')->nullable(); + $table->text('tax_provider_snapshot_json')->nullable(); + $table->text('totals_json')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + + $table->index(['store_id', 'status']); + $table->index('expires_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('checkouts'); + } +}; diff --git a/database/migrations/2026_06_09_300004_create_shipping_zones_table.php b/database/migrations/2026_06_09_300004_create_shipping_zones_table.php new file mode 100644 index 00000000..f1433fb4 --- /dev/null +++ b/database/migrations/2026_06_09_300004_create_shipping_zones_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->text('countries_json')->default('[]'); + $table->text('regions_json')->default('[]'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shipping_zones'); + } +}; diff --git a/database/migrations/2026_06_09_300005_create_shipping_rates_table.php b/database/migrations/2026_06_09_300005_create_shipping_rates_table.php new file mode 100644 index 00000000..cda6f04a --- /dev/null +++ b/database/migrations/2026_06_09_300005_create_shipping_rates_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('zone_id')->constrained('shipping_zones')->cascadeOnDelete(); + $table->string('name'); + $table->enum('type', ['flat', 'weight', 'price', 'carrier'])->default('flat'); + $table->text('config_json')->default('{}'); + $table->boolean('is_active')->default(true); + + $table->index(['zone_id', 'is_active']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shipping_rates'); + } +}; diff --git a/database/migrations/2026_06_09_300006_create_tax_settings_table.php b/database/migrations/2026_06_09_300006_create_tax_settings_table.php new file mode 100644 index 00000000..cb46de1b --- /dev/null +++ b/database/migrations/2026_06_09_300006_create_tax_settings_table.php @@ -0,0 +1,30 @@ +foreignId('store_id')->primary()->constrained()->cascadeOnDelete(); + $table->enum('mode', ['manual', 'provider'])->default('manual'); + $table->enum('provider', ['stripe_tax', 'none'])->default('none'); + $table->boolean('prices_include_tax')->default(false); + $table->text('config_json')->default('{}'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tax_settings'); + } +}; diff --git a/database/migrations/2026_06_09_300007_create_discounts_table.php b/database/migrations/2026_06_09_300007_create_discounts_table.php new file mode 100644 index 00000000..163caba6 --- /dev/null +++ b/database/migrations/2026_06_09_300007_create_discounts_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->enum('type', ['code', 'automatic'])->default('code'); + $table->string('code')->nullable(); + $table->enum('value_type', ['fixed', 'percent', 'free_shipping']); + $table->integer('value_amount')->default(0); + $table->timestamp('starts_at'); + $table->timestamp('ends_at')->nullable(); + $table->integer('usage_limit')->nullable(); + $table->integer('usage_count')->default(0); + $table->text('rules_json')->default('{}'); + $table->enum('status', ['draft', 'active', 'expired', 'disabled'])->default('active'); + $table->timestamps(); + + $table->unique(['store_id', 'code']); + $table->index(['store_id', 'status']); + $table->index(['store_id', 'type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('discounts'); + } +}; diff --git a/database/migrations/2026_06_09_400001_create_orders_table.php b/database/migrations/2026_06_09_400001_create_orders_table.php new file mode 100644 index 00000000..e76c01fa --- /dev/null +++ b/database/migrations/2026_06_09_400001_create_orders_table.php @@ -0,0 +1,52 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('checkout_id')->nullable()->unique()->constrained()->nullOnDelete(); + $table->string('order_number'); + $table->enum('payment_method', ['credit_card', 'paypal', 'bank_transfer']); + $table->enum('status', ['pending', 'paid', 'fulfilled', 'cancelled', 'refunded'])->default('pending'); + $table->enum('financial_status', ['pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided'])->default('pending'); + $table->enum('fulfillment_status', ['unfulfilled', 'partial', 'fulfilled'])->default('unfulfilled'); + $table->string('currency', 3)->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->string('email')->nullable(); + $table->text('billing_address_json')->nullable(); + $table->text('shipping_address_json')->nullable(); + $table->timestamp('placed_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'order_number']); + $table->index('customer_id'); + $table->index(['store_id', 'status']); + $table->index(['store_id', 'financial_status']); + $table->index(['store_id', 'fulfillment_status']); + $table->index(['store_id', 'placed_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('orders'); + } +}; diff --git a/database/migrations/2026_06_09_400002_create_order_lines_table.php b/database/migrations/2026_06_09_400002_create_order_lines_table.php new file mode 100644 index 00000000..f6b56717 --- /dev/null +++ b/database/migrations/2026_06_09_400002_create_order_lines_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('variant_id')->nullable()->constrained('product_variants')->nullOnDelete(); + $table->string('title_snapshot'); + $table->string('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('[]'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('order_lines'); + } +}; diff --git a/database/migrations/2026_06_09_400003_create_payments_table.php b/database/migrations/2026_06_09_400003_create_payments_table.php new file mode 100644 index 00000000..f07ea903 --- /dev/null +++ b/database/migrations/2026_06_09_400003_create_payments_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->enum('provider', ['mock'])->default('mock'); + $table->enum('method', ['credit_card', 'paypal', 'bank_transfer']); + $table->string('provider_payment_id')->nullable(); + $table->enum('status', ['pending', 'captured', 'failed', 'refunded'])->default('pending'); + $table->integer('amount')->default(0); + $table->string('currency', 3)->default('USD'); + $table->text('raw_json_encrypted')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index(['provider', 'provider_payment_id']); + $table->index('method'); + $table->index('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('payments'); + } +}; diff --git a/database/migrations/2026_06_09_400004_create_refunds_table.php b/database/migrations/2026_06_09_400004_create_refunds_table.php new file mode 100644 index 00000000..c6962fbf --- /dev/null +++ b/database/migrations/2026_06_09_400004_create_refunds_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('payment_id')->constrained()->cascadeOnDelete(); + $table->integer('amount')->default(0); + $table->string('reason')->nullable(); + $table->enum('status', ['pending', 'processed', 'failed'])->default('pending'); + $table->string('provider_refund_id')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('refunds'); + } +}; diff --git a/database/migrations/2026_06_09_400005_create_fulfillments_table.php b/database/migrations/2026_06_09_400005_create_fulfillments_table.php new file mode 100644 index 00000000..d26abf31 --- /dev/null +++ b/database/migrations/2026_06_09_400005_create_fulfillments_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->enum('status', ['pending', 'shipped', 'delivered'])->default('pending'); + $table->string('tracking_company')->nullable(); + $table->string('tracking_number')->nullable(); + $table->string('tracking_url')->nullable(); + $table->timestamp('shipped_at')->nullable(); + $table->timestamp('delivered_at')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('status'); + $table->index(['tracking_company', 'tracking_number']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('fulfillments'); + } +}; diff --git a/database/migrations/2026_06_09_400006_create_fulfillment_lines_table.php b/database/migrations/2026_06_09_400006_create_fulfillment_lines_table.php new file mode 100644 index 00000000..fa332144 --- /dev/null +++ b/database/migrations/2026_06_09_400006_create_fulfillment_lines_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('fulfillment_id')->constrained()->cascadeOnDelete(); + $table->foreignId('order_line_id')->constrained()->cascadeOnDelete(); + $table->integer('quantity')->default(1); + + $table->unique(['fulfillment_id', 'order_line_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('fulfillment_lines'); + } +}; diff --git a/database/migrations/2026_06_10_053315_create_personal_access_tokens_table.php b/database/migrations/2026_06_10_053315_create_personal_access_tokens_table.php new file mode 100644 index 00000000..40ff706e --- /dev/null +++ b/database/migrations/2026_06_10_053315_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +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/migrations/2026_06_10_600001_create_search_settings_table.php b/database/migrations/2026_06_10_600001_create_search_settings_table.php new file mode 100644 index 00000000..a566052f --- /dev/null +++ b/database/migrations/2026_06_10_600001_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_06_10_600002_create_search_queries_table.php b/database/migrations/2026_06_10_600002_create_search_queries_table.php new file mode 100644 index 00000000..91087030 --- /dev/null +++ b/database/migrations/2026_06_10_600002_create_search_queries_table.php @@ -0,0 +1,34 @@ +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', 'created_at']); + $table->index(['store_id', 'query']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('search_queries'); + } +}; diff --git a/database/migrations/2026_06_10_600003_create_products_fts_virtual_table.php b/database/migrations/2026_06_10_600003_create_products_fts_virtual_table.php new file mode 100644 index 00000000..62566454 --- /dev/null +++ b/database/migrations/2026_06_10_600003_create_products_fts_virtual_table.php @@ -0,0 +1,48 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('type'); + $table->string('session_id')->nullable(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->text('properties_json')->default('{}'); + $table->string('client_event_id')->nullable(); + $table->timestamp('occurred_at')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index(['store_id', 'type']); + $table->index(['store_id', 'created_at']); + $table->index('session_id'); + $table->unique(['store_id', 'client_event_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('analytics_events'); + } +}; diff --git a/database/migrations/2026_06_10_700002_create_analytics_daily_table.php b/database/migrations/2026_06_10_700002_create_analytics_daily_table.php new file mode 100644 index 00000000..088632f2 --- /dev/null +++ b/database/migrations/2026_06_10_700002_create_analytics_daily_table.php @@ -0,0 +1,36 @@ +foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('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']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('analytics_daily'); + } +}; diff --git a/database/migrations/2026_06_10_800001_create_apps_table.php b/database/migrations/2026_06_10_800001_create_apps_table.php new file mode 100644 index 00000000..a9c7968f --- /dev/null +++ b/database/migrations/2026_06_10_800001_create_apps_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name'); + $table->string('status')->default('active'); + $table->timestamp('created_at')->nullable(); + + $table->index('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('apps'); + } +}; diff --git a/database/migrations/2026_06_10_800002_create_app_installations_table.php b/database/migrations/2026_06_10_800002_create_app_installations_table.php new file mode 100644 index 00000000..f8933fa0 --- /dev/null +++ b/database/migrations/2026_06_10_800002_create_app_installations_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('app_id')->constrained()->cascadeOnDelete(); + $table->text('scopes_json')->default('[]'); + $table->string('status')->default('active'); + $table->timestamp('installed_at')->nullable(); + + $table->unique(['store_id', 'app_id']); + $table->index('app_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('app_installations'); + } +}; diff --git a/database/migrations/2026_06_10_800003_create_oauth_clients_table.php b/database/migrations/2026_06_10_800003_create_oauth_clients_table.php new file mode 100644 index 00000000..93403676 --- /dev/null +++ b/database/migrations/2026_06_10_800003_create_oauth_clients_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('app_id')->constrained()->cascadeOnDelete(); + $table->string('client_id')->unique(); + $table->text('client_secret_encrypted'); + $table->text('redirect_uris_json')->default('[]'); + + $table->index('app_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_clients'); + } +}; diff --git a/database/migrations/2026_06_10_800004_create_oauth_tokens_table.php b/database/migrations/2026_06_10_800004_create_oauth_tokens_table.php new file mode 100644 index 00000000..3b4eafe5 --- /dev/null +++ b/database/migrations/2026_06_10_800004_create_oauth_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('installation_id')->constrained('app_installations')->cascadeOnDelete(); + $table->string('access_token_hash')->unique(); + $table->string('refresh_token_hash')->nullable(); + $table->timestamp('expires_at'); + + $table->index('installation_id'); + $table->index('expires_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_tokens'); + } +}; diff --git a/database/migrations/2026_06_10_800005_create_webhook_subscriptions_table.php b/database/migrations/2026_06_10_800005_create_webhook_subscriptions_table.php new file mode 100644 index 00000000..211cb30b --- /dev/null +++ b/database/migrations/2026_06_10_800005_create_webhook_subscriptions_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('app_installation_id')->nullable()->constrained('app_installations')->cascadeOnDelete(); + $table->string('event_type'); + $table->text('target_url'); + $table->text('signing_secret_encrypted'); + $table->string('status')->default('active'); + $table->unsignedInteger('consecutive_failures')->default(0); + + $table->index(['store_id', 'event_type']); + $table->index('app_installation_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_subscriptions'); + } +}; diff --git a/database/migrations/2026_06_10_800006_create_webhook_deliveries_table.php b/database/migrations/2026_06_10_800006_create_webhook_deliveries_table.php new file mode 100644 index 00000000..5cb91215 --- /dev/null +++ b/database/migrations/2026_06_10_800006_create_webhook_deliveries_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('subscription_id')->constrained('webhook_subscriptions')->cascadeOnDelete(); + $table->string('event_id'); + $table->unsignedInteger('attempt_count')->default(1); + $table->string('status')->default('pending'); + $table->timestamp('last_attempt_at')->nullable(); + $table->unsignedSmallInteger('response_code')->nullable(); + $table->text('response_body_snippet')->nullable(); + + $table->index('event_id'); + $table->index('status'); + $table->index('last_attempt_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_deliveries'); + } +}; diff --git a/database/migrations/2026_06_11_000001_repair_acme_fashion_storefront_seed_data.php b/database/migrations/2026_06_11_000001_repair_acme_fashion_storefront_seed_data.php new file mode 100644 index 00000000..23e6e033 --- /dev/null +++ b/database/migrations/2026_06_11_000001_repair_acme_fashion_storefront_seed_data.php @@ -0,0 +1,223 @@ +where('handle', 'acme-fashion')->first(['id']); + + if ($store === null) { + return; + } + + $storeId = (int) $store->id; + + $this->ensurePreviewDomain($storeId); + $this->repairPublishedTheme($storeId); + $this->repairNavigation($storeId); + + Cache::forget("theme_settings:{$storeId}"); + Cache::forget("navigation_tree:{$storeId}:main-menu"); + Cache::forget("navigation_tree:{$storeId}:footer-menu"); + } + + /** + * Reverse is intentionally empty: this is an idempotent data correction for + * preview/demo seed drift and should not remove user-visible storefront data. + */ + public function down(): void + { + // + } + + private function ensurePreviewDomain(int $storeId): void + { + $hosts = [ + '2026-06-09-claude-code-fable-5.agentic-engineers.dev', + parse_url((string) config('app.url'), PHP_URL_HOST) ?: null, + ]; + + foreach (array_filter(array_unique($hosts)) as $hostname) { + if (in_array($hostname, ['localhost', '127.0.0.1', '::1'], true)) { + continue; + } + + DB::table('store_domains')->updateOrInsert( + ['hostname' => strtolower($hostname)], + [ + 'store_id' => $storeId, + 'type' => 'storefront', + 'is_primary' => false, + 'tls_mode' => 'managed', + 'created_at' => now(), + ], + ); + + Cache::forget('store_domain:'.strtolower($hostname)); + } + } + + private function repairPublishedTheme(int $storeId): void + { + $now = now(); + $theme = DB::table('themes') + ->where('store_id', $storeId) + ->where('name', 'Default Theme') + ->first(['id']); + + if ($theme === null) { + $themeId = DB::table('themes')->insertGetId([ + 'store_id' => $storeId, + 'name' => 'Default Theme', + 'version' => '1.0.0', + 'status' => 'published', + 'published_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } else { + $themeId = (int) $theme->id; + + DB::table('themes') + ->where('id', $themeId) + ->update([ + 'version' => '1.0.0', + 'status' => 'published', + 'published_at' => $now, + 'updated_at' => $now, + ]); + } + + DB::table('theme_settings')->updateOrInsert( + ['theme_id' => $themeId], + [ + 'settings_json' => json_encode($this->fashionThemeSettings(), JSON_THROW_ON_ERROR), + 'updated_at' => $now, + ], + ); + } + + private function repairNavigation(int $storeId): void + { + $this->seedMenu($storeId, 'main-menu', 'Main Menu', [ + ['label' => 'Home', 'type' => 'link', 'url' => '/'], + ['label' => 'New Arrivals', 'type' => 'collection', 'handle' => 'new-arrivals'], + ['label' => 'T-Shirts', 'type' => 'collection', 'handle' => 't-shirts'], + ['label' => 'Pants & Jeans', 'type' => 'collection', 'handle' => 'pants-jeans'], + ['label' => 'Sale', 'type' => 'collection', 'handle' => 'sale'], + ]); + + $this->seedMenu($storeId, 'footer-menu', 'Footer Menu', [ + ['label' => 'About Us', 'type' => 'page', 'handle' => 'about'], + ['label' => 'FAQ', 'type' => 'page', 'handle' => 'faq'], + ['label' => 'Shipping & Returns', 'type' => 'page', 'handle' => 'shipping-returns'], + ['label' => 'Privacy Policy', 'type' => 'page', 'handle' => 'privacy-policy'], + ['label' => 'Terms of Service', 'type' => 'page', 'handle' => 'terms'], + ]); + } + + /** + * @param list $items + */ + private function seedMenu(int $storeId, string $handle, string $title, array $items): void + { + $now = now(); + $menu = DB::table('navigation_menus') + ->where('store_id', $storeId) + ->where('handle', $handle) + ->first(['id']); + + if ($menu === null) { + $menuId = DB::table('navigation_menus')->insertGetId([ + 'store_id' => $storeId, + 'handle' => $handle, + 'title' => $title, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } else { + $menuId = (int) $menu->id; + + DB::table('navigation_menus') + ->where('id', $menuId) + ->update(['title' => $title, 'updated_at' => $now]); + } + + foreach ($items as $position => $item) { + $resourceId = $this->resourceId($storeId, $item); + + DB::table('navigation_items')->updateOrInsert( + [ + 'menu_id' => $menuId, + 'label' => $item['label'], + ], + [ + 'type' => $item['type'], + 'url' => $item['url'] ?? null, + 'resource_id' => $resourceId, + 'position' => $position, + ], + ); + } + } + + /** + * @param array{type: string, handle?: string} $item + */ + private function resourceId(int $storeId, array $item): ?int + { + if (($item['handle'] ?? null) === null) { + return null; + } + + $table = match ($item['type']) { + 'collection' => 'collections', + 'page' => 'pages', + default => null, + }; + + if ($table === null) { + return null; + } + + $id = DB::table($table) + ->where('store_id', $storeId) + ->where('handle', $item['handle']) + ->value('id'); + + return $id === null ? null : (int) $id; + } + + /** + * @return array + */ + private function fashionThemeSettings(): array + { + return [ + 'primary_color' => '#1a1a2e', + 'secondary_color' => '#e94560', + 'font_family' => 'Inter, sans-serif', + 'hero_heading' => 'Welcome to Acme Fashion', + 'hero_subheading' => 'Discover our curated collection of modern essentials', + 'hero_cta_text' => 'Shop New Arrivals', + 'hero_cta_link' => '/collections/new-arrivals', + 'featured_collection_handles' => ['new-arrivals', 't-shirts', 'sale'], + 'featured_products_collection_handle' => 'new-arrivals', + 'footer_text' => '2025 Acme Fashion. All rights reserved.', + 'show_announcement_bar' => true, + 'announcement_text' => 'Free shipping on orders over 50 EUR - Use code FREESHIP', + 'products_per_page' => 12, + 'show_vendor' => true, + 'show_quantity_selector' => true, + ]; + } +}; diff --git a/database/migrations/2026_06_11_000002_repair_acme_fashion_collection_products.php b/database/migrations/2026_06_11_000002_repair_acme_fashion_collection_products.php new file mode 100644 index 00000000..059a6b6f --- /dev/null +++ b/database/migrations/2026_06_11_000002_repair_acme_fashion_collection_products.php @@ -0,0 +1,96 @@ +where('handle', 'acme-fashion')->first(['id']); + + if ($store === null) { + return; + } + + $storeId = (int) $store->id; + + foreach ($this->collectionProducts() as $collectionHandle => $productHandles) { + $collectionId = DB::table('collections') + ->where('store_id', $storeId) + ->where('handle', $collectionHandle) + ->value('id'); + + if ($collectionId === null) { + continue; + } + + foreach ($productHandles as $position => $productHandle) { + $productId = DB::table('products') + ->where('store_id', $storeId) + ->where('handle', $productHandle) + ->value('id'); + + if ($productId === null) { + continue; + } + + DB::table('collection_products')->updateOrInsert( + [ + 'collection_id' => $collectionId, + 'product_id' => $productId, + ], + ['position' => $position], + ); + } + } + } + + /** + * Reverse is intentionally empty: this repairs demo seed drift and should + * not remove user-visible collection assignments. + */ + public function down(): void + { + // + } + + /** + * @return array> + */ + private function collectionProducts(): array + { + return [ + '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', + ], + ]; + } +}; diff --git a/database/seeders/AnalyticsSeeder.php b/database/seeders/AnalyticsSeeder.php new file mode 100644 index 00000000..abb59844 --- /dev/null +++ b/database/seeders/AnalyticsSeeder.php @@ -0,0 +1,168 @@ +where('handle', 'acme-fashion')->firstOrFail(); + + if (AnalyticsDaily::query()->where('store_id', $store->getKey())->exists()) { + return; + } + + $this->seedDailyAggregates($store); + $this->seedEventStream($store); + } + + /** + * One analytics_daily row per day for the past 30 days with roughly 3% + * daily growth. + */ + protected function seedDailyAggregates(Store $store): void + { + $rows = []; + + for ($daysAgo = 30; $daysAgo >= 0; $daysAgo--) { + $dayFactor = 1 + (30 - $daysAgo) * 0.03; + $visits = (int) round(random_int(50, 100) * $dayFactor); + $addToCart = (int) round($visits * random_int(18, 25) / 100); + $checkoutStarted = (int) round($addToCart * random_int(40, 55) / 100); + $orders = max(2, (int) round($checkoutStarted * random_int(35, 55) / 100)); + $aov = random_int(4000, 9000); + + $rows[] = [ + 'store_id' => $store->getKey(), + 'date' => now()->subDays($daysAgo)->toDateString(), + '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, + ]; + } + + AnalyticsDaily::query()->insert($rows); + } + + /** + * Roughly 220 events across ~35 sessions over the last 7 days, with the + * spec's type distribution and 30% of events tied to a customer. + */ + protected function seedEventStream(Store $store): void + { + $products = Product::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->get(['id', 'title', 'handle']); + + $customerIds = Customer::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->pluck('id'); + + $referrers = ['https://www.google.com', 'https://www.instagram.com', 'https://news.example.com', null, null]; + $searchTerms = ['cotton t-shirt', 'jeans', 'gift card', 'hoodie', 'summer dress']; + + $typePlan = 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'), + ); + + shuffle($typePlan); + + $sessions = []; + + for ($index = 0; $index < 35; $index++) { + $sessions[] = [ + 'id' => 'sess_'.Str::uuid(), + 'referrer' => $referrers[array_rand($referrers)], + 'customer_id' => random_int(1, 100) <= 30 && $customerIds->isNotEmpty() + ? $customerIds->random() + : null, + ]; + } + + $rows = []; + + foreach ($typePlan as $type) { + $session = $sessions[array_rand($sessions)]; + $createdAt = now() + ->subDays(random_int(0, 100) < 55 ? random_int(0, 2) : random_int(3, 6)) + ->subMinutes(random_int(0, 1439)); + $product = $products->isNotEmpty() ? $products->random() : null; + + $rows[] = [ + 'store_id' => $store->getKey(), + 'type' => $type, + 'session_id' => $session['id'], + 'customer_id' => $session['customer_id'], + 'client_event_id' => 'evt_'.Str::uuid(), + 'properties_json' => json_encode($this->propertiesFor($type, $product, $session['referrer'], $searchTerms)), + 'occurred_at' => $createdAt, + 'created_at' => $createdAt, + ]; + } + + AnalyticsEvent::query()->insert($rows); + } + + /** + * @param list $searchTerms + * @return array + */ + protected function propertiesFor(string $type, ?Product $product, ?string $referrer, array $searchTerms): array + { + return match ($type) { + 'page_view' => array_filter([ + 'url' => collect(['/', '/collections/t-shirts', '/collections/new-arrivals', '/pages/about'])->random(), + 'referrer' => $referrer, + ], fn (mixed $value): bool => $value !== null), + 'product_view' => [ + 'product_id' => $product?->getKey(), + 'product_title' => $product?->title, + 'url' => '/products/'.($product?->handle ?? 'unknown'), + ], + 'add_to_cart' => [ + 'product_id' => $product?->getKey(), + 'variant_id' => random_int(1, 200), + 'quantity' => random_int(1, 3), + 'price_amount' => random_int(1500, 9000), + ], + 'checkout_started' => [ + 'cart_id' => random_int(1, 50), + 'item_count' => random_int(1, 4), + 'cart_total' => random_int(2500, 15000), + ], + 'checkout_completed' => [ + 'order_id' => random_int(1, 30), + 'order_number' => '#10'.random_int(10, 99), + 'total_amount' => random_int(2500, 15000), + ], + default => [ + 'query' => $searchTerms[array_rand($searchTerms)], + 'results_count' => random_int(0, 12), + ], + }; + } +} diff --git a/database/seeders/AppSeeder.php b/database/seeders/AppSeeder.php new file mode 100644 index 00000000..2ffd5669 --- /dev/null +++ b/database/seeders/AppSeeder.php @@ -0,0 +1,108 @@ +firstOrCreate( + ['name' => $name], + ['status' => AppStatus::Active, 'created_at' => now()->subMonths(3)], + ); + + OauthClient::query()->firstOrCreate( + ['app_id' => $app->getKey()], + [ + 'client_id' => (string) Str::uuid(), + 'client_secret_encrypted' => Str::random(40), + 'redirect_uris_json' => ['https://'.Str::slug($name).'.example.test/oauth/callback'], + ], + ); + } + + $fashionStore = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $loyaltyApp = AppModel::query()->where('name', 'Loyalty Rewards')->firstOrFail(); + + $installation = AppInstallation::query()->firstOrCreate( + [ + 'store_id' => $fashionStore->getKey(), + 'app_id' => $loyaltyApp->getKey(), + ], + [ + 'scopes_json' => ['read-products', 'read-orders', 'read-customers'], + 'status' => AppInstallationStatus::Active, + 'installed_at' => now()->subMonths(2), + ], + ); + + $appSubscription = WebhookSubscription::query()->firstOrCreate( + [ + 'store_id' => $fashionStore->getKey(), + 'app_installation_id' => $installation->getKey(), + 'event_type' => 'order.created', + ], + [ + 'target_url' => 'https://loyalty-rewards.example.test/webhooks/orders', + 'signing_secret_encrypted' => 'whsec_'.Str::random(32), + 'status' => WebhookSubscriptionStatus::Active, + ], + ); + + $storeSubscription = WebhookSubscription::query()->firstOrCreate( + [ + 'store_id' => $fashionStore->getKey(), + 'app_installation_id' => null, + 'event_type' => 'order.paid', + ], + [ + 'target_url' => 'https://erp.acme-fashion.example.test/hooks/payments', + 'signing_secret_encrypted' => 'whsec_'.Str::random(32), + 'status' => WebhookSubscriptionStatus::Active, + ], + ); + + if ($appSubscription->deliveries()->doesntExist()) { + WebhookDelivery::factory()->count(2)->succeeded()->create([ + 'subscription_id' => $appSubscription->getKey(), + ]); + } + + if ($storeSubscription->deliveries()->doesntExist()) { + WebhookDelivery::factory()->succeeded()->create([ + 'subscription_id' => $storeSubscription->getKey(), + ]); + + WebhookDelivery::factory()->failed()->create([ + 'subscription_id' => $storeSubscription->getKey(), + 'attempt_count' => 2, + 'status' => WebhookDeliveryStatus::Pending, + ]); + } + } +} diff --git a/database/seeders/CollectionSeeder.php b/database/seeders/CollectionSeeder.php new file mode 100644 index 00000000..a33cb3ca --- /dev/null +++ b/database/seeders/CollectionSeeder.php @@ -0,0 +1,72 @@ + [ + [ + 'title' => 'New Arrivals', + 'handle' => 'new-arrivals', + 'description_html' => '

Discover the latest additions to our store.

', + ], + [ + 'title' => 'T-Shirts', + 'handle' => 't-shirts', + 'description_html' => '

Premium cotton tees for every occasion.

', + ], + [ + 'title' => 'Pants & Jeans', + 'handle' => 'pants-jeans', + 'description_html' => '

Find the perfect fit from our denim and trouser range.

', + ], + [ + 'title' => 'Sale', + 'handle' => 'sale', + 'description_html' => '

Great deals on selected items.

', + ], + ], + 'acme-electronics' => [ + [ + 'title' => 'Featured', + 'handle' => 'featured', + 'description_html' => null, + ], + [ + 'title' => 'Accessories', + 'handle' => 'accessories', + 'description_html' => null, + ], + ], + ]; + + foreach ($collectionsByStore as $storeHandle => $collections) { + $store = Store::query()->where('handle', $storeHandle)->firstOrFail(); + + foreach ($collections as $collection) { + Collection::query()->updateOrCreate( + [ + 'store_id' => $store->getKey(), + 'handle' => $collection['handle'], + ], + [ + 'title' => $collection['title'], + 'description_html' => $collection['description_html'], + 'type' => 'manual', + 'status' => 'active', + ], + ); + } + } + } +} diff --git a/database/seeders/CustomerSeeder.php b/database/seeders/CustomerSeeder.php new file mode 100644 index 00000000..ddf7d183 --- /dev/null +++ b/database/seeders/CustomerSeeder.php @@ -0,0 +1,167 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + $fashionCustomers = [ + ['email' => 'customer@acme.test', 'name' => 'John Doe', 'marketing_opt_in' => true], + ['email' => 'jane@example.com', 'name' => 'Jane Smith', 'marketing_opt_in' => false], + ['email' => 'michael@example.com', 'name' => 'Michael Brown', 'marketing_opt_in' => true], + ['email' => 'sarah@example.com', 'name' => 'Sarah Wilson', 'marketing_opt_in' => false], + ['email' => 'david@example.com', 'name' => 'David Lee', 'marketing_opt_in' => true], + ['email' => 'emma@example.com', 'name' => 'Emma Garcia', 'marketing_opt_in' => false], + ['email' => 'james@example.com', 'name' => 'James Taylor', 'marketing_opt_in' => false], + ['email' => 'lisa@example.com', 'name' => 'Lisa Anderson', 'marketing_opt_in' => true], + ['email' => 'robert@example.com', 'name' => 'Robert Martinez', 'marketing_opt_in' => false], + ['email' => 'anna@example.com', 'name' => 'Anna Thomas', 'marketing_opt_in' => true], + ]; + + foreach ($fashionCustomers as $attributes) { + $customer = $this->seedCustomer($fashion, $attributes); + + if (! $customer->addresses()->exists()) { + $this->seedAddresses($customer); + } + } + + $electronicsCustomers = [ + ['email' => 'techfan@example.com', 'name' => 'Tech Fan', 'marketing_opt_in' => false], + ['email' => 'gadgetlover@example.com', 'name' => 'Gadget Lover', 'marketing_opt_in' => false], + ]; + + foreach ($electronicsCustomers as $attributes) { + $customer = $this->seedCustomer($electronics, $attributes); + + if (! $customer->addresses()->exists()) { + $customer->addresses()->create([ + 'label' => 'Home', + 'address_json' => $this->fakerAddress($customer->name), + 'is_default' => true, + ]); + } + } + } + + /** + * @param array{email: string, name: string, marketing_opt_in: bool} $attributes + */ + private function seedCustomer(Store $store, array $attributes): Customer + { + return Customer::query()->withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->getKey(), 'email' => $attributes['email']], + [ + 'name' => $attributes['name'], + 'marketing_opt_in' => $attributes['marketing_opt_in'], + 'password_hash' => Hash::make('password'), + ], + ); + } + + /** + * Spec-defined addresses for customers 1 and 2; one Faker-generated + * German default address for everyone else. + */ + private function seedAddresses(Customer $customer): void + { + if ($customer->email === 'customer@acme.test') { + $customer->addresses()->create([ + 'label' => 'Home', + 'address_json' => $this->address('John', 'Doe', 'Hauptstrasse 1', 'Berlin', '10115', phone: '+49 30 12345678'), + 'is_default' => true, + ]); + $customer->addresses()->create([ + 'label' => 'Work', + 'address_json' => $this->address('John', 'Doe', 'Friedrichstrasse 100', 'Berlin', '10117', company: 'Acme Corp', address2: '3rd Floor', phone: '+49 30 87654321'), + 'is_default' => false, + ]); + + return; + } + + if ($customer->email === 'jane@example.com') { + $customer->addresses()->create([ + 'label' => 'Home', + 'address_json' => $this->address('Jane', 'Smith', 'Schillerstrasse 45', 'Munich', '80336', province: 'Bavaria', provinceCode: 'BY'), + 'is_default' => true, + ]); + + return; + } + + $customer->addresses()->create([ + 'label' => 'Home', + 'address_json' => $this->fakerAddress($customer->name), + 'is_default' => true, + ]); + } + + /** + * @return array + */ + private function address( + string $firstName, + string $lastName, + string $address1, + string $city, + string $zip, + string $company = '', + string $address2 = '', + string $province = '', + string $provinceCode = '', + string $phone = '', + ): 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', + 'zip' => $zip, + 'phone' => $phone, + ]; + } + + /** + * @return array + */ + private function fakerAddress(?string $name): array + { + [$firstName, $lastName] = array_pad(explode(' ', (string) $name, 2), 2, ''); + + return [ + 'first_name' => $firstName, + 'last_name' => $lastName, + 'company' => '', + 'address1' => fake()->streetAddress(), + 'address2' => '', + 'city' => fake()->city(), + 'province' => '', + 'province_code' => '', + 'country' => 'Germany', + 'country_code' => 'DE', + 'zip' => fake()->postcode(), + 'phone' => fake()->phoneNumber(), + ]; + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef2..c1539255 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,8 +2,6 @@ namespace Database\Seeders; -use App\Models\User; -// use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder @@ -13,11 +11,26 @@ class DatabaseSeeder extends Seeder */ 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, + ThemeSeeder::class, + PageSeeder::class, + NavigationSeeder::class, + CustomerSeeder::class, + OrderSeeder::class, + AnalyticsSeeder::class, + SearchSettingsSeeder::class, + AppSeeder::class, ]); } } diff --git a/database/seeders/DiscountSeeder.php b/database/seeders/DiscountSeeder.php new file mode 100644 index 00000000..275ff380 --- /dev/null +++ b/database/seeders/DiscountSeeder.php @@ -0,0 +1,83 @@ +where('handle', 'acme-fashion')->firstOrFail(); + + $discounts = [ + [ + 'code' => 'WELCOME10', + 'value_type' => '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' => 'active', + ], + [ + 'code' => 'FLAT5', + 'value_type' => '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' => 'active', + ], + [ + 'code' => 'FREESHIP', + 'value_type' => 'free_shipping', + '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' => 'active', + ], + [ + 'code' => 'EXPIRED20', + 'value_type' => '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' => 'expired', + ], + [ + 'code' => 'MAXED', + 'value_type' => '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' => 'active', + ], + ]; + + foreach ($discounts as $attributes) { + Discount::query()->withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->getKey(), 'code' => $attributes['code']], + [...$attributes, 'type' => 'code'], + ); + } + } +} diff --git a/database/seeders/NavigationSeeder.php b/database/seeders/NavigationSeeder.php new file mode 100644 index 00000000..696a49e5 --- /dev/null +++ b/database/seeders/NavigationSeeder.php @@ -0,0 +1,84 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + $this->seedMenu($fashion, 'main-menu', 'Main Menu', [ + ['label' => 'Home', 'type' => NavigationItemType::Link, 'url' => '/'], + ['label' => 'New Arrivals', 'type' => NavigationItemType::Collection, 'handle' => 'new-arrivals'], + ['label' => 'T-Shirts', 'type' => NavigationItemType::Collection, 'handle' => 't-shirts'], + ['label' => 'Pants & Jeans', 'type' => NavigationItemType::Collection, 'handle' => 'pants-jeans'], + ['label' => 'Sale', 'type' => NavigationItemType::Collection, 'handle' => 'sale'], + ]); + + $this->seedMenu($fashion, 'footer-menu', 'Footer Menu', [ + ['label' => 'About Us', 'type' => NavigationItemType::Page, 'handle' => 'about'], + ['label' => 'FAQ', 'type' => NavigationItemType::Page, 'handle' => 'faq'], + ['label' => 'Shipping & Returns', 'type' => NavigationItemType::Page, 'handle' => 'shipping-returns'], + ['label' => 'Privacy Policy', 'type' => NavigationItemType::Page, 'handle' => 'privacy-policy'], + ['label' => 'Terms of Service', 'type' => NavigationItemType::Page, 'handle' => 'terms'], + ]); + + $this->seedMenu($electronics, 'main-menu', 'Main Menu', [ + ['label' => 'Home', 'type' => NavigationItemType::Link, 'url' => '/'], + ['label' => 'Featured', 'type' => NavigationItemType::Collection, 'handle' => 'featured'], + ['label' => 'Accessories', 'type' => NavigationItemType::Collection, 'handle' => 'accessories'], + ]); + } + + /** + * @param list $items + */ + private function seedMenu(Store $store, string $handle, string $title, array $items): void + { + $menu = NavigationMenu::query()->updateOrCreate( + [ + 'store_id' => $store->getKey(), + 'handle' => $handle, + ], + ['title' => $title], + ); + + foreach ($items as $position => $item) { + $resourceId = match ($item['type']) { + NavigationItemType::Collection => Collection::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', $item['handle']) + ->value('id'), + NavigationItemType::Page => Page::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', $item['handle']) + ->value('id'), + default => null, + }; + + $menu->items()->updateOrCreate( + ['label' => $item['label']], + [ + 'type' => $item['type'], + 'url' => $item['url'] ?? null, + 'resource_id' => $resourceId, + 'position' => $position, + ], + ); + } + } +} diff --git a/database/seeders/OrderSeeder.php b/database/seeders/OrderSeeder.php new file mode 100644 index 00000000..3139f0bc --- /dev/null +++ b/database/seeders/OrderSeeder.php @@ -0,0 +1,420 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + foreach ($this->fashionOrders($fashion) as $definition) { + $this->seedOrder($fashion, $definition); + } + + foreach ($this->electronicsOrders() as $definition) { + $this->seedOrder($electronics, $definition); + } + } + + /** + * @param array $definition + */ + private function seedOrder(Store $store, array $definition): void + { + $exists = Order::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('order_number', $definition['number']) + ->exists(); + + if ($exists) { + return; + } + + $customer = Customer::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('email', $definition['customer']) + ->firstOrFail(); + + $shippingAddress = $customer->addresses()->where('is_default', true)->first()?->address_json; + + $totals = $definition['totals']; + + $order = new Order([ + 'customer_id' => $customer->getKey(), + 'order_number' => $definition['number'], + 'payment_method' => $definition['payment_method'], + 'status' => $definition['status'], + 'financial_status' => $definition['financial_status'], + 'fulfillment_status' => $definition['fulfillment_status'], + 'currency' => 'EUR', + 'subtotal_amount' => $totals['subtotal'], + 'discount_amount' => $totals['discount'], + 'shipping_amount' => $totals['shipping'], + 'tax_amount' => $totals['tax'], + 'total_amount' => $totals['total'], + 'email' => $customer->email, + 'billing_address_json' => $shippingAddress, + 'shipping_address_json' => $shippingAddress, + 'placed_at' => $definition['placed_at'], + ]); + $order->store_id = $store->getKey(); + $order->save(); + + $orderLines = []; + + foreach ($definition['lines'] as $line) { + $variant = $this->findVariant($store, $line['product'], $line['values'] ?? []); + $optionLabels = implode(' / ', $line['values'] ?? []); + + $orderLines[] = $order->lines()->create([ + 'product_id' => $variant?->product_id, + 'variant_id' => $variant?->getKey(), + 'title_snapshot' => $line['product'].($optionLabels !== '' ? " ({$optionLabels})" : ''), + 'sku_snapshot' => $variant?->sku, + 'quantity' => $line['quantity'], + 'unit_price_amount' => $line['unit_price'], + 'total_amount' => $line['unit_price'] * $line['quantity'] - ($line['discount'] ?? 0), + 'tax_lines_json' => [], + 'discount_allocations_json' => isset($line['discount']) + ? [[ + 'discount_id' => Discount::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('code', 'WELCOME10') + ->first()?->getKey(), + 'amount' => $line['discount'], + ]] + : [], + ]); + } + + $payment = $order->payments()->create([ + 'provider' => 'mock', + 'method' => $definition['payment_method'], + 'provider_payment_id' => $definition['payment']['reference'], + 'status' => $definition['payment']['status'], + 'amount' => $totals['total'], + 'currency' => 'EUR', + ]); + + if (isset($definition['fulfillment'])) { + $fulfillmentData = $definition['fulfillment']; + + $fulfillment = $order->fulfillments()->create([ + 'status' => $fulfillmentData['status'], + 'tracking_company' => $fulfillmentData['tracking_company'] ?? null, + 'tracking_number' => $fulfillmentData['tracking_number'] ?? null, + 'shipped_at' => $fulfillmentData['shipped_at'], + 'delivered_at' => $fulfillmentData['status'] === 'delivered' + ? ($fulfillmentData['delivered_at'] ?? now()) + : null, + ]); + + $fulfilledIndexes = $fulfillmentData['line_indexes'] ?? array_keys($orderLines); + + foreach ($fulfilledIndexes as $index) { + $fulfillment->lines()->create([ + 'order_line_id' => $orderLines[$index]->getKey(), + 'quantity' => $orderLines[$index]->quantity, + ]); + } + } + + if (isset($definition['refund'])) { + $order->refunds()->create([ + 'payment_id' => $payment->getKey(), + 'amount' => $definition['refund']['amount'], + 'reason' => $definition['refund']['reason'], + 'status' => 'processed', + 'provider_refund_id' => $definition['refund']['reference'], + ]); + } + } + + /** + * Find a variant by product title and option value labels. + * + * @param list $values + */ + private function findVariant(Store $store, string $productTitle, array $values): ?ProductVariant + { + $query = ProductVariant::query() + ->whereHas('product', fn ($builder) => $builder + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('title', $productTitle)); + + foreach ($values as $value) { + $query->whereHas('optionValues', fn ($builder) => $builder->where('value', $value)); + } + + return $query->first(); + } + + /** + * @return list> + */ + private function fashionOrders(Store $store): array + { + return [ + [ + 'number' => '#1001', + 'customer' => 'customer@acme.test', + 'payment_method' => 'credit_card', + 'status' => 'paid', 'financial_status' => 'paid', 'fulfillment_status' => 'unfulfilled', + 'placed_at' => now()->subDays(2), + 'lines' => [ + ['product' => 'Classic Cotton T-Shirt', 'values' => ['S', 'White'], 'quantity' => 2, 'unit_price' => 2499], + ], + 'totals' => ['subtotal' => 4998, 'discount' => 0, 'shipping' => 499, 'tax' => 798, 'total' => 5497], + 'payment' => ['reference' => 'mock_test_order1001', 'status' => 'captured'], + ], + [ + 'number' => '#1002', + 'customer' => 'customer@acme.test', + 'payment_method' => 'credit_card', + 'status' => 'fulfilled', 'financial_status' => 'paid', 'fulfillment_status' => 'fulfilled', + 'placed_at' => now()->subDays(10), + 'lines' => [ + ['product' => 'Organic Hoodie', 'values' => ['M'], 'quantity' => 1, 'unit_price' => 5999], + ['product' => 'Classic Cotton T-Shirt', 'values' => ['L', 'Black'], 'quantity' => 1, 'unit_price' => 2499], + ], + 'totals' => ['subtotal' => 8498, 'discount' => 0, 'shipping' => 499, 'tax' => 1357, 'total' => 8997], + 'payment' => ['reference' => 'mock_test_order1002', 'status' => 'captured'], + 'fulfillment' => ['status' => 'delivered', 'tracking_company' => 'DHL', 'tracking_number' => 'DHL1234567890', 'shipped_at' => now()->subDays(8)], + ], + [ + 'number' => '#1003', + 'customer' => 'jane@example.com', + 'payment_method' => 'credit_card', + 'status' => 'paid', 'financial_status' => 'paid', 'fulfillment_status' => 'partial', + 'placed_at' => now()->subDays(5), + 'lines' => [ + ['product' => 'Premium Slim Fit Jeans', 'values' => ['32', 'Blue'], 'quantity' => 1, 'unit_price' => 7999], + ['product' => 'Leather Belt', 'values' => ['L/XL', 'Brown'], 'quantity' => 1, 'unit_price' => 3499], + ], + 'totals' => ['subtotal' => 11498, 'discount' => 0, 'shipping' => 499, 'tax' => 1836, 'total' => 11997], + 'payment' => ['reference' => 'mock_test_order1003', 'status' => 'captured'], + 'fulfillment' => ['status' => 'shipped', 'tracking_company' => 'DHL', 'tracking_number' => 'DHL9876543210', 'shipped_at' => now()->subDays(3), 'line_indexes' => [0]], + ], + [ + 'number' => '#1004', + 'customer' => 'customer@acme.test', + 'payment_method' => 'credit_card', + 'status' => 'cancelled', 'financial_status' => 'refunded', 'fulfillment_status' => 'unfulfilled', + 'placed_at' => now()->subDays(15), + 'lines' => [ + ['product' => 'Classic Cotton T-Shirt', 'values' => ['M', 'Navy'], 'quantity' => 1, 'unit_price' => 2499], + ], + 'totals' => ['subtotal' => 2499, 'discount' => 0, 'shipping' => 499, 'tax' => 399, 'total' => 2998], + 'payment' => ['reference' => 'mock_test_order1004', 'status' => 'refunded'], + 'refund' => ['amount' => 2998, 'reason' => 'Customer requested cancellation', 'reference' => 'mock_re_test_order1004'], + ], + [ + 'number' => '#1005', + 'customer' => 'jane@example.com', + 'payment_method' => 'bank_transfer', + 'status' => 'pending', 'financial_status' => 'pending', 'fulfillment_status' => 'unfulfilled', + 'placed_at' => now()->subHours(2), + 'lines' => [ + ['product' => 'Leather Belt', 'values' => ['S/M', 'Black'], 'quantity' => 1, 'unit_price' => 3499], + ], + 'totals' => ['subtotal' => 3499, 'discount' => 0, 'shipping' => 499, 'tax' => 559, 'total' => 3998], + 'payment' => ['reference' => 'mock_test_order1005', 'status' => 'pending'], + ], + [ + 'number' => '#1006', + 'customer' => 'michael@example.com', + 'payment_method' => 'credit_card', + 'status' => 'paid', 'financial_status' => 'paid', 'fulfillment_status' => 'unfulfilled', + 'placed_at' => now()->subDay(), + 'lines' => [ + ['product' => 'Running Sneakers', 'values' => ['EU 42', 'Black'], 'quantity' => 1, 'unit_price' => 11999], + ], + 'totals' => ['subtotal' => 11999, 'discount' => 0, 'shipping' => 499, 'tax' => 1916, 'total' => 12498], + 'payment' => ['reference' => 'mock_test_order1006', 'status' => 'captured'], + ], + [ + 'number' => '#1007', + 'customer' => 'sarah@example.com', + 'payment_method' => 'paypal', + 'status' => 'fulfilled', 'financial_status' => 'paid', 'fulfillment_status' => 'fulfilled', + 'placed_at' => now()->subDays(20), + 'lines' => [ + ['product' => 'V-Neck Linen Tee', 'values' => ['M', 'Beige'], 'quantity' => 2, 'unit_price' => 3499], + ['product' => 'Wool Scarf', 'values' => ['Grey'], 'quantity' => 1, 'unit_price' => 2999], + ], + 'totals' => ['subtotal' => 9997, 'discount' => 0, 'shipping' => 499, 'tax' => 1596, 'total' => 10496], + 'payment' => ['reference' => 'mock_test_order1007', 'status' => 'captured'], + 'fulfillment' => ['status' => 'delivered', 'tracking_company' => 'DHL', 'tracking_number' => 'DHL1112223334', 'shipped_at' => now()->subDays(18)], + ], + [ + 'number' => '#1008', + 'customer' => 'david@example.com', + 'payment_method' => 'credit_card', + 'status' => 'paid', 'financial_status' => 'partially_refunded', 'fulfillment_status' => 'fulfilled', + 'placed_at' => now()->subDays(12), + 'lines' => [ + ['product' => 'Cargo Pants', 'values' => ['32', 'Khaki'], 'quantity' => 1, 'unit_price' => 5499], + ['product' => 'Graphic Print Tee', 'values' => ['L'], 'quantity' => 1, 'unit_price' => 2999], + ], + 'totals' => ['subtotal' => 8498, 'discount' => 0, 'shipping' => 499, 'tax' => 1357, 'total' => 8997], + 'payment' => ['reference' => 'mock_test_order1008', 'status' => 'captured'], + 'fulfillment' => ['status' => 'delivered', 'tracking_company' => 'UPS', 'tracking_number' => 'UPS5556667778', 'shipped_at' => now()->subDays(10)], + 'refund' => ['amount' => 2999, 'reason' => 'Item returned', 'reference' => 'mock_re_test_order1008'], + ], + [ + 'number' => '#1009', + 'customer' => 'emma@example.com', + 'payment_method' => 'credit_card', + 'status' => 'paid', 'financial_status' => 'paid', 'fulfillment_status' => 'unfulfilled', + 'placed_at' => now()->subDays(3), + 'lines' => [ + ['product' => 'Canvas Tote Bag', 'values' => ['Natural'], 'quantity' => 1, 'unit_price' => 1999], + ['product' => 'Bucket Hat', 'values' => ['S/M', 'Black'], 'quantity' => 1, 'unit_price' => 2499], + ], + 'totals' => ['subtotal' => 4498, 'discount' => 0, 'shipping' => 499, 'tax' => 718, 'total' => 4997], + 'payment' => ['reference' => 'mock_test_order1009', 'status' => 'captured'], + ], + [ + 'number' => '#1010', + 'customer' => 'customer@acme.test', + 'payment_method' => 'paypal', + 'status' => 'paid', 'financial_status' => 'paid', 'fulfillment_status' => 'unfulfilled', + 'placed_at' => now()->subDay(), + 'lines' => [ + ['product' => 'Cashmere Overcoat', 'values' => ['M', 'Camel'], 'quantity' => 1, 'unit_price' => 49999], + ], + 'totals' => ['subtotal' => 49999, 'discount' => 0, 'shipping' => 499, 'tax' => 7983, 'total' => 50498], + 'payment' => ['reference' => 'mock_test_order1010', 'status' => 'captured'], + ], + [ + 'number' => '#1011', + 'customer' => 'james@example.com', + 'payment_method' => 'credit_card', + 'status' => 'paid', 'financial_status' => 'paid', 'fulfillment_status' => 'fulfilled', + 'placed_at' => now()->subDays(25), + 'lines' => [ + ['product' => 'Striped Polo Shirt', 'values' => ['XL'], 'quantity' => 1, 'unit_price' => 2799], + ], + 'totals' => ['subtotal' => 2799, 'discount' => 0, 'shipping' => 499, 'tax' => 447, 'total' => 3298], + 'payment' => ['reference' => 'mock_test_order1011', 'status' => 'captured'], + 'fulfillment' => ['status' => 'delivered', 'tracking_company' => 'FedEx', 'tracking_number' => 'FX9998887776', 'shipped_at' => now()->subDays(23)], + ], + [ + 'number' => '#1012', + 'customer' => 'lisa@example.com', + 'payment_method' => 'credit_card', + 'status' => 'paid', 'financial_status' => 'paid', 'fulfillment_status' => 'unfulfilled', + 'placed_at' => now()->subDays(4), + 'lines' => [ + ['product' => 'Chino Shorts', 'values' => ['34', 'Navy'], 'quantity' => 2, 'unit_price' => 3999], + ], + 'totals' => ['subtotal' => 7998, 'discount' => 0, 'shipping' => 499, 'tax' => 1277, 'total' => 8497], + 'payment' => ['reference' => 'mock_test_order1012', 'status' => 'captured'], + ], + [ + 'number' => '#1013', + 'customer' => 'robert@example.com', + 'payment_method' => 'credit_card', + 'status' => 'paid', 'financial_status' => 'paid', 'fulfillment_status' => 'unfulfilled', + 'placed_at' => now()->subDay(), + 'lines' => [ + ['product' => 'Wide Leg Trousers', 'values' => ['M'], 'quantity' => 1, 'unit_price' => 4999], + ['product' => 'Wool Scarf', 'values' => ['Burgundy'], 'quantity' => 1, 'unit_price' => 2999], + ], + 'totals' => ['subtotal' => 7998, 'discount' => 0, 'shipping' => 499, 'tax' => 1277, 'total' => 8497], + 'payment' => ['reference' => 'mock_test_order1013', 'status' => 'captured'], + ], + [ + 'number' => '#1014', + 'customer' => 'anna@example.com', + 'payment_method' => 'credit_card', + 'status' => 'paid', 'financial_status' => 'paid', 'fulfillment_status' => 'fulfilled', + 'placed_at' => now()->subDays(14), + 'lines' => [ + ['product' => 'Gift Card', 'values' => ['50 EUR'], 'quantity' => 1, 'unit_price' => 5000], + ], + 'totals' => ['subtotal' => 5000, 'discount' => 0, 'shipping' => 0, 'tax' => 798, 'total' => 5000], + 'payment' => ['reference' => 'mock_test_order1014', 'status' => 'captured'], + 'fulfillment' => ['status' => 'delivered', 'shipped_at' => now()->subDays(14), 'delivered_at' => now()->subDays(14)], + ], + [ + 'number' => '#1015', + 'customer' => 'customer@acme.test', + 'payment_method' => 'bank_transfer', + 'status' => 'paid', 'financial_status' => 'paid', 'fulfillment_status' => 'unfulfilled', + 'placed_at' => now(), + 'lines' => [ + ['product' => 'Classic Cotton T-Shirt', 'values' => ['M', 'White'], 'quantity' => 1, 'unit_price' => 2499, 'discount' => 250], + ['product' => 'Graphic Print Tee', 'values' => ['M'], 'quantity' => 1, 'unit_price' => 2999, 'discount' => 300], + ], + 'totals' => ['subtotal' => 5498, 'discount' => 550, 'shipping' => 499, 'tax' => 790, 'total' => 5447], + 'payment' => ['reference' => 'mock_test_order1015', 'status' => 'captured'], + ], + ]; + } + + /** + * @return list> + */ + private function electronicsOrders(): array + { + return [ + [ + 'number' => '#5001', + 'customer' => 'techfan@example.com', + 'payment_method' => 'credit_card', + 'status' => 'paid', 'financial_status' => 'paid', 'fulfillment_status' => 'fulfilled', + 'placed_at' => now()->subDays(6), + 'lines' => [ + ['product' => 'Pro Laptop 15', 'values' => ['512GB'], 'quantity' => 1, 'unit_price' => 119999], + ['product' => 'USB-C Cable 2m', 'quantity' => 1, 'unit_price' => 1299], + ], + 'totals' => ['subtotal' => 121298, 'discount' => 0, 'shipping' => 0, 'tax' => 0, 'total' => 121298], + 'payment' => ['reference' => 'mock_test_order5001', 'status' => 'captured'], + 'fulfillment' => ['status' => 'delivered', 'tracking_company' => 'DHL', 'tracking_number' => 'DHL5550001112', 'shipped_at' => now()->subDays(4)], + ], + [ + 'number' => '#5002', + 'customer' => 'gadgetlover@example.com', + 'payment_method' => 'credit_card', + 'status' => 'paid', 'financial_status' => 'paid', 'fulfillment_status' => 'unfulfilled', + 'placed_at' => now()->subDays(2), + 'lines' => [ + ['product' => 'Wireless Headphones', 'values' => ['Black'], 'quantity' => 1, 'unit_price' => 14999], + ], + 'totals' => ['subtotal' => 14999, 'discount' => 0, 'shipping' => 0, 'tax' => 0, 'total' => 14999], + 'payment' => ['reference' => 'mock_test_order5002', 'status' => 'captured'], + ], + [ + 'number' => '#5003', + 'customer' => 'techfan@example.com', + 'payment_method' => 'bank_transfer', + 'status' => 'pending', 'financial_status' => 'pending', 'fulfillment_status' => 'unfulfilled', + 'placed_at' => now()->subDay(), + 'lines' => [ + ['product' => 'Monitor Stand', 'quantity' => 1, 'unit_price' => 4999], + ], + 'totals' => ['subtotal' => 4999, 'discount' => 0, 'shipping' => 0, 'tax' => 0, 'total' => 4999], + 'payment' => ['reference' => 'mock_test_order5003', 'status' => 'pending'], + ], + ]; + } +} diff --git a/database/seeders/OrganizationSeeder.php b/database/seeders/OrganizationSeeder.php new file mode 100644 index 00000000..66ff5309 --- /dev/null +++ b/database/seeders/OrganizationSeeder.php @@ -0,0 +1,20 @@ +updateOrCreate( + ['billing_email' => 'billing@acme.test'], + ['name' => 'Acme Corp'], + ); + } +} diff --git a/database/seeders/PageSeeder.php b/database/seeders/PageSeeder.php new file mode 100644 index 00000000..f3150436 --- /dev/null +++ b/database/seeders/PageSeeder.php @@ -0,0 +1,124 @@ +where('handle', 'acme-fashion')->firstOrFail(); + + foreach ($this->pages() as $page) { + Page::query()->updateOrCreate( + [ + 'store_id' => $store->getKey(), + 'handle' => $page['handle'], + ], + [ + 'title' => $page['title'], + 'body_html' => $page['body_html'], + 'status' => PageStatus::Published, + 'published_at' => now()->subMonths(3), + ], + ); + } + } + + /** + * @return list + */ + private function pages(): array + { + return [ + [ + 'title' => 'About Us', + 'handle' => 'about', + 'body_html' => <<<'HTML' +

Our Story

+

Acme Fashion was founded with a simple mission: to make modern, well-crafted clothing accessible to everyone. What began as a small studio project has grown into a curated label trusted by customers across Europe.

+

We believe great style should never come at the cost of quality. Every piece in our collection is designed to last, season after season, with timeless silhouettes and dependable materials.

+

Our Values

+

We source our fabrics from ethical suppliers, prioritize sustainable production methods, and partner only with factories that guarantee fair labor conditions. Transparency is at the heart of everything we make.

+

From recycled packaging to carbon-conscious shipping, we are constantly working to reduce our footprint while raising the bar for responsible fashion.

+

Our Team

+

Our Berlin-based design team blends classic tailoring with contemporary streetwear influences. Together with our buyers and customer care crew, they make sure every Acme Fashion experience feels personal.

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

Frequently Asked Questions

+

How long does shipping take?

+

Orders within Germany arrive in 2-4 business days with standard shipping, or 1-2 business days with express shipping. Deliveries to the rest of the EU typically take 5-7 business days.

+

What is your return policy?

+

You can return any item within 30 days of delivery, as long as it is unworn and in its original packaging. Start a return from your account page or contact our support team.

+

Do you ship internationally?

+

Yes. In addition to the EU, we currently ship to the United States, the 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 so you can follow your parcel every step of the way.

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

Shipping Rates

+

Germany

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

European Union

+
    +
  • Standard shipping (5-7 business days): 8.99 EUR
  • +
+

International

+
    +
  • Standard shipping (7-14 business days): 14.99 EUR
  • +
+

Returns

+

We accept returns within 30 days of delivery. Items must be unworn and returned in their original packaging. Return shipping costs are paid by the customer unless the item arrived damaged or defective, in which case we cover all costs and offer a full refund or replacement.

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

Privacy Policy

+

Information We Collect

+

We collect the information you provide when creating an account, placing an order, or contacting support. This includes your name, email address, shipping address, and order history. Payment details are processed securely and never stored on our servers.

+

How We Use Your Information

+

Your data is used to fulfill orders, provide customer support, and, with your consent, send you updates about new products and offers. We never sell your personal information to third parties.

+

Cookies

+

We use cookies to keep your cart between visits, remember your preferences, and understand how our store is used so we can improve it. You can disable cookies in your browser settings at any time.

+

Contact

+

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

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

Terms of Service

+

Orders and Payments

+

All prices are listed in EUR and include applicable taxes. An order is confirmed once payment has been authorized. We reserve the right to cancel orders in cases of suspected fraud or pricing errors.

+

Product Descriptions

+

We strive to present our products as accurately as possible. Please note that colors may vary slightly depending on your screen settings, and minor variations are not considered defects.

+

Limitation of Liability

+

Acme Fashion is not liable for indirect or consequential damages arising from the use of our products or website, to the extent permitted by law. Your statutory rights remain unaffected.

+

Governing Law

+

These terms are governed by the laws of the Federal Republic of Germany. Place of jurisdiction, where legally permissible, is Berlin.

+ HTML, + ], + ]; + } +} diff --git a/database/seeders/ProductSeeder.php b/database/seeders/ProductSeeder.php new file mode 100644 index 00000000..52a555d0 --- /dev/null +++ b/database/seeders/ProductSeeder.php @@ -0,0 +1,646 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + foreach ($this->fashionProducts() as $definition) { + $this->seedProduct($fashion, $definition); + } + + foreach ($this->electronicsProducts() as $definition) { + $this->seedProduct($electronics, $definition); + } + } + + /** + * @param array $definition + */ + private function seedProduct(Store $store, array $definition): void + { + $exists = Product::query() + ->where('store_id', $store->getKey()) + ->where('handle', $definition['handle']) + ->exists(); + + if ($exists) { + return; + } + + $product = new Product([ + 'title' => $definition['title'], + 'handle' => $definition['handle'], + 'status' => $definition['status'], + 'description_html' => $definition['description_html'], + 'vendor' => $definition['vendor'], + 'product_type' => $definition['product_type'], + 'tags' => $definition['tags'], + 'published_at' => $definition['published_at'], + ]); + $product->store_id = $store->getKey(); + $product->save(); + + $valueModelsPerOption = []; + + foreach ($definition['options'] as $optionPosition => $option) { + $productOption = $product->options()->create([ + 'name' => $option['name'], + 'position' => $optionPosition, + ]); + + $valueModels = []; + + foreach ($option['values'] as $valuePosition => $value) { + $valueModels[] = $productOption->values()->create([ + 'value' => $value, + 'position' => $valuePosition, + ]); + } + + $valueModelsPerOption[] = $valueModels; + } + + $combinations = $this->cartesianProduct($valueModelsPerOption); + + foreach ($combinations as $position => $combination) { + $override = $definition['variant_overrides'][$position] ?? []; + + $variant = $product->variants()->create([ + 'sku' => $override['sku'] ?? $this->buildSku($definition['sku_prefix'], $combination), + 'price_amount' => $override['price_amount'] ?? $definition['price_amount'], + 'compare_at_amount' => $definition['compare_at_amount'] ?? null, + 'currency' => 'EUR', + 'weight_g' => $definition['weight_g'], + 'requires_shipping' => $definition['requires_shipping'] ?? true, + 'is_default' => $position === 0, + 'position' => $position, + 'status' => 'active', + ]); + + if ($combination !== []) { + $variant->optionValues()->attach(array_map(fn ($value) => $value->getKey(), $combination)); + } + + $variant->inventoryItem()->create([ + 'store_id' => $store->getKey(), + 'quantity_on_hand' => $definition['inventory'], + 'quantity_reserved' => 0, + 'policy' => $definition['policy'] ?? 'deny', + ]); + } + + foreach ($definition['collections'] as $collectionHandle) { + $collection = Collection::query() + ->where('store_id', $store->getKey()) + ->where('handle', $collectionHandle) + ->firstOrFail(); + + $collection->products()->syncWithoutDetaching([ + $product->getKey() => ['position' => $collection->products()->count()], + ]); + } + } + + /** + * @param list<\App\Models\ProductOptionValue> $combination + */ + private function buildSku(string $prefix, array $combination): string + { + if ($combination === []) { + return $prefix; + } + + $parts = array_map( + fn ($value): string => preg_replace('/[^A-Z0-9]/', '', strtoupper($value->value)), + $combination, + ); + + return $prefix.'-'.implode('-', $parts); + } + + /** + * @param list> $sets + * @return list> + */ + private function cartesianProduct(array $sets): array + { + $combinations = [[]]; + + foreach ($sets as $set) { + $next = []; + + foreach ($combinations as $combination) { + foreach ($set as $value) { + $next[] = [...$combination, $value]; + } + } + + $combinations = $next; + } + + return $combinations; + } + + /** + * @return list> + */ + private function fashionProducts(): array + { + return [ + [ + 'title' => 'Classic Cotton T-Shirt', + 'handle' => 'classic-cotton-t-shirt', + 'status' => 'active', + 'vendor' => 'Acme Basics', + 'product_type' => 'T-Shirts', + 'tags' => ['new', 'popular'], + 'description_html' => '

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

', + 'published_at' => now(), + 'collections' => ['new-arrivals', 't-shirts'], + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ['name' => 'Color', 'values' => ['White', 'Black', 'Navy']], + ], + 'sku_prefix' => 'ACME-CTSH', + 'price_amount' => 2499, + 'weight_g' => 200, + 'inventory' => 15, + ], + [ + 'title' => 'Premium Slim Fit Jeans', + 'handle' => 'premium-slim-fit-jeans', + 'status' => 'active', + 'vendor' => 'Acme Denim', + 'product_type' => 'Pants', + 'tags' => ['new', 'sale'], + 'description_html' => '

Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.

', + 'published_at' => now(), + 'collections' => ['new-arrivals', 'pants-jeans', 'sale'], + 'options' => [ + ['name' => 'Size', 'values' => ['28', '30', '32', '34', '36']], + ['name' => 'Color', 'values' => ['Blue', 'Black']], + ], + 'sku_prefix' => 'ACME-JEAN', + 'price_amount' => 7999, + 'compare_at_amount' => 9999, + 'weight_g' => 800, + 'inventory' => 8, + ], + [ + 'title' => 'Organic Hoodie', + 'handle' => 'organic-hoodie', + 'status' => 'active', + 'vendor' => 'Acme Basics', + 'product_type' => 'Hoodies', + 'tags' => ['new', 'trending'], + 'description_html' => '

Made from 100% organic cotton. Warm, soft, and sustainably produced.

', + 'published_at' => now(), + 'collections' => ['new-arrivals'], + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ], + 'sku_prefix' => 'ACME-HOOD', + 'price_amount' => 5999, + 'weight_g' => 500, + 'inventory' => 20, + ], + [ + 'title' => 'Leather Belt', + 'handle' => 'leather-belt', + 'status' => 'active', + 'vendor' => 'Acme Accessories', + 'product_type' => 'Accessories', + 'tags' => ['popular'], + 'description_html' => '

Genuine leather belt with brushed metal buckle. A wardrobe essential.

', + 'published_at' => now(), + 'collections' => [], + 'options' => [ + ['name' => 'Size', 'values' => ['S/M', 'L/XL']], + ['name' => 'Color', 'values' => ['Brown', 'Black']], + ], + 'sku_prefix' => 'ACME-BELT', + 'price_amount' => 3499, + 'weight_g' => 150, + 'inventory' => 25, + ], + [ + 'title' => 'Running Sneakers', + 'handle' => 'running-sneakers', + 'status' => 'active', + 'vendor' => 'Acme Sport', + 'product_type' => 'Shoes', + 'tags' => ['trending'], + 'description_html' => '

Lightweight running sneakers with responsive cushioning and breathable mesh upper.

', + 'published_at' => now(), + 'collections' => ['new-arrivals'], + 'options' => [ + ['name' => 'Size', 'values' => ['EU 38', 'EU 39', 'EU 40', 'EU 41', 'EU 42', 'EU 43', 'EU 44']], + ['name' => 'Color', 'values' => ['White', 'Black']], + ], + 'sku_prefix' => 'ACME-SNKR', + 'price_amount' => 11999, + 'weight_g' => 600, + 'inventory' => 5, + ], + [ + 'title' => 'Graphic Print Tee', + 'handle' => 'graphic-print-tee', + 'status' => 'active', + 'vendor' => 'Acme Basics', + 'product_type' => 'T-Shirts', + 'tags' => ['new'], + 'description_html' => '

Bold graphic print on soft cotton. Express yourself with this statement piece.

', + 'published_at' => now(), + 'collections' => ['t-shirts'], + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ], + 'sku_prefix' => 'ACME-GTEE', + 'price_amount' => 2999, + 'weight_g' => 210, + 'inventory' => 18, + ], + [ + 'title' => 'V-Neck Linen Tee', + 'handle' => 'v-neck-linen-tee', + 'status' => 'active', + 'vendor' => 'Acme Basics', + 'product_type' => 'T-Shirts', + 'tags' => ['popular'], + 'description_html' => '

Lightweight linen blend v-neck. Perfect for warm summer days.

', + 'published_at' => now(), + 'collections' => ['t-shirts'], + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L']], + ['name' => 'Color', 'values' => ['Beige', 'Olive', 'Sky Blue']], + ], + 'sku_prefix' => 'ACME-VNLT', + 'price_amount' => 3499, + 'weight_g' => 180, + 'inventory' => 12, + ], + [ + 'title' => 'Striped Polo Shirt', + 'handle' => 'striped-polo-shirt', + 'status' => 'active', + 'vendor' => 'Acme Basics', + 'product_type' => 'T-Shirts', + 'tags' => ['sale'], + 'description_html' => '

Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.

', + 'published_at' => now(), + 'collections' => ['t-shirts', 'sale'], + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ], + 'sku_prefix' => 'ACME-POLO', + 'price_amount' => 2799, + 'compare_at_amount' => 3999, + 'weight_g' => 250, + 'inventory' => 10, + ], + [ + 'title' => 'Cargo Pants', + 'handle' => 'cargo-pants', + 'status' => 'active', + 'vendor' => 'Acme Workwear', + 'product_type' => 'Pants', + 'tags' => ['popular'], + 'description_html' => '

Utility cargo pants with multiple pockets. Durable cotton twill construction.

', + 'published_at' => now(), + 'collections' => ['pants-jeans'], + 'options' => [ + ['name' => 'Size', 'values' => ['30', '32', '34', '36']], + ['name' => 'Color', 'values' => ['Khaki', 'Olive', 'Black']], + ], + 'sku_prefix' => 'ACME-CRGO', + 'price_amount' => 5499, + 'weight_g' => 700, + 'inventory' => 14, + ], + [ + 'title' => 'Chino Shorts', + 'handle' => 'chino-shorts', + 'status' => 'active', + 'vendor' => 'Acme Basics', + 'product_type' => 'Pants', + 'tags' => ['new', 'trending'], + 'description_html' => '

Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.

', + 'published_at' => now(), + 'collections' => ['pants-jeans', 'new-arrivals'], + 'options' => [ + ['name' => 'Size', 'values' => ['30', '32', '34', '36']], + ['name' => 'Color', 'values' => ['Navy', 'Sand']], + ], + 'sku_prefix' => 'ACME-CHSH', + 'price_amount' => 3999, + 'weight_g' => 350, + 'inventory' => 16, + ], + [ + 'title' => 'Wide Leg Trousers', + 'handle' => 'wide-leg-trousers', + 'status' => 'active', + 'vendor' => 'Acme Denim', + 'product_type' => 'Pants', + 'tags' => ['sale'], + 'description_html' => '

Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.

', + 'published_at' => now(), + 'collections' => ['pants-jeans', 'sale'], + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L']], + ], + 'sku_prefix' => 'ACME-WLTR', + 'price_amount' => 4999, + 'compare_at_amount' => 6999, + 'weight_g' => 550, + 'inventory' => 7, + ], + [ + 'title' => 'Wool Scarf', + 'handle' => 'wool-scarf', + 'status' => 'active', + 'vendor' => 'Acme Accessories', + 'product_type' => 'Accessories', + 'tags' => ['popular'], + 'description_html' => '

Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.

', + 'published_at' => now(), + 'collections' => [], + 'options' => [ + ['name' => 'Color', 'values' => ['Grey', 'Burgundy', 'Navy']], + ], + 'sku_prefix' => 'ACME-SCRF', + 'price_amount' => 2999, + 'weight_g' => 120, + 'inventory' => 30, + ], + [ + 'title' => 'Canvas Tote Bag', + 'handle' => 'canvas-tote-bag', + 'status' => 'active', + 'vendor' => 'Acme Accessories', + 'product_type' => 'Accessories', + 'tags' => ['trending'], + 'description_html' => '

Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.

', + 'published_at' => now(), + 'collections' => [], + 'options' => [ + ['name' => 'Color', 'values' => ['Natural', 'Black']], + ], + 'sku_prefix' => 'ACME-TOTE', + 'price_amount' => 1999, + 'weight_g' => 300, + 'inventory' => 40, + ], + [ + 'title' => 'Bucket Hat', + 'handle' => 'bucket-hat', + 'status' => 'active', + 'vendor' => 'Acme Accessories', + 'product_type' => 'Accessories', + 'tags' => ['new', 'trending'], + 'description_html' => '

Lightweight bucket hat for sun protection. Packable design, washed cotton twill.

', + 'published_at' => now(), + 'collections' => ['new-arrivals'], + 'options' => [ + ['name' => 'Size', 'values' => ['S/M', 'L/XL']], + ['name' => 'Color', 'values' => ['Beige', 'Black', 'Olive']], + ], + 'sku_prefix' => 'ACME-BCKT', + 'price_amount' => 2499, + 'weight_g' => 80, + 'inventory' => 22, + ], + [ + 'title' => 'Unreleased Winter Jacket', + 'handle' => 'unreleased-winter-jacket', + 'status' => 'draft', + 'vendor' => 'Acme Outerwear', + 'product_type' => 'Jackets', + 'tags' => ['limited'], + 'description_html' => '

Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.

', + 'published_at' => null, + 'collections' => [], + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ], + 'sku_prefix' => 'ACME-WJKT', + 'price_amount' => 14999, + 'weight_g' => 900, + 'inventory' => 0, + ], + [ + 'title' => 'Discontinued Raincoat', + 'handle' => 'discontinued-raincoat', + 'status' => 'archived', + 'vendor' => 'Acme Outerwear', + 'product_type' => 'Jackets', + 'tags' => [], + 'description_html' => '

Lightweight waterproof raincoat. This product has been discontinued.

', + 'published_at' => now()->subMonths(6), + 'collections' => [], + 'options' => [ + ['name' => 'Size', 'values' => ['M', 'L']], + ], + 'sku_prefix' => 'ACME-RNCT', + 'price_amount' => 8999, + 'weight_g' => 400, + 'inventory' => 3, + ], + [ + 'title' => 'Limited Edition Sneakers', + 'handle' => 'limited-edition-sneakers', + 'status' => 'active', + 'vendor' => 'Acme Sport', + 'product_type' => 'Shoes', + 'tags' => ['limited'], + 'description_html' => '

Limited edition collaboration sneakers. Once they are gone, they are gone.

', + 'published_at' => now(), + 'collections' => [], + 'options' => [ + ['name' => 'Size', 'values' => ['EU 40', 'EU 42', 'EU 44']], + ], + 'sku_prefix' => 'ACME-LESN', + 'price_amount' => 15999, + 'weight_g' => 650, + 'inventory' => 0, + ], + [ + 'title' => 'Backorder Denim Jacket', + 'handle' => 'backorder-denim-jacket', + 'status' => 'active', + 'vendor' => 'Acme Denim', + 'product_type' => 'Jackets', + 'tags' => ['popular'], + 'description_html' => '

Classic denim jacket. Currently on backorder - ships within 2-3 weeks.

', + 'published_at' => now(), + 'collections' => [], + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ], + 'sku_prefix' => 'ACME-BDJK', + 'price_amount' => 9999, + 'weight_g' => 750, + 'inventory' => 0, + 'policy' => 'continue', + ], + [ + 'title' => 'Gift Card', + 'handle' => 'gift-card', + 'status' => 'active', + 'vendor' => 'Acme Fashion', + 'product_type' => 'Gift Cards', + 'tags' => ['popular'], + 'description_html' => '

Digital gift card delivered via email. The perfect gift when you are not sure what to choose.

', + 'published_at' => now(), + 'collections' => [], + 'options' => [ + ['name' => 'Amount', 'values' => ['25 EUR', '50 EUR', '100 EUR']], + ], + 'sku_prefix' => 'ACME-GIFT', + 'price_amount' => 2500, + 'weight_g' => 0, + 'requires_shipping' => false, + 'inventory' => 9999, + 'variant_overrides' => [ + 0 => ['sku' => 'ACME-GIFT-25', 'price_amount' => 2500], + 1 => ['sku' => 'ACME-GIFT-50', 'price_amount' => 5000], + 2 => ['sku' => 'ACME-GIFT-100', 'price_amount' => 10000], + ], + ], + [ + 'title' => 'Cashmere Overcoat', + 'handle' => 'cashmere-overcoat', + 'status' => 'active', + 'vendor' => 'Acme Premium', + 'product_type' => 'Jackets', + 'tags' => ['limited', 'new'], + 'description_html' => '

Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.

', + 'published_at' => now(), + 'collections' => ['new-arrivals'], + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L']], + ['name' => 'Color', 'values' => ['Camel', 'Charcoal']], + ], + 'sku_prefix' => 'ACME-OVCT', + 'price_amount' => 49999, + 'weight_g' => 1200, + 'inventory' => 3, + ], + ]; + } + + /** + * @return list> + */ + private function electronicsProducts(): array + { + return [ + [ + 'title' => 'Pro Laptop 15', + 'handle' => 'pro-laptop-15', + 'status' => 'active', + 'vendor' => 'TechCorp', + 'product_type' => 'Laptops', + 'tags' => ['new'], + 'description_html' => '

Powerful 15-inch laptop for professionals.

', + 'published_at' => now(), + 'collections' => ['featured'], + 'options' => [ + ['name' => 'Storage', 'values' => ['256GB', '512GB', '1TB']], + ], + 'sku_prefix' => 'TECH-LPT15', + 'price_amount' => 99999, + 'weight_g' => 1800, + 'inventory' => 10, + 'variant_overrides' => [ + 0 => ['price_amount' => 99999], + 1 => ['price_amount' => 119999], + 2 => ['price_amount' => 149999], + ], + ], + [ + 'title' => 'Wireless Headphones', + 'handle' => 'wireless-headphones', + 'status' => 'active', + 'vendor' => 'AudioMax', + 'product_type' => 'Audio', + 'tags' => ['popular'], + 'description_html' => '

Premium wireless over-ear headphones with active noise cancellation.

', + 'published_at' => now(), + 'collections' => ['featured'], + 'options' => [ + ['name' => 'Color', 'values' => ['Black', 'Silver']], + ], + 'sku_prefix' => 'AUDIO-WHP', + 'price_amount' => 14999, + 'weight_g' => 250, + 'inventory' => 25, + ], + [ + 'title' => 'USB-C Cable 2m', + 'handle' => 'usb-c-cable-2m', + 'status' => 'active', + 'vendor' => 'CablePro', + 'product_type' => 'Cables', + 'tags' => [], + 'description_html' => '

Durable braided USB-C cable, 2 meters long.

', + 'published_at' => now(), + 'collections' => ['accessories'], + 'options' => [], + 'sku_prefix' => 'CBL-USBC2M', + 'price_amount' => 1299, + 'weight_g' => 50, + 'inventory' => 200, + ], + [ + 'title' => 'Mechanical Keyboard', + 'handle' => 'mechanical-keyboard', + 'status' => 'active', + 'vendor' => 'KeyTech', + 'product_type' => 'Peripherals', + 'tags' => ['trending'], + 'description_html' => '

Full-size mechanical keyboard with hot-swappable switches.

', + 'published_at' => now(), + 'collections' => ['featured'], + 'options' => [ + ['name' => 'Switch Type', 'values' => ['Red', 'Blue', 'Brown']], + ], + 'sku_prefix' => 'KEY-MECH', + 'price_amount' => 12999, + 'weight_g' => 1100, + 'inventory' => 15, + ], + [ + 'title' => 'Monitor Stand', + 'handle' => 'monitor-stand', + 'status' => 'active', + 'vendor' => 'DeskGear', + 'product_type' => 'Accessories', + 'tags' => [], + 'description_html' => '

Sturdy aluminium monitor stand with cable management.

', + 'published_at' => now(), + 'collections' => ['accessories'], + 'options' => [], + 'sku_prefix' => 'DESK-MST', + 'price_amount' => 4999, + 'weight_g' => 2500, + 'inventory' => 30, + ], + ]; + } +} diff --git a/database/seeders/SearchSettingsSeeder.php b/database/seeders/SearchSettingsSeeder.php new file mode 100644 index 00000000..70ba7bd1 --- /dev/null +++ b/database/seeders/SearchSettingsSeeder.php @@ -0,0 +1,50 @@ + [ + '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 ($settings as $handle => $values) { + $store = Store::query()->where('handle', $handle)->firstOrFail(); + + SearchSettings::query()->updateOrCreate( + ['store_id' => $store->getKey()], + $values, + ); + + $search->reindexStore($store); + } + } +} diff --git a/database/seeders/ShippingSeeder.php b/database/seeders/ShippingSeeder.php new file mode 100644 index 00000000..229e2b1b --- /dev/null +++ b/database/seeders/ShippingSeeder.php @@ -0,0 +1,75 @@ + [ + [ + 'name' => 'Domestic', + 'countries' => ['DE'], + 'rates' => [ + ['name' => 'Standard Shipping', 'amount' => 499], + ['name' => 'Express Shipping', 'amount' => 999], + ], + ], + [ + 'name' => 'EU', + 'countries' => ['AT', 'FR', 'IT', 'ES', 'NL', 'BE', 'PL'], + 'rates' => [ + ['name' => 'EU Standard', 'amount' => 899], + ], + ], + [ + 'name' => 'Rest of World', + 'countries' => ['US', 'GB', 'CA', 'AU'], + 'rates' => [ + ['name' => 'International', 'amount' => 1499], + ], + ], + ], + 'acme-electronics' => [ + [ + 'name' => 'Germany', + 'countries' => ['DE'], + 'rates' => [ + ['name' => 'Standard', 'amount' => 0], + ], + ], + ], + ]; + + foreach ($zonesByStore as $handle => $zones) { + $store = Store::query()->where('handle', $handle)->firstOrFail(); + + foreach ($zones as $zoneData) { + $zone = ShippingZone::query()->withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->getKey(), 'name' => $zoneData['name']], + ['countries_json' => $zoneData['countries'], 'regions_json' => []], + ); + + foreach ($zoneData['rates'] as $rateData) { + ShippingRate::query()->updateOrCreate( + ['zone_id' => $zone->getKey(), 'name' => $rateData['name']], + [ + 'type' => 'flat', + 'config_json' => ['amount' => $rateData['amount']], + 'is_active' => true, + ], + ); + } + } + } + } +} diff --git a/database/seeders/StoreDomainSeeder.php b/database/seeders/StoreDomainSeeder.php new file mode 100644 index 00000000..40f7ac26 --- /dev/null +++ b/database/seeders/StoreDomainSeeder.php @@ -0,0 +1,56 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + $domains = [ + ['store_id' => $fashion->getKey(), 'hostname' => 'acme-fashion.test', 'type' => 'storefront', 'is_primary' => true], + ['store_id' => $fashion->getKey(), 'hostname' => 'admin.acme-fashion.test', 'type' => 'admin', 'is_primary' => false], + ['store_id' => $fashion->getKey(), 'hostname' => 'shop.test', 'type' => 'storefront', 'is_primary' => false], + ['store_id' => $fashion->getKey(), 'hostname' => '2026-06-09-claude-code-fable-5.agentic-engineers.dev', 'type' => 'storefront', 'is_primary' => false], + ['store_id' => $electronics->getKey(), 'hostname' => 'acme-electronics.test', 'type' => 'storefront', 'is_primary' => true], + ]; + + $appHost = parse_url((string) config('app.url'), PHP_URL_HOST); + + if (is_string($appHost) && ! in_array($appHost, ['localhost', '127.0.0.1', '::1'], true)) { + $domains[] = [ + 'store_id' => $fashion->getKey(), + 'hostname' => strtolower($appHost), + 'type' => 'storefront', + 'is_primary' => false, + ]; + } + + foreach ($domains as $domain) { + StoreDomain::query()->updateOrCreate( + ['hostname' => $domain['hostname']], + [ + 'store_id' => $domain['store_id'], + 'type' => $domain['type'], + 'is_primary' => $domain['is_primary'], + 'tls_mode' => 'managed', + ], + ); + + Cache::forget('store_domain:'.$domain['hostname']); + } + } +} diff --git a/database/seeders/StoreSeeder.php b/database/seeders/StoreSeeder.php new file mode 100644 index 00000000..793e06d6 --- /dev/null +++ b/database/seeders/StoreSeeder.php @@ -0,0 +1,37 @@ +where('billing_email', 'billing@acme.test')->firstOrFail(); + + $stores = [ + ['name' => 'Acme Fashion', 'handle' => 'acme-fashion'], + ['name' => 'Acme Electronics', 'handle' => 'acme-electronics'], + ]; + + foreach ($stores as $store) { + Store::query()->updateOrCreate( + ['handle' => $store['handle']], + [ + 'organization_id' => $organization->getKey(), + 'name' => $store['name'], + 'status' => '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..506ce5b9 --- /dev/null +++ b/database/seeders/StoreSettingsSeeder.php @@ -0,0 +1,40 @@ + [ + '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 ($settings as $handle => $json) { + $store = Store::query()->where('handle', $handle)->firstOrFail(); + + StoreSettings::query()->updateOrCreate( + ['store_id' => $store->getKey()], + ['settings_json' => $json], + ); + } + } +} diff --git a/database/seeders/StoreUserSeeder.php b/database/seeders/StoreUserSeeder.php new file mode 100644 index 00000000..9a2f19e9 --- /dev/null +++ b/database/seeders/StoreUserSeeder.php @@ -0,0 +1,35 @@ + 'admin@acme.test', 'handle' => 'acme-fashion', 'role' => 'owner'], + ['email' => 'staff@acme.test', 'handle' => 'acme-fashion', 'role' => 'staff'], + ['email' => 'support@acme.test', 'handle' => 'acme-fashion', 'role' => 'support'], + ['email' => 'manager@acme.test', 'handle' => 'acme-fashion', 'role' => 'admin'], + ['email' => 'admin2@acme.test', 'handle' => 'acme-electronics', 'role' => 'owner'], + ]; + + foreach ($assignments as $assignment) { + $user = User::query()->where('email', $assignment['email'])->firstOrFail(); + $store = Store::query()->where('handle', $assignment['handle'])->firstOrFail(); + + StoreUser::query()->updateOrCreate( + ['store_id' => $store->getKey(), 'user_id' => $user->getKey()], + ['role' => $assignment['role']], + ); + } + } +} diff --git a/database/seeders/TaxSettingsSeeder.php b/database/seeders/TaxSettingsSeeder.php new file mode 100644 index 00000000..50915d01 --- /dev/null +++ b/database/seeders/TaxSettingsSeeder.php @@ -0,0 +1,30 @@ +where('handle', $handle)->firstOrFail(); + + TaxSettings::query()->updateOrCreate( + ['store_id' => $store->getKey()], + [ + 'mode' => '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..263e9d3a --- /dev/null +++ b/database/seeders/ThemeSeeder.php @@ -0,0 +1,69 @@ + [ + 'primary_color' => '#1a1a2e', + 'secondary_color' => '#e94560', + 'font_family' => 'Inter, sans-serif', + 'hero_heading' => 'Welcome to Acme Fashion', + 'hero_subheading' => 'Discover our curated collection of modern essentials', + 'hero_cta_text' => 'Shop New Arrivals', + 'hero_cta_link' => '/collections/new-arrivals', + 'featured_collection_handles' => ['new-arrivals', 't-shirts', 'sale'], + 'featured_products_collection_handle' => 'new-arrivals', + 'footer_text' => '2025 Acme Fashion. All rights reserved.', + 'show_announcement_bar' => true, + 'announcement_text' => 'Free shipping on orders over 50 EUR - Use code FREESHIP', + 'products_per_page' => 12, + 'show_vendor' => true, + 'show_quantity_selector' => true, + ], + 'acme-electronics' => [ + 'primary_color' => '#0f172a', + 'secondary_color' => '#3b82f6', + 'font_family' => 'Inter, sans-serif', + 'hero_heading' => 'Acme Electronics', + 'hero_subheading' => 'Premium tech for professionals', + 'hero_cta_text' => 'Shop Featured', + 'hero_cta_link' => '/collections/featured', + 'featured_collection_handles' => ['featured'], + 'footer_text' => '2025 Acme Electronics. All rights reserved.', + ], + ]; + + foreach ($themesByStore as $storeHandle => $settings) { + $store = Store::query()->where('handle', $storeHandle)->firstOrFail(); + + $theme = Theme::query()->updateOrCreate( + [ + 'store_id' => $store->getKey(), + 'name' => 'Default Theme', + ], + [ + 'version' => '1.0.0', + 'status' => ThemeStatus::Published, + 'published_at' => now(), + ], + ); + + $theme->settings()->updateOrCreate( + ['theme_id' => $theme->getKey()], + ['settings_json' => $settings], + ); + } + } +} diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php new file mode 100644 index 00000000..7f7f03ba --- /dev/null +++ b/database/seeders/UserSeeder.php @@ -0,0 +1,36 @@ + 'admin@acme.test', 'name' => 'Admin User', 'last_login_at' => now()], + ['email' => 'staff@acme.test', 'name' => 'Staff User', 'last_login_at' => now()->subDays(2)], + ['email' => 'support@acme.test', 'name' => 'Support User', 'last_login_at' => now()->subDay()], + ['email' => 'manager@acme.test', 'name' => 'Store Manager', 'last_login_at' => now()->subDay()], + ['email' => 'admin2@acme.test', 'name' => 'Admin Two', 'last_login_at' => now()->subDay()], + ]; + + foreach ($users as $user) { + User::query()->updateOrCreate( + ['email' => $user['email']], + [ + 'name' => $user['name'], + 'password_hash' => Hash::make('password'), + 'status' => 'active', + 'last_login_at' => $user['last_login_at'], + ], + ); + } + } +} diff --git a/package-lock.json b/package-lock.json index b558d2d8..c49a8885 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,9 @@ "tailwindcss": "^4.0.7", "vite": "^7.0.4" }, + "devDependencies": { + "playwright": "^1.60.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.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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..03c30eb0 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.60.0" } } diff --git a/phpunit.xml b/phpunit.xml index d7032415..3b207652 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -11,6 +11,9 @@ tests/Feature + + tests/Browser + @@ -18,6 +21,7 @@ + diff --git a/resources/css/app.css b/resources/css/app.css index ad6eeedc..0a5444d0 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -36,6 +36,99 @@ } } +[x-cloak] { + display: none !important; +} + +/* Storefront rich-text (CMS pages, product/collection descriptions, theme rich-text sections) */ +.sf-prose { + color: var(--color-zinc-600); + font-size: var(--text-base); + line-height: 1.75; +} + +.dark .sf-prose { + color: var(--color-zinc-400); +} + +.sf-prose :is(h1, h2, h3, h4, h5, h6) { + color: var(--color-zinc-900); + font-weight: 700; + letter-spacing: -0.015em; + margin-block: 1.5em 0.5em; +} + +.dark .sf-prose :is(h1, h2, h3, h4, h5, h6) { + color: var(--color-white); +} + +.sf-prose :is(h1, h2, h3, h4, h5, h6):first-child { + margin-block-start: 0; +} + +.sf-prose h1 { + font-size: var(--text-2xl); +} + +.sf-prose h2 { + font-size: var(--text-xl); +} + +.sf-prose h3 { + font-size: var(--text-lg); +} + +.sf-prose p { + margin-block: 0.75em; +} + +.sf-prose :is(ul, ol) { + margin-block: 0.75em; + padding-inline-start: 1.5em; +} + +.sf-prose ul { + list-style-type: disc; +} + +.sf-prose ol { + list-style-type: decimal; +} + +.sf-prose li { + margin-block: 0.25em; +} + +.sf-prose a { + color: var(--color-blue-700); + text-decoration: underline; + text-underline-offset: 2px; +} + +.dark .sf-prose a { + color: var(--color-blue-400); +} + +.sf-prose blockquote { + border-inline-start: 3px solid var(--color-zinc-200); + font-style: italic; + margin-block: 1em; + padding-inline-start: 1em; +} + +.dark .sf-prose blockquote { + border-inline-start-color: var(--color-zinc-700); +} + +.sf-prose strong { + color: var(--color-zinc-900); + font-weight: 600; +} + +.dark .sf-prose strong { + color: var(--color-white); +} + @layer base { *, diff --git a/resources/views/components/admin/breadcrumbs.blade.php b/resources/views/components/admin/breadcrumbs.blade.php new file mode 100644 index 00000000..21ea22e8 --- /dev/null +++ b/resources/views/components/admin/breadcrumbs.blade.php @@ -0,0 +1,14 @@ +@props(['items' => []]) + +{{-- Dynamic breadcrumb trail (spec 03 section 19.5): Home > parent > current. --}} + + {{ __('Home') }} + + @foreach ($items as $item) + @if (! empty($item['href']) && ! $loop->last) + {{ $item['label'] }} + @else + {{ $item['label'] }} + @endif + @endforeach + diff --git a/resources/views/components/admin/card.blade.php b/resources/views/components/admin/card.blade.php new file mode 100644 index 00000000..fccc4dd1 --- /dev/null +++ b/resources/views/components/admin/card.blade.php @@ -0,0 +1,11 @@ +@props(['heading' => null]) + +{{-- Reusable admin card (spec 03 section 19.7). --}} +
merge(['class' => 'rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-900']) }}> + @if ($heading !== null) + {{ $heading }} + + @endif + + {{ $slot }} +
diff --git a/resources/views/components/admin/settings-tabs.blade.php b/resources/views/components/admin/settings-tabs.blade.php new file mode 100644 index 00000000..5557cb30 --- /dev/null +++ b/resources/views/components/admin/settings-tabs.blade.php @@ -0,0 +1,30 @@ +@props(['active' => 'general']) + +@php + $tabs = [ + 'general' => ['label' => __('General'), 'href' => route('admin.settings.index')], + 'domains' => ['label' => __('Domains'), 'href' => route('admin.settings.index', ['tab' => 'domains'])], + 'shipping' => ['label' => __('Shipping'), 'href' => route('admin.settings.shipping')], + 'taxes' => ['label' => __('Taxes'), 'href' => route('admin.settings.taxes')], + 'checkout' => ['label' => __('Checkout'), 'href' => route('admin.settings.index', ['tab' => 'checkout'])], + 'notifications' => ['label' => __('Notifications'), 'href' => route('admin.settings.index', ['tab' => 'notifications'])], + ]; +@endphp + +{{-- Settings tab bar (spec 02: tabs General, Domains, Shipping, Taxes, Checkout, Notifications). --}} +
+ @foreach ($tabs as $key => $tab) + + {{ $tab['label'] }} + + @endforeach +
diff --git a/resources/views/components/admin/status-badge.blade.php b/resources/views/components/admin/status-badge.blade.php new file mode 100644 index 00000000..5aa1dfdc --- /dev/null +++ b/resources/views/components/admin/status-badge.blade.php @@ -0,0 +1,17 @@ +@props(['status', 'size' => 'sm']) + +@php + $value = $status instanceof \BackedEnum ? $status->value : (string) $status; + + $color = match ($value) { + 'active', 'paid', 'fulfilled', 'captured', 'delivered', 'processed', 'published' => 'green', + 'partial', 'partially_refunded', 'refunded', 'scheduled' => 'yellow', + 'archived', 'cancelled', 'failed', 'voided', 'expired' => 'red', + 'shipped' => 'blue', + default => 'zinc', + }; +@endphp + + + {{ \Illuminate\Support\Str::headline($value) }} + 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/components/storefront/account-nav.blade.php b/resources/views/components/storefront/account-nav.blade.php new file mode 100644 index 00000000..40f144c1 --- /dev/null +++ b/resources/views/components/storefront/account-nav.blade.php @@ -0,0 +1,50 @@ +{{-- + Tab navigation shared by all customer account pages. `current` is one of + "dashboard", "orders", or "addresses". +--}} +@props([ + 'current' => 'dashboard', +]) + +@php + $tabs = [ + 'dashboard' => ['label' => __('My Account'), 'url' => route('storefront.account.index')], + 'orders' => ['label' => __('Orders'), 'url' => route('storefront.account.orders.index')], + 'addresses' => ['label' => __('Addresses'), 'url' => route('storefront.account.addresses.index')], + ]; + + $activeClasses = 'border-(--sf-primary,#2563eb) font-semibold text-zinc-900 dark:text-white'; + $inactiveClasses = 'border-transparent font-medium text-zinc-500 hover:border-zinc-300 hover:text-zinc-700 dark:text-zinc-400 dark:hover:border-zinc-600 dark:hover:text-zinc-200'; +@endphp + + diff --git a/resources/views/components/storefront/address-form.blade.php b/resources/views/components/storefront/address-form.blade.php new file mode 100644 index 00000000..69b8e856 --- /dev/null +++ b/resources/views/components/storefront/address-form.blade.php @@ -0,0 +1,172 @@ +{{-- + Renders a full address form. Inputs bind to Livewire via the given model + prefix (e.g. prefix="shipping" binds shipping.first_name). Used by the + Phase 4 checkout and account address book. +--}} +@props([ + 'address' => null, + 'prefix' => '', +]) + +@php + $field = fn (string $name): string => $prefix === '' ? $name : "{$prefix}.{$name}"; + + $inputClasses = 'block w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 transition focus:border-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-600/30 dark:border-zinc-700 dark:bg-zinc-900 dark:text-white dark:placeholder-zinc-500'; + $labelClasses = 'mb-1.5 block text-sm font-medium text-zinc-700 dark:text-zinc-300'; + + $countries = \App\Support\Storefront\Countries::OPTIONS; +@endphp + +
class('grid grid-cols-1 gap-4 sm:grid-cols-2') }}> +
+ + + @error($field('first_name')) +

{{ $message }}

+ @enderror +
+ +
+ + + @error($field('last_name')) +

{{ $message }}

+ @enderror +
+ +
+ + + @error($field('address1')) +

{{ $message }}

+ @enderror +
+ +
+ + +
+ +
+ + + @error($field('city')) +

{{ $message }}

+ @enderror +
+ +
+ + +
+ +
+ + + @error($field('postal_code')) +

{{ $message }}

+ @enderror +
+ +
+ + + @error($field('country_code')) +

{{ $message }}

+ @enderror +
+ +
+ + +
+
diff --git a/resources/views/components/storefront/badge.blade.php b/resources/views/components/storefront/badge.blade.php new file mode 100644 index 00000000..0b3f468c --- /dev/null +++ b/resources/views/components/storefront/badge.blade.php @@ -0,0 +1,17 @@ +@props([ + 'text', + 'variant' => 'default', +]) + +@php + $variantClasses = match ($variant) { + 'sale' => 'bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-400', + 'sold-out' => 'bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300', + 'new' => 'bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400', + default => 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300', + }; +@endphp + +class("inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {$variantClasses}") }}> + {{ $text }} + diff --git a/resources/views/components/storefront/breadcrumbs.blade.php b/resources/views/components/storefront/breadcrumbs.blade.php new file mode 100644 index 00000000..8d75702e --- /dev/null +++ b/resources/views/components/storefront/breadcrumbs.blade.php @@ -0,0 +1,41 @@ +@props([ + 'items' => [], +]) + +@if ($items !== []) + +@endif diff --git a/resources/views/components/storefront/order-status-badge.blade.php b/resources/views/components/storefront/order-status-badge.blade.php new file mode 100644 index 00000000..10df63f4 --- /dev/null +++ b/resources/views/components/storefront/order-status-badge.blade.php @@ -0,0 +1,28 @@ +{{-- + Status badge for orders. Accepts an OrderStatus, FinancialStatus, + FulfillmentStatus, or FulfillmentShipmentStatus enum (or its string + value). Colors per spec 04: pending yellow, paid green, fulfilled blue, + cancelled gray, refunded red. +--}} +@props([ + 'status', +]) + +@php + $value = $status instanceof \BackedEnum ? $status->value : (string) $status; + + $classes = match ($value) { + 'pending', 'authorized', 'unfulfilled' => 'bg-yellow-100 text-yellow-800 dark:bg-yellow-500/15 dark:text-yellow-400', + 'paid', 'delivered' => 'bg-green-100 text-green-700 dark:bg-green-500/15 dark:text-green-400', + 'fulfilled', 'partial', 'shipped' => 'bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400', + 'cancelled', 'voided' => 'bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300', + 'refunded', 'partially_refunded' => 'bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-400', + default => 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300', + }; + + $label = __(\Illuminate\Support\Str::of($value)->replace('_', ' ')->title()->value()); +@endphp + +class("inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {$classes}") }}> + {{ $label }} + diff --git a/resources/views/components/storefront/order-summary.blade.php b/resources/views/components/storefront/order-summary.blade.php new file mode 100644 index 00000000..65d389ab --- /dev/null +++ b/resources/views/components/storefront/order-summary.blade.php @@ -0,0 +1,101 @@ +{{-- + Checkout order summary sidebar. Phase 4 integration point: the checkout + page passes line items and totals derived from the Checkout model. Lines + are arrays with keys: title, variant (nullable), quantity, image_url + (nullable), line_total_amount. +--}} +@props([ + 'lines' => [], + 'currency' => 'EUR', + 'subtotalAmount' => null, + 'discountAmount' => null, + 'discountLabel' => null, + 'shippingAmount' => null, + 'taxAmount' => null, + 'totalAmount' => null, + 'showDiscountInput' => true, +]) + + diff --git a/resources/views/components/storefront/pagination.blade.php b/resources/views/components/storefront/pagination.blade.php new file mode 100644 index 00000000..54fd3b5b --- /dev/null +++ b/resources/views/components/storefront/pagination.blade.php @@ -0,0 +1,100 @@ +@props([ + 'paginator', +]) + +@php + /** @var \Illuminate\Pagination\LengthAwarePaginator $paginator */ + $window = \Illuminate\Pagination\UrlWindow::make($paginator); + + $elements = array_filter([ + $window['first'], + is_array($window['slider']) ? '...' : null, + $window['slider'], + is_array($window['last']) ? '...' : null, + $window['last'], + ]); +@endphp + +@if ($paginator->hasPages()) + {{-- Plain string label: __('Pagination') would resolve to the framework's pagination.php lang group. --}} + +@endif diff --git a/resources/views/components/storefront/price.blade.php b/resources/views/components/storefront/price.blade.php new file mode 100644 index 00000000..7f5203be --- /dev/null +++ b/resources/views/components/storefront/price.blade.php @@ -0,0 +1,22 @@ +@props([ + 'amount', + 'currency' => null, + 'compareAtAmount' => null, +]) + +@php + $currency ??= $currentStore->default_currency ?? 'EUR'; + $isOnSale = $compareAtAmount !== null && $compareAtAmount > $amount; +@endphp + +class('inline-flex flex-wrap items-baseline gap-x-2') }}> + + {{ \App\Support\Storefront\PriceFormatter::format($amount, $currency) }} + + @if ($isOnSale) + + {{ __('Original price:') }} + {{ \App\Support\Storefront\PriceFormatter::format($compareAtAmount, $currency) }} + + @endif + diff --git a/resources/views/components/storefront/product-card.blade.php b/resources/views/components/storefront/product-card.blade.php new file mode 100644 index 00000000..a5de24fe --- /dev/null +++ b/resources/views/components/storefront/product-card.blade.php @@ -0,0 +1,79 @@ +@props([ + 'product', + 'headingLevel' => 'h3', + 'showQuickAdd' => true, +]) + +@php + use App\Enums\InventoryPolicy; + use App\Enums\MediaStatus; + use Illuminate\Support\Facades\Storage; + + /** @var \App\Models\Product $product */ + $variant = $product->variants->firstWhere('is_default', true) ?? $product->variants->first(); + $priceAmount = $variant?->price_amount ?? 0; + $compareAtAmount = $variant?->compare_at_amount; + $currency = $variant?->currency ?? ($currentStore->default_currency ?? 'EUR'); + + $isOnSale = $compareAtAmount !== null && $compareAtAmount > $priceAmount; + $isSoldOut = $product->variants->isNotEmpty() && $product->variants->every( + fn ($productVariant): bool => $productVariant->inventoryItem !== null + && $productVariant->inventoryItem->availableQuantity() <= 0 + && $productVariant->inventoryItem->policy === InventoryPolicy::Deny, + ); + + $primaryImage = $product->media->firstWhere('status', MediaStatus::Ready) ?? $product->media->first(); + $imageUrl = $primaryImage !== null ? Storage::disk('public')->url($primaryImage->storage_key) : null; + + $headingTag = in_array($headingLevel, ['h2', 'h3', 'h4'], true) ? $headingLevel : 'h3'; + $hasMultipleVariants = $product->variants->count() > 1; +@endphp + +
class('group relative flex flex-col') }}> +
+ @if ($imageUrl !== null) + {{ $primaryImage->alt_text ?? $product->title }} + @else +
+ +
+ @endif + + @if ($isOnSale || $isSoldOut) +
+ @if ($isOnSale) + + @endif + @if ($isSoldOut) + + @endif +
+ @endif +
+ +
+ <{{ $headingTag }} class="text-sm font-semibold text-zinc-900 dark:text-white"> + + {{ $product->title }} + + + + + + @if ($showQuickAdd && ! $isSoldOut) + + {{ $hasMultipleVariants ? __('Choose options') : __('View product') }} + + @endif +
+
diff --git a/resources/views/components/storefront/quantity-selector.blade.php b/resources/views/components/storefront/quantity-selector.blade.php new file mode 100644 index 00000000..7c706d33 --- /dev/null +++ b/resources/views/components/storefront/quantity-selector.blade.php @@ -0,0 +1,62 @@ +@props([ + 'value' => 1, + 'min' => 1, + 'max' => null, + 'wireModel' => null, + 'compact' => false, + 'label' => null, +]) + +@php + $label ??= __('Quantity'); + $buttonSize = $compact ? 'size-8' : 'size-10'; + $inputSize = $compact ? 'h-8 w-10 text-xs' : 'h-10 w-14 text-sm'; + $alpineValue = $wireModel !== null + ? "\$wire.entangle('".$wireModel."')" + : (string) max((int) $value, (int) $min); +@endphp + +
class('inline-flex items-stretch overflow-hidden rounded-lg border border-zinc-300 dark:border-zinc-700') }} + x-data="{ + quantity: {{ $alpineValue }}, + min: {{ (int) $min }}, + max: {{ $max === null ? 'null' : (int) $max }}, + decrease() { this.quantity = Math.max(this.min, (parseInt(this.quantity) || this.min) - 1); }, + increase() { + const next = (parseInt(this.quantity) || this.min) + 1; + this.quantity = this.max === null ? next : Math.min(this.max, next); + }, + }" +> + + + +
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/errors/404.blade.php b/resources/views/errors/404.blade.php new file mode 100644 index 00000000..5ceec851 --- /dev/null +++ b/resources/views/errors/404.blade.php @@ -0,0 +1,19 @@ + + + + @include('partials.head', ['title' => __('Page not found')]) + + + +

{{ __('Page not found') }}

+

+ {{ __("The page you're looking for doesn't exist or has been moved.") }} +

+ + {{ __('Go to home page') }} + + + diff --git a/resources/views/errors/419.blade.php b/resources/views/errors/419.blade.php new file mode 100644 index 00000000..e15559c8 --- /dev/null +++ b/resources/views/errors/419.blade.php @@ -0,0 +1,19 @@ + + + + @include('partials.head', ['title' => __('Page expired')]) + + + +

{{ __('Page expired') }}

+

+ {{ __('Your session expired. Please go back, refresh the page, and try again.') }} +

+ + {{ __('Go back') }} + + + diff --git a/resources/views/errors/500.blade.php b/resources/views/errors/500.blade.php new file mode 100644 index 00000000..8e568d4a --- /dev/null +++ b/resources/views/errors/500.blade.php @@ -0,0 +1,19 @@ + + + + @include('partials.head', ['title' => __('Something went wrong')]) + + + +

{{ __('Something went wrong') }}

+

+ {{ __('An unexpected error occurred on our end. Please try again in a moment.') }} +

+ + {{ __('Go to home page') }} + + + diff --git a/resources/views/errors/503.blade.php b/resources/views/errors/503.blade.php new file mode 100644 index 00000000..7f185c0f --- /dev/null +++ b/resources/views/errors/503.blade.php @@ -0,0 +1,13 @@ + + + + @include('partials.head', ['title' => __("We'll be back soon")]) + + + +

{{ __("We'll be back soon") }}

+

+ {{ __("We're currently performing maintenance. Please check back shortly.") }} +

+ + diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php new file mode 100644 index 00000000..b736584c --- /dev/null +++ b/resources/views/layouts/admin.blade.php @@ -0,0 +1,68 @@ + + + + @include('partials.head', ['title' => isset($title) && filled($title) ? $title.' - '.__('Admin') : __('Admin')]) + + + + {{ __('Skip to main content') }} + + + + +
+ + +
+ {{ $slot }} +
+
+ + {{-- Global toast notifications (spec 03 section 1.5): top-right, auto-dismiss, stacking. --}} +
+ +
+ + @fluxScripts + + diff --git a/resources/views/layouts/storefront.blade.php b/resources/views/layouts/storefront.blade.php new file mode 100644 index 00000000..0a1157c0 --- /dev/null +++ b/resources/views/layouts/storefront.blade.php @@ -0,0 +1,80 @@ +@php + $themeSettings = app(\App\Services\ThemeSettingsService::class)->all(); + $mainMenu = app(\App\Services\NavigationService::class)->tree('main-menu'); + $footerMenu = app(\App\Services\NavigationService::class)->tree('footer-menu'); + $storeName = $currentStore->name ?? config('app.name'); + $cartItemCount = isset($currentStore) + ? (app(\App\Services\CartService::class)->findFor($currentStore, auth('customer')->user())?->itemCount() ?? 0) + : 0; +@endphp + + + + @include('partials.head', ['title' => isset($title) && filled($title) ? $title.' - '.$storeName : $storeName]) + @isset($metaDescription) + + @endisset + + + + {{ __('Skip to main content') }} + + + @include('storefront.partials.announcement-bar') + + @include('storefront.partials.header') + +
+ {{ $slot }} +
+ + @include('storefront.partials.footer') + + + + + + {{-- Analytics: page_view tracking via the batch ingestion API (spec 02 section 2.6) --}} + + + @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..649580f2 --- /dev/null +++ b/resources/views/livewire/admin/analytics/index.blade.php @@ -0,0 +1,182 @@ +
+ + +
+ {{ __('Analytics') }} + +
+ + {{ __('Today') }} + {{ __('Last 7 days') }} + {{ __('Last 30 days') }} + {{ __('Custom range') }} + + + @if ($dateRange === 'custom') + + + @endif +
+
+ + {{-- KPI tiles --}} +
+ @foreach ([ + ['label' => __('Total sales'), 'value' => $formattedTotalSales, 'change' => $salesChange, 'test' => 'analytics-kpi-total-sales'], + ['label' => __('Orders'), 'value' => number_format($ordersCount), 'change' => $ordersChange, 'test' => 'analytics-kpi-orders'], + ['label' => __('Average order value'), 'value' => $formattedAov, 'change' => $aovChange, 'test' => 'analytics-kpi-aov'], + ['label' => __('Conversion rate'), 'value' => number_format($conversionRate, 1).'%', 'change' => $conversionChange, 'test' => 'analytics-kpi-conversion'], + ] as $tile) + + {{ $tile['label'] }} + {{ $tile['value'] }} +
+ + {{ ($tile['change'] >= 0 ? '+' : '').number_format($tile['change'], 1) }}% + + + {{ __('vs previous period') }} +
+
+ @endforeach +
+ + {{-- Sales over time (inline SVG line chart, no JS chart dependency) --}} + +
+ {{ __('Sales over time') }} + + {{ __('Peak: :max/day', ['max' => \App\Support\Storefront\PriceFormatter::format($chart['max'], app('current_store')->default_currency ?? 'EUR')]) }} + +
+ +
+ + + + + + +
+ {{ \Illuminate\Support\Carbon::parse($chart['days'][0]['date'])->format('M j') }} + {{ \Illuminate\Support\Carbon::parse(end($chart['days'])['date'])->format('M j') }} +
+
+
+ +
+ {{-- Conversion funnel --}} + + {{ __('Conversion funnel') }} + +
+ @foreach ($funnel as $index => $step) +
+
+ {{ $step['label'] }} + {{ number_format($step['count']) }} +
+
+
+
+
+ @endforeach +
+ + + {{ trans_choice(':count unique visit in this period|:count unique visits in this period', $visitsCount, ['count' => number_format($visitsCount)]) }} + +
+ + {{-- Top referrers --}} + +
+ {{ __('Top referrers') }} +
+ + @if ($topReferrers === []) +
+ {{ __('No traffic data for this period.') }} +
+ @else +
+ + + + + + + + + + + @foreach ($topReferrers as $referrer) + + + + + + + @endforeach + +
{{ __('Source') }}{{ __('Sessions') }}{{ __('Orders') }}{{ __('Conversion') }}
{{ $referrer['source'] }}{{ number_format($referrer['sessions']) }}{{ number_format($referrer['orders']) }}{{ number_format($referrer['conversion'], 2) }}%
+
+ @endif +
+
+ + {{-- Top products --}} + +
+ {{ __('Top products') }} +
+ + @if ($topProducts === []) +
+ {{ __('No sales data for this period.') }} +
+ @else +
+ + + + + + + + + + + + @foreach ($topProducts as $product) + + + + + + + + @endforeach + +
{{ __('Rank') }}{{ __('Product') }}{{ __('Units sold') }}{{ __('Revenue') }}{{ __('% of total') }}
{{ $loop->iteration }}{{ $product['title'] }}{{ number_format($product['units_sold']) }} + {{ \App\Support\Storefront\PriceFormatter::format($product['revenue'], app('current_store')->default_currency ?? 'EUR') }} + {{ number_format($product['share'], 1) }}%
+
+ @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..7660ae7f --- /dev/null +++ b/resources/views/livewire/admin/apps/index.blade.php @@ -0,0 +1,91 @@ +
+ + + {{ __('Apps') }} + +
+ {{ __('Installed apps') }} + {{ __('Apps connected to this store.') }} +
+ + @if ($this->installedApps->isEmpty()) + +
+ + {{ __('No apps installed') }} + {{ __('Install an app from the directory below to extend your store.') }} +
+
+ @else +
+ @foreach ($this->installedApps as $installation) + +
+
+ +
+ +
+ + {{ $installation->app->name }} + + + {{ __('Installed :time', ['time' => $installation->installed_at?->diffForHumans() ?? __('recently')]) }} + +
+ + + + + {{ __('Uninstall') }} + +
+
+ @endforeach +
+ @endif + + + +
+ {{ __('Available apps') }} + {{ __('Apps from the platform directory that can be installed on this store.') }} +
+ + @if ($this->availableApps->isEmpty()) + + {{ __('No more apps available to install.') }} + + @else +
+ @foreach ($this->availableApps as $app) + +
+
+ +
+ +
+ {{ $app->name }} +
+ + + {{ __('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..5dbdba4b --- /dev/null +++ b/resources/views/livewire/admin/apps/show.blade.php @@ -0,0 +1,110 @@ +
+ + +
+
+
+ +
+
+ {{ $this->installation->app->name }} + + {{ __('Installed :time', ['time' => $this->installation->installed_at?->diffForHumans() ?? __('recently')]) }} + +
+
+ +
+ + + + {{ __('Uninstall') }} + +
+
+ + + {{ __('Granted scopes') }} + {{ __('Permissions this app may use to access store data.') }} + +
+ @forelse ($this->installation->scopes_json ?? [] as $scope) + {{ $scope }} + @empty + {{ __('No scopes granted.') }} + @endforelse +
+
+ + +
+ {{ __('Webhook subscriptions') }} + {{ __('Events this app receives from your store.') }} +
+ +
+ + + + + + + + + + + @forelse ($this->installation->webhookSubscriptions as $subscription) + + + + + + + @empty + + + + @endforelse + +
{{ __('Event type') }}{{ __('URL') }}{{ __('Status') }}{{ __('Last delivery') }}
{{ $subscription->event_type }}{{ $subscription->target_url }} + @if ($subscription->latestDelivery !== null) + {{ $subscription->latestDelivery->last_attempt_at?->diffForHumans() ?? __('Pending') }} + @if ($subscription->latestDelivery->response_code !== null) + + {{ $subscription->latestDelivery->response_code }} + + @endif + @else + {{ __('Never') }} + @endif +
+ {{ __('This app has no webhook subscriptions.') }} +
+
+
+ + + {{ __('Usage') }} + {{ __('API access activity for this installation.') }} + +
+
+
{{ __('Active API tokens') }}
+
{{ $this->installation->oauthTokens->count() }}
+
+
+
{{ __('Last API call') }}
+
{{ __('Never') }}
+
+
+
+
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..9e4b61b1 --- /dev/null +++ b/resources/views/livewire/admin/auth/login.blade.php @@ -0,0 +1,44 @@ +
+ + + + +
+ @csrf + + + + @error('email') + {{ $message }} + @enderror + + + + @error('password') + {{ $message }} + @enderror + + + + + {{ __('Sign in') }} + + +
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..ecf01a62 --- /dev/null +++ b/resources/views/livewire/admin/collections/form.blade.php @@ -0,0 +1,156 @@ +
+ + +
+ + {{ $this->isEditing ? $collection->title : __('Add collection') }} + + + @if ($this->isEditing) + @can('delete', $collection) + + {{ __('Delete') }} + + @endcan + @endif +
+ +
+ {{-- LEFT COLUMN (2/3) --}} +
+ + + {{ __('Title') }} + + + + + + {{ __('Handle') }} + + {{ __('Leave empty to generate from the title.') }} + + + + + {{ __('Description') }} + + + + + + {{-- Product assignment --}} + +
+ + + @if ($this->searchResults->isNotEmpty()) +
+ @foreach ($this->searchResults as $product) +
+ {{ $product->title }} + + {{ __('Add') }} + +
+ @endforeach +
+ @endif +
+ + @if ($this->assignedProducts->isEmpty()) + {{ __('No products assigned yet. Search above to add products.') }} + @else +
+ @foreach ($this->assignedProducts as $product) +
+ + + @if ($product->media->isNotEmpty()) + {{ $product->media->first()->alt_text ?? $product->title }} + @else +
+ +
+ @endif + + {{ $product->title }} + + +
+ @endforeach +
+ @endif +
+
+ + {{-- RIGHT COLUMN (1/3) --}} +
+ + + + {{ __('Draft') }} + {{ __('Active') }} + {{ __('Archived') }} + + + + +
+ + {{-- Sticky save bar --}} +
+
+ + {{ __('Discard') }} + + + {{ __('Save') }} + {{ __('Saving...') }} + +
+
+
+ + @if ($this->isEditing) + +
+ {{ __('Delete this collection?') }} + + {{ __('The collection will be removed. Products in the collection are not deleted.') }} + +
+ + {{ __('Cancel') }} + + + {{ __('Delete collection') }} + +
+
+
+ @endif +
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..7f84eaf5 --- /dev/null +++ b/resources/views/livewire/admin/collections/index.blade.php @@ -0,0 +1,114 @@ +
+ + +
+ {{ __('Collections') }} + + @can('create', \App\Models\Collection::class) + + {{ __('Add collection') }} + + @endcan +
+ + @if (! $this->hasAnyCollections) + + + {{ __('Create your first collection') }} + {{ __('Group products into collections to organize your storefront.') }} + @can('create', \App\Models\Collection::class) + + {{ __('Add collection') }} + + @endcan + + @else +
+ + + + {{ __('Status: All') }} + {{ __('Draft') }} + {{ __('Active') }} + {{ __('Archived') }} + +
+ + +
+ + + + + + + + + + + + @forelse ($this->collections as $collection) + + + + + + + + @empty + + + + @endforelse + +
{{ __('Title') }}{{ __('Products') }}{{ __('Status') }}{{ __('Updated') }}{{ __('Actions') }}
+ + {{ $collection->title }} + + {{ number_format($collection->products_count) }}{{ $collection->updated_at?->diffForHumans(short: true) }} + @can('delete', $collection) + + @endcan +
+ {{ __('No collections match your filters.') }} +
+
+ + @if ($this->collections->hasPages()) +
+ {{ $this->collections->links() }} +
+ @endif +
+ + +
+ {{ __('Delete this collection?') }} + + {{ __('The collection will be removed. Products in the collection are not deleted.') }} + +
+ + {{ __('Cancel') }} + + + {{ __('Delete collection') }} + +
+
+
+ @endif +
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..476f82a3 --- /dev/null +++ b/resources/views/livewire/admin/customers/index.blade.php @@ -0,0 +1,58 @@ +
+ + + {{ __('Customers') }} + + + + +
+ + + + + + + + + + + + @forelse ($this->customers as $customer) + + + + + + + + @empty + + + + @endforelse + +
{{ __('Name') }}{{ __('Email') }}{{ __('Orders') }}{{ __('Total spent') }}{{ __('Created') }}
+ + {{ $customer->name ?: __('(no name)') }} + + {{ $customer->email }}{{ $customer->orders_count }} + {{ \App\Support\Storefront\PriceFormatter::format((int) ($customer->orders_sum_total_amount ?? 0), $currentStore->default_currency ?? 'EUR') }} + {{ $customer->created_at?->format('M j, Y') }}
+ {{ __('No customers found.') }} +
+
+ + @if ($this->customers->hasPages()) +
+ {{ $this->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..e5f14206 --- /dev/null +++ b/resources/views/livewire/admin/customers/show.blade.php @@ -0,0 +1,234 @@ +@php + use App\Support\Storefront\Countries; + use App\Support\Storefront\PriceFormatter; + + $customer = $this->customer; + $currency = $currentStore->default_currency ?? 'EUR'; +@endphp + +
+ + + {{ $customer->name ?: $customer->email }} + +
+ {{-- LEFT COLUMN (2/3) --}} +
+ +
+
+
{{ __('Name') }}
+
{{ $customer->name ?: '-' }}
+
+
+
{{ __('Email') }}
+
{{ $customer->email }}
+
+
+
{{ __('Created') }}
+
{{ $customer->created_at?->format('M j, Y') }}
+
+
+
{{ __('Marketing') }}
+
+ + {{ $customer->marketing_opt_in ? __('Opted in') : __('Opted out') }} + +
+
+
+
{{ __('Orders') }}
+
{{ $customer->orders_count }}
+
+
+
{{ __('Total spent') }}
+
+ {{ PriceFormatter::format((int) ($customer->orders_sum_total_amount ?? 0), $currency) }} +
+
+
+
+ + +
+ {{ __('Order history') }} +
+ + @if ($this->orders->isEmpty()) +
+ {{ __('No orders yet.') }} +
+ @else +
+ + + + + + + + + + + @foreach ($this->orders as $order) + + + + + + + @endforeach + +
{{ __('Order') }}{{ __('Date') }}{{ __('Status') }}{{ __('Total') }}
+ + {{ $order->order_number }} + + {{ $order->placed_at?->format('M j, Y') }} + {{ PriceFormatter::format($order->total_amount, $order->currency) }} +
+
+ + @if ($this->orders->hasPages()) +
+ {{ $this->orders->links() }} +
+ @endif + @endif +
+
+ + {{-- RIGHT COLUMN (1/3) --}} +
+ +
+ @forelse ($customer->addresses as $address) + @php($json = $address->address_json ?? []) +
+
+

+ {{ $address->label ?: __('Address') }} +

+ @if ($address->is_default) + {{ __('Default') }} + @endif +
+ +
+ @if (filled(trim(($json['first_name'] ?? '').' '.($json['last_name'] ?? '')))) +

{{ trim(($json['first_name'] ?? '').' '.($json['last_name'] ?? '')) }}

+ @endif + @if (filled($json['address1'] ?? null))

{{ $json['address1'] }}

@endif + @if (filled($json['address2'] ?? null))

{{ $json['address2'] }}

@endif +

{{ trim(collect([$json['zip'] ?? null, $json['city'] ?? null])->filter()->implode(' ')) }}

+ @if (filled($json['country_code'] ?? null)) +

{{ Countries::name($json['country_code']) }}

+ @endif +
+ + @can('update', $customer) +
+ + {{ __('Edit') }} + + @unless ($address->is_default) + + {{ __('Set default') }} + + @endunless + + {{ __('Delete') }} + +
+ @endcan +
+ @empty + {{ __('No addresses on file.') }} + @endforelse + + @can('update', $customer) + + {{ __('Add address') }} + + @endcan +
+
+
+
+ + {{-- Address form modal --}} + +
+ + {{ $editingAddressId !== null ? __('Edit address') : __('Add address') }} + + + + {{ __('Label') }} + + + +
+ + {{ __('First name') }} + + + + {{ __('Last name') }} + + +
+ + + {{ __('Address line 1') }} + + + + + + {{ __('Address line 2') }} + + + +
+ + {{ __('City') }} + + + + + {{ __('State / Province') }} + + +
+ +
+ + {{ __('ZIP / Postal code') }} + + + + + {{ __('Country') }} + + @foreach (Countries::OPTIONS as $code => $name) + {{ $name }} + @endforeach + + + +
+ +
+ + {{ __('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..cc54fa70 --- /dev/null +++ b/resources/views/livewire/admin/dashboard.blade.php @@ -0,0 +1,117 @@ +
+ + +
+ {{ __('Dashboard') }} + + + {{ __('Last 7 days') }} + {{ __('Last 30 days') }} + {{ __('Last 90 days') }} + +
+ + {{-- KPI tiles --}} +
+ @foreach ([ + ['label' => __('Total sales'), 'value' => $formattedTotalSales, 'change' => $salesChange, 'test' => 'kpi-total-sales'], + ['label' => __('Orders'), 'value' => number_format($ordersCount), 'change' => $ordersChange, 'test' => 'kpi-orders'], + ['label' => __('Average order value'), 'value' => $formattedAov, 'change' => $aovChange, 'test' => 'kpi-aov'], + ['label' => __('Conversion rate'), 'value' => number_format($conversionRate, 1).'%', 'change' => $conversionChange, 'test' => 'kpi-conversion'], + ] as $tile) + + {{ $tile['label'] }} + {{ $tile['value'] }} +
+ + {{ ($tile['change'] >= 0 ? '+' : '').number_format($tile['change'], 1) }}% + + + {{ __('vs previous period') }} +
+
+ @endforeach +
+ + {{-- Orders over time (inline SVG line chart, no JS chart dependency) --}} + +
+ {{ __('Orders over time') }} + {{ __('Peak: :max orders/day', ['max' => $chart['max']]) }} +
+ +
+ + + + + + +
+ {{ \Illuminate\Support\Carbon::parse($chart['days'][0]['date'])->format('M j') }} + {{ \Illuminate\Support\Carbon::parse(end($chart['days'])['date'])->format('M j') }} +
+
+
+ + {{-- Recent orders --}} + +
+ {{ __('Recent orders') }} + + {{ __('View all') }} + +
+ + @if ($recentOrders->isEmpty()) +
+ {{ __('No orders yet.') }} +
+ @else +
+ + + + + + + + + + + + + @foreach ($recentOrders as $order) + + + + + + + + + @endforeach + +
{{ __('Order') }}{{ __('Date') }}{{ __('Customer') }}{{ __('Payment') }}{{ __('Fulfillment') }}{{ __('Total') }}
+ + {{ $order->order_number }} + + {{ $order->placed_at?->format('M j, g:i A') }}{{ $order->customer?->name ?? __('Guest') }} + {{ \App\Support\Storefront\PriceFormatter::format($order->total_amount, $order->currency) }} +
+
+ @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..1a23bcec --- /dev/null +++ b/resources/views/livewire/admin/developers/index.blade.php @@ -0,0 +1,301 @@ +
+ + +
+ {{ __('Developers') }} + + + + {{ __('Generate new token') }} + + +
+ +
+ {{ __('API tokens') }} + {{ __('Manage personal access tokens for the Admin API. Tokens are sent as a Bearer header and expire after one year.') }} +
+ + @if ($generatedToken !== null) +
+
+ +
+ + {{ __('Copy this token now. It will not be shown again.') }} + + {{ $generatedToken }} +
+
+
+ @endif + + +
+ + + + + + + + + + + + @forelse ($this->tokens as $token) + + + + + + + + @empty + + + + @endforelse + +
{{ __('Name') }}{{ __('Abilities') }}{{ __('Last used') }}{{ __('Created') }}{{ __('Actions') }}
{{ $token->name }} +
+ @foreach ($token->abilities ?? [] as $ability) + {{ $ability }} + @endforeach +
+
+ {{ $token->last_used_at?->diffForHumans() ?? __('Never') }} + + {{ $token->created_at?->format('M j, Y') }} + + + {{ __('Revoke') }} + +
+ {{ __('No API tokens yet. Generate one to access the Admin API.') }} +
+
+
+ + + +
+
+ {{ __('Webhooks') }} + {{ __('Manage webhook subscriptions for real-time event notifications.') }} +
+ + + {{ __('Add webhook') }} + +
+ + @if ($generatedWebhookSecret !== null) +
+
+ +
+ + {{ __('Copy this signing secret now. It will not be shown again. Use it to verify the X-Platform-Signature header.') }} + + {{ $generatedWebhookSecret }} +
+
+
+ @endif + + +
+ + + + + + + + + + + + @forelse ($this->webhooks as $webhook) + + + + + + + + @empty + + + + @endforelse + +
{{ __('Event type') }}{{ __('URL') }}{{ __('Status') }}{{ __('Last delivery') }}{{ __('Actions') }}
{{ $webhook->event_type }}{{ $webhook->target_url }} + + {{ \Illuminate\Support\Str::headline($webhook->status->value) }} + + + @if ($webhook->latestDelivery?->last_attempt_at !== null) + {{ $webhook->latestDelivery->last_attempt_at->diffForHumans() }} + @if ($webhook->latestDelivery->response_code !== null) + + {{ $webhook->latestDelivery->response_code }} + + @endif + @else + {{ __('Never') }} + @endif + +
+ @if ($webhook->status !== \App\Enums\WebhookSubscriptionStatus::Disabled) + + {{ $webhook->status === \App\Enums\WebhookSubscriptionStatus::Active ? __('Pause') : __('Resume') }} + + @endif + + +
+
+ {{ __('No webhook subscriptions yet. Add one to receive real-time event notifications.') }} +
+
+
+ + @if ($this->recentDeliveries->isNotEmpty()) +
+ {{ __('Recent deliveries') }} + {{ __('The latest webhook delivery attempts across all subscriptions.') }} +
+ + +
+ + + + + + + + + + + + @foreach ($this->recentDeliveries as $delivery) + + + + + + + + @endforeach + +
{{ __('Event type') }}{{ __('Status') }}{{ __('Response') }}{{ __('Attempts') }}{{ __('Last attempt') }}
{{ $delivery->subscription->event_type }} + {{ $delivery->response_code ?? __('No response') }} + {{ $delivery->attempt_count }} + {{ $delivery->last_attempt_at?->diffForHumans() ?? __('Pending') }} +
+
+
+ @endif + + +
+ + {{ $editingWebhookId !== null ? __('Edit webhook') : __('Add webhook') }} + + + + {{ __('Event type') }} + + @foreach ($this->webhookEventTypes as $eventType) + {{ $eventType }} + @endforeach + + + + + + {{ __('Endpoint URL') }} + + + + +
+ + {{ __('Cancel') }} + + + {{ __('Save') }} + +
+
+
+ + +
+ {{ __('Generate API token') }} + + + {{ __('Token name') }} + + + + + + {{ __('Abilities') }} +
+ @foreach ($this->availableAbilities as $ability => $description) + + @endforeach +
+ +
+ +
+ + {{ __('Cancel') }} + + + {{ __('Generate') }} + +
+
+
+
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..ae043f88 --- /dev/null +++ b/resources/views/livewire/admin/discounts/form.blade.php @@ -0,0 +1,243 @@ +
+ + +
+ + {{ $this->isEditing ? ($discount->code ?? __('Automatic discount')) : __('Create discount') }} + + + @if ($this->isEditing) + @can('delete', $discount) + + {{ __('Delete') }} + + @endcan + @endif +
+ +
+ {{-- Type --}} + + + + + + + + + {{-- Code --}} + @if ($type === 'code') + +
+ + {{ __('Code') }} + + + + {{ __('Generate') }} + +
+ +
+ @endif + + {{-- Value --}} + + + + + + + + + @if ($valueType !== 'free_shipping') + + {{ $valueType === 'percent' ? __('Percentage') : __('Amount') }} + + + + @endif + + + {{-- Conditions --}} + + + {{ __('Minimum purchase amount') }} + + {{ __('Leave empty for no minimum') }} + + + + + +
+ {{ __('Specific products') }} +
+ + + @if ($this->productSearchResults->isNotEmpty()) +
+ @foreach ($this->productSearchResults as $product) + + @endforeach +
+ @endif +
+ + @if ($this->selectedProducts->isNotEmpty()) +
+ @foreach ($this->selectedProducts as $product) + + {{ $product->title }} + + + @endforeach +
+ @endif + {{ __('Leave empty to apply to the entire order.') }} +
+ +
+ {{ __('Specific collections') }} +
+ + + @if ($this->collectionSearchResults->isNotEmpty()) +
+ @foreach ($this->collectionSearchResults as $collection) + + @endforeach +
+ @endif +
+ + @if ($this->selectedCollections->isNotEmpty()) +
+ @foreach ($this->selectedCollections as $collection) + + {{ $collection->title }} + + + @endforeach +
+ @endif +
+
+ + {{-- Usage limits --}} + + + {{ __('Total usage limit') }} + + + + + + + + {{-- Active dates --}} + +
+ + {{ __('Start date') }} + + + + + + {{ __('End date') }} + + {{ __('Leave empty for no end date') }} + + +
+
+ + {{-- Status --}} + + + + {{ $isActive ? __('Active') : __('Disabled') }} + + + + {{-- Sticky save bar --}} +
+
+ + {{ __('Discard') }} + + + {{ __('Save') }} + {{ __('Saving...') }} + +
+
+
+ + @if ($this->isEditing) + +
+ {{ __('Delete this discount?') }} + {{ __('The discount will be permanently removed. Existing orders are not affected.') }} +
+ + {{ __('Cancel') }} + + + {{ __('Delete discount') }} + +
+
+
+ @endif +
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..439ca213 --- /dev/null +++ b/resources/views/livewire/admin/discounts/index.blade.php @@ -0,0 +1,117 @@ +
+ + +
+ {{ __('Discounts') }} + + @can('create', \App\Models\Discount::class) + + {{ __('Create discount') }} + + @endcan +
+ + @if (! $this->hasAnyDiscounts) + + + {{ __('Create your first discount') }} + {{ __('Offer discount codes or automatic discounts at checkout.') }} + @can('create', \App\Models\Discount::class) + + {{ __('Create discount') }} + + @endcan + + @else +
+ + + + {{ __('Status: All') }} + {{ __('Active') }} + {{ __('Scheduled') }} + {{ __('Expired') }} + {{ __('Disabled') }} + + + + {{ __('Type: All') }} + {{ __('Code') }} + {{ __('Automatic') }} + +
+ + +
+ + + + + + + + + + + + + @forelse ($this->discounts as $discount) + + + + + + + + + @empty + + + + @endforelse + +
{{ __('Code') }}{{ __('Type') }}{{ __('Value') }}{{ __('Usage') }}{{ __('Status') }}{{ __('Dates') }}
+ + {{ $discount->code ?? __('Automatic') }} + + + + {{ $discount->type === \App\Enums\DiscountType::Code ? __('Code') : __('Automatic') }} + + + @switch($discount->value_type) + @case(\App\Enums\DiscountValueType::Percent) + {{ $discount->value_amount }}% + @break + @case(\App\Enums\DiscountValueType::Fixed) + {{ \App\Support\Storefront\PriceFormatter::format($discount->value_amount, app('current_store')->default_currency) }} + @break + @default + {{ __('Free shipping') }} + @endswitch + + {{ number_format($discount->usage_count) }} / {{ $discount->usage_limit !== null ? number_format($discount->usage_limit) : __('unlimited') }} + + {{ $discount->starts_at?->format('M j, Y') }} + @if ($discount->ends_at !== null) + - {{ $discount->ends_at->format('M j, Y') }} + @endif +
+ {{ __('No discounts match your filters.') }} +
+
+ + @if ($this->discounts->hasPages()) +
+ {{ $this->discounts->links() }} +
+ @endif +
+ @endif +
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..6af42f95 --- /dev/null +++ b/resources/views/livewire/admin/inventory/index.blade.php @@ -0,0 +1,100 @@ +
+ + + {{ __('Inventory') }} + +
+ + + + {{ __('Stock: All') }} + {{ __('In stock') }} + {{ __('Low stock') }} + {{ __('Out of stock') }} + +
+ + +
+ + + + + + + + + + + + + + @forelse ($this->inventoryItems as $item) + @php($available = $item->availableQuantity()) + + + + + + + + + + @empty + + + + @endforelse + +
{{ __('Product') }}{{ __('Variant') }}{{ __('SKU') }}{{ __('On hand') }}{{ __('Reserved') }}{{ __('Available') }}{{ __('Policy') }}
+ + {{ $item->variant->product->title }} + + + {{ $item->variant->optionValues->isEmpty() ? __('Default') : $item->variant->optionValues->pluck('value')->implode(' / ') }} + {{ $item->variant->sku ?: '-' }} + @can('update', $item->variant->product) + + @else + {{ number_format($item->quantity_on_hand) }} + @endcan + {{ number_format($item->quantity_reserved) }} + $available <= 0, + 'text-yellow-600 dark:text-yellow-400' => $available > 0 && $available <= \App\Livewire\Admin\Inventory\Index::LOW_STOCK_THRESHOLD, + 'text-zinc-700 dark:text-zinc-300' => $available > \App\Livewire\Admin\Inventory\Index::LOW_STOCK_THRESHOLD, + ])> + {{ number_format($available) }} + + + + {{ $item->policy->value }} + +
+ {{ __('No inventory items match your filters.') }} +
+
+ + @if ($this->inventoryItems->hasPages()) +
+ {{ $this->inventoryItems->links() }} +
+ @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..81938c3e --- /dev/null +++ b/resources/views/livewire/admin/layout/sidebar.blade.php @@ -0,0 +1,80 @@ +
+ {{-- Mobile backdrop --}} + + + +
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..8f165bbc --- /dev/null +++ b/resources/views/livewire/admin/layout/top-bar.blade.php @@ -0,0 +1,71 @@ +
+ + + {{-- Store selector --}} + + + {{ $currentStore->name }} + + + + @foreach ($stores as $store) + + {{ $store->name }} + + @endforeach + + + + + + {{-- User menu --}} + + + + +
+ {{ auth()->user()->name }} + {{ auth()->user()->email }} +
+ + + + @if (\Illuminate\Support\Facades\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..f280bfdf --- /dev/null +++ b/resources/views/livewire/admin/navigation/index.blade.php @@ -0,0 +1,165 @@ +
+ + + {{ __('Navigation') }} + + {{-- Menu list --}} + @if ($this->menus->isEmpty()) + + + {{ __('No menus yet') }} + {{ __('Navigation menus are created when the store is set up.') }} + + @else +
+ @foreach ($this->menus as $menu) + +
+ {{ $menu->title }} + + {{ trans_choice(':count item|:count items', $menu->items_count, ['count' => $menu->items_count]) }} + - {{ $menu->handle }} + +
+ + {{ __('Edit') }} + +
+ @endforeach +
+ @endif + + {{-- Menu editor --}} + @if ($editingMenuId !== null) + @php($menu = $this->menus->firstWhere('id', $editingMenuId)) + +
+ {{ $menu?->title }} + + @can('update', $menu) + + {{ __('Add item') }} + + @endcan +
+ + @if ($menuItems === []) + {{ __('This menu has no items yet.') }} + @else +
+ @foreach ($menuItems as $index => $item) +
+ + +
+

{{ $item['label'] }}

+

{{ $this->describeItem($item) }}

+
+ + @can('update', $menu) + + + @endcan +
+ @endforeach +
+ @endif + + @can('update', $menu) +
+ + {{ __('Save menu') }} + {{ __('Saving...') }} + +
+ @endcan +
+ @endif + + {{-- Item form modal --}} + +
+ + {{ $editingItemIndex !== null ? __('Edit menu item') : __('Add menu item') }} + + + + {{ __('Label') }} + + + + + + {{ __('Type') }} + + {{ __('Custom link') }} + {{ __('Page') }} + {{ __('Collection') }} + {{ __('Product') }} + + + + + @if ($itemType === 'link') + + {{ __('URL') }} + + + + @else + + + {{ match ($itemType) { + 'page' => __('Page'), + 'collection' => __('Collection'), + default => __('Product'), + } }} + + + {{ __('Select...') }} + @foreach (match ($itemType) { + 'page' => $this->availablePages, + 'collection' => $this->availableCollections, + default => $this->availableProducts, + } 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..ec572c6d --- /dev/null +++ b/resources/views/livewire/admin/orders/index.blade.php @@ -0,0 +1,105 @@ +
+ + + {{ __('Orders') }} + +
+ + + + {{ __('to') }} + +
+ + {{-- Status filter tabs --}} +
+ @foreach ([ + 'all' => __('All'), + 'pending' => __('Pending'), + 'paid' => __('Paid'), + 'fulfilled' => __('Fulfilled'), + 'cancelled' => __('Cancelled'), + 'refunded' => __('Refunded'), + ] as $value => $label) + + @endforeach +
+ + +
+ + + + + + + + + + + + + @forelse ($this->orders as $order) + + + + + + + + + @empty + + + + @endforelse + +
{{ __('Order') }} + + {{ __('Customer') }}{{ __('Payment') }}{{ __('Fulfillment') }} + +
+ + {{ $order->order_number }} + + {{ $order->placed_at?->format('M j, Y g:i A') }}{{ $order->customer?->name ?? __('Guest') }} + {{ \App\Support\Storefront\PriceFormatter::format($order->total_amount, $order->currency) }} +
+ {{ __('No orders match your filters.') }} +
+
+ + @if ($this->orders->hasPages()) +
+ {{ $this->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..52ad07ca --- /dev/null +++ b/resources/views/livewire/admin/orders/show.blade.php @@ -0,0 +1,427 @@ +@php + use App\Enums\FinancialStatus; + use App\Enums\FulfillmentShipmentStatus; + use App\Enums\OrderStatus; + use App\Enums\PaymentMethod; + use App\Support\Storefront\PriceFormatter; + + $order = $this->order; + $showConfirmPayment = $order->payment_method === PaymentMethod::BankTransfer + && $order->financial_status === FinancialStatus::Pending; + $canRefund = in_array($order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true); + $canCancel = $order->status !== OrderStatus::Cancelled + && $order->fulfillment_status === \App\Enums\FulfillmentStatus::Unfulfilled; +@endphp + +
+ + +
+ {{-- LEFT COLUMN (2/3) --}} +
+ {{-- Heading --}} +
+
+ {{ $order->order_number }} + + +
+ {{ $order->placed_at?->format('M j, Y g:i A') }} +
+ + {{-- Action buttons --}} +
+ @if ($showConfirmPayment) + @can('update', $order) + + {{ __('Confirm payment') }} + + @endcan + @endif + + @if ($this->canCreateFulfillment) + @can('createFulfillment', $order) + + {{ __('Create fulfillment') }} + + @endcan + @endif + + @if ($canRefund) + @can('createRefund', $order) + + {{ __('Refund') }} + + @endcan + @endif + + @if ($canCancel) + @can('cancel', $order) + + + {{ __('Cancel order') }} + + + @endcan + @endif +
+ + {{-- Fulfillment guard callout --}} + @if (! $order->financial_status->allowsFulfillment() && $order->status !== OrderStatus::Cancelled && $order->fulfillment_status !== \App\Enums\FulfillmentStatus::Fulfilled) + + {{ __('Cannot create fulfillment.') }} + + {{ __('Payment must be confirmed before items can be fulfilled. Current financial status: :status.', ['status' => $order->financial_status->value]) }} + + + @endif + + {{-- Timeline --}} + +
    + @foreach ($this->timeline as $event) +
  1. + +

    {{ $event['label'] }}

    + @if ($event['description'] !== null) +

    {{ $event['description'] }}

    + @endif +

    {{ $event['timestamp']->format('M j, Y g:i A') }}

    +
  2. + @endforeach +
+
+ + {{-- Fulfillment cards --}} + @foreach ($order->fulfillments as $fulfillment) + +
+
+ {{ __('Fulfillment #:id', ['id' => $fulfillment->id]) }} + +
+
+ @can('update', $fulfillment) + @if ($fulfillment->status === FulfillmentShipmentStatus::Pending) + + {{ __('Mark as shipped') }} + + @elseif ($fulfillment->status === FulfillmentShipmentStatus::Shipped) + + {{ __('Mark as delivered') }} + + @endif + @endcan +
+
+ + @if (filled($fulfillment->tracking_number) || filled($fulfillment->tracking_company)) + + {{ trim(($fulfillment->tracking_company ?? '').' '.($fulfillment->tracking_number ?? '')) }} + @if (filled($fulfillment->tracking_url)) + + {{ __('Track shipment') }} + + @endif + + @endif + +
    + @foreach ($fulfillment->lines as $fulfillmentLine) +
  • + {{ $fulfillmentLine->quantity }} x {{ $fulfillmentLine->orderLine?->title_snapshot }} +
  • + @endforeach +
+
+ @endforeach + + {{-- Order lines --}} + +
+ {{ __('Order lines') }} +
+
+ + + + + + + + + + + + + @foreach ($order->lines as $line) + @php($lineMedia = $line->variant?->product?->media->first()) + + + + + + + + + @endforeach + +
{{ __('Image') }}{{ __('Product') }}{{ __('Fulfillment') }}{{ __('Qty') }}{{ __('Unit price') }}{{ __('Total') }}
+ @if ($lineMedia !== null) + + @else +
+ +
+ @endif +
+

{{ $line->title_snapshot }}

+ @if (filled($line->sku_snapshot)) +

{{ __('SKU: :sku', ['sku' => $line->sku_snapshot]) }}

+ @endif +
+ @php($unfulfilled = $line->unfulfilledQuantity()) + + {{ $line->quantity }}{{ PriceFormatter::format($line->unit_price_amount, $order->currency) }}{{ PriceFormatter::format($line->total_amount, $order->currency) }}
+
+ + {{-- Totals --}} +
+
+
+
{{ __('Subtotal') }}
+
{{ PriceFormatter::format($order->subtotal_amount, $order->currency) }}
+
+ @if ($order->discount_amount > 0) +
+
{{ __('Discount') }}
+
-{{ PriceFormatter::format($order->discount_amount, $order->currency) }}
+
+ @endif +
+
{{ __('Shipping') }}
+
{{ PriceFormatter::format($order->shipping_amount, $order->currency) }}
+
+
+
{{ __('Tax') }}
+
{{ PriceFormatter::format($order->tax_amount, $order->currency) }}
+
+ +
+
{{ __('Total') }}
+
{{ PriceFormatter::format($order->total_amount, $order->currency) }}
+
+ @if ($order->refundedAmount() > 0) +
+
{{ __('Refunded') }}
+
-{{ PriceFormatter::format($order->refundedAmount(), $order->currency) }}
+
+ @endif +
+
+
+ + {{-- Payment details --}} + +
+ @forelse ($order->payments as $payment) +
+
+

+ {{ \Illuminate\Support\Str::headline($payment->method->value) }} +

+

+ {{ PriceFormatter::format($payment->amount, $payment->currency) }} + @if (filled($payment->provider_payment_id)) + - {{ __('Ref:') }} {{ $payment->provider_payment_id }} + @endif +

+
+ +
+ @empty + {{ __('No payment recorded.') }} + @endforelse + + @if ($showConfirmPayment) + @can('update', $order) + + {{ __('Confirm payment') }} + + @endcan + @endif +
+
+
+ + {{-- RIGHT COLUMN (1/3) --}} +
+ +

{{ $order->customer?->name ?? __('Guest') }}

+ {{ $order->email }} + @if ($order->customer !== null) + + {{ __('View customer') }} + + @endif +
+ + @foreach ([['heading' => __('Shipping address'), 'address' => $order->shipping_address_json], ['heading' => __('Billing address'), 'address' => $order->billing_address_json]] as $panel) + + @if (filled($panel['address'])) +
+ @php($address = $panel['address']) + @if (filled(trim(($address['first_name'] ?? '').' '.($address['last_name'] ?? '')))) +

{{ trim(($address['first_name'] ?? '').' '.($address['last_name'] ?? '')) }}

+ @endif + @if (filled($address['address1'] ?? null))

{{ $address['address1'] }}

@endif + @if (filled($address['address2'] ?? null))

{{ $address['address2'] }}

@endif +

+ {{ trim(collect([$address['zip'] ?? $address['postal_code'] ?? null, $address['city'] ?? null])->filter()->implode(' ')) }} + {{ filled($address['province'] ?? null) ? ', '.$address['province'] : '' }} +

+ @if (filled($address['country_code'] ?? null)) +

{{ \App\Support\Storefront\Countries::name($address['country_code']) }}

+ @endif +
+ @else + {{ __('No address provided.') }} + @endif +
+ @endforeach +
+
+ + {{-- Fulfillment modal --}} + +
+ {{ __('Create fulfillment') }} + +
+ @forelse ($fulfillmentLines as $lineId => $line) +
+ +
+

{{ $line['title'] }}

+

{{ __(':count unfulfilled', ['count' => $line['max']]) }}

+
+ +
+ @empty + {{ __('All lines are fulfilled.') }} + @endforelse +
+ + + + + {{ __('Tracking company') }} + + + + {{ __('Tracking number') }} + + + + {{ __('Tracking URL') }} + + + +
+ + {{ __('Cancel') }} + + + {{ __('Create fulfillment') }} + +
+
+
+ + {{-- Refund modal --}} + +
+ {{ __('Refund order') }} + +
+ @foreach ($refundLines as $lineId => $line) +
+ +
+

{{ $line['title'] }}

+
+ +
+ @endforeach +
+ + + + + {{ __('Or enter custom amount') }} + + + {{ __('Remaining refundable: :amount', ['amount' => PriceFormatter::format($order->remainingRefundableAmount(), $order->currency)]) }} + + + + + {{ __('Reason') }} + + + + + +
+ + {{ __('Cancel') }} + + + {{ __('Create refund') }} + +
+
+
+ + {{-- Cancel order modal --}} + +
+ {{ __('Cancel this order?') }} + + {{ __('Pending orders release their inventory reservation; paid orders are restocked. 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..f31a8a2d --- /dev/null +++ b/resources/views/livewire/admin/pages/form.blade.php @@ -0,0 +1,99 @@ +
+ + +
+ + {{ $this->isEditing ? $page->title : __('Add page') }} + + + @if ($this->isEditing) + @can('delete', $page) + + {{ __('Delete') }} + + @endcan + @endif +
+ +
+ {{-- LEFT COLUMN (2/3) --}} +
+ + + {{ __('Title') }} + + + + + + {{ __('Handle') }} + + {{ __('Leave empty to generate from the title. The page is served at /pages/{handle}.') }} + + + + + {{ __('Body') }} + + {{ __('Basic HTML tags are supported.') }} + + + +
+ + {{-- RIGHT COLUMN (1/3) --}} +
+ + + + {{ __('Draft') }} + {{ __('Published') }} + {{ __('Archived') }} + + + + + + + + {{ __('Published at') }} + + + + +
+ + {{-- Sticky save bar --}} +
+
+ + {{ __('Discard') }} + + + {{ __('Save') }} + {{ __('Saving...') }} + +
+
+
+ + @if ($this->isEditing) + +
+ {{ __('Delete this page?') }} + {{ __('The page will be permanently removed from your storefront.') }} +
+ + {{ __('Cancel') }} + + + {{ __('Delete page') }} + +
+
+
+ @endif +
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..586dae4f --- /dev/null +++ b/resources/views/livewire/admin/pages/index.blade.php @@ -0,0 +1,75 @@ +
+ + +
+ {{ __('Pages') }} + + @can('create', \App\Models\Page::class) + + {{ __('Add page') }} + + @endcan +
+ + @if (! $this->hasAnyPages) + + + {{ __('Create your first page') }} + {{ __('Add content pages like About Us, FAQ, or Shipping policies.') }} + @can('create', \App\Models\Page::class) + + {{ __('Add page') }} + + @endcan + + @else + + + +
+ + + + + + + + + + + @forelse ($this->pages as $page) + + + + + + + @empty + + + + @endforelse + +
{{ __('Title') }}{{ __('Handle') }}{{ __('Status') }}{{ __('Updated') }}
+ + {{ $page->title }} + + /pages/{{ $page->handle }}{{ $page->updated_at?->diffForHumans(short: true) }}
+ {{ __('No pages match your search.') }} +
+
+ + @if ($this->pages->hasPages()) +
+ {{ $this->pages->links() }} +
+ @endif +
+ @endif +
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..49aa073c --- /dev/null +++ b/resources/views/livewire/admin/products/form.blade.php @@ -0,0 +1,318 @@ +
+ + +
+ + {{ $this->isEditing ? $product->title : __('Add product') }} + + + @if ($this->isEditing) + @can('delete', $product) + + {{ __('Delete') }} + + @endcan + @endif +
+ + @error('variants') + + {{ $message }} + + @enderror + +
+ {{-- LEFT COLUMN (2/3) --}} +
+ + + {{ __('Title') }} + + + + + + {{ __('Description') }} + + + + + + {{-- Media --}} + + + +
+ {{ __('Uploading...') }} +
+ + + + @if ($this->isEditing && $this->mediaItems->isNotEmpty()) +
+ @foreach ($this->mediaItems as $media) +
+ {{ $media->alt_text ?? '' }} + + + + + +
+ + +
+
+ @endforeach +
+ @endif + + @if (! $this->isEditing && count($pendingMedia) > 0) +
+ @foreach ($pendingMedia as $index => $file) +
+ + +
+ @endforeach +
+ @endif +
+ + {{-- Variants --}} + +
+ @foreach ($options as $index => $option) +
+ + {{ __('Option name') }} + + + + + {{ __('Values') }} + + {{ __('Separate values with commas') }} + + + +
+ @endforeach + + + {{ __('Add another option') }} + +
+ + @if (count($variants) > 0) +
+ + + + + + + + + + + + + + + @foreach ($variants as $index => $variant) + + + + + + + + + + + @endforeach + +
{{ __('Variant') }}{{ __('SKU') }}{{ __('Barcode') }}{{ __('Price') }}{{ __('Compare at') }}{{ __('Weight (g)') }}{{ __('Qty') }}{{ __('Ship') }}
+ {{ $variant['label'] }} + + + + + + +
+
+ @endif +
+ + {{-- SEO (collapsible) --}} + + + +
+ + {{ __('URL handle') }} + + {{ __('Leave empty to generate from the title.') }} + + +
+
+
+ + {{-- RIGHT COLUMN (1/3) --}} +
+ + + + {{ __('Draft') }} + {{ __('Active') }} + {{ __('Archived') }} + + + + + + + + {{ __('Published at') }} + + + + + + + + {{ __('Vendor') }} + + + + + {{ __('Product type') }} + + + + + {{ __('Tags') }} + + {{ __('Separate tags with commas') }} + + + + + @if ($this->availableCollections->isEmpty()) + {{ __('No collections yet.') }} + @else +
+ @foreach ($this->availableCollections as $collection) + + @endforeach +
+ @endif +
+
+ + {{-- Sticky save bar --}} +
+
+ + {{ __('Discard') }} + + + {{ __('Save') }} + {{ __('Saving...') }} + +
+
+
+ + @if ($this->isEditing) + +
+ {{ __('Delete this product?') }} + + {{ __('This product will be archived. Products with existing orders cannot be permanently removed.') }} + +
+ + {{ __('Cancel') }} + + + {{ __('Delete product') }} + +
+
+
+ @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..13a9414f --- /dev/null +++ b/resources/views/livewire/admin/products/index.blade.php @@ -0,0 +1,182 @@ +
+ + +
+ {{ __('Products') }} + + @can('create', \App\Models\Product::class) + + {{ __('Add product') }} + + @endcan +
+ + @if (! $this->hasAnyProducts) + {{-- Empty state: no products at all --}} + + + {{ __('Add your first product') }} + {{ __('Start building your catalog by adding products.') }} + @can('create', \App\Models\Product::class) + + {{ __('Add product') }} + + @endcan + + @else + {{-- Status filter tabs --}} +
+ @foreach (['all' => __('All'), 'draft' => __('Draft'), 'active' => __('Active'), 'archived' => __('Archived')] as $value => $label) + + @endforeach +
+ +
+ + + + {{ __('Type: All') }} + @foreach ($this->productTypes as $type) + {{ $type }} + @endforeach + +
+ + {{-- Bulk action bar --}} + @if (count($selectedIds) > 0) +
+ {{ trans_choice(':count product selected|:count products selected', count($selectedIds), ['count' => count($selectedIds)]) }} + + @can('create', \App\Models\Product::class) + {{ __('Set Active') }} + @endcan + {{ __('Archive') }} + + {{ __('Delete') }} + +
+ @endif + + {{-- Products table --}} + +
+ + + + + + + + + + + + + + + @forelse ($this->products as $product) + + + + + + + + + + + @empty + + + + @endforelse + +
+ + {{ __('Image') }} + + {{ __('Status') }} + + {{ __('Type') }}{{ __('Vendor') }} + +
+ + + @if ($product->media->isNotEmpty()) + {{ $product->media->first()->alt_text ?? $product->title }} + @else +
+ +
+ @endif +
+ + {{ $product->title }} + + {{ number_format((int) $product->inventory_quantity) }}{{ $product->product_type ?: '-' }}{{ $product->vendor ?: '-' }}{{ $product->updated_at?->diffForHumans(short: true) }}
+ {{ __('No products match your filters.') }} +
+
+ + @if ($this->products->hasPages()) +
+ {{ $this->products->links() }} +
+ @endif +
+ + {{-- Bulk delete confirmation --}} + +
+ {{ __('Delete products?') }} + + {{ __('This will archive :count product(s). Products with orders cannot be permanently deleted.', ['count' => count($selectedIds)]) }} + +
+ + {{ __('Cancel') }} + + + {{ __('Delete') }} + +
+
+
+ @endif +
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..3673e52c --- /dev/null +++ b/resources/views/livewire/admin/search/settings.blade.php @@ -0,0 +1,94 @@ +
+ + + {{ __('Search settings') }} + +
+ {{-- Synonyms --}} + +
+ {{ __('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 queries.') }} + +
+ + + + {{ __('Separate words with commas.') }} + + +
+ + {{-- Search index --}} + + {{ __('Search index') }} + +
+ + {{ __('Reindex now') }} + {{ __('Reindexing...') }} + + + @if ($lastIndexedAt !== null) + + {{ __('Last indexed: :timestamp', ['timestamp' => \Illuminate\Support\Carbon::parse($lastIndexedAt)->format('M j, Y g:i A')]) }} + + @endif +
+
+ +
+ + {{ __('Save') }} + +
+
+
diff --git a/resources/views/livewire/admin/settings/checkout.blade.php b/resources/views/livewire/admin/settings/checkout.blade.php new file mode 100644 index 00000000..40c2e7c1 --- /dev/null +++ b/resources/views/livewire/admin/settings/checkout.blade.php @@ -0,0 +1,31 @@ +
+ +
+
+ {{ __('Checkout') }} + {{ __('Control how customers complete their purchase.') }} +
+ +
+ + + {{ __('Allow guest checkout') }} + + + + {{ __('Cancel unpaid bank transfers after') }} + + {{ __('Days before unpaid bank transfer orders are cancelled automatically.') }} + + +
+
+
+ +
+ + {{ __('Save') }} + {{ __('Saving...') }} + +
+
diff --git a/resources/views/livewire/admin/settings/domains.blade.php b/resources/views/livewire/admin/settings/domains.blade.php new file mode 100644 index 00000000..6e6ec04b --- /dev/null +++ b/resources/views/livewire/admin/settings/domains.blade.php @@ -0,0 +1,102 @@ +
+
+ {{ __('Domains') }} + + @can('updateSettings', app('current_store')) + + {{ __('Add domain') }} + + @endcan +
+ + +
+ + + + + + + + + + + + @forelse ($this->domains as $domain) + + + + + + + + @empty + + + + @endforelse + +
{{ __('Hostname') }}{{ __('Type') }}{{ __('Primary') }}{{ __('TLS') }}{{ __('Actions') }}
{{ $domain->hostname }} + {{ $domain->type->value }} + + @if ($domain->is_primary) + {{ __('Primary') }} + @endif + + {{ $domain->tls_mode }} + + @can('updateSettings', app('current_store')) +
+ @unless ($domain->is_primary) + + {{ __('Set Primary') }} + + @endunless + +
+ @endcan +
+ {{ __('No domains configured yet.') }} +
+
+
+ + +
+ {{ __('Add domain') }} + + + {{ __('Hostname') }} + + + + + + {{ __('Type') }} + + {{ __('Storefront') }} + {{ __('Admin') }} + {{ __('API') }} + + + + +
+ + {{ __('Cancel') }} + + + {{ __('Add domain') }} + +
+
+
+
diff --git a/resources/views/livewire/admin/settings/general.blade.php b/resources/views/livewire/admin/settings/general.blade.php new file mode 100644 index 00000000..5d07d5bd --- /dev/null +++ b/resources/views/livewire/admin/settings/general.blade.php @@ -0,0 +1,86 @@ +
+ +
+
+ {{ __('Store details') }} + {{ __('Basic information about your store.') }} +
+ +
+ + {{ __('Store name') }} + + + + + + {{ __('Store handle') }} + + {{ __('The store handle cannot be changed after creation.') }} + + + + {{ __('Contact email') }} + + + + + + {{ __('Order number prefix') }} + + + +
+
+ + + +
+
+ {{ __('Defaults') }} + {{ __('Currency, language, and timezone settings.') }} +
+ +
+ + {{ __('Default currency') }} + + @foreach (['EUR', 'USD', 'GBP', 'CHF', 'SEK', 'DKK', 'NOK', 'PLN', 'CAD', 'AUD', 'JPY'] as $currency) + {{ $currency }} + @endforeach + + + + + + {{ __('Default locale') }} + + {{ __('English') }} + {{ __('German') }} + {{ __('French') }} + {{ __('Spanish') }} + {{ __('Italian') }} + + + + + + {{ __('Timezone') }} + + @foreach ($timezones as $timezoneOption) + {{ $timezoneOption }} + @endforeach + + + +
+
+
+ +
+ + {{ __('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..e6a80dad --- /dev/null +++ b/resources/views/livewire/admin/settings/index.blade.php @@ -0,0 +1,24 @@ +
+ + + {{ __('Store Settings') }} + + + + @switch($tab) + @case('domains') + + @break + + @case('checkout') + + @break + + @case('notifications') + + @break + + @default + + @endswitch +
diff --git a/resources/views/livewire/admin/settings/notifications.blade.php b/resources/views/livewire/admin/settings/notifications.blade.php new file mode 100644 index 00000000..f64e1ccf --- /dev/null +++ b/resources/views/livewire/admin/settings/notifications.blade.php @@ -0,0 +1,41 @@ +
+ +
+
+ {{ __('Notifications') }} + {{ __('Transactional emails and internal order alerts.') }} +
+ +
+ + {{ __('Notification email') }} + + {{ __('Internal alerts about new orders are sent to this address.') }} + + + + + + {{ __('Send order confirmation emails to customers') }} + + + + + {{ __('Send shipping confirmation emails to customers') }} + + + + + {{ __('Notify me when a new order is placed') }} + +
+
+
+ +
+ + {{ __('Save') }} + {{ __('Saving...') }} + +
+
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..0a401ca9 --- /dev/null +++ b/resources/views/livewire/admin/settings/shipping.blade.php @@ -0,0 +1,298 @@ +
+ + + {{ __('Settings') }} + + + +
+ {{ __('Shipping') }} + + @can('updateSettings', app('current_store')) + + {{ __('Add zone') }} + + @endcan +
+ + @if ($this->zones->isEmpty()) + + + {{ __('No shipping zones yet') }} + {{ __('Create a shipping zone to define where you ship and what it costs.') }} + + @else + @foreach ($this->zones as $zone) + +
+
+ {{ $zone->name }} + + {{ __('Countries:') }} {{ implode(', ', $zone->countries_json ?? []) }} + @if (filled($zone->regions_json)) + - {{ __('Regions:') }} {{ implode(', ', $zone->regions_json) }} + @endif + +
+ + @can('updateSettings', app('current_store')) +
+ + {{ __('Edit') }} + + +
+ @endcan +
+ +
+ + + + + + + + + + + + @forelse ($zone->rates as $rate) + + + + + + + + @empty + + + + @endforelse + +
{{ __('Name') }}{{ __('Type') }}{{ __('Config') }}{{ __('Active') }}{{ __('Actions') }}
{{ $rate->name }} + {{ $rate->type->value }} + {{ $this->describeRateConfig($rate) }} + @can('updateSettings', app('current_store')) + + @else + + @endcan + + @can('updateSettings', app('current_store')) +
+ + {{ __('Edit') }} + + +
+ @endcan +
+ {{ __('No rates in this zone yet.') }} +
+
+ + @can('updateSettings', app('current_store')) + + {{ __('Add rate') }} + + @endcan +
+ @endforeach + @endif + + {{-- Test shipping address tool --}} + + {{ __('Enter an address to see which shipping zone and rates match.') }} + +
+ + {{ __('Country') }} + + @foreach (\App\Livewire\Admin\Settings\Shipping::COUNTRIES as $code => $name) + {{ $name }} + @endforeach + + + + + {{ __('State / Region') }} + + + + + {{ __('City') }} + + + + + {{ __('ZIP / Postal code') }} + + +
+ + {{ __('Test') }} + + @if ($testResult === false) + + {{ __('No shipping zone matches this address.') }} + + @elseif (is_array($testResult)) + + + {{ __('Matched zone: :zone', ['zone' => $testResult['zone']]) }}
+ {{ implode(' / ', $testResult['rates']) }} +
+
+ @endif +
+ + {{-- Zone modal --}} + +
+ + {{ $editingZoneId !== null ? __('Edit shipping zone') : __('Add shipping zone') }} + + + + {{ __('Zone name') }} + + + + + + {{ __('Countries') }} +
+ @foreach (\App\Livewire\Admin\Settings\Shipping::COUNTRIES as $code => $name) + + @endforeach +
+ +
+ + + {{ __('Regions') }} + + {{ __('Optional comma-separated region codes for finer matching.') }} + + +
+ + {{ __('Cancel') }} + + {{ __('Save zone') }} +
+
+
+ + {{-- Rate modal --}} + +
+ + {{ $editingRateId !== null ? __('Edit shipping rate') : __('Add shipping rate') }} + + + + {{ __('Rate name') }} + + + + + + {{ __('Rate type') }} + + {{ __('Flat rate') }} + {{ __('Weight-based') }} + {{ __('Price-based') }} + + + + + @if ($rateType === 'flat') + + {{ __('Price') }} + + + + @else +
+ + {{ $rateType === 'weight' ? __('Weight ranges (grams)') : __('Order amount ranges') }} + + + @foreach ($rateRanges as $index => $range) +
+ + {{ __('Min') }} + + + + {{ __('Max') }} + + + + {{ __('Price') }} + + + +
+ + @endforeach + + + {{ __('Add range') }} + + +
+ @endif + + + + {{ __('Active') }} + + +
+ + {{ __('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..f37db827 --- /dev/null +++ b/resources/views/livewire/admin/settings/taxes.blade.php @@ -0,0 +1,79 @@ +
+ + + {{ __('Settings') }} + + + + {{ __('Tax Settings') }} + +
+ + + + + + + + @if ($mode === 'manual') +
+ + {{ __('Tax rate (%)') }} + + + + + + {{ __('Tax name') }} + + + +
+ @else +
+ + {{ __('Provider') }} + + {{ __('None') }} + {{ __('Stripe Tax') }} + + + + + + {{ __('API key') }} + + + +
+ @endif +
+ + + + + {{ __('Prices include tax') }} + + + {{ __('When enabled, the listed price includes tax. Tax is calculated backwards from the price.') }} + + + + + + + {{ __('Charge tax on shipping') }} + + + +
+ + {{ __('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..c9abc313 --- /dev/null +++ b/resources/views/livewire/admin/themes/editor.blade.php @@ -0,0 +1,171 @@ +
+ + + {{-- Toolbar --}} +
+ + {{ __('Back to themes') }} + + +
+ + + {{ __('Save') }} + {{ __('Saving...') }} + + + {{ __('Save & publish') }} + +
+
+ + {{-- Three-panel layout --}} +
+ {{-- Left panel: sections --}} + + {{ __('Theme settings') }} + +
+ @foreach (['header', 'colors', 'catalog', 'footer'] as $sectionKey) + + @endforeach +
+ + + + {{ __('Home page sections') }} + {{ __('Drag to reorder, toggle to show or hide.') }} + +
+ @foreach ($sectionOrder as $sectionKey) +
+ + + +
+ @endforeach +
+
+ + {{-- Center panel: live preview --}} +
+ @if ($this->previewUrl !== null) + + @else +
+ {{ __('No storefront domain configured for preview.') }} +
+ @endif +
+ + {{-- Right panel: settings for the selected section --}} + + @php($section = $this->sections()[$selectedSection] ?? null) + + @if ($section === null) +
+ {{ __('Select a section to edit its settings.') }} +
+ @else + {{ __(':section settings', ['section' => $section['label']]) }} + + +
+ @foreach ($section['fields'] as $field) +
+ @switch($field['type']) + @case('checkbox') + + @break + + @case('color') + + {{ $field['label'] }} + + + @break + + @case('select') + + {{ $field['label'] }} + + @foreach ($field['options'] ?? [] as $value => $label) + {{ $label }} + @endforeach + + + @break + + @case('textarea') + + {{ $field['label'] }} + + + @break + + @case('number') + + {{ $field['label'] }} + + + @break + + @default + + {{ $field['label'] }} + + + @endswitch +
+ @endforeach +
+ @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..945b1462 --- /dev/null +++ b/resources/views/livewire/admin/themes/index.blade.php @@ -0,0 +1,75 @@ +
+ + + {{ __('Themes') }} + + @if ($this->themes->isEmpty()) + + + {{ __('No themes yet') }} + {{ __('Themes control the look and feel of your storefront.') }} + + @else +
+ @foreach ($this->themes as $theme) +
$theme->status !== \App\Enums\ThemeStatus::Published, + 'border-transparent ring-2 ring-blue-500' => $theme->status === \App\Enums\ThemeStatus::Published, + ]) + data-test="theme-card-{{ $theme->id }}" + > + {{-- Thumbnail placeholder --}} +
+ +
+ +
+
+
+ {{ $theme->name }} + v{{ $theme->version }} +
+ +
+ +
+ + {{ __('Customize') }} + + + + + + + @if ($theme->status !== \App\Enums\ThemeStatus::Published) + + {{ __('Publish') }} + + @endif + + {{ __('Duplicate') }} + + @if ($theme->status !== \App\Enums\ThemeStatus::Published) + + + {{ __('Delete') }} + + @endif + + +
+
+
+ @endforeach +
+ @endif +
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..68d8631a --- /dev/null +++ b/resources/views/livewire/storefront/account/addresses/index.blade.php @@ -0,0 +1,166 @@ +
+
+

{{ __('Your Addresses') }}

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

+ {{ $statusMessage }} +

+ @endif + + @if ($addresses->isEmpty()) +
+ +

{{ __("You haven't saved any addresses yet.") }}

+
+ @else +
    + @foreach ($addresses as $address) + @php + $json = $address->address_json ?? []; + @endphp +
  • $address->is_default, + 'border-zinc-200 dark:border-zinc-800' => ! $address->is_default, + ]) + > +
    + {{ $address->label ?: __('Address') }} + @if ($address->is_default) + + @endif +
    + +
    + {{ trim(($json['first_name'] ?? '').' '.($json['last_name'] ?? '')) }}
    + {{ $json['address1'] ?? '' }}
    + @if (filled($json['address2'] ?? null)) + {{ $json['address2'] }}
    + @endif + {{ trim(($json['zip'] ?? '').' '.($json['city'] ?? '')) }}@if (filled($json['province'] ?? null)), {{ $json['province'] }}@endif
    + {{ \App\Support\Storefront\Countries::name($json['country_code'] ?? '') }} + @if (filled($json['phone'] ?? null)) +
    {{ $json['phone'] }} + @endif +
    + +
    + + + @unless ($address->is_default) + + @endunless +
    +
  • + @endforeach +
+ @endif + + {{-- Add / edit modal --}} + @if ($showForm) + + @endif +
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..fe5d2431 --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/login.blade.php @@ -0,0 +1,38 @@ +
+

{{ __('Log in') }}

+ +
+ @csrf + + + + @error('email') + {{ $message }} + @enderror + + + + + {{ __('Log in') }} + + + +

+ {{ __('No account yet?') }} + {{ __('Register') }} +

+
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..94e9d96a --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/register.blade.php @@ -0,0 +1,52 @@ +
+

{{ __('Create an account') }}

+ +
+ @csrf + + + + + + @error('email') + {{ $message }} + @enderror + + + + + + + + + {{ __('Create account') }} + + +
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..32c99d81 --- /dev/null +++ b/resources/views/livewire/storefront/account/dashboard.blade.php @@ -0,0 +1,150 @@ +@php + $cardClasses = 'group flex flex-col gap-2 rounded-2xl border border-zinc-200 p-6 transition hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 dark:border-zinc-800 dark:hover:border-zinc-700'; +@endphp + +
+

+ {{ __('Welcome back, :name!', ['name' => $customer->name]) }} +

+ + + + {{-- Quick links --}} + + + {{-- Recent orders --}} +
+
+

{{ __('Recent Orders') }}

+ @if ($recentOrders->isNotEmpty()) + + {{ __('View all') }} + + @endif +
+ + @if ($recentOrders->isEmpty()) +

+ {{ __("You haven't placed any orders yet.") }} +

+ @else +
+ + + + + + + + + + + + @foreach ($recentOrders as $order) + + + + + + + + @endforeach + +
{{ __('Order') }}{{ __('Status') }}{{ __('Total') }}{{ __('Action') }}
{{ $order->order_number }} + + {{ __('View') }} + +
+
+ @endif +
+ + {{-- Profile --}} +
+

{{ __('Profile') }}

+ + @if (session('profile-updated')) +

+ {{ session('profile-updated') }} +

+ @endif + +
+
+ + + @error('name') +

{{ $message }}

+ @enderror +
+ +
+

{{ __('Email') }}

+

{{ $customer->email }}

+
+ + + + +
+
+
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..cc7037de --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/index.blade.php @@ -0,0 +1,85 @@ +
+

{{ __('Order History') }}

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

{{ __("You haven't placed any orders yet.") }}

+ + {{ __('Start shopping') }} + +
+ @else + {{-- Desktop table --}} + + + {{-- Mobile cards --}} + + + + @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..e88ad696 --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/show.blade.php @@ -0,0 +1,196 @@ +@php + use App\Enums\PaymentMethod; + + $shippingAddress = $order->shipping_address_json ?? []; + $billingAddress = $order->billing_address_json; + + $paymentLabel = match ($order->payment_method) { + PaymentMethod::CreditCard => __('Credit card'), + PaymentMethod::Paypal => __('PayPal'), + PaymentMethod::BankTransfer => __('Bank transfer'), + default => null, + }; +@endphp + +
+ + + {{-- Header --}} +
+
+

+ {{ __('Order :number', ['number' => $order->order_number]) }} +

+

+ {{ __('Placed on :date', ['date' => $order->placed_at?->format('F j, Y')]) }} +

+
+
+ + +
+
+ + {{-- Timeline --}} + @if ($timeline !== []) +
+

{{ __('Order timeline') }}

+
    + @foreach ($timeline as $event) +
  1. + @unless ($loop->last) + + @endunless + +
    +

    {{ $event['label'] }}

    + @if (filled($event['description'])) +

    {{ $event['description'] }}

    + @endif +

    + +

    +
    +
  2. + @endforeach +
+
+ @endif + + {{-- Items --}} +
+

{{ __('Items') }}

+
    + @foreach ($lines as $line) +
  • +
    + @if (filled($line['image_url'])) + + @endif +
    +
    +

    {{ $line['title'] }}

    + @if ($line['variant_label'] !== '') +

    {{ $line['variant_label'] }}

    + @endif +

    + {{ __('Qty :quantity', ['quantity' => $line['quantity']]) }} + × + {{ \App\Support\Storefront\PriceFormatter::format($line['unit_price_amount'], $order->currency) }} +

    +
    + +
  • + @endforeach +
+
+ + {{-- Addresses and payment --}} +
+
+

{{ __('Shipping Address') }}

+ @if ($shippingAddress === []) +

{{ __('No shipping required') }}

+ @else +
+ {{ trim(($shippingAddress['first_name'] ?? '').' '.($shippingAddress['last_name'] ?? '')) }}
+ {{ $shippingAddress['address1'] ?? '' }}
+ @if (filled($shippingAddress['address2'] ?? null)) + {{ $shippingAddress['address2'] }}
+ @endif + {{ trim((($shippingAddress['postal_code'] ?? $shippingAddress['zip'] ?? '')).' '.($shippingAddress['city'] ?? '')) }}
+ {{ \App\Support\Storefront\Countries::name($shippingAddress['country_code'] ?? '') }} +
+ @endif +
+
+

{{ __('Billing Address') }}

+ @if (blank($billingAddress)) +

{{ __('Same as shipping') }}

+ @else +
+ {{ trim(($billingAddress['first_name'] ?? '').' '.($billingAddress['last_name'] ?? '')) }}
+ {{ $billingAddress['address1'] ?? '' }}
+ {{ trim((($billingAddress['postal_code'] ?? $billingAddress['zip'] ?? '')).' '.($billingAddress['city'] ?? '')) }}
+ {{ \App\Support\Storefront\Countries::name($billingAddress['country_code'] ?? '') }} +
+ @endif +
+
+

{{ __('Payment') }}

+

{{ $paymentLabel }}

+

+
+
+ + {{-- Totals --}} +
+

{{ __('Order totals') }}

+
+
+
{{ __('Subtotal') }}
+
+
+ @if ($order->discount_amount > 0) +
+
{{ __('Discount') }}
+
-{{ \App\Support\Storefront\PriceFormatter::format($order->discount_amount, $order->currency) }}
+
+ @endif +
+
{{ __('Shipping') }}
+
+
+
+
{{ __('Tax') }}
+
+
+
+
{{ __('Total') }}
+
+
+
+
+ + {{-- Fulfillment tracking --}} + @if ($order->fulfillments->isNotEmpty()) +
+

{{ __('Fulfillment') }}

+
    + @foreach ($order->fulfillments as $fulfillment) +
  • +
    + + + @if (filled($fulfillment->tracking_number)) + {{ __('Shipped via :company - :number', [ + 'company' => $fulfillment->tracking_company ?: __('carrier'), + 'number' => $fulfillment->tracking_number, + ]) }} + @else + {{ __('Fulfillment created :date', ['date' => $fulfillment->created_at?->format('M j, Y')]) }} + @endif + +
    + @if (filled($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..e64dde1e --- /dev/null +++ b/resources/views/livewire/storefront/cart-drawer.blade.php @@ -0,0 +1,201 @@ +
+ +
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..f3c57f89 --- /dev/null +++ b/resources/views/livewire/storefront/cart/show.blade.php @@ -0,0 +1,236 @@ +
+

{{ __('Your Cart') }}

+ + @if ($lines === []) + {{-- Empty state --}} +
+ +

{{ __('Your cart is empty') }}

+ + {{ __('Continue shopping') }} + +
+ @else + @if ($cartError !== null) + + @endif + +
+ {{-- Desktop table / mobile cards --}} +
+ + + + + + + + + + + + @foreach ($lines as $line) + + + + + + + + + @endforeach + + + + {{-- Mobile cards --}} +
    + @foreach ($lines as $line) +
  • +
    + @if ($line['image_url'] !== null) + + @endif +
    +
    +

    {{ $line['title'] }}

    + @if ($line['variant_label'] !== '') +

    {{ $line['variant_label'] }}

    + @endif +
    +
    + + {{ $line['quantity'] }} + +
    + +
    +
    + +
    +
    +
  • + @endforeach +
+
+ + {{-- Summary --}} +
+
+ {{-- Discount code --}} + @if ($discount !== null) +
+ + {{ $discount['code'] }} + @if ($discount['free_shipping']) + ({{ __('Free shipping') }}) + @endif + + +
+ @else +
+ + + +
+ @if ($discountError !== null) + + @endif + @endif + +
+
+
{{ __('Subtotal') }}
+
+
+ @if ($discount !== null && $discount['amount'] > 0) +
+
{{ __('Discount') }} ({{ $discount['code'] }})
+
-{{ \App\Support\Storefront\PriceFormatter::format($discount['amount'], $currency) }}
+
+ @endif + @if ($shippingEstimate !== null) +
+
{{ __('Shipping estimate') }} ({{ $shippingEstimate['name'] }})
+
+
+ @endif +
+
{{ __('Estimated total') }}
+
+
+
+ + @if ($requiresShipping) +
+ + + @if ($estimateCountry !== '' && $shippingEstimate === null) +

{{ __('No shipping methods are available for this country.') }}

+ @endif +
+ @endif + +

{{ __('Shipping and taxes calculated at checkout') }}

+ + + {{ __('Checkout') }} + + + {{ __('Continue shopping') }} + +
+
+
+ @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..37ac312a --- /dev/null +++ b/resources/views/livewire/storefront/checkout/confirmation.blade.php @@ -0,0 +1,139 @@ +@php + use App\Enums\FinancialStatus; + use App\Enums\PaymentMethod; + use App\Support\Storefront\PriceFormatter; + + $shippingAddress = $order->shipping_address_json ?? []; + $isBankTransfer = $order->payment_method === PaymentMethod::BankTransfer; +@endphp + +
+ {{-- Success header --}} +
+ + + +

{{ __('Thank you for your order!') }}

+

{{ __('Order :number', ['number' => $order->order_number]) }}

+ @if (filled($order->email)) +

{{ __("We've sent a confirmation to :email", ['email' => $order->email]) }}

+ @endif +
+ + {{-- Items --}} +
+

{{ __('Order summary') }}

+
    + @foreach ($items as $item) +
  • + @if ($item['image_url'] !== null) + + @else + + + + @endif + + {{ $item['title'] }} + {{ __('Qty') }}: {{ $item['quantity'] }} + + +
  • + @endforeach +
+
+ + {{-- Address and payment method --}} +
+
+

{{ __('Shipping address') }}

+ @if ($shippingAddress !== []) +

+ {{ $shippingAddress['first_name'] ?? '' }} {{ $shippingAddress['last_name'] ?? '' }}
+ {{ $shippingAddress['address1'] ?? '' }}@if (filled($shippingAddress['address2'] ?? ''))
{{ $shippingAddress['address2'] }}@endif
+ {{ $shippingAddress['postal_code'] ?? '' }} {{ $shippingAddress['city'] ?? '' }}, {{ $shippingAddress['country_code'] ?? '' }} +

+ @else +

{{ __('No shipping required (digital order)') }}

+ @endif +
+
+

{{ __('Payment method') }}

+

{{ $order->payment_method->label() }}

+
+
+ + {{-- Bank transfer instructions --}} + @if ($isBankTransfer && $order->financial_status === FinancialStatus::Pending) +
+

+ + {{ __('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') }}:
{{ PriceFormatter::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') }}
+
+
+ @if ($order->discount_amount > 0) +
+
{{ __('Discount') }}
+
-{{ PriceFormatter::format($order->discount_amount, $order->currency) }}
+
+ @endif +
+
{{ __('Shipping') }}
+
+
+
+
{{ __('Tax') }}
+
+
+
+
{{ __('Total') }}
+
+
+
+
+ + {{-- Actions --}} + +
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..162f08aa --- /dev/null +++ b/resources/views/livewire/storefront/checkout/show.blade.php @@ -0,0 +1,385 @@ +@php + use App\Enums\CheckoutStatus; + + $summaryLines = array_map(fn (array $line): array => [ + 'title' => $line['title'], + 'variant' => $line['variant_label'] !== '' ? $line['variant_label'] : null, + 'quantity' => $line['quantity'], + 'image_url' => $line['image_url'], + 'line_total_amount' => $line['line_total_amount'], + ], $lines); + + $shippingKnown = in_array($checkout->status, [CheckoutStatus::ShippingSelected, CheckoutStatus::PaymentSelected], true); + $addressed = $checkout->status !== CheckoutStatus::Started; + + $stepHeaderClasses = 'flex items-center justify-between gap-4'; + $stepTitleClasses = 'text-base font-semibold text-zinc-900 dark:text-white'; + $futureTitleClasses = 'text-base font-semibold text-zinc-400 dark:text-zinc-600'; + $cardClasses = 'rounded-2xl border border-zinc-200 p-6 dark:border-zinc-800'; + $editLinkClasses = 'text-sm font-medium text-blue-600 transition hover:text-blue-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 dark:text-blue-400'; + $primaryButtonClasses = 'rounded-lg px-5 py-2.5 text-sm font-semibold text-white transition hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-600'; +@endphp + +
+

{{ __('Checkout') }}

+ +
+ {{-- Form steps --}} +
+ {{-- Step 1: Contact --}} +
+
+

1. {{ __('Contact information') }}

+ @if ($step > 1 && $step < 5) + + @endif +
+ + @if ($step === 1) +
+
+ + + @error('email') +

{{ $message }}

+ @enderror + @guest('customer') +

+ {{ __('Already have an account?') }} + {{ __('Log in') }} +

+ @endguest +
+ +
+ @elseif ($email !== '') +

{{ $email }}

+ @endif +
+ + {{-- Step 2: Shipping address --}} +
+
+

2. {{ __('Shipping address') }}

+ @if ($step > 2 && $step < 5) + + @endif +
+ + @if ($step === 2) +
+ @if ($savedAddresses !== []) +
+ + +
+ @endif + + + + @elseif ($step > 2 && $addressed) +

+ {{ $shipping['first_name'] }} {{ $shipping['last_name'] }}, + {{ $shipping['address1'] }}@if (filled($shipping['address2'] ?? '')), {{ $shipping['address2'] }}@endif, + {{ $shipping['postal_code'] }} {{ $shipping['city'] }}, {{ $shipping['country_code'] }} +

+ @endif +
+ + {{-- Step 3: Shipping method --}} +
+
+

3. {{ __('Shipping method') }}

+ @if ($step > 3 && $step < 5 && $requiresShipping) + + @endif +
+ + @if ($step === 3) +
+ @if ($availableRates === []) +

+ + {{ __('No shipping methods are available for your address. Please verify your address or contact us.') }} +

+ @else +
+ {{ __('Shipping method') }} +
+ @foreach ($availableRates as $rate) + + @endforeach +
+
+ @if ($shippingError !== null) + + @endif + + @endif +
+ @elseif ($step > 3) + @php + $selectedRate = collect($availableRates)->firstWhere('id', $selectedRateId); + @endphp +

+ @if (! $requiresShipping) + {{ __('No shipping required (digital order)') }} + @elseif ($selectedRate !== null) + {{ $selectedRate['name'] }} + @endif +

+ @endif +
+ + {{-- Step 4: Payment method --}} +
+
+

4. {{ __('Payment') }}

+
+ + @if ($step >= 4) + @php + $formattedTotal = \App\Support\Storefront\PriceFormatter::format($totals['total'] ?? 0, $currency); + $inputClasses = 'block w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-600 focus:ring-2 focus:ring-blue-600/30 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-white'; + $labelClasses = 'mb-1.5 block text-sm font-medium text-zinc-700 dark:text-zinc-300'; + @endphp +
+
+ {{ __('Select a payment method') }} +
+ @foreach (['credit_card' => __('Credit Card'), 'paypal' => __('PayPal'), 'bank_transfer' => __('Bank Transfer')] as $method => $label) + + @endforeach +
+
+ + @if ($paymentMethod === 'credit_card') +
+
+ + + @error('cardNumber') +

{{ $message }}

+ @enderror +
+
+ + + @error('cardName') +

{{ $message }}

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

{{ $message }}

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

{{ $message }}

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

+ {{ __('Your PayPal payment will be processed securely.') }} +

+ @else +

+ {{ __('After placing your order, you will receive bank transfer instructions. Your order will be held for 7 days while we await your payment.') }} +

+ @endif + + @if ($paymentError !== null) + + @endif + + +
+ @endif +
+
+ + {{-- Order summary --}} +
+ + + {{-- Discount code --}} +
+ @if (filled($checkout->discount_code)) +
+ {{ $checkout->discount_code }} + +
+ @else +
+ + + +
+ @if ($discountError !== null) + + @endif + @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..3e38522e --- /dev/null +++ b/resources/views/livewire/storefront/collections/index.blade.php @@ -0,0 +1,26 @@ +
+ + +

+ {{ __('Collections') }} +

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

{{ __('No collections yet') }}

+

{{ __('Check back soon for curated collections.') }}

+
+ @else +
+ @foreach ($collections as $collection) + @include('storefront.partials.collection-card', ['collection' => $collection]) + @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..e541b193 --- /dev/null +++ b/resources/views/livewire/storefront/collections/show.blade.php @@ -0,0 +1,188 @@ +
+ + + {{-- Collection header --}} +
+

+ {{ $collection->title }} +

+ @if (filled($collection->description_html)) +
+ {!! $collection->description_html !!} +
+ @endif +
+ + {{-- Toolbar --}} +
+ + + + +
+ + +
+
+ + {{-- Active filter pills --}} + @if ($hasActiveFilters) +
+ @if ($inStock) + + {{ __('In stock') }} + + + @endif + @if ($priceMin !== '' || $priceMax !== '') + + {{ __('Price') }}: {{ $priceMin !== '' ? $priceMin : '0' }} – {{ $priceMax !== '' ? $priceMax : '∞' }} + + + @endif + @foreach ($productTypes as $activeType) + + {{ $activeType }} + + + @endforeach + @foreach ($vendors as $activeVendor) + + {{ $activeVendor }} + + + @endforeach + +
+ @endif + +
+ {{-- Desktop filter sidebar --}} + + + {{-- Product grid --}} +
+ @if ($products->isEmpty()) +
+ +

{{ __('No products found') }}

+

+ {{ __('Try adjusting your filters or browse our full collection.') }} +

+ @if ($hasActiveFilters) + + @endif +
+ @else +
+ @foreach ($products as $product) + + @endforeach +
+ +
+ +
+ @endif +
+
+ + {{-- Mobile filter drawer --}} + +
diff --git a/resources/views/livewire/storefront/home.blade.php b/resources/views/livewire/storefront/home.blade.php new file mode 100644 index 00000000..d89cb280 --- /dev/null +++ b/resources/views/livewire/storefront/home.blade.php @@ -0,0 +1,26 @@ +
+ @foreach ($sections as $section) + @switch($section) + @case('hero') + @include('storefront.sections.hero') + + @break + @case('featured-collections') + @include('storefront.sections.featured-collections') + + @break + @case('featured-products') + @include('storefront.sections.featured-products') + + @break + @case('newsletter') + @include('storefront.sections.newsletter') + + @break + @case('rich-text') + @include('storefront.sections.rich-text') + + @break + @endswitch + @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..161b68c3 --- /dev/null +++ b/resources/views/livewire/storefront/pages/show.blade.php @@ -0,0 +1,15 @@ +
+ + +
+

+ {{ $page->title }} +

+
+ {!! $page->body_html !!} +
+
+
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..9c82b18a --- /dev/null +++ b/resources/views/livewire/storefront/products/show.blade.php @@ -0,0 +1,253 @@ +@php + use App\Enums\InventoryPolicy; + use App\Enums\MediaStatus; + use Illuminate\Support\Facades\Storage; + + $galleryMedia = $product->media + ->filter(fn ($media): bool => $media->status === MediaStatus::Ready || $media->status === MediaStatus::Processing) + ->values(); + $activeMedia = $galleryMedia->get($activeImageIndex) ?? $galleryMedia->first(); + + $priceAmount = $selectedVariant?->price_amount ?? 0; + $compareAtAmount = $selectedVariant?->compare_at_amount; + $currency = $selectedVariant?->currency ?? ($currentStore->default_currency ?? 'EUR'); + + $inventory = $selectedVariant?->inventoryItem; + $availableQuantity = $inventory?->availableQuantity(); + + $swatchColors = [ + 'black' => '#18181b', 'white' => '#fafafa', 'gray' => '#9ca3af', 'grey' => '#9ca3af', + 'red' => '#dc2626', 'blue' => '#2563eb', 'navy' => '#1e3a5f', 'green' => '#16a34a', + 'yellow' => '#eab308', 'orange' => '#ea580c', 'purple' => '#9333ea', 'pink' => '#ec4899', + 'brown' => '#92400e', 'beige' => '#d6c7a1', 'silver' => '#c0c0c0', 'gold' => '#d4af37', + ]; +@endphp + +
+ + +
+ {{-- Image gallery --}} +
+
+ @if ($activeMedia !== null) + {{ $activeMedia->alt_text ?? $product->title }} + @else +
+ +
+ @endif +
+ + @if ($galleryMedia->count() > 1) +
+ @foreach ($galleryMedia as $index => $media) + + @endforeach +
+ @endif +
+ + {{-- Product info --}} +
+

+ {{ $product->title }} +

+ + @if ($settings['show_vendor'] ?? true) + @if (filled($product->vendor)) +

{{ $product->vendor }}

+ @endif + @endif + + {{-- Price (live region: announced on variant change) --}} +
+ + @if ($compareAtAmount !== null && $compareAtAmount > $priceAmount) + + @endif +
+ + {{-- Variant selector --}} + @if ($product->options->isNotEmpty()) +
+ @foreach ($product->options as $option) +
+ {{ $option->name }} + @if (strtolower($option->name) === 'color' && $option->values->count() <= 6) +
+ @foreach ($option->values as $value) + + @endforeach +
+ @elseif ($option->values->count() <= 6) +
+ @foreach ($option->values as $value) + + @endforeach +
+ @else + + @endif +
+ @endforeach +
+ @endif + + {{-- Stock messaging (live region) --}} +
+ @if ($selectedVariant === null) +

+ + {{ __('This combination is unavailable') }} +

+ @elseif ($inventory === null || ($availableQuantity > 10)) +

+ + {{ __('In stock') }} +

+ @elseif ($availableQuantity > 0) +

+ + {{ __('Only :count left in stock', ['count' => $availableQuantity]) }} +

+ @elseif ($inventory->policy === InventoryPolicy::Continue) +

+ + {{ __('Available on backorder') }} +

+ @else +

+ + {{ __('Out of stock') }} +

+ @endif +
+ + {{-- Quantity + add to cart --}} +
+ @if ($settings['show_quantity_selector'] ?? true) +
+ {{ __('Quantity') }} + +
+ @endif + + @if ($isPurchasable) + + @else + + @endif + +
+ @if ($addedToCart) +

+ + {{ __('Added to cart') }} +

+ @endif +
+
+ + {{-- Description --}} + @if (filled($product->description_html)) +
+
+ {!! $product->description_html !!} +
+ @endif + + {{-- Tags --}} + @if (filled($product->tags)) +
+ @foreach ($product->tags as $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..f14d5691 --- /dev/null +++ b/resources/views/livewire/storefront/search/index.blade.php @@ -0,0 +1,157 @@ +
+ + + {{-- Search header --}} +
+

+ @if (trim($query) !== '') + {{ trans_choice(':count result for ":query"|:count results for ":query"', $products->total(), ['count' => $products->total(), 'query' => $query]) }} + @else + {{ __('Search') }} + @endif +

+
+ + {{-- Search input --}} +
+ +
+ + {{-- Toolbar --}} +
+ + + + +
+ + +
+
+ +
+ {{-- Desktop filter sidebar --}} + + + {{-- Product grid --}} +
+ @if ($products->isEmpty()) +
+ + @if (trim($query) !== '') +

+ {{ __('No results found for ":query".', ['query' => $query]) }} +

+

+ {{ __('Try a different search term.') }} +

+ @else +

{{ __('Start searching') }}

+

+ {{ __('Enter a search term above to find products.') }} +

+ @endif +
+ @else +
+ @foreach ($products as $product) + + @endforeach +
+ +
+ +
+ @endif +
+
+ + {{-- Mobile filter drawer --}} + +
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..a4308907 --- /dev/null +++ b/resources/views/livewire/storefront/search/modal.blade.php @@ -0,0 +1,173 @@ +
+ +
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/errors/404.blade.php b/resources/views/storefront/errors/404.blade.php new file mode 100644 index 00000000..47dfb7b7 --- /dev/null +++ b/resources/views/storefront/errors/404.blade.php @@ -0,0 +1,44 @@ + +
+ +
+

{{ __('Page not found') }}

+

+ {{ __("The page you're looking for doesn't exist or has been moved.") }} +

+
+ + +
+ +
+
+
diff --git a/resources/views/storefront/errors/503.blade.php b/resources/views/storefront/errors/503.blade.php new file mode 100644 index 00000000..2a8ca3a2 --- /dev/null +++ b/resources/views/storefront/errors/503.blade.php @@ -0,0 +1,15 @@ + + + + @include('partials.head', ['title' => __("We'll be back soon")]) + + + +

{{ __("We'll be back soon") }}

+

+ {{ __("We're currently performing maintenance. Please check back shortly.") }} +

+ + diff --git a/resources/views/storefront/partials/announcement-bar.blade.php b/resources/views/storefront/partials/announcement-bar.blade.php new file mode 100644 index 00000000..322a3f6b --- /dev/null +++ b/resources/views/storefront/partials/announcement-bar.blade.php @@ -0,0 +1,36 @@ +@if ($themeSettings['show_announcement_bar'] && filled($themeSettings['announcement_text'])) +
+
+

+ @if (filled($themeSettings['announcement_link'])) + + {{ $themeSettings['announcement_text'] }} + + @else + {{ $themeSettings['announcement_text'] }} + @endif +

+ +
+
+@endif diff --git a/resources/views/storefront/partials/collection-card.blade.php b/resources/views/storefront/partials/collection-card.blade.php new file mode 100644 index 00000000..1fcf496e --- /dev/null +++ b/resources/views/storefront/partials/collection-card.blade.php @@ -0,0 +1,37 @@ +@php + use App\Enums\MediaStatus; + use Illuminate\Support\Facades\Storage; + + /** @var \App\Models\Collection $collection */ + $cardMedia = $collection->products->first()?->media->firstWhere('status', MediaStatus::Ready) + ?? $collection->products->first()?->media->first(); + $cardImageUrl = $cardMedia !== null ? Storage::disk('public')->url($cardMedia->storage_key) : null; +@endphp + + +
+ @if ($cardImageUrl !== null) + {{ $collection->title }} + @else + + @endif +
+
+

{{ $collection->title }}

+ + {{ __('Shop now') }} → + +
+
diff --git a/resources/views/storefront/partials/collection-filters.blade.php b/resources/views/storefront/partials/collection-filters.blade.php new file mode 100644 index 00000000..54ce1145 --- /dev/null +++ b/resources/views/storefront/partials/collection-filters.blade.php @@ -0,0 +1,145 @@ +{{-- Filter groups shared by the desktop sidebar and the mobile filter drawer. --}} +
+ @if ($hasActiveFilters) + + @endif + + {{-- Availability --}} +
+ + + +
+ +
+
+ + {{-- Price range --}} +
+ + + +
+ + + +
+
+ + {{-- Product type --}} + @if ($availableProductTypes !== []) +
+ + + +
+ @foreach ($availableProductTypes as $productType) + + @endforeach +
+
+ @endif + + {{-- Vendor --}} + @if ($availableVendors !== []) +
+ + + +
+ @foreach ($availableVendors as $vendor) + + @endforeach +
+
+ @endif +
diff --git a/resources/views/storefront/partials/footer.blade.php b/resources/views/storefront/partials/footer.blade.php new file mode 100644 index 00000000..13c0e97e --- /dev/null +++ b/resources/views/storefront/partials/footer.blade.php @@ -0,0 +1,100 @@ +@php + $socialIcons = [ + 'facebook' => 'M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12Z', + 'instagram' => 'M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069Zm0 1.802c-3.15 0-3.504.011-4.747.068-2.412.11-3.524 1.24-3.635 3.636-.056 1.243-.067 1.596-.067 4.747s.011 3.504.067 4.748c.111 2.39 1.219 3.525 3.635 3.635 1.243.056 1.597.068 4.747.068 3.151 0 3.505-.012 4.748-.068 2.412-.11 3.524-1.24 3.635-3.635.056-1.244.067-1.597.067-4.748s-.011-3.504-.067-4.747c-.111-2.392-1.219-3.526-3.635-3.636-1.243-.057-1.597-.068-4.748-.068ZM12 7.054a4.946 4.946 0 1 1 0 9.892 4.946 4.946 0 0 1 0-9.892Zm0 1.802a3.144 3.144 0 1 0 0 6.288 3.144 3.144 0 0 0 0-6.288Zm5.106-3.034a1.156 1.156 0 1 1 0 2.312 1.156 1.156 0 0 1 0-2.312Z', + 'twitter' => 'M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231 5.45-6.231Zm-1.161 17.52h1.833L7.084 4.126H5.117l11.966 15.644Z', + 'tiktok' => 'M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1Z', + 'youtube' => 'M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814ZM9.545 15.568V8.432L15.818 12l-6.273 3.568Z', + ]; +@endphp +
+
+
+ {{-- Footer menu links --}} +
+

+ {{ __('Shop') }} +

+ +
+ +
+

+ {{ __('Information') }} +

+ +
+ + {{-- Store info --}} +
+

+ {{ $storeName }} +

+ @if (filled($themeSettings['footer_text'])) +

{{ $themeSettings['footer_text'] }}

+ @endif + @if (filled($themeSettings['social_links'])) + + @endif +
+
+ +
+

+ © {{ now()->year }} {{ $storeName }}. {{ __('All rights reserved.') }} +

+
    + @foreach (['Visa', 'Mastercard', 'Amex', 'PayPal'] as $paymentMethod) +
  • + {{ $paymentMethod }} +
  • + @endforeach +
+
+
+
diff --git a/resources/views/storefront/partials/header.blade.php b/resources/views/storefront/partials/header.blade.php new file mode 100644 index 00000000..a2dd1fcd --- /dev/null +++ b/resources/views/storefront/partials/header.blade.php @@ -0,0 +1,161 @@ +
+
+ {{-- Mobile: hamburger --}} + + + {{-- Logo / store name --}} + + @if (filled($themeSettings['logo_url'])) + {{ $storeName }} + @else + {{ $storeName }} + @endif + + + {{-- Desktop navigation --}} + + + {{-- Action icons --}} +
+ {{-- Search: opens the search modal --}} + + + {{-- Account --}} + + + {{-- Cart: opens the cart drawer; the badge updates from "cart-updated" browser events --}} + +
+
+ + {{-- Mobile navigation drawer --}} + +
diff --git a/resources/views/storefront/sections/featured-collections.blade.php b/resources/views/storefront/sections/featured-collections.blade.php new file mode 100644 index 00000000..bbaba321 --- /dev/null +++ b/resources/views/storefront/sections/featured-collections.blade.php @@ -0,0 +1,12 @@ +@if ($featuredCollections->isNotEmpty()) +
+ +
+ @foreach ($featuredCollections as $featuredCollection) + @include('storefront.partials.collection-card', ['collection' => $featuredCollection]) + @endforeach +
+
+@endif diff --git a/resources/views/storefront/sections/featured-products.blade.php b/resources/views/storefront/sections/featured-products.blade.php new file mode 100644 index 00000000..af85b79a --- /dev/null +++ b/resources/views/storefront/sections/featured-products.blade.php @@ -0,0 +1,12 @@ +@if ($featuredProducts->isNotEmpty()) +
+ +
+ @foreach ($featuredProducts as $featuredProduct) + + @endforeach +
+
+@endif diff --git a/resources/views/storefront/sections/hero.blade.php b/resources/views/storefront/sections/hero.blade.php new file mode 100644 index 00000000..172208b4 --- /dev/null +++ b/resources/views/storefront/sections/hero.blade.php @@ -0,0 +1,28 @@ +
+ +
+

+ {{ $settings['hero_heading'] }} +

+ @if (filled($settings['hero_subheading'])) +

+ {{ $settings['hero_subheading'] }} +

+ @endif + @if (filled($settings['hero_cta_text'])) + + {{ $settings['hero_cta_text'] }} + + @endif +
+
diff --git a/resources/views/storefront/sections/newsletter.blade.php b/resources/views/storefront/sections/newsletter.blade.php new file mode 100644 index 00000000..cd5dab42 --- /dev/null +++ b/resources/views/storefront/sections/newsletter.blade.php @@ -0,0 +1,34 @@ +@if ($settings['show_newsletter']) +
+
+

+ {{ __('Stay in the loop') }} +

+

+ {{ __('Subscribe for exclusive offers and updates.') }} +

+ {{-- Newsletter subscriptions are stored from Phase 8 onward; this form confirms client-side for now. --}} +
+
+ + + +
+

+ {{ __('Thanks for subscribing!') }} +

+
+
+
+@endif diff --git a/resources/views/storefront/sections/rich-text.blade.php b/resources/views/storefront/sections/rich-text.blade.php new file mode 100644 index 00000000..5d4fe7f4 --- /dev/null +++ b/resources/views/storefront/sections/rich-text.blade.php @@ -0,0 +1,7 @@ +@if (filled($settings['rich_text_html'])) +
+
+ {!! $settings['rich_text_html'] !!} +
+
+@endif 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/api.php b/routes/api.php new file mode 100644 index 00000000..2f8927db --- /dev/null +++ b/routes/api.php @@ -0,0 +1,117 @@ +name('api.storefront.') + ->middleware('store.resolve:storefront') + ->group(function (): void { + Route::middleware('throttle:api.storefront')->group(function (): void { + Route::post('/carts', [CartController::class, 'store'])->name('carts.store'); + Route::get('/carts/{cartId}', [CartController::class, 'show']) + ->whereNumber('cartId') + ->name('carts.show'); + Route::post('/carts/{cartId}/lines', [CartController::class, 'storeLine']) + ->whereNumber('cartId') + ->name('carts.lines.store'); + Route::put('/carts/{cartId}/lines/{lineId}', [CartController::class, 'updateLine']) + ->whereNumber('cartId') + ->whereNumber('lineId') + ->name('carts.lines.update'); + Route::delete('/carts/{cartId}/lines/{lineId}', [CartController::class, 'destroyLine']) + ->whereNumber('cartId') + ->whereNumber('lineId') + ->name('carts.lines.destroy'); + }); + + Route::middleware('throttle:search')->group(function (): void { + Route::get('/search', [SearchController::class, 'index'])->name('search'); + Route::get('/search/suggest', [SearchController::class, 'suggest'])->name('search.suggest'); + }); + + Route::post('/analytics/events', [AnalyticsEventController::class, 'store']) + ->middleware('throttle:analytics') + ->name('analytics.events'); + + Route::middleware('throttle:checkout')->whereNumber('checkoutId')->group(function (): void { + Route::post('/checkouts', [CheckoutController::class, 'store'])->name('checkouts.store'); + Route::get('/checkouts/{checkoutId}', [CheckoutController::class, 'show'])->name('checkouts.show'); + Route::put('/checkouts/{checkoutId}/address', [CheckoutController::class, 'updateAddress'])->name('checkouts.address'); + Route::put('/checkouts/{checkoutId}/shipping-method', [CheckoutController::class, 'updateShippingMethod'])->name('checkouts.shipping-method'); + Route::post('/checkouts/{checkoutId}/apply-discount', [CheckoutController::class, 'applyDiscount'])->name('checkouts.apply-discount'); + Route::delete('/checkouts/{checkoutId}/discount', [CheckoutController::class, 'removeDiscount'])->name('checkouts.remove-discount'); + Route::put('/checkouts/{checkoutId}/payment-method', [CheckoutController::class, 'updatePaymentMethod'])->name('checkouts.payment-method'); + Route::post('/checkouts/{checkoutId}/pay', [CheckoutController::class, 'pay'])->name('checkouts.pay'); + }); + }); + +/* +|-------------------------------------------------------------------------- +| Admin REST API (spec 02 section 3) +|-------------------------------------------------------------------------- +| +| Sanctum token authentication; the store is bound from the {storeId} route +| parameter after verifying membership. Token abilities are enforced +| per endpoint (spec 06 section 1.3). +| +*/ + +Route::prefix('admin/v1/stores/{storeId}') + ->name('api.admin.') + ->whereNumber('storeId') + ->middleware(['auth:sanctum', 'store.resolve:api-admin', 'throttle:api.admin']) + ->group(function (): void { + Route::get('/products', [AdminProductController::class, 'index']) + ->middleware('abilities:read-products') + ->name('products.index'); + Route::post('/products', [AdminProductController::class, 'store']) + ->middleware('abilities:write-products') + ->name('products.store'); + Route::get('/products/{productId}', [AdminProductController::class, 'show']) + ->whereNumber('productId') + ->middleware('abilities:read-products') + ->name('products.show'); + Route::put('/products/{productId}', [AdminProductController::class, 'update']) + ->whereNumber('productId') + ->middleware('abilities:write-products') + ->name('products.update'); + Route::delete('/products/{productId}', [AdminProductController::class, 'destroy']) + ->whereNumber('productId') + ->middleware('abilities:write-products') + ->name('products.destroy'); + + Route::get('/orders', [AdminOrderController::class, 'index']) + ->middleware('abilities:read-orders') + ->name('orders.index'); + Route::get('/orders/{orderId}', [AdminOrderController::class, 'show']) + ->whereNumber('orderId') + ->middleware('abilities:read-orders') + ->name('orders.show'); + Route::post('/orders/{orderId}/fulfillments', [AdminOrderFulfillmentController::class, 'store']) + ->whereNumber('orderId') + ->middleware('abilities:write-orders') + ->name('orders.fulfillments.store'); + Route::post('/orders/{orderId}/refunds', [AdminOrderRefundController::class, 'store']) + ->whereNumber('orderId') + ->middleware('abilities:write-orders') + ->name('orders.refunds.store'); + }); diff --git a/routes/console.php b/routes/console.php index 3c9adf1a..d93e7d7e 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,18 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::job(new ExpireAbandonedCheckouts)->everyFifteenMinutes(); +Schedule::job(new CleanupAbandonedCarts)->daily(); +Schedule::job(new CancelUnpaidBankTransferOrders)->daily(); +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..2e135e75 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,13 +1,174 @@ name('home'); +/* +|-------------------------------------------------------------------------- +| Admin Auth Routes (no store resolution) +|-------------------------------------------------------------------------- +*/ -Route::view('dashboard', 'dashboard') - ->middleware(['auth', 'verified']) - ->name('dashboard'); +Route::livewire('/admin/login', AdminLogin::class) + ->middleware('guest') + ->name('admin.login'); -require __DIR__.'/settings.php'; +Route::post('/admin/login', [LoginController::class, 'store']) + ->middleware(['guest', 'throttle:login']) + ->name('admin.login.attempt'); + +Route::post('/admin/logout', [LoginController::class, 'destroy']) + ->middleware('auth') + ->name('admin.logout'); + +/* +|-------------------------------------------------------------------------- +| Admin Routes (session-based store resolution) +|-------------------------------------------------------------------------- +*/ + +Route::middleware(['auth', 'admin'])->group(function (): void { + Route::livewire('/admin', AdminDashboard::class)->name('admin.dashboard'); + + Route::livewire('/admin/products', AdminProductsIndex::class)->name('admin.products.index'); + Route::livewire('/admin/products/create', AdminProductsForm::class)->name('admin.products.create'); + Route::livewire('/admin/products/{productId}/edit', AdminProductsForm::class) + ->whereNumber('productId') + ->name('admin.products.edit'); + + Route::livewire('/admin/orders', AdminOrdersIndex::class)->name('admin.orders.index'); + Route::livewire('/admin/orders/{order}', AdminOrdersShow::class) + ->whereNumber('order') + ->name('admin.orders.show'); + + Route::livewire('/admin/customers', AdminCustomersIndex::class)->name('admin.customers.index'); + Route::livewire('/admin/customers/{customer}', AdminCustomersShow::class) + ->whereNumber('customer') + ->name('admin.customers.show'); + + Route::livewire('/admin/collections', AdminCollectionsIndex::class)->name('admin.collections.index'); + Route::livewire('/admin/collections/create', AdminCollectionsForm::class)->name('admin.collections.create'); + Route::livewire('/admin/collections/{collectionId}/edit', AdminCollectionsForm::class) + ->whereNumber('collectionId') + ->name('admin.collections.edit'); + + Route::livewire('/admin/inventory', AdminInventoryIndex::class)->name('admin.inventory.index'); + + Route::livewire('/admin/discounts', AdminDiscountsIndex::class)->name('admin.discounts.index'); + Route::livewire('/admin/discounts/create', AdminDiscountsForm::class)->name('admin.discounts.create'); + Route::livewire('/admin/discounts/{discountId}/edit', AdminDiscountsForm::class) + ->whereNumber('discountId') + ->name('admin.discounts.edit'); + + Route::livewire('/admin/settings', AdminSettingsIndex::class)->name('admin.settings.index'); + Route::livewire('/admin/settings/shipping', AdminSettingsShipping::class)->name('admin.settings.shipping'); + Route::livewire('/admin/settings/taxes', AdminSettingsTaxes::class)->name('admin.settings.taxes'); + + Route::livewire('/admin/themes', AdminThemesIndex::class)->name('admin.themes.index'); + Route::livewire('/admin/themes/{themeId}/editor', AdminThemesEditor::class) + ->whereNumber('themeId') + ->name('admin.themes.editor'); + + Route::livewire('/admin/pages', AdminPagesIndex::class)->name('admin.pages.index'); + Route::livewire('/admin/pages/create', AdminPagesForm::class)->name('admin.pages.create'); + Route::livewire('/admin/pages/{pageId}/edit', AdminPagesForm::class) + ->whereNumber('pageId') + ->name('admin.pages.edit'); + + Route::livewire('/admin/navigation', AdminNavigationIndex::class)->name('admin.navigation.index'); + + Route::livewire('/admin/analytics', AdminAnalyticsIndex::class)->name('admin.analytics.index'); + + Route::livewire('/admin/search/settings', AdminSearchSettings::class)->name('admin.search.settings'); + + Route::livewire('/admin/developers', AdminDevelopersIndex::class)->name('admin.developers.index'); + + Route::livewire('/admin/apps', AdminAppsIndex::class)->name('admin.apps.index'); + Route::livewire('/admin/apps/{installation}', AdminAppsShow::class) + ->whereNumber('installation') + ->name('admin.apps.show'); +}); + +/* +|-------------------------------------------------------------------------- +| Storefront Routes (hostname-based store resolution) +|-------------------------------------------------------------------------- +*/ + +Route::middleware('storefront')->group(function (): void { + Route::livewire('/', Home::class)->name('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('/pages/{handle}', PagesShow::class)->name('storefront.pages.show'); + Route::livewire('/search', SearchIndex::class)->name('storefront.search'); + + Route::livewire('/cart', CartShow::class)->name('storefront.cart'); + Route::livewire('/checkout', CheckoutShow::class)->name('storefront.checkout'); + Route::livewire('/checkout/{checkoutId}/confirmation', CheckoutConfirmation::class) + ->whereNumber('checkoutId') + ->name('storefront.checkout.confirmation'); + + Route::livewire('/account/login', CustomerLogin::class)->name('storefront.account.login'); + Route::post('/account/login', [CustomerLoginController::class, 'store']) + ->middleware('throttle:login') + ->name('storefront.account.login.attempt'); + + Route::livewire('/account/register', CustomerRegister::class)->name('storefront.account.register'); + Route::post('/account/register', [CustomerRegisterController::class, 'store']) + ->name('storefront.account.register.attempt'); + + Route::post('/account/logout', [CustomerLoginController::class, 'destroy']) + ->name('storefront.account.logout'); + + Route::middleware('auth:customer')->group(function (): void { + Route::livewire('/account', AccountDashboard::class)->name('storefront.account.index'); + Route::livewire('/account/orders', AccountOrders::class)->name('storefront.account.orders.index'); + Route::livewire('/account/orders/{orderNumber}', AccountOrderShow::class)->name('storefront.account.orders.show'); + Route::livewire('/account/addresses', AccountAddresses::class)->name('storefront.account.addresses.index'); + }); +}); diff --git a/specs/progress.md b/specs/progress.md new file mode 100644 index 00000000..17b415fa --- /dev/null +++ b/specs/progress.md @@ -0,0 +1,41 @@ +# Implementation Progress + +Tracking implementation of the shop system per `specs/09-IMPLEMENTATION-ROADMAP.md`. + +| Phase | Scope | Status | Tests | +|-------|-------|--------|-------| +| 1 | Foundation: config, tenancy migrations/models, enums, ResolveStore, BelongsToStore, admin+customer auth, policies, Pest helpers | DONE (commit 5b743cde) | 30 passed, 3 todos | +| 2 | Catalog: products, options, variants, inventory, collections, media | DONE | full suite 79 passed, 5 todos (4 deferred to P5, 1 to P4) | +| 3 | Themes, pages, navigation, storefront layout + components | DONE (commit 5aee8dc4) | full suite 98 passed, 5 todos; Storefront/* rendering tests added | +| 4 | Cart, checkout, discounts, shipping, taxes + storefront cart/checkout UI | DONE | full suite 202 passed, 8 todos; Unit/* (48 new), Cart/CartServiceTest (12), Checkout/* (34), Storefront/CartUiTest (13) | +| 5 | Payments (mock PSP), orders, refunds, fulfillments, events | DONE | full suite 259 passed, 0 todos; Orders/* (28), Payments/* (17), Storefront/CheckoutUiTest (4), all 8 deferred todos resolved | +| 6 | Customer accounts | DONE | full suite 273 passed, 0 todos; Customers/CustomerAccountTest (6), Customers/AddressManagementTest (7), checkout prefill case added to Storefront/CheckoutUiTest | +| 7a | Admin panel core: layout shell (sidebar/top bar/breadcrumbs/toasts), dashboard, products (index + shared form with variants builder + media), orders (index + detail with fulfillment/refund/cancel/confirm-payment), customers (index + detail with address CRUD) | DONE | full suite 290 passed, 0 todos; Admin/DashboardTest (4), Admin/ProductManagementTest (8), Admin/OrderManagementTest (5) | +| 7b | Admin panel remaining sections: collections, inventory, discounts, settings (general/domains/checkout/notifications/shipping/taxes), themes (cards + editor), pages, navigation (apps/developers/analytics stay "Coming soon" until Phases 9/10) | DONE | full suite 338 passed, 0 todos; Admin/DiscountManagementTest (6), Admin/SettingsTest (6), Admin/CollectionManagementTest (7), Admin/InventoryManagementTest (7), Admin/ThemeManagementTest (8), Admin/PageManagementTest (7), Admin/NavigationManagementTest (8) | +| API | Spec 02 REST APIs + Sanctum: storefront cart/checkout API, admin products/orders API with token abilities, rate limiters, Developers admin page | DONE | full suite 386 passed, 0 todos; Auth/SanctumTokenTest (5), Cart/CartApiTest (8), Api/StorefrontCartApiTest (8), Api/StorefrontCheckoutApiTest (9), Api/AdminProductApiTest (7), Api/AdminOrderApiTest (6), Admin/DevelopersTest (5) | +| 8 | Search (FTS5) | DONE | full suite 422 passed, 0 todos; Search/SearchTest (5), Search/AutocompleteTest (3), Storefront/SearchUiTest (8), Admin/SearchSettingsTest (5) | +| 9 | Analytics | DONE | (same run as Phase 8) Analytics/EventIngestionTest (5), Analytics/AggregationTest (3), Admin/AnalyticsTest (7) | +| 10 | Apps & webhooks: 6 migrations (apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, webhook_deliveries), WebhookService + DeliverWebhook job (HMAC, backoff, circuit breaker), DispatchWebhooks listener + ProductObserver wiring, Admin Apps directory/detail, Developers webhook CRUD, AppSeeder | DONE | full suite 444 passed, 0 todos; Webhooks/WebhookDeliveryTest (5), Webhooks/WebhookSignatureTest (4), Admin/AppsTest (7), Admin/DevelopersTest (+6 webhook cases, 11 total) | +| 11 | Polish: seeder audit vs spec 07, a11y (skip links, focus management, aria-live, labels), responsive + dark mode pass, error pages (404/419/500/503), structured business-event logging, SmokeTest | DONE | full suite 450 passed, 0 todos; Orders/StructuredLoggingTest (3), SmokeTest (3 tests, ~50 routes) | +| 12 | Full suite + browser tests (spec 08) + Playwright MCP acceptance verification | DONE: all 18 suites, 143 browser tests complete | full suite 593 passed (2054 assertions), 0 todos; Browser: SmokeTest (10), Admin/AuthenticationTest (10), Admin/ProductManagementTest (7), Admin/OrderManagementTest (11), Admin/DiscountManagementTest (6), Admin/SettingsTest (7), Admin/CollectionManagementTest (3), Admin/CustomerManagementTest (3), Admin/PageManagementTest (3), Admin/AnalyticsTest (3), Storefront/BrowsingTest (15), CartTest (12), CheckoutTest (13), CustomerAccountTest (12), InventoryTest (4), TenantIsolationTest (5), ResponsiveTest (8), AccessibilityTest (11) | + +## Final status (2026-06-10) + +ALL PHASES COMPLETE. Full suite: 594 passed (2057 assertions), 0 todos - 451 unit/feature + 143 browser tests. +Live Playwright MCP acceptance simulation on shop.test completed: storefront browse, variant selection, cart drawer + discount (WELCOME10), full checkout with mock credit card -> order #1016 confirmed (idempotency verified, single order), search autocomplete, customer registration + account dashboard, admin dashboard/order detail (fulfillment with DHL tracking), bank-transfer confirmation (#1005 -> paid/captured), products, product form, discounts, shipping settings, analytics, theme editor, developers. Zero console errors after fix. +One production bug found during live simulation and fixed: DeliverWebhook threw on the sync queue when a webhook target was unreachable, which 500'd checkout completion (browser tests had masked it with Http::fake). Fix: inline (sync) execution dead-letters after the first failed attempt instead of throwing; background queues keep retry-by-throwing with backoff. Regression test added (WebhookDeliveryTest, 6 cases now). + +## Log + +- 2026-06-09: Project start. Fresh Livewire starter kit (Fortify). Created progress tracker, started Phase 1. +- 2026-06-09: Phase 2 done. Catalog migrations (9 tables), models, enums, ProductService/VariantMatrixService/InventoryService/MediaService, HandleGenerator, ProcessMediaUpload job (GD), Collection+Product seeders (25 products, 127 variants). Order-reference checks implemented behind Schema::hasTable('order_lines') guard; 3 order-dependent test cases todo'd for Phase 5. +- 2026-06-09: Phase 4 done. Migrations for carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts. Models + factories + 7 enums. Services: CartService (session key `cart_id`, version bump on every mutation, sum-quantities merge on login), DiscountService (reason-coded InvalidDiscountException, largest-remainder allocation), ShippingCalculator (specificity-ordered zone matching, flat/weight/price rates), TaxCalculator (basis points, intdiv for exclusive add and inclusive extract), PricingEngine (deterministic pipeline, snapshot to totals_json; inclusive total = discounted subtotal + shipping), CheckoutService state machine (payment_selected reserves inventory + 24h expires_at; completeCheckout guarded, throws LogicException until Phase 5). Jobs ExpireAbandonedCheckouts (15 min) + CleanupAbandonedCarts (daily) scheduled in routes/console.php. Storefront UI: CartDrawer (global, listens cart-updated/open-cart), /cart page with shipping estimate, /checkout 4-step stepper through payment method selection (pay button is the Phase 5 mount point), live header badge, real addToCart. Seeders: TaxSettingsSeeder, ShippingSeeder, DiscountSeeder (WELCOME10/FLAT5/FREESHIP/EXPIRED20/MAXED). Cart merge wired into CustomerLoginController; CustomerAuthTest merge case implemented. 4 test cases todo'd for Phase 5 (order completion paths). +- 2026-06-09: Phase 5 done. Migrations for orders (+ nullable unique checkout_id for idempotency), order_lines, payments, refunds, fulfillments (+ delivered_at), fulfillment_lines. Models + factories (paid/pending/fulfilled/cancelled states) + 7 enums (OrderStatus, FinancialStatus, FulfillmentStatus, PaymentMethod, PaymentStatus, RefundStatus, FulfillmentShipmentStatus). PaymentProvider contract bound to MockPaymentProvider (magic cards 4242/0002/9995, paypal instant capture, bank_transfer pending, mock_ reference IDs); PaymentResult/RefundResult value objects. OrderService (createFromCheckout atomic with snapshots + payment record + inventory commit for captured / keep-reserved for bank transfer + discount usage increment + cart converted; generateOrderNumber sequential per store from #1001 inside the creation transaction with unique (store_id, order_number) backstop; cancel releases or restocks per financial status; confirmBankTransferPayment commits inventory + digital auto-fulfillment). RefundService (refundable validation, partial/full financial status, restock flag). FulfillmentService (FulfillmentGuardException unless paid/partially_refunded, qty <= unfulfilled validation, markAsShipped/markAsDelivered, autoFulfillDigital delivered). CheckoutService::completeCheckout implemented: idempotent via orders.checkout_id, PaymentFailedException on decline (reservation kept, released by 24h expiry). Events: OrderCreated/OrderPaid/OrderFulfilled/OrderCancelled/OrderRefunded/CheckoutCompleted/FulfillmentDelivered. CancelUnpaidBankTransferOrders daily job (store settings bank_transfer_cancel_days, default 7). Storefront pay step (card form / paypal / bank transfer) + /checkout/{checkoutId}/confirmation page with bank transfer instructions. CustomerSeeder (12 customers + addresses) and OrderSeeder (15 fashion + 3 electronics orders) wired into DatabaseSeeder, idempotent. All 8 deferred todos resolved; suite has ZERO todos. +- 2026-06-09: Phase 6 done. Livewire account area behind `auth:customer` inside the storefront group: `Storefront\Account\Dashboard` (GET /account: welcome heading, quick-link cards, last 5 orders, profile form with name + marketing_opt_in via `updateProfile`), `Account\Orders\Index` (GET /account/orders: paginated table desktop / cards mobile, status + fulfillment badges), `Account\Orders\Show` (GET /account/orders/{orderNumber}: bare number in URL matched against the stored `#`-prefixed order_number, scoped to the auth'd customer so foreign orders 404; timeline built from placed_at + captured payment + fulfillments shipped/delivered + cancelled, line items with snapshots, shipping/billing/payment grid, totals, tracking links target=_blank rel=noopener), `Account\Addresses\Index` (GET /account/addresses: modal CRUD reusing x-storefront.address-form with prefix="form", postal_code<->zip mapping to the spec 01 JSON shape, first address auto-default, setDefault flips others off, deleting the default promotes the newest remaining, foreign address ids abort 404). New components: x-storefront.account-nav (tabs + logout form), x-storefront.order-status-badge (spec 04 colors). `App\Support\Storefront\Countries` extracted (address-form now uses it). CustomerAddress gained toCheckoutAddress()/summaryLine(). Checkout: address step prefills from the default address when checkout has no address, saved-address dropdown ("Use a new address" resets) per spec 04; email prefill existed. Logout now session()->invalidate() + regenerateToken (spec 06). Confirmation page "View order" links to the order detail. Header account icon already linked correctly (Phase 3). Verified in browser via Playwright on acme-fashion.test (login, dashboard, orders, order #1002 timeline, address create modal, checkout prefill + dropdown switch).- 2026-06-09: Phase 7a done (admin panel core). Layout: `resources/views/layouts/admin.blade.php` (component layout, used via `#[Layout('layouts::admin')]`) with `Admin\Layout\Sidebar` (fixed 256px desktop, Alpine-driven mobile overlay via `toggle-admin-sidebar` window event, nav groups per spec 03 section 1.2; unimplemented sections render as disabled "Coming soon" items and auto-activate once `Route::has()` finds them) and `Admin\Layout\TopBar` (store selector dropdown -> `switchStore()` validates membership, writes session `current_store_id`, redirects to dashboard; profile menu with logout; mobile hamburger). Global toast container (Alpine, top-right, 5s auto-dismiss, success/error/info left borders) listens for the Livewire `toast` event via `x-on:toast.window` and replays `session('toast')` flashes after redirects; components use the `Admin\Concerns\SendsToasts` trait. Breadcrumbs/cards/status badges are anonymous Blade components `x-admin.breadcrumbs` / `x-admin.card` / `x-admin.status-badge`. Dashboard (GET /admin): KPI tiles (total sales/orders/AOV + conversion rate approximated as orders/carts until Phase 9 analytics; change badges vs previous period), dependency-free inline SVG line chart of daily order counts, recent orders table (last 10), 7/30/90-day presets. Products: `Admin\Products\Index` (search, status tabs, type filter, sortable columns incl. inventory subquery sum, bulk set-active/archive/delete with confirm modal, empty states, 15/page) and shared `Admin\Products\Form` for create+edit (route param `{productId}` because a `{product}` param collides with the `$product` model property during Livewire param auto-assignment): options builder (comma-separated values, live matrix regeneration preserving row edits by combination key), per-variant sku/barcode/price/compare-at/weight/qty/ship rows applied post-`VariantMatrixService::rebuildMatrix` via case-insensitive sorted value-combination keys, diff-based option/value sync so untouched combinations keep variants+inventory, media upload (WithFileUploads, immediate attach in edit mode, pending until save in create mode, alt text inline edit, wire:sort reorder, delete), collections checkboxes, SEO handle (blank -> HandleGenerator), publishing datetime, status transitions through `ProductService::transitionStatus`. Orders: `Admin\Orders\Index` (status tabs all/pending/paid/fulfilled/cancelled/refunded, search order#/email, date range, sortable) and `Admin\Orders\Show` (computed `order` keyed by locked `orderId`, timeline incl. refunds/cancellation, line items with per-line fulfillment badges, totals incl. refunded, payment panel with Confirm Payment for pending bank transfers -> `OrderService::confirmBankTransferPayment`, fulfillment guard callout, fulfillment modal -> `FulfillmentService::create`, mark shipped/delivered, refund modal (selected lines sum or custom amount, reason, restock) -> `RefundService::create`, cancel modal -> `OrderService::cancel`; service ValidationException/FulfillmentGuardException surfaced as error toasts). Customers: `Admin\Customers\Index` (search, orders count + total spent) and `Admin\Customers\Show` (info card, paginated order history, address CRUD modal with default handling per spec 03 section 9.2). Routes registered in the existing `['auth','admin']` group; placeholder dashboard route/view removed. All actions policy-guarded (`@can` in views + `authorize()` in actions; Support read-only, Staff cannot delete/archive). Pest helper `createStoreMember(Store, StoreUserRole)` added. Tests: Admin/DashboardTest (4), Admin/ProductManagementTest (8), Admin/OrderManagementTest (5), exactly per roadmap tables; full suite 290 passed, 0 todos; pint clean; npm run build ok; migrate:fresh --seed ok. Browser-verified via Playwright on shop.test/admin: login, dashboard KPIs+chart, products list, Leather Belt edit form (options/variants prefilled), order #1005 bank-transfer confirm -> paid, fulfillment modal -> DHL fulfillment -> shipped -> delivered with timeline updates, toast rendering, customer detail with addresses, mobile sidebar overlay at 375px, store switcher (verified with a temporary second membership, then reverted; revoked membership correctly 403s). +- 2026-06-10: Phase 7b done (remaining admin sections). Collections: `Admin\Collections\Index` (search, status filter, products_count, delete modal) + shared `Admin\Collections\Form` ({collectionId} edit route; title/handle/description/status, handle unique per store + auto-slug from title, product picker with debounced search dropdown + Add buttons, assigned list with thumbnails, `wire:sort` drag reorder buffered in `assignedProductIds` and synced to `collection_products.position` on save). Discounts: `Admin\Discounts\Index` (code search, schedule-aware status filter incl. scheduled/expired derived states, type filter, value column %/fixed-currency/free-shipping, usage "n / limit-or-unlimited" column) + shared `Admin\Discounts\Form` (code/automatic radios, Generate code, percent/fixed/free_shipping value types with unit conversion percent-whole-number vs fixed-minor-units, per-store code uniqueness `Rule::unique`, min purchase + specific products/collections + one-per-customer persisted into `rules_json` per DiscountService keys, usage limit, start/end datetimes with after-validation, Active/Disabled switch -> status). Inventory: `Admin\Inventory\Index` (search by product title or SKU, stock filter in/low(<=5)/out via on_hand - reserved expressions, inline on-hand number inputs `wire:change` -> `updateQuantity` clamped >= 0 and guarded by ProductPolicy::update so Support is read-only, policy badges, available column color-coded). Settings: `Admin\Settings\Index` page with `?tab=` (general/domains/checkout/notifications rendered as child Livewire components `Settings\General/Domains/Checkout/Notifications`; shared `x-admin.settings-tabs` bar links Shipping/Taxes to their own routes per spec 02). General: store name/contact email/order number prefix + currency/locale/timezone (writes both `stores` columns and `store_settings.settings_json`). Domains: table with type/primary/TLS badges, add-domain modal (hostname regex + global uniqueness), set-primary scoped per domain type, refuses removing the last domain. Checkout: guest checkout toggle + `bank_transfer_cancel_days` (consumed by the Phase 5 job). Notifications: notification email + transactional toggles in settings_json. `Settings\Shipping`: zone cards (countries checklist of 22 ISO codes + optional comma-separated region codes), nested rate tables (flat amount or multi-range weight/price configs matching ShippingCalculator's `ranges`/`min_g`/`min_amount` shape; carrier intentionally omitted as unimplemented in the calculator), inline active toggles, zone/rate modals, test-address tool calling `ShippingCalculator::getAvailableRates`. `Settings\Taxes`: manual/provider radios, manual rate % -> `default_rate_bps`, tax name, prices_include_tax + shipping_taxable switches, provider select + API key into config_json. All settings pages authorize via StorePolicy viewSettings/updateSettings (owner/admin only; staff 403s). Themes: `Admin\Themes\Index` (card grid, published card gets blue ring, Customize button, ellipsis menu Publish/Duplicate/Delete with wire:confirm; publish demotes other published themes inside a transaction and calls `ThemeSettingsService::forget`; duplicate copies settings as a draft "(Copy)"; published theme cannot be deleted) and `Admin\Themes\Editor` (three-panel: left fixed groups header/colors/catalog/footer plus orderable+toggleable home sections hero/featured-collections/featured-products/newsletter/rich-text driving the persisted `sections` array via `wire:sort` + eye toggles; center iframe preview of the primary storefront domain; right dynamic fields text/textarea/color/select/number/checkbox bound to `settings.*` from `ThemeSettingsService::defaults()`, featured_collection_handles edited as comma list; Save persists theme_settings (model event invalidates cache), Save & publish also publishes). Pages: `Admin\Pages\Index` (search, handle column) + shared `Admin\Pages\Form` (title/handle unique per store/body textarea 16 rows/status draft-published-archived/published_at backfilled on publish, delete modal owner-admin only). Navigation: `NavigationMenuPolicy` added (view owner/admin/staff, update owner/admin per spec 06 matrix), `Admin\Navigation\Index` (menu cards -> selectMenu buffers items in state; item modal with label, type select link/page/collection/product and conditional URL input or resource select; `wire:sort` reorder; Save menu diff-deletes/upserts with positions inside a transaction then `NavigationService::forget` so storefront trees refresh; nesting omitted because navigation_items has no parent_id in spec 01). Routes registered for all sections ({collectionId}/{discountId}/{pageId}/{themeId} params following the Phase 7a productId convention); sidebar items for Collections/Inventory/Discounts/Pages/Navigation/Themes/Settings auto-enabled via the existing Route::has() mechanism (Analytics/Apps/Developers still disabled). Tests exactly per roadmap tables (DiscountManagementTest 6 cases, SettingsTest 6 cases incl. staff 403 + domains) plus CollectionManagementTest (7), InventoryManagementTest (7), ThemeManagementTest (8, incl. cache invalidation end-to-end via ThemeSettingsService and staff 403), PageManagementTest (7), NavigationManagementTest (8, incl. cached tree invalidation and support 403 / staff read-only). Full suite 338 passed, 0 todos; migrate:fresh --seed ok; pint clean; npm run build ok. +- 2026-06-10: API phase done. Installed laravel/sanctum ^4.3 (personal access tokens on User via HasApiTokens; config/sanctum.php with token_prefix `shop_` and 1-year default expiration 525600 min; published personal_access_tokens migration). routes/api.php registered in bootstrap/app.php withRouting: Storefront API `/api/storefront/v1` (middleware `store.resolve:storefront`; carts under `throttle:api.storefront`, checkouts under `throttle:checkout`) with carts CRUD (POST 201, line add returns 200 full cart per roadmap table; optimistic concurrency accepts both `cart_version` (spec 02) and `expected_version` (roadmap) and answers 409 with `error_code: version_conflict`, `current_version`, and the full current cart state; CartVersionMismatchException now carries expected/current version properties) and the full checkout flow (create 201/`started`, address with available_shipping_methods, shipping-method with recalculated totals, apply-discount/DELETE discount with InvalidDiscountException mapped to 400 `discount_expired`/`discount_usage_exceeded` or 422, payment-method, pay -> completeCheckout via mock PSP returning order payload + bank transfer instructions, 422 + error_code on decline, 409 on wrong state, 410 on expired checkouts). Admin API `/api/admin/v1/stores/{storeId}` (middleware `auth:sanctum`, `store.resolve:api-admin`, `throttle:api.admin`, Sanctum `abilities:` per endpoint): products index (filters/sort, default per_page 15 per roadmap table, data+meta envelope)/store (options+explicit variants with option_values, inventory quantity+policy, collections sync, store-scoped handle+SKU uniqueness)/show/update (partial; status via ProductService::transitionStatus mapped to 422)/destroy (archives, returns id/status/updated_at), orders index (status/financial/fulfillment/customer/date/query filters)/show (lines+payments+fulfillments+refunds)/fulfillments (creates + marks shipped per spec 02 "mark items as shipped"; FulfillmentGuardException -> 409)/refunds (RefundService against latest captured payment; 409 when nothing refundable). ResolveStore gained `api-admin` context resolving the store from {storeId} and verifying token-user membership (404 unknown store, 403 non-member, 403 mutations on suspended). bootstrap/app.php: `abilities`/`ability` middleware aliases; JSON error envelopes for api/* (404 "The requested resource was not found.", 403 "You do not have permission to perform this action.", InsufficientInventoryException -> 422 validation shape, FulfillmentGuardException -> 409). AppServiceProvider rate limiters per spec 02 section 7: api.admin 60/min per token id, api.storefront 120/min per IP, checkout 10/min per session (IP fallback for stateless API), search 30/min, analytics 60/min, webhooks 100/min, all returning the spec 429 body {message, retry_after}. API Resources: Storefront CartResource/CheckoutResource (unwrapped, spec shapes incl. line snapshots, totals, available_shipping_methods), Admin ProductListResource/ProductResource/OrderListResource/OrderResource/FulfillmentResource/RefundResource (data-wrapped). Admin Developers page (`/admin/developers`, Admin\Developers\Index, sidebar auto-enabled): token table (name, ability badges, last used, created, revoke with wire:confirm), generate-token modal (name + 18 ability checkboxes from App\Support\TokenAbilities), plaintext shown once in amber callout; StorePolicy::manageDevelopers gates to owner/admin (staff 403); webhooks section deferred to Phase 10. Storefront search/analytics endpoints deferred to Phases 8/9 per roadmap; GET /orders/{orderNumber} tokenized order status endpoint deferred with them. Tests exactly per roadmap tables (SanctumTokenTest 5, CartApiTest 8 incl. 121-request rate limit, StorefrontCartApiTest 8, StorefrontCheckoutApiTest 9, AdminProductApiTest 7 incl. default page size 15, AdminOrderApiTest 6) plus Admin/DevelopersTest (5). Full suite 386 passed, 0 todos; migrate:fresh --seed ok; pint clean. +- 2026-06-10: Phases 8+9 done (Search + Analytics). Search: migrations `search_settings` (store_id PK, synonyms_json/stop_words_json), `search_queries` (store-scoped log with results_count), and a raw-SQL `products_fts` FTS5 virtual table (CREATE VIRTUAL TABLE IF NOT EXISTS, sqlite-driver-guarded, rowid = product id so upserts are delete+insert by rowid; KEY QUIRK: FTS5 stores column values as text, so the UNINDEXED `store_id` is always filtered with `CAST(store_id AS INTEGER) = ?` because an integer binding/literal silently matches nothing). `SearchService`: `search()` (tokenize -> stop-word removal (falls back to original tokens if all are stop words) -> synonym OR-groups from search_settings with multi-word synonyms as quoted phrases -> trailing prefix `*` on the last token; MATCH subquery joined via joinSub so `fts.rank` drives relevance; filters vendor(s)/product_types/collection_id/price range (minor units)/in_stock/tags; sorts relevance/price_asc/price_desc/newest/best_selling; published+published_at scoping at query time per spec 05 16.3; every search logged to search_queries + a `search` analytics event, with a `logQuery` flag so Livewire filter/pagination re-renders only log when the query text changes), `autocomplete()` (min prefix 2), `countMatches()`, `facetValues()`, `syncProduct()`/`removeProduct()`/`reindexStore()` (last-indexed timestamp kept in cache). `ProductObserver` (created/updated -> sync, deleted -> remove) registered in AppServiceProvider, so seeded/created products index themselves; SearchSettingsSeeder also reindexes both stores defensively. Storefront UI: `Storefront\Search\Modal` (global in storefront layout, opened by the header icon dispatching `open-search-modal`, 300ms debounce, products max 5 + collections max 5 + "View all N results", skeleton loading, Alpine arrow-key/Enter/Escape keyboard nav, dialog/listbox ARIA) and `Storefront\Search\Index` at GET /search (?q= via #[Url], same filter sidebar partial + sort + pagination as collection pages, result heading 'N results for "q"', empty state with retry input); 404 page got the spec search form. Storefront API: GET /api/storefront/v1/search (spec JSON with results/facets/pagination, filters as URL-encoded JSON) and /search/suggest (products + collections), both `throttle:search`. Admin: `Admin\Search\Settings` at /admin/search/settings (synonym group rows add/remove, comma-separated stop words textarea, Reindex now button calling reindexStore synchronously with last-indexed timestamp; viewSettings/updateSettings policies so staff 403). Analytics: migrations `analytics_events` (unique (store_id, client_event_id) dedupe, occurred_at, properties_json) and `analytics_daily` (composite PK store_id+date). `AnalyticsService` (`track()` swallowing UniqueConstraintViolationException for idempotent ingestion, `getDailyMetrics()`, `eventCountsBetween()` for funnels). `AggregateAnalytics` job (optional Y-m-d constructor arg, defaults to yesterday; per-store recompute + upsert so reruns are idempotent; orders/revenue/AOV from checkout_completed event `total_amount` properties, visits = distinct page_view sessions; scheduled dailyAt 01:00 in routes/console.php). Ingestion endpoint POST /api/storefront/v1/analytics/events (`throttle:analytics`, batch 1-50, occurred_at within +/-1h, 202 {accepted, rejected} where dupes count as rejected, customer guard picked up via $request->user('customer')). Server-side tracking: product_view in Products\Show::mount, add_to_cart in Products\Show::addToCart, remove_from_cart in InteractsWithCart::removeLine, checkout_started in CheckoutService::createFromCart, checkout_completed via new `LogOrderAnalyticsEvent` listener on OrderCreated (spec 05 17); page_view via a tiny vanilla fetch snippet in the storefront layout (sessionStorage session id, crypto.randomUUID client_event_id). `Admin\Analytics\Index` at /admin/analytics (today/7d/30d/custom ranges, KPI tiles with vs-previous-period badges from analytics_daily, SVG sales chart reusing the Phase 7a dependency-free geometry approach, funnel page_view->product_view->add_to_cart->checkout_started->checkout_completed from raw events with proportional Tailwind bars, top products from order_lines with % of total, top referrers table attributing sessions to first page_view referrer host with per-source conversion); StorePolicy::viewAnalytics owner/admin/staff (support 403); sidebar Analytics item auto-enabled via Route::has(). Dashboard integration point switched: conversion rate now uses analytics_daily visits for the range, falling back to the cart-based approximation when a store has no analytics data. Seeders: AnalyticsSeeder (31 daily rows with ~3% growth + 220 events across ~35 sessions/7 days with spec type distribution and 30% customer attribution) + SearchSettingsSeeder (spec 07 3.18 synonym/stop-word sets for both stores), wired into DatabaseSeeder. Tests exactly per roadmap tables (SearchTest 5, AutocompleteTest 3, EventIngestionTest 5, AggregationTest 3) plus Storefront/SearchUiTest (8 incl. both search APIs), Admin/AnalyticsTest (7 incl. role matrix), Admin/SearchSettingsTest (5 incl. reindex rebuild + staff 403). Full suite 422 passed, 0 todos; migrate:fresh --seed ok (25 products indexed, 31 daily rows, 220 events); pint clean; npm run build ok; live-smoke-tested search page (synonym "tee" finds t-shirts), suggest API, and analytics ingestion (202) on shop.test. +- 2026-06-10: Phase 10 done (Apps and Webhooks). Migrations per spec 01 Epic 8: apps (name/status/created_at), app_installations (unique store+app, scopes_json, active/suspended/uninstalled, installed_at), oauth_clients (client_id unique, encrypted secret, redirect URIs), oauth_tokens (hashed access/refresh, expires_at), webhook_subscriptions (store-scoped, nullable app_installation_id, event_type, target_url, encrypted signing secret, active/paused/disabled, plus a `consecutive_failures` counter column - the one deliberate addition over spec 01 because spec 05 13.4 requires a persisted circuit breaker counter that resets on success), webhook_deliveries (event_id, attempt_count, pending/success/failed, last_attempt_at, response_code, response_body_snippet). OAuth2 flow itself stays deferred per roadmap tech decisions: tables + models + factories only, no Passport. Models: `App\Models\App` (explicit $table='apps', UPDATED_AT=null; import as `App as AppModel` where the facade collides), AppInstallation, OauthClient, OauthToken, WebhookSubscription (BelongsToStore, 'encrypted' cast on signing_secret_encrypted, latestDelivery ofMany relation), WebhookDelivery; 4 new enums (AppStatus, AppInstallationStatus, WebhookSubscriptionStatus, WebhookDeliveryStatus). `WebhookService`: EVENT_TYPES catalog (order.created/paid/fulfilled/cancelled/refunded, product.created/updated/deleted, checkout.completed), dispatch(store,eventType,payload) queries active subscriptions withoutGlobalScope + explicit store_id (queue context has no current_store), creates pending deliveries (attempt_count 0) sharing one event UUID, queues DeliverWebhook with a versioned envelope {id, event, api_version:'v1' per spec 02 sec 9, store_id, created_at, data}; sign() HMAC-SHA256 hex; verify() hash_equals timing-safe. `DeliverWebhook` job: tries=6, backoff [60,300,1800,7200,43200] (spec 05 13.3); POSTs JSON via Http facade with X-Platform-Signature/-Event/-Delivery-Id (fresh UUID per attempt)/-Timestamp headers; DB attempt_count is the attempt source of truth (incremented per run, robust under sync driver); records response code + 500-char body snippet + last_attempt_at; non-2xx/connection error throws RuntimeException to trigger queue retry except on the 6th attempt which dead-letters as failed; circuit breaker increments subscription.consecutive_failures per failed attempt, pauses at 5 with a logged warning, success resets to 0; paused/disabled/missing subscriptions cause pending deliveries to be marked failed and skipped. Event wiring: `DispatchWebhooks` listener with a union-typed handle (auto-discovered for all six events, verified via event:list) maps OrderCreated/OrderPaid/OrderFulfilled/OrderCancelled/OrderRefunded (payload incl. refund block)/CheckoutCompleted; product.created/updated/deleted dispatched from ProductObserver (product changes are model events, not domain event classes; archiving via status change emits product.deleted per spec 05 13.1). Admin UI: `Admin\Apps\Index` at /admin/apps (installed app cards with icon/installed-ago/status badge/uninstall linking to detail, plus available-apps directory with Install; install reactivates uninstalled rows, uninstall flips status and disables the installation's webhook subscriptions), `Admin\Apps\Show` at /admin/apps/{installation} (granted scopes badges, webhook subscriptions table with last delivery + response code badge, usage panel with token count and "Never" for API calls since OAuth is stubbed, uninstall), both behind new StorePolicy::manageApps (owner/admin per spec 05 1.3 matrix); sidebar Apps item auto-enabled via existing Route::has(). Developers page webhooks section replaces the placeholder: subscription table (event type, URL, status badge active=green/paused=red, last delivery + response code), Add/Edit modal (event type select from EVENT_TYPES, URL input, secret `whsec_` + 32 random chars generated server-side and shown once in an amber callout), pause/resume (resume resets consecutive_failures per spec 05 13.4 manual re-enable), delete with confirm, recent-deliveries panel (last 10 attempts with status/response/attempts/timestamp). AppSeeder (idempotent, wired into DatabaseSeeder): 3 registry apps each with an oauth client, Loyalty Rewards installed on acme-fashion with an order.created subscription, one store-level order.paid subscription, sample success/pending deliveries. Tests exactly per roadmap tables: Webhooks/WebhookDeliveryTest (5: delivery via real OrderCreated event + Http::fake, HMAC header matches hash_hmac of raw body, retry increments attempt_count + asserts tries/backoff config, 6th attempt dead-letters as failed, 5 consecutive failures pause the subscription and paused subscriptions get no new deliveries) and Webhooks/WebhookSignatureTest (4: known-hash signature, verify round-trip, tampered payload rejected, wrong secret rejected); plus Admin/AppsTest (7: render, install, reinstall reactivation, uninstall disables subscriptions, detail page scopes+webhooks, cross-store 404, staff 403/admin 200) and 6 new Admin/DevelopersTest cases (create with one-time whsec_ secret, event-type/URL validation, edit prefill + update without secret rotation, pause/resume resets failures, delete, deliveries panel render). Full suite 444 passed, 0 todos; migrate:fresh --seed ok (3 apps, 1 installation, 3 oauth clients, 2 subscriptions, 4 deliveries); pint clean. +- 2026-06-10: Phase 11 done (Polish). Seeder audit against spec 07: all 19 seeders matched the spec'd dataset (2 stores, 4 domains incl. shop.test, 5 users/roles, 25 products with full option/variant/inventory graphs, 6 collections, 5 discounts, 12 customers, 18 orders incl. the full status matrix, themes/pages/navigation, 31 analytics_daily rows + 220 events, search settings, apps/webhooks); one gap fixed: Mechanical Keyboard now assigned to the Electronics "Featured" collection per spec 07 section 4. A11y: admin layout gained a skip link + main#main-content (storefront already had one); cart drawer and mobile nav drawer now move focus into the panel on open; account address modal focuses its first control on open; sr-only aria-live cart-count region added to the header; aria-labels added to the admin theme color inputs and the product media alt-text editor. Responsive + dark mode audits found no real gaps (all admin tables wrapped in overflow-x-auto, storefront tables have mobile card alternatives, only intentional light-only classes remain). Error pages: generic styled errors/404, 419, 500, 503 views added (admin/non-tenant contexts; storefront keeps its themed 404/503 via the exception render hooks, API keeps JSON envelopes). Structured logging: new LogStructuredBusinessEvent listener writes order.created/order.paid/order.cancelled/order.refunded to the JSON `structured` channel; payment.failed logged in CheckoutService on declined charges; webhook.delivery_failed logged in DeliverWebhook when attempts are exhausted. New tests: Orders/StructuredLoggingTest (3) and tests/Feature/SmokeTest.php (3 tests iterating every public storefront GET route, customer account pages, and 29 admin pages against the demo seed). phpunit.xml memory_limit raised to 512M (full-suite smoke seeding exceeded the 128M default). Verification checklist: composer install clean, npm run build clean, migrate:fresh --seed clean, route:list 93 routes no duplicates, config:cache + config:clear OK, optimize:clear OK, pint clean on whole codebase, full suite 450 passed / 0 todos. +- 2026-06-10: Phase 12 part 1 done (browser test infrastructure + suites 1, 2, 7, 8, 9 of spec 08). Installed pestphp/pest-plugin-browser ^4.3 (Playwright 1.59 already present via node_modules/MCP). Infrastructure decisions: Pest's browser plugin serves the Laravel app IN-PROCESS (Amp socket server on 127.0.0.1:random-port inside the test process), so the existing phpunit.xml in-memory SQLite + RefreshDatabase setup is shared by the browser-issued HTTP requests and stays untouched - all 450 existing tests keep their config. `.env.testing` created per spec 08 (APP_ENV/APP_URL/PAYMENT_PROVIDER=mock/MAIL_MAILER=array/QUEUE_CONNECTION=sync + APP_KEY copied from .env because creating .env.testing stops Laravel from falling back to .env); phpunit.xml `` values still win for the test run (DB stays :memory:). Host/tenancy strategy: the spec's "browse acme-fashion.test via Herd" approach doesn't work because the plugin's Amp DNS resolver bypasses macOS /etc/resolver (Herd dnsmasq), so instead the Browser suite's beforeEach seeds the full DatabaseSeeder and registers an extra store_domains row mapping hostname `127.0.0.1` to the Acme Fashion store - ResolveStore resolves the tenant through the real domain-table mechanism. `Http::fake()` in the Browser beforeEach prevents the seeded webhook subscriptions (AppSeeder) from making real outbound HTTP on the sync queue during checkout completion (was 500ing the pay step). tests/Browser registered in phpunit.xml testsuites + tests/Pest.php (TestCase + RefreshDatabase + seeded beforeEach); browser helpers added to Pest.php (browserLoginAsAdmin, browserAddClassicTeeToCart, browserFillCheckoutAddress, browserReachCheckoutPaymentStep). App changes made to satisfy spec 08 (spec-vs-implementation deviations fixed toward spec): (1) admin login heading/button "Login" -> "Sign in" and customer login "Login" -> "Log in" (specs 08 1.5/1.6/2.x; AdminAuthTest/CustomerAuthTest updated); (2) checkout payment steps 4+5 merged into the single spec-04 "Payment Method & Pay" step - radio fieldset (wire:model.live) + method-specific form/pay button render together, the "Continue to payment" intermediate click is gone, payNow() now calls selectPaymentMethod when the checkout is still shipping_selected and persists a switched method when already payment_selected (Livewire selectPayment action removed; CheckoutUiTest still green); (3) DE postal-code format validation (regex ^\d{5}$) + friendly validationAttributes added to the checkout address step (spec 08 9.7); (4) `novalidate` added to admin login + checkout contact/address forms so server-side validation messages render in the browser instead of native bubbles (spec 08 2.3/2.4/9.5/9.6); password @error block added to admin login; (5) InvalidDiscountException::notFound message "This discount code does not exist." -> "Invalid discount code." per spec 04 section "Invalid discount code" toast/error (CartUiTest updated); (6) ThemeSeeder: acme-fashion gets featured_products_collection_handle=new-arrivals so the home page actually shows Classic Cotton T-Shirt per spec 08 7.1 (default was latest-by-id which excluded product #1). Browser test gotchas learned: bare tag selectors like 'h1' are NOT treated as CSS by the plugin's locator guesser (use 'h1[data-flux-heading]' or other explicit selectors with CSS metacharacters); explicit selectors are strict-mode (scope duplicates like 'nav a:visible:has-text(...)' / 'table [aria-label=...]'); the cart drawer auto-opens on cart-updated after applying a discount and overlays the page Checkout link (close it via [aria-label="Close cart"]). tests/Browser/Screenshots + database/testing.sqlite gitignored. Suites: SmokeTest 10, Admin/AuthenticationTest 10, Storefront/BrowsingTest 15, Storefront/CartTest 12, Storefront/CheckoutTest 13 (incl. magic cards 4242/0002 declined/9995 insufficient, PayPal, bank transfer IBAN/BIC/reference instructions, FLAT5-in-checkout totals 24.98, DE vs US shipping zones). Full suite 510 passed (1641 assertions), 0 todos, ~81s; pint clean. Remaining for part 2: suites 3-6 and 10-18 (83 tests) + Playwright MCP acceptance verification. +- 2026-06-10: Phase 12 part 2 done (browser test suites 3-6 and 10-18, 83 new tests; spec 08 complete at 143 browser tests / 18 files). New files: Browser/Admin/ProductManagementTest (7), OrderManagementTest (11), DiscountManagementTest (6), SettingsTest (7), CollectionManagementTest (3), CustomerManagementTest (3), PageManagementTest (3), AnalyticsTest (3); Browser/Storefront/CustomerAccountTest (12), InventoryTest (4), TenantIsolationTest (5), ResponsiveTest (8), AccessibilityTest (11). New Pest.php helpers: browserLoginAsCustomer, browserOpenAdminOrder, browserCreateFulfillment, switchBrowserTestDomainToStore (re-points the 127.0.0.1 store_domains row at another store and forgets the ResolveStore hostname cache so tenant isolation is exercised through the REAL domain-resolution path in both directions; the plugin's in-process HTTP server always reports 127.0.0.1 as the request host, so a second hostname is impossible). REAL BUG found+fixed: discounts/form.blade.php used a Blade `@if` directive inside the component tag attribute list, which is unsupported by the component-tag compiler and silently broke rendering of the discount value input (admin could never enter a discount value in the browser); replaced with a bound `:max` attribute. Spec-08 alignment changes to the app (noted where spec 03/04 said otherwise): refund toast "Refund issued" -> "Refund processed" (08 4.6; 03's toast table said "Refund issued"), shipping rate save toast -> "Shipping rate saved" (08 6.4), tax save toast -> "Tax settings saved" + "Tax Settings" secondary heading on the taxes tab (08 6.5/6.6; 03 only had generic "Settings saved"), customer register heading/button "Register" -> "Create an account"/"Create account" (per BOTH 04 10.2 and 08 10.1), account-nav dashboard tab "Account" -> "My Account" (08 10.1/10.4 expect visible "My Account"; 04 doesn't spec the tab labels), address book now shows an "Address saved" status message after save (08 10.10/10.11; component previously closed the modal silently). Test-side adaptations to seeded reality (not weakenings): product list paginates at 15 of 20 products sorted by updated_at, so 3.1 sorts by Title and 3.3 uses the admin search; "FAQ" page already seeded so 17.2 supplies a unique handle (faq-e2e); pages list shows "About Us" (seeded title) for 17.3; 3.4 clicks the Active tab explicitly because the app's default product filter is All. Gotchas learned: Playwright fill() does not trigger Alpine x-model on the entangled PDP quantity input (click + Backspace + type() does - used in 11.4 with assertScript on Alpine.$data to prove qty 15 before the over-stock increment); '#1001'-style text starts with '#' so the locator guesser treats it as CSS - click via 'a:has-text("#1001")'; getByText is case-insensitive substring ("Fulfilled" matches "Unfulfilled" - 4.11 asserts the badge text via assertScript; "Visits" matches "unique visits" on analytics); resize() returns AwaitableWebpage so browserFillCheckoutAddress now accepts/returns the union type; keys('body', ...) fails the locator guesser (bare tag) - use 'body:first-of-type'; Flux labels are custom elements associated at runtime, so a11y label checks must accept aria-labelledby. Full suite 593 passed (2054 assertions), 0 todos, ~156s; pint clean. diff --git a/tests/Browser/Admin/AnalyticsTest.php b/tests/Browser/Admin/AnalyticsTest.php new file mode 100644 index 00000000..b8fe9210 --- /dev/null +++ b/tests/Browser/Admin/AnalyticsTest.php @@ -0,0 +1,27 @@ +click('aside a:has-text("Analytics")') + ->assertSeeIn('h1[data-flux-heading]', 'Analytics') + ->assertNoJavascriptErrors(); +}); + +it('shows sales data', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Analytics")') + ->assertSeeIn('[data-test="analytics-kpi-orders"]', 'Orders') + ->assertSee('Revenue') + ->assertNoJavascriptErrors(); +}); + +it('shows conversion funnel data', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Analytics")') + ->assertVisible('[data-test="analytics-funnel"]') + ->assertSee('Visits') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Admin/AuthenticationTest.php b/tests/Browser/Admin/AuthenticationTest.php new file mode 100644 index 00000000..579755cd --- /dev/null +++ b/tests/Browser/Admin/AuthenticationTest.php @@ -0,0 +1,97 @@ +fill('email', 'admin@acme.test') + ->fill('password', 'password') + ->click('@admin-login-button') + ->assertSee('Dashboard') + ->assertNoJavascriptErrors(); +}); + +it('shows error for invalid credentials', function (): void { + $page = visit('/admin/login'); + + $page->fill('email', 'admin@acme.test') + ->fill('password', 'wrongpassword') + ->click('@admin-login-button') + ->assertSee('Invalid credentials') + ->assertNoJavascriptErrors(); +}); + +it('shows error for empty email', function (): void { + $page = visit('/admin/login'); + + $page->fill('password', 'password') + ->click('@admin-login-button') + ->assertSee('The email field is required') + ->assertNoJavascriptErrors(); +}); + +it('shows error for empty password', function (): void { + $page = visit('/admin/login'); + + $page->fill('email', 'admin@acme.test') + ->click('@admin-login-button') + ->assertSee('The password field is required') + ->assertNoJavascriptErrors(); +}); + +it('redirects unauthenticated users to login from dashboard', function (): void { + $page = visit('/admin'); + + $page->assertSee('Sign in') + ->assertNoJavascriptErrors(); +}); + +it('redirects unauthenticated users to login from products', function (): void { + $page = visit('/admin/products'); + + $page->assertSee('Sign in') + ->assertNoJavascriptErrors(); +}); + +it('can log out', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('@admin-user-menu') + ->click('@admin-logout-button') + ->assertSee('Sign in'); +}); + +it('can navigate through admin sidebar sections', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Products")') + ->assertSeeIn('h1[data-flux-heading]', 'Products') + ->assertNoJavascriptErrors() + ->click('aside a:has-text("Orders")') + ->assertSeeIn('h1[data-flux-heading]', 'Orders') + ->assertNoJavascriptErrors() + ->click('aside a:has-text("Customers")') + ->assertSeeIn('h1[data-flux-heading]', 'Customers') + ->assertNoJavascriptErrors() + ->click('aside a:has-text("Discounts")') + ->assertSeeIn('h1[data-flux-heading]', 'Discounts') + ->assertNoJavascriptErrors() + ->click('aside a:has-text("Settings")') + ->assertSeeIn('h1[data-flux-heading]', 'Store Settings') + ->assertNoJavascriptErrors(); +}); + +it('can navigate to analytics from sidebar', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Analytics")') + ->assertSeeIn('h1[data-flux-heading]', 'Analytics') + ->assertNoJavascriptErrors(); +}); + +it('can navigate to themes from sidebar', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Themes")') + ->assertSeeIn('h1[data-flux-heading]', 'Themes') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Admin/CollectionManagementTest.php b/tests/Browser/Admin/CollectionManagementTest.php new file mode 100644 index 00000000..b01ced04 --- /dev/null +++ b/tests/Browser/Admin/CollectionManagementTest.php @@ -0,0 +1,40 @@ +navigate('/admin/collections') + ->assertSeeIn('h1[data-flux-heading]', 'Collections') + ->assertSee('T-Shirts') + ->assertSee('New Arrivals') + ->assertNoJavascriptErrors(); +}); + +it('can create a new collection', function (): void { + $page = browserLoginAsAdmin(); + + $page->navigate('/admin/collections') + ->click('@add-collection-button') + ->assertSeeIn('h1[data-flux-heading]', 'Add collection') + ->fill('title', 'E2E Test Collection') + ->fill('@collection-description-input', 'A collection created by the E2E test suite.') + ->click('@save-collection-button') + ->assertSee('Collection saved') + ->assertNoJavascriptErrors(); + + $page->navigate('/admin/collections') + ->assertSee('E2E Test Collection'); +}); + +it('can edit a collection', function (): void { + $page = browserLoginAsAdmin(); + + $page->navigate('/admin/collections') + ->assertSee('T-Shirts') + ->click('T-Shirts') + ->assertSeeIn('h1[data-flux-heading]', 'T-Shirts') + ->fill('@collection-description-input', 'Updated description for T-Shirts collection.') + ->click('@save-collection-button') + ->assertSee('Collection saved') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Admin/CustomerManagementTest.php b/tests/Browser/Admin/CustomerManagementTest.php new file mode 100644 index 00000000..39cd98b9 --- /dev/null +++ b/tests/Browser/Admin/CustomerManagementTest.php @@ -0,0 +1,32 @@ +click('aside a:has-text("Customers")') + ->assertSeeIn('h1[data-flux-heading]', 'Customers') + ->assertSee('customer@acme.test') + ->assertSee('John Doe') + ->assertNoJavascriptErrors(); +}); + +it('shows customer detail with order history', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Customers")') + ->assertSee('John Doe') + ->click('John Doe') + ->assertSeeIn('h1[data-flux-heading]', 'John Doe') + ->assertSee('customer@acme.test') + ->assertSee('#1001') + ->assertNoJavascriptErrors(); +}); + +it('shows customer addresses', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Customers")') + ->click('John Doe') + ->assertSee('Addresses') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Admin/DiscountManagementTest.php b/tests/Browser/Admin/DiscountManagementTest.php new file mode 100644 index 00000000..de5d9b51 --- /dev/null +++ b/tests/Browser/Admin/DiscountManagementTest.php @@ -0,0 +1,79 @@ +click('aside a:has-text("Discounts")') + ->assertSeeIn('h1[data-flux-heading]', 'Discounts') + ->assertSee('WELCOME10') + ->assertSee('FLAT5') + ->assertSee('FREESHIP') + ->assertNoJavascriptErrors(); +}); + +it('can create a new percentage discount code', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Discounts")') + ->click('@create-discount-button') + ->assertSeeIn('h1[data-flux-heading]', 'Create discount') + ->fill('@discount-code-input', 'E2ETEST25') + ->click('@value-type-percent') + ->fill('@discount-value-input', '25') + ->fill('@starts-at-input', '2026-01-01T00:00') + ->fill('@ends-at-input', '2026-12-31T23:59') + ->click('@save-discount-button') + ->assertSee('Discount saved') + ->assertNoJavascriptErrors() + ->click('aside a:has-text("Discounts")') + ->assertSee('E2ETEST25'); +}); + +it('can create a fixed amount discount code', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Discounts")') + ->click('@create-discount-button') + ->fill('@discount-code-input', 'E2EFLAT10') + ->click('@value-type-fixed') + ->fill('@discount-value-input', '10.00') + ->fill('@starts-at-input', '2026-01-01T00:00') + ->click('@save-discount-button') + ->assertSee('Discount saved') + ->assertNoJavascriptErrors(); +}); + +it('can create a free shipping discount code', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Discounts")') + ->click('@create-discount-button') + ->fill('@discount-code-input', 'E2EFREESHIP') + ->click('@value-type-free-shipping') + ->fill('@starts-at-input', '2026-01-01T00:00') + ->click('@save-discount-button') + ->assertSee('Discount saved') + ->assertNoJavascriptErrors(); +}); + +it('can edit a discount', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Discounts")') + ->assertSee('WELCOME10') + ->click('WELCOME10') + ->assertSeeIn('h1[data-flux-heading]', 'WELCOME10') + ->fill('@discount-value-input', '15') + ->click('@save-discount-button') + ->assertSee('Discount saved') + ->assertNoJavascriptErrors(); +}); + +it('shows discount status indicators', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Discounts")') + ->assertVisible('table tr:has-text("WELCOME10") [data-flux-badge]:has-text("Active")') + ->assertVisible('table tr:has-text("EXPIRED20") [data-flux-badge]:has-text("Expired")') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Admin/OrderManagementTest.php b/tests/Browser/Admin/OrderManagementTest.php new file mode 100644 index 00000000..63194e3b --- /dev/null +++ b/tests/Browser/Admin/OrderManagementTest.php @@ -0,0 +1,122 @@ +click('aside a:has-text("Orders")') + ->assertSeeIn('h1[data-flux-heading]', 'Orders') + ->assertSee('#1001') + ->assertNoJavascriptErrors(); +}); + +it('can filter orders by status', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Orders")') + ->click('@order-status-tab-paid') + ->assertSee('#1001') + ->assertNoJavascriptErrors() + ->click('@order-status-tab-fulfilled') + ->assertSee('#1002') + ->assertDontSee('#1001') + ->assertNoJavascriptErrors() + ->click('@order-status-tab-all') + ->assertSee('#1001') + ->assertNoJavascriptErrors(); +}); + +it('shows order detail with line items and totals', function (): void { + $page = browserOpenAdminOrder('#1001'); + + $page->assertSee('#1001') + ->assertSee('Paid') + ->assertSee('Unfulfilled') + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('Subtotal') + ->assertSee('Shipping') + ->assertSee('Tax') + ->assertSee('Total') + ->assertNoJavascriptErrors(); +}); + +it('shows order timeline events', function (): void { + $page = browserOpenAdminOrder('#1001'); + + $page->assertSee('Timeline') + ->assertSee('Order placed') + ->assertNoJavascriptErrors(); +}); + +it('can create a fulfillment', function (): void { + $page = browserOpenAdminOrder('#1001'); + + browserCreateFulfillment($page, '#1001'); + + $page->assertSee('DHL') + ->assertSee('DHL123456789') + ->assertNoJavascriptErrors(); +}); + +it('can process a refund', function (): void { + $page = browserOpenAdminOrder('#1001'); + + $page->click('@refund-button') + ->assertSee('Refund order') + ->fill('@refund-amount-input', '10.00') + ->fill('@refund-reason-input', 'Customer requested partial refund') + ->click('@submit-refund-button') + ->assertSee('Refund processed') + ->assertSee('Partially Refunded') + ->assertNoJavascriptErrors(); +}); + +it('shows customer information in order detail', function (): void { + $page = browserOpenAdminOrder('#1001'); + + $page->assertSee('customer@acme.test') + ->assertNoJavascriptErrors(); +}); + +it('can confirm bank transfer payment', function (): void { + $page = browserOpenAdminOrder('#1005'); + + $page->assertSee('Pending') + ->assertVisible('[data-test="confirm-payment-button"]') + ->click('@confirm-payment-button') + ->assertSee('Payment confirmed') + ->assertSee('Paid') + ->assertNotPresent('[data-test="confirm-payment-button"]') + ->assertNoJavascriptErrors(); +}); + +it('shows fulfillment guard for unpaid order', function (): void { + $page = browserOpenAdminOrder('#1005'); + + $page->assertVisible('[data-test="fulfillment-guard-callout"]') + ->assertSee('Payment must be confirmed before items can be fulfilled') + ->assertNotPresent('[data-test="create-fulfillment-button"]') + ->assertNoJavascriptErrors(); +}); + +it('can mark fulfillment as shipped', function (): void { + $page = browserOpenAdminOrder('#1001'); + + browserCreateFulfillment($page, '#1001'); + + $page->click('Mark as shipped') + ->assertSee('Shipped') + ->assertNoJavascriptErrors(); +}); + +it('can mark fulfillment as delivered', function (): void { + $page = browserOpenAdminOrder('#1001'); + + browserCreateFulfillment($page, '#1001'); + + $page->click('Mark as shipped') + ->assertSee('Shipped') + ->click('Mark as delivered') + ->assertSee('Delivered') + ->assertScript("document.querySelector('[data-test=\"fulfillment-status-badge\"]').textContent.trim()", 'Fulfilled') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Admin/PageManagementTest.php b/tests/Browser/Admin/PageManagementTest.php new file mode 100644 index 00000000..b9a8bbd8 --- /dev/null +++ b/tests/Browser/Admin/PageManagementTest.php @@ -0,0 +1,36 @@ +navigate('/admin/pages') + ->assertSeeIn('h1[data-flux-heading]', 'Pages') + ->assertSee('About') + ->assertNoJavascriptErrors(); +}); + +it('can create a new page', function (): void { + $page = browserLoginAsAdmin(); + + $page->navigate('/admin/pages') + ->click('@add-page-button') + ->assertSeeIn('h1[data-flux-heading]', 'Add page') + ->fill('title', 'FAQ') + ->fill('@page-handle-input', 'faq-e2e') + ->fill('@page-body-input', 'Frequently asked questions content here.') + ->click('@save-page-button') + ->assertSee('Page saved') + ->assertNoJavascriptErrors(); +}); + +it('can edit an existing page', function (): void { + $page = browserLoginAsAdmin(); + + $page->navigate('/admin/pages') + ->assertSee('About') + ->click('About Us') + ->fill('@page-body-input', 'Updated about page content.') + ->click('@save-page-button') + ->assertSee('Page saved') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Admin/ProductManagementTest.php b/tests/Browser/Admin/ProductManagementTest.php new file mode 100644 index 00000000..a84af9c9 --- /dev/null +++ b/tests/Browser/Admin/ProductManagementTest.php @@ -0,0 +1,118 @@ +click('aside a:has-text("Products")') + ->assertSeeIn('h1[data-flux-heading]', 'Products') + ->click('thead button:has-text("Title")') + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('Premium Slim Fit Jeans') + ->assertNoJavascriptErrors(); +}); + +it('can create a new product', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Products")') + ->assertSeeIn('h1[data-flux-heading]', 'Products') + ->click('@add-product-button') + ->assertSeeIn('h1[data-flux-heading]', 'Add product') + ->fill('title', 'Test Product Created by E2E') + ->fill('@product-description-input', 'This product was created by the E2E test suite.') + ->fill('vendor', 'Test Vendor') + ->fill('productType', 'T-Shirts') + ->fill('@variant-price-0', '29.99') + ->fill('[name="variants.0.sku"]', 'E2E-TEST-001') + ->fill('@variant-quantity-0', '50') + ->click('@save-product-button') + ->assertSee('Product saved') + ->assertNoJavascriptErrors() + ->click('aside a:has-text("Products")') + ->assertSee('Test Product Created by E2E'); +}); + +it('can edit an existing product title', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Products")') + ->fill('@product-search', 'Classic Cotton') + ->wait(1) + ->assertSee('Classic Cotton T-Shirt') + ->click('Classic Cotton T-Shirt') + ->assertSeeIn('h1[data-flux-heading]', 'Classic Cotton T-Shirt') + ->fill('title', 'Classic Cotton T-Shirt Updated') + ->click('@save-product-button') + ->assertSee('Product saved') + ->assertNoJavascriptErrors() + ->click('aside a:has-text("Products")') + ->assertSee('Classic Cotton T-Shirt Updated'); +}); + +it('can archive a product', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Products")') + ->click('@add-product-button') + ->fill('title', 'Product To Archive') + ->fill('@variant-price-0', '19.99') + ->fill('[name="variants.0.sku"]', 'E2E-ARCHIVE-001') + ->fill('@variant-quantity-0', '10') + ->click('@save-product-button') + ->assertSee('Product saved') + ->click('aside a:has-text("Products")') + ->assertSee('Product To Archive') + ->click('Product To Archive') + ->select('@product-status-select', 'archived') + ->click('@save-product-button') + ->assertSee('Product saved') + ->click('aside a:has-text("Products")') + ->click('button[role="tab"]:has-text("Active")') + ->assertDontSee('Product To Archive') + ->assertNoJavascriptErrors(); +}); + +it('shows draft products only in admin, not storefront', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Products")') + ->assertSee('Unreleased Winter Jacket') + ->assertSeeIn('table tr:has-text("Unreleased Winter Jacket")', 'Draft') + ->assertNoJavascriptErrors(); + + $page->navigate('/collections/t-shirts') + ->assertSee('T-Shirts') + ->assertDontSee('Unreleased Winter Jacket') + ->assertNoJavascriptErrors(); + + $page->navigate('/search?q=draft') + ->assertDontSee('Unreleased Winter Jacket') + ->assertNoJavascriptErrors(); +}); + +it('can search products in admin', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Products")') + ->fill('@product-search', 'Cotton') + ->wait(1) + ->assertSee('Classic Cotton T-Shirt') + ->assertDontSee('Premium Slim Fit Jeans') + ->assertNoJavascriptErrors(); +}); + +it('can filter products by status in admin', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Products")') + ->click('@product-status-tab-draft') + ->assertVisible('[data-test="product-status-tab-draft"][aria-selected="true"]') + ->assertSee('Unreleased Winter Jacket') + ->assertDontSee('Classic Cotton T-Shirt') + ->assertNoJavascriptErrors() + ->click('@product-status-tab-active') + ->assertVisible('[data-test="product-status-tab-active"][aria-selected="true"]') + ->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..4aacc01d --- /dev/null +++ b/tests/Browser/Admin/SettingsTest.php @@ -0,0 +1,89 @@ +click('aside a:has-text("Settings")') + ->assertSeeIn('h1[data-flux-heading]', 'Store Settings') + ->assertValue('@store-name-input', 'Acme Fashion') + ->assertNoJavascriptErrors(); +}); + +it('can update store name', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Settings")') + ->fill('@store-name-input', 'Acme Fashion Updated') + ->click('@save-general-settings-button') + ->assertSee('Settings saved') + ->assertNoJavascriptErrors(); + + $page->navigate('/admin/settings') + ->assertValue('@store-name-input', 'Acme Fashion Updated'); +}); + +it('can view shipping zones', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Settings")') + ->click('@settings-tab-shipping') + ->assertSee('Domestic') + ->assertSee('Standard Shipping') + ->assertSee('4.99') + ->assertNoJavascriptErrors(); +}); + +it('can add a new shipping rate to existing zone', function (): void { + $domesticZoneId = ShippingZone::query() + ->withoutGlobalScopes() + ->whereRelation('store', 'handle', 'acme-fashion') + ->where('name', 'Domestic') + ->firstOrFail() + ->getKey(); + + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Settings")') + ->click('@settings-tab-shipping') + ->assertSee('Domestic') + ->click('@add-rate-'.$domesticZoneId) + ->assertSee('Add shipping rate') + ->fill('@rate-name-input', 'Overnight Shipping') + ->fill('@rate-flat-amount-input', '14.99') + ->click('@save-rate-button') + ->assertSee('Shipping rate saved') + ->assertSee('Overnight Shipping') + ->assertSee('14.99') + ->assertNoJavascriptErrors(); +}); + +it('can view tax settings', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Settings")') + ->click('@settings-tab-taxes') + ->assertSee('Tax Settings') + ->assertNoJavascriptErrors(); +}); + +it('can update tax inclusion setting', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Settings")') + ->click('@settings-tab-taxes') + ->click('@prices-include-tax-switch') + ->click('@save-tax-settings-button') + ->assertSee('Tax settings saved') + ->assertNoJavascriptErrors(); +}); + +it('can view domain settings', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Settings")') + ->click('@settings-tab-domains') + ->assertSee('acme-fashion.test') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/SmokeTest.php b/tests/Browser/SmokeTest.php new file mode 100644 index 00000000..99e3b907 --- /dev/null +++ b/tests/Browser/SmokeTest.php @@ -0,0 +1,80 @@ +assertSee('Acme Fashion') + ->assertNoJavascriptErrors(); +}); + +it('loads a collection page', function (): void { + $page = visit('/collections/t-shirts'); + + $page->assertSee('T-Shirts') + ->assertNoJavascriptErrors(); +}); + +it('loads a product page', function (): void { + $page = visit('/products/classic-cotton-t-shirt'); + + $page->assertSee('Classic Cotton T-Shirt') + ->assertSee('24.99') + ->assertNoJavascriptErrors(); +}); + +it('loads the cart page', function (): void { + $page = visit('/cart'); + + $page->assertSee('Your Cart') + ->assertNoJavascriptErrors(); +}); + +it('loads the customer login page', function (): void { + $page = visit('/account/login'); + + $page->assertSee('Log in') + ->assertNoJavascriptErrors(); +}); + +it('loads the admin login page', function (): void { + $page = visit('/admin/login'); + + $page->assertSee('Sign in') + ->assertNoJavascriptErrors(); +}); + +it('loads the about page', function (): void { + $page = visit('/pages/about'); + + $page->assertSee('About') + ->assertNoJavascriptErrors(); +}); + +it('loads the search page', function (): void { + $page = visit('/search?q=shirt'); + + $page->assertSee('shirt') + ->assertNoJavascriptErrors(); +}); + +it('loads all collections listing', function (): void { + $page = visit('/collections'); + + $page->assertSee('Collections') + ->assertNoJavascriptErrors(); +}); + +it('has no errors on critical pages', function (): void { + $pages = visit([ + '/', + '/collections/new-arrivals', + '/products/classic-cotton-t-shirt', + '/cart', + '/account/login', + '/admin/login', + '/pages/about', + '/search?q=shirt', + ]); + + $pages->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Storefront/AccessibilityTest.php b/tests/Browser/Storefront/AccessibilityTest.php new file mode 100644 index 00000000..117757f0 --- /dev/null +++ b/tests/Browser/Storefront/AccessibilityTest.php @@ -0,0 +1,124 @@ +assertNoJavascriptErrors() + ->assertNoConsoleLogs(); +}); + +it('home page has proper heading hierarchy', function (): void { + $page = visit('/'); + + $page->assertScript("document.querySelectorAll('h1').length", 1) + ->assertSee('Acme Fashion') + ->assertNoJavascriptErrors(); +}); + +it('product page has proper ARIA labels for variant selector', function (): void { + $page = visit('/products/classic-cotton-t-shirt'); + + $page->assertSee('Size') + ->assertSee('Color') + ->assertVisible('button:has-text("Add to cart")') + ->assertNoJavascriptErrors(); +}); + +it('product page images have alt text', function (): void { + $page = visit('/products/classic-cotton-t-shirt'); + + $page->assertScript( + "Array.from(document.querySelectorAll('img')).every((img) =>" + ." img.closest('[aria-hidden=\"true\"]') !== null" + ." || img.closest('button[aria-label]') !== null" + ." || img.alt.trim() !== '')" + ) + ->assertNoJavascriptErrors(); +}); + +it('customer login form has accessible labels', function (): void { + $page = visit('/account/login'); + + $page->assertSee('Email') + ->assertSee('Password') + ->assertScript( + "Array.from(document.querySelectorAll('form input')).every((input) =>" + ." input.type === 'hidden'" + ." || input.getAttribute('aria-label') !== null" + ." || input.getAttribute('aria-labelledby') !== null" + .' || (input.labels !== null && input.labels.length > 0))' + ) + ->assertNoJavascriptErrors(); +}); + +it('admin login form has accessible labels', function (): void { + $page = visit('/admin/login'); + + $page->assertSee('Email') + ->assertSee('Password') + ->assertScript( + "Array.from(document.querySelectorAll('form input')).every((input) =>" + ." input.type === 'hidden'" + ." || input.getAttribute('aria-label') !== null" + ." || input.getAttribute('aria-labelledby') !== null" + .' || (input.labels !== null && input.labels.length > 0))' + ) + ->assertNoJavascriptErrors(); +}); + +it('checkout form has accessible labels', function (): void { + $page = browserAddClassicTeeToCart(); + + $page->navigate('/cart') + ->click('Checkout') + ->assertSee('Contact information') + ->assertSee('Email') + ->assertScript( + "Array.from(document.querySelectorAll('form input')).every((input) =>" + ." input.type === 'hidden'" + ." || input.getAttribute('aria-label') !== null" + ." || input.getAttribute('aria-labelledby') !== null" + .' || (input.labels !== null && input.labels.length > 0))' + ) + ->assertNoJavascriptErrors(); +}); + +it('checkout validation errors are accessible', function (): void { + $page = browserAddClassicTeeToCart(); + + $page->navigate('/cart') + ->click('Checkout') + ->assertSee('Contact information') + ->click('Continue') + ->assertSee('The email field is required') + ->assertAttribute('[id="checkout-email"]', 'aria-describedby', 'checkout-email-error') + ->assertNoJavascriptErrors(); +}); + +it('can navigate storefront with keyboard only', function (): void { + $page = visit('/'); + + $page->keys('body:first-of-type', ['Tab']) + ->assertScript("['A', 'BUTTON', 'INPUT'].includes(document.activeElement.tagName)") + ->keys('nav a:visible:has-text("T-Shirts")', ['Enter']) + ->assertPathIs('/collections/t-shirts') + ->assertSee('T-Shirts') + ->assertNoJavascriptErrors(); +}); + +it('cart page has no console errors or warnings', function (): void { + $page = visit('/cart'); + + $page->assertNoJavascriptErrors() + ->assertNoConsoleLogs(); +}); + +it('search page has proper form labels', function (): void { + $page = visit('/search?q=shirt'); + + $page->assertScript( + "(() => { const input = document.querySelector('input[type=\"search\"]');" + ." return input !== null && (input.getAttribute('aria-label') !== null || (input.labels !== null && input.labels.length > 0)); })()" + ) + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Storefront/BrowsingTest.php b/tests/Browser/Storefront/BrowsingTest.php new file mode 100644 index 00000000..49c73f55 --- /dev/null +++ b/tests/Browser/Storefront/BrowsingTest.php @@ -0,0 +1,130 @@ +assertSee('Acme Fashion') + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('24.99') + ->assertNoJavascriptErrors(); +}); + +it('shows collection with product grid', function (): void { + $page = visit('/collections/t-shirts'); + + $page->assertSee('T-Shirts') + ->assertSee('Classic Cotton T-Shirt') + ->assertNoJavascriptErrors(); +}); + +it('can navigate from collection to product', function (): void { + $page = visit('/collections/t-shirts'); + + $page->click('Classic Cotton T-Shirt') + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('24.99') + ->assertSee('Add to cart') + ->assertNoJavascriptErrors(); +}); + +it('shows product detail with variant options', function (): void { + $page = visit('/products/classic-cotton-t-shirt'); + + $page->assertSee('Classic Cotton T-Shirt') + ->assertSee('24.99') + ->assertSee('Size') + ->assertSee('Color') + ->assertNoJavascriptErrors(); +}); + +it('shows size and color option values', function (): void { + $page = visit('/products/classic-cotton-t-shirt'); + + $page->assertSee('S') + ->assertSee('M') + ->assertSee('L') + ->assertSee('XL') + ->assertVisible('label[title="Black"]') + ->assertVisible('label[title="White"]') + ->assertVisible('label[title="Navy"]') + ->assertNoJavascriptErrors(); +}); + +it('updates price when variant changes on product with compare-at pricing', function (): void { + $page = visit('/products/premium-slim-fit-jeans'); + + $page->assertSee('Premium Slim Fit Jeans') + ->click('32') + ->click('label[title="Blue"]') + ->assertSee('79.99') + ->assertVisible('s:has-text("99.99")') + ->assertNoJavascriptErrors(); +}); + +it('shows search results for valid query', function (): void { + $page = visit('/search?q=cotton'); + + $page->assertSee('Classic Cotton T-Shirt') + ->assertNoJavascriptErrors(); +}); + +it('shows no results message for invalid query', function (): void { + $page = visit('/search?q=zznonexistentproductzz'); + + $page->assertSee('No results') + ->assertNoJavascriptErrors(); +}); + +it('does not show draft products on storefront collections', function (): void { + $page = visit('/collections'); + + $page->assertSee('Collections') + ->assertDontSee('Unreleased Winter Jacket') + ->assertNoJavascriptErrors(); +}); + +it('does not show draft products in search results', function (): void { + $page = visit('/search?q=draft'); + + $page->assertDontSee('Unreleased Winter Jacket') + ->assertNoJavascriptErrors(); +}); + +it('shows out of stock messaging for deny-policy product', function (): void { + $page = visit('/products/limited-edition-sneakers'); + + $page->assertSee('Sold out') + ->assertDontSee('Add to cart') + ->assertNoJavascriptErrors(); +}); + +it('shows backorder messaging for continue-policy product', function (): void { + $page = visit('/products/backorder-denim-jacket'); + + $page->assertSee('Available on backorder') + ->assertButtonEnabled('Add to cart') + ->assertNoJavascriptErrors(); +}); + +it('shows new arrivals collection', function (): void { + $page = visit('/collections/new-arrivals'); + + $page->assertSee('New Arrivals') + ->assertNoJavascriptErrors(); +}); + +it('shows static about page', function (): void { + $page = visit('/pages/about'); + + $page->assertSee('About') + ->assertNoJavascriptErrors(); +}); + +it('navigates between pages using the main navigation', function (): void { + $page = visit('/'); + + $page->click('nav a:visible:has-text("T-Shirts")') + ->assertPathIs('/collections/t-shirts') + ->assertSee('T-Shirts') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Storefront/CartTest.php b/tests/Browser/Storefront/CartTest.php new file mode 100644 index 00000000..3d7c8e6f --- /dev/null +++ b/tests/Browser/Storefront/CartTest.php @@ -0,0 +1,129 @@ +assertSee('Classic Cotton T-Shirt') + ->assertSee('24.99') + ->assertNoJavascriptErrors(); +}); + +it('can view cart with added item', function (): void { + $page = browserAddClassicTeeToCart(); + + $page->navigate('/cart') + ->assertSee('Your Cart') + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('24.99') + ->assertNoJavascriptErrors(); +}); + +it('can update quantity in cart', function (): void { + $page = browserAddClassicTeeToCart(); + + $page->navigate('/cart') + ->assertSee('Classic Cotton T-Shirt') + ->click('table [aria-label="Increase quantity"]') + ->assertSee('49.98') + ->assertNoJavascriptErrors(); +}); + +it('can remove item from cart', function (): void { + $page = browserAddClassicTeeToCart(); + + $page->navigate('/cart') + ->assertSee('Classic Cotton T-Shirt') + ->click('table [aria-label^="Remove"]') + ->assertSee('Your cart is empty') + ->assertNoJavascriptErrors(); +}); + +it('can add multiple different products', function (): void { + $page = browserAddClassicTeeToCart(); + + $page->navigate('/products/premium-slim-fit-jeans') + ->assertSee('Premium Slim Fit Jeans') + ->click('32') + ->click('label[title="Blue"]') + ->click('Add to cart') + ->assertSee('Added to cart'); + + $page->navigate('/cart') + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('Premium Slim Fit Jeans') + ->assertNoJavascriptErrors(); +}); + +it('can apply valid discount code WELCOME10', function (): void { + $page = browserAddClassicTeeToCart(); + + $page->navigate('/cart') + ->assertSee('24.99') + ->fill('cart-discount-code', 'WELCOME10') + ->click('Apply') + ->assertSee('WELCOME10') + ->assertSee('Discount') + ->assertSee('2.50') + ->assertNoJavascriptErrors(); +}); + +it('shows error for invalid discount code', function (): void { + $page = browserAddClassicTeeToCart(); + + $page->navigate('/cart') + ->fill('cart-discount-code', 'INVALID') + ->click('Apply') + ->assertSee('Invalid discount code') + ->assertNoJavascriptErrors(); +}); + +it('shows error for expired discount code', function (): void { + $page = browserAddClassicTeeToCart(); + + $page->navigate('/cart') + ->fill('cart-discount-code', 'EXPIRED20') + ->click('Apply') + ->assertSee('expired') + ->assertNoJavascriptErrors(); +}); + +it('shows error for maxed out discount code', function (): void { + $page = browserAddClassicTeeToCart(); + + $page->navigate('/cart') + ->fill('cart-discount-code', 'MAXED') + ->click('Apply') + ->assertSee('usage limit') + ->assertNoJavascriptErrors(); +}); + +it('can apply free shipping discount', function (): void { + $page = browserAddClassicTeeToCart(); + + $page->navigate('/cart') + ->fill('cart-discount-code', 'FREESHIP') + ->click('Apply') + ->assertSee('FREESHIP') + ->assertSee('(Free shipping)') + ->assertNoJavascriptErrors(); +}); + +it('can apply FLAT5 discount for fixed amount off', function (): void { + $page = browserAddClassicTeeToCart(); + + $page->navigate('/cart') + ->fill('cart-discount-code', 'FLAT5') + ->click('Apply') + ->assertSee('FLAT5') + ->assertSee('5.00') + ->assertNoJavascriptErrors(); +}); + +it('shows subtotal and total in cart', function (): void { + $page = browserAddClassicTeeToCart(); + + $page->navigate('/cart') + ->assertSee('Subtotal') + ->assertSee('24.99') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Storefront/CheckoutTest.php b/tests/Browser/Storefront/CheckoutTest.php new file mode 100644 index 00000000..df68fd19 --- /dev/null +++ b/tests/Browser/Storefront/CheckoutTest.php @@ -0,0 +1,205 @@ +assertRadioSelected('payment-method', 'credit_card') + ->fill('card-number', '4242 4242 4242 4242') + ->fill('card-name', 'Test Buyer') + ->fill('card-expiry', '12/28') + ->fill('card-cvc', '123') + ->assertSee('29.98') + ->click('button:has-text("Pay now")') + ->assertSee('Thank you'); + + $order = Order::query()->latest('id')->firstOrFail(); + + expect($order->order_number)->toStartWith('#'); + + $page->assertSee($order->order_number) + ->assertNoJavascriptErrors(); +}); + +it('shows shipping methods based on German address', function (): void { + $page = browserAddClassicTeeToCart(); + + $page->navigate('/cart') + ->click('Checkout') + ->assertSee('Contact information') + ->fill('checkout-email', 'test@example.com') + ->click('Continue'); + + browserFillCheckoutAddress($page, 'Hans', 'Mueller', 'Berliner Str. 10', 'Munich', '80331', 'DE'); + + $page->assertSee('Standard Shipping') + ->assertSee('4.99') + ->assertNoJavascriptErrors(); +}); + +it('shows international shipping methods for non-DE address', function (): void { + $page = browserAddClassicTeeToCart(); + + $page->navigate('/cart') + ->click('Checkout') + ->assertSee('Contact information') + ->fill('checkout-email', 'test@example.com') + ->click('Continue'); + + browserFillCheckoutAddress($page, 'John', 'Smith', '123 Main St', 'New York', '10001', 'US'); + + $page->assertSee('International') + ->assertDontSee('Standard Shipping') + ->assertNoJavascriptErrors(); +}); + +it('applies discount during checkout', function (): void { + $page = browserAddClassicTeeToCart(); + + $page->navigate('/cart') + ->fill('cart-discount-code', 'FLAT5') + ->click('Apply') + ->assertSee('FLAT5') + ->click('[aria-label="Close cart"]') + ->click('Checkout') + ->assertSee('Contact information') + ->fill('checkout-email', 'test@example.com') + ->click('Continue'); + + browserFillCheckoutAddress($page, 'Test', 'User', 'Teststr 1', 'Berlin', '10115', 'DE'); + + $page->assertSee('Standard Shipping') + ->click('Standard Shipping') + ->click('Continue') + ->assertSee('Select a payment method') + ->assertSee('FLAT5') + ->assertSee('5.00') + ->assertSee('24.98') + ->assertNoJavascriptErrors(); +}); + +it('validates required contact email', function (): void { + $page = browserAddClassicTeeToCart(); + + $page->navigate('/cart') + ->click('Checkout') + ->assertSee('Contact information') + ->click('Continue') + ->assertSee('The email field is required') + ->assertNoJavascriptErrors(); +}); + +it('validates required shipping address fields', function (): void { + $page = browserAddClassicTeeToCart(); + + $page->navigate('/cart') + ->click('Checkout') + ->assertSee('Contact information') + ->fill('checkout-email', 'test@example.com') + ->click('Continue') + ->assertSee('First name') + ->click('Continue') + ->assertSee('The first name field is required') + ->assertSee('The last name field is required') + ->assertSee('The address field is required') + ->assertSee('The city field is required') + ->assertSee('The postal code field is required') + ->assertSee('The country field is required') + ->assertNoJavascriptErrors(); +}); + +it('validates invalid postal code format', function (): void { + $page = browserAddClassicTeeToCart(); + + $page->navigate('/cart') + ->click('Checkout') + ->assertSee('Contact information') + ->fill('checkout-email', 'test@example.com') + ->click('Continue'); + + browserFillCheckoutAddress($page, 'Test', 'User', 'Teststr 1', 'Berlin', 'INVALID', 'DE'); + + $page->assertSee('The postal code format is invalid') + ->assertNoJavascriptErrors(); +}); + +it('prevents checkout with empty cart', function (): void { + $page = visit('/cart'); + + $page->assertSee('Your cart is empty') + ->assertDontSee('Checkout') + ->assertNoJavascriptErrors(); +}); + +it('completes checkout with PayPal', function (): void { + $page = browserReachCheckoutPaymentStep(); + + $page->click('PayPal') + ->click('button:has-text("Pay with PayPal")') + ->assertSee('Thank you') + ->assertSee('Payment method') + ->assertSee('PayPal') + ->assertNoJavascriptErrors(); +}); + +it('completes checkout with bank transfer', function (): void { + $page = browserReachCheckoutPaymentStep(); + + $page->click('Bank Transfer') + ->click('button:has-text("Place order")') + ->assertSee('Thank you') + ->assertSee('Bank Transfer Instructions') + ->assertSee('IBAN') + ->assertSee('DE89 3704 0044 0532 0130 00') + ->assertSee('BIC') + ->assertSee('COBADEFFXXX') + ->assertSee('Reference'); + + $order = Order::query()->latest('id')->firstOrFail(); + + $page->assertSee($order->order_number) + ->assertNoJavascriptErrors(); +}); + +it('shows error for declined credit card', function (): void { + $page = browserReachCheckoutPaymentStep(); + + $page->assertRadioSelected('payment-method', 'credit_card') + ->fill('card-number', '4000 0000 0000 0002') + ->fill('card-name', 'Test Buyer') + ->fill('card-expiry', '12/28') + ->fill('card-cvc', '123') + ->click('button:has-text("Pay now")') + ->assertSee('declined') + ->assertPathIs('/checkout') + ->assertNoJavascriptErrors(); +}); + +it('shows error for insufficient funds', function (): void { + $page = browserReachCheckoutPaymentStep(); + + $page->fill('card-number', '4000 0000 0000 9995') + ->fill('card-name', 'Test Buyer') + ->fill('card-expiry', '12/28') + ->fill('card-cvc', '123') + ->click('button:has-text("Pay now")') + ->assertSee('insufficient') + ->assertPathIs('/checkout') + ->assertNoJavascriptErrors(); +}); + +it('switches between payment method forms', function (): void { + $page = browserReachCheckoutPaymentStep(); + + $page->assertRadioSelected('payment-method', 'credit_card') + ->assertVisible('#card-number') + ->assertVisible('#card-name') + ->click('PayPal') + ->assertNotPresent('#card-number') + ->assertVisible('button:has-text("Pay with PayPal")') + ->click('Bank Transfer') + ->assertVisible('button:has-text("Place order")') + ->assertSee('you will receive bank transfer instructions') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Storefront/CustomerAccountTest.php b/tests/Browser/Storefront/CustomerAccountTest.php new file mode 100644 index 00000000..bda99961 --- /dev/null +++ b/tests/Browser/Storefront/CustomerAccountTest.php @@ -0,0 +1,140 @@ +assertSee('Create an account') + ->fill('name', 'New Customer') + ->fill('email', 'new-customer-e2e@example.com') + ->fill('password', 'password123') + ->fill('password_confirmation', 'password123') + ->click('@customer-register-button') + ->assertSee('My Account') + ->assertNoJavascriptErrors(); +}); + +it('shows validation errors for duplicate email registration', function (): void { + $page = visit('/account/register'); + + $page->fill('name', 'Duplicate Customer') + ->fill('email', 'customer@acme.test') + ->fill('password', 'password123') + ->fill('password_confirmation', 'password123') + ->click('@customer-register-button') + ->assertSee('already been taken') + ->assertNoJavascriptErrors(); +}); + +it('shows validation errors for mismatched passwords', function (): void { + $page = visit('/account/register'); + + $page->fill('name', 'Test Customer') + ->fill('email', 'mismatch@example.com') + ->fill('password', 'password123') + ->fill('password_confirmation', 'different456') + ->click('@customer-register-button') + ->assertSee('password') + ->assertSee('does not match') + ->assertNoJavascriptErrors(); +}); + +it('can log in as existing customer', function (): void { + $page = browserLoginAsCustomer(); + + $page->assertSee('My Account') + ->assertSee('John Doe') + ->assertNoJavascriptErrors(); +}); + +it('shows error for invalid customer credentials', function (): void { + $page = visit('/account/login'); + + $page->fill('email', 'customer@acme.test') + ->fill('password', 'wrongpassword') + ->click('@customer-login-button') + ->assertSee('Invalid credentials') + ->assertNoJavascriptErrors(); +}); + +it('redirects unauthenticated customers to login', function (): void { + $page = visit('/account'); + + $page->assertSee('Log in') + ->assertNoJavascriptErrors(); +}); + +it('shows order history for logged-in customer', function (): void { + $page = browserLoginAsCustomer(); + + $page->click('Orders') + ->assertSee('Order History') + ->assertSee('#1001') + ->assertSee('#1002') + ->assertSee('#1004') + ->assertNoJavascriptErrors(); +}); + +it('shows order detail for customer order', function (): void { + $page = browserLoginAsCustomer(); + + $page->click('Orders') + ->assertSee('Order History') + ->click('a:visible:has-text("#1001")') + ->assertSee('#1001') + ->assertSee('Subtotal') + ->assertSee('Total') + ->assertNoJavascriptErrors(); +}); + +it('can view addresses', function (): void { + $page = browserLoginAsCustomer(); + + $page->click('Addresses') + ->assertSee('Your Addresses') + ->assertSee('Hauptstrasse 1') + ->assertSee('Friedrichstrasse 100') + ->assertNoJavascriptErrors(); +}); + +it('can add a new address', function (): void { + $page = browserLoginAsCustomer(); + + $page->click('Addresses') + ->assertSee('Your Addresses') + ->click('@add-address-button') + ->assertSee('Add new address') + ->fill('[id="form.first_name"]', 'John') + ->fill('[id="form.last_name"]', 'Doe') + ->fill('[id="form.address1"]', 'New Street 42') + ->fill('[id="form.city"]', 'Hamburg') + ->fill('[id="form.postal_code"]', '20095') + ->select('[id="form.country_code"]', 'DE') + ->click('@save-address-button') + ->assertSee('Address saved') + ->assertSee('New Street 42') + ->assertSee('Hamburg') + ->assertNoJavascriptErrors(); +}); + +it('can edit an existing address', function (): void { + $page = browserLoginAsCustomer(); + + $page->click('Addresses') + ->assertSee('Your Addresses') + ->click('ul[role="list"] li:first-child button:has-text("Edit")') + ->assertSee('Edit address') + ->fill('[id="form.city"]', 'Frankfurt') + ->click('@save-address-button') + ->assertSee('Address saved') + ->assertSee('Frankfurt') + ->assertNoJavascriptErrors(); +}); + +it('can log out', function (): void { + $page = browserLoginAsCustomer(); + + $page->assertSee('My Account') + ->click('@customer-logout-button') + ->assertSee('Log in') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Storefront/InventoryTest.php b/tests/Browser/Storefront/InventoryTest.php new file mode 100644 index 00000000..64b883ea --- /dev/null +++ b/tests/Browser/Storefront/InventoryTest.php @@ -0,0 +1,53 @@ +assertSee('Sold out') + ->assertDontSee('Add to cart') + ->assertButtonDisabled('button:has-text("Sold out")') + ->assertNoJavascriptErrors(); +}); + +it('allows add-to-cart for out-of-stock continue-policy product', function (): void { + $page = visit('/products/backorder-denim-jacket'); + + $page->assertSee('Available on backorder') + ->assertButtonEnabled('Add to cart') + ->click('Add to cart') + ->assertSee('Added to cart'); + + $page->navigate('/cart') + ->assertSee('Backorder Denim Jacket') + ->assertNoJavascriptErrors(); +}); + +it('shows correct stock status for in-stock product', function (): void { + $page = visit('/products/classic-cotton-t-shirt'); + + $page->assertButtonEnabled('Add to cart') + ->assertDontSee('Sold out') + ->assertDontSee('Available on backorder') + ->assertNoJavascriptErrors(); +}); + +it('prevents adding more than available stock for deny-policy product', function (): void { + // The seeded M / Black variant has exactly 15 units on hand (deny policy). + $page = visit('/products/classic-cotton-t-shirt'); + + $page->assertSee('Classic Cotton T-Shirt') + ->click('M') + ->click('label[title="Black"]') + ->click('input[aria-label="Quantity"]') + ->keys('input[aria-label="Quantity"]', ['Backspace']) + ->type('input[aria-label="Quantity"]', '15') + ->assertScript("Alpine.\$data(document.querySelector('input[aria-label=\"Quantity\"]')).quantity", 15) + ->click('Add to cart') + ->assertSee('Added to cart'); + + $page->navigate('/cart') + ->assertSee('Classic Cotton T-Shirt') + ->click('table [aria-label="Increase quantity"]') + ->assertSee('Not enough stock available') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Storefront/ResponsiveTest.php b/tests/Browser/Storefront/ResponsiveTest.php new file mode 100644 index 00000000..6a38a30a --- /dev/null +++ b/tests/Browser/Storefront/ResponsiveTest.php @@ -0,0 +1,106 @@ +resize(375, 812); + + $page->assertSee('Acme Fashion') + ->assertVisible('button[aria-label="Open navigation menu"]') + ->assertMissing('nav[aria-label="Main navigation"]') + ->assertScript('document.documentElement.scrollWidth <= window.innerWidth + 1') + ->assertNoJavascriptErrors(); +}); + +it('product page stacks layout on mobile', function (): void { + $page = visit('/products/classic-cotton-t-shirt')->resize(375, 812); + + $page->assertSee('Classic Cotton T-Shirt') + ->assertSee('24.99') + ->assertSee('Add to cart') + ->assertScript( + "document.querySelector('section[aria-label=\"Product images\"]').getBoundingClientRect().bottom" + ." <= document.querySelector('section[aria-label=\"Product information\"]').getBoundingClientRect().top + 1" + ) + ->assertNoJavascriptErrors(); +}); + +it('can add to cart on mobile', function (): void { + $page = visit('/products/classic-cotton-t-shirt')->resize(375, 812); + + $page->click('M') + ->click('label[title="Black"]') + ->click('Add to cart') + ->assertSee('Added to cart') + ->assertNoJavascriptErrors(); +}); + +it('cart page works on mobile', function (): void { + $page = visit('/products/classic-cotton-t-shirt')->resize(375, 812); + + $page->click('M') + ->click('label[title="Black"]') + ->click('Add to cart') + ->assertSee('Added to cart'); + + $page->navigate('/cart') + ->assertSee('Classic Cotton T-Shirt') + ->assertVisible('a:visible:has-text("Checkout")') + ->assertNoJavascriptErrors(); +}); + +it('checkout flow works on mobile', function (): void { + $page = visit('/products/classic-cotton-t-shirt')->resize(375, 812); + + $page->click('M') + ->click('label[title="Black"]') + ->click('Add to cart') + ->assertSee('Added to cart'); + + $page->navigate('/cart') + ->click('a:visible:has-text("Checkout")') + ->assertSee('Contact information') + ->fill('checkout-email', 'mobile@example.com') + ->click('Continue'); + + browserFillCheckoutAddress($page, 'Mobile', 'User', 'Mobile Str 1', 'Berlin', '10115', 'DE'); + + $page->assertSee('Standard Shipping') + ->assertScript('document.documentElement.scrollWidth <= window.innerWidth + 1') + ->assertNoJavascriptErrors(); +}); + +it('admin login works on tablet viewport', function (): void { + $page = visit('/admin/login')->resize(768, 1024); + + $page->fill('email', 'admin@acme.test') + ->fill('password', 'password') + ->click('@admin-login-button') + ->assertSee('Dashboard') + ->assertNoJavascriptErrors(); +}); + +it('admin sidebar navigation works on tablet', function (): void { + $page = visit('/admin/login')->resize(768, 1024); + + $page->fill('email', 'admin@acme.test') + ->fill('password', 'password') + ->click('@admin-login-button') + ->assertSee('Dashboard'); + + $page->click('button[aria-label="Open sidebar"]') + ->click('aside a:has-text("Products")') + ->assertSeeIn('h1[data-flux-heading]', 'Products') + ->assertNoJavascriptErrors() + ->click('button[aria-label="Open sidebar"]') + ->click('aside a:has-text("Orders")') + ->assertSeeIn('h1[data-flux-heading]', 'Orders') + ->assertNoJavascriptErrors(); +}); + +it('collection page works on mobile with filters', function (): void { + $page = visit('/collections/t-shirts')->resize(375, 812); + + $page->assertSee('T-Shirts') + ->assertSee('Classic Cotton T-Shirt') + ->assertVisible('button:visible:has-text("Filter")') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/Storefront/TenantIsolationTest.php b/tests/Browser/Storefront/TenantIsolationTest.php new file mode 100644 index 00000000..8f5f2635 --- /dev/null +++ b/tests/Browser/Storefront/TenantIsolationTest.php @@ -0,0 +1,82 @@ +assertSee('Acme Fashion') + ->assertSee('Classic Cotton T-Shirt') + ->assertDontSee('Pro Laptop 15') + ->assertDontSee('Wireless Headphones') + ->assertNoJavascriptErrors(); + + // Re-point the hostname at Acme Electronics and verify the reverse + // direction through the real ResolveStore middleware: store 2 never + // shows store 1 data. + switchBrowserTestDomainToStore('acme-electronics'); + + $electronicsPage = visit('/'); + + $electronicsPage->assertSee('Acme Electronics') + ->assertDontSee('Classic Cotton T-Shirt') + ->assertDontSee('Acme Fashion') + ->assertNoJavascriptErrors(); +}); + +it('store 1 collections only contain store 1 products', function (): void { + $page = visit('/collections/t-shirts'); + + $page->assertSee('T-Shirts') + ->assertSee('Classic Cotton T-Shirt') + ->assertDontSee('Pro Laptop 15') + ->assertDontSee('Wireless Headphones') + ->assertDontSee('Mechanical Keyboard') + ->assertNoJavascriptErrors(); +}); + +it('admin cannot access other store data', function (): void { + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Products")') + ->assertSeeIn('h1[data-flux-heading]', 'Products') + ->assertDontSee('Pro Laptop 15') + ->assertDontSee('Wireless Headphones') + ->fill('@product-search', 'Laptop') + ->wait(1) + ->assertSee('No products match your filters.') + ->assertNoJavascriptErrors(); + + $page->click('aside a:has-text("Orders")') + ->assertSeeIn('h1[data-flux-heading]', 'Orders') + ->assertSee('#1001') + ->assertDontSee('#5001') + ->fill('@order-search', '#5001') + ->wait(1) + ->assertSee('No orders match your filters.') + ->assertNoJavascriptErrors(); +}); + +it('search only returns current store products', function (): void { + $page = visit('/search?q=product'); + + $page->assertDontSee('Pro Laptop 15') + ->assertDontSee('Wireless Headphones') + ->assertNoJavascriptErrors(); + + $page->navigate('/search?q=laptop') + ->assertSee('No results') + ->assertDontSee('Pro Laptop 15') + ->assertNoJavascriptErrors(); +}); + +it('customer accounts are scoped to their store', function (): void { + $page = browserLoginAsCustomer(); + + $page->click('Orders') + ->assertSee('Order History') + ->assertSee('#1001') + ->assertSee('#1002') + ->assertSee('#1004') + ->assertDontSee('#5001') + ->assertDontSee('#5002') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Feature/Admin/AnalyticsTest.php b/tests/Feature/Admin/AnalyticsTest.php new file mode 100644 index 00000000..c2e9a7b2 --- /dev/null +++ b/tests/Feature/Admin/AnalyticsTest.php @@ -0,0 +1,98 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; +}); + +it('renders the analytics page', function () { + actingAsAdmin($this->user) + ->get('/admin/analytics') + ->assertOk() + ->assertSee('Analytics') + ->assertSee('Conversion funnel') + ->assertSee('Top products') + ->assertSee('Top referrers'); +}); + +it('shows KPIs from pre-aggregated daily metrics', function () { + AnalyticsDaily::factory()->for($this->store)->onDate(now()->subDay()->toDateString())->create([ + 'orders_count' => 4, + 'revenue_amount' => 20000, + 'visits_count' => 100, + ]); + AnalyticsDaily::factory()->for($this->store)->onDate(now()->subDays(2)->toDateString())->create([ + 'orders_count' => 6, + 'revenue_amount' => 30000, + 'visits_count' => 100, + ]); + + actingAsAdmin($this->user); + + Livewire::test(AnalyticsIndex::class) + ->assertViewHas('ordersCount', 10) + ->assertViewHas('totalSales', 50000) + ->assertViewHas('averageOrderValue', 5000) + ->assertViewHas('conversionRate', 5.0) + ->assertSee('500.00 EUR'); +}); + +it('filters metrics by date range', function () { + AnalyticsDaily::factory()->for($this->store)->onDate(now()->subDays(2)->toDateString())->create([ + 'orders_count' => 3, + 'revenue_amount' => 9000, + ]); + AnalyticsDaily::factory()->for($this->store)->onDate(now()->subDays(20)->toDateString())->create([ + 'orders_count' => 5, + 'revenue_amount' => 25000, + ]); + + actingAsAdmin($this->user); + + Livewire::test(AnalyticsIndex::class) + ->assertViewHas('ordersCount', 8) + ->set('dateRange', 'last_7_days') + ->assertViewHas('ordersCount', 3) + ->assertViewHas('totalSales', 9000); +}); + +it('builds the conversion funnel from raw events', function () { + AnalyticsEvent::factory()->count(10)->pageView()->for($this->store)->create(['created_at' => now()->subDay()]); + AnalyticsEvent::factory()->count(6)->productView()->for($this->store)->create(['created_at' => now()->subDay()]); + AnalyticsEvent::factory()->count(4)->addToCart()->for($this->store)->create(['created_at' => now()->subDay()]); + AnalyticsEvent::factory()->count(2)->for($this->store)->create(['type' => 'checkout_started', 'created_at' => now()->subDay()]); + AnalyticsEvent::factory()->for($this->store)->create(['type' => 'checkout_completed', 'created_at' => now()->subDay()]); + + actingAsAdmin($this->user); + + Livewire::test(AnalyticsIndex::class) + ->assertViewHas('funnel', function (array $funnel): bool { + return array_column($funnel, 'count') === [10, 6, 4, 2, 1]; + }); +}); + +it('allows owner, admin, and staff to view analytics', function (StoreUserRole $role) { + $member = createStoreMember($this->store, $role); + + actingAsAdmin($member, $this->store) + ->get('/admin/analytics') + ->assertOk(); +})->with([ + 'admin' => StoreUserRole::Admin, + 'staff' => StoreUserRole::Staff, +]); + +it('restricts analytics from the support role', function () { + $support = createStoreMember($this->store, StoreUserRole::Support); + + actingAsAdmin($support, $this->store) + ->get('/admin/analytics') + ->assertForbidden(); +}); diff --git a/tests/Feature/Admin/AppsTest.php b/tests/Feature/Admin/AppsTest.php new file mode 100644 index 00000000..de303a91 --- /dev/null +++ b/tests/Feature/Admin/AppsTest.php @@ -0,0 +1,127 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; +}); + +it('renders the apps page with installed and available apps', function () { + $installedApp = AppModel::factory()->create(['name' => 'Loyalty Rewards']); + AppInstallation::factory()->for($this->store)->for($installedApp)->create(); + + AppModel::factory()->create(['name' => 'Shipping Labels Pro']); + + actingAsAdmin($this->user) + ->get('/admin/apps') + ->assertOk() + ->assertSee('Loyalty Rewards') + ->assertSee('Shipping Labels Pro'); +}); + +it('installs an app from the directory', function () { + $app = AppModel::factory()->create(); + + actingAsAdmin($this->user); + + Livewire::test(AppsIndex::class) + ->call('installApp', $app->getKey()); + + $this->assertDatabaseHas('app_installations', [ + 'store_id' => $this->store->getKey(), + 'app_id' => $app->getKey(), + 'status' => 'active', + ]); +}); + +it('reactivates an uninstalled app on reinstall', function () { + $app = AppModel::factory()->create(); + + $installation = AppInstallation::factory() + ->for($this->store) + ->for($app) + ->uninstalled() + ->create(); + + actingAsAdmin($this->user); + + Livewire::test(AppsIndex::class) + ->call('installApp', $app->getKey()); + + expect($installation->refresh()->status)->toBe(AppInstallationStatus::Active) + ->and(AppInstallation::query()->count())->toBe(1); +}); + +it('uninstalls an app and disables its webhook subscriptions', function () { + $installation = AppInstallation::factory()->for($this->store)->create(); + + $subscription = WebhookSubscription::factory()->for($this->store)->create([ + 'app_installation_id' => $installation->getKey(), + ]); + + actingAsAdmin($this->user); + + Livewire::test(AppsIndex::class) + ->call('uninstallApp', $installation->getKey()); + + expect($installation->refresh()->status)->toBe(AppInstallationStatus::Uninstalled) + ->and($subscription->refresh()->status)->toBe(WebhookSubscriptionStatus::Disabled); +}); + +it('shows installed app details with scopes and webhook subscriptions', function () { + $app = AppModel::factory()->create(['name' => 'Loyalty Rewards']); + + $installation = AppInstallation::factory()->for($this->store)->for($app)->create([ + 'scopes_json' => ['read-orders', 'read-customers'], + ]); + + WebhookSubscription::factory()->for($this->store)->create([ + 'app_installation_id' => $installation->getKey(), + 'event_type' => 'order.created', + 'target_url' => 'https://loyalty.example.test/hooks', + ]); + + actingAsAdmin($this->user) + ->get('/admin/apps/'.$installation->getKey()) + ->assertOk() + ->assertSee('Loyalty Rewards') + ->assertSee('read-orders') + ->assertSee('read-customers') + ->assertSee('order.created') + ->assertSee('https://loyalty.example.test/hooks'); +}); + +it('returns 404 for an installation belonging to another store', function () { + $otherStore = Store::factory()->for(Organization::factory())->create(); + + $foreignInstallation = AppInstallation::factory()->for($otherStore)->create(); + + actingAsAdmin($this->user) + ->get('/admin/apps/'.$foreignInstallation->getKey()) + ->assertNotFound(); +}); + +it('restricts the apps page to owner and admin roles', function () { + $staff = createStoreMember($this->store, StoreUserRole::Staff); + + actingAsAdmin($staff, $this->store) + ->get('/admin/apps') + ->assertForbidden(); + + $admin = createStoreMember($this->store, StoreUserRole::Admin); + + actingAsAdmin($admin, $this->store) + ->get('/admin/apps') + ->assertOk(); +}); diff --git a/tests/Feature/Admin/CollectionManagementTest.php b/tests/Feature/Admin/CollectionManagementTest.php new file mode 100644 index 00000000..b771ca09 --- /dev/null +++ b/tests/Feature/Admin/CollectionManagementTest.php @@ -0,0 +1,144 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; +}); + +it('lists collections with product counts', function () { + $collection = Collection::factory()->for($this->store)->create(['title' => 'Summer Sale']); + $products = Product::factory()->count(2)->for($this->store)->create(); + $collection->products()->sync([ + $products[0]->getKey() => ['position' => 0], + $products[1]->getKey() => ['position' => 1], + ]); + + actingAsAdmin($this->user) + ->get('/admin/collections') + ->assertOk() + ->assertSee('Summer Sale'); + + $component = Livewire::test(CollectionsIndex::class); + + expect($component->instance()->collections()->first()->products_count)->toBe(2); +}); + +it('creates a collection with assigned products', function () { + $products = Product::factory()->count(2)->for($this->store)->create(); + + actingAsAdmin($this->user); + + Livewire::test(CollectionForm::class) + ->set('title', 'New Arrivals') + ->call('addProduct', $products[0]->getKey()) + ->call('addProduct', $products[1]->getKey()) + ->call('save') + ->assertHasNoErrors(); + + $collection = Collection::query()->where('title', 'New Arrivals')->firstOrFail(); + + expect($collection->handle)->toBe('new-arrivals'); + expect($collection->products()->pluck('products.id')->all()) + ->toBe([$products[0]->getKey(), $products[1]->getKey()]); +}); + +it('edits a collection', function () { + $collection = Collection::factory()->for($this->store)->create(['title' => 'Old Title']); + + actingAsAdmin($this->user); + + Livewire::test(CollectionForm::class, ['collectionId' => $collection->getKey()]) + ->set('title', 'Updated Title') + ->set('status', 'archived') + ->call('save') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('collections', [ + 'id' => $collection->getKey(), + 'title' => 'Updated Title', + 'status' => 'archived', + ]); +}); + +it('reorders products within a collection', function () { + $collection = Collection::factory()->for($this->store)->create(); + $products = Product::factory()->count(3)->for($this->store)->create(); + $collection->products()->sync([ + $products[0]->getKey() => ['position' => 0], + $products[1]->getKey() => ['position' => 1], + $products[2]->getKey() => ['position' => 2], + ]); + + actingAsAdmin($this->user); + + Livewire::test(CollectionForm::class, ['collectionId' => $collection->getKey()]) + ->call('reorderProducts', $products[2]->getKey(), 0) + ->call('save') + ->assertHasNoErrors(); + + expect($collection->refresh()->products()->pluck('products.id')->all()) + ->toBe([$products[2]->getKey(), $products[0]->getKey(), $products[1]->getKey()]); +}); + +it('removes a product from a collection', function () { + $collection = Collection::factory()->for($this->store)->create(); + $products = Product::factory()->count(2)->for($this->store)->create(); + $collection->products()->sync([ + $products[0]->getKey() => ['position' => 0], + $products[1]->getKey() => ['position' => 1], + ]); + + actingAsAdmin($this->user); + + Livewire::test(CollectionForm::class, ['collectionId' => $collection->getKey()]) + ->call('removeProduct', $products[0]->getKey()) + ->call('save') + ->assertHasNoErrors(); + + expect($collection->refresh()->products()->pluck('products.id')->all()) + ->toBe([$products[1]->getKey()]); +}); + +it('validates handle uniqueness within store', function () { + Collection::factory()->for($this->store)->create(['handle' => 'summer']); + + actingAsAdmin($this->user); + + Livewire::test(CollectionForm::class) + ->set('title', 'Another Summer') + ->set('handle', 'summer') + ->call('save') + ->assertHasErrors(['handle']); +}); + +it('restricts collection management by role', function () { + $collection = Collection::factory()->for($this->store)->create(); + + $support = createStoreMember($this->store, StoreUserRole::Support); + + actingAsAdmin($support, $this->store) + ->get('/admin/collections') + ->assertOk(); + + actingAsAdmin($support, $this->store) + ->get('/admin/collections/create') + ->assertForbidden(); + + $staff = createStoreMember($this->store, StoreUserRole::Staff); + + actingAsAdmin($staff, $this->store); + + Livewire::test(CollectionForm::class, ['collectionId' => $collection->getKey()]) + ->call('deleteCollection') + ->assertForbidden(); + + $this->assertDatabaseHas('collections', ['id' => $collection->getKey()]); +}); diff --git a/tests/Feature/Admin/DashboardTest.php b/tests/Feature/Admin/DashboardTest.php new file mode 100644 index 00000000..bd169753 --- /dev/null +++ b/tests/Feature/Admin/DashboardTest.php @@ -0,0 +1,60 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; +}); + +it('renders the admin dashboard', function () { + actingAsAdmin($this->user) + ->get('/admin') + ->assertOk() + ->assertSee('Dashboard'); +}); + +it('shows KPI tiles with correct data', function () { + Order::factory() + ->count(5) + ->for($this->store) + ->totaling(5000) + ->create(['placed_at' => now()->subDay(), 'currency' => 'EUR']); + + actingAsAdmin($this->user); + + Livewire::test(Dashboard::class) + ->assertViewHas('ordersCount', 5) + ->assertViewHas('totalSales', 25000) + ->assertViewHas('formattedTotalSales', '250.00 EUR') + ->assertSee('250.00 EUR'); +}); + +it('restricts dashboard to authenticated admins', function () { + $this->get('/admin')->assertRedirect(route('admin.login')); +}); + +it('filters metrics by date range', function () { + Order::factory() + ->count(2) + ->for($this->store) + ->totaling(1000) + ->create(['placed_at' => now()->subDays(2)]); + + Order::factory() + ->count(3) + ->for($this->store) + ->totaling(1000) + ->create(['placed_at' => now()->subDays(20)]); + + actingAsAdmin($this->user); + + Livewire::test(Dashboard::class) + ->assertViewHas('ordersCount', 5) + ->set('dateRange', '7') + ->assertViewHas('ordersCount', 2) + ->assertViewHas('totalSales', 2000); +}); diff --git a/tests/Feature/Admin/DevelopersTest.php b/tests/Feature/Admin/DevelopersTest.php new file mode 100644 index 00000000..6b224dd9 --- /dev/null +++ b/tests/Feature/Admin/DevelopersTest.php @@ -0,0 +1,183 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; +}); + +it('renders the developers page for an owner', function () { + actingAsAdmin($this->user) + ->get('/admin/developers') + ->assertOk() + ->assertSee('API tokens'); +}); + +it('generates a token and shows the plain text value once', function () { + actingAsAdmin($this->user); + + Livewire::test(DevelopersIndex::class) + ->set('newTokenName', 'My integration') + ->set('newTokenAbilities', ['read-products', 'write-products']) + ->call('generateToken') + ->assertHasNoErrors() + ->assertSet('generatedToken', fn (?string $token): bool => $token !== null && str_contains($token, 'shop_')); + + $this->assertDatabaseHas('personal_access_tokens', [ + 'tokenable_id' => $this->user->getKey(), + 'name' => 'My integration', + ]); +}); + +it('requires at least one ability when generating a token', function () { + actingAsAdmin($this->user); + + Livewire::test(DevelopersIndex::class) + ->set('newTokenName', 'No abilities') + ->set('newTokenAbilities', []) + ->call('generateToken') + ->assertHasErrors('newTokenAbilities'); +}); + +it('revokes a token', function () { + $token = $this->user->createToken('Revocable', ['read-products']); + + actingAsAdmin($this->user); + + Livewire::test(DevelopersIndex::class) + ->call('revokeToken', $token->accessToken->getKey()); + + $this->assertDatabaseMissing('personal_access_tokens', [ + 'id' => $token->accessToken->getKey(), + ]); +}); + +it('creates a webhook subscription and shows the signing secret once', function () { + actingAsAdmin($this->user); + + Livewire::test(DevelopersIndex::class) + ->call('openWebhookModal') + ->set('webhookEventType', 'order.created') + ->set('webhookUrl', 'https://example.test/hooks/orders') + ->call('saveWebhook') + ->assertHasNoErrors() + ->assertSet('generatedWebhookSecret', fn (?string $secret): bool => $secret !== null && str_starts_with($secret, 'whsec_')); + + $this->assertDatabaseHas('webhook_subscriptions', [ + 'store_id' => $this->store->getKey(), + 'event_type' => 'order.created', + 'target_url' => 'https://example.test/hooks/orders', + 'status' => 'active', + ]); +}); + +it('validates the webhook event type and endpoint URL', function () { + actingAsAdmin($this->user); + + Livewire::test(DevelopersIndex::class) + ->call('openWebhookModal') + ->set('webhookEventType', 'invalid.event') + ->set('webhookUrl', 'not-a-url') + ->call('saveWebhook') + ->assertHasErrors(['webhookEventType', 'webhookUrl']); + + expect(WebhookSubscription::query()->count())->toBe(0); +}); + +it('updates an existing webhook subscription', function () { + $webhook = WebhookSubscription::factory()->for($this->store)->create([ + 'event_type' => 'order.created', + 'target_url' => 'https://example.test/old', + ]); + + actingAsAdmin($this->user); + + Livewire::test(DevelopersIndex::class) + ->call('openWebhookModal', $webhook->getKey()) + ->assertSet('webhookEventType', 'order.created') + ->assertSet('webhookUrl', 'https://example.test/old') + ->set('webhookEventType', 'order.paid') + ->set('webhookUrl', 'https://example.test/new') + ->call('saveWebhook') + ->assertHasNoErrors() + ->assertSet('generatedWebhookSecret', null); + + $webhook->refresh(); + + expect($webhook->event_type)->toBe('order.paid') + ->and($webhook->target_url)->toBe('https://example.test/new'); +}); + +it('pauses and resumes a webhook subscription', function () { + $webhook = WebhookSubscription::factory()->for($this->store)->create([ + 'consecutive_failures' => 3, + ]); + + actingAsAdmin($this->user); + + Livewire::test(DevelopersIndex::class) + ->call('toggleWebhookStatus', $webhook->getKey()); + + expect($webhook->refresh()->status)->toBe(WebhookSubscriptionStatus::Paused); + + Livewire::test(DevelopersIndex::class) + ->call('toggleWebhookStatus', $webhook->getKey()); + + $webhook->refresh(); + + expect($webhook->status)->toBe(WebhookSubscriptionStatus::Active) + ->and($webhook->consecutive_failures)->toBe(0); +}); + +it('deletes a webhook subscription', function () { + $webhook = WebhookSubscription::factory()->for($this->store)->create(); + + actingAsAdmin($this->user); + + Livewire::test(DevelopersIndex::class) + ->call('deleteWebhook', $webhook->getKey()); + + $this->assertDatabaseMissing('webhook_subscriptions', [ + 'id' => $webhook->getKey(), + ]); +}); + +it('lists webhook subscriptions with recent deliveries and response codes', function () { + $webhook = WebhookSubscription::factory()->for($this->store)->create([ + 'event_type' => 'order.created', + 'target_url' => 'https://example.test/hooks/orders', + ]); + + WebhookDelivery::factory()->succeeded()->create([ + 'subscription_id' => $webhook->getKey(), + ]); + + actingAsAdmin($this->user) + ->get('/admin/developers') + ->assertOk() + ->assertSee('order.created') + ->assertSee('https://example.test/hooks/orders') + ->assertSee('Recent deliveries') + ->assertSee('200'); +}); + +it('restricts the developers page to owner and admin roles', function () { + $staff = createStoreMember($this->store, StoreUserRole::Staff); + + actingAsAdmin($staff, $this->store) + ->get('/admin/developers') + ->assertForbidden(); + + $admin = createStoreMember($this->store, StoreUserRole::Admin); + + actingAsAdmin($admin, $this->store) + ->get('/admin/developers') + ->assertOk(); +}); diff --git a/tests/Feature/Admin/DiscountManagementTest.php b/tests/Feature/Admin/DiscountManagementTest.php new file mode 100644 index 00000000..a137a751 --- /dev/null +++ b/tests/Feature/Admin/DiscountManagementTest.php @@ -0,0 +1,115 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; +}); + +it('lists discounts', function () { + $discounts = Discount::factory()->count(3)->for($this->store)->create(); + + actingAsAdmin($this->user) + ->get('/admin/discounts') + ->assertOk(); + + $component = Livewire::test(DiscountsIndex::class); + + expect($component->instance()->discounts()->total())->toBe(3); + + foreach ($discounts as $discount) { + $component->assertSee($discount->code); + } +}); + +it('creates a percent discount', function () { + actingAsAdmin($this->user); + + Livewire::test(DiscountForm::class) + ->set('type', 'code') + ->set('code', 'SAVE10') + ->set('valueType', 'percent') + ->set('valueAmount', '10') + ->call('save') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('discounts', [ + 'store_id' => $this->store->getKey(), + 'code' => 'SAVE10', + 'value_type' => 'percent', + 'value_amount' => 10, + ]); +}); + +it('creates a fixed discount', function () { + actingAsAdmin($this->user); + + Livewire::test(DiscountForm::class) + ->set('type', 'code') + ->set('code', '5OFF') + ->set('valueType', 'fixed') + ->set('valueAmount', '5.00') + ->call('save') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('discounts', [ + 'store_id' => $this->store->getKey(), + 'code' => '5OFF', + 'value_type' => 'fixed', + 'value_amount' => 500, + ]); +}); + +it('validates discount code uniqueness within store', function () { + Discount::factory()->for($this->store)->create(['code' => 'SAVE10']); + + actingAsAdmin($this->user); + + Livewire::test(DiscountForm::class) + ->set('type', 'code') + ->set('code', 'SAVE10') + ->set('valueType', 'percent') + ->set('valueAmount', '10') + ->call('save') + ->assertHasErrors(['code']); + + expect(Discount::query()->where('code', 'SAVE10')->count())->toBe(1); +}); + +it('edits a discount', function () { + $discount = Discount::factory()->for($this->store)->create([ + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + ]); + + actingAsAdmin($this->user); + + Livewire::test(DiscountForm::class, ['discountId' => $discount->getKey()]) + ->set('valueAmount', '15') + ->call('save') + ->assertHasNoErrors(); + + expect($discount->refresh()->value_amount)->toBe(15); +}); + +it('disables a discount', function () { + $discount = Discount::factory()->for($this->store)->create([ + 'status' => DiscountStatus::Active, + ]); + + actingAsAdmin($this->user); + + Livewire::test(DiscountForm::class, ['discountId' => $discount->getKey()]) + ->set('isActive', false) + ->call('save') + ->assertHasNoErrors(); + + expect($discount->refresh()->status)->toBe(DiscountStatus::Disabled); +}); diff --git a/tests/Feature/Admin/InventoryManagementTest.php b/tests/Feature/Admin/InventoryManagementTest.php new file mode 100644 index 00000000..a13a09ca --- /dev/null +++ b/tests/Feature/Admin/InventoryManagementTest.php @@ -0,0 +1,101 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; +}); + +it('lists inventory items with variant and product details', function () { + createPurchasableVariant($this->store, quantityOnHand: 12, variantAttributes: ['sku' => 'SKU-AAA']); + createPurchasableVariant($this->store, quantityOnHand: 0, variantAttributes: ['sku' => 'SKU-BBB']); + + actingAsAdmin($this->user) + ->get('/admin/inventory') + ->assertOk(); + + $component = Livewire::test(InventoryIndex::class); + + expect($component->instance()->inventoryItems()->total())->toBe(2); + $component->assertSee('SKU-AAA')->assertSee('SKU-BBB'); +}); + +it('searches inventory by sku and product title', function () { + $variant = createPurchasableVariant($this->store, variantAttributes: ['sku' => 'FIND-ME-1']); + createPurchasableVariant($this->store, variantAttributes: ['sku' => 'OTHER-2']); + + actingAsAdmin($this->user); + + $component = Livewire::test(InventoryIndex::class)->set('search', 'FIND-ME'); + + expect($component->instance()->inventoryItems()->total())->toBe(1); + + $component->set('search', $variant->product->title); + + expect($component->instance()->inventoryItems()->total())->toBe(1); +}); + +it('filters inventory by stock level', function () { + createPurchasableVariant($this->store, quantityOnHand: 50, variantAttributes: ['sku' => 'PLENTY']); + createPurchasableVariant($this->store, quantityOnHand: 3, variantAttributes: ['sku' => 'LOW']); + createPurchasableVariant($this->store, quantityOnHand: 0, variantAttributes: ['sku' => 'OUT']); + + actingAsAdmin($this->user); + + $component = Livewire::test(InventoryIndex::class); + + $component->set('stockFilter', 'low_stock'); + expect($component->instance()->inventoryItems()->getCollection()->first()->variant->sku)->toBe('LOW'); + + $component->set('stockFilter', 'out_of_stock'); + expect($component->instance()->inventoryItems()->getCollection()->first()->variant->sku)->toBe('OUT'); + + $component->set('stockFilter', 'in_stock'); + expect($component->instance()->inventoryItems()->total())->toBe(2); +}); + +it('adjusts the on-hand quantity inline', function () { + $variant = createPurchasableVariant($this->store, quantityOnHand: 10); + $item = $variant->inventoryItem()->firstOrFail(); + + actingAsAdmin($this->user); + + Livewire::test(InventoryIndex::class) + ->call('updateQuantity', $item->getKey(), '25') + ->assertDispatched('toast'); + + expect($item->refresh()->quantity_on_hand)->toBe(25); +}); + +it('clamps negative quantities to zero', function () { + $variant = createPurchasableVariant($this->store, quantityOnHand: 10); + $item = $variant->inventoryItem()->firstOrFail(); + + actingAsAdmin($this->user); + + Livewire::test(InventoryIndex::class) + ->call('updateQuantity', $item->getKey(), '-5'); + + expect($item->refresh()->quantity_on_hand)->toBe(0); +}); + +it('restricts inventory adjustments for support users', function () { + $variant = createPurchasableVariant($this->store, quantityOnHand: 10); + $item = $variant->inventoryItem()->firstOrFail(); + + $support = createStoreMember($this->store, StoreUserRole::Support); + + actingAsAdmin($support, $this->store) + ->get('/admin/inventory') + ->assertOk(); + + Livewire::test(InventoryIndex::class) + ->call('updateQuantity', $item->getKey(), '99') + ->assertForbidden(); + + expect($item->refresh()->quantity_on_hand)->toBe(10); +}); diff --git a/tests/Feature/Admin/NavigationManagementTest.php b/tests/Feature/Admin/NavigationManagementTest.php new file mode 100644 index 00000000..8ae3420a --- /dev/null +++ b/tests/Feature/Admin/NavigationManagementTest.php @@ -0,0 +1,169 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; + + $this->menu = NavigationMenu::factory()->for($this->store)->create([ + 'handle' => 'main-menu', + 'title' => 'Main Menu', + ]); +}); + +it('lists navigation menus', function () { + NavigationMenu::factory()->for($this->store)->create(['handle' => 'footer-menu', 'title' => 'Footer Menu']); + + actingAsAdmin($this->user) + ->get('/admin/navigation') + ->assertOk() + ->assertSee('Main Menu') + ->assertSee('Footer Menu'); +}); + +it('adds a link item to a menu', function () { + actingAsAdmin($this->user); + + Livewire::test(NavigationIndex::class) + ->call('selectMenu', $this->menu->getKey()) + ->call('addItem') + ->set('itemLabel', 'Contact') + ->set('itemType', 'link') + ->set('itemUrl', '/contact') + ->call('saveItem') + ->assertHasNoErrors() + ->call('saveMenu') + ->assertDispatched('toast'); + + $this->assertDatabaseHas('navigation_items', [ + 'menu_id' => $this->menu->getKey(), + 'label' => 'Contact', + 'type' => 'link', + 'url' => '/contact', + 'position' => 0, + ]); +}); + +it('adds a page item with a resource picker', function () { + $page = Page::factory()->for($this->store)->create(['title' => 'About Us']); + + actingAsAdmin($this->user); + + Livewire::test(NavigationIndex::class) + ->call('selectMenu', $this->menu->getKey()) + ->call('addItem') + ->set('itemLabel', 'About') + ->set('itemType', 'page') + ->set('itemResourceId', (string) $page->getKey()) + ->call('saveItem') + ->assertHasNoErrors() + ->call('saveMenu'); + + $this->assertDatabaseHas('navigation_items', [ + 'menu_id' => $this->menu->getKey(), + 'label' => 'About', + 'type' => 'page', + 'resource_id' => $page->getKey(), + ]); +}); + +it('requires a url for link items and a resource for resource items', function () { + actingAsAdmin($this->user); + + $component = Livewire::test(NavigationIndex::class) + ->call('selectMenu', $this->menu->getKey()) + ->call('addItem') + ->set('itemLabel', 'Broken') + ->set('itemType', 'link') + ->set('itemUrl', '') + ->call('saveItem') + ->assertHasErrors(['itemUrl']); + + $component + ->set('itemType', 'collection') + ->set('itemResourceId', '') + ->call('saveItem') + ->assertHasErrors(['itemResourceId']); +}); + +it('edits and removes menu items', function () { + NavigationItem::factory()->for($this->menu, 'menu')->create(['label' => 'Home', 'position' => 0]); + NavigationItem::factory()->for($this->menu, 'menu')->create(['label' => 'Old Label', 'position' => 1]); + + actingAsAdmin($this->user); + + Livewire::test(NavigationIndex::class) + ->call('selectMenu', $this->menu->getKey()) + ->call('editItem', 1) + ->set('itemLabel', 'New Label') + ->call('saveItem') + ->call('removeItem', 0) + ->call('saveMenu') + ->assertDispatched('toast'); + + expect($this->menu->items()->pluck('label')->all())->toBe(['New Label']); +}); + +it('reorders menu items and persists positions', function () { + NavigationItem::factory()->for($this->menu, 'menu')->create(['label' => 'First', 'position' => 0]); + NavigationItem::factory()->for($this->menu, 'menu')->create(['label' => 'Second', 'position' => 1]); + NavigationItem::factory()->for($this->menu, 'menu')->create(['label' => 'Third', 'position' => 2]); + + actingAsAdmin($this->user); + + Livewire::test(NavigationIndex::class) + ->call('selectMenu', $this->menu->getKey()) + ->call('reorderItems', 2, 0) + ->call('saveMenu'); + + expect($this->menu->items()->orderBy('position')->pluck('label')->all()) + ->toBe(['Third', 'First', 'Second']); +}); + +it('invalidates the cached navigation tree on save', function () { + NavigationItem::factory()->for($this->menu, 'menu')->create(['label' => 'Home', 'url' => '/', 'position' => 0]); + + $service = app(NavigationService::class); + + expect(collect($service->tree('main-menu'))->pluck('label')->all())->toBe(['Home']); + + actingAsAdmin($this->user); + + Livewire::test(NavigationIndex::class) + ->call('selectMenu', $this->menu->getKey()) + ->call('addItem') + ->set('itemLabel', 'Contact') + ->set('itemType', 'link') + ->set('itemUrl', '/contact') + ->call('saveItem') + ->call('saveMenu'); + + expect(collect($service->tree('main-menu'))->pluck('label')->all())->toBe(['Home', 'Contact']); +}); + +it('restricts navigation management by role', function () { + $support = createStoreMember($this->store, StoreUserRole::Support); + + actingAsAdmin($support, $this->store) + ->get('/admin/navigation') + ->assertForbidden(); + + $staff = createStoreMember($this->store, StoreUserRole::Staff); + + actingAsAdmin($staff, $this->store) + ->get('/admin/navigation') + ->assertOk(); + + Livewire::test(NavigationIndex::class) + ->call('selectMenu', $this->menu->getKey()) + ->call('saveMenu') + ->assertForbidden(); +}); diff --git a/tests/Feature/Admin/OrderManagementTest.php b/tests/Feature/Admin/OrderManagementTest.php new file mode 100644 index 00000000..2e894c22 --- /dev/null +++ b/tests/Feature/Admin/OrderManagementTest.php @@ -0,0 +1,129 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; +}); + +it('lists orders with status filter', function () { + Order::factory()->count(3)->pending()->for($this->store)->create(); + Order::factory()->count(2)->paid()->for($this->store)->create(); + + actingAsAdmin($this->user); + + $component = Livewire::test(OrdersIndex::class) + ->call('setStatusFilter', 'paid'); + + expect($component->instance()->orders()->total())->toBe(2); + + $component->call('setStatusFilter', 'all'); + + expect($component->instance()->orders()->total())->toBe(5); +}); + +it('shows order detail page', function () { + $order = Order::factory()->paid()->for($this->store)->totaling(5000)->create(['currency' => 'EUR']); + $line = OrderLine::factory()->for($order)->create([ + 'title_snapshot' => 'Blue Shirt (M)', + 'quantity' => 2, + 'unit_price_amount' => 2500, + 'total_amount' => 5000, + ]); + + actingAsAdmin($this->user) + ->get("/admin/orders/{$order->getKey()}") + ->assertOk() + ->assertSee($order->order_number) + ->assertSee('Blue Shirt (M)') + ->assertSee('50.00 EUR'); +}); + +it('creates a fulfillment from order detail', function () { + $order = Order::factory()->paid()->for($this->store)->create(); + $line = OrderLine::factory()->for($order)->create(['quantity' => 2]); + + actingAsAdmin($this->user); + + Livewire::test(OrderShow::class, ['order' => $order->getKey()]) + ->set("fulfillmentLines.{$line->getKey()}.selected", true) + ->set("fulfillmentLines.{$line->getKey()}.quantity", 2) + ->set('trackingCompany', 'DHL') + ->set('trackingNumber', 'TRACK-123') + ->call('createFulfillment') + ->assertDispatched('toast'); + + $this->assertDatabaseHas('fulfillments', [ + 'order_id' => $order->getKey(), + 'tracking_company' => 'DHL', + 'tracking_number' => 'TRACK-123', + ]); + + $this->assertDatabaseHas('fulfillment_lines', [ + 'order_line_id' => $line->getKey(), + 'quantity' => 2, + ]); + + expect($order->refresh()->fulfillment_status->value)->toBe('fulfilled'); +}); + +it('processes a refund from order detail', function () { + $order = Order::factory()->paid()->for($this->store)->totaling(5000)->create(); + OrderLine::factory()->for($order)->create(['quantity' => 1, 'total_amount' => 5000]); + $payment = Payment::factory()->captured()->for($order)->create(['amount' => 5000]); + + actingAsAdmin($this->user); + + Livewire::test(OrderShow::class, ['order' => $order->getKey()]) + ->set('refundAmount', '10.00') + ->set('refundReason', 'Damaged item') + ->call('createRefund') + ->assertDispatched('toast'); + + $this->assertDatabaseHas('refunds', [ + 'order_id' => $order->getKey(), + 'payment_id' => $payment->getKey(), + 'amount' => 1000, + 'reason' => 'Damaged item', + 'status' => 'processed', + ]); + + expect($order->refresh()->financial_status->value)->toBe('partially_refunded'); +}); + +it('restricts order management by role', function () { + $support = createStoreMember($this->store, StoreUserRole::Support); + + $order = Order::factory()->paid()->for($this->store)->create(); + $line = OrderLine::factory()->for($order)->create(['quantity' => 1]); + Payment::factory()->captured()->for($order)->create(); + + actingAsAdmin($support) + ->get('/admin/orders') + ->assertOk(); + + actingAsAdmin($support) + ->get("/admin/orders/{$order->getKey()}") + ->assertOk(); + + Livewire::test(OrderShow::class, ['order' => $order->getKey()]) + ->set("fulfillmentLines.{$line->getKey()}.selected", true) + ->call('createFulfillment') + ->assertForbidden(); + + Livewire::test(OrderShow::class, ['order' => $order->getKey()]) + ->set('refundAmount', '5.00') + ->call('createRefund') + ->assertForbidden(); + + $this->assertDatabaseMissing('fulfillments', ['order_id' => $order->getKey()]); + $this->assertDatabaseMissing('refunds', ['order_id' => $order->getKey()]); +}); diff --git a/tests/Feature/Admin/PageManagementTest.php b/tests/Feature/Admin/PageManagementTest.php new file mode 100644 index 00000000..5d123e51 --- /dev/null +++ b/tests/Feature/Admin/PageManagementTest.php @@ -0,0 +1,122 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; +}); + +it('lists pages with search', function () { + Page::factory()->for($this->store)->create(['title' => 'About Us']); + Page::factory()->for($this->store)->create(['title' => 'Shipping Policy']); + + actingAsAdmin($this->user) + ->get('/admin/pages') + ->assertOk() + ->assertSee('About Us') + ->assertSee('Shipping Policy'); + + $component = Livewire::test(PagesIndex::class)->set('search', 'About'); + + expect($component->instance()->pages()->total())->toBe(1); +}); + +it('creates a draft page', function () { + actingAsAdmin($this->user); + + Livewire::test(PageForm::class) + ->set('title', 'Returns Policy') + ->set('bodyHtml', '

You can return items within 30 days.

') + ->call('save') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('pages', [ + 'store_id' => $this->store->getKey(), + 'title' => 'Returns Policy', + 'handle' => 'returns-policy', + 'status' => 'draft', + ]); +}); + +it('publishes a page and backfills the published date', function () { + $page = Page::factory()->draft()->for($this->store)->create(); + + actingAsAdmin($this->user); + + Livewire::test(PageForm::class, ['pageId' => $page->getKey()]) + ->set('status', 'published') + ->call('save') + ->assertHasNoErrors(); + + $page->refresh(); + + expect($page->status)->toBe(PageStatus::Published); + expect($page->published_at)->not->toBeNull(); +}); + +it('edits a page', function () { + $page = Page::factory()->for($this->store)->create(['title' => 'Old Page Title']); + + actingAsAdmin($this->user); + + Livewire::test(PageForm::class, ['pageId' => $page->getKey()]) + ->set('title', 'New Page Title') + ->set('bodyHtml', '

Updated body.

') + ->call('save') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('pages', [ + 'id' => $page->getKey(), + 'title' => 'New Page Title', + 'body_html' => '

Updated body.

', + ]); +}); + +it('validates handle uniqueness within store', function () { + Page::factory()->for($this->store)->create(['handle' => 'about']); + + actingAsAdmin($this->user); + + Livewire::test(PageForm::class) + ->set('title', 'Another About') + ->set('handle', 'about') + ->call('save') + ->assertHasErrors(['handle']); +}); + +it('deletes a page', function () { + $page = Page::factory()->for($this->store)->create(); + + actingAsAdmin($this->user); + + Livewire::test(PageForm::class, ['pageId' => $page->getKey()]) + ->call('deletePage'); + + $this->assertDatabaseMissing('pages', ['id' => $page->getKey()]); +}); + +it('restricts page deletion to owner and admin roles', function () { + $page = Page::factory()->for($this->store)->create(); + + $staff = createStoreMember($this->store, StoreUserRole::Staff); + + actingAsAdmin($staff, $this->store); + + Livewire::test(PageForm::class, ['pageId' => $page->getKey()]) + ->set('title', 'Staff Edited Title') + ->call('save') + ->assertHasNoErrors(); + + Livewire::test(PageForm::class, ['pageId' => $page->getKey()]) + ->call('deletePage') + ->assertForbidden(); + + $this->assertDatabaseHas('pages', ['id' => $page->getKey()]); +}); diff --git a/tests/Feature/Admin/ProductManagementTest.php b/tests/Feature/Admin/ProductManagementTest.php new file mode 100644 index 00000000..dead3455 --- /dev/null +++ b/tests/Feature/Admin/ProductManagementTest.php @@ -0,0 +1,169 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; +}); + +it('lists products with pagination', function () { + Product::factory()->count(25)->for($this->store)->create(); + + actingAsAdmin($this->user) + ->get('/admin/products') + ->assertOk(); + + $component = Livewire::test(ProductsIndex::class); + + expect($component->instance()->products()->count())->toBe(15); + expect($component->instance()->products()->total())->toBe(25); + expect($component->instance()->products()->hasMorePages())->toBeTrue(); +}); + +it('creates a product via admin form', function () { + actingAsAdmin($this->user); + + Livewire::test(ProductForm::class) + ->set('title', 'Admin Created Tee') + ->set('descriptionHtml', '

Soft cotton tee.

') + ->set('variants.0.price', '19.99') + ->set('variants.0.quantity', 7) + ->call('save') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('products', [ + 'store_id' => $this->store->getKey(), + 'title' => 'Admin Created Tee', + 'description_html' => '

Soft cotton tee.

', + ]); + + $product = Product::query()->where('title', 'Admin Created Tee')->firstOrFail(); + + expect($product->variants)->toHaveCount(1); + expect($product->variants->first()->price_amount)->toBe(1999); + expect($product->variants->first()->inventoryItem->quantity_on_hand)->toBe(7); +}); + +it('edits a product via admin form', function () { + $product = app(ProductService::class)->create($this->store, ['title' => 'Original Title']); + + actingAsAdmin($this->user); + + Livewire::test(ProductForm::class, ['productId' => $product->getKey()]) + ->set('title', 'Renamed Title') + ->set('vendor', 'Acme') + ->call('save') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('products', [ + 'id' => $product->getKey(), + 'title' => 'Renamed Title', + 'vendor' => 'Acme', + ]); +}); + +it('bulk archives selected products', function () { + $products = Product::factory()->count(3)->for($this->store)->create(['status' => ProductStatus::Draft]); + + actingAsAdmin($this->user); + + Livewire::test(ProductsIndex::class) + ->set('selectedIds', $products->pluck('id')->all()) + ->call('bulkArchive') + ->assertDispatched('toast'); + + foreach ($products as $product) { + expect($product->refresh()->status)->toBe(ProductStatus::Archived); + } +}); + +it('uploads media from the product form', function () { + Storage::fake('public'); + Queue::fake(); + + $product = app(ProductService::class)->create($this->store, ['title' => 'Photogenic Mug']); + + actingAsAdmin($this->user); + + Livewire::test(ProductForm::class, ['productId' => $product->getKey()]) + ->set('newMedia', [UploadedFile::fake()->image('photo.jpg', 600, 400)]) + ->assertHasNoErrors(); + + $this->assertDatabaseHas('product_media', [ + 'product_id' => $product->getKey(), + 'type' => 'image', + ]); +}); + +it('manages variants from the product form', function () { + actingAsAdmin($this->user); + + Livewire::test(ProductForm::class) + ->set('title', 'Sized Tee') + ->call('addOption') + ->set('options.0.name', 'Size') + ->set('options.0.values', 'S, M') + ->set('variants.0.price', '10.00') + ->set('variants.1.price', '12.00') + ->call('save') + ->assertHasNoErrors(); + + $product = Product::query()->where('title', 'Sized Tee')->firstOrFail(); + + expect($product->options)->toHaveCount(1); + expect($product->options->first()->values)->toHaveCount(2); + expect($product->variants)->toHaveCount(2); + + $labels = $product->variants + ->map(fn ($variant) => $variant->optionValues->pluck('value')->implode(' / ')) + ->sort() + ->values() + ->all(); + + expect($labels)->toBe(['M', 'S']); +}); + +it('restricts product management to authorized roles', function () { + $support = createStoreMember($this->store, StoreUserRole::Support); + + actingAsAdmin($support) + ->get('/admin/products') + ->assertOk(); + + actingAsAdmin($support) + ->get('/admin/products/create') + ->assertForbidden(); +}); + +it('staff can create but not delete products', function () { + $staff = createStoreMember($this->store, StoreUserRole::Staff); + + actingAsAdmin($staff); + + Livewire::test(ProductForm::class) + ->set('title', 'Staff Product') + ->set('variants.0.price', '5.00') + ->call('save') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('products', ['title' => 'Staff Product']); + + $product = Product::query()->where('title', 'Staff Product')->firstOrFail(); + + Livewire::test(ProductForm::class, ['productId' => $product->getKey()]) + ->call('deleteProduct') + ->assertForbidden(); + + expect($product->refresh()->status)->not->toBe(ProductStatus::Archived); +}); diff --git a/tests/Feature/Admin/SearchSettingsTest.php b/tests/Feature/Admin/SearchSettingsTest.php new file mode 100644 index 00000000..ca1c0a9f --- /dev/null +++ b/tests/Feature/Admin/SearchSettingsTest.php @@ -0,0 +1,82 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; +}); + +it('renders the search settings page', function () { + actingAsAdmin($this->user) + ->get('/admin/search/settings') + ->assertOk() + ->assertSee('Synonyms') + ->assertSee('Stop words') + ->assertSee('Reindex now'); +}); + +it('saves synonym groups and stop words', function () { + actingAsAdmin($this->user); + + Livewire::test(SearchSettingsPage::class) + ->call('addSynonymGroup') + ->set('synonymGroups.0', 't-shirt, tee, tshirt') + ->set('stopWords', 'the, a, an') + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('toast'); + + $settings = SearchSettings::query()->findOrFail($this->store->getKey()); + + expect($settings->synonymGroups())->toBe([['t-shirt', 'tee', 'tshirt']]); + expect($settings->stopWords())->toBe(['the', 'a', 'an']); +}); + +it('removes a synonym group', function () { + SearchSettings::factory()->for($this->store)->withSynonyms([ + ['tee', 't-shirt'], + ['pants', 'jeans'], + ])->create(); + + actingAsAdmin($this->user); + + Livewire::test(SearchSettingsPage::class) + ->call('removeSynonymGroup', 0) + ->call('save') + ->assertHasNoErrors(); + + expect(SearchSettings::query()->findOrFail($this->store->getKey())->synonymGroups()) + ->toBe([['pants', 'jeans']]); +}); + +it('rebuilds the search index from the reindex button', function () { + $product = Product::factory()->active()->for($this->store)->create(['title' => 'Indexable Jacket']); + + DB::delete('DELETE FROM products_fts WHERE rowid = ?', [$product->getKey()]); + + expect(app(SearchService::class)->search($this->store, 'indexable', logQuery: false)->total())->toBe(0); + + actingAsAdmin($this->user); + + Livewire::test(SearchSettingsPage::class) + ->call('triggerReindex') + ->assertDispatched('toast'); + + expect(app(SearchService::class)->search($this->store, 'indexable', logQuery: false)->total())->toBe(1); +}); + +it('restricts search settings to owner and admin roles', function () { + $staff = createStoreMember($this->store, StoreUserRole::Staff); + + actingAsAdmin($staff, $this->store) + ->get('/admin/search/settings') + ->assertForbidden(); +}); diff --git a/tests/Feature/Admin/SettingsTest.php b/tests/Feature/Admin/SettingsTest.php new file mode 100644 index 00000000..eef56223 --- /dev/null +++ b/tests/Feature/Admin/SettingsTest.php @@ -0,0 +1,131 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; +}); + +it('renders the settings page', function () { + actingAsAdmin($this->user) + ->get('/admin/settings') + ->assertOk() + ->assertSee('General') + ->assertSee('Domains') + ->assertSee('Shipping') + ->assertSee('Taxes') + ->assertSee('Checkout') + ->assertSee('Notifications'); +}); + +it('updates general store settings', function () { + actingAsAdmin($this->user); + + Livewire::test(GeneralSettings::class) + ->set('storeName', 'Renamed Store') + ->set('contactEmail', 'support@renamed.test') + ->set('orderNumberPrefix', 'RS-') + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('toast'); + + expect($this->store->refresh()->name)->toBe('Renamed Store'); + + $settings = $this->store->settings()->first()->settings_json; + + expect($settings['store_name'])->toBe('Renamed Store'); + expect($settings['contact_email'])->toBe('support@renamed.test'); + expect($settings['order_number_prefix'])->toBe('RS-'); +}); + +it('configures shipping zones', function () { + actingAsAdmin($this->user); + + $component = Livewire::test(ShippingSettings::class) + ->call('openZoneModal') + ->set('zoneName', 'Domestic') + ->set('zoneCountries', ['DE', 'AT']) + ->call('saveZone') + ->assertHasNoErrors(); + + $zone = ShippingZone::query()->where('name', 'Domestic')->firstOrFail(); + + expect($zone->countries_json)->toBe(['DE', 'AT']); + expect($zone->store_id)->toBe($this->store->getKey()); + + $component + ->call('openRateModal', $zone->getKey()) + ->set('rateName', 'Standard') + ->set('rateType', 'flat') + ->set('rateFlatAmount', '5.00') + ->call('saveRate') + ->assertHasNoErrors(); + + $rate = ShippingRate::query()->where('name', 'Standard')->firstOrFail(); + + expect($rate->zone_id)->toBe($zone->getKey()); + expect($rate->config_json['amount'])->toBe(500); + expect($rate->is_active)->toBeTrue(); +}); + +it('configures tax settings', function () { + actingAsAdmin($this->user); + + Livewire::test(TaxesSettings::class) + ->set('mode', 'manual') + ->set('manualRate', '19.00') + ->set('pricesIncludeTax', true) + ->set('shippingTaxable', false) + ->call('save') + ->assertHasNoErrors(); + + $settings = TaxSettings::query()->findOrFail($this->store->getKey()); + + expect($settings->mode)->toBe(TaxMode::Manual); + expect($settings->defaultRateBasisPoints())->toBe(1900); + expect($settings->prices_include_tax)->toBeTrue(); + expect($settings->shippingTaxable())->toBeFalse(); +}); + +it('manages store domains', function () { + actingAsAdmin($this->user); + + Livewire::test(DomainsSettings::class) + ->set('newHostname', 'shop.example.com') + ->set('newType', 'storefront') + ->call('addDomain') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('store_domains', [ + 'store_id' => $this->store->getKey(), + 'hostname' => 'shop.example.com', + 'type' => 'storefront', + ]); +}); + +it('restricts settings to owner and admin roles', function () { + $staff = createStoreMember($this->store, StoreUserRole::Staff); + + actingAsAdmin($staff, $this->store) + ->get('/admin/settings') + ->assertForbidden(); + + actingAsAdmin($staff, $this->store) + ->get('/admin/settings/shipping') + ->assertForbidden(); + + actingAsAdmin($staff, $this->store) + ->get('/admin/settings/taxes') + ->assertForbidden(); +}); diff --git a/tests/Feature/Admin/ThemeManagementTest.php b/tests/Feature/Admin/ThemeManagementTest.php new file mode 100644 index 00000000..39e05c6f --- /dev/null +++ b/tests/Feature/Admin/ThemeManagementTest.php @@ -0,0 +1,143 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; +}); + +it('lists themes as cards', function () { + Theme::factory()->for($this->store)->create(['name' => 'Live Theme']); + Theme::factory()->draft()->for($this->store)->create(['name' => 'Draft Theme']); + + actingAsAdmin($this->user) + ->get('/admin/themes') + ->assertOk() + ->assertSee('Live Theme') + ->assertSee('Draft Theme'); +}); + +it('publishes a theme and invalidates the cached settings', function () { + $oldTheme = Theme::factory()->for($this->store)->create(['published_at' => now()->subDay()]); + ThemeSettings::factory()->for($oldTheme, 'theme')->create(['settings_json' => ['hero_heading' => 'Old Heading']]); + + $newTheme = Theme::factory()->draft()->for($this->store)->create(); + ThemeSettings::factory()->for($newTheme, 'theme')->create(['settings_json' => ['hero_heading' => 'New Heading']]); + + $service = app(ThemeSettingsService::class); + + expect($service->all($this->store)['hero_heading'])->toBe('Old Heading'); + + actingAsAdmin($this->user); + + Livewire::test(ThemesIndex::class) + ->call('publishTheme', $newTheme->getKey()) + ->assertDispatched('toast'); + + expect($newTheme->refresh()->status)->toBe(ThemeStatus::Published); + expect($oldTheme->refresh()->status)->toBe(ThemeStatus::Draft); + expect($service->all($this->store)['hero_heading'])->toBe('New Heading'); +}); + +it('duplicates a theme including its settings', function () { + $theme = Theme::factory()->for($this->store)->create(['name' => 'Original']); + ThemeSettings::factory()->for($theme, 'theme')->create(['settings_json' => ['hero_heading' => 'Copied Heading']]); + + actingAsAdmin($this->user); + + Livewire::test(ThemesIndex::class) + ->call('duplicateTheme', $theme->getKey()) + ->assertDispatched('toast'); + + $copy = Theme::query()->where('name', 'Original (Copy)')->firstOrFail(); + + expect($copy->status)->toBe(ThemeStatus::Draft); + expect($copy->settings->settings_json['hero_heading'])->toBe('Copied Heading'); +}); + +it('deletes a draft theme but refuses to delete the published theme', function () { + $published = Theme::factory()->for($this->store)->create(); + $draft = Theme::factory()->draft()->for($this->store)->create(); + + actingAsAdmin($this->user); + + Livewire::test(ThemesIndex::class) + ->call('deleteTheme', $draft->getKey()); + + $this->assertDatabaseMissing('themes', ['id' => $draft->getKey()]); + + Livewire::test(ThemesIndex::class) + ->call('deleteTheme', $published->getKey()) + ->assertDispatched('toast', type: 'error'); + + $this->assertDatabaseHas('themes', ['id' => $published->getKey()]); +}); + +it('saves settings from the theme editor', function () { + $theme = Theme::factory()->for($this->store)->create(); + ThemeSettings::factory()->for($theme, 'theme')->create(['settings_json' => ['hero_heading' => 'Before']]); + + $service = app(ThemeSettingsService::class); + + expect($service->all($this->store)['hero_heading'])->toBe('Before'); + + actingAsAdmin($this->user); + + Livewire::test(ThemeEditor::class, ['themeId' => $theme->getKey()]) + ->call('selectSection', 'hero') + ->set('settings.hero_heading', 'After') + ->call('save') + ->assertDispatched('toast'); + + expect($theme->settings()->first()->settings_json['hero_heading'])->toBe('After'); + expect($service->all($this->store)['hero_heading'])->toBe('After'); +}); + +it('reorders and toggles home page sections in the editor', function () { + $theme = Theme::factory()->for($this->store)->create(); + + actingAsAdmin($this->user); + + Livewire::test(ThemeEditor::class, ['themeId' => $theme->getKey()]) + ->call('reorderSections', 'newsletter', 0) + ->call('toggleSection', 'rich-text') + ->call('save') + ->assertDispatched('toast'); + + $sections = $theme->settings()->first()->settings_json['sections']; + + expect($sections[0])->toBe('newsletter'); + expect($sections)->not->toContain('rich-text'); +}); + +it('publishes from the editor via save and publish', function () { + $theme = Theme::factory()->draft()->for($this->store)->create(); + + actingAsAdmin($this->user); + + Livewire::test(ThemeEditor::class, ['themeId' => $theme->getKey()]) + ->call('publish') + ->assertDispatched('toast'); + + expect($theme->refresh()->status)->toBe(ThemeStatus::Published); + expect($theme->published_at)->not->toBeNull(); +}); + +it('restricts theme management to owner and admin roles', function () { + Theme::factory()->for($this->store)->create(); + + $staff = createStoreMember($this->store, StoreUserRole::Staff); + + actingAsAdmin($staff, $this->store) + ->get('/admin/themes') + ->assertForbidden(); +}); diff --git a/tests/Feature/Analytics/AggregationTest.php b/tests/Feature/Analytics/AggregationTest.php new file mode 100644 index 00000000..49174095 --- /dev/null +++ b/tests/Feature/Analytics/AggregationTest.php @@ -0,0 +1,82 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->date = now()->subDay()->toDateString(); +}); + +it('aggregates daily metrics from raw events', function () { + AnalyticsEvent::factory()->count(5)->pageView()->for($this->store)->create([ + 'created_at' => now()->subDay()->setTime(10, 0), + ]); + AnalyticsEvent::factory()->count(3)->addToCart()->for($this->store)->create([ + 'created_at' => now()->subDay()->setTime(11, 0), + ]); + AnalyticsEvent::factory()->count(2)->for($this->store)->create([ + 'type' => 'checkout_completed', + 'properties_json' => ['order_id' => 1, 'total_amount' => 5000], + 'created_at' => now()->subDay()->setTime(12, 0), + ]); + + (new AggregateAnalytics($this->date))->handle(); + + $daily = AnalyticsDaily::query() + ->where('store_id', $this->store->getKey()) + ->where('date', $this->date) + ->firstOrFail(); + + expect($daily->visits_count)->toBe(5); + expect($daily->add_to_cart_count)->toBe(3); + expect($daily->checkout_completed_count)->toBe(2); + expect($daily->orders_count)->toBe(2); +}); + +it('calculates revenue and AOV correctly', function () { + foreach ([1000, 2000, 3000] as $totalAmount) { + AnalyticsEvent::factory()->for($this->store)->create([ + 'type' => 'checkout_completed', + 'properties_json' => ['total_amount' => $totalAmount], + 'created_at' => now()->subDay()->setTime(12, 0), + ]); + } + + (new AggregateAnalytics($this->date))->handle(); + + $daily = AnalyticsDaily::query() + ->where('store_id', $this->store->getKey()) + ->where('date', $this->date) + ->firstOrFail(); + + expect($daily->revenue_amount)->toBe(6000); + expect($daily->aov_amount)->toBe(2000); + expect($daily->orders_count)->toBe(3); +}); + +it('runs idempotently', function () { + AnalyticsEvent::factory()->count(4)->pageView()->for($this->store)->create([ + 'created_at' => now()->subDay()->setTime(9, 0), + ]); + AnalyticsEvent::factory()->for($this->store)->create([ + 'type' => 'checkout_completed', + 'properties_json' => ['total_amount' => 2500], + 'created_at' => now()->subDay()->setTime(10, 0), + ]); + + (new AggregateAnalytics($this->date))->handle(); + (new AggregateAnalytics($this->date))->handle(); + + $rows = AnalyticsDaily::query() + ->where('store_id', $this->store->getKey()) + ->where('date', $this->date) + ->get(); + + expect($rows)->toHaveCount(1); + expect($rows->first()->visits_count)->toBe(4); + expect($rows->first()->revenue_amount)->toBe(2500); + expect($rows->first()->orders_count)->toBe(1); +}); diff --git a/tests/Feature/Analytics/EventIngestionTest.php b/tests/Feature/Analytics/EventIngestionTest.php new file mode 100644 index 00000000..fd0b19ef --- /dev/null +++ b/tests/Feature/Analytics/EventIngestionTest.php @@ -0,0 +1,89 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->baseUrl = 'http://'.$this->context['domain']->hostname.'/api/storefront/v1'; +}); + +/** + * A valid analytics event payload for the batch ingestion endpoint. + * + * @param array $overrides + * @return array + */ +function analyticsEventPayload(array $overrides = []): array +{ + return array_merge([ + 'type' => 'page_view', + 'session_id' => 'sess_abc123', + 'client_event_id' => 'evt_'.fake()->unique()->uuid(), + 'properties' => ['url' => '/'], + 'occurred_at' => now()->toIso8601String(), + ], $overrides); +} + +it('tracks a page view event', function () { + $this->postJson("{$this->baseUrl}/analytics/events", [ + 'events' => [analyticsEventPayload(['type' => 'page_view'])], + ]) + ->assertStatus(202) + ->assertJsonPath('accepted', 1) + ->assertJsonPath('rejected', 0); + + $event = AnalyticsEvent::query()->withoutGlobalScopes()->where('type', 'page_view')->first(); + + expect($event)->not->toBeNull(); + expect($event->store_id)->toBe($this->store->getKey()); +}); + +it('tracks an add to cart event', function () { + $this->postJson("{$this->baseUrl}/analytics/events", [ + 'events' => [analyticsEventPayload([ + 'type' => 'add_to_cart', + 'properties' => ['product_id' => 10, 'variant_id' => 101, 'quantity' => 1], + ])], + ])->assertStatus(202); + + $event = AnalyticsEvent::query()->withoutGlobalScopes()->where('type', 'add_to_cart')->firstOrFail(); + + expect($event->properties_json['product_id'])->toBe(10); + expect($event->properties_json['variant_id'])->toBe(101); + expect($event->properties_json['quantity'])->toBe(1); +}); + +it('scopes events to current store', function () { + $this->postJson("{$this->baseUrl}/analytics/events", [ + 'events' => [analyticsEventPayload()], + ])->assertStatus(202); + + $event = AnalyticsEvent::query()->withoutGlobalScopes()->firstOrFail(); + + expect($event->store_id)->toBe($this->store->getKey()); +}); + +it('includes session ID when available', function () { + $this->postJson("{$this->baseUrl}/analytics/events", [ + 'events' => [analyticsEventPayload(['session_id' => 'sess_with_id'])], + ])->assertStatus(202); + + $event = AnalyticsEvent::query()->withoutGlobalScopes()->firstOrFail(); + + expect($event->session_id)->toBe('sess_with_id'); +}); + +it('includes customer ID when authenticated', function () { + $customer = Customer::factory()->for($this->store)->create(); + + actingAsCustomer($customer) + ->postJson("{$this->baseUrl}/analytics/events", [ + 'events' => [analyticsEventPayload()], + ])->assertStatus(202); + + $event = AnalyticsEvent::query()->withoutGlobalScopes()->firstOrFail(); + + expect($event->customer_id)->toBe($customer->getKey()); +}); diff --git a/tests/Feature/Api/AdminOrderApiTest.php b/tests/Feature/Api/AdminOrderApiTest.php new file mode 100644 index 00000000..4a1ecad8 --- /dev/null +++ b/tests/Feature/Api/AdminOrderApiTest.php @@ -0,0 +1,125 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; + $this->baseUrl = "/api/admin/v1/stores/{$this->store->getKey()}/orders"; +}); + +/** + * Authorization headers for a token with the given abilities. + * + * @param list $abilities + * @return array + */ +function orderApiHeaders(array $abilities = ['read-orders', 'write-orders']): array +{ + return ['Authorization' => 'Bearer '.test()->user->createToken('test', $abilities)->plainTextToken]; +} + +/** + * A paid order with one line (qty 2) and a captured payment. + */ +function paidOrderWithLine(): Order +{ + $order = Order::factory()->paid()->for(test()->store)->create([ + 'subtotal_amount' => 5000, + 'total_amount' => 5000, + ]); + + OrderLine::factory()->for($order)->create([ + 'quantity' => 2, + 'unit_price_amount' => 2500, + 'total_amount' => 5000, + ]); + + Payment::factory()->captured()->for($order)->create(['amount' => 5000]); + + return $order; +} + +it('lists orders with authentication', function () { + Order::factory()->count(2)->paid()->for($this->store)->create(); + + $this->getJson($this->baseUrl, orderApiHeaders()) + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonStructure(['data' => [['id', 'order_number', 'status', 'total_amount']], 'meta']); +}); + +it('retrieves a single order', function () { + $order = paidOrderWithLine(); + + $this->getJson("{$this->baseUrl}/{$order->getKey()}", orderApiHeaders()) + ->assertOk() + ->assertJsonPath('data.id', $order->getKey()) + ->assertJsonCount(1, 'data.lines') + ->assertJsonCount(1, 'data.payments') + ->assertJsonCount(0, 'data.fulfillments'); +}); + +it('filters orders by status', function () { + Order::factory()->paid()->for($this->store)->create(); + Order::factory()->pending()->for($this->store)->create(); + + $this->getJson("{$this->baseUrl}?status=paid", orderApiHeaders()) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.status', 'paid'); +}); + +it('creates a fulfillment via API', function () { + $order = paidOrderWithLine(); + $line = $order->lines()->first(); + + $this->postJson("{$this->baseUrl}/{$order->getKey()}/fulfillments", [ + 'tracking_company' => 'DHL', + 'tracking_number' => '1234567890', + 'line_items' => [ + ['order_line_id' => $line->getKey(), 'quantity' => 2], + ], + ], orderApiHeaders()) + ->assertCreated() + ->assertJsonPath('data.status', 'shipped') + ->assertJsonPath('data.tracking_company', 'DHL') + ->assertJsonPath('data.line_items.0.quantity', 2); + + $this->assertDatabaseHas('fulfillments', [ + 'order_id' => $order->getKey(), + 'tracking_number' => '1234567890', + ]); +}); + +it('creates a refund via API', function () { + $order = paidOrderWithLine(); + + $this->postJson("{$this->baseUrl}/{$order->getKey()}/refunds", [ + 'amount' => 2500, + 'reason' => 'Customer requested return for 1 item', + ], orderApiHeaders()) + ->assertCreated() + ->assertJsonPath('data.amount', 2500) + ->assertJsonPath('data.status', 'processed'); + + $this->assertDatabaseHas('refunds', [ + 'order_id' => $order->getKey(), + 'amount' => 2500, + ]); +}); + +it('requires write-orders ability for mutations', function () { + $order = paidOrderWithLine(); + $line = $order->lines()->first(); + + $this->postJson("{$this->baseUrl}/{$order->getKey()}/fulfillments", [ + 'line_items' => [ + ['order_line_id' => $line->getKey(), 'quantity' => 1], + ], + ], orderApiHeaders(['read-orders'])) + ->assertForbidden(); +}); diff --git a/tests/Feature/Api/AdminProductApiTest.php b/tests/Feature/Api/AdminProductApiTest.php new file mode 100644 index 00000000..3b820610 --- /dev/null +++ b/tests/Feature/Api/AdminProductApiTest.php @@ -0,0 +1,117 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; + $this->baseUrl = "/api/admin/v1/stores/{$this->store->getKey()}/products"; +}); + +/** + * Authorization headers for a token with the given abilities. + * + * @param list $abilities + * @return array + */ +function productApiHeaders(array $abilities = ['read-products', 'write-products']): array +{ + return ['Authorization' => 'Bearer '.test()->user->createToken('test', $abilities)->plainTextToken]; +} + +it('lists products with authentication', function () { + Product::factory()->count(3)->for($this->store)->create(); + + $this->getJson($this->baseUrl, productApiHeaders()) + ->assertOk() + ->assertJsonCount(3, 'data') + ->assertJsonStructure(['data', 'meta' => ['current_page', 'per_page', 'total', 'last_page']]); +}); + +it('creates a product via API', function () { + $this->postJson($this->baseUrl, [ + 'title' => 'Classic T-Shirt', + 'description_html' => '

A comfortable cotton t-shirt.

', + 'vendor' => 'Acme Apparel', + 'status' => 'draft', + 'tags' => ['organic', 'cotton'], + 'options' => [ + ['name' => 'Size', 'position' => 1], + ], + 'variants' => [ + [ + 'sku' => 'TSH-S', + 'price_amount' => 2500, + 'is_default' => true, + 'option_values' => [['option_name' => 'Size', 'value' => 'Small']], + 'inventory' => ['quantity_on_hand' => 50, 'policy' => 'deny'], + ], + [ + 'sku' => 'TSH-M', + 'price_amount' => 2500, + 'option_values' => [['option_name' => 'Size', 'value' => 'Medium']], + ], + ], + ], productApiHeaders()) + ->assertCreated() + ->assertJsonPath('data.title', 'Classic T-Shirt') + ->assertJsonPath('data.handle', 'classic-t-shirt') + ->assertJsonCount(2, 'data.variants') + ->assertJsonPath('data.variants.0.inventory.quantity_on_hand', 50); + + $this->assertDatabaseHas('products', [ + 'store_id' => $this->store->getKey(), + 'title' => 'Classic T-Shirt', + ]); +}); + +it('updates a product via API', function () { + $product = Product::factory()->for($this->store)->create(['title' => 'Old Title']); + ProductVariant::factory()->asDefault()->priced(2500)->for($product)->create(); + + $this->putJson("{$this->baseUrl}/{$product->getKey()}", [ + 'title' => 'New Title', + 'tags' => ['bestseller'], + ], productApiHeaders()) + ->assertOk() + ->assertJsonPath('data.title', 'New Title') + ->assertJsonPath('data.tags.0', 'bestseller'); + + expect($product->refresh()->title)->toBe('New Title'); +}); + +it('deletes a draft product via API', function () { + $product = Product::factory()->for($this->store)->create(['status' => ProductStatus::Draft]); + + $this->deleteJson("{$this->baseUrl}/{$product->getKey()}", [], productApiHeaders()) + ->assertOk() + ->assertJsonPath('data.status', 'archived'); + + expect($product->refresh()->status)->toBe(ProductStatus::Archived); +}); + +it('requires write-products ability for mutations', function () { + $this->postJson($this->baseUrl, [ + 'title' => 'Forbidden Product', + 'variants' => [['sku' => 'FP-1', 'price_amount' => 1000]], + ], productApiHeaders(['read-products'])) + ->assertForbidden(); +}); + +it('returns 401 without token', function () { + $this->getJson($this->baseUrl)->assertUnauthorized(); +}); + +it('paginates results', function () { + Product::factory()->count(25)->for($this->store)->create(); + + $this->getJson($this->baseUrl, productApiHeaders()) + ->assertOk() + ->assertJsonCount(15, 'data') + ->assertJsonPath('meta.total', 25) + ->assertJsonPath('meta.per_page', 15) + ->assertJsonPath('meta.last_page', 2); +}); diff --git a/tests/Feature/Api/StorefrontCartApiTest.php b/tests/Feature/Api/StorefrontCartApiTest.php new file mode 100644 index 00000000..8e87c950 --- /dev/null +++ b/tests/Feature/Api/StorefrontCartApiTest.php @@ -0,0 +1,98 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->baseUrl = 'http://'.$this->context['domain']->hostname.'/api/storefront/v1'; +}); + +it('creates a cart', function () { + $this->postJson("{$this->baseUrl}/carts") + ->assertCreated() + ->assertJsonPath('cart_version', 1) + ->assertJsonStructure(['id', 'store_id', 'currency', 'cart_version', 'status', 'lines', 'totals']); +}); + +it('retrieves a cart with lines and totals', function () { + $variantA = createPurchasableVariant($this->store, 2500); + $variantB = createPurchasableVariant($this->store, 1000); + $cartService = app(CartService::class); + $cart = $cartService->create($this->store); + $cartService->addLine($cart, $variantA->getKey(), 2); + $cartService->addLine($cart, $variantB->getKey(), 1); + + $this->getJson("{$this->baseUrl}/carts/{$cart->getKey()}") + ->assertOk() + ->assertJsonCount(2, 'lines') + ->assertJsonPath('totals.subtotal', 6000) + ->assertJsonPath('totals.total', 6000) + ->assertJsonPath('totals.item_count', 3); +}); + +it('adds a line to the cart', function () { + $variant = createPurchasableVariant($this->store, 2500); + $cart = app(CartService::class)->create($this->store); + + $this->postJson("{$this->baseUrl}/carts/{$cart->getKey()}/lines", [ + 'variant_id' => $variant->getKey(), + 'quantity' => 1, + ]) + ->assertOk() + ->assertJsonCount(1, 'lines'); +}); + +it('updates a line quantity', function () { + $variant = createPurchasableVariant($this->store, 2500); + $cart = app(CartService::class)->create($this->store); + $line = app(CartService::class)->addLine($cart, $variant->getKey(), 1); + + $this->putJson("{$this->baseUrl}/carts/{$cart->getKey()}/lines/{$line->getKey()}", [ + 'quantity' => 4, + 'cart_version' => $cart->refresh()->cart_version, + ]) + ->assertOk() + ->assertJsonPath('lines.0.quantity', 4); +}); + +it('removes a line', function () { + $variant = createPurchasableVariant($this->store, 2500); + $cart = app(CartService::class)->create($this->store); + $line = app(CartService::class)->addLine($cart, $variant->getKey(), 1); + + $this->deleteJson("{$this->baseUrl}/carts/{$cart->getKey()}/lines/{$line->getKey()}", [ + 'cart_version' => $cart->refresh()->cart_version, + ]) + ->assertOk() + ->assertJsonCount(0, 'lines'); +}); + +it('validates variant exists on add', function () { + $cart = app(CartService::class)->create($this->store); + + $this->postJson("{$this->baseUrl}/carts/{$cart->getKey()}/lines", [ + 'variant_id' => 999999, + 'quantity' => 1, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('variant_id'); +}); + +it('validates quantity is positive', function () { + $variant = createPurchasableVariant($this->store, 2500); + $cart = app(CartService::class)->create($this->store); + + $this->postJson("{$this->baseUrl}/carts/{$cart->getKey()}/lines", [ + 'variant_id' => $variant->getKey(), + 'quantity' => 0, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('quantity'); +}); + +it('returns 404 for nonexistent cart', function () { + $this->getJson("{$this->baseUrl}/carts/999") + ->assertNotFound() + ->assertJsonPath('message', 'The requested resource was not found.'); +}); diff --git a/tests/Feature/Api/StorefrontCheckoutApiTest.php b/tests/Feature/Api/StorefrontCheckoutApiTest.php new file mode 100644 index 00000000..2eae4a36 --- /dev/null +++ b/tests/Feature/Api/StorefrontCheckoutApiTest.php @@ -0,0 +1,188 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->baseUrl = 'http://'.$this->context['domain']->hostname.'/api/storefront/v1'; +}); + +/** + * A cart containing one purchasable variant for the current test store. + */ +function checkoutApiCart(): \App\Models\Cart +{ + $variant = createPurchasableVariant(test()->store, 2500); + $cart = app(CartService::class)->create(test()->store); + app(CartService::class)->addLine($cart, $variant->getKey(), 2); + + return $cart; +} + +/** + * A German shipping zone with a flat 499 rate for the current test store. + */ +function checkoutApiShippingRate(): ShippingRate +{ + $zone = ShippingZone::factory()->for(test()->store)->create(['countries_json' => ['DE']]); + + return ShippingRate::factory()->for($zone, 'zone')->flatAmount(499)->create(); +} + +it('creates a checkout from a cart', function () { + $cart = checkoutApiCart(); + + $this->postJson("{$this->baseUrl}/checkouts", [ + 'cart_id' => $cart->getKey(), + 'email' => 'shopper@example.test', + ]) + ->assertCreated() + ->assertJsonPath('status', 'started') + ->assertJsonPath('cart_id', $cart->getKey()) + ->assertJsonPath('email', 'shopper@example.test'); +}); + +it('sets checkout address', function () { + $cart = checkoutApiCart(); + $checkout = app(CheckoutService::class)->createFromCart($cart); + $checkout->forceFill(['email' => 'shopper@example.test'])->save(); + + $this->putJson("{$this->baseUrl}/checkouts/{$checkout->getKey()}/address", [ + 'shipping_address' => validShippingAddress(), + ]) + ->assertOk() + ->assertJsonPath('status', 'addressed') + ->assertJsonPath('shipping_address_json.city', 'Berlin'); +}); + +it('selects a shipping method', function () { + $cart = checkoutApiCart(); + $rate = checkoutApiShippingRate(); + $checkoutService = app(CheckoutService::class); + $checkout = $checkoutService->createFromCart($cart); + $checkout->forceFill(['email' => 'shopper@example.test'])->save(); + $checkout = $checkoutService->setAddress($checkout, [ + 'email' => 'shopper@example.test', + 'shipping_address' => validShippingAddress(), + ]); + + $this->putJson("{$this->baseUrl}/checkouts/{$checkout->getKey()}/shipping-method", [ + 'shipping_method_id' => $rate->getKey(), + ]) + ->assertOk() + ->assertJsonPath('status', 'shipping_selected') + ->assertJsonPath('shipping_method_id', $rate->getKey()) + ->assertJsonPath('available_shipping_methods.0.id', $rate->getKey()) + ->assertJsonPath('totals.shipping', 499); +}); + +it('applies a discount code', function () { + Discount::factory()->for($this->store)->create([ + 'code' => 'WELCOME10', + 'value_amount' => 10, + ]); + + $cart = checkoutApiCart(); + $checkout = app(CheckoutService::class)->createFromCart($cart); + $checkout->forceFill(['email' => 'shopper@example.test'])->save(); + + $this->postJson("{$this->baseUrl}/checkouts/{$checkout->getKey()}/apply-discount", [ + 'code' => 'WELCOME10', + ]) + ->assertOk() + ->assertJsonPath('discount_code', 'WELCOME10') + ->assertJsonPath('totals.discount', 500); +}); + +it('retrieves checkout with totals', function () { + $cart = checkoutApiCart(); + $checkout = app(CheckoutService::class)->createFromCart($cart); + + $this->getJson("{$this->baseUrl}/checkouts/{$checkout->getKey()}") + ->assertOk() + ->assertJsonStructure(['id', 'status', 'lines', 'totals' => ['subtotal', 'discount', 'shipping', 'tax', 'total', 'currency']]) + ->assertJsonPath('totals.subtotal', 5000); +}); + +it('selects a payment method', function () { + $checkout = shippingSelectedCheckoutForApi(); + + $this->putJson("{$this->baseUrl}/checkouts/{$checkout->getKey()}/payment-method", [ + 'payment_method' => 'credit_card', + ]) + ->assertOk() + ->assertJsonPath('status', 'payment_selected') + ->assertJsonPath('payment_method', 'credit_card'); +}); + +it('completes checkout with credit card payment', function () { + $checkout = createPaymentSelectedCheckout($this->store); + + $this->postJson("{$this->baseUrl}/checkouts/{$checkout->getKey()}/pay", [ + 'payment_method' => 'credit_card', + 'card_number' => '4242424242424242', + 'card_expiry' => '12/28', + 'card_cvc' => '123', + 'card_holder' => 'Erika Mustermann', + ]) + ->assertOk() + ->assertJsonPath('status', 'completed') + ->assertJsonPath('order.status', 'paid') + ->assertJsonPath('order.financial_status', 'paid'); + + $this->assertDatabaseHas('orders', [ + 'checkout_id' => $checkout->getKey(), + 'status' => 'paid', + ]); +}); + +it('rejects payment with declined card', function () { + $checkout = createPaymentSelectedCheckout($this->store); + + $this->postJson("{$this->baseUrl}/checkouts/{$checkout->getKey()}/pay", [ + 'payment_method' => 'credit_card', + 'card_number' => '4000000000000002', + 'card_expiry' => '12/28', + 'card_cvc' => '123', + 'card_holder' => 'Erika Mustermann', + ]) + ->assertUnprocessable() + ->assertJsonPath('error_code', 'card_declined'); + + $this->assertDatabaseMissing('orders', ['checkout_id' => $checkout->getKey()]); +}); + +it('validates required address fields', function () { + $cart = checkoutApiCart(); + $checkout = app(CheckoutService::class)->createFromCart($cart); + $checkout->forceFill(['email' => 'shopper@example.test'])->save(); + + $this->putJson("{$this->baseUrl}/checkouts/{$checkout->getKey()}/address", [ + 'shipping_address' => validShippingAddress(['city' => '']), + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('shipping_address.city'); +}); + +/** + * Drive a checkout to the shipping_selected state for the current test store. + */ +function shippingSelectedCheckoutForApi(): \App\Models\Checkout +{ + $cart = checkoutApiCart(); + $rate = checkoutApiShippingRate(); + $checkoutService = app(CheckoutService::class); + $checkout = $checkoutService->createFromCart($cart); + $checkout->forceFill(['email' => 'shopper@example.test'])->save(); + $checkout = $checkoutService->setAddress($checkout, [ + 'email' => 'shopper@example.test', + 'shipping_address' => validShippingAddress(), + ]); + + return $checkoutService->setShippingMethod($checkout, $rate->getKey()); +} diff --git a/tests/Feature/Auth/AdminAuthTest.php b/tests/Feature/Auth/AdminAuthTest.php new file mode 100644 index 00000000..9018d57e --- /dev/null +++ b/tests/Feature/Auth/AdminAuthTest.php @@ -0,0 +1,123 @@ +get('/admin/login') + ->assertOk() + ->assertSee('Sign in'); +}); + +it('authenticates an admin user with valid credentials', function () { + $context = createStoreContext(); + app()->forgetInstance('current_store'); + + $response = $this->post('/admin/login', [ + 'email' => $context['user']->email, + 'password' => 'password', + ]); + + $response->assertRedirect(route('admin.dashboard')); + $this->assertAuthenticatedAs($context['user']); +}); + +it('rejects invalid credentials', function () { + $user = User::factory()->create(); + + $response = $this->from('/admin/login')->post('/admin/login', [ + 'email' => $user->email, + 'password' => 'wrong-password', + ]); + + $response->assertRedirect('/admin/login'); + $response->assertSessionHasErrors(['email' => 'Invalid credentials']); + $this->assertGuest(); +}); + +it('does not reveal whether email or password is incorrect', function () { + $response = $this->from('/admin/login')->post('/admin/login', [ + 'email' => 'does-not-exist@example.test', + 'password' => 'whatever-password', + ]); + + $response->assertRedirect('/admin/login'); + $response->assertSessionHasErrors(['email' => 'Invalid credentials']); +}); + +it('rate limits login attempts', function () { + $user = User::factory()->create(); + + foreach (range(1, 5) as $attempt) { + $this->post('/admin/login', [ + 'email' => $user->email, + 'password' => 'wrong-password', + ])->assertRedirect(); + } + + $this->post('/admin/login', [ + 'email' => $user->email, + 'password' => 'wrong-password', + ])->assertTooManyRequests(); +}); + +it('regenerates session on successful login', function () { + $context = createStoreContext(); + app()->forgetInstance('current_store'); + + $this->get('/admin/login'); + $previousSessionId = session()->getId(); + + $this->post('/admin/login', [ + 'email' => $context['user']->email, + 'password' => 'password', + ]); + + expect(session()->getId())->not->toBe($previousSessionId); +}); + +it('logs out and invalidates session', function () { + $context = createStoreContext(); + app()->forgetInstance('current_store'); + + $response = actingAsAdmin($context['user'], $context['store'])->post('/admin/logout'); + + $response->assertRedirect(route('admin.login')); + $this->assertGuest(); +}); + +it('redirects unauthenticated users to login', function () { + $this->get('/admin')->assertRedirect(route('admin.login')); +}); + +it('supports remember me functionality', function () { + $context = createStoreContext(); + app()->forgetInstance('current_store'); + + $response = $this->post('/admin/login', [ + 'email' => $context['user']->email, + 'password' => 'password', + 'remember' => 'on', + ]); + + $response->assertRedirect(route('admin.dashboard')); + $response->assertCookie(Auth::guard('web')->getRecallerName()); +}); + +it('records last_login_at on successful login', function () { + $context = createStoreContext(); + app()->forgetInstance('current_store'); + + $user = $context['user']; + $user->forceFill(['last_login_at' => null])->save(); + + $this->post('/admin/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $user->refresh(); + + expect($user->last_login_at)->not->toBeNull(); + expect($user->last_login_at->diffInSeconds(now()))->toBeLessThan(5); +}); diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php deleted file mode 100644 index fff11fd7..00000000 --- a/tests/Feature/Auth/AuthenticationTest.php +++ /dev/null @@ -1,69 +0,0 @@ -get(route('login')); - - $response->assertOk(); -}); - -test('users can authenticate using the login screen', function () { - $user = User::factory()->create(); - - $response = $this->post(route('login.store'), [ - 'email' => $user->email, - 'password' => 'password', - ]); - - $response - ->assertSessionHasNoErrors() - ->assertRedirect(route('dashboard', absolute: false)); - - $this->assertAuthenticated(); -}); - -test('users can not authenticate with invalid password', function () { - $user = User::factory()->create(); - - $response = $this->post(route('login.store'), [ - 'email' => $user->email, - 'password' => 'wrong-password', - ]); - - $response->assertSessionHasErrorsIn('email'); - - $this->assertGuest(); -}); - -test('users with two factor enabled are redirected to two factor challenge', function () { - if (! Features::canManageTwoFactorAuthentication()) { - $this->markTestSkipped('Two-factor authentication is not enabled.'); - } - Features::twoFactorAuthentication([ - 'confirm' => true, - 'confirmPassword' => true, - ]); - - $user = User::factory()->withTwoFactor()->create(); - - $response = $this->post(route('login.store'), [ - 'email' => $user->email, - 'password' => 'password', - ]); - - $response->assertRedirect(route('two-factor.login')); - $this->assertGuest(); -}); - -test('users can logout', function () { - $user = User::factory()->create(); - - $response = $this->actingAs($user)->post(route('logout')); - - $response->assertRedirect(route('home')); - $this->assertGuest(); -}); \ No newline at end of file diff --git a/tests/Feature/Auth/CustomerAuthTest.php b/tests/Feature/Auth/CustomerAuthTest.php new file mode 100644 index 00000000..5072a9c2 --- /dev/null +++ b/tests/Feature/Auth/CustomerAuthTest.php @@ -0,0 +1,169 @@ +store = Store::factory()->create(); + $this->domain = StoreDomain::factory()->for($this->store)->create(); + $this->baseUrl = 'http://'.$this->domain->hostname; +}); + +it('renders the customer login page', function () { + $this->get($this->baseUrl.'/account/login') + ->assertOk() + ->assertSee('Log in') + ->assertSee('Email'); +}); + +it('authenticates a customer with valid credentials', function () { + $customer = Customer::factory()->for($this->store)->create(); + + $response = $this->post($this->baseUrl.'/account/login', [ + 'email' => $customer->email, + 'password' => 'password', + ]); + + $response->assertRedirect('/account'); + $this->assertAuthenticatedAs($customer, 'customer'); +}); + +it('rejects invalid customer credentials', function () { + $customer = Customer::factory()->for($this->store)->create(); + + $response = $this->from($this->baseUrl.'/account/login')->post($this->baseUrl.'/account/login', [ + 'email' => $customer->email, + 'password' => 'wrong-password', + ]); + + $response->assertRedirect($this->baseUrl.'/account/login'); + $response->assertSessionHasErrors('email'); + $this->assertGuest('customer'); +}); + +it('scopes customer login to the current store', function () { + $storeB = Store::factory()->create(); + $domainB = StoreDomain::factory()->for($storeB)->create(); + + $customer = Customer::factory()->for($this->store)->create(); + + $response = $this->post('http://'.$domainB->hostname.'/account/login', [ + 'email' => $customer->email, + 'password' => 'password', + ]); + + $response->assertSessionHasErrors('email'); + $this->assertGuest('customer'); +}); + +it('rate limits customer login attempts', function () { + $customer = Customer::factory()->for($this->store)->create(); + + foreach (range(1, 5) as $attempt) { + $this->post($this->baseUrl.'/account/login', [ + 'email' => $customer->email, + 'password' => 'wrong-password', + ])->assertRedirect(); + } + + $this->post($this->baseUrl.'/account/login', [ + 'email' => $customer->email, + 'password' => 'wrong-password', + ])->assertTooManyRequests(); +}); + +it('registers a new customer', function () { + $response = $this->post($this->baseUrl.'/account/register', [ + 'name' => 'Jane Shopper', + 'email' => 'jane@example.test', + 'password' => 'super-secret', + 'password_confirmation' => 'super-secret', + ]); + + $response->assertRedirect('/account'); + + $this->assertDatabaseHas('customers', [ + 'store_id' => $this->store->getKey(), + 'email' => 'jane@example.test', + 'name' => 'Jane Shopper', + ]); + + $this->assertAuthenticated('customer'); +}); + +it('rejects duplicate email registration in the same store', function () { + Customer::factory()->for($this->store)->create(['email' => 'jane@example.test']); + + $response = $this->from($this->baseUrl.'/account/register')->post($this->baseUrl.'/account/register', [ + 'name' => 'Jane Shopper', + 'email' => 'jane@example.test', + 'password' => 'super-secret', + 'password_confirmation' => 'super-secret', + ]); + + $response->assertSessionHasErrors('email'); + expect(Customer::query()->withoutGlobalScopes()->where('email', 'jane@example.test')->count())->toBe(1); +}); + +it('allows same email in different stores', function () { + $storeB = Store::factory()->create(); + $domainB = StoreDomain::factory()->for($storeB)->create(); + + Customer::factory()->for($this->store)->create(['email' => 'jane@example.test']); + + $response = $this->post('http://'.$domainB->hostname.'/account/register', [ + 'name' => 'Jane Shopper', + 'email' => 'jane@example.test', + 'password' => 'super-secret', + 'password_confirmation' => 'super-secret', + ]); + + $response->assertRedirect('/account'); + + $this->assertDatabaseHas('customers', [ + 'store_id' => $storeB->getKey(), + 'email' => 'jane@example.test', + ]); +}); + +it('logs out customer and redirects to login', function () { + $customer = Customer::factory()->for($this->store)->create(); + + $response = actingAsCustomer($customer)->post($this->baseUrl.'/account/logout'); + + $response->assertRedirect($this->baseUrl.'/account/login'); + $this->assertGuest('customer'); +}); + +it('merges guest cart into customer cart on login', function () { + $customer = Customer::factory()->for($this->store)->create(); + + $variantA = createPurchasableVariant($this->store, 2500); + $variantB = createPurchasableVariant($this->store, 3500); + + $guestCart = Cart::factory()->for($this->store)->create(); + CartLine::factory()->for($guestCart)->priced(2500, 2)->create(['variant_id' => $variantA->getKey()]); + + $customerCart = Cart::factory()->for($this->store)->create(['customer_id' => $customer->getKey()]); + CartLine::factory()->for($customerCart)->priced(2500, 1)->create(['variant_id' => $variantA->getKey()]); + CartLine::factory()->for($customerCart)->priced(3500, 3)->create(['variant_id' => $variantB->getKey()]); + + $response = $this->withSession([CartService::SESSION_KEY => $guestCart->getKey()]) + ->post($this->baseUrl.'/account/login', [ + 'email' => $customer->email, + 'password' => 'password', + ]); + + $response->assertRedirect('/account'); + + expect($customerCart->lines()->count())->toBe(2); + expect($customerCart->lines()->where('variant_id', $variantA->getKey())->first()->quantity)->toBe(3); + expect($customerCart->lines()->where('variant_id', $variantB->getKey())->first()->quantity)->toBe(3); + expect($guestCart->refresh()->status)->toBe(CartStatus::Abandoned); + expect(session(CartService::SESSION_KEY))->toBe($customerCart->getKey()); +}); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php deleted file mode 100644 index 66f58e36..00000000 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ /dev/null @@ -1,69 +0,0 @@ -unverified()->create(); - - $response = $this->actingAs($user)->get(route('verification.notice')); - - $response->assertOk(); -}); - -test('email can be verified', function () { - $user = User::factory()->unverified()->create(); - - Event::fake(); - - $verificationUrl = URL::temporarySignedRoute( - 'verification.verify', - now()->addMinutes(60), - ['id' => $user->id, 'hash' => sha1($user->email)] - ); - - $response = $this->actingAs($user)->get($verificationUrl); - - Event::assertDispatched(Verified::class); - - expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); - $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); -}); - -test('email is not verified with invalid hash', function () { - $user = User::factory()->unverified()->create(); - - $verificationUrl = URL::temporarySignedRoute( - 'verification.verify', - now()->addMinutes(60), - ['id' => $user->id, 'hash' => sha1('wrong-email')] - ); - - $this->actingAs($user)->get($verificationUrl); - - expect($user->fresh()->hasVerifiedEmail())->toBeFalse(); -}); - -test('already verified user visiting verification link is redirected without firing event again', function () { - $user = User::factory()->create([ - 'email_verified_at' => now(), - ]); - - Event::fake(); - - $verificationUrl = URL::temporarySignedRoute( - 'verification.verify', - now()->addMinutes(60), - ['id' => $user->id, 'hash' => sha1($user->email)] - ); - - $this->actingAs($user)->get($verificationUrl) - ->assertRedirect(route('dashboard', absolute: false).'?verified=1'); - - expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); - Event::assertNotDispatched(Verified::class); -}); \ No newline at end of file diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php deleted file mode 100644 index f42a259e..00000000 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ /dev/null @@ -1,13 +0,0 @@ -create(); - - $response = $this->actingAs($user)->get(route('password.confirm')); - - $response->assertOk(); -}); \ No newline at end of file diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php deleted file mode 100644 index bea78251..00000000 --- a/tests/Feature/Auth/PasswordResetTest.php +++ /dev/null @@ -1,61 +0,0 @@ -get(route('password.request')); - - $response->assertOk(); -}); - -test('reset password link can be requested', function () { - Notification::fake(); - - $user = User::factory()->create(); - - $this->post(route('password.request'), ['email' => $user->email]); - - Notification::assertSentTo($user, ResetPassword::class); -}); - -test('reset password screen can be rendered', function () { - Notification::fake(); - - $user = User::factory()->create(); - - $this->post(route('password.request'), ['email' => $user->email]); - - Notification::assertSentTo($user, ResetPassword::class, function ($notification) { - $response = $this->get(route('password.reset', $notification->token)); - $response->assertOk(); - - return true; - }); -}); - -test('password can be reset with valid token', function () { - Notification::fake(); - - $user = User::factory()->create(); - - $this->post(route('password.request'), ['email' => $user->email]); - - Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { - $response = $this->post(route('password.update'), [ - 'token' => $notification->token, - 'email' => $user->email, - 'password' => 'password', - 'password_confirmation' => 'password', - ]); - - $response - ->assertSessionHasNoErrors() - ->assertRedirect(route('login', absolute: false)); - - return true; - }); -}); \ No newline at end of file diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php deleted file mode 100644 index c22ea5e1..00000000 --- a/tests/Feature/Auth/RegistrationTest.php +++ /dev/null @@ -1,23 +0,0 @@ -get(route('register')); - - $response->assertOk(); -}); - -test('new users can register', function () { - $response = $this->post(route('register.store'), [ - 'name' => 'John Doe', - 'email' => 'test@example.com', - 'password' => 'password', - 'password_confirmation' => 'password', - ]); - - $response->assertSessionHasNoErrors() - ->assertRedirect(route('dashboard', absolute: false)); - - $this->assertAuthenticated(); -}); \ No newline at end of file diff --git a/tests/Feature/Auth/SanctumTokenTest.php b/tests/Feature/Auth/SanctumTokenTest.php new file mode 100644 index 00000000..1acf8dfa --- /dev/null +++ b/tests/Feature/Auth/SanctumTokenTest.php @@ -0,0 +1,58 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; +}); + +it('creates a personal access token with abilities', function () { + $token = $this->user->createToken('My integration', ['read-products', 'write-products']); + + expect($token->plainTextToken)->toContain('shop_'); + + $this->assertDatabaseHas('personal_access_tokens', [ + 'tokenable_id' => $this->user->getKey(), + 'name' => 'My integration', + ]); + + expect(PersonalAccessToken::query()->first()->abilities) + ->toBe(['read-products', 'write-products']); +}); + +it('authenticates API request with valid token', function () { + $token = $this->user->createToken('integration', ['read-products'])->plainTextToken; + + $this->getJson("/api/admin/v1/stores/{$this->store->getKey()}/products", [ + 'Authorization' => 'Bearer '.$token, + ])->assertOk(); +}); + +it('rejects API request with invalid token', function () { + $this->getJson("/api/admin/v1/stores/{$this->store->getKey()}/products", [ + 'Authorization' => 'Bearer shop_totally-fake-token', + ])->assertUnauthorized(); +}); + +it('enforces token abilities', function () { + $token = $this->user->createToken('read only', ['read-products'])->plainTextToken; + + $this->postJson("/api/admin/v1/stores/{$this->store->getKey()}/products", [ + 'title' => 'New Product', + 'variants' => [['sku' => 'NP-1', 'price_amount' => 1000]], + ], [ + 'Authorization' => 'Bearer '.$token, + ])->assertForbidden(); +}); + +it('revokes a token', function () { + $token = $this->user->createToken('revocable', ['read-products']); + + $this->user->tokens()->whereKey($token->accessToken->getKey())->delete(); + + $this->getJson("/api/admin/v1/stores/{$this->store->getKey()}/products", [ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->assertUnauthorized(); +}); diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php deleted file mode 100644 index cda794f2..00000000 --- a/tests/Feature/Auth/TwoFactorChallengeTest.php +++ /dev/null @@ -1,34 +0,0 @@ -markTestSkipped('Two-factor authentication is not enabled.'); - } - - $response = $this->get(route('two-factor.login')); - - $response->assertRedirect(route('login')); -}); - -test('two factor challenge can be rendered', function () { - if (! Features::canManageTwoFactorAuthentication()) { - $this->markTestSkipped('Two-factor authentication is not enabled.'); - } - - Features::twoFactorAuthentication([ - 'confirm' => true, - 'confirmPassword' => true, - ]); - - $user = User::factory()->withTwoFactor()->create(); - - $this->post(route('login.store'), [ - 'email' => $user->email, - 'password' => 'password', - ])->assertRedirect(route('two-factor.login')); -}); \ No newline at end of file diff --git a/tests/Feature/Cart/CartApiTest.php b/tests/Feature/Cart/CartApiTest.php new file mode 100644 index 00000000..b88497c7 --- /dev/null +++ b/tests/Feature/Cart/CartApiTest.php @@ -0,0 +1,106 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->baseUrl = 'http://'.$this->context['domain']->hostname.'/api/storefront/v1'; +}); + +it('creates a cart via API', function () { + $this->postJson("{$this->baseUrl}/carts") + ->assertCreated() + ->assertJsonPath('store_id', $this->store->getKey()) + ->assertJsonPath('cart_version', 1) + ->assertJsonPath('status', 'active'); +}); + +it('retrieves a cart via API', function () { + $variant = createPurchasableVariant($this->store, 2500); + $cart = app(CartService::class)->create($this->store); + app(CartService::class)->addLine($cart, $variant->getKey(), 2); + + $this->getJson("{$this->baseUrl}/carts/{$cart->getKey()}") + ->assertOk() + ->assertJsonCount(1, 'lines') + ->assertJsonPath('totals.subtotal', 5000) + ->assertJsonPath('totals.total', 5000); +}); + +it('adds a line via API', function () { + $variant = createPurchasableVariant($this->store, 2500); + $cart = app(CartService::class)->create($this->store); + + $this->postJson("{$this->baseUrl}/carts/{$cart->getKey()}/lines", [ + 'variant_id' => $variant->getKey(), + 'quantity' => 2, + ])->assertOk(); + + $this->assertDatabaseHas('cart_lines', [ + 'cart_id' => $cart->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity' => 2, + ]); +}); + +it('updates line quantity via API', function () { + $variant = createPurchasableVariant($this->store, 2500); + $cart = app(CartService::class)->create($this->store); + $line = app(CartService::class)->addLine($cart, $variant->getKey(), 1); + + $this->putJson("{$this->baseUrl}/carts/{$cart->getKey()}/lines/{$line->getKey()}", [ + 'quantity' => 3, + 'cart_version' => $cart->refresh()->cart_version, + ])->assertOk(); + + expect($line->refresh()->quantity)->toBe(3); +}); + +it('removes a line via API', function () { + $variant = createPurchasableVariant($this->store, 2500); + $cart = app(CartService::class)->create($this->store); + $line = app(CartService::class)->addLine($cart, $variant->getKey(), 1); + + $this->deleteJson("{$this->baseUrl}/carts/{$cart->getKey()}/lines/{$line->getKey()}", [ + 'cart_version' => $cart->refresh()->cart_version, + ])->assertOk(); + + $this->assertDatabaseMissing('cart_lines', ['id' => $line->getKey()]); +}); + +it('returns 404 for nonexistent cart', function () { + $this->getJson("{$this->baseUrl}/carts/999") + ->assertNotFound(); +}); + +it('returns 409 on version mismatch', function () { + $variant = createPurchasableVariant($this->store, 2500); + $cartService = app(CartService::class); + $cart = $cartService->create($this->store); + $line = $cartService->addLine($cart, $variant->getKey(), 1); + $cartService->updateLineQuantity($cart, $line->getKey(), 2); + + expect($cart->refresh()->cart_version)->toBe(3); + + $this->putJson("{$this->baseUrl}/carts/{$cart->getKey()}/lines/{$line->getKey()}", [ + 'quantity' => 5, + 'expected_version' => 2, + ]) + ->assertConflict() + ->assertJsonPath('error_code', 'version_conflict') + ->assertJsonPath('current_version', 3) + ->assertJsonPath('cart.cart_version', 3); +}); + +it('respects storefront rate limiting', function () { + $cart = app(CartService::class)->create($this->store); + + foreach (range(1, 120) as $i) { + $this->getJson("{$this->baseUrl}/carts/{$cart->getKey()}")->assertOk(); + } + + $this->getJson("{$this->baseUrl}/carts/{$cart->getKey()}") + ->assertStatus(429) + ->assertJsonPath('message', 'Too many requests. Please try again later.'); +}); diff --git a/tests/Feature/Cart/CartServiceTest.php b/tests/Feature/Cart/CartServiceTest.php new file mode 100644 index 00000000..a7d5f314 --- /dev/null +++ b/tests/Feature/Cart/CartServiceTest.php @@ -0,0 +1,162 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->service = app(CartService::class); +}); + +it('creates a cart for the current store', function () { + $cart = $this->service->create($this->store); + + $this->assertDatabaseHas('carts', [ + 'id' => $cart->getKey(), + 'store_id' => $this->store->getKey(), + 'currency' => 'EUR', + 'cart_version' => 1, + 'status' => 'active', + ]); +}); + +it('adds a line item to the cart', function () { + $variant = createPurchasableVariant($this->store, 2500); + $cart = $this->service->create($this->store); + + $line = $this->service->addLine($cart, $variant->getKey(), 2); + + expect($line->unit_price_amount)->toBe(2500); + expect($line->line_subtotal_amount)->toBe(5000); + expect($line->line_total_amount)->toBe(5000); + $this->assertDatabaseHas('cart_lines', [ + 'cart_id' => $cart->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity' => 2, + ]); +}); + +it('increments quantity when adding an existing variant', function () { + $variant = createPurchasableVariant($this->store, 2500); + $cart = $this->service->create($this->store); + + $this->service->addLine($cart, $variant->getKey(), 1); + $line = $this->service->addLine($cart, $variant->getKey(), 2); + + expect($cart->lines()->count())->toBe(1); + expect($line->quantity)->toBe(3); + expect($line->line_subtotal_amount)->toBe(7500); +}); + +it('rejects add when product is not active', function () { + $product = Product::factory()->for($this->store)->create(); + $variant = ProductVariant::factory()->asDefault()->priced(2500)->for($product)->create(); + $cart = $this->service->create($this->store); + + $this->service->addLine($cart, $variant->getKey(), 1); +})->throws(ValidationException::class); + +it('rejects add when inventory is insufficient and policy is deny', function () { + $variant = createPurchasableVariant($this->store, 2500, 2); + $cart = $this->service->create($this->store); + + $this->service->addLine($cart, $variant->getKey(), 5); +})->throws(InsufficientInventoryException::class); + +it('allows add when inventory is insufficient but policy is continue', function () { + $variant = createPurchasableVariant($this->store, 2500, 2, policy: InventoryPolicy::Continue); + $cart = $this->service->create($this->store); + + $line = $this->service->addLine($cart, $variant->getKey(), 5); + + expect($line->quantity)->toBe(5); +}); + +it('updates line quantity', function () { + $variant = createPurchasableVariant($this->store, 2500); + $cart = $this->service->create($this->store); + $line = $this->service->addLine($cart, $variant->getKey(), 2); + + $updated = $this->service->updateLineQuantity($cart, $line->getKey(), 5); + + expect($updated->quantity)->toBe(5); + expect($updated->line_subtotal_amount)->toBe(12500); + expect($updated->line_total_amount)->toBe(12500); +}); + +it('removes a line when quantity set to zero', function () { + $variant = createPurchasableVariant($this->store, 2500); + $cart = $this->service->create($this->store); + $line = $this->service->addLine($cart, $variant->getKey(), 2); + + $result = $this->service->updateLineQuantity($cart, $line->getKey(), 0); + + expect($result)->toBeNull(); + $this->assertDatabaseMissing('cart_lines', ['id' => $line->getKey()]); +}); + +it('removes a specific line item', function () { + $variantA = createPurchasableVariant($this->store, 2500); + $variantB = createPurchasableVariant($this->store, 3500); + $cart = $this->service->create($this->store); + $lineA = $this->service->addLine($cart, $variantA->getKey(), 1); + $this->service->addLine($cart, $variantB->getKey(), 1); + + $this->service->removeLine($cart, $lineA->getKey()); + + expect($cart->lines()->count())->toBe(1); + expect($cart->lines()->first()->variant_id)->toBe($variantB->getKey()); +}); + +it('increments cart version on every mutation', function () { + $variant = createPurchasableVariant($this->store, 2500); + + $cart = $this->service->create($this->store); + expect($cart->cart_version)->toBe(1); + + $line = $this->service->addLine($cart, $variant->getKey(), 1); + expect($cart->refresh()->cart_version)->toBe(2); + + $this->service->updateLineQuantity($cart, $line->getKey(), 3); + expect($cart->refresh()->cart_version)->toBe(3); + + $this->service->removeLine($cart, $line->getKey()); + expect($cart->refresh()->cart_version)->toBe(4); +}); + +it('returns cart via session for guest users', function () { + $cart = $this->service->getOrCreateForSession($this->store); + + expect(session(CartService::SESSION_KEY))->toBe($cart->getKey()); + + $resolved = $this->service->getOrCreateForSession($this->store); + + expect($resolved->getKey())->toBe($cart->getKey()); +}); + +it('merges guest cart into customer cart on login', function () { + $variantA = createPurchasableVariant($this->store, 2500); + $variantB = createPurchasableVariant($this->store, 3500); + + $guestCart = Cart::factory()->for($this->store)->create(); + CartLine::factory()->for($guestCart)->priced(2500, 2)->create(['variant_id' => $variantA->getKey()]); + + $customerCart = Cart::factory()->forCustomer()->for($this->store)->create(); + CartLine::factory()->for($customerCart)->priced(2500, 1)->create(['variant_id' => $variantA->getKey()]); + CartLine::factory()->for($customerCart)->priced(3500, 3)->create(['variant_id' => $variantB->getKey()]); + + $merged = $this->service->mergeOnLogin($guestCart, $customerCart); + + expect($merged->lines()->count())->toBe(2); + expect($merged->lines()->where('variant_id', $variantA->getKey())->first()->quantity)->toBe(3); + expect($merged->lines()->where('variant_id', $variantB->getKey())->first()->quantity)->toBe(3); + expect($guestCart->refresh()->status)->toBe(CartStatus::Abandoned); +}); diff --git a/tests/Feature/Checkout/CheckoutFlowTest.php b/tests/Feature/Checkout/CheckoutFlowTest.php new file mode 100644 index 00000000..8249c536 --- /dev/null +++ b/tests/Feature/Checkout/CheckoutFlowTest.php @@ -0,0 +1,115 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->checkoutService = app(CheckoutService::class); +}); + +it('creates a checkout from a cart', function () { + $cart = Cart::factory()->for($this->store)->create(); + CartLine::factory()->for($cart)->priced(2500, 1)->create([ + 'variant_id' => createPurchasableVariant($this->store)->getKey(), + ]); + CartLine::factory()->for($cart)->priced(3500, 1)->create([ + 'variant_id' => createPurchasableVariant($this->store, 3500)->getKey(), + ]); + + $checkout = $this->checkoutService->createFromCart($cart); + + $this->assertDatabaseHas('checkouts', [ + 'id' => $checkout->getKey(), + 'cart_id' => $cart->getKey(), + 'store_id' => $this->store->getKey(), + 'status' => 'started', + ]); +}); + +it('completes full checkout happy path', function () { + $variant = createPurchasableVariant($this->store, 2500, 10); + $zone = ShippingZone::factory()->for($this->store)->create(['countries_json' => ['DE']]); + ShippingRate::factory()->for($zone, 'zone')->flatAmount(499)->create(); + + $cartService = app(CartService::class); + $cart = $cartService->create($this->store); + $cartService->addLine($cart, $variant->getKey(), 2); + + $checkout = $this->checkoutService->createFromCart($cart); + $checkout = $this->checkoutService->setAddress($checkout, [ + 'email' => 'shopper@example.test', + 'shipping_address' => validShippingAddress(), + ]); + $checkout = $this->checkoutService->setShippingMethod($checkout, $zone->rates()->first()->getKey()); + $checkout = $this->checkoutService->selectPaymentMethod($checkout, 'credit_card'); + + $order = $this->checkoutService->completeCheckout($checkout, ['card_number' => '4242424242424242']); + + $this->assertDatabaseHas('orders', [ + 'id' => $order->getKey(), + 'store_id' => $this->store->getKey(), + 'status' => 'paid', + 'financial_status' => 'paid', + 'total_amount' => 5499, + ]); + expect($checkout->refresh()->status)->toBe(CheckoutStatus::Completed); + expect($cart->refresh()->status)->toBe(CartStatus::Converted); + + $item = $variant->inventoryItem->refresh(); + expect($item->quantity_on_hand)->toBe(8); + expect($item->quantity_reserved)->toBe(0); +}); + +it('rejects checkout for empty cart', function () { + $cart = Cart::factory()->for($this->store)->create(); + + $this->checkoutService->createFromCart($cart); +})->throws(ValidationException::class); + +it('expires checkout after timeout', function () { + $variant = createPurchasableVariant($this->store, 2500, 10); + $zone = ShippingZone::factory()->for($this->store)->create(['countries_json' => ['DE']]); + ShippingRate::factory()->for($zone, 'zone')->flatAmount(499)->create(); + + $cartService = app(CartService::class); + $cart = $cartService->create($this->store); + $cartService->addLine($cart, $variant->getKey(), 3); + + $checkout = $this->checkoutService->createFromCart($cart); + $checkout = $this->checkoutService->setAddress($checkout, [ + 'email' => 'shopper@example.test', + 'shipping_address' => validShippingAddress(), + ]); + $checkout = $this->checkoutService->setShippingMethod($checkout, $zone->rates()->first()->getKey()); + $checkout = $this->checkoutService->selectPaymentMethod($checkout, 'credit_card'); + + expect($variant->inventoryItem->refresh()->quantity_reserved)->toBe(3); + + $checkout->forceFill(['expires_at' => now()->subHour()])->save(); + + (new ExpireAbandonedCheckouts)->handle($this->checkoutService); + + expect($checkout->refresh()->status)->toBe(CheckoutStatus::Expired); + expect($variant->inventoryItem->refresh()->quantity_reserved)->toBe(0); +}); + +it('prevents duplicate orders from same checkout', function () { + $checkout = createPaymentSelectedCheckout($this->store); + + $first = $this->checkoutService->completeCheckout($checkout, ['card_number' => '4242424242424242']); + $second = $this->checkoutService->completeCheckout($checkout->refresh(), ['card_number' => '4242424242424242']); + + expect($second->getKey())->toBe($first->getKey()); + expect(Order::query()->count())->toBe(1); +}); diff --git a/tests/Feature/Checkout/CheckoutStateTest.php b/tests/Feature/Checkout/CheckoutStateTest.php new file mode 100644 index 00000000..dd9b4556 --- /dev/null +++ b/tests/Feature/Checkout/CheckoutStateTest.php @@ -0,0 +1,181 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->checkoutService = app(CheckoutService::class); +}); + +/** + * Create a checkout in the "started" state from a single-line cart. + */ +function startedCheckout($test, array $variantAttributes = [], int $quantity = 1): Checkout +{ + $variant = createPurchasableVariant($test->store, 2500, 100, $variantAttributes); + + $cartService = app(CartService::class); + $cart = $cartService->create($test->store); + $cartService->addLine($cart, $variant->getKey(), $quantity); + + return $test->checkoutService->createFromCart($cart); +} + +/** + * Create a DE shipping zone with one flat rate for the store. + */ +function makeGermanZone($test, int $amount = 499): ShippingRate +{ + $zone = ShippingZone::factory()->for($test->store)->create(['countries_json' => ['DE']]); + + return ShippingRate::factory()->for($zone, 'zone')->flatAmount($amount)->create(); +} + +it('transitions from started to addressed with valid address', function () { + $checkout = startedCheckout($this); + + $checkout = $this->checkoutService->setAddress($checkout, [ + 'email' => 'shopper@example.test', + 'shipping_address' => validShippingAddress(), + ]); + + expect($checkout->status)->toBe(CheckoutStatus::Addressed); + expect($checkout->email)->toBe('shopper@example.test'); + expect($checkout->billing_address_json)->toBe($checkout->shipping_address_json); +}); + +it('rejects address transition with missing required fields', function () { + $checkout = startedCheckout($this); + + try { + $this->checkoutService->setAddress($checkout, [ + 'email' => 'shopper@example.test', + 'shipping_address' => collect(validShippingAddress())->except('city')->all(), + ]); + $this->fail('Expected a ValidationException.'); + } catch (ValidationException $exception) { + expect($exception->errors())->toHaveKey('shipping_address.city'); + } + + expect($checkout->refresh()->status)->toBe(CheckoutStatus::Started); +}); + +it('transitions from addressed to shipping_selected', function () { + $checkout = startedCheckout($this); + $rate = makeGermanZone($this); + + $checkout = $this->checkoutService->setAddress($checkout, [ + 'email' => 'shopper@example.test', + 'shipping_address' => validShippingAddress(), + ]); + + $checkout = $this->checkoutService->setShippingMethod($checkout, $rate->getKey()); + + expect($checkout->status)->toBe(CheckoutStatus::ShippingSelected); + expect($checkout->shipping_method_id)->toBe($rate->getKey()); +}); + +it('rejects shipping selection with rate from wrong zone', function () { + $checkout = startedCheckout($this); + + $usZone = ShippingZone::factory()->for($this->store)->create(['countries_json' => ['US']]); + $usRate = ShippingRate::factory()->for($usZone, 'zone')->create(); + + $checkout = $this->checkoutService->setAddress($checkout, [ + 'email' => 'shopper@example.test', + 'shipping_address' => validShippingAddress(), + ]); + + $this->checkoutService->setShippingMethod($checkout, $usRate->getKey()); +})->throws(InvalidShippingRateException::class); + +it('skips shipping selection when no items require shipping', function () { + $checkout = startedCheckout($this, ['requires_shipping' => false]); + + $checkout = $this->checkoutService->setAddress($checkout, [ + 'email' => 'shopper@example.test', + 'shipping_address' => validShippingAddress(), + ]); + + $checkout = $this->checkoutService->setShippingMethod($checkout); + + expect($checkout->status)->toBe(CheckoutStatus::ShippingSelected); + expect($checkout->shipping_method_id)->toBeNull(); + expect($checkout->totals_json['shipping'])->toBe(0); +}); + +it('transitions from shipping_selected to payment_selected', function () { + $checkout = startedCheckout($this, quantity: 2); + $rate = makeGermanZone($this); + + $checkout = $this->checkoutService->setAddress($checkout, [ + 'email' => 'shopper@example.test', + 'shipping_address' => validShippingAddress(), + ]); + $checkout = $this->checkoutService->setShippingMethod($checkout, $rate->getKey()); + + $checkout = $this->checkoutService->selectPaymentMethod($checkout, 'credit_card'); + + expect($checkout->status)->toBe(CheckoutStatus::PaymentSelected); + expect($checkout->payment_method)->toBe('credit_card'); + expect($checkout->expires_at)->not->toBeNull(); + + $reserved = $checkout->cart->lines()->first()->variant->inventoryItem->quantity_reserved; + expect($reserved)->toBe(2); +}); + +it('transitions from payment_selected to completed', function () { + $checkout = startedCheckout($this, quantity: 2); + $rate = makeGermanZone($this); + + $checkout = $this->checkoutService->setAddress($checkout, [ + 'email' => 'shopper@example.test', + 'shipping_address' => validShippingAddress(), + ]); + $checkout = $this->checkoutService->setShippingMethod($checkout, $rate->getKey()); + $checkout = $this->checkoutService->selectPaymentMethod($checkout, 'credit_card'); + + $order = $this->checkoutService->completeCheckout($checkout, ['card_number' => '4242424242424242']); + + expect($checkout->refresh()->status)->toBe(CheckoutStatus::Completed); + expect($order)->toBeInstanceOf(Order::class); + expect($order->checkout_id)->toBe($checkout->getKey()); +}); + +it('rejects invalid state transitions', function () { + $checkout = startedCheckout($this); + + $this->checkoutService->completeCheckout($checkout); +})->throws(InvalidCheckoutTransitionException::class); + +it('recalculates pricing on address change', function () { + $checkout = startedCheckout($this); + $rate = makeGermanZone($this); + + $checkout = $this->checkoutService->setAddress($checkout, [ + 'email' => 'shopper@example.test', + 'shipping_address' => validShippingAddress(), + ]); + $checkout = $this->checkoutService->setShippingMethod($checkout, $rate->getKey()); + + expect($checkout->totals_json['shipping'])->toBe(499); + + $checkout = $this->checkoutService->setAddress($checkout, [ + 'email' => 'shopper@example.test', + 'shipping_address' => validShippingAddress(['country_code' => 'FR', 'city' => 'Paris', 'postal_code' => '75001']), + ]); + + expect($checkout->status)->toBe(CheckoutStatus::Addressed); + expect($checkout->shipping_method_id)->toBeNull(); + expect($checkout->totals_json['shipping'])->toBe(0); +}); diff --git a/tests/Feature/Checkout/DiscountTest.php b/tests/Feature/Checkout/DiscountTest.php new file mode 100644 index 00000000..67dd5c37 --- /dev/null +++ b/tests/Feature/Checkout/DiscountTest.php @@ -0,0 +1,98 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->checkoutService = app(CheckoutService::class); +}); + +/** + * A started checkout with a single line at the given subtotal. + */ +function discountCheckout($test, int $subtotal, ?string $discountCode = null): Checkout +{ + $variant = createPurchasableVariant($test->store, $subtotal); + + $cartService = app(CartService::class); + $cart = $cartService->create($test->store); + $cartService->addLine($cart, $variant->getKey(), 1); + + return $test->checkoutService->createFromCart($cart, discountCode: $discountCode); +} + +it('applies a valid percent discount code at checkout', function () { + Discount::factory()->for($this->store)->create(['code' => 'SAVE10', 'value_amount' => 10]); + + $checkout = discountCheckout($this, 5000, 'SAVE10'); + + expect($checkout->totals_json['discount'])->toBe(500); +}); + +it('applies a valid fixed discount code at checkout', function () { + Discount::factory()->for($this->store)->fixed(500)->create(['code' => '5OFF']); + + $checkout = discountCheckout($this, 5000, '5OFF'); + + expect($checkout->totals_json['discount'])->toBe(500); +}); + +it('removes discount when code is cleared', function () { + Discount::factory()->for($this->store)->create(['code' => 'SAVE10', 'value_amount' => 10]); + + $checkout = discountCheckout($this, 5000, 'SAVE10'); + expect($checkout->totals_json['discount'])->toBe(500); + + $checkout->forceFill(['discount_code' => null])->save(); + $this->checkoutService->recalculate($checkout); + $checkout->refresh(); + + expect($checkout->totals_json['discount'])->toBe(0); + expect($checkout->totals_json['total'])->toBe(5000); +}); + +it('rejects expired discount at checkout', function () { + Discount::factory()->for($this->store)->create([ + 'code' => 'OLD20', + 'starts_at' => now()->subYear(), + 'ends_at' => now()->subDay(), + ]); + + $checkout = discountCheckout($this, 5000); + + app(DiscountService::class)->validate('OLD20', $this->store, $checkout->cart); +})->throws(InvalidDiscountException::class); + +it('increments usage count on order completion', function () { + $discount = Discount::factory()->for($this->store)->create(['code' => 'SAVE10', 'value_amount' => 10]); + expect($discount->usage_count)->toBe(0); + + $checkout = createPaymentSelectedCheckout($this->store, discountCode: 'SAVE10'); + + $this->checkoutService->completeCheckout($checkout, ['card_number' => '4242424242424242']); + + expect($discount->refresh()->usage_count)->toBe(1); +}); + +it('handles free shipping discount at checkout', function () { + Discount::factory()->for($this->store)->freeShipping()->create(['code' => 'FREESHIP']); + $zone = ShippingZone::factory()->for($this->store)->create(['countries_json' => ['DE']]); + $rate = ShippingRate::factory()->for($zone, 'zone')->flatAmount(499)->create(); + + $checkout = discountCheckout($this, 5000, 'FREESHIP'); + $checkout = $this->checkoutService->setAddress($checkout, [ + 'email' => 'shopper@example.test', + 'shipping_address' => validShippingAddress(), + ]); + $checkout = $this->checkoutService->setShippingMethod($checkout, $rate->getKey()); + + expect($checkout->totals_json['shipping'])->toBe(0); +}); diff --git a/tests/Feature/Checkout/PricingIntegrationTest.php b/tests/Feature/Checkout/PricingIntegrationTest.php new file mode 100644 index 00000000..466b6774 --- /dev/null +++ b/tests/Feature/Checkout/PricingIntegrationTest.php @@ -0,0 +1,107 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->checkoutService = app(CheckoutService::class); +}); + +/** + * Drive a checkout through address + shipping with one line of the given + * price and quantity. + */ +function pricingCheckout($test, int $price, int $quantity, ?int $rateId = null, array $variantAttributes = []): Checkout +{ + $variant = createPurchasableVariant($test->store, $price, 100, $variantAttributes); + + $cartService = app(CartService::class); + $cart = $cartService->create($test->store); + $cartService->addLine($cart, $variant->getKey(), $quantity); + + $checkout = $test->checkoutService->createFromCart($cart); + $checkout = $test->checkoutService->setAddress($checkout, [ + 'email' => 'shopper@example.test', + 'shipping_address' => validShippingAddress(), + ]); + + if ($rateId !== null) { + $checkout = $test->checkoutService->setShippingMethod($checkout, $rateId); + } + + return $checkout; +} + +it('calculates correct totals for a simple checkout', function () { + TaxSettings::factory()->for($this->store)->rateBasisPoints(1900)->create(); + $zone = ShippingZone::factory()->for($this->store)->create(['countries_json' => ['DE']]); + $rate = ShippingRate::factory()->for($zone, 'zone')->flatAmount(499)->create(); + + $checkout = pricingCheckout($this, 2500, 2, $rate->getKey()); + + expect($checkout->totals_json['subtotal'])->toBe(5000); + expect($checkout->totals_json['shipping'])->toBe(499); + expect($checkout->totals_json['tax'])->toBe(1044); + expect($checkout->totals_json['total'])->toBe(6543); +}); + +it('applies discount code and recalculates', function () { + Discount::factory()->for($this->store)->create(['code' => 'SAVE10', 'value_amount' => 10]); + + $checkout = pricingCheckout($this, 10000, 1); + + $checkout->forceFill(['discount_code' => 'SAVE10'])->save(); + $this->checkoutService->recalculate($checkout); + $checkout->refresh(); + + expect($checkout->totals_json['discount'])->toBe(1000); + expect($checkout->totals_json['subtotal'] - $checkout->totals_json['discount'])->toBe(9000); +}); + +it('stores pricing snapshot in totals_json', function () { + TaxSettings::factory()->for($this->store)->rateBasisPoints(1900)->create(); + + $checkout = pricingCheckout($this, 2500, 2); + + expect($checkout->totals_json) + ->toHaveKeys(['subtotal', 'discount', 'shipping', 'tax_lines', 'tax', 'total', 'currency']); +}); + +it('recalculates on shipping method change', function () { + $zone = ShippingZone::factory()->for($this->store)->create(['countries_json' => ['DE']]); + $flatRate = ShippingRate::factory()->for($zone, 'zone')->flatAmount(499)->create(); + $weightRate = ShippingRate::factory()->for($zone, 'zone')->create([ + 'type' => 'weight', + 'config_json' => [ + 'ranges' => [ + ['min_g' => 0, 'max_g' => 500, 'amount' => 499], + ['min_g' => 501, 'max_g' => 2000, 'amount' => 899], + ], + ], + ]); + + $checkout = pricingCheckout($this, 2500, 3, $flatRate->getKey(), ['weight_g' => 250]); + + expect($checkout->totals_json['shipping'])->toBe(499); + + $checkout = $this->checkoutService->setShippingMethod($checkout, $weightRate->getKey()); + + expect($checkout->totals_json['shipping'])->toBe(899); +}); + +it('handles prices-include-tax correctly', function () { + TaxSettings::factory()->for($this->store)->rateBasisPoints(1900)->pricesIncludeTax()->create(); + + $checkout = pricingCheckout($this, 11900, 1); + + expect($checkout->totals_json['tax'])->toBe(1900); + expect($checkout->totals_json['total'])->toBe(11900); + expect($checkout->totals_json['total'] - $checkout->totals_json['tax'])->toBe(10000); +}); diff --git a/tests/Feature/Checkout/ShippingTest.php b/tests/Feature/Checkout/ShippingTest.php new file mode 100644 index 00000000..9ba39c90 --- /dev/null +++ b/tests/Feature/Checkout/ShippingTest.php @@ -0,0 +1,89 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->checkoutService = app(CheckoutService::class); +}); + +/** + * An addressed checkout with a single line. + */ +function shippingCheckout($test, array $variantAttributes = [], int $quantity = 1): Checkout +{ + $variant = createPurchasableVariant($test->store, 2500, 100, $variantAttributes); + + $cartService = app(CartService::class); + $cart = $cartService->create($test->store); + $cartService->addLine($cart, $variant->getKey(), $quantity); + + $checkout = $test->checkoutService->createFromCart($cart); + + return $test->checkoutService->setAddress($checkout, [ + 'email' => 'shopper@example.test', + 'shipping_address' => validShippingAddress(), + ]); +} + +it('returns available shipping rates for address', function () { + $zone = ShippingZone::factory()->for($this->store)->create(['countries_json' => ['DE']]); + ShippingRate::factory()->for($zone, 'zone')->flatAmount(499)->create(['name' => 'Standard Shipping']); + + $rates = app(ShippingCalculator::class)->getAvailableRates($this->store, ['country_code' => 'DE']); + + expect($rates)->toHaveCount(1); + expect($rates->first()->name)->toBe('Standard Shipping'); + expect($rates->first()->config_json['amount'])->toBe(499); +}); + +it('returns empty when no zone matches address', function () { + $zone = ShippingZone::factory()->for($this->store)->create(['countries_json' => ['DE']]); + ShippingRate::factory()->for($zone, 'zone')->create(); + + $rates = app(ShippingCalculator::class)->getAvailableRates($this->store, ['country_code' => 'FR']); + + expect($rates)->toBeEmpty(); +}); + +it('calculates flat rate correctly', function () { + $zone = ShippingZone::factory()->for($this->store)->create(['countries_json' => ['DE']]); + $rate = ShippingRate::factory()->for($zone, 'zone')->flatAmount(499)->create(); + + $checkout = shippingCheckout($this); + $checkout = $this->checkoutService->setShippingMethod($checkout, $rate->getKey()); + + expect($checkout->totals_json['shipping'])->toBe(499); +}); + +it('calculates weight-based rate correctly', function () { + $zone = ShippingZone::factory()->for($this->store)->create(['countries_json' => ['DE']]); + $rate = ShippingRate::factory()->for($zone, 'zone')->create([ + 'type' => 'weight', + 'config_json' => [ + 'ranges' => [ + ['min_g' => 0, 'max_g' => 500, 'amount' => 499], + ['min_g' => 501, 'max_g' => 2000, 'amount' => 899], + ], + ], + ]); + + $checkout = shippingCheckout($this, ['weight_g' => 250], 3); + $checkout = $this->checkoutService->setShippingMethod($checkout, $rate->getKey()); + + expect($checkout->totals_json['shipping'])->toBe(899); +}); + +it('returns zero shipping when all items are digital', function () { + $checkout = shippingCheckout($this, ['requires_shipping' => false]); + + $checkout = $this->checkoutService->setShippingMethod($checkout); + + expect($checkout->totals_json['shipping'])->toBe(0); +}); diff --git a/tests/Feature/Checkout/TaxTest.php b/tests/Feature/Checkout/TaxTest.php new file mode 100644 index 00000000..41fb092e --- /dev/null +++ b/tests/Feature/Checkout/TaxTest.php @@ -0,0 +1,79 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->checkoutService = app(CheckoutService::class); +}); + +/** + * A checkout addressed in Germany with one line and an optional flat rate. + */ +function taxCheckout($test, int $price, int $quantity, ?int $flatRateAmount = null): Checkout +{ + $variant = createPurchasableVariant($test->store, $price); + + $cartService = app(CartService::class); + $cart = $cartService->create($test->store); + $cartService->addLine($cart, $variant->getKey(), $quantity); + + $checkout = $test->checkoutService->createFromCart($cart); + $checkout = $test->checkoutService->setAddress($checkout, [ + 'email' => 'shopper@example.test', + 'shipping_address' => validShippingAddress(), + ]); + + if ($flatRateAmount !== null) { + $zone = ShippingZone::factory()->for($test->store)->create(['countries_json' => ['DE']]); + $rate = ShippingRate::factory()->for($zone, 'zone')->flatAmount($flatRateAmount)->create(); + $checkout = $test->checkoutService->setShippingMethod($checkout, $rate->getKey()); + } + + return $checkout; +} + +it('calculates exclusive tax correctly at checkout', function () { + TaxSettings::factory()->for($this->store)->rateBasisPoints(1900)->create(); + + $checkout = taxCheckout($this, 2500, 2, 499); + + // Discounted subtotal + shipping = 5499; 19% of 5499 = 1044. + expect($checkout->totals_json['tax'])->toBe(1044); + expect($checkout->totals_json['total'])->toBe(6543); +}); + +it('extracts inclusive tax correctly at checkout', function () { + TaxSettings::factory()->for($this->store)->rateBasisPoints(1900)->pricesIncludeTax()->create(); + + $checkout = taxCheckout($this, 11900, 1); + + expect($checkout->totals_json['tax'])->toBe(1900); + expect($checkout->totals_json['total'])->toBe(11900); +}); + +it('applies zero tax when no tax settings exist', function () { + $checkout = taxCheckout($this, 5000, 1); + + expect($checkout->totals_json['tax'])->toBe(0); + expect($checkout->totals_json['tax_lines'])->toBe([]); +}); + +it('stores tax lines in totals_json', function () { + TaxSettings::factory()->for($this->store)->rateBasisPoints(1900)->create(); + + $checkout = taxCheckout($this, 10000, 1); + + expect($checkout->totals_json['tax_lines'])->toHaveCount(1); + expect($checkout->totals_json['tax_lines'][0])->toMatchArray([ + 'name' => 'Tax', + 'rate' => 1900, + 'amount' => 1900, + ]); +}); diff --git a/tests/Feature/Customers/AddressManagementTest.php b/tests/Feature/Customers/AddressManagementTest.php new file mode 100644 index 00000000..6f601635 --- /dev/null +++ b/tests/Feature/Customers/AddressManagementTest.php @@ -0,0 +1,160 @@ +store = Store::factory()->create(); + $this->domain = StoreDomain::factory()->for($this->store)->create(); + $this->baseUrl = 'http://'.$this->domain->hostname; + $this->customer = Customer::factory()->for($this->store)->create(); + + app()->instance('current_store', $this->store); +}); + +it('lists saved addresses', function () { + CustomerAddress::factory()->for($this->customer)->create([ + 'label' => 'Home', + 'address_json' => addressJsonFixture(['address1' => 'Musterstrasse 1']), + 'is_default' => true, + ]); + + CustomerAddress::factory()->for($this->customer)->create([ + 'label' => 'Work', + 'address_json' => addressJsonFixture(['address1' => 'Friedrichstrasse 100']), + 'is_default' => false, + ]); + + actingAsCustomer($this->customer) + ->get($this->baseUrl.'/account/addresses') + ->assertOk() + ->assertSee('Musterstrasse 1') + ->assertSee('Friedrichstrasse 100'); +}); + +it('creates a new address', function () { + actingAsCustomer($this->customer); + + Livewire::test(AddressBook::class) + ->call('create') + ->set('label', 'Home') + ->set('form.first_name', 'Jane') + ->set('form.last_name', 'Shopper') + ->set('form.address1', 'Musterstrasse 1') + ->set('form.city', 'Berlin') + ->set('form.postal_code', '10115') + ->set('form.country_code', 'DE') + ->call('save') + ->assertHasNoErrors() + ->assertSet('showForm', false); + + $address = $this->customer->addresses()->sole(); + + expect($address->label)->toBe('Home'); + expect($address->address_json['address1'])->toBe('Musterstrasse 1'); + expect($address->address_json['zip'])->toBe('10115'); + expect($address->address_json['country'])->toBe('Germany'); + expect($address->is_default)->toBeTrue(); +}); + +it('updates an existing address', function () { + $address = CustomerAddress::factory()->for($this->customer)->create([ + 'address_json' => addressJsonFixture(['city' => 'Berlin']), + ]); + + actingAsCustomer($this->customer); + + Livewire::test(AddressBook::class) + ->call('edit', $address->getKey()) + ->assertSet('form.city', 'Berlin') + ->set('form.city', 'Hamburg') + ->call('save') + ->assertHasNoErrors(); + + expect($address->refresh()->address_json['city'])->toBe('Hamburg'); +}); + +it('deletes an address', function () { + $address = CustomerAddress::factory()->for($this->customer)->create(); + + actingAsCustomer($this->customer); + + Livewire::test(AddressBook::class) + ->call('delete', $address->getKey()); + + $this->assertDatabaseMissing('customer_addresses', ['id' => $address->getKey()]); +}); + +it('sets a default address', function () { + $defaultAddress = CustomerAddress::factory()->for($this->customer)->create(['is_default' => true]); + $otherAddress = CustomerAddress::factory()->for($this->customer)->create(['is_default' => false]); + + actingAsCustomer($this->customer); + + Livewire::test(AddressBook::class) + ->call('setDefault', $otherAddress->getKey()); + + expect($otherAddress->refresh()->is_default)->toBeTrue(); + expect($defaultAddress->refresh()->is_default)->toBeFalse(); +}); + +it('validates required address fields', function () { + actingAsCustomer($this->customer); + + Livewire::test(AddressBook::class) + ->call('create') + ->set('form.first_name', 'Jane') + ->set('form.last_name', 'Shopper') + ->set('form.city', 'Berlin') + ->set('form.postal_code', '10115') + ->set('form.country_code', 'DE') + ->call('save') + ->assertHasErrors(['form.address1' => 'required']); + + expect($this->customer->addresses()->count())->toBe(0); +}); + +it('prevents managing another customers addresses', function () { + $otherCustomer = Customer::factory()->for($this->store)->create(); + $otherAddress = CustomerAddress::factory()->for($otherCustomer)->create(); + + actingAsCustomer($this->customer); + + Livewire::test(AddressBook::class) + ->call('edit', $otherAddress->getKey()) + ->assertNotFound(); + + Livewire::test(AddressBook::class) + ->call('delete', $otherAddress->getKey()) + ->assertNotFound(); + + expect(CustomerAddress::query()->whereKey($otherAddress->getKey())->exists())->toBeTrue(); +}); + +/** + * A complete spec 01 address JSON object with optional overrides. + * + * @param array $overrides + * @return array + */ +function addressJsonFixture(array $overrides = []): array +{ + return array_merge([ + 'first_name' => 'Jane', + 'last_name' => 'Shopper', + 'company' => '', + 'address1' => 'Musterstrasse 1', + 'address2' => '', + 'city' => 'Berlin', + 'province' => '', + 'province_code' => '', + 'country' => 'Germany', + 'country_code' => 'DE', + 'zip' => '10115', + 'phone' => '', + ], $overrides); +} diff --git a/tests/Feature/Customers/CustomerAccountTest.php b/tests/Feature/Customers/CustomerAccountTest.php new file mode 100644 index 00000000..e7b595e7 --- /dev/null +++ b/tests/Feature/Customers/CustomerAccountTest.php @@ -0,0 +1,99 @@ +store = Store::factory()->create(); + $this->domain = StoreDomain::factory()->for($this->store)->create(); + $this->baseUrl = 'http://'.$this->domain->hostname; + $this->customer = Customer::factory()->for($this->store)->create(['name' => 'Jane Shopper']); +}); + +it('renders the customer dashboard', function () { + actingAsCustomer($this->customer) + ->get($this->baseUrl.'/account') + ->assertOk() + ->assertSee('Jane Shopper'); +}); + +it('lists customer orders', function () { + foreach (['#1001', '#1002', '#1003'] as $number) { + Order::factory()->paid()->for($this->store)->create([ + 'customer_id' => $this->customer->getKey(), + 'order_number' => $number, + ]); + } + + actingAsCustomer($this->customer) + ->get($this->baseUrl.'/account/orders') + ->assertOk() + ->assertSee('#1001') + ->assertSee('#1002') + ->assertSee('#1003'); +}); + +it('shows order detail', function () { + $order = Order::factory()->paid()->for($this->store)->create([ + 'customer_id' => $this->customer->getKey(), + 'order_number' => '#1001', + 'currency' => 'USD', + 'subtotal_amount' => 5000, + 'total_amount' => 5000, + ]); + + OrderLine::factory()->for($order)->create([ + 'title_snapshot' => 'Classic Tee', + 'quantity' => 2, + 'unit_price_amount' => 2500, + 'total_amount' => 5000, + ]); + + actingAsCustomer($this->customer) + ->get($this->baseUrl.'/account/orders/1001') + ->assertOk() + ->assertSee('#1001') + ->assertSee('Classic Tee') + ->assertSee('50.00 USD'); +}); + +it('prevents accessing another customers orders', function () { + $otherCustomer = Customer::factory()->for($this->store)->create(); + + Order::factory()->paid()->for($this->store)->create([ + 'customer_id' => $otherCustomer->getKey(), + 'order_number' => '#2001', + ]); + + actingAsCustomer($this->customer) + ->get($this->baseUrl.'/account/orders/2001') + ->assertNotFound(); +}); + +it('redirects unauthenticated requests to login', function () { + $this->get($this->baseUrl.'/account') + ->assertRedirect($this->baseUrl.'/account/login'); +}); + +it('updates customer profile', function () { + app()->instance('current_store', $this->store); + + actingAsCustomer($this->customer); + + Livewire::test(Dashboard::class) + ->set('name', 'Jane Updated') + ->set('marketingOptIn', true) + ->call('updateProfile') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('customers', [ + 'id' => $this->customer->getKey(), + 'name' => 'Jane Updated', + 'marketing_opt_in' => 1, + ]); +}); diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php deleted file mode 100644 index fcd0258d..00000000 --- a/tests/Feature/DashboardTest.php +++ /dev/null @@ -1,18 +0,0 @@ -get(route('dashboard')); - $response->assertRedirect(route('login')); -}); - -test('authenticated users can visit the dashboard', function () { - $user = User::factory()->create(); - $this->actingAs($user); - - $response = $this->get(route('dashboard')); - $response->assertOk(); -}); \ No newline at end of file diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index 8b5843f4..00000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,7 +0,0 @@ -get('/'); - - $response->assertStatus(200); -}); diff --git a/tests/Feature/Orders/FulfillmentTest.php b/tests/Feature/Orders/FulfillmentTest.php new file mode 100644 index 00000000..e744dbc5 --- /dev/null +++ b/tests/Feature/Orders/FulfillmentTest.php @@ -0,0 +1,180 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->fulfillmentService = app(FulfillmentService::class); +}); + +/** + * A paid order with two lines (qty 3 and qty 2). + * + * @return array{order: Order, lineA: OrderLine, lineB: OrderLine} + */ +function fulfillableOrder($test): array +{ + $order = Order::factory()->paid()->for($test->store)->create(); + $lineA = OrderLine::factory()->for($order)->create(['quantity' => 3]); + $lineB = OrderLine::factory()->for($order)->create(['quantity' => 2]); + + return ['order' => $order, 'lineA' => $lineA, 'lineB' => $lineB]; +} + +it('creates a fulfillment for specific order lines', function () { + ['order' => $order, 'lineA' => $lineA] = fulfillableOrder($this); + + $fulfillment = $this->fulfillmentService->create($order, [$lineA->getKey() => 3]); + + $this->assertDatabaseHas('fulfillments', [ + 'id' => $fulfillment->getKey(), + 'order_id' => $order->getKey(), + 'status' => 'pending', + ]); + expect($fulfillment->lines)->toHaveCount(1); + expect($fulfillment->lines->first()->order_line_id)->toBe($lineA->getKey()); +}); + +it('updates order fulfillment status to partial', function () { + ['order' => $order, 'lineA' => $lineA] = fulfillableOrder($this); + + $this->fulfillmentService->create($order, [$lineA->getKey() => 3]); + + expect($order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Partial); +}); + +it('updates order fulfillment status to fulfilled when all lines done', function () { + ['order' => $order, 'lineA' => $lineA, 'lineB' => $lineB] = fulfillableOrder($this); + + $this->fulfillmentService->create($order, [$lineA->getKey() => 3]); + $this->fulfillmentService->create($order, [$lineB->getKey() => 2]); + + expect($order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled); +}); + +it('adds tracking information', function () { + ['order' => $order, 'lineA' => $lineA] = fulfillableOrder($this); + + $fulfillment = $this->fulfillmentService->create($order, [$lineA->getKey() => 3]); + + $this->fulfillmentService->markAsShipped($fulfillment, [ + 'tracking_company' => 'DHL', + 'tracking_number' => '123456', + ]); + + $fulfillment->refresh(); + expect($fulfillment->tracking_company)->toBe('DHL'); + expect($fulfillment->tracking_number)->toBe('123456'); + expect($fulfillment->shipped_at)->not->toBeNull(); +}); + +it('transitions fulfillment from pending to shipped', function () { + ['order' => $order, 'lineA' => $lineA] = fulfillableOrder($this); + + $fulfillment = $this->fulfillmentService->create($order, [$lineA->getKey() => 3]); + expect($fulfillment->status)->toBe(FulfillmentShipmentStatus::Pending); + + $this->fulfillmentService->markAsShipped($fulfillment); + + expect($fulfillment->refresh()->status)->toBe(FulfillmentShipmentStatus::Shipped); +}); + +it('transitions fulfillment from shipped to delivered', function () { + ['order' => $order, 'lineA' => $lineA] = fulfillableOrder($this); + + $fulfillment = $this->fulfillmentService->create($order, [$lineA->getKey() => 3]); + $this->fulfillmentService->markAsShipped($fulfillment); + + $this->fulfillmentService->markAsDelivered($fulfillment); + + $fulfillment->refresh(); + expect($fulfillment->status)->toBe(FulfillmentShipmentStatus::Delivered); + expect($fulfillment->delivered_at)->not->toBeNull(); +}); + +it('prevents fulfilling more than ordered quantity', function () { + ['order' => $order, 'lineB' => $lineB] = fulfillableOrder($this); + + $this->fulfillmentService->create($order, [$lineB->getKey() => 3]); +})->throws(ValidationException::class); + +it('fulfillment guard blocks fulfillment when financial_status is pending', function () { + $order = Order::factory()->pending()->for($this->store)->create(); + $line = OrderLine::factory()->for($order)->create(['quantity' => 1]); + + $this->fulfillmentService->create($order, [$line->getKey() => 1]); +})->throws(FulfillmentGuardException::class); + +it('fulfillment guard allows fulfillment when financial_status is paid', function () { + ['order' => $order, 'lineA' => $lineA] = fulfillableOrder($this); + + $fulfillment = $this->fulfillmentService->create($order, [$lineA->getKey() => 1]); + + expect($fulfillment->exists)->toBeTrue(); +}); + +it('fulfillment guard allows fulfillment when financial_status is partially_refunded', function () { + $order = Order::factory()->paid()->for($this->store)->create([ + 'financial_status' => FinancialStatus::PartiallyRefunded, + ]); + $line = OrderLine::factory()->for($order)->create(['quantity' => 1]); + + $fulfillment = $this->fulfillmentService->create($order, [$line->getKey() => 1]); + + expect($fulfillment->exists)->toBeTrue(); +}); + +it('auto-fulfills digital products on payment confirmation', function () { + $checkout = createPaymentSelectedCheckout( + $this->store, + 'bank_transfer', + variantAttributes: ['requires_shipping' => false], + ); + + $order = app(CheckoutService::class)->completeCheckout($checkout); + expect($order->financial_status)->toBe(FinancialStatus::Pending); + expect($order->fulfillments)->toHaveCount(0); + + app(OrderService::class)->confirmBankTransferPayment($order); + + $order->refresh(); + $fulfillment = $order->fulfillments->first(); + expect($fulfillment)->not->toBeNull(); + expect($fulfillment->status)->toBe(FulfillmentShipmentStatus::Delivered); + expect($order->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled); +}); + +it('only allows admin, owner, or staff to create fulfillments', function () { + ['order' => $order] = fulfillableOrder($this); + + $support = User::factory()->create(); + StoreUser::query()->create([ + 'store_id' => $this->store->getKey(), + 'user_id' => $support->getKey(), + 'role' => StoreUserRole::Support, + ]); + + $staff = User::factory()->create(); + StoreUser::query()->create([ + 'store_id' => $this->store->getKey(), + 'user_id' => $staff->getKey(), + 'role' => StoreUserRole::Staff, + ]); + + expect(Gate::forUser($support)->denies('createFulfillment', $order))->toBeTrue(); + expect(Gate::forUser($staff)->allows('createFulfillment', $order))->toBeTrue(); +}); diff --git a/tests/Feature/Orders/OrderCreationTest.php b/tests/Feature/Orders/OrderCreationTest.php new file mode 100644 index 00000000..5d27c5be --- /dev/null +++ b/tests/Feature/Orders/OrderCreationTest.php @@ -0,0 +1,122 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->checkoutService = app(CheckoutService::class); +}); + +it('creates an order from a completed checkout', function () { + $checkout = createPaymentSelectedCheckout($this->store, 'bank_transfer', quantity: 2); + + $order = $this->checkoutService->completeCheckout($checkout); + + $this->assertDatabaseHas('orders', [ + 'id' => $order->getKey(), + 'store_id' => $this->store->getKey(), + 'status' => 'pending', + 'subtotal_amount' => 5000, + 'shipping_amount' => 499, + 'total_amount' => 5499, + ]); +}); + +it('generates sequential order numbers per store', function () { + $orderNumbers = []; + + foreach (range(1, 3) as $i) { + $checkout = createPaymentSelectedCheckout($this->store); + $orderNumbers[] = $this->checkoutService + ->completeCheckout($checkout, ['card_number' => '4242424242424242']) + ->order_number; + } + + expect($orderNumbers)->toBe(['#1001', '#1002', '#1003']); +}); + +it('creates order lines with snapshots', function () { + $checkout = createPaymentSelectedCheckout($this->store, quantity: 2, variantAttributes: ['sku' => 'SNAP-001']); + + $order = $this->checkoutService->completeCheckout($checkout, ['card_number' => '4242424242424242']); + + $lines = $order->lines; + expect($lines)->toHaveCount(1); + expect($lines->first()->title_snapshot)->not->toBeEmpty(); + expect($lines->first()->sku_snapshot)->not->toBeEmpty(); + expect($lines->first()->quantity)->toBe(2); +}); + +it('commits inventory on order creation', function () { + $checkout = createPaymentSelectedCheckout($this->store, quantity: 3, quantityOnHand: 10); + + $item = $checkout->cart->lines()->first()->variant->inventoryItem; + expect($item->refresh()->quantity_reserved)->toBe(3); + + $this->checkoutService->completeCheckout($checkout, ['card_number' => '4242424242424242']); + + $item->refresh(); + expect($item->quantity_on_hand)->toBe(7); + expect($item->quantity_reserved)->toBe(0); +}); + +it('marks cart as converted', function () { + $checkout = createPaymentSelectedCheckout($this->store); + + $this->checkoutService->completeCheckout($checkout, ['card_number' => '4242424242424242']); + + expect($checkout->cart->refresh()->status)->toBe(CartStatus::Converted); +}); + +it('dispatches OrderCreated event', function () { + $checkout = createPaymentSelectedCheckout($this->store); + + Event::fake([OrderCreated::class]); + + $order = $this->checkoutService->completeCheckout($checkout, ['card_number' => '4242424242424242']); + + Event::assertDispatched( + OrderCreated::class, + fn (OrderCreated $event): bool => $event->order->is($order), + ); +}); + +it('preserves order data when product is deleted', function () { + $checkout = createPaymentSelectedCheckout($this->store, variantAttributes: ['sku' => 'SNAP-002']); + + $order = $this->checkoutService->completeCheckout($checkout, ['card_number' => '4242424242424242']); + + $product = $order->lines->first()->variant->product; + app(ProductService::class)->transitionStatus($product, ProductStatus::Archived); + + $line = $order->refresh()->lines->first(); + expect($line->title_snapshot)->not->toBeEmpty(); + expect($line->sku_snapshot)->not->toBeEmpty(); +}); + +it('links order to customer when authenticated', function () { + $customer = Customer::factory()->for($this->store)->create(); + + $checkout = createPaymentSelectedCheckout($this->store, customer: $customer); + + $order = $this->checkoutService->completeCheckout($checkout, ['card_number' => '4242424242424242']); + + expect($order->customer_id)->toBe($customer->getKey()); +}); + +it('sets email from checkout on the order', function () { + $checkout = createPaymentSelectedCheckout($this->store, email: 'test@example.com'); + + $order = $this->checkoutService->completeCheckout($checkout, ['card_number' => '4242424242424242']); + + expect($order->email)->toBe('test@example.com'); + expect(Order::query()->find($order->getKey())->email)->toBe('test@example.com'); +}); diff --git a/tests/Feature/Orders/RefundTest.php b/tests/Feature/Orders/RefundTest.php new file mode 100644 index 00000000..afd729df --- /dev/null +++ b/tests/Feature/Orders/RefundTest.php @@ -0,0 +1,103 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->refundService = app(RefundService::class); +}); + +/** + * A paid order with a captured payment for the given total. + * + * @return array{order: Order, payment: Payment} + */ +function refundableOrder($test, int $total = 5000): array +{ + $order = Order::factory()->paid()->totaling($total)->for($test->store)->create(); + $payment = Payment::factory()->captured()->for($order)->create(['amount' => $total]); + + return ['order' => $order, 'payment' => $payment]; +} + +it('creates a full refund', function () { + ['order' => $order, 'payment' => $payment] = refundableOrder($this); + + $refund = $this->refundService->create($order, $payment, 5000); + + $this->assertDatabaseHas('refunds', [ + 'id' => $refund->getKey(), + 'order_id' => $order->getKey(), + 'amount' => 5000, + 'status' => 'processed', + ]); + expect($order->refresh()->financial_status)->toBe(FinancialStatus::Refunded); +}); + +it('creates a partial refund', function () { + ['order' => $order, 'payment' => $payment] = refundableOrder($this); + + $this->refundService->create($order, $payment, 2000); + + expect($order->refresh()->financial_status)->toBe(FinancialStatus::PartiallyRefunded); +}); + +it('rejects refund exceeding payment amount', function () { + ['order' => $order, 'payment' => $payment] = refundableOrder($this); + + $this->refundService->create($order, $payment, 6000); +})->throws(ValidationException::class); + +it('restocks inventory when restock flag is true', function () { + ['order' => $order, 'payment' => $payment] = refundableOrder($this); + + $variant = createPurchasableVariant($this->store, quantityOnHand: 5); + OrderLine::factory()->for($order)->forVariant($variant)->create(['quantity' => 2]); + + $this->refundService->create($order, $payment, 5000, restock: true); + + expect($variant->inventoryItem->refresh()->quantity_on_hand)->toBe(7); +}); + +it('does not restock when restock flag is false', function () { + ['order' => $order, 'payment' => $payment] = refundableOrder($this); + + $variant = createPurchasableVariant($this->store, quantityOnHand: 5); + OrderLine::factory()->for($order)->forVariant($variant)->create(['quantity' => 2]); + + $this->refundService->create($order, $payment, 5000, restock: false); + + expect($variant->inventoryItem->refresh()->quantity_on_hand)->toBe(5); +}); + +it('only allows admin or owner to process refunds', function () { + ['order' => $order] = refundableOrder($this); + + $staff = User::factory()->create(); + StoreUser::query()->create([ + 'store_id' => $this->store->getKey(), + 'user_id' => $staff->getKey(), + 'role' => StoreUserRole::Staff, + ]); + + expect(Gate::forUser($staff)->denies('createRefund', $order))->toBeTrue(); + expect(Gate::forUser($this->context['user'])->allows('createRefund', $order))->toBeTrue(); +}); + +it('records refund reason', function () { + ['order' => $order, 'payment' => $payment] = refundableOrder($this); + + $refund = $this->refundService->create($order, $payment, 5000, 'Customer requested'); + + expect($refund->reason)->toBe('Customer requested'); +}); diff --git a/tests/Feature/Orders/StructuredLoggingTest.php b/tests/Feature/Orders/StructuredLoggingTest.php new file mode 100644 index 00000000..67fd9042 --- /dev/null +++ b/tests/Feature/Orders/StructuredLoggingTest.php @@ -0,0 +1,90 @@ +}> $logged + */ +function fakeStructuredChannel(array &$logged): void +{ + $channel = Mockery::mock(LoggerInterface::class); + + foreach (['info', 'warning'] as $level) { + $channel->shouldReceive($level)->andReturnUsing( + function (string $event, array $context = []) use (&$logged, $level): void { + $logged[] = [$level, $event, $context]; + }, + ); + } + + Log::shouldReceive('channel')->with('structured')->andReturn($channel); + Log::shouldReceive('channel')->andReturn(Mockery::spy(LoggerInterface::class))->byDefault(); + Log::shouldReceive('info', 'warning', 'error', 'debug')->andReturnNull()->byDefault(); +} + +it('writes an order.created entry to the structured channel when a checkout completes', function () { + ['store' => $store] = createStoreContext(); + + $logged = []; + fakeStructuredChannel($logged); + + $checkout = createPaymentSelectedCheckout($store); + + $order = app(CheckoutService::class)->completeCheckout($checkout, [ + 'card_number' => MockPaymentProvider::CARD_SUCCESS, + ]); + + expect(array_column($logged, 1))->toContain('order.created'); + + [, , $context] = collect($logged)->first(fn (array $entry): bool => $entry[1] === 'order.created'); + + expect($context['order_id'])->toBe($order->getKey()) + ->and($context['store_id'])->toBe($store->getKey()) + ->and($context['total_amount'])->toBe($order->total_amount); +}); + +it('writes an order.paid entry to the structured channel when a bank transfer is confirmed', function () { + ['store' => $store] = createStoreContext(); + + $logged = []; + fakeStructuredChannel($logged); + + $checkout = createPaymentSelectedCheckout($store, paymentMethod: 'bank_transfer'); + $order = app(CheckoutService::class)->completeCheckout($checkout); + + app(OrderService::class)->confirmBankTransferPayment($order); + + expect(array_column($logged, 1))->toContain('order.paid'); + + [, , $context] = collect($logged)->first(fn (array $entry): bool => $entry[1] === 'order.paid'); + + expect($context['order_id'])->toBe($order->getKey()) + ->and($context['financial_status'])->toBe('paid'); +}); + +it('writes a payment.failed entry to the structured channel when the charge is declined', function () { + ['store' => $store] = createStoreContext(); + + $logged = []; + fakeStructuredChannel($logged); + + $checkout = createPaymentSelectedCheckout($store); + + expect(fn () => app(CheckoutService::class)->completeCheckout($checkout, [ + 'card_number' => MockPaymentProvider::CARD_DECLINED, + ]))->toThrow(PaymentFailedException::class); + + expect(array_column($logged, 1))->toContain('payment.failed'); + + [, , $context] = collect($logged)->first(fn (array $entry): bool => $entry[1] === 'payment.failed'); + + expect($context['checkout_id'])->toBe($checkout->getKey()) + ->and($context['error_code'])->toBe('card_declined'); +}); diff --git a/tests/Feature/Payments/BankTransferConfirmationTest.php b/tests/Feature/Payments/BankTransferConfirmationTest.php new file mode 100644 index 00000000..4cf6737f --- /dev/null +++ b/tests/Feature/Payments/BankTransferConfirmationTest.php @@ -0,0 +1,105 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->orderService = app(OrderService::class); +}); + +/** + * A real bank transfer order created through the checkout flow, leaving the + * inventory reserved (not committed). + * + * @param array $variantAttributes + */ +function bankTransferOrder($test, int $quantity = 2, array $variantAttributes = []): Order +{ + $checkout = createPaymentSelectedCheckout( + $test->store, + 'bank_transfer', + quantity: $quantity, + quantityOnHand: 10, + variantAttributes: $variantAttributes, + ); + + return app(CheckoutService::class)->completeCheckout($checkout); +} + +it('admin can confirm bank transfer payment', function () { + $order = bankTransferOrder($this); + + $item = $order->lines->first()->variant->inventoryItem; + expect($item->refresh()->quantity_reserved)->toBe(2); + + $this->orderService->confirmBankTransferPayment($order); + + $order->refresh(); + expect($order->financial_status)->toBe(FinancialStatus::Paid); + expect($order->status)->toBe(OrderStatus::Paid); + expect($order->payments->first()->status)->toBe(PaymentStatus::Captured); + + $item->refresh(); + expect($item->quantity_on_hand)->toBe(8); + expect($item->quantity_reserved)->toBe(0); +}); + +it('cannot confirm payment for non-bank-transfer orders', function () { + $order = Order::factory()->for($this->store)->create(); + + $this->orderService->confirmBankTransferPayment($order); +})->throws(ValidationException::class); + +it('cannot confirm already confirmed payment', function () { + $order = Order::factory()->paid()->for($this->store)->create([ + 'payment_method' => 'bank_transfer', + ]); + + $this->orderService->confirmBankTransferPayment($order); +})->throws(ValidationException::class); + +it('auto-cancel job cancels unpaid bank transfer orders after config days', function () { + $order = bankTransferOrder($this); + $order->forceFill(['placed_at' => now()->subDays(8)])->save(); + + $item = $order->lines->first()->variant->inventoryItem; + expect($item->refresh()->quantity_reserved)->toBe(2); + + (new CancelUnpaidBankTransferOrders)->handle($this->orderService); + + $order->refresh(); + expect($order->status)->toBe(OrderStatus::Cancelled); + expect($order->financial_status)->toBe(FinancialStatus::Voided); + expect($item->refresh()->quantity_reserved)->toBe(0); +}); + +it('auto-cancel job does not cancel orders within config days', function () { + $order = bankTransferOrder($this); + $order->forceFill(['placed_at' => now()->subDays(2)])->save(); + + (new CancelUnpaidBankTransferOrders)->handle($this->orderService); + + $order->refresh(); + expect($order->status)->toBe(OrderStatus::Pending); + expect($order->financial_status)->toBe(FinancialStatus::Pending); +}); + +it('auto-fulfills digital products on payment confirmation', function () { + $order = bankTransferOrder($this, variantAttributes: ['requires_shipping' => false]); + expect($order->fulfillments)->toHaveCount(0); + + $this->orderService->confirmBankTransferPayment($order); + + $fulfillment = $order->refresh()->fulfillments->first(); + expect($fulfillment)->not->toBeNull(); + expect($fulfillment->status)->toBe(FulfillmentShipmentStatus::Delivered); +}); diff --git a/tests/Feature/Payments/MockPaymentProviderTest.php b/tests/Feature/Payments/MockPaymentProviderTest.php new file mode 100644 index 00000000..83968592 --- /dev/null +++ b/tests/Feature/Payments/MockPaymentProviderTest.php @@ -0,0 +1,73 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->provider = app(PaymentProvider::class); +}); + +it('charges credit card with success card number', function () { + $checkout = Checkout::factory()->withCreditCard()->for($this->store)->create(); + + $result = $this->provider->charge($checkout, PaymentMethod::CreditCard, [ + 'card_number' => '4242424242424242', + ]); + + expect($result->success)->toBeTrue(); + expect($result->status)->toBe(PaymentStatus::Captured); +}); + +it('declines credit card with decline card number', function () { + $checkout = Checkout::factory()->withCreditCard()->for($this->store)->create(); + + $result = $this->provider->charge($checkout, PaymentMethod::CreditCard, [ + 'card_number' => '4000000000000002', + ]); + + expect($result->success)->toBeFalse(); + expect($result->errorCode)->toBe('card_declined'); +}); + +it('returns insufficient funds for that card number', function () { + $checkout = Checkout::factory()->withCreditCard()->for($this->store)->create(); + + $result = $this->provider->charge($checkout, PaymentMethod::CreditCard, [ + 'card_number' => '4000000000009995', + ]); + + expect($result->success)->toBeFalse(); + expect($result->errorCode)->toBe('insufficient_funds'); +}); + +it('charges PayPal successfully', function () { + $checkout = Checkout::factory()->withPaypal()->for($this->store)->create(); + + $result = $this->provider->charge($checkout, PaymentMethod::Paypal, []); + + expect($result->success)->toBeTrue(); + expect($result->status)->toBe(PaymentStatus::Captured); +}); + +it('creates pending payment for bank transfer', function () { + $checkout = Checkout::factory()->withBankTransfer()->for($this->store)->create(); + + $result = $this->provider->charge($checkout, PaymentMethod::BankTransfer, []); + + expect($result->success)->toBeTrue(); + expect($result->status)->toBe(PaymentStatus::Pending); +}); + +it('generates mock reference ID', function () { + $checkout = Checkout::factory()->withCreditCard()->for($this->store)->create(); + + $result = $this->provider->charge($checkout, PaymentMethod::CreditCard, [ + 'card_number' => '4242424242424242', + ]); + + expect($result->providerPaymentId)->toStartWith('mock_'); +}); diff --git a/tests/Feature/Payments/PaymentServiceTest.php b/tests/Feature/Payments/PaymentServiceTest.php new file mode 100644 index 00000000..89d22815 --- /dev/null +++ b/tests/Feature/Payments/PaymentServiceTest.php @@ -0,0 +1,66 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->checkoutService = app(CheckoutService::class); +}); + +it('processes credit card payment and creates order as paid', function () { + $checkout = createPaymentSelectedCheckout($this->store, 'credit_card', quantity: 2, quantityOnHand: 10); + + $order = $this->checkoutService->completeCheckout($checkout, ['card_number' => '4242424242424242']); + + expect($order->financial_status)->toBe(FinancialStatus::Paid); + + $item = $checkout->cart->lines()->first()->variant->inventoryItem->refresh(); + expect($item->quantity_on_hand)->toBe(8); + expect($item->quantity_reserved)->toBe(0); +}); + +it('processes PayPal payment and creates order as paid', function () { + $checkout = createPaymentSelectedCheckout($this->store, 'paypal', quantity: 2, quantityOnHand: 10); + + $order = $this->checkoutService->completeCheckout($checkout); + + expect($order->financial_status)->toBe(FinancialStatus::Paid); + + $item = $checkout->cart->lines()->first()->variant->inventoryItem->refresh(); + expect($item->quantity_on_hand)->toBe(8); + expect($item->quantity_reserved)->toBe(0); +}); + +it('processes bank transfer and creates order as pending', function () { + $checkout = createPaymentSelectedCheckout($this->store, 'bank_transfer', quantity: 2, quantityOnHand: 10); + + $order = $this->checkoutService->completeCheckout($checkout); + + expect($order->financial_status)->toBe(FinancialStatus::Pending); + + $item = $checkout->cart->lines()->first()->variant->inventoryItem->refresh(); + expect($item->quantity_on_hand)->toBe(10); + expect($item->quantity_reserved)->toBe(2); +}); + +it('resolves MockPaymentProvider from container', function () { + expect(app(PaymentProvider::class))->toBeInstanceOf(MockPaymentProvider::class); +}); + +it('creates a payment record with correct method', function () { + $checkout = createPaymentSelectedCheckout($this->store, 'credit_card'); + + $order = $this->checkoutService->completeCheckout($checkout, ['card_number' => '4242424242424242']); + + $this->assertDatabaseHas('payments', [ + 'order_id' => $order->getKey(), + 'provider' => 'mock', + 'method' => 'credit_card', + 'status' => 'captured', + 'amount' => $order->total_amount, + ]); +}); diff --git a/tests/Feature/Products/CollectionTest.php b/tests/Feature/Products/CollectionTest.php new file mode 100644 index 00000000..1ade66e6 --- /dev/null +++ b/tests/Feature/Products/CollectionTest.php @@ -0,0 +1,124 @@ +create([ + 'title' => 'Summer Sale', + 'handle' => app(HandleGenerator::class)->generate('Summer Sale', 'collections', $context['store']->getKey()), + ]); + + expect($collection->handle)->toBe('summer-sale'); + + $this->assertDatabaseHas('collections', [ + 'id' => $collection->getKey(), + 'store_id' => $context['store']->getKey(), + 'handle' => 'summer-sale', + ]); +}); + +it('adds products to a collection', function () { + $context = createStoreContext(); + + $collection = Collection::factory()->for($context['store'])->create(); + $products = Product::factory()->count(3)->for($context['store'])->create(); + + foreach ($products as $position => $product) { + $collection->products()->attach($product, ['position' => $position]); + } + + expect($collection->products()->count())->toBe(3); + $this->assertDatabaseCount('collection_products', 3); +}); + +it('removes products from a collection', function () { + $context = createStoreContext(); + + $collection = Collection::factory()->for($context['store'])->create(); + $products = Product::factory()->count(3)->for($context['store'])->create(); + + foreach ($products as $position => $product) { + $collection->products()->attach($product, ['position' => $position]); + } + + $collection->products()->detach($products->first()); + + expect($collection->products()->count())->toBe(2); +}); + +it('reorders products within a collection', function () { + $context = createStoreContext(); + + $collection = Collection::factory()->for($context['store'])->create(); + $products = Product::factory()->count(3)->for($context['store'])->create(); + + foreach ($products as $position => $product) { + $collection->products()->attach($product, ['position' => $position]); + } + + $newOrder = [ + $products[0]->getKey() => 2, + $products[1]->getKey() => 0, + $products[2]->getKey() => 1, + ]; + + foreach ($newOrder as $productId => $position) { + $collection->products()->updateExistingPivot($productId, ['position' => $position]); + } + + $orderedIds = $collection->products()->pluck('products.id')->all(); + + expect($orderedIds)->toBe([ + $products[1]->getKey(), + $products[2]->getKey(), + $products[0]->getKey(), + ]); +}); + +it('transitions collection from draft to active', function () { + $context = createStoreContext(); + + $collection = Collection::factory()->draft()->for($context['store'])->create(); + + $collection->update(['status' => CollectionStatus::Active]); + + expect($collection->refresh()->status)->toBe(CollectionStatus::Active); +}); + +it('lists collections with product count', function () { + $context = createStoreContext(); + + $collectionA = Collection::factory()->for($context['store'])->create(); + $collectionB = Collection::factory()->for($context['store'])->create(); + + foreach (Product::factory()->count(5)->for($context['store'])->create() as $position => $product) { + $collectionA->products()->attach($product, ['position' => $position]); + } + + foreach (Product::factory()->count(3)->for($context['store'])->create() as $position => $product) { + $collectionB->products()->attach($product, ['position' => $position]); + } + + $collections = Collection::query()->withCount('products')->get()->keyBy('id'); + + expect($collections[$collectionA->getKey()]->products_count)->toBe(5); + expect($collections[$collectionB->getKey()]->products_count)->toBe(3); +}); + +it('scopes collections to current store', function () { + $storeA = Store::factory()->create(); + $storeB = Store::factory()->create(); + + Collection::factory()->count(2)->for($storeA)->create(); + Collection::factory()->count(4)->for($storeB)->create(); + + app()->instance('current_store', $storeA); + + expect(Collection::query()->count())->toBe(2); +}); diff --git a/tests/Feature/Products/InventoryTest.php b/tests/Feature/Products/InventoryTest.php new file mode 100644 index 00000000..2c194f20 --- /dev/null +++ b/tests/Feature/Products/InventoryTest.php @@ -0,0 +1,107 @@ +for(\App\Models\Product::factory()->for($context['store'])) + ->create(); + + return InventoryItem::factory() + ->forVariant($variant) + ->create($attributes); +} + +it('creates inventory item when variant is created', function () { + $context = createStoreContext(); + + $product = app(ProductService::class)->create($context['store'], [ + 'title' => 'Stocked Product', + ]); + + $variant = $product->variants->first(); + + $this->assertDatabaseHas('inventory_items', [ + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + ]); +}); + +it('checks availability correctly', function () { + $item = makeInventoryItem(['quantity_on_hand' => 10, 'quantity_reserved' => 3]); + + expect($item->availableQuantity())->toBe(7); + expect(app(InventoryService::class)->checkAvailability($item, 7))->toBeTrue(); + expect(app(InventoryService::class)->checkAvailability($item, 8))->toBeFalse(); +}); + +it('reserves inventory', function () { + $item = makeInventoryItem(['quantity_on_hand' => 10, 'quantity_reserved' => 0]); + + app(InventoryService::class)->reserve($item, 3); + + $item->refresh(); + + expect($item->quantity_reserved)->toBe(3); + expect($item->availableQuantity())->toBe(7); +}); + +it('throws InsufficientInventoryException when reserving more than available with deny policy', function () { + $item = makeInventoryItem([ + 'quantity_on_hand' => 5, + 'quantity_reserved' => 3, + 'policy' => 'deny', + ]); + + expect(fn () => app(InventoryService::class)->reserve($item, 3)) + ->toThrow(InsufficientInventoryException::class); + + expect($item->refresh()->quantity_reserved)->toBe(3); +}); + +it('allows overselling with continue policy', function () { + $item = makeInventoryItem([ + 'quantity_on_hand' => 2, + 'quantity_reserved' => 0, + 'policy' => 'continue', + ]); + + app(InventoryService::class)->reserve($item, 5); + + expect($item->refresh()->quantity_reserved)->toBe(5); +}); + +it('releases reserved inventory', function () { + $item = makeInventoryItem(['quantity_on_hand' => 10, 'quantity_reserved' => 5]); + + app(InventoryService::class)->release($item, 3); + + expect($item->refresh()->quantity_reserved)->toBe(2); +}); + +it('commits inventory on order completion', function () { + $item = makeInventoryItem(['quantity_on_hand' => 10, 'quantity_reserved' => 3]); + + app(InventoryService::class)->commit($item, 3); + + $item->refresh(); + + expect($item->quantity_on_hand)->toBe(7); + expect($item->quantity_reserved)->toBe(0); +}); + +it('restocks inventory', function () { + $item = makeInventoryItem(['quantity_on_hand' => 5]); + + app(InventoryService::class)->restock($item, 10); + + expect($item->refresh()->quantity_on_hand)->toBe(15); +}); diff --git a/tests/Feature/Products/MediaUploadTest.php b/tests/Feature/Products/MediaUploadTest.php new file mode 100644 index 00000000..e5a336f3 --- /dev/null +++ b/tests/Feature/Products/MediaUploadTest.php @@ -0,0 +1,121 @@ +for($context['store'])->create(); + + $media = app(MediaService::class)->attach($product, UploadedFile::fake()->image('photo.jpg', 800, 600)); + + $this->assertDatabaseHas('product_media', [ + 'id' => $media->getKey(), + 'product_id' => $product->getKey(), + 'type' => 'image', + 'status' => 'processing', + ]); + + Storage::disk('public')->assertExists($media->storage_key); + Queue::assertPushed(ProcessMediaUpload::class); +}); + +it('processes uploaded image and generates variants', function () { + $context = createStoreContext(); + $product = Product::factory()->for($context['store'])->create(); + + Queue::fake(); + $media = app(MediaService::class)->attach($product, UploadedFile::fake()->image('photo.jpg', 1600, 900)); + + (new ProcessMediaUpload($media))->handle(); + + $media->refresh(); + + expect($media->status)->toBe(MediaStatus::Ready); + expect($media->width)->toBe(1600); + expect($media->height)->toBe(900); + + Storage::disk('public')->assertExists($media->storage_key); + Storage::disk('public')->assertExists($media->derivedStorageKey('thumbnail')); + Storage::disk('public')->assertExists($media->derivedStorageKey('medium')); + Storage::disk('public')->assertExists($media->derivedStorageKey('large')); +}); + +it('rejects non-image file types', function () { + $context = createStoreContext(); + $product = Product::factory()->for($context['store'])->create(); + + $file = UploadedFile::fake()->create('notes.txt', 5, 'text/plain'); + + expect(fn () => app(MediaService::class)->attach($product, $file)) + ->toThrow(ValidationException::class); + + $this->assertDatabaseCount('product_media', 0); +}); + +it('sets alt text on media', function () { + Queue::fake(); + + $context = createStoreContext(); + $product = Product::factory()->for($context['store'])->create(); + + $media = app(MediaService::class)->attach($product, UploadedFile::fake()->image('photo.jpg')); + + app(MediaService::class)->updateAltText($media, 'A red t-shirt on a hanger'); + + $this->assertDatabaseHas('product_media', [ + 'id' => $media->getKey(), + 'alt_text' => 'A red t-shirt on a hanger', + ]); +}); + +it('reorders media positions', function () { + Queue::fake(); + + $context = createStoreContext(); + $product = Product::factory()->for($context['store'])->create(); + $service = app(MediaService::class); + + $first = $service->attach($product, UploadedFile::fake()->image('one.jpg')); + $second = $service->attach($product, UploadedFile::fake()->image('two.jpg')); + $third = $service->attach($product, UploadedFile::fake()->image('three.jpg')); + + expect([$first->position, $second->position, $third->position])->toBe([0, 1, 2]); + + $service->reorder($product, [$third->getKey(), $first->getKey(), $second->getKey()]); + + expect($third->refresh()->position)->toBe(0); + expect($first->refresh()->position)->toBe(1); + expect($second->refresh()->position)->toBe(2); +}); + +it('deletes media and removes file from storage', function () { + $context = createStoreContext(); + $product = Product::factory()->for($context['store'])->create(); + $service = app(MediaService::class); + + Queue::fake(); + $media = $service->attach($product, UploadedFile::fake()->image('photo.jpg', 400, 400)); + (new ProcessMediaUpload($media))->handle(); + + $storageKey = $media->storage_key; + $thumbnailKey = $media->derivedStorageKey('thumbnail'); + + $service->delete($media); + + $this->assertDatabaseMissing('product_media', ['id' => $media->getKey()]); + Storage::disk('public')->assertMissing($storageKey); + Storage::disk('public')->assertMissing($thumbnailKey); +}); diff --git a/tests/Feature/Products/ProductCrudTest.php b/tests/Feature/Products/ProductCrudTest.php new file mode 100644 index 00000000..5fc174ec --- /dev/null +++ b/tests/Feature/Products/ProductCrudTest.php @@ -0,0 +1,210 @@ +create(); + + $products = Product::factory()->count(5)->for($context['store'])->create(); + Product::factory()->count(3)->for($otherStore)->create(); + + $listedTitles = Product::query()->pluck('title'); + + expect($listedTitles)->toHaveCount(5); + + foreach ($products as $product) { + expect($listedTitles)->toContain($product->title); + } +}); + +it('creates a product with a default variant', function () { + $context = createStoreContext(); + + $product = app(ProductService::class)->create($context['store'], [ + 'title' => 'Plain Mug', + 'description_html' => '

A plain mug.

', + 'status' => ProductStatus::Draft, + ]); + + $this->assertDatabaseHas('products', [ + 'id' => $product->getKey(), + 'store_id' => $context['store']->getKey(), + 'title' => 'Plain Mug', + 'status' => 'draft', + ]); + + expect($product->variants)->toHaveCount(1); + + $variant = $product->variants->first(); + + expect($variant->is_default)->toBeTrue(); + expect($variant->inventoryItem)->not->toBeNull(); + expect($variant->inventoryItem->quantity_on_hand)->toBe(0); +}); + +it('generates a unique handle from the title', function () { + $context = createStoreContext(); + + $product = app(ProductService::class)->create($context['store'], [ + 'title' => 'Summer T-Shirt', + ]); + + expect($product->handle)->toBe('summer-t-shirt'); +}); + +it('appends suffix when handle collides', function () { + $context = createStoreContext(); + $service = app(ProductService::class); + + $first = $service->create($context['store'], ['title' => 'T-Shirt']); + $second = $service->create($context['store'], ['title' => 'T-Shirt']); + + expect($first->handle)->toBe('t-shirt'); + expect($second->handle)->toBe('t-shirt-1'); +}); + +it('updates a product', function () { + $context = createStoreContext(); + $product = Product::factory()->for($context['store'])->create(); + + app(ProductService::class)->update($product, [ + 'title' => 'Updated Title', + 'description_html' => '

Updated description.

', + ]); + + $this->assertDatabaseHas('products', [ + 'id' => $product->getKey(), + 'title' => 'Updated Title', + 'description_html' => '

Updated description.

', + ]); +}); + +it('transitions product from draft to active', function () { + $context = createStoreContext(); + $service = app(ProductService::class); + + $product = $service->create($context['store'], [ + 'title' => 'Publishable Product', + 'price_amount' => 1999, + ]); + + $service->transitionStatus($product, ProductStatus::Active); + + $product->refresh(); + + expect($product->status)->toBe(ProductStatus::Active); + expect($product->published_at)->not->toBeNull(); +}); + +it('rejects draft to active without a priced variant', function () { + $context = createStoreContext(); + $service = app(ProductService::class); + + $product = $service->create($context['store'], [ + 'title' => 'Unpriced Product', + ]); + + expect($product->variants->first()->price_amount)->toBe(0); + + expect(fn () => $service->transitionStatus($product, ProductStatus::Active)) + ->toThrow(InvalidProductTransitionException::class); + + expect($product->refresh()->status)->toBe(ProductStatus::Draft); +}); + +it('transitions product from active to archived', function () { + $context = createStoreContext(); + $service = app(ProductService::class); + + $product = $service->create($context['store'], [ + 'title' => 'Soon Archived', + 'status' => ProductStatus::Active, + 'price_amount' => 1500, + ]); + + $service->transitionStatus($product, ProductStatus::Archived); + + expect($product->refresh()->status)->toBe(ProductStatus::Archived); +}); + +it('prevents active to draft when order lines exist', function () { + $context = createStoreContext(); + $service = app(ProductService::class); + + $product = $service->create($context['store'], [ + 'title' => 'Ordered Product', + 'status' => ProductStatus::Active, + 'price_amount' => 1500, + ]); + + OrderLine::factory() + ->for(Order::factory()->paid()->for($context['store'])) + ->forVariant($product->variants->first()) + ->create(); + + expect(fn () => $service->transitionStatus($product, ProductStatus::Draft)) + ->toThrow(InvalidProductTransitionException::class); + + expect($product->refresh()->status)->toBe(ProductStatus::Active); +}); + +it('hard deletes a draft product with no order references', function () { + $context = createStoreContext(); + $service = app(ProductService::class); + + $product = $service->create($context['store'], ['title' => 'Disposable Draft']); + $variantId = $product->variants->first()->getKey(); + + $service->delete($product); + + $this->assertDatabaseMissing('products', ['id' => $product->getKey()]); + $this->assertDatabaseMissing('product_variants', ['id' => $variantId]); + $this->assertDatabaseMissing('inventory_items', ['variant_id' => $variantId]); +}); + +it('prevents deletion of product with order references', function () { + $context = createStoreContext(); + $service = app(ProductService::class); + + $product = $service->create($context['store'], ['title' => 'Referenced Draft']); + + OrderLine::factory() + ->for(Order::factory()->paid()->for($context['store'])) + ->forVariant($product->variants->first()) + ->create(); + + expect(fn () => $service->delete($product)) + ->toThrow(ProductDeletionException::class); + + $this->assertDatabaseHas('products', ['id' => $product->getKey()]); +}); + +it('filters products by status', function () { + $context = createStoreContext(); + + Product::factory()->count(3)->active()->for($context['store'])->create(); + Product::factory()->count(2)->draft()->for($context['store'])->create(); + Product::factory()->count(1)->archived()->for($context['store'])->create(); + + expect(Product::query()->where('status', ProductStatus::Active)->count())->toBe(3); +}); + +it('searches products by title', function () { + $context = createStoreContext(); + + Product::factory()->for($context['store'])->create(['title' => 'Organic Cotton Hoodie']); + Product::factory()->for($context['store'])->create(['title' => 'Leather Belt']); + + $results = Product::query()->where('title', 'like', '%cotton%')->get(); + + expect($results)->toHaveCount(1); + expect($results->first()->title)->toBe('Organic Cotton Hoodie'); +}); diff --git a/tests/Feature/Products/VariantTest.php b/tests/Feature/Products/VariantTest.php new file mode 100644 index 00000000..8e586183 --- /dev/null +++ b/tests/Feature/Products/VariantTest.php @@ -0,0 +1,161 @@ +create($context['store'], [ + 'title' => 'Matrix Tee', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L']], + ['name' => 'Color', 'values' => ['Red', 'Blue']], + ], + ]); + + expect($product->variants)->toHaveCount(6); + + foreach ($product->variants as $variant) { + expect($variant->optionValues)->toHaveCount(2); + expect($variant->inventoryItem)->not->toBeNull(); + } +}); + +it('preserves existing variants when adding an option value', function () { + $context = createStoreContext(); + + $product = app(ProductService::class)->create($context['store'], [ + 'title' => 'Growing Tee', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M']], + ], + ]); + + $originalVariants = $product->variants; + expect($originalVariants)->toHaveCount(2); + + $originalVariants[0]->update(['price_amount' => 1100]); + $originalVariants[1]->update(['price_amount' => 1200]); + + $product->options->first()->values()->create(['value' => 'L', 'position' => 2]); + + app(VariantMatrixService::class)->rebuildMatrix($product); + + $product->refresh()->load('variants'); + + expect($product->variants)->toHaveCount(3); + expect($product->variants->find($originalVariants[0]->getKey())->price_amount)->toBe(1100); + expect($product->variants->find($originalVariants[1]->getKey())->price_amount)->toBe(1200); +}); + +it('archives orphaned variants with order references', function () { + $context = createStoreContext(); + + $product = app(ProductService::class)->create($context['store'], [ + 'title' => 'Referenced Tee', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L']], + ], + ]); + + $removedValue = $product->options->first()->values->firstWhere('value', 'L'); + $orphanedVariant = $product->variants + ->first(fn ($variant) => $variant->optionValues->contains('id', $removedValue->getKey())); + + OrderLine::factory() + ->for(Order::factory()->paid()->for($context['store'])) + ->forVariant($orphanedVariant) + ->create(); + + $removedValue->delete(); + + app(VariantMatrixService::class)->rebuildMatrix($product); + + $this->assertDatabaseHas('product_variants', ['id' => $orphanedVariant->getKey()]); + expect($orphanedVariant->refresh()->status)->toBe(VariantStatus::Archived); +}); + +it('deletes orphaned variants without order references', function () { + $context = createStoreContext(); + + $product = app(ProductService::class)->create($context['store'], [ + 'title' => 'Shrinking Tee', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L']], + ], + ]); + + expect($product->variants)->toHaveCount(3); + + $removedValue = $product->options->first()->values->firstWhere('value', 'L'); + $orphanedVariantId = $product->variants + ->first(fn ($variant) => $variant->optionValues->contains('id', $removedValue->getKey())) + ->getKey(); + + $removedValue->delete(); + + app(VariantMatrixService::class)->rebuildMatrix($product); + + $this->assertDatabaseMissing('product_variants', ['id' => $orphanedVariantId]); + expect($product->refresh()->variants)->toHaveCount(2); +}); + +it('auto-creates default variant for products without options', function () { + $context = createStoreContext(); + + $product = app(ProductService::class)->create($context['store'], [ + 'title' => 'Optionless Product', + ]); + + expect($product->variants)->toHaveCount(1); + expect($product->variants->first()->is_default)->toBeTrue(); +}); + +it('validates SKU uniqueness within store', function () { + $context = createStoreContext(); + $service = app(ProductService::class); + + $productA = $service->create($context['store'], ['title' => 'Product A']); + $productB = $service->create($context['store'], ['title' => 'Product B']); + + $service->createVariant($productA, ['sku' => 'TSH-001']); + + expect(fn () => $service->createVariant($productB, ['sku' => 'TSH-001'])) + ->toThrow(ValidationException::class); +}); + +it('allows duplicate SKU across different stores', function () { + $storeA = Store::factory()->create(); + $storeB = Store::factory()->create(); + $service = app(ProductService::class); + + $productA = Product::factory()->for($storeA)->create(); + $productB = Product::factory()->for($storeB)->create(); + + $variantA = $service->createVariant($productA, ['sku' => 'TSH-001']); + $variantB = $service->createVariant($productB, ['sku' => 'TSH-001']); + + expect($variantA->sku)->toBe('TSH-001'); + expect($variantB->sku)->toBe('TSH-001'); +}); + +it('allows null SKUs', function () { + $context = createStoreContext(); + $service = app(ProductService::class); + + $product = Product::factory()->for($context['store'])->create(); + + $first = $service->createVariant($product, ['sku' => null]); + $second = $service->createVariant($product, ['sku' => null]); + + expect($first->exists)->toBeTrue(); + expect($second->exists)->toBeTrue(); + expect($product->variants()->count())->toBe(2); +}); diff --git a/tests/Feature/Search/AutocompleteTest.php b/tests/Feature/Search/AutocompleteTest.php new file mode 100644 index 00000000..72168fb0 --- /dev/null +++ b/tests/Feature/Search/AutocompleteTest.php @@ -0,0 +1,39 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->search = app(SearchService::class); +}); + +it('returns suggestions matching prefix', function () { + Product::factory()->active()->for($this->store)->create(['title' => 'Summer Dress']); + Product::factory()->active()->for($this->store)->create(['title' => 'Summer Hat']); + Product::factory()->active()->for($this->store)->create(['title' => 'Winter Coat']); + + $suggestions = $this->search->autocomplete($this->store, 'sum'); + + expect($suggestions->pluck('title')->sort()->values()->all()) + ->toBe(['Summer Dress', 'Summer Hat']); +}); + +it('limits results to configured count', function () { + foreach (range(1, 20) as $index) { + Product::factory()->active()->for($this->store)->create(['title' => "Summer Item {$index}"]); + } + + $suggestions = $this->search->autocomplete($this->store, 'summer', 5); + + expect($suggestions)->toHaveCount(5); +}); + +it('returns empty for very short prefix', function () { + Product::factory()->active()->for($this->store)->create(['title' => 'Anorak']); + + $suggestions = $this->search->autocomplete($this->store, 'a'); + + expect($suggestions)->toBeEmpty(); +}); diff --git a/tests/Feature/Search/SearchTest.php b/tests/Feature/Search/SearchTest.php new file mode 100644 index 00000000..0a2daa14 --- /dev/null +++ b/tests/Feature/Search/SearchTest.php @@ -0,0 +1,73 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->search = app(SearchService::class); +}); + +it('returns products matching search query', function () { + Product::factory()->active()->for($this->store)->create(['title' => 'Blue Cotton T-Shirt']); + Product::factory()->active()->for($this->store)->create(['title' => 'Red Wool Sweater']); + + $results = $this->search->search($this->store, 'cotton'); + + expect($results->total())->toBe(1); + expect($results->first()->title)->toBe('Blue Cotton T-Shirt'); +}); + +it('scopes search to current store', function () { + Product::factory()->active()->for($this->store)->create(['title' => 'T-Shirt']); + + $otherContext = createStoreContext(); + Product::factory()->active()->for($otherContext['store'])->create(['title' => 'T-Shirt Deluxe']); + + app()->instance('current_store', $this->store); + + $results = $this->search->search($this->store, 't-shirt'); + + expect($results->total())->toBe(1); + expect($results->first()->store_id)->toBe($this->store->getKey()); + expect($results->first()->title)->toBe('T-Shirt'); +}); + +it('returns empty for no matches', function () { + Product::factory()->active()->for($this->store)->create(['title' => 'Blue Cotton T-Shirt']); + + $results = $this->search->search($this->store, 'xyznonexistent'); + + expect($results->total())->toBe(0); + expect($results->items())->toBe([]); +}); + +it('logs search query for analytics', function () { + Product::factory()->active()->for($this->store)->create(['title' => 'Blue Cotton T-Shirt']); + + $this->search->search($this->store, 'cotton'); + + $logged = SearchQuery::query()->where('query', 'cotton')->first(); + + expect($logged)->not->toBeNull(); + expect($logged->store_id)->toBe($this->store->getKey()); + expect($logged->results_count)->toBe(1); +}); + +it('paginates search results', function () { + foreach (range(1, 25) as $index) { + Product::factory()->active()->for($this->store)->create(['title' => "Cotton Shirt {$index}"]); + } + + $pageOne = $this->search->search($this->store, 'cotton', [], 12, 'relevance', 'page', 1); + $pageTwo = $this->search->search($this->store, 'cotton', [], 12, 'relevance', 'page', 2); + $pageThree = $this->search->search($this->store, 'cotton', [], 12, 'relevance', 'page', 3); + + expect($pageOne->total())->toBe(25); + expect($pageOne->lastPage())->toBe(3); + expect($pageOne->count())->toBe(12); + expect($pageTwo->count())->toBe(12); + expect($pageThree->count())->toBe(1); +}); diff --git a/tests/Feature/Settings/PasswordUpdateTest.php b/tests/Feature/Settings/PasswordUpdateTest.php deleted file mode 100644 index a6379b2b..00000000 --- a/tests/Feature/Settings/PasswordUpdateTest.php +++ /dev/null @@ -1,42 +0,0 @@ -create([ - 'password' => Hash::make('password'), - ]); - - $this->actingAs($user); - - $response = Livewire::test(Password::class) - ->set('current_password', 'password') - ->set('password', 'new-password') - ->set('password_confirmation', 'new-password') - ->call('updatePassword'); - - $response->assertHasNoErrors(); - - expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue(); -}); - -test('correct password must be provided to update password', function () { - $user = User::factory()->create([ - 'password' => Hash::make('password'), - ]); - - $this->actingAs($user); - - $response = Livewire::test(Password::class) - ->set('current_password', 'wrong-password') - ->set('password', 'new-password') - ->set('password_confirmation', 'new-password') - ->call('updatePassword'); - - $response->assertHasErrors(['current_password']); -}); \ No newline at end of file diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php deleted file mode 100644 index 276e9fef..00000000 --- a/tests/Feature/Settings/ProfileUpdateTest.php +++ /dev/null @@ -1,78 +0,0 @@ -actingAs($user = User::factory()->create()); - - $this->get('/settings/profile')->assertOk(); -}); - -test('profile information can be updated', function () { - $user = User::factory()->create(); - - $this->actingAs($user); - - $response = Livewire::test(Profile::class) - ->set('name', 'Test User') - ->set('email', 'test@example.com') - ->call('updateProfileInformation'); - - $response->assertHasNoErrors(); - - $user->refresh(); - - expect($user->name)->toEqual('Test User'); - expect($user->email)->toEqual('test@example.com'); - expect($user->email_verified_at)->toBeNull(); -}); - -test('email verification status is unchanged when email address is unchanged', function () { - $user = User::factory()->create(); - - $this->actingAs($user); - - $response = Livewire::test(Profile::class) - ->set('name', 'Test User') - ->set('email', $user->email) - ->call('updateProfileInformation'); - - $response->assertHasNoErrors(); - - expect($user->refresh()->email_verified_at)->not->toBeNull(); -}); - -test('user can delete their account', function () { - $user = User::factory()->create(); - - $this->actingAs($user); - - $response = Livewire::test('settings.delete-user-form') - ->set('password', 'password') - ->call('deleteUser'); - - $response - ->assertHasNoErrors() - ->assertRedirect('/'); - - expect($user->fresh())->toBeNull(); - expect(auth()->check())->toBeFalse(); -}); - -test('correct password must be provided to delete account', function () { - $user = User::factory()->create(); - - $this->actingAs($user); - - $response = Livewire::test('settings.delete-user-form') - ->set('password', 'wrong-password') - ->call('deleteUser'); - - $response->assertHasErrors(['password']); - - expect($user->fresh())->not->toBeNull(); -}); \ No newline at end of file diff --git a/tests/Feature/Settings/TwoFactorAuthenticationTest.php b/tests/Feature/Settings/TwoFactorAuthenticationTest.php deleted file mode 100644 index e2d530fb..00000000 --- a/tests/Feature/Settings/TwoFactorAuthenticationTest.php +++ /dev/null @@ -1,72 +0,0 @@ -markTestSkipped('Two-factor authentication is not enabled.'); - } - - Features::twoFactorAuthentication([ - 'confirm' => true, - 'confirmPassword' => true, - ]); -}); - -test('two factor settings page can be rendered', function () { - $user = User::factory()->create(); - - $this->actingAs($user) - ->withSession(['auth.password_confirmed_at' => time()]) - ->get(route('two-factor.show')) - ->assertOk() - ->assertSee('Two Factor Authentication') - ->assertSee('Disabled'); -}); - -test('two factor settings page requires password confirmation when enabled', function () { - $user = User::factory()->create(); - - $response = $this->actingAs($user) - ->get(route('two-factor.show')); - - $response->assertRedirect(route('password.confirm')); -}); - -test('two factor settings page returns forbidden response when two factor is disabled', function () { - config(['fortify.features' => []]); - - $user = User::factory()->create(); - - $response = $this->actingAs($user) - ->withSession(['auth.password_confirmed_at' => time()]) - ->get(route('two-factor.show')); - - $response->assertForbidden(); -}); - -test('two factor authentication disabled when confirmation abandoned between requests', function () { - $user = User::factory()->create(); - - $user->forceFill([ - 'two_factor_secret' => encrypt('test-secret'), - 'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])), - 'two_factor_confirmed_at' => null, - ])->save(); - - $this->actingAs($user); - - $component = Livewire::test('settings.two-factor'); - - $component->assertSet('twoFactorEnabled', false); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'two_factor_secret' => null, - 'two_factor_recovery_codes' => null, - ]); -}); \ No newline at end of file diff --git a/tests/Feature/SmokeTest.php b/tests/Feature/SmokeTest.php new file mode 100644 index 00000000..69efea73 --- /dev/null +++ b/tests/Feature/SmokeTest.php @@ -0,0 +1,128 @@ +seed(DatabaseSeeder::class); + + $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +}); + +it('renders every public storefront page for the demo store', function () { + $base = 'http://acme-fashion.test'; + + $paths = ['/', '/collections', '/search', '/search?q=shirt', '/cart', '/account/login', '/account/register']; + + foreach (Collection::query()->withoutGlobalScopes()->where('store_id', $this->store->id)->pluck('handle') as $handle) { + $paths[] = "/collections/{$handle}"; + } + + foreach (Product::query()->withoutGlobalScopes()->where('store_id', $this->store->id)->where('status', 'active')->pluck('handle') as $handle) { + $paths[] = "/products/{$handle}"; + } + + foreach (Page::query()->withoutGlobalScopes()->where('store_id', $this->store->id)->pluck('handle') as $handle) { + $paths[] = "/pages/{$handle}"; + } + + foreach ($paths as $path) { + $response = $this->get($base.$path); + + expect($response->getStatusCode())->toBe(200, "GET {$path} returned {$response->getStatusCode()}"); + } + + // An empty cart sends the checkout page back to the cart. + $this->get($base.'/checkout')->assertRedirect(); +}); + +it('renders every customer account page for the demo customer', function () { + $base = 'http://acme-fashion.test'; + + $customer = Customer::query() + ->withoutGlobalScopes() + ->where('store_id', $this->store->id) + ->where('email', 'customer@acme.test') + ->firstOrFail(); + + $paths = ['/account', '/account/orders', '/account/addresses']; + + foreach (Order::query()->withoutGlobalScopes()->where('store_id', $this->store->id)->where('customer_id', $customer->id)->pluck('order_number') as $orderNumber) { + $paths[] = '/account/orders/'.ltrim($orderNumber, '#'); + } + + foreach ($paths as $path) { + $response = $this->actingAs($customer, 'customer')->get($base.$path); + + expect($response->getStatusCode())->toBe(200, "GET {$path} returned {$response->getStatusCode()}"); + } +}); + +it('renders every key admin page for the demo owner', function () { + $owner = User::query()->where('email', 'admin@acme.test')->firstOrFail(); + + $storeId = $this->store->id; + $productId = Product::query()->withoutGlobalScopes()->where('store_id', $storeId)->value('id'); + $orderId = Order::query()->withoutGlobalScopes()->where('store_id', $storeId)->value('id'); + $customerId = Customer::query()->withoutGlobalScopes()->where('store_id', $storeId)->value('id'); + $collectionId = Collection::query()->withoutGlobalScopes()->where('store_id', $storeId)->value('id'); + $discountId = Discount::query()->withoutGlobalScopes()->where('store_id', $storeId)->value('id'); + $themeId = Theme::query()->withoutGlobalScopes()->where('store_id', $storeId)->value('id'); + $pageId = Page::query()->withoutGlobalScopes()->where('store_id', $storeId)->value('id'); + $installationId = AppInstallation::query()->withoutGlobalScopes()->where('store_id', $storeId)->value('id'); + + $paths = [ + '/admin', + '/admin/products', + '/admin/products/create', + "/admin/products/{$productId}/edit", + '/admin/orders', + "/admin/orders/{$orderId}", + '/admin/customers', + "/admin/customers/{$customerId}", + '/admin/collections', + '/admin/collections/create', + "/admin/collections/{$collectionId}/edit", + '/admin/inventory', + '/admin/discounts', + '/admin/discounts/create', + "/admin/discounts/{$discountId}/edit", + '/admin/settings', + '/admin/settings/shipping', + '/admin/settings/taxes', + '/admin/themes', + "/admin/themes/{$themeId}/editor", + '/admin/pages', + '/admin/pages/create', + "/admin/pages/{$pageId}/edit", + '/admin/navigation', + '/admin/analytics', + '/admin/search/settings', + '/admin/developers', + '/admin/apps', + "/admin/apps/{$installationId}", + ]; + + foreach ($paths as $path) { + $response = $this->actingAs($owner) + ->withSession(['current_store_id' => $storeId]) + ->get($path); + + expect($response->getStatusCode())->toBe(200, "GET {$path} returned {$response->getStatusCode()}"); + } +}); diff --git a/tests/Feature/Storefront/CartUiTest.php b/tests/Feature/Storefront/CartUiTest.php new file mode 100644 index 00000000..bdac28df --- /dev/null +++ b/tests/Feature/Storefront/CartUiTest.php @@ -0,0 +1,161 @@ +context = createStoreContext(); + $this->store = $this->context['store']; +}); + +it('adds a product to the cart from the product page', function () { + $variant = createPurchasableVariant($this->store, 2500); + + Livewire::test(ProductPage::class, ['handle' => $variant->product->handle]) + ->set('quantity', 2) + ->call('addToCart') + ->assertDispatched('cart-updated') + ->assertSet('addedToCart', true); + + $this->assertDatabaseHas('cart_lines', [ + 'variant_id' => $variant->getKey(), + 'quantity' => 2, + 'unit_price_amount' => 2500, + ]); +}); + +it('shows an inventory error when adding more than available stock', function () { + $variant = createPurchasableVariant($this->store, 2500, 1); + + Livewire::test(ProductPage::class, ['handle' => $variant->product->handle]) + ->set('quantity', 5) + ->call('addToCart') + ->assertHasErrors('quantity') + ->assertNotDispatched('cart-updated'); +}); + +it('renders cart lines in the drawer and opens on cart-updated', function () { + $variant = createPurchasableVariant($this->store, 2500); + $cart = app(CartService::class)->getOrCreateForSession($this->store); + app(CartService::class)->addLine($cart, $variant->getKey(), 2); + + Livewire::test(CartDrawer::class) + ->assertSet('open', false) + ->dispatch('cart-updated') + ->assertSet('open', true) + ->assertSee($variant->product->title) + ->assertSee('50.00 EUR'); +}); + +it('updates a line quantity from the drawer', function () { + $variant = createPurchasableVariant($this->store, 2500); + $cart = app(CartService::class)->getOrCreateForSession($this->store); + $line = app(CartService::class)->addLine($cart, $variant->getKey(), 1); + + Livewire::test(CartDrawer::class) + ->call('updateQuantity', $line->getKey(), 3) + ->assertDispatched('cart-updated'); + + expect($line->refresh()->quantity)->toBe(3); + expect($line->line_subtotal_amount)->toBe(7500); +}); + +it('removes a line from the drawer', function () { + $variant = createPurchasableVariant($this->store, 2500); + $cart = app(CartService::class)->getOrCreateForSession($this->store); + $line = app(CartService::class)->addLine($cart, $variant->getKey(), 1); + + Livewire::test(CartDrawer::class) + ->call('removeLine', $line->getKey()) + ->assertDispatched('cart-updated'); + + $this->assertDatabaseMissing('cart_lines', ['id' => $line->getKey()]); +}); + +it('renders the full cart page with line items and totals', function () { + $variant = createPurchasableVariant($this->store, 2500); + $cart = app(CartService::class)->getOrCreateForSession($this->store); + app(CartService::class)->addLine($cart, $variant->getKey(), 2); + + Livewire::test(CartPage::class) + ->assertSee($variant->product->title) + ->assertSee('50.00 EUR'); +}); + +it('shows the empty state on the cart page without a cart', function () { + Livewire::test(CartPage::class) + ->assertSee('Your cart is empty'); +}); + +it('applies a valid discount code on the cart page', function () { + Discount::factory()->for($this->store)->create(['code' => 'SAVE10', 'value_amount' => 10]); + + $variant = createPurchasableVariant($this->store, 10000); + $cart = app(CartService::class)->getOrCreateForSession($this->store); + app(CartService::class)->addLine($cart, $variant->getKey(), 1); + + Livewire::test(CartPage::class) + ->set('discountCode', 'SAVE10') + ->call('applyDiscount') + ->assertSet('discountError', null) + ->assertSee('SAVE10') + ->assertSee('-10.00 EUR'); + + expect(session('cart_discount_code'))->toBe('SAVE10'); +}); + +it('shows an error for an invalid discount code', function () { + $variant = createPurchasableVariant($this->store, 10000); + $cart = app(CartService::class)->getOrCreateForSession($this->store); + app(CartService::class)->addLine($cart, $variant->getKey(), 1); + + Livewire::test(CartPage::class) + ->set('discountCode', 'NOPE') + ->call('applyDiscount') + ->assertSet('discountError', 'Invalid discount code.'); +}); + +it('serves the cart page over http with the storefront layout', function () { + $variant = createPurchasableVariant($this->store, 2500); + $cart = app(CartService::class)->getOrCreateForSession($this->store); + app(CartService::class)->addLine($cart, $variant->getKey(), 1); + + $this->withSession([CartService::SESSION_KEY => $cart->getKey()]) + ->get('http://'.$this->context['domain']->hostname.'/cart') + ->assertOk() + ->assertSee('Your Cart') + ->assertSee($variant->product->title); +}); + +it('redirects to the cart page when checking out with an empty cart', function () { + $this->get('http://'.$this->context['domain']->hostname.'/checkout') + ->assertRedirect(); +}); + +it('serves the checkout page over http for a filled cart', function () { + $variant = createPurchasableVariant($this->store, 2500); + $cart = app(CartService::class)->getOrCreateForSession($this->store); + app(CartService::class)->addLine($cart, $variant->getKey(), 2); + + $this->withSession([CartService::SESSION_KEY => $cart->getKey()]) + ->get('http://'.$this->context['domain']->hostname.'/checkout') + ->assertOk() + ->assertSee('Checkout') + ->assertSee('Contact information') + ->assertSee('Order Summary'); +}); + +it('removes a line from the cart page when quantity is set to zero', function () { + $variant = createPurchasableVariant($this->store, 2500); + $cart = app(CartService::class)->getOrCreateForSession($this->store); + $line = app(CartService::class)->addLine($cart, $variant->getKey(), 1); + + Livewire::test(CartPage::class) + ->call('updateQuantity', $line->getKey(), 0); + + $this->assertDatabaseMissing('cart_lines', ['id' => $line->getKey()]); +}); diff --git a/tests/Feature/Storefront/CheckoutUiTest.php b/tests/Feature/Storefront/CheckoutUiTest.php new file mode 100644 index 00000000..0f82f606 --- /dev/null +++ b/tests/Feature/Storefront/CheckoutUiTest.php @@ -0,0 +1,137 @@ +context = createStoreContext(); + $this->store = $this->context['store']; +}); + +/** + * Drive the session cart's checkout to payment_selected so the checkout + * page mounts on the pay step. + */ +function paymentStepCheckout($test, string $paymentMethod = 'credit_card'): Checkout +{ + $variant = createPurchasableVariant($test->store, 2500); + + $cartService = app(CartService::class); + $cart = $cartService->getOrCreateForSession($test->store); + $cartService->addLine($cart, $variant->getKey(), 2); + + $checkoutService = app(CheckoutService::class); + $checkout = $checkoutService->createFromCart($cart); + $checkout = $checkoutService->setAddress($checkout, [ + 'email' => 'shopper@example.test', + 'shipping_address' => validShippingAddress(), + ]); + + $zone = ShippingZone::factory()->for($test->store)->create(['countries_json' => ['DE']]); + $rate = ShippingRate::factory()->for($zone, 'zone')->flatAmount(499)->create(); + $checkout = $checkoutService->setShippingMethod($checkout, $rate->getKey()); + $checkout = $checkoutService->selectPaymentMethod($checkout, $paymentMethod); + + Session::put('checkout_id', $checkout->getKey()); + + return $checkout; +} + +it('pays with credit card and redirects to the confirmation page', function () { + $checkout = paymentStepCheckout($this); + + Livewire::test(CheckoutPage::class) + ->assertSet('step', 5) + ->set('cardNumber', '4242424242424242') + ->set('cardName', 'Erika Mustermann') + ->set('cardExpiry', '12/28') + ->set('cardCvc', '123') + ->call('payNow') + ->assertRedirect(route('storefront.checkout.confirmation', ['checkoutId' => $checkout->getKey()])); + + $this->assertDatabaseHas('orders', [ + 'checkout_id' => $checkout->getKey(), + 'status' => 'paid', + ]); +}); + +it('shows an error and stays on the payment step when the card is declined', function () { + paymentStepCheckout($this); + + Livewire::test(CheckoutPage::class) + ->set('cardNumber', '4000000000000002') + ->set('cardName', 'Erika Mustermann') + ->set('cardExpiry', '12/28') + ->set('cardCvc', '123') + ->call('payNow') + ->assertSet('step', 5) + ->assertSee('declined'); + + expect(Order::query()->count())->toBe(0); +}); + +it('renders the order confirmation page', function () { + $checkout = paymentStepCheckout($this); + $order = app(CheckoutService::class)->completeCheckout($checkout, ['card_number' => '4242424242424242']); + + Livewire::test(Confirmation::class, ['checkoutId' => $checkout->getKey()]) + ->assertSee('Thank you for your order!') + ->assertSee($order->order_number) + ->assertSee('54.99 EUR'); +}); + +it('shows bank transfer instructions on the confirmation page for pending orders', function () { + $checkout = paymentStepCheckout($this, 'bank_transfer'); + $order = app(CheckoutService::class)->completeCheckout($checkout); + + Livewire::test(Confirmation::class, ['checkoutId' => $checkout->getKey()]) + ->assertSee('Bank Transfer Instructions') + ->assertSee('DE89 3704 0044 0532 0130 00') + ->assertSee($order->order_number); +}); + +it('prefills the checkout address step from the customer default address', function () { + $customer = Customer::factory()->for($this->store)->create(); + + CustomerAddress::factory()->for($customer)->create([ + 'address_json' => [ + 'first_name' => 'Jane', + 'last_name' => 'Shopper', + 'company' => '', + 'address1' => 'Musterstrasse 1', + 'address2' => '', + 'city' => 'Berlin', + 'province' => '', + 'province_code' => '', + 'country' => 'Germany', + 'country_code' => 'DE', + 'zip' => '10115', + 'phone' => '', + ], + 'is_default' => true, + ]); + + $variant = createPurchasableVariant($this->store); + $cartService = app(CartService::class); + $cart = $cartService->create($this->store, $customer); + $cartService->addLine($cart, $variant->getKey(), 1); + + actingAsCustomer($customer); + + Livewire::test(CheckoutPage::class) + ->assertSet('email', $customer->email) + ->assertSet('shipping.first_name', 'Jane') + ->assertSet('shipping.address1', 'Musterstrasse 1') + ->assertSet('shipping.postal_code', '10115') + ->assertSet('shipping.country_code', 'DE'); +}); diff --git a/tests/Feature/Storefront/SearchUiTest.php b/tests/Feature/Storefront/SearchUiTest.php new file mode 100644 index 00000000..07457626 --- /dev/null +++ b/tests/Feature/Storefront/SearchUiTest.php @@ -0,0 +1,119 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->baseUrl = 'http://'.$this->context['domain']->hostname; +}); + +/** + * Create a published product with a priced default variant. + * + * @param array $attributes + */ +function createSearchableProduct($store, string $title, int $priceAmount = 2500, array $attributes = []): Product +{ + $product = Product::factory()->active()->for($store)->create(['title' => $title, ...$attributes]); + + ProductVariant::factory()->asDefault()->priced($priceAmount)->for($product)->create(); + + return $product; +} + +it('renders the search results page with matching products', function () { + createSearchableProduct($this->store, 'Linen Summer Shirt', 3499); + createSearchableProduct($this->store, 'Wool Winter Coat', 9900); + + $this->get($this->baseUrl.'/search?q=linen') + ->assertOk() + ->assertSee('Linen Summer Shirt') + ->assertSee('result for') + ->assertDontSee('Wool Winter Coat'); +}); + +it('shows the empty state for a query without matches', function () { + createSearchableProduct($this->store, 'Linen Summer Shirt'); + + $this->get($this->baseUrl.'/search?q=xyznonexistent') + ->assertOk() + ->assertSee('No results found'); +}); + +it('filters search results by vendor', function () { + createSearchableProduct($this->store, 'Linen Shirt Classic', 2500, ['vendor' => 'Acme Apparel']); + createSearchableProduct($this->store, 'Linen Shirt Premium', 4500, ['vendor' => 'Other Brand']); + + app()->instance('current_store', $this->store); + + Livewire::test(SearchIndex::class, ['q' => 'linen']) + ->set('query', 'linen') + ->assertSee('Linen Shirt Classic') + ->assertSee('Linen Shirt Premium') + ->set('vendors', ['Acme Apparel']) + ->assertSee('Linen Shirt Classic') + ->assertDontSee('Linen Shirt Premium'); +}); + +it('sorts search results by price', function () { + createSearchableProduct($this->store, 'Linen Shirt Cheap', 1000); + createSearchableProduct($this->store, 'Linen Shirt Expensive', 9000); + + app()->instance('current_store', $this->store); + + Livewire::test(SearchIndex::class) + ->set('query', 'linen') + ->set('sort', 'price_desc') + ->assertSeeInOrder(['Linen Shirt Expensive', 'Linen Shirt Cheap']); +}); + +it('suggests products and collections in the search modal', function () { + createSearchableProduct($this->store, 'Summer Dress', 5900); + Collection::factory()->for($this->store)->create(['title' => 'Summer Collection']); + + app()->instance('current_store', $this->store); + + Livewire::test(SearchModal::class) + ->set('query', 'summer') + ->assertSee('Summer Dress') + ->assertSee('Summer Collection') + ->assertSee('View all'); +}); + +it('shows no modal suggestions below the minimum prefix length', function () { + createSearchableProduct($this->store, 'Anorak Jacket'); + + app()->instance('current_store', $this->store); + + Livewire::test(SearchModal::class) + ->set('query', 'a') + ->assertDontSee('Anorak Jacket') + ->assertSee('Start typing'); +}); + +it('serves search results over the storefront API', function () { + createSearchableProduct($this->store, 'Linen Summer Shirt', 3499, ['vendor' => 'Acme Apparel']); + + $this->getJson($this->baseUrl.'/api/storefront/v1/search?q=linen') + ->assertOk() + ->assertJsonPath('query', 'linen') + ->assertJsonPath('results.0.title', 'Linen Summer Shirt') + ->assertJsonPath('results.0.price_amount', 3499) + ->assertJsonPath('pagination.total', 1) + ->assertJsonStructure(['facets' => ['vendors', 'tags', 'price_range']]); +}); + +it('serves autocomplete suggestions over the storefront API', function () { + createSearchableProduct($this->store, 'Summer Dress', 5900); + + $this->getJson($this->baseUrl.'/api/storefront/v1/search/suggest?q=sum') + ->assertOk() + ->assertJsonPath('suggestions.0.type', 'product') + ->assertJsonPath('suggestions.0.title', 'Summer Dress'); +}); diff --git a/tests/Feature/Storefront/StorefrontPagesTest.php b/tests/Feature/Storefront/StorefrontPagesTest.php new file mode 100644 index 00000000..4a0abfcc --- /dev/null +++ b/tests/Feature/Storefront/StorefrontPagesTest.php @@ -0,0 +1,221 @@ + $productAttributes + */ +function createPublishedProduct($store, int $priceAmount = 2499, array $productAttributes = []): Product +{ + $product = Product::factory()->active()->for($store)->create($productAttributes); + + ProductVariant::factory()->asDefault()->priced($priceAmount)->for($product)->create(); + + return $product; +} + +it('renders the home page with the store name and products', function () { + $context = createStoreContext(); + + $product = createPublishedProduct($context['store'], 2499, ['title' => 'Organic Cotton Tee']); + + $response = $this->get('http://'.$context['domain']->hostname.'/'); + + $response->assertOk(); + $response->assertSee($context['store']->name); + $response->assertSee('Organic Cotton Tee'); + $response->assertSee('24.99 EUR'); +}); + +it('renders home page sections from the active theme settings', function () { + $context = createStoreContext(); + + $theme = Theme::factory()->for($context['store'])->create(); + ThemeSettings::factory()->for($theme)->withSettings([ + 'hero_heading' => 'Summer Essentials Are Here', + 'show_announcement_bar' => true, + 'announcement_text' => 'Free shipping over 50 EUR', + ])->create(); + + $response = $this->get('http://'.$context['domain']->hostname.'/'); + + $response->assertOk(); + $response->assertSee('Summer Essentials Are Here'); + $response->assertSee('Free shipping over 50 EUR'); +}); + +it('renders the collections index with published collections', function () { + $context = createStoreContext(); + + Collection::factory()->for($context['store'])->create(['title' => 'Summer Collection']); + Collection::factory()->draft()->for($context['store'])->create(['title' => 'Hidden Drafts']); + + $response = $this->get('http://'.$context['domain']->hostname.'/collections'); + + $response->assertOk(); + $response->assertSee('Summer Collection'); + $response->assertDontSee('Hidden Drafts'); +}); + +it('renders a collection page with products and pagination', function () { + $context = createStoreContext(); + + $collection = Collection::factory()->for($context['store'])->create([ + 'title' => 'New Arrivals', + 'handle' => 'new-arrivals', + ]); + + $products = collect(range(1, 15))->map( + fn (int $i): Product => createPublishedProduct($context['store'], 1000 + $i, ['title' => "Catalog Item {$i}"]), + ); + + $collection->products()->attach( + $products->mapWithKeys(fn (Product $product, int $index): array => [$product->getKey() => ['position' => $index]])->all(), + ); + + $firstPage = $this->get('http://'.$context['domain']->hostname.'/collections/new-arrivals'); + + $firstPage->assertOk(); + $firstPage->assertSee('New Arrivals'); + $firstPage->assertSee('15 products'); + $firstPage->assertSee('Catalog Item 1'); + $firstPage->assertDontSee('Catalog Item 13'); + + $secondPage = $this->get('http://'.$context['domain']->hostname.'/collections/new-arrivals?page=2'); + + $secondPage->assertOk(); + $secondPage->assertSee('Catalog Item 13'); +}); + +it('renders a product page with title, price, and options', function () { + $context = createStoreContext(); + + $product = Product::factory()->active()->for($context['store'])->create([ + 'title' => 'Classic Crewneck', + 'handle' => 'classic-crewneck', + ]); + + $option = ProductOption::factory()->for($product)->create(['name' => 'Size']); + $small = $option->values()->create(['value' => 'S', 'position' => 0]); + $medium = $option->values()->create(['value' => 'M', 'position' => 1]); + + $smallVariant = ProductVariant::factory()->asDefault()->priced(2999)->for($product)->create(); + $mediumVariant = ProductVariant::factory()->priced(3499)->for($product)->create(['position' => 1]); + + $smallVariant->optionValues()->attach($small); + $mediumVariant->optionValues()->attach($medium); + + $response = $this->get('http://'.$context['domain']->hostname.'/products/classic-crewneck'); + + $response->assertOk(); + $response->assertSee('Classic Crewneck'); + $response->assertSee('29.99 EUR'); + $response->assertSee('Size'); + $response->assertSee('Add to cart'); +}); + +it('renders a published CMS page', function () { + $context = createStoreContext(); + + Page::factory()->for($context['store'])->create([ + 'title' => 'About Us', + 'handle' => 'about', + 'body_html' => '

Our Story

We make great things.

', + ]); + + $response = $this->get('http://'.$context['domain']->hostname.'/pages/about'); + + $response->assertOk(); + $response->assertSee('About Us'); + $response->assertSee('Our Story'); +}); + +it('returns 404 for a draft CMS page', function () { + $context = createStoreContext(); + + Page::factory()->draft()->for($context['store'])->create(['handle' => 'coming-soon']); + + $this->get('http://'.$context['domain']->hostname.'/pages/coming-soon')->assertNotFound(); +}); + +it('returns 404 for an unknown product handle', function () { + $context = createStoreContext(); + + $this->get('http://'.$context['domain']->hostname.'/products/does-not-exist')->assertNotFound(); +}); + +it('returns 404 for an unknown collection handle', function () { + $context = createStoreContext(); + + $this->get('http://'.$context['domain']->hostname.'/collections/does-not-exist')->assertNotFound(); +}); + +it('renders navigation menu items in the header and footer', function () { + $context = createStoreContext(); + + $collection = Collection::factory()->for($context['store'])->create([ + 'title' => 'Best Sellers', + 'handle' => 'best-sellers', + ]); + + $menu = \App\Models\NavigationMenu::factory()->for($context['store'])->create(['handle' => 'main-menu']); + \App\Models\NavigationItem::factory()->for($menu, 'menu')->collection($collection->getKey())->create(['label' => 'Best Sellers']); + + $response = $this->get('http://'.$context['domain']->hostname.'/'); + + $response->assertOk(); + $response->assertSee('Best Sellers'); + $response->assertSee('/collections/best-sellers'); +}); + +it('updates the price when a different variant is selected and adds it to the cart', function () { + $context = createStoreContext(); + + $product = Product::factory()->active()->for($context['store'])->create(['handle' => 'tee']); + + $option = ProductOption::factory()->for($product)->create(['name' => 'Size']); + $small = $option->values()->create(['value' => 'S', 'position' => 0]); + $medium = $option->values()->create(['value' => 'M', 'position' => 1]); + + $smallVariant = ProductVariant::factory()->asDefault()->priced(2999)->for($product)->create(); + $mediumVariant = ProductVariant::factory()->priced(3499)->for($product)->create(['position' => 1]); + + $smallVariant->optionValues()->attach($small); + $mediumVariant->optionValues()->attach($medium); + + Livewire\Livewire::test(\App\Livewire\Storefront\Products\Show::class, ['handle' => 'tee']) + ->assertSee('29.99 EUR') + ->set('selectedOptions.Size', 'M') + ->assertSee('34.99 EUR') + ->call('addToCart') + ->assertDispatched('cart-updated', itemCount: 1) + ->assertSee('Added to cart'); + + test()->assertDatabaseHas('cart_lines', [ + 'variant_id' => $mediumVariant->getKey(), + 'quantity' => 1, + 'unit_price_amount' => 3499, + ]); +}); + +it('does not leak products from another store on the storefront', function () { + $context = createStoreContext(); + $otherContext = createStoreContext(); + + createPublishedProduct($otherContext['store'], 9999, ['title' => 'Foreign Store Product']); + + app()->instance('current_store', $context['store']); + + $response = $this->get('http://'.$context['domain']->hostname.'/'); + + $response->assertOk(); + $response->assertDontSee('Foreign Store Product'); +}); diff --git a/tests/Feature/Storefront/ThemeAndNavigationServicesTest.php b/tests/Feature/Storefront/ThemeAndNavigationServicesTest.php new file mode 100644 index 00000000..9f2dd657 --- /dev/null +++ b/tests/Feature/Storefront/ThemeAndNavigationServicesTest.php @@ -0,0 +1,203 @@ +all(); + + expect($settings['hero_heading'])->toBe(ThemeSettingsService::defaults()['hero_heading']); + expect($settings['products_per_page'])->toBe(12); +}); + +it('merges stored settings of the active theme over the defaults', function () { + $context = createStoreContext(); + + $theme = Theme::factory()->for($context['store'])->create(); + ThemeSettings::factory()->for($theme)->withSettings(['hero_heading' => 'Custom Heading'])->create(); + + $service = app(ThemeSettingsService::class); + + expect($service->get('hero_heading'))->toBe('Custom Heading'); + expect($service->get('products_per_page'))->toBe(12); +}); + +it('ignores draft themes when loading settings', function () { + $context = createStoreContext(); + + $theme = Theme::factory()->draft()->for($context['store'])->create(); + ThemeSettings::factory()->for($theme)->withSettings(['hero_heading' => 'Draft Heading'])->create(); + + expect(app(ThemeSettingsService::class)->get('hero_heading')) + ->toBe(ThemeSettingsService::defaults()['hero_heading']); +}); + +it('invalidates the settings cache when theme settings are updated', function () { + $context = createStoreContext(); + + $theme = Theme::factory()->for($context['store'])->create(); + $settings = ThemeSettings::factory()->for($theme)->withSettings(['hero_heading' => 'Before'])->create(); + + $service = app(ThemeSettingsService::class); + expect($service->get('hero_heading'))->toBe('Before'); + + $settings->update(['settings_json' => ['hero_heading' => 'After']]); + + expect($service->get('hero_heading'))->toBe('After'); +}); + +it('builds a navigation tree with resolved urls for every item type', function () { + $context = createStoreContext(); + + $collection = Collection::factory()->for($context['store'])->create(['handle' => 'summer']); + $page = Page::factory()->for($context['store'])->create(['handle' => 'about']); + $product = Product::factory()->active()->for($context['store'])->create(['handle' => 'tee']); + + $menu = NavigationMenu::factory()->for($context['store'])->create(['handle' => 'main-menu']); + + NavigationItem::factory()->for($menu, 'menu')->create(['label' => 'Home', 'url' => '/', 'position' => 0]); + NavigationItem::factory()->for($menu, 'menu')->collection($collection->getKey())->create(['label' => 'Summer', 'position' => 1]); + NavigationItem::factory()->for($menu, 'menu')->page($page->getKey())->create(['label' => 'About', 'position' => 2]); + NavigationItem::factory()->for($menu, 'menu')->product($product->getKey())->create(['label' => 'Tee', 'position' => 3]); + + $tree = app(NavigationService::class)->buildTree($menu); + + expect(array_column($tree, 'url'))->toBe(['/', '/collections/summer', '/pages/about', '/products/tee']); + expect(array_column($tree, 'label'))->toBe(['Home', 'Summer', 'About', 'Tee']); +}); + +it('omits navigation items whose linked resource was deleted', function () { + $context = createStoreContext(); + + $collection = Collection::factory()->for($context['store'])->create(['handle' => 'gone']); + $menu = NavigationMenu::factory()->for($context['store'])->create(); + NavigationItem::factory()->for($menu, 'menu')->collection($collection->getKey())->create(['label' => 'Gone']); + + $collection->delete(); + + expect(app(NavigationService::class)->buildTree($menu))->toBe([]); +}); + +it('resolves a single navigation item url', function () { + $context = createStoreContext(); + + $page = Page::factory()->for($context['store'])->create(['handle' => 'faq']); + $menu = NavigationMenu::factory()->for($context['store'])->create(); + $item = NavigationItem::factory()->for($menu, 'menu')->page($page->getKey())->create(); + + expect(app(NavigationService::class)->resolveUrl($item))->toBe('/pages/faq'); +}); + +it('repairs stale Acme Fashion preview theme and navigation seed data', function () { + $context = createStoreContext([ + 'name' => 'Acme Fashion', + 'handle' => 'acme-fashion', + ]); + $store = $context['store']; + + $theme = Theme::factory()->for($store)->create(['name' => 'Default Theme']); + ThemeSettings::factory() + ->for($theme) + ->withSettings([ + 'primary_color' => '#1a1a2e', + 'secondary_color' => '#0f45e6', + 'hero_heading' => 'Welcome to Acme Fashion', + ]) + ->create(); + + foreach ([ + 'New Arrivals' => 'new-arrivals', + 'T-Shirts' => 't-shirts', + 'Pants & Jeans' => 'pants-jeans', + 'Sale' => 'sale', + ] as $title => $handle) { + Collection::factory()->for($store)->create([ + 'title' => $title, + 'handle' => $handle, + ]); + } + + foreach ([ + 'Classic Cotton T-Shirt' => 'classic-cotton-t-shirt', + 'Graphic Print Tee' => 'graphic-print-tee', + 'V-Neck Linen Tee' => 'v-neck-linen-tee', + 'Striped Polo Shirt' => 'striped-polo-shirt', + ] as $title => $handle) { + Product::factory()->for($store)->create([ + 'title' => $title, + 'handle' => $handle, + 'status' => 'active', + 'published_at' => now(), + ]); + } + + foreach ([ + 'About Us' => 'about', + 'FAQ' => 'faq', + 'Shipping & Returns' => 'shipping-returns', + 'Privacy Policy' => 'privacy-policy', + 'Terms of Service' => 'terms', + ] as $title => $handle) { + Page::factory()->for($store)->create([ + 'title' => $title, + 'handle' => $handle, + ]); + } + + $menu = NavigationMenu::factory()->for($store)->create([ + 'handle' => 'main-menu', + 'title' => 'Main Menu', + ]); + + NavigationItem::factory()->for($menu, 'menu')->create([ + 'label' => 'Home', + 'type' => NavigationItemType::Link, + 'url' => '/', + 'position' => 0, + ]); + + foreach (['New Arrivals', 'T-Shirts', 'Pants & Jeans', 'Sale'] as $position => $label) { + NavigationItem::factory()->for($menu, 'menu')->create([ + 'label' => $label, + 'type' => NavigationItemType::Collection, + 'url' => null, + 'resource_id' => null, + 'position' => $position + 1, + ]); + } + + Cache::put("theme_settings:{$store->getKey()}", ['secondary_color' => '#0f45e6'], now()->addMinutes(5)); + Cache::put("navigation_tree:{$store->getKey()}:main-menu", [['label' => 'Home']], now()->addMinutes(5)); + + $migration = require database_path('migrations/2026_06_11_000001_repair_acme_fashion_storefront_seed_data.php'); + $migration->up(); + $collectionProductsMigration = require database_path('migrations/2026_06_11_000002_repair_acme_fashion_collection_products.php'); + $collectionProductsMigration->up(); + + app()->instance('current_store', $store->fresh()); + + expect(app(ThemeSettingsService::class)->all($store)['secondary_color'])->toBe('#e94560'); + expect(array_column(app(NavigationService::class)->tree('main-menu'), 'label')) + ->toBe(['Home', 'New Arrivals', 'T-Shirts', 'Pants & Jeans', 'Sale']); + expect(StoreDomain::query()->where('hostname', '2026-06-09-claude-code-fable-5.agentic-engineers.dev')->exists()) + ->toBeTrue(); + expect(Collection::query()->where('handle', 't-shirts')->firstOrFail()->products()->pluck('products.handle')->all()) + ->toBe([ + 'classic-cotton-t-shirt', + 'graphic-print-tee', + 'v-neck-linen-tee', + 'striped-polo-shirt', + ]); +}); diff --git a/tests/Feature/Tenancy/StoreIsolationTest.php b/tests/Feature/Tenancy/StoreIsolationTest.php new file mode 100644 index 00000000..e7c2243f --- /dev/null +++ b/tests/Feature/Tenancy/StoreIsolationTest.php @@ -0,0 +1,114 @@ +create(); + $storeB = Store::factory()->create(); + + Product::factory()->count(3)->for($storeA)->create(); + Product::factory()->count(5)->for($storeB)->create(); + + app()->instance('current_store', $storeA); + + expect(Product::all())->toHaveCount(3); + expect(Product::query()->count())->toBe(3); +}); + +it('scopes order queries to the current store', function () { + $storeA = Store::factory()->create(); + $storeB = Store::factory()->create(); + + Order::factory()->count(2)->for($storeA)->create(); + Order::factory()->count(3)->for($storeB)->create(); + + app()->instance('current_store', $storeA); + + expect(Order::all())->toHaveCount(2); + expect(Order::query()->count())->toBe(2); +}); + +it('automatically sets store_id on product creation', function () { + $context = createStoreContext(); + + $product = Product::query()->create([ + 'title' => 'Isolation Product', + 'handle' => 'isolation-product', + ]); + + expect($product->store_id)->toBe($context['store']->getKey()); +}); + +it('prevents accessing another stores products via direct ID', function () { + $storeA = Store::factory()->create(); + $storeB = Store::factory()->create(); + + $product = Product::factory()->for($storeA)->create(); + + app()->instance('current_store', $storeB); + + expect(Product::query()->find($product->getKey()))->toBeNull(); +}); + +it('allows cross-store product access when global scope is removed', function () { + $storeA = Store::factory()->create(); + $storeB = Store::factory()->create(); + + Product::factory()->count(2)->for($storeA)->create(); + Product::factory()->count(3)->for($storeB)->create(); + + app()->instance('current_store', $storeA); + + expect(Product::query()->withoutGlobalScope(StoreScope::class)->count())->toBe(5); +}); + +it('scopes store-bound queries to the current store', function () { + $storeA = Store::factory()->create(); + $storeB = Store::factory()->create(); + + Customer::factory()->count(3)->for($storeA)->create(); + Customer::factory()->count(5)->for($storeB)->create(); + + app()->instance('current_store', $storeA); + + expect(Customer::all())->toHaveCount(3); + expect(Customer::query()->count())->toBe(3); +}); + +it('automatically sets store_id on model creation', function () { + $context = createStoreContext(); + + $customer = Customer::query()->create([ + 'email' => 'isolation@example.test', + 'name' => 'Isolation Test', + ]); + + expect($customer->store_id)->toBe($context['store']->getKey()); +}); + +it('prevents accessing another stores records via direct ID', function () { + $storeA = Store::factory()->create(); + $storeB = Store::factory()->create(); + + $customer = Customer::factory()->for($storeA)->create(); + + app()->instance('current_store', $storeB); + + expect(Customer::query()->find($customer->getKey()))->toBeNull(); +}); + +it('allows cross-store access when global scope is removed', function () { + $storeA = Store::factory()->create(); + $storeB = Store::factory()->create(); + + Customer::factory()->count(2)->for($storeA)->create(); + Customer::factory()->count(3)->for($storeB)->create(); + + app()->instance('current_store', $storeA); + + expect(Customer::query()->withoutGlobalScope(StoreScope::class)->count())->toBe(5); +}); diff --git a/tests/Feature/Tenancy/TenantResolutionTest.php b/tests/Feature/Tenancy/TenantResolutionTest.php new file mode 100644 index 00000000..b881bc9d --- /dev/null +++ b/tests/Feature/Tenancy/TenantResolutionTest.php @@ -0,0 +1,64 @@ +forgetInstance('current_store'); + + $response = $this->get('http://'.$context['domain']->hostname.'/'); + + $response->assertOk(); + expect(app()->bound('current_store'))->toBeTrue(); + expect(app('current_store')->getKey())->toBe($context['store']->getKey()); +}); + +it('returns 404 for unknown hostname', function () { + $this->get('http://nonexistent.test/')->assertNotFound(); +}); + +it('returns 503 for suspended store on storefront', function () { + $store = Store::factory()->suspended()->create(); + $domain = StoreDomain::factory()->for($store)->create(); + + $this->get('http://'.$domain->hostname.'/')->assertServiceUnavailable(); +}); + +it('resolves store from session for admin requests', function () { + $context = createStoreContext(); + app()->forgetInstance('current_store'); + + $response = actingAsAdmin($context['user'], $context['store'])->get('/admin'); + + $response->assertOk(); + expect(app('current_store')->getKey())->toBe($context['store']->getKey()); +}); + +it('denies admin access when user has no store_users record', function () { + $context = createStoreContext(); + app()->forgetInstance('current_store'); + + $outsider = User::factory()->create(); + + $this->actingAs($outsider) + ->withSession(['current_store_id' => $context['store']->getKey()]) + ->get('/admin') + ->assertForbidden(); +}); + +it('caches hostname lookup', function () { + $context = createStoreContext(); + app()->forgetInstance('current_store'); + + $hostname = $context['domain']->hostname; + + expect(Cache::has("store_domain:{$hostname}"))->toBeFalse(); + + $this->get('http://'.$hostname.'/')->assertOk(); + + expect(Cache::has("store_domain:{$hostname}"))->toBeTrue(); + expect(Cache::get("store_domain:{$hostname}"))->toBe($context['store']->getKey()); +}); diff --git a/tests/Feature/Webhooks/WebhookDeliveryTest.php b/tests/Feature/Webhooks/WebhookDeliveryTest.php new file mode 100644 index 00000000..10446b68 --- /dev/null +++ b/tests/Feature/Webhooks/WebhookDeliveryTest.php @@ -0,0 +1,179 @@ +context = createStoreContext(); + $this->store = $this->context['store']; +}); + +/** + * Run a DeliverWebhook job by hand, swallowing the RuntimeException the job + * throws to trigger a queue retry on non-final failed attempts. + */ +function runWebhookDeliveryJob(WebhookDelivery $delivery): DeliverWebhook +{ + $job = new DeliverWebhook($delivery, ['event' => 'order.created', 'data' => []], now()->getTimestamp()); + + try { + $job->handle(app(WebhookService::class)); + } catch (RuntimeException) { + // In production the queue retries the job per its backoff schedule. + } + + return $job; +} + +it('delivers a webhook to a subscribed URL', function () { + Http::fake(); + + $subscription = WebhookSubscription::factory()->for($this->store)->create([ + 'event_type' => 'order.created', + 'target_url' => 'https://example.test/hooks/orders', + ]); + + $order = Order::factory()->for($this->store)->create(); + + event(new OrderCreated($order)); + + Http::assertSent(function (Request $request) use ($order): bool { + $payload = $request->data(); + + return $request->url() === 'https://example.test/hooks/orders' + && $request->header('X-Platform-Event') === ['order.created'] + && $request->header('X-Platform-Delivery-Id') !== [] + && $request->header('X-Platform-Timestamp') !== [] + && $payload['event'] === 'order.created' + && $payload['data']['id'] === $order->getKey(); + }); + + $this->assertDatabaseHas('webhook_deliveries', [ + 'subscription_id' => $subscription->getKey(), + 'status' => 'success', + 'attempt_count' => 1, + 'response_code' => 200, + ]); +}); + +it('signs the payload with HMAC', function () { + Http::fake(); + + WebhookSubscription::factory()->for($this->store)->create([ + 'event_type' => 'order.created', + 'signing_secret_encrypted' => 'test-secret', + ]); + + app(WebhookService::class)->dispatch($this->store, 'order.created', ['id' => 42]); + + Http::assertSent(function (Request $request): bool { + $expected = hash_hmac('sha256', $request->body(), 'test-secret'); + + return $request->header('X-Platform-Signature') === [$expected]; + }); +}); + +it('retries failed deliveries with exponential backoff', function () { + Http::fake(['*' => Http::response('Internal Server Error', 500)]); + + $subscription = WebhookSubscription::factory()->for($this->store)->create(); + + $delivery = WebhookDelivery::factory()->create([ + 'subscription_id' => $subscription->getKey(), + ]); + + $job = runWebhookDeliveryJob($delivery); + + expect($job->tries)->toBe(6) + ->and($job->backoff)->toBe([60, 300, 1800, 7200, 43200]); + + $delivery->refresh(); + + expect($delivery->attempt_count)->toBe(1) + ->and($delivery->status)->toBe(WebhookDeliveryStatus::Pending) + ->and($delivery->response_code)->toBe(500); + + runWebhookDeliveryJob($delivery); + + expect($delivery->refresh()->attempt_count)->toBe(2) + ->and($delivery->status)->toBe(WebhookDeliveryStatus::Pending); +}); + +it('marks delivery as failed after max retries', function () { + Http::fake(['*' => Http::response('Internal Server Error', 500)]); + + $subscription = WebhookSubscription::factory()->for($this->store)->create(); + + $delivery = WebhookDelivery::factory()->create([ + 'subscription_id' => $subscription->getKey(), + 'attempt_count' => 5, + ]); + + $job = new DeliverWebhook($delivery, ['event' => 'order.created', 'data' => []], now()->getTimestamp()); + + // The sixth and final attempt records the dead letter without throwing. + $job->handle(app(WebhookService::class)); + + $delivery->refresh(); + + expect($delivery->attempt_count)->toBe(6) + ->and($delivery->status)->toBe(WebhookDeliveryStatus::Failed) + ->and($delivery->response_code)->toBe(500); +}); + +it('pauses subscription after circuit breaker threshold', function () { + Http::fake(['*' => Http::response('Internal Server Error', 500)]); + + $subscription = WebhookSubscription::factory()->for($this->store)->create([ + 'event_type' => 'order.created', + ]); + + foreach (range(1, 5) as $attempt) { + $delivery = WebhookDelivery::factory()->create([ + 'subscription_id' => $subscription->getKey(), + ]); + + runWebhookDeliveryJob($delivery); + } + + $subscription->refresh(); + + expect($subscription->status)->toBe(WebhookSubscriptionStatus::Paused) + ->and($subscription->consecutive_failures)->toBe(5); + + // A paused subscription no longer receives new deliveries. + $deliveriesBefore = WebhookDelivery::query()->count(); + + app(WebhookService::class)->dispatch($this->store, 'order.created', ['id' => 1]); + + expect(WebhookDelivery::query()->count())->toBe($deliveriesBefore); +}); + +it('does not break the triggering request when sync-queue delivery fails', function () { + Http::fake(['*' => Http::response('Internal Server Error', 500)]); + + $subscription = WebhookSubscription::factory()->for($this->store)->create([ + 'event_type' => 'order.created', + ]); + + // On the sync queue the job runs inline inside the dispatching request; + // a delivery failure must dead-letter instead of throwing into it. + app(WebhookService::class)->dispatch($this->store, 'order.created', ['id' => 7]); + + $delivery = WebhookDelivery::query() + ->where('subscription_id', $subscription->getKey()) + ->latest('id') + ->first(); + + expect($delivery->status)->toBe(WebhookDeliveryStatus::Failed) + ->and($delivery->attempt_count)->toBe(1) + ->and($subscription->refresh()->consecutive_failures)->toBe(1); +}); diff --git a/tests/Feature/Webhooks/WebhookSignatureTest.php b/tests/Feature/Webhooks/WebhookSignatureTest.php new file mode 100644 index 00000000..67d4200b --- /dev/null +++ b/tests/Feature/Webhooks/WebhookSignatureTest.php @@ -0,0 +1,39 @@ +service = app(WebhookService::class); +}); + +it('generates a valid HMAC-SHA256 signature', function () { + $payload = '{"event":"order.created"}'; + + $signature = $this->service->sign($payload, 'test-secret'); + + expect($signature)->toBe(hash_hmac('sha256', $payload, 'test-secret')); +}); + +it('verifies a valid signature', function () { + $payload = '{"event":"order.created","data":{"id":42}}'; + + $signature = $this->service->sign($payload, 'test-secret'); + + expect($this->service->verify($payload, $signature, 'test-secret'))->toBeTrue(); +}); + +it('rejects a tampered payload', function () { + $signature = $this->service->sign('{"event":"order.created","total":1000}', 'test-secret'); + + $tampered = '{"event":"order.created","total":9999}'; + + expect($this->service->verify($tampered, $signature, 'test-secret'))->toBeFalse(); +}); + +it('rejects an incorrect secret', function () { + $payload = '{"event":"order.created"}'; + + $signature = $this->service->sign($payload, 'secret-a'); + + expect($this->service->verify($payload, $signature, 'secret-b'))->toBeFalse(); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 60f04a45..e17bef78 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,47 +1,388 @@ extend(Tests\TestCase::class) - // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) +pest()->extend(TestCase::class) + ->use(RefreshDatabase::class) ->in('Feature'); -/* -|-------------------------------------------------------------------------- -| Expectations -|-------------------------------------------------------------------------- -| -| When you're writing tests, you often need to check that values meet certain conditions. The -| "expect()" function gives you access to a set of "expectations" methods that you can use -| to assert different things. Of course, you may extend the Expectation API at any time. -| -*/ +pest()->extend(TestCase::class) + ->use(RefreshDatabase::class) + ->beforeEach(function (): void { + $this->seed(); + + registerBrowserTestDomain(); -expect()->extend('toBeOne', function () { - return $this->toBe(1); -}); + // The demo seed includes webhook subscriptions; the sync queue would + // deliver them inline during checkout and fail on real HTTP calls. + Http::fake(); + }) + ->in('Browser'); /* |-------------------------------------------------------------------------- | Functions |-------------------------------------------------------------------------- -| -| While Pest is very powerful out-of-the-box, you may have some testing code specific to your -| project that you don't want to repeat in every file. Here you can also expose helpers as -| global functions to help you to reduce the number of lines of code in your test files. -| */ -function something() +/** + * Map the browser test server hostname (127.0.0.1) to the demo store so the + * ResolveStore middleware resolves the tenant exactly as it would for the + * seeded acme-fashion.test domain. Pest's browser plugin serves the app + * in-process on 127.0.0.1, which cannot be resolved via Herd's dnsmasq. + */ +function registerBrowserTestDomain(): void +{ + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + + StoreDomain::query()->create([ + 'store_id' => $store->getKey(), + 'hostname' => '127.0.0.1', + 'type' => 'storefront', + 'is_primary' => false, + 'tls_mode' => 'managed', + ]); +} + +/** + * Re-point the browser test hostname (127.0.0.1) at another seeded store so + * subsequent requests resolve that tenant through the real ResolveStore + * middleware. Used by the tenant isolation browser tests. + */ +function switchBrowserTestDomainToStore(string $handle): void +{ + $store = Store::query()->where('handle', $handle)->firstOrFail(); + + StoreDomain::query() + ->where('hostname', '127.0.0.1') + ->update(['store_id' => $store->getKey()]); + + Cache::forget('store_domain:127.0.0.1'); +} + +/** + * Browser test helper: log in to the admin panel as the seeded admin user + * and land on the dashboard. + */ +function browserLoginAsAdmin(): PendingAwaitablePage +{ + $page = visit('/admin/login'); + + $page->fill('email', 'admin@acme.test') + ->fill('password', 'password') + ->click('@admin-login-button') + ->assertSee('Dashboard'); + + return $page; +} + +/** + * Browser test helper: log in to the storefront account as the seeded + * customer (customer@acme.test) and land on the account dashboard. + */ +function browserLoginAsCustomer(): PendingAwaitablePage +{ + $page = visit('/account/login'); + + $page->fill('email', 'customer@acme.test') + ->fill('password', 'password') + ->click('@customer-login-button') + ->assertSee('My Account'); + + return $page; +} + +/** + * Browser test helper: log in to the admin panel and open the detail page + * of the given seeded order. + */ +function browserOpenAdminOrder(string $orderNumber): PendingAwaitablePage +{ + $page = browserLoginAsAdmin(); + + $page->click('aside a:has-text("Orders")') + ->assertSeeIn('h1[data-flux-heading]', 'Orders') + ->click('a:has-text("'.$orderNumber.'")') + ->assertSee('Timeline'); + + return $page; +} + +/** + * Browser test helper: create a fulfillment with tracking details for the + * single line of the currently open admin order detail page. + */ +function browserCreateFulfillment( + PendingAwaitablePage $page, + string $orderNumber, + string $trackingCompany = 'DHL', + string $trackingNumber = 'DHL123456789', +): PendingAwaitablePage { + $order = \App\Models\Order::query() + ->withoutGlobalScopes() + ->where('order_number', $orderNumber) + ->firstOrFail(); + + $lineId = $order->lines()->first()->getKey(); + + $page->click('@create-fulfillment-button') + ->assertSee('Tracking company') + ->click('@fulfill-line-checkbox-'.$lineId) + ->fill('trackingCompany', $trackingCompany) + ->fill('trackingNumber', $trackingNumber) + ->click('@submit-fulfillment-button') + ->assertSee('Fulfillment created'); + + return $page; +} + +/** + * Browser test helper: add the seeded Classic Cotton T-Shirt (size M, + * color Black) to the cart through the storefront product page. + */ +function browserAddClassicTeeToCart(): PendingAwaitablePage +{ + $page = visit('/products/classic-cotton-t-shirt'); + + $page->assertSee('Classic Cotton T-Shirt') + ->click('M') + ->click('label[title="Black"]') + ->click('Add to cart') + ->assertSee('Added to cart'); + + return $page; +} + +/** + * Browser test helper: fill the checkout shipping address form with a + * German address and submit it. + */ +function browserFillCheckoutAddress( + PendingAwaitablePage|AwaitableWebpage $page, + string $firstName = 'Test', + string $lastName = 'Buyer', + string $address1 = 'Teststrasse 1', + string $city = 'Berlin', + string $postalCode = '10115', + string $countryCode = 'DE', +): PendingAwaitablePage|AwaitableWebpage { + $page->assertSee('First name') + ->fill('[id="shipping.first_name"]', $firstName) + ->fill('[id="shipping.last_name"]', $lastName) + ->fill('[id="shipping.address1"]', $address1) + ->fill('[id="shipping.city"]', $city) + ->fill('[id="shipping.postal_code"]', $postalCode) + ->select('[id="shipping.country_code"]', $countryCode) + ->click('Continue'); + + return $page; +} + +/** + * Browser test helper: drive a fresh cart with the Classic Cotton T-Shirt + * through checkout steps 1-3 (contact, DE address, Standard Shipping) so + * the payment step is visible. + */ +function browserReachCheckoutPaymentStep(string $email = 'test@example.com'): PendingAwaitablePage +{ + $page = browserAddClassicTeeToCart(); + + $page->navigate('/cart') + ->click('Checkout') + ->assertSee('Contact information') + ->fill('checkout-email', $email) + ->click('Continue'); + + browserFillCheckoutAddress($page); + + $page->assertSee('Standard Shipping') + ->click('Standard Shipping') + ->click('Continue') + ->assertSee('Select a payment method'); + + return $page; +} + +/** + * Create a full store context: Organization, Store, StoreDomain, and a User + * with the Owner role. Binds the store in the container as "current_store". + * + * @param array $storeAttributes + * @return array{organization: Organization, store: Store, domain: StoreDomain, user: User} + */ +function createStoreContext(array $storeAttributes = []): array +{ + $organization = Organization::factory()->create(); + $store = Store::factory()->for($organization)->create($storeAttributes); + $domain = StoreDomain::factory()->for($store)->create(); + + $user = User::factory()->create(); + + StoreUser::query()->create([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => StoreUserRole::Owner, + ]); + + app()->instance('current_store', $store); + + return [ + 'organization' => $organization, + 'store' => $store, + 'domain' => $domain, + 'user' => $user, + ]; +} + +/** + * Create an active product with a single active default variant (and an + * inventory item) for the given store. Used by cart and checkout tests. + * + * @param array $variantAttributes + */ +function createPurchasableVariant( + Store $store, + int $priceAmount = 2500, + int $quantityOnHand = 100, + array $variantAttributes = [], + InventoryPolicy $policy = InventoryPolicy::Deny, +): ProductVariant { + $product = Product::factory()->active()->for($store)->create(); + + $variant = ProductVariant::factory() + ->asDefault() + ->priced($priceAmount) + ->for($product) + ->create($variantAttributes); + + InventoryItem::factory() + ->forVariant($variant) + ->withStock($quantityOnHand) + ->create(['policy' => $policy]); + + return $variant; +} + +/** + * A complete, valid checkout shipping address (Germany by default). + * + * @param array $overrides + * @return array + */ +function validShippingAddress(array $overrides = []): array +{ + return array_merge([ + 'first_name' => 'Erika', + 'last_name' => 'Mustermann', + 'address1' => 'Musterstrasse 1', + 'city' => 'Berlin', + 'postal_code' => '10115', + 'country_code' => 'DE', + ], $overrides); +} + +/** + * Drive a checkout for one purchasable variant through the full state + * machine up to payment_selected (DE address, flat 499 shipping rate when + * shipping is required). Used by the Phase 5 order and payment tests. + * + * @param array $variantAttributes + */ +function createPaymentSelectedCheckout( + Store $store, + string $paymentMethod = 'credit_card', + int $quantity = 1, + int $priceAmount = 2500, + int $quantityOnHand = 100, + array $variantAttributes = [], + ?Customer $customer = null, + string $email = 'shopper@example.test', + ?string $discountCode = null, +): Checkout { + $variant = createPurchasableVariant($store, $priceAmount, $quantityOnHand, $variantAttributes); + + $cartService = app(CartService::class); + $cart = $cartService->create($store); + $cartService->addLine($cart, $variant->getKey(), $quantity); + + $checkoutService = app(CheckoutService::class); + $checkout = $checkoutService->createFromCart($cart, $customer, $discountCode); + + $checkout = $checkoutService->setAddress($checkout, [ + 'email' => $email, + 'shipping_address' => validShippingAddress(), + ]); + + if ($cart->requiresShipping()) { + $zone = ShippingZone::factory()->for($store)->create(['countries_json' => ['DE']]); + $rate = ShippingRate::factory()->for($zone, 'zone')->flatAmount(499)->create(); + $checkout = $checkoutService->setShippingMethod($checkout, $rate->getKey()); + } else { + $checkout = $checkoutService->setShippingMethod($checkout); + } + + return $checkoutService->selectPaymentMethod($checkout, $paymentMethod); +} + +/** + * Create an additional user with the given role on the store. + */ +function createStoreMember(Store $store, StoreUserRole $role): User +{ + $user = User::factory()->create(); + + StoreUser::query()->create([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => $role, + ]); + + return $user; +} + +/** + * Authenticate as an admin user and put their store in the session. + */ +function actingAsAdmin(User $user, ?Store $store = null): TestCase +{ + $store ??= $user->stores()->first(); + + return test() + ->actingAs($user) + ->withSession(['current_store_id' => $store?->getKey()]); +} + +/** + * Authenticate as a storefront customer via the customer guard. + */ +function actingAsCustomer(Customer $customer): TestCase { - // .. + return test()->actingAs($customer, 'customer'); } diff --git a/tests/Unit/CartVersionTest.php b/tests/Unit/CartVersionTest.php new file mode 100644 index 00000000..f9ea65d7 --- /dev/null +++ b/tests/Unit/CartVersionTest.php @@ -0,0 +1,69 @@ +create(); + + $cart = app(CartService::class)->create($store); + + expect($cart->cart_version)->toBe(1); +}); + +it('increments version on add line', function () { + $store = Store::factory()->create(); + $variant = createPurchasableVariant($store); + + $service = app(CartService::class); + $cart = $service->create($store); + + $service->addLine($cart, $variant->getKey(), 1); + + expect($cart->refresh()->cart_version)->toBe(2); +}); + +it('increments version on update quantity', function () { + $store = Store::factory()->create(); + $variant = createPurchasableVariant($store); + + $service = app(CartService::class); + $cart = $service->create($store); + $line = $service->addLine($cart, $variant->getKey(), 1); + + $versionBefore = $cart->refresh()->cart_version; + + $service->updateLineQuantity($cart, $line->getKey(), 3); + + expect($cart->refresh()->cart_version)->toBe($versionBefore + 1); +}); + +it('increments version on remove line', function () { + $store = Store::factory()->create(); + $variant = createPurchasableVariant($store); + + $service = app(CartService::class); + $cart = $service->create($store); + $line = $service->addLine($cart, $variant->getKey(), 1); + + $versionBefore = $cart->refresh()->cart_version; + + $service->removeLine($cart, $line->getKey()); + + expect($cart->refresh()->cart_version)->toBe($versionBefore + 1); +}); + +it('detects version mismatch', function () { + $store = Store::factory()->create(); + + $service = app(CartService::class); + $cart = $service->create($store); + $cart->update(['cart_version' => 3]); + + $service->assertVersion($cart->refresh(), 2); +})->throws(CartVersionMismatchException::class); diff --git a/tests/Unit/DiscountCalculatorTest.php b/tests/Unit/DiscountCalculatorTest.php new file mode 100644 index 00000000..1dcfb1b7 --- /dev/null +++ b/tests/Unit/DiscountCalculatorTest.php @@ -0,0 +1,180 @@ +create(); + $cart = Cart::factory()->for($store)->create(); + CartLine::factory()->for($cart)->priced($subtotal, 1)->create(); + + return ['store' => $store, 'cart' => $cart]; +} + +/** + * Validate a code and return the InvalidDiscountException reason, or null + * when validation passes. + */ +function discountValidationReason(string $code, Store $store, Cart $cart): ?string +{ + try { + app(DiscountService::class)->validate($code, $store, $cart); + } catch (InvalidDiscountException $exception) { + return $exception->reason; + } + + return null; +} + +it('validates an active discount code', function () { + ['store' => $store, 'cart' => $cart] = discountTestContext(); + $discount = Discount::factory()->for($store)->create([ + 'code' => 'SAVE10', + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addDay(), + ]); + + $validated = app(DiscountService::class)->validate('SAVE10', $store, $cart); + + expect($validated)->toBeInstanceOf(Discount::class); + expect($validated->getKey())->toBe($discount->getKey()); +}); + +it('rejects an expired discount code', function () { + ['store' => $store, 'cart' => $cart] = discountTestContext(); + Discount::factory()->for($store)->create([ + 'code' => 'OLD20', + 'starts_at' => now()->subYear(), + 'ends_at' => now()->subDay(), + ]); + + expect(discountValidationReason('OLD20', $store, $cart))->toBe('expired'); +}); + +it('rejects a not-yet-active discount code', function () { + ['store' => $store, 'cart' => $cart] = discountTestContext(); + Discount::factory()->for($store)->create([ + 'code' => 'SOON10', + 'starts_at' => now()->addDay(), + 'ends_at' => now()->addYear(), + ]); + + expect(discountValidationReason('SOON10', $store, $cart))->toBe('not_yet_active'); +}); + +it('rejects a discount that has reached its usage limit', function () { + ['store' => $store, 'cart' => $cart] = discountTestContext(); + Discount::factory()->for($store)->create([ + 'code' => 'LIMITED', + 'usage_limit' => 10, + 'usage_count' => 10, + ]); + + expect(discountValidationReason('LIMITED', $store, $cart))->toBe('usage_limit_reached'); +}); + +it('rejects an unknown discount code', function () { + ['store' => $store, 'cart' => $cart] = discountTestContext(); + + expect(discountValidationReason('DOESNOTEXIST', $store, $cart))->toBe('not_found'); +}); + +it('performs case-insensitive code lookup', function () { + ['store' => $store, 'cart' => $cart] = discountTestContext(); + Discount::factory()->for($store)->create(['code' => 'SUMMER20']); + + $validated = app(DiscountService::class)->validate('summer20', $store, $cart); + + expect($validated->code)->toBe('SUMMER20'); +}); + +it('enforces minimum purchase amount rule', function () { + ['store' => $store, 'cart' => $cart] = discountTestContext(3000); + Discount::factory()->for($store)->create([ + 'code' => 'MIN50', + 'rules_json' => ['min_purchase_amount' => 5000], + ]); + + expect(discountValidationReason('MIN50', $store, $cart))->toBe('minimum_not_met'); +}); + +it('passes minimum purchase when cart meets threshold', function () { + ['store' => $store, 'cart' => $cart] = discountTestContext(5000); + Discount::factory()->for($store)->create([ + 'code' => 'MIN50', + 'rules_json' => ['min_purchase_amount' => 5000], + ]); + + expect(discountValidationReason('MIN50', $store, $cart))->toBeNull(); +}); + +it('calculates percent discount amount', function () { + ['store' => $store, 'cart' => $cart] = discountTestContext(10000); + $discount = Discount::factory()->for($store)->create(['value_amount' => 15]); + + $result = app(DiscountService::class)->calculate($discount, 10000, $cart->lines()->get()->all()); + + expect($result->amount)->toBe(1500); +}); + +it('calculates fixed discount amount', function () { + ['store' => $store, 'cart' => $cart] = discountTestContext(10000); + $discount = Discount::factory()->for($store)->fixed(500)->create(); + + $result = app(DiscountService::class)->calculate($discount, 10000, $cart->lines()->get()->all()); + + expect($result->amount)->toBe(500); +}); + +it('handles free shipping discount type', function () { + ['store' => $store, 'cart' => $cart] = discountTestContext(10000); + $discount = Discount::factory()->for($store)->freeShipping()->create(); + + $result = app(DiscountService::class)->calculate($discount, 10000, $cart->lines()->get()->all()); + + expect($result->amount)->toBe(0); + expect($result->freeShipping)->toBeTrue(); +}); + +it('allocates discount proportionally across multiple lines', function () { + $store = Store::factory()->create(); + $cart = Cart::factory()->for($store)->create(); + $lineA = CartLine::factory()->for($cart)->priced(7500, 1)->create(); + $lineB = CartLine::factory()->for($cart)->priced(2500, 1)->create(); + + $discount = Discount::factory()->for($store)->create(['value_amount' => 10]); + + $result = app(DiscountService::class)->calculate($discount, 10000, $cart->lines()->get()->all()); + + expect($result->amount)->toBe(1000); + expect($result->allocations[$lineA->getKey()])->toBe(750); + expect($result->allocations[$lineB->getKey()])->toBe(250); +}); + +it('distributes rounding remainder to the last qualifying line', function () { + $store = Store::factory()->create(); + $cart = Cart::factory()->for($store)->create(); + CartLine::factory()->for($cart)->priced(1111, 1)->create(); + CartLine::factory()->for($cart)->priced(2222, 1)->create(); + CartLine::factory()->for($cart)->priced(3333, 1)->create(); + + $discount = Discount::factory()->for($store)->create(['value_amount' => 15]); + + $result = app(DiscountService::class)->calculate($discount, 6666, $cart->lines()->get()->all()); + + expect(array_sum($result->allocations))->toBe($result->amount); +}); diff --git a/tests/Unit/HandleGeneratorTest.php b/tests/Unit/HandleGeneratorTest.php new file mode 100644 index 00000000..73e56a6e --- /dev/null +++ b/tests/Unit/HandleGeneratorTest.php @@ -0,0 +1,64 @@ +create(); + + $handle = app(HandleGenerator::class)->generate('My Amazing Product', 'products', $store->getKey()); + + expect($handle)->toBe('my-amazing-product'); +}); + +it('appends suffix on collision', function () { + $store = Store::factory()->create(); + Product::factory()->for($store)->create(['handle' => 't-shirt']); + + $handle = app(HandleGenerator::class)->generate('T-Shirt', 'products', $store->getKey()); + + expect($handle)->toBe('t-shirt-1'); +}); + +it('increments suffix on multiple collisions', function () { + $store = Store::factory()->create(); + Product::factory()->for($store)->create(['handle' => 't-shirt']); + Product::factory()->for($store)->create(['handle' => 't-shirt-1']); + + $handle = app(HandleGenerator::class)->generate('T-Shirt', 'products', $store->getKey()); + + expect($handle)->toBe('t-shirt-2'); +}); + +it('handles special characters', function () { + $store = Store::factory()->create(); + + $handle = app(HandleGenerator::class)->generate("Loewe's Fall/Winter 2026", 'products', $store->getKey()); + + expect($handle)->toMatch('/^[a-z0-9]+(-[a-z0-9]+)*$/'); + expect($handle)->toContain('loewe'); +}); + +it('excludes current record id from collision check', function () { + $store = Store::factory()->create(); + $product = Product::factory()->for($store)->create(['handle' => 't-shirt']); + + $handle = app(HandleGenerator::class)->generate('T-Shirt', 'products', $store->getKey(), $product->getKey()); + + expect($handle)->toBe('t-shirt'); +}); + +it('scopes uniqueness check to store', function () { + $storeA = Store::factory()->create(); + $storeB = Store::factory()->create(); + Product::factory()->for($storeA)->create(['handle' => 't-shirt']); + + $handle = app(HandleGenerator::class)->generate('T-Shirt', 'products', $storeB->getKey()); + + expect($handle)->toBe('t-shirt'); +}); diff --git a/tests/Unit/PricingEngineTest.php b/tests/Unit/PricingEngineTest.php new file mode 100644 index 00000000..7ab86691 --- /dev/null +++ b/tests/Unit/PricingEngineTest.php @@ -0,0 +1,205 @@ + $lines + * @param array $checkoutAttributes + * @param array $variantAttributes + * @return array{store: Store, cart: Cart, checkout: Checkout} + */ +function makePricedCheckout(array $lines, array $checkoutAttributes = [], array $variantAttributes = []): array +{ + $store = Store::factory()->create(); + $cart = Cart::factory()->for($store)->create(); + + foreach ($lines as [$price, $quantity]) { + $variant = createPurchasableVariant($store, $price, 100, $variantAttributes); + + CartLine::factory()->for($cart)->priced($price, $quantity)->create([ + 'variant_id' => $variant->getKey(), + ]); + } + + $checkout = Checkout::factory()->for($store)->for($cart)->create($checkoutAttributes); + + return ['store' => $store, 'cart' => $cart, 'checkout' => $checkout]; +} + +/** + * Create a flat shipping rate within a DE zone for the store. + */ +function makeFlatRateForStore(Store $store, int $amount): ShippingRate +{ + $zone = ShippingZone::factory()->for($store)->create(['countries_json' => ['DE']]); + + return ShippingRate::factory()->for($zone, 'zone')->flatAmount($amount)->create(); +} + +it('calculates subtotal from line items', function () { + ['checkout' => $checkout] = makePricedCheckout([[2499, 2], [7999, 1]]); + + $result = app(PricingEngine::class)->calculate($checkout); + + expect($result->subtotal)->toBe(12997); +}); + +it('calculates subtotal for a single line', function () { + ['checkout' => $checkout] = makePricedCheckout([[1500, 3]]); + + $result = app(PricingEngine::class)->calculate($checkout); + + expect($result->subtotal)->toBe(4500); +}); + +it('returns zero subtotal for empty cart', function () { + $store = Store::factory()->create(); + $cart = Cart::factory()->for($store)->create(); + $checkout = Checkout::factory()->for($store)->for($cart)->create(); + + $result = app(PricingEngine::class)->calculate($checkout); + + expect($result->subtotal)->toBe(0); + expect($result->total)->toBe(0); +}); + +it('applies percent discount correctly', function () { + ['store' => $store, 'checkout' => $checkout] = makePricedCheckout([[10000, 1]]); + Discount::factory()->for($store)->create(['code' => 'SAVE10', 'value_amount' => 10]); + $checkout->update(['discount_code' => 'SAVE10']); + + $result = app(PricingEngine::class)->calculate($checkout); + + expect($result->discount)->toBe(1000); + expect($result->subtotal - $result->discount)->toBe(9000); +}); + +it('applies fixed discount correctly', function () { + ['store' => $store, 'checkout' => $checkout] = makePricedCheckout([[10000, 1]]); + Discount::factory()->for($store)->fixed(500)->create(['code' => '5OFF']); + $checkout->update(['discount_code' => '5OFF']); + + $result = app(PricingEngine::class)->calculate($checkout); + + expect($result->discount)->toBe(500); + expect($result->subtotal - $result->discount)->toBe(9500); +}); + +it('caps fixed discount at subtotal so it never goes negative', function () { + ['store' => $store, 'checkout' => $checkout] = makePricedCheckout([[300, 1]]); + Discount::factory()->for($store)->fixed(500)->create(['code' => '5OFF']); + $checkout->update(['discount_code' => '5OFF']); + + $result = app(PricingEngine::class)->calculate($checkout); + + expect($result->discount)->toBe(300); + expect($result->total)->toBe(0); +}); + +it('applies free shipping discount by zeroing shipping', function () { + ['store' => $store, 'checkout' => $checkout] = makePricedCheckout([[5000, 1]]); + $rate = makeFlatRateForStore($store, 499); + Discount::factory()->for($store)->freeShipping()->create(['code' => 'FREESHIP']); + $checkout->update(['discount_code' => 'FREESHIP', 'shipping_method_id' => $rate->getKey()]); + + $result = app(PricingEngine::class)->calculate($checkout); + + expect($result->shipping)->toBe(0); +}); + +it('calculates tax exclusive correctly', function () { + ['store' => $store, 'checkout' => $checkout] = makePricedCheckout([[10000, 1]]); + TaxSettings::factory()->for($store)->rateBasisPoints(1900)->create(); + + $result = app(PricingEngine::class)->calculate($checkout); + + expect($result->taxTotal)->toBe(1900); + expect($result->total)->toBe(11900); +}); + +it('extracts tax from inclusive price correctly', function () { + ['store' => $store, 'checkout' => $checkout] = makePricedCheckout([[11900, 1]]); + TaxSettings::factory()->for($store)->rateBasisPoints(1900)->pricesIncludeTax()->create(); + + $result = app(PricingEngine::class)->calculate($checkout); + + expect($result->taxTotal)->toBe(1900); + expect($result->total)->toBe(11900); + expect($result->total - $result->taxTotal)->toBe(10000); +}); + +it('returns zero tax when rate is zero', function () { + ['store' => $store, 'checkout' => $checkout] = makePricedCheckout([[10000, 1]]); + TaxSettings::factory()->for($store)->rateBasisPoints(0)->create(); + + $result = app(PricingEngine::class)->calculate($checkout); + + expect($result->taxTotal)->toBe(0); +}); + +it('calculates shipping flat rate', function () { + ['store' => $store, 'checkout' => $checkout] = makePricedCheckout([[2500, 1]]); + $rate = makeFlatRateForStore($store, 499); + $checkout->update(['shipping_method_id' => $rate->getKey()]); + + $result = app(PricingEngine::class)->calculate($checkout); + + expect($result->shipping)->toBe(499); +}); + +it('calculates full checkout totals end to end', function () { + ['store' => $store, 'checkout' => $checkout] = makePricedCheckout([[2499, 1], [2499, 1]]); + $rate = makeFlatRateForStore($store, 499); + TaxSettings::factory()->for($store)->rateBasisPoints(1900)->pricesIncludeTax()->create(); + Discount::factory()->for($store)->create(['code' => 'WELCOME10', 'value_amount' => 10]); + $checkout->update(['discount_code' => 'WELCOME10', 'shipping_method_id' => $rate->getKey()]); + + $result = app(PricingEngine::class)->calculate($checkout); + + expect($result->subtotal)->toBe(4998); + expect($result->discount)->toBe(499); + expect($result->shipping)->toBe(499); + // Tax extracted from the gross 4499 + 499 = 4998: 4998 - intdiv(4998 * 10000, 11900) = 798. + expect($result->taxTotal)->toBe(798); + expect($result->total)->toBe(4998); +}); + +it('handles rounding correctly with odd cent amounts', function () { + ['store' => $store, 'cart' => $cart, 'checkout' => $checkout] = makePricedCheckout([[1111, 1], [2222, 1], [3333, 1]]); + Discount::factory()->for($store)->create(['code' => 'ODD15', 'value_amount' => 15]); + $checkout->update(['discount_code' => 'ODD15']); + + $result = app(PricingEngine::class)->calculate($checkout); + + $allocatedTotal = (int) $cart->lines()->sum('line_discount_amount'); + + expect($result->discount)->toBe(999); + expect($allocatedTotal)->toBe($result->discount); +}); + +it('produces identical results for identical inputs', function () { + ['store' => $store, 'checkout' => $checkout] = makePricedCheckout([[2499, 2], [7999, 1]]); + $rate = makeFlatRateForStore($store, 499); + TaxSettings::factory()->for($store)->rateBasisPoints(1900)->create(); + Discount::factory()->for($store)->create(['code' => 'SAVE10', 'value_amount' => 10]); + $checkout->update(['discount_code' => 'SAVE10', 'shipping_method_id' => $rate->getKey()]); + + $first = app(PricingEngine::class)->calculate($checkout); + $second = app(PricingEngine::class)->calculate($checkout->refresh()); + + expect($first->toArray())->toBe($second->toArray()); +}); diff --git a/tests/Unit/ShippingCalculatorTest.php b/tests/Unit/ShippingCalculatorTest.php new file mode 100644 index 00000000..987466c1 --- /dev/null +++ b/tests/Unit/ShippingCalculatorTest.php @@ -0,0 +1,134 @@ +create(); + $zone = ShippingZone::factory()->for($store)->create(['countries_json' => ['DE', 'AT', 'CH']]); + $rate = ShippingRate::factory()->for($zone, 'zone')->create(); + + $rates = app(ShippingCalculator::class)->getAvailableRates($store, ['country_code' => 'DE']); + + expect($rates)->toHaveCount(1); + expect($rates->first()->getKey())->toBe($rate->getKey()); +}); + +it('matches a zone by region code', function () { + $store = Store::factory()->create(); + $zone = ShippingZone::factory()->for($store)->create([ + 'countries_json' => ['US'], + 'regions_json' => ['US-NY', 'US-CA'], + ]); + ShippingRate::factory()->for($zone, 'zone')->create(); + + $rates = app(ShippingCalculator::class)->getAvailableRates($store, [ + 'country_code' => 'US', + 'province_code' => 'US-NY', + ]); + + expect($rates)->toHaveCount(1); +}); + +it('returns empty when no zone matches the address', function () { + $store = Store::factory()->create(); + $zone = ShippingZone::factory()->for($store)->create(['countries_json' => ['DE']]); + ShippingRate::factory()->for($zone, 'zone')->create(); + + $rates = app(ShippingCalculator::class)->getAvailableRates($store, ['country_code' => 'FR']); + + expect($rates)->toBeEmpty(); +}); + +it('calculates a flat rate', function () { + $store = Store::factory()->create(); + $variant = createPurchasableVariant($store); + $cart = Cart::factory()->for($store)->create(); + CartLine::factory()->for($cart)->priced(2500, 1)->create(['variant_id' => $variant->getKey()]); + + $rate = ShippingRate::factory()->flatAmount(499)->create(); + + expect(app(ShippingCalculator::class)->calculate($rate, $cart))->toBe(499); +}); + +it('calculates a weight-based rate', function () { + $store = Store::factory()->create(); + $variant = createPurchasableVariant($store, 2500, 100, ['weight_g' => 250]); + $cart = Cart::factory()->for($store)->create(); + CartLine::factory()->for($cart)->priced(2500, 3)->create(['variant_id' => $variant->getKey()]); + + $rate = ShippingRate::factory()->create([ + 'type' => 'weight', + 'config_json' => [ + 'ranges' => [ + ['min_g' => 0, 'max_g' => 500, 'amount' => 499], + ['min_g' => 501, 'max_g' => 2000, 'amount' => 899], + ], + ], + ]); + + expect(app(ShippingCalculator::class)->calculate($rate, $cart))->toBe(899); +}); + +it('calculates a price-based rate', function () { + $store = Store::factory()->create(); + $variant = createPurchasableVariant($store, 7500); + $cart = Cart::factory()->for($store)->create(); + CartLine::factory()->for($cart)->priced(7500, 1)->create(['variant_id' => $variant->getKey()]); + + $rate = ShippingRate::factory()->create([ + 'type' => 'price', + 'config_json' => [ + 'ranges' => [ + ['min_amount' => 0, 'max_amount' => 5000, 'amount' => 799], + ['min_amount' => 5001, 'max_amount' => 999999, 'amount' => 399], + ], + ], + ]); + + expect(app(ShippingCalculator::class)->calculate($rate, $cart))->toBe(399); +}); + +it('returns zero shipping when no items require shipping', function () { + $store = Store::factory()->create(); + $variant = createPurchasableVariant($store, 2500, 100, ['requires_shipping' => false]); + $cart = Cart::factory()->for($store)->create(); + CartLine::factory()->for($cart)->priced(2500, 1)->create(['variant_id' => $variant->getKey()]); + + $rate = ShippingRate::factory()->flatAmount(499)->create(); + + expect(app(ShippingCalculator::class)->calculate($rate, $cart))->toBe(0); +}); + +it('returns the correct rate when multiple zones match and the first is selected', function () { + $store = Store::factory()->create(); + + $zoneA = ShippingZone::factory()->for($store)->create(['countries_json' => ['DE']]); + $rateA = ShippingRate::factory()->for($zoneA, 'zone')->flatAmount(499)->create(); + + $zoneB = ShippingZone::factory()->for($store)->create(['countries_json' => ['DE', 'AT']]); + $rateB = ShippingRate::factory()->for($zoneB, 'zone')->flatAmount(999)->create(); + + $rates = app(ShippingCalculator::class)->getAvailableRates($store, ['country_code' => 'DE']); + + expect($rates->pluck('id')->all())->toContain($rateA->getKey(), $rateB->getKey()); +}); + +it('skips inactive rates', function () { + $store = Store::factory()->create(); + $zone = ShippingZone::factory()->for($store)->create(['countries_json' => ['DE']]); + ShippingRate::factory()->for($zone, 'zone')->inactive()->create(); + $activeRate = ShippingRate::factory()->for($zone, 'zone')->create(); + + $rates = app(ShippingCalculator::class)->getAvailableRates($store, ['country_code' => 'DE']); + + expect($rates->pluck('id')->all())->toBe([$activeRate->getKey()]); +}); diff --git a/tests/Unit/TaxCalculatorTest.php b/tests/Unit/TaxCalculatorTest.php new file mode 100644 index 00000000..0f111a31 --- /dev/null +++ b/tests/Unit/TaxCalculatorTest.php @@ -0,0 +1,53 @@ +addExclusive(10000, 1900))->toBe(1900); +}); + +it('extracts manual tax from inclusive amount', function () { + $tax = new TaxCalculator; + + $extracted = $tax->extractInclusive(11900, 1900); + + expect($extracted)->toBe(1900); + expect(11900 - $extracted)->toBe(10000); +}); + +it('returns zero tax when no rate is configured', function () { + $tax = new TaxCalculator; + + expect($tax->addExclusive(10000, 0))->toBe(0); + expect($tax->extractInclusive(10000, 0))->toBe(0); +}); + +it('handles zero amount lines', function () { + $tax = new TaxCalculator; + + expect($tax->addExclusive(0, 1900))->toBe(0); + expect($tax->extractInclusive(0, 1900))->toBe(0); +}); + +it('calculates tax with non-standard rate', function () { + $tax = new TaxCalculator; + + expect($tax->addExclusive(8999, 700))->toBe(629); +}); + +it('extracts tax correctly for small amounts', function () { + $tax = new TaxCalculator; + + $extracted = $tax->extractInclusive(119, 1900); + + expect($extracted)->toBe(19); + expect(119 - $extracted)->toBe(100); +}); + +it('handles high tax rates', function () { + $tax = new TaxCalculator; + + expect($tax->addExclusive(10000, 2500))->toBe(2500); +});