diff --git a/.agents/skills/developing-with-fortify/SKILL.md b/.agents/skills/developing-with-fortify/SKILL.md new file mode 100644 index 00000000..2ff71a4b --- /dev/null +++ b/.agents/skills/developing-with-fortify/SKILL.md @@ -0,0 +1,116 @@ +--- +name: developing-with-fortify +description: Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications. +--- + +# Laravel Fortify Development + +Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications. + +## Documentation + +Use `search-docs` for detailed Laravel Fortify patterns and documentation. + +## Usage + +- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints +- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.) +- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field +- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.) +- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc. + +## Available Features + +Enable in `config/fortify.php` features array: + +- `Features::registration()` - User registration +- `Features::resetPasswords()` - Password reset via email +- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail` +- `Features::updateProfileInformation()` - Profile updates +- `Features::updatePasswords()` - Password changes +- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes + +> Use `search-docs` for feature configuration options and customization patterns. + +## Setup Workflows + +### Two-Factor Authentication Setup + +``` +- [ ] Add TwoFactorAuthenticatable trait to User model +- [ ] Enable feature in config/fortify.php +- [ ] Run migrations for 2FA columns +- [ ] Set up view callbacks in FortifyServiceProvider +- [ ] Create 2FA management UI +- [ ] Test QR code and recovery codes +``` + +> Use `search-docs` for TOTP implementation and recovery code handling patterns. + +### Email Verification Setup + +``` +- [ ] Enable emailVerification feature in config +- [ ] Implement MustVerifyEmail interface on User model +- [ ] Set up verifyEmailView callback +- [ ] Add verified middleware to protected routes +- [ ] Test verification email flow +``` + +> Use `search-docs` for MustVerifyEmail implementation patterns. + +### Password Reset Setup + +``` +- [ ] Enable resetPasswords feature in config +- [ ] Set up requestPasswordResetLinkView callback +- [ ] Set up resetPasswordView callback +- [ ] Define password.reset named route (if views disabled) +- [ ] Test reset email and link flow +``` + +> Use `search-docs` for custom password reset flow patterns. + +### SPA Authentication Setup + +``` +- [ ] Set 'views' => false in config/fortify.php +- [ ] Install and configure Laravel Sanctum +- [ ] Use 'web' guard in fortify config +- [ ] Set up CSRF token handling +- [ ] Test XHR authentication flows +``` + +> Use `search-docs` for integration and SPA authentication patterns. + +## Best Practices + +### Custom Authentication Logic + +Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects. + +### Registration Customization + +Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields. + +### Rate Limiting + +Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination. + +## Key Endpoints + +| Feature | Method | Endpoint | +|------------------------|----------|---------------------------------------------| +| Login | POST | `/login` | +| Logout | POST | `/logout` | +| Register | POST | `/register` | +| Password Reset Request | POST | `/forgot-password` | +| Password Reset | POST | `/reset-password` | +| Email Verify Notice | GET | `/email/verify` | +| Resend Verification | POST | `/email/verification-notification` | +| Password Confirm | POST | `/user/confirm-password` | +| Enable 2FA | POST | `/user/two-factor-authentication` | +| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` | +| 2FA Challenge | POST | `/two-factor-challenge` | +| Get QR Code | GET | `/user/two-factor-qr-code` | +| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` | \ No newline at end of file diff --git a/.agents/skills/fluxui-development/SKILL.md b/.agents/skills/fluxui-development/SKILL.md new file mode 100644 index 00000000..4b5aabb1 --- /dev/null +++ b/.agents/skills/fluxui-development/SKILL.md @@ -0,0 +1,81 @@ +--- +name: fluxui-development +description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling." +license: MIT +metadata: + author: laravel +--- + +# Flux UI Development + +## Documentation + +Use `search-docs` for detailed Flux UI patterns and documentation. + +## Basic Usage + +This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components. + +Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize. + +Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs. + + +```blade +Click me +``` + +## Available Components (Free Edition) + +Available: avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, profile, radio, select, separator, skeleton, switch, text, textarea, 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 \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/SKILL.md b/.agents/skills/laravel-best-practices/SKILL.md new file mode 100644 index 00000000..aca32c9c --- /dev/null +++ b/.agents/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 \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/advanced-queries.md b/.agents/skills/laravel-best-practices/rules/advanced-queries.md new file mode 100644 index 00000000..920714a1 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/advanced-queries.md @@ -0,0 +1,106 @@ +# Advanced Query Patterns + +## Use `addSelect()` Subqueries for Single Values from Has-Many + +Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries. + +```php +public function scopeWithLastLoginAt($query): void +{ + $query->addSelect([ + 'last_login_at' => Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->withCasts(['last_login_at' => 'datetime']); +} +``` + +## Create Dynamic Relationships via Subquery FK + +Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection. + +```php +public function lastLogin(): BelongsTo +{ + return $this->belongsTo(Login::class); +} + +public function scopeWithLastLogin($query): void +{ + $query->addSelect([ + 'last_login_id' => Login::select('id') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->with('lastLogin'); +} +``` + +## Use Conditional Aggregates Instead of Multiple Count Queries + +Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values. + +```php +$statuses = Feature::toBase() + ->selectRaw("count(case when status = 'Requested' then 1 end) as requested") + ->selectRaw("count(case when status = 'Planned' then 1 end) as planned") + ->selectRaw("count(case when status = 'Completed' then 1 end) as completed") + ->first(); +``` + +## Use `setRelation()` to Prevent Circular N+1 + +When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries. + +```php +$feature->load('comments.user'); +$feature->comments->each->setRelation('feature', $feature); +``` + +## Prefer `whereIn` + Subquery Over `whereHas` + +`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory. + +Incorrect (correlated EXISTS re-executes per row): + +```php +$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term)); +``` + +Correct (index-friendly subquery, no PHP memory overhead): + +```php +$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id')); +``` + +## Sometimes Two Simple Queries Beat One Complex Query + +Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index. + +## Use Compound Indexes Matching `orderBy` Column Order + +When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index. + +```php +// Migration +$table->index(['last_name', 'first_name']); + +// Query — column order must match the index +User::query()->orderBy('last_name')->orderBy('first_name')->paginate(); +``` + +## Use Correlated Subqueries for Has-Many Ordering + +When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading. + +```php +public function scopeOrderByLastLogin($query): void +{ + $query->orderByDesc(Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1) + ); +} +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/architecture.md b/.agents/skills/laravel-best-practices/rules/architecture.md new file mode 100644 index 00000000..6112a635 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/architecture.md @@ -0,0 +1,202 @@ +# Architecture Best Practices + +## Single-Purpose Action Classes + +Extract discrete business operations into invokable Action classes. + +```php +class CreateOrderAction +{ + public function __construct(private InventoryService $inventory) {} + + public function 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); + } +} +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/blade-views.md b/.agents/skills/laravel-best-practices/rules/blade-views.md new file mode 100644 index 00000000..c6f8aaf1 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/blade-views.md @@ -0,0 +1,36 @@ +# Blade & Views Best Practices + +## Use `$attributes->merge()` in Component Templates + +Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly. + +```blade +
merge(['class' => 'alert alert-'.$type]) }}> + {{ $message }} +
+``` + +## Use `@pushOnce` for Per-Component Scripts + +If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once. + +## Prefer Blade Components Over `@include` + +`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots. + +## Use View Composers for Shared View Data + +If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it. + +## Use Blade Fragments for Partial Re-Renders (htmx/Turbo) + +A single view can return either the full page or just a fragment, keeping routing clean. + +```php +return view('dashboard', compact('users')) + ->fragmentIf($request->hasHeader('HX-Request'), 'user-list'); +``` + +## Use `@aware` for Deeply Nested Component Props + +Avoids re-passing parent props through every level of nested components. \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/caching.md b/.agents/skills/laravel-best-practices/rules/caching.md new file mode 100644 index 00000000..e65146dc --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/caching.md @@ -0,0 +1,70 @@ +# Caching Best Practices + +## Use `Cache::remember()` Instead of Manual Get/Put + +Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions. + +Incorrect: +```php +$val = Cache::get('stats'); +if (! $val) { + $val = $this->computeStats(); + Cache::put('stats', $val, 60); +} +``` + +Correct: +```php +$val = Cache::remember('stats', 60, fn () => $this->computeStats()); +``` + +## Use `Cache::flexible()` for Stale-While-Revalidate + +On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background. + +Incorrect: `Cache::remember('users', 300, fn () => User::all());` + +Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function. + +## Use `Cache::memo()` to Avoid Redundant Hits Within a Request + +If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory. + +`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5. + +## Use Cache Tags to Invalidate Related Groups + +Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`. + +```php +Cache::tags(['user-1'])->flush(); +``` + +## Use `Cache::add()` for Atomic Conditional Writes + +`add()` only writes if the key does not exist — atomic, no race condition between checking and writing. + +Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }` + +Correct: `Cache::add('lock', true, 10);` + +## Use `once()` for Per-Request Memoization + +`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory. + +```php +public function roles(): Collection +{ + return once(fn () => $this->loadRoles()); +} +``` + +Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching. + +## Configure Failover Cache Stores in Production + +If Redis goes down, the app falls back to a secondary store automatically. + +```php +'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/collections.md b/.agents/skills/laravel-best-practices/rules/collections.md new file mode 100644 index 00000000..14f683d3 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/collections.md @@ -0,0 +1,44 @@ +# Collection Best Practices + +## Use Higher-Order Messages for Simple Operations + +Incorrect: +```php +$users->each(function (User $user) { + $user->markAsVip(); +}); +``` + +Correct: `$users->each->markAsVip();` + +Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc. + +## Choose `cursor()` vs. `lazy()` Correctly + +- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk). +- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading. + +Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored. + +Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work. + +## Use `lazyById()` When Updating Records While Iterating + +`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation. + +## Use `toQuery()` for Bulk Operations on Collections + +Avoids manual `whereIn` construction. + +Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);` + +Correct: `$users->toQuery()->update([...]);` + +## Use `#[CollectedBy]` for Custom Collection Classes + +More declarative than overriding `newCollection()`. + +```php +#[CollectedBy(UserCollection::class)] +class User extends Model {} +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/config.md b/.agents/skills/laravel-best-practices/rules/config.md new file mode 100644 index 00000000..193155d6 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/config.md @@ -0,0 +1,73 @@ +# Configuration Best Practices + +## `env()` Only in Config Files + +Direct `env()` calls may return `null` when config is cached. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'key' => env('API_KEY'), + +// Application code +$key = config('services.key'); +``` + +## Use Encrypted Env or External Secrets + +Never store production secrets in plain `.env` files in version control. + +Incorrect: +```bash + +# .env committed to repo or shared in Slack + +STRIPE_SECRET=sk_live_abc123 +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI +``` + +Correct: +```bash +php artisan env:encrypt --env=production --readable +php artisan env:decrypt --env=production +``` + +For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime. + +## Use `App::environment()` for Environment Checks + +Incorrect: +```php +if (env('APP_ENV') === 'production') { +``` + +Correct: +```php +if (app()->isProduction()) { +// or +if (App::environment('production')) { +``` + +## Use Constants and Language Files + +Use class constants instead of hardcoded magic strings for model states, types, and statuses. + +```php +// Incorrect +return $this->type === 'normal'; + +// Correct +return $this->type === self::TYPE_NORMAL; +``` + +If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there. + +```php +// Only when lang files already exist in the project +return back()->with('message', __('app.article_added')); +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/db-performance.md b/.agents/skills/laravel-best-practices/rules/db-performance.md new file mode 100644 index 00000000..8fb71937 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/db-performance.md @@ -0,0 +1,192 @@ +# Database Performance Best Practices + +## Always Eager Load Relationships + +Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront. + +Incorrect (N+1 — executes 1 + N queries): +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Correct (2 queries total): +```php +$posts = Post::with('author')->get(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Constrain eager loads to select only needed columns (always include the foreign key): + +```php +$users = User::with(['posts' => function ($query) { + $query->select('id', 'user_id', 'title') + ->where('published', true) + ->latest() + ->limit(10); +}])->get(); +``` + +## Prevent Lazy Loading in Development + +Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development. + +```php +public function boot(): void +{ + Model::preventLazyLoading(! app()->isProduction()); +} +``` + +Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded. + +## Select Only Needed Columns + +Avoid `SELECT *` — especially when tables have large text or JSON columns. + +Incorrect: +```php +$posts = Post::with('author')->get(); +``` + +Correct: +```php +$posts = Post::select('id', 'title', 'user_id', 'created_at') + ->with(['author:id,name,avatar']) + ->get(); +``` + +When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match. + +## Chunk Large Datasets + +Never load thousands of records at once. Use chunking for batch processing. + +Incorrect: +```php +$users = User::all(); +foreach ($users as $user) { + $user->notify(new WeeklyDigest); +} +``` + +Correct: +```php +User::where('subscribed', true)->chunk(200, function ($users) { + foreach ($users as $user) { + $user->notify(new WeeklyDigest); + } +}); +``` + +Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change: + +```php +User::where('active', false)->chunkById(200, function ($users) { + $users->each->delete(); +}); +``` + +## Add Database Indexes + +Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->index()->constrained(); + $table->string('status')->index(); + $table->timestamps(); + $table->index(['status', 'created_at']); +}); +``` + +Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`). + +## Use `withCount()` for Counting Relations + +Never load entire collections just to count them. + +Incorrect: +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->comments->count(); +} +``` + +Correct: +```php +$posts = Post::withCount('comments')->get(); +foreach ($posts as $post) { + echo $post->comments_count; +} +``` + +Conditional counting: + +```php +$posts = Post::withCount([ + 'comments', + 'comments as approved_comments_count' => function ($query) { + $query->where('approved', true); + }, +])->get(); +``` + +## Use `cursor()` for Memory-Efficient Iteration + +For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator. + +Incorrect: +```php +$users = User::where('active', true)->get(); +``` + +Correct: +```php +foreach (User::where('active', true)->cursor() as $user) { + ProcessUser::dispatch($user->id); +} +``` + +Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records. + +## No Queries in Blade Templates + +Never execute queries in Blade templates. Pass data from controllers. + +Incorrect: +```blade +@foreach (User::all() as $user) + {{ $user->profile->name }} +@endforeach +``` + +Correct: +```php +// Controller +$users = User::with('profile')->get(); +return view('users.index', compact('users')); +``` + +```blade +@foreach ($users as $user) + {{ $user->profile->name }} +@endforeach +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/eloquent.md b/.agents/skills/laravel-best-practices/rules/eloquent.md new file mode 100644 index 00000000..09cd66a0 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/eloquent.md @@ -0,0 +1,148 @@ +# Eloquent Best Practices + +## Use Correct Relationship Types + +Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints. + +```php +public function comments(): HasMany +{ + return $this->hasMany(Comment::class); +} + +public function author(): BelongsTo +{ + return $this->belongsTo(User::class, 'user_id'); +} +``` + +## Use Local Scopes for Reusable Queries + +Extract reusable query constraints into local scopes to avoid duplication. + +Incorrect: +```php +$active = User::where('verified', true)->whereNotNull('activated_at')->get(); +$articles = Article::whereHas('user', function ($q) { + $q->where('verified', true)->whereNotNull('activated_at'); +})->get(); +``` + +Correct: +```php +public function scopeActive(Builder $query): Builder +{ + return $query->where('verified', true)->whereNotNull('activated_at'); +} + +// Usage +$active = User::active()->get(); +$articles = Article::whereHas('user', fn ($q) => $q->active())->get(); +``` + +## Apply Global Scopes Sparingly + +Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy. + +Incorrect (global scope for a conditional filter): +```php +class PublishedScope implements Scope +{ + public function apply(Builder $builder, Model $model): void + { + $builder->where('published', true); + } +} +// Now admin panels, reports, and background jobs all silently skip drafts +``` + +Correct (local scope you opt into): +```php +public function scopePublished(Builder $query): Builder +{ + return $query->where('published', true); +} + +Post::published()->paginate(); // Explicit +Post::paginate(); // Admin sees all +``` + +## Define Attribute Casts + +Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion. + +```php +protected function casts(): array +{ + return [ + 'is_active' => 'boolean', + 'metadata' => 'array', + 'total' => 'decimal:2', + ]; +} +``` + +## Cast Date Columns Properly + +Always cast date columns. Use Carbon instances in templates instead of formatting strings manually. + +Incorrect: +```blade +{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }} +``` + +Correct: +```php +protected function casts(): array +{ + return [ + 'ordered_at' => 'datetime', + ]; +} +``` + +```blade +{{ $order->ordered_at->toDateString() }} +{{ $order->ordered_at->format('m-d') }} +``` + +## Use `whereBelongsTo()` for Relationship Queries + +Cleaner than manually specifying foreign keys. + +Incorrect: +```php +Post::where('user_id', $user->id)->get(); +``` + +Correct: +```php +Post::whereBelongsTo($user)->get(); +Post::whereBelongsTo($user, 'author')->get(); +``` + +## Avoid Hardcoded Table Names in Queries + +Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string). + +Incorrect: +```php +DB::table('users')->where('active', true)->get(); + +$query->join('companies', 'companies.id', '=', 'users.company_id'); + +DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); +``` + +Correct — reference the model's table: +```php +DB::table((new User)->getTable())->where('active', true)->get(); + +// Even better — use Eloquent or the query builder instead of raw SQL +User::where('active', true)->get(); +Order::where('status', 'pending')->get(); +``` + +Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable. + +**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration. \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/error-handling.md b/.agents/skills/laravel-best-practices/rules/error-handling.md new file mode 100644 index 00000000..bb8e7a38 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/error-handling.md @@ -0,0 +1,72 @@ +# Error Handling Best Practices + +## Exception Reporting and Rendering + +There are two valid approaches — choose one and apply it consistently across the project. + +**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find: + +```php +class InvalidOrderException extends Exception +{ + public function report(): void { /* custom reporting */ } + + public function render(Request $request): Response + { + return response()->view('errors.invalid-order', status: 422); + } +} +``` + +**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture: + +```php +->withExceptions(function (Exceptions $exceptions) { + $exceptions->report(function (InvalidOrderException $e) { /* ... */ }); + $exceptions->render(function (InvalidOrderException $e, Request $request) { + return response()->view('errors.invalid-order', status: 422); + }); +}) +``` + +Check the existing codebase and follow whichever pattern is already established. + +## Use `ShouldntReport` for Exceptions That Should Never Log + +More discoverable than listing classes in `dontReport()`. + +```php +class PodcastProcessingException extends Exception implements ShouldntReport {} +``` + +## Throttle High-Volume Exceptions + +A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type. + +## Enable `dontReportDuplicates()` + +Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks. + +## Force JSON Error Rendering for API Routes + +Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes. + +```php +$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { + return $request->is('api/*') || $request->expectsJson(); +}); +``` + +## Add Context to Exception Classes + +Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry. + +```php +class InvalidOrderException extends Exception +{ + public function context(): array + { + return ['order_id' => $this->orderId]; + } +} +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/events-notifications.md b/.agents/skills/laravel-best-practices/rules/events-notifications.md new file mode 100644 index 00000000..47fcf324 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/events-notifications.md @@ -0,0 +1,52 @@ +# Events & Notifications Best Practices + +## Rely on Event Discovery + +Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`. + +## Run `event:cache` in Production Deploy + +Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`. + +## Use `ShouldDispatchAfterCommit` Inside Transactions + +Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet. + +```php +class OrderShipped implements ShouldDispatchAfterCommit {} +``` + +## Always Queue Notifications + +Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response. + +```php +class InvoicePaid extends Notification implements ShouldQueue +{ + use Queueable; +} +``` + +## Use `afterCommit()` on Notifications in Transactions + +Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits. + +```php +$user->notify((new InvoicePaid($invoice))->afterCommit()); +``` + +## Route Notification Channels to Dedicated Queues + +Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues. + +## Use On-Demand Notifications for Non-User Recipients + +Avoid creating dummy models to send notifications to arbitrary addresses. + +```php +Notification::route('mail', 'admin@example.com')->notify(new SystemAlert()); +``` + +## Implement `HasLocalePreference` on Notifiable Models + +Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/http-client.md b/.agents/skills/laravel-best-practices/rules/http-client.md new file mode 100644 index 00000000..fd37ddb9 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/http-client.md @@ -0,0 +1,160 @@ +# HTTP Client Best Practices + +## Always Set Explicit Timeouts + +The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users'); +``` + +Correct: +```php +$response = Http::timeout(5) + ->connectTimeout(3) + ->get('https://api.example.com/users'); +``` + +For service-specific clients, define timeouts in a macro: + +```php +Http::macro('github', function () { + return Http::baseUrl('https://api.github.com') + ->timeout(10) + ->connectTimeout(3) + ->withToken(config('services.github.token')); +}); + +$response = Http::github()->get('/repos/laravel/framework'); +``` + +## Use Retry with Backoff for External APIs + +External APIs have transient failures. Use `retry()` with increasing delays. + +Incorrect: +```php +$response = Http::post('https://api.stripe.com/v1/charges', $data); + +if ($response->failed()) { + throw new PaymentFailedException('Charge failed'); +} +``` + +Correct: +```php +$response = Http::retry([100, 500, 1000]) + ->timeout(10) + ->post('https://api.stripe.com/v1/charges', $data); +``` + +Only retry on specific errors: + +```php +$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException && $exception->response->serverError()); +})->post('https://api.example.com/data'); +``` + +## Handle Errors Explicitly + +The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users/1'); +$user = $response->json(); // Could be an error body +``` + +Correct: +```php +$response = Http::timeout(5) + ->get('https://api.example.com/users/1') + ->throw(); + +$user = $response->json(); +``` + +For graceful degradation: + +```php +$response = Http::get('https://api.example.com/users/1'); + +if ($response->successful()) { + return $response->json(); +} + +if ($response->notFound()) { + return null; +} + +$response->throw(); +``` + +## Use Request Pooling for Concurrent Requests + +When making multiple independent API calls, use `Http::pool()` instead of sequential calls. + +Incorrect: +```php +$users = Http::get('https://api.example.com/users')->json(); +$posts = Http::get('https://api.example.com/posts')->json(); +$comments = Http::get('https://api.example.com/comments')->json(); +``` + +Correct: +```php +use Illuminate\Http\Client\Pool; + +$responses = Http::pool(fn (Pool $pool) => [ + $pool->as('users')->get('https://api.example.com/users'), + $pool->as('posts')->get('https://api.example.com/posts'), + $pool->as('comments')->get('https://api.example.com/comments'), +]); + +$users = $responses['users']->json(); +$posts = $responses['posts']->json(); +``` + +## Fake HTTP Calls in Tests + +Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`. + +Incorrect: +```php +it('syncs user from API', function () { + $service = new UserSyncService; + $service->sync(1); // Hits the real API +}); +``` + +Correct: +```php +it('syncs user from API', function () { + Http::preventStrayRequests(); + + Http::fake([ + 'api.example.com/users/1' => Http::response([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]), + ]); + + $service = new UserSyncService; + $service->sync(1); + + Http::assertSent(function (Request $request) { + return $request->url() === 'https://api.example.com/users/1'; + }); +}); +``` + +Test failure scenarios too: + +```php +Http::fake([ + 'api.example.com/*' => Http::failedConnection(), +]); +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/mail.md b/.agents/skills/laravel-best-practices/rules/mail.md new file mode 100644 index 00000000..2435d9cc --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/mail.md @@ -0,0 +1,27 @@ +# Mail Best Practices + +## Implement `ShouldQueue` on the Mailable Class + +Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it. + +## Use `afterCommit()` on Mailables Inside Transactions + +A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor. + +## Use `assertQueued()` Not `assertSent()` for Queued Mailables + +`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint. + +Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. + +Correct: `Mail::assertQueued(OrderShipped::class);` + +## Use Markdown Mailables for Transactional Emails + +Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag. + +## Separate Content Tests from Sending Tests + +Content tests: instantiate the mailable directly, call `assertSeeInHtml()`. +Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`. +Don't mix them — it conflates concerns and makes tests brittle. \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/migrations.md b/.agents/skills/laravel-best-practices/rules/migrations.md new file mode 100644 index 00000000..de25aa39 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/migrations.md @@ -0,0 +1,121 @@ +# Migration Best Practices + +## Generate Migrations with Artisan + +Always use `php artisan make:migration` for consistent naming and timestamps. + +Incorrect (manually created file): +```php +// database/migrations/posts_migration.php ← wrong naming, no timestamp +``` + +Correct (Artisan-generated): +```bash +php artisan make:migration create_posts_table +php artisan make:migration add_slug_to_posts_table +``` + +## Use `constrained()` for Foreign Keys + +Automatic naming and referential integrity. + +```php +$table->foreignId('user_id')->constrained()->cascadeOnDelete(); + +// Non-standard names +$table->foreignId('author_id')->constrained('users'); +``` + +## Never Modify Deployed Migrations + +Once a migration has run in production, treat it as immutable. Create a new migration to change the table. + +Incorrect (editing a deployed migration): +```php +// 2024_01_01_create_posts_table.php — already in production +$table->string('slug')->unique(); // ← added after deployment +``` + +Correct (new migration to alter): +```php +// 2024_03_15_add_slug_to_posts_table.php +Schema::table('posts', function (Blueprint $table) { + $table->string('slug')->unique()->after('title'); +}); +``` + +## Add Indexes in the Migration + +Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->index(); + $table->string('status')->index(); + $table->timestamp('shipped_at')->nullable()->index(); + $table->timestamps(); +}); +``` + +## Mirror Defaults in Model `$attributes` + +When a column has a database default, mirror it in the model so new instances have correct values before saving. + +```php +// Migration +$table->string('status')->default('pending'); + +// Model +protected $attributes = [ + 'status' => 'pending', +]; +``` + +## Write Reversible `down()` Methods by Default + +Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments. + +```php +public function down(): void +{ + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn('slug'); + }); +} +``` + +For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported. + +## Keep Migrations Focused + +One concern per migration. Never mix DDL (schema changes) and DML (data manipulation). + +Incorrect (partial failure creates unrecoverable state): +```php +public function up(): void +{ + Schema::create('settings', function (Blueprint $table) { ... }); + DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +} +``` + +Correct (separate migrations): +```php +// Migration 1: create_settings_table +Schema::create('settings', function (Blueprint $table) { ... }); + +// Migration 2: seed_default_settings +DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/queue-jobs.md b/.agents/skills/laravel-best-practices/rules/queue-jobs.md new file mode 100644 index 00000000..f7aa548b --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/queue-jobs.md @@ -0,0 +1,144 @@ +# Queue & Job Best Practices + +## Set `retry_after` Greater Than `timeout` + +If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution. + +Incorrect (`retry_after` ≤ `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 90 ← job retried while still running! +``` + +Correct (`retry_after` > `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 180 ← safely longer than any job timeout +``` + +## Use Exponential Backoff + +Use progressively longer delays between retries to avoid hammering failing services. + +Incorrect (fixed retry interval): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + // Default: retries immediately, overwhelming the API +} +``` + +Correct (exponential backoff): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + public $backoff = [1, 5, 10]; +} +``` + +## Implement `ShouldBeUnique` + +Prevent duplicate job processing. + +```php +class GenerateInvoice implements ShouldQueue, ShouldBeUnique +{ + public function uniqueId(): string + { + return $this->order->id; + } + + public $uniqueFor = 3600; +} +``` + +## Always Implement `failed()` + +Handle errors explicitly — don't rely on silent failure. + +```php +public function failed(?Throwable $exception): void +{ + $this->podcast->update(['status' => 'failed']); + Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]); +} +``` + +## Rate Limit External API Calls in Jobs + +Use `RateLimited` middleware to throttle jobs calling third-party APIs. + +```php +public function middleware(): array +{ + return [new RateLimited('external-api')]; +} +``` + +## Batch Related Jobs + +Use `Bus::batch()` when jobs should succeed or fail together. + +```php +Bus::batch([ + new ImportCsvChunk($chunk1), + new ImportCsvChunk($chunk2), +]) +->then(fn (Batch $batch) => Notification::send($user, new ImportComplete)) +->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed')) +->dispatch(); +``` + +## `retryUntil()` Needs `$tries = 0` + +When using time-based retry limits, set `$tries = 0` to avoid premature failure. + +```php +public $tries = 0; + +public function retryUntil(): \DateTimeInterface +{ + return now()->addHours(4); +} +``` + +## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release + +`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue. + +```php +class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing +{ + // Lock releases when processing begins, not when it finishes +} +``` + +## Use Horizon for Complex Queue Scenarios + +Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities. + +```php +// config/horizon.php +'environments' => [ + 'production' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['high', 'default', 'low'], + 'balance' => 'auto', + 'minProcesses' => 1, + 'maxProcesses' => 10, + 'tries' => 3, + ], + ], +], +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/routing.md b/.agents/skills/laravel-best-practices/rules/routing.md new file mode 100644 index 00000000..977d136e --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/routing.md @@ -0,0 +1,99 @@ +# Routing & Controllers Best Practices + +## Use Implicit Route Model Binding + +Let Laravel resolve models automatically from route parameters. + +Incorrect: +```php +public function show(int $id) +{ + $post = Post::findOrFail($id); +} +``` + +Correct: +```php +public function show(Post $post) +{ + return view('posts.show', ['post' => $post]); +} +``` + +## Use Scoped Bindings for Nested Resources + +Enforce parent-child relationships automatically. + +```php +Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { + // $post is automatically scoped to $user +})->scopeBindings(); +``` + +## Use Resource Controllers + +Use `Route::resource()` or `apiResource()` for RESTful endpoints. + +```php +Route::resource('posts', PostController::class); +// In routes/api.php — the /api prefix is applied automatically +Route::apiResource('posts', Api\PostController::class); +``` + +## Keep Controllers Thin + +Aim for under 10 lines per method. Extract business logic to action or service classes. + +Incorrect: +```php +public function store(Request $request) +{ + $validated = $request->validate([...]); + if ($request->hasFile('image')) { + $request->file('image')->move(public_path('images')); + } + $post = Post::create($validated); + $post->tags()->sync($validated['tags']); + event(new PostCreated($post)); + return redirect()->route('posts.show', $post); +} +``` + +Correct: +```php +public function store(StorePostRequest $request, CreatePostAction $create) +{ + $post = $create->execute($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +## Type-Hint Form Requests + +Type-hinting Form Requests triggers automatic validation and authorization before the method executes. + +Incorrect: +```php +public function store(Request $request): RedirectResponse +{ + $validated = $request->validate([ + 'title' => ['required', 'max:255'], + 'body' => ['required'], + ]); + + Post::create($validated); + + return redirect()->route('posts.index'); +} +``` + +Correct: +```php +public function store(StorePostRequest $request): RedirectResponse +{ + Post::create($request->validated()); + + return redirect()->route('posts.index'); +} +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/scheduling.md b/.agents/skills/laravel-best-practices/rules/scheduling.md new file mode 100644 index 00000000..dfaefa26 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/scheduling.md @@ -0,0 +1,39 @@ +# Task Scheduling Best Practices + +## Use `withoutOverlapping()` on Variable-Duration Tasks + +Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion. + +## Use `onOneServer()` on Multi-Server Deployments + +Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached). + +## Use `runInBackground()` for Concurrent Long Tasks + +By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes. + +## Use `environments()` to Restrict Tasks + +Prevent accidental execution of production-only tasks (billing, reporting) on staging. + +```php +Schedule::command('billing:charge')->monthly()->environments(['production']); +``` + +## Use `takeUntilTimeout()` for Time-Bounded Processing + +A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time. + +## Use Schedule Groups for Shared Configuration + +Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks. + +```php +Schedule::daily() + ->onOneServer() + ->timezone('America/New_York') + ->group(function () { + Schedule::command('emails:send --force'); + Schedule::command('emails:prune'); + }); +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/security.md b/.agents/skills/laravel-best-practices/rules/security.md new file mode 100644 index 00000000..909ff91a --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/security.md @@ -0,0 +1,198 @@ +# Security Best Practices + +## Mass Assignment Protection + +Every model must define `$fillable` (whitelist) or `$guarded` (blacklist). + +Incorrect: +```php +class User extends Model +{ + protected $guarded = []; // All fields are mass assignable +} +``` + +Correct: +```php +class User extends Model +{ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; +} +``` + +Never use `$guarded = []` on models that accept user input. + +## Authorize Every Action + +Use policies or gates in controllers. Never skip authorization. + +Incorrect: +```php +public function update(UpdatePostRequest $request, Post $post) +{ + $post->update($request->validated()); +} +``` + +Correct: +```php +public function update(UpdatePostRequest $request, Post $post) +{ + Gate::authorize('update', $post); + + $post->update($request->validated()); +} +``` + +Or via Form Request: + +```php +public function authorize(): bool +{ + return $this->user()->can('update', $this->route('post')); +} +``` + +## Prevent SQL Injection + +Always use parameter binding. Never interpolate user input into queries. + +Incorrect: +```php +DB::select("SELECT * FROM users WHERE name = '{$request->name}'"); +``` + +Correct: +```php +User::where('name', $request->name)->get(); + +// Raw expressions with bindings +User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get(); +``` + +## Escape Output to Prevent XSS + +Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content. + +Incorrect: +```blade +{!! $user->bio !!} +``` + +Correct: +```blade +{{ $user->bio }} +``` + +## CSRF Protection + +Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied. + +Incorrect: +```blade +
+ +
+``` + +Correct: +```blade +
+ @csrf + +
+``` + +## Rate Limit Auth and API Routes + +Apply `throttle` middleware to authentication and API routes. + +```php +RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by($request->ip()); +}); + +Route::post('/login', LoginController::class)->middleware('throttle:login'); +``` + +## Validate File Uploads + +Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames. + +```php +public function rules(): array +{ + return [ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + ]; +} +``` + +Store with generated filenames: + +```php +$path = $request->file('avatar')->store('avatars', 'public'); +``` + +## Keep Secrets Out of Code + +Never commit `.env`. Access secrets via `config()` only. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'api_key' => env('API_KEY'), + +// In application code +$key = config('services.api_key'); +``` + +## Audit Dependencies + +Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment. + +```bash +composer audit +``` + +## Encrypt Sensitive Database Fields + +Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`. + +Incorrect: +```php +class Integration extends Model +{ + protected function casts(): array + { + return [ + 'api_key' => 'string', + ]; + } +} +``` + +Correct: +```php +class Integration extends Model +{ + protected $hidden = ['api_key', 'api_secret']; + + protected function casts(): array + { + return [ + 'api_key' => 'encrypted', + 'api_secret' => 'encrypted', + ]; + } +} +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/style.md b/.agents/skills/laravel-best-practices/rules/style.md new file mode 100644 index 00000000..67af9891 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/style.md @@ -0,0 +1,125 @@ +# Conventions & Style + +## Follow Laravel Naming Conventions + +| What | Convention | Good | Bad | +|------|-----------|------|-----| +| Controller | singular | `ArticleController` | `ArticlesController` | +| Model | singular | `User` | `Users` | +| Table | plural, snake_case | `article_comments` | `articleComments` | +| Pivot table | singular alphabetical | `article_user` | `user_article` | +| Column | snake_case, no model name | `meta_title` | `article_meta_title` | +| Foreign key | singular model + `_id` | `article_id` | `articles_id` | +| Route | plural | `articles/1` | `article/1` | +| Route name | snake_case with dots | `users.show_active` | `users.show-active` | +| Method | camelCase | `getAll` | `get_all` | +| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` | +| Collection | descriptive, plural | `$activeUsers` | `$data` | +| Object | descriptive, singular | `$activeUser` | `$users` | +| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` | +| Config | snake_case | `google_calendar.php` | `googleCalendar.php` | +| Enum | singular | `UserType` | `UserTypes` | + +## Prefer Shorter Readable Syntax + +| Verbose | Shorter | +|---------|---------| +| `Session::get('cart')` | `session('cart')` | +| `$request->session()->get('cart')` | `session('cart')` | +| `$request->input('name')` | `$request->name` | +| `return Redirect::back()` | `return back()` | +| `Carbon::now()` | `now()` | +| `App::make('Class')` | `app('Class')` | +| `->where('column', '=', 1)` | `->where('column', 1)` | +| `->orderBy('created_at', 'desc')` | `->latest()` | +| `->orderBy('created_at', 'asc')` | `->oldest()` | +| `->first()->name` | `->value('name')` | + +## Use Laravel String & Array Helpers + +Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them. + +Strings — use `Str` and fluent `Str::of()` over raw PHP: +```php +// Incorrect +$slug = strtolower(str_replace(' ', '-', $title)); +$short = substr($text, 0, 100) . '...'; +$class = substr(strrchr('App\Models\User', '\'), 1); + +// Correct +$slug = Str::slug($title); +$short = Str::limit($text, 100); +$class = class_basename('App\Models\User'); +``` + +Fluent strings — chain operations for complex transformations: +```php +// Incorrect +$result = strtolower(trim(str_replace('_', '-', $input))); + +// Correct +$result = Str::of($input)->trim()->replace('_', '-')->lower(); +``` + +Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`. + +Arrays — use `Arr` over raw PHP: +```php +// Incorrect +$name = isset($array['user']['name']) ? $array['user']['name'] : 'default'; + +// Correct +$name = Arr::get($array, 'user.name', 'default'); +``` + +Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`. + +Numbers — use `Number` for display formatting: +```php +Number::format(1000000); // "1,000,000" +Number::currency(1500, 'USD'); // "$1,500.00" +Number::abbreviate(1000000); // "1M" +Number::fileSize(1024 * 1024); // "1 MB" +Number::percentage(75.5); // "75.5%" +``` + +URIs — use `Uri` for URL manipulation: +```php +$uri = Uri::of('https://example.com/search') + ->withQuery(['q' => 'laravel', 'page' => 1]); +``` + +Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining. + +Use `search-docs` for the full list of available methods — these helpers are extensive. + +## No Inline JS/CSS in Blade + +Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes. + +Incorrect: +```blade +let article = `{{ json_encode($article) }}`; +``` + +Correct: +```blade + +``` + +Pass data to JS via data attributes or use a dedicated PHP-to-JS package. + +## No Unnecessary Comments + +Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected. + +Incorrect: +```php +// Check if there are any joins +if (count((array) $builder->getQuery()->joins) > 0) +``` + +Correct: +```php +if ($this->hasJoins()) +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/testing.md b/.agents/skills/laravel-best-practices/rules/testing.md new file mode 100644 index 00000000..287b083b --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/testing.md @@ -0,0 +1,43 @@ +# Testing Best Practices + +## Use `LazilyRefreshDatabase` Over `RefreshDatabase` + +`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date. + +## Use Model Assertions Over Raw Database Assertions + +Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);` + +Correct: `$this->assertModelExists($user);` + +More expressive, type-safe, and fails with clearer messages. + +## Use Factory States and Sequences + +Named states make tests self-documenting. Sequences eliminate repetitive setup. + +Incorrect: `User::factory()->create(['email_verified_at' => null]);` + +Correct: `User::factory()->unverified()->create();` + +## Use `Exceptions::fake()` to Assert Exception Reporting + +Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally. + +## Call `Event::fake()` After Factory Setup + +Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models. + +Incorrect: `Event::fake(); $user = User::factory()->create();` + +Correct: `$user = User::factory()->create(); Event::fake();` + +## Use `recycle()` to Share Relationship Instances Across Factories + +Without `recycle()`, nested factories create separate instances of the same conceptual entity. + +```php +Ticket::factory() + ->recycle(Airline::factory()->create()) + ->create(); +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/validation.md b/.agents/skills/laravel-best-practices/rules/validation.md new file mode 100644 index 00000000..a20202ff --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/validation.md @@ -0,0 +1,75 @@ +# Validation & Forms Best Practices + +## Use Form Request Classes + +Extract validation from controllers into dedicated Form Request classes. + +Incorrect: +```php +public function store(Request $request) +{ + $request->validate([ + 'title' => 'required|max:255', + 'body' => 'required', + ]); +} +``` + +Correct: +```php +public function store(StorePostRequest $request) +{ + Post::create($request->validated()); +} +``` + +## Array vs. String Notation for Rules + +Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses. + +```php +// Preferred for new code +'email' => ['required', 'email', Rule::unique('users')], + +// Follow existing convention if the project uses string notation +'email' => 'required|email|unique:users', +``` + +## Always Use `validated()` + +Get only validated data. Never use `$request->all()` for mass operations. + +Incorrect: +```php +Post::create($request->all()); +``` + +Correct: +```php +Post::create($request->validated()); +``` + +## Use `Rule::when()` for Conditional Validation + +```php +'company_name' => [ + Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']), +], +``` + +## Use the `after()` Method for Custom Validation + +Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields. + +```php +public function after(): array +{ + return [ + function (Validator $validator) { + if ($this->quantity > Product::find($this->product_id)?->stock) { + $validator->errors()->add('quantity', 'Not enough stock.'); + } + }, + ]; +} +``` \ No newline at end of file diff --git a/.agents/skills/livewire-development/SKILL.md b/.agents/skills/livewire-development/SKILL.md new file mode 100644 index 00000000..62d032dd --- /dev/null +++ b/.agents/skills/livewire-development/SKILL.md @@ -0,0 +1,175 @@ +--- +name: livewire-development +description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire." +license: MIT +metadata: + author: laravel +--- + +# Livewire Development + +## Documentation + +Use `search-docs` for detailed Livewire 4 patterns and documentation. + +## Basic Usage + +### Creating Components + +```bash + +# Single-file component (SFC - default in v4) + +# Creates: resources/views/components/⚡create-post.blade.php + +php artisan make:livewire create-post + +# Page component (SFC - Full Page in v4) + +# Creates: resources/views/pages/⚡create-post.blade.php + +php artisan make:livewire pages::create-post + +# Multi-file component (MFC) + +# Creates: resources/views/components/⚡create-post/create-post.php + +# resources/views/components/⚡create-post/create-post.blade.php + +php artisan make:livewire create-post --mfc + +# Class-based component (v3 style) + +# Creates: app/Livewire/CreatePost.php AND resources/views/livewire/create-post.blade.php + +php artisan make:livewire create-post --class + +# With namespace + +php artisan make:livewire Posts/CreatePost +``` + +### Converting Between Formats + +Use `php artisan livewire:convert create-post` to convert between single-file, multi-file, and class-based formats. + +### Choosing a Component Format + +> **Always follow the project's existing conventions first.** Before creating any component, inspect the project's existing Livewire components to determine the established format (SFC, MFC, or class-based) and directory structure. Check `app/Livewire/`, `resources/views/components/`, and `resources/views/livewire/` for existing components. If the project already uses a consistent format, **use that same format** — even if it differs from the Livewire v4 defaults below. Only fall back to the v4 defaults (SFC in `resources/views/components/`) when no existing convention is established. + +Also check `config/livewire.php` for `make_command.type`, `make_command.emoji`, `component_locations`, and `component_namespaces` overrides, which change the default format and where files are stored. + +### Component Format Reference + +| Format | Flag | Class Path | View Path | +|--------|------|------------|-----------| +| Single-file (SFC) | default | — | `resources/views/components/⚡create-post.blade.php` (PHP + Blade in one file) | +| Full Page SFC | `pages::name` | — | `resources/views/pages/⚡create-post.blade.php` | +| Multi-file (MFC) | `--mfc` | `resources/views/components/⚡create-post/create-post.php` | `resources/views/components/⚡create-post/create-post.blade.php` | +| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` | +| View-based | default (Blade-only) | — | `resources/views/components/⚡create-post.blade.php` (Blade-only with functional state) | + +> **Important:** The ⚡ prefix shown above is the **default** behavior in Livewire v4 — it is **configurable**. Check `config/livewire.php` for the `make_command.emoji` setting. When `true` (default), always include the ⚡ prefix in filenames you create. When `false`, omit the ⚡ prefix from all paths above. + +Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates `resources/views/components/posts/⚡create-post.blade.php` (single-file by default). Use `make:livewire Posts/CreatePost --mfc` for multi-file output at `resources/views/components/posts/⚡create-post/create-post.php` and `resources/views/components/posts/⚡create-post/create-post.blade.php`. + +### Single-File Component Example + + +```php +count++; + } +}; +?> + +
+ +
+``` + +## Livewire 4 Specifics + +### Key Changes From Livewire 3 + +These things changed in Livewire 4, but may not have been updated in this application. Verify this application's setup to ensure you follow existing conventions. + +- Use `Route::livewire()` for full-page components (e.g., `Route::livewire('/posts/create', CreatePost::class)`); config keys renamed: `layout` → `component_layout`, `lazy_placeholder` → `component_placeholder`. +- `wire:model` now ignores child events by default (use `wire:model.deep` for old behavior); `wire:scroll` renamed to `wire:navigate:scroll`. +- Component tags must be properly closed; `wire:transition` now uses View Transitions API (modifiers removed). +- JavaScript: `$wire.$js('name', fn)` → `$wire.$js.name = fn`; `commit`/`request` hooks → `interceptMessage()`/`interceptRequest()`. + +### New Features + +- Component formats: single-file (SFC), multi-file (MFC), view-based components. +- Islands (`@island`) for isolated updates; async actions (`wire:click.async`, `#[Async]`) for parallel execution. +- Deferred/bundled loading: `defer`, `lazy.bundle` for optimized component loading. + +| Feature | Usage | Purpose | +|---------|-------|---------| +| Islands | `@island(name: 'stats')` | Isolated update regions | +| Async | `wire:click.async` or `#[Async]` | Non-blocking actions | +| Deferred | `defer` attribute | Load after page render | +| Bundled | `lazy.bundle` | Load multiple together | + +### New Directives + +- `wire:sort`, `wire:intersect`, `wire:ref`, `.renderless`, `.preserve-scroll` are available for use. +- `data-loading` attribute automatically added to elements triggering network requests. + +| Directive | Purpose | +|-----------|---------| +| `wire:sort` | Drag-and-drop sorting | +| `wire:intersect` | Viewport intersection detection | +| `wire:ref` | Element references for JS | +| `.renderless` | Component without rendering | +| `.preserve-scroll` | Preserve scroll position | + +## Best Practices + +- Always use `wire:key` in loops +- Use `wire:loading` for loading states +- Use `wire:model.live` for instant updates (default is debounced) +- Validate and authorize in actions (treat like HTTP requests) + +## Configuration + +- `smart_wire_keys` defaults to `true`; new configs: `component_locations`, `component_namespaces`, `make_command`, `csp_safe`. + +## Alpine & JavaScript + +- `wire:transition` uses browser View Transitions API; `$errors` and `$intercept` magic properties available. +- Non-blocking `wire:poll` and parallel `wire:model.live` updates improve performance. + +For interceptors and hooks, see [reference/javascript-hooks.md](reference/javascript-hooks.md). + +## Testing + + +```php +Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1); +``` + +## Verification + +1. Browser console: Check for JS errors +2. Network tab: Verify Livewire requests return 200 +3. Ensure `wire:key` on all `@foreach` loops + +## Common Pitfalls + +- Missing `wire:key` in loops → unexpected re-rendering +- Expecting `wire:model` real-time → use `wire:model.live` +- Unclosed component tags → syntax errors in v4 +- Using deprecated config keys or JS hooks +- Including Alpine.js separately (already bundled in Livewire 4) \ No newline at end of file diff --git a/.agents/skills/livewire-development/reference/javascript-hooks.md b/.agents/skills/livewire-development/reference/javascript-hooks.md new file mode 100644 index 00000000..d6a44170 --- /dev/null +++ b/.agents/skills/livewire-development/reference/javascript-hooks.md @@ -0,0 +1,39 @@ +# Livewire 4 JavaScript Integration + +## Interceptor System (v4) + +### Intercept Messages + +```js +Livewire.interceptMessage(({ component, message, onFinish, onSuccess, onError }) => { + onFinish(() => { /* After response, before processing */ }); + onSuccess(({ payload }) => { /* payload.snapshot, payload.effects */ }); + onError(() => { /* Server errors */ }); +}); +``` + +### Intercept Requests + +```js +Livewire.interceptRequest(({ request, onResponse, onSuccess, onError, onFailure }) => { + onResponse(({ response }) => { /* When received */ }); + onSuccess(({ response, responseJson }) => { /* Success */ }); + onError(({ response, responseBody, preventDefault }) => { /* 4xx/5xx */ }); + onFailure(({ error }) => { /* Network failures */ }); +}); +``` + +### Component-Scoped Interceptors + +```blade + +``` + +## Magic Properties + +- `$errors` - Access validation errors from JavaScript +- `$intercept` - Component-scoped interceptors \ No newline at end of file diff --git a/.agents/skills/pest-testing/SKILL.md b/.agents/skills/pest-testing/SKILL.md new file mode 100644 index 00000000..323d4723 --- /dev/null +++ b/.agents/skills/pest-testing/SKILL.md @@ -0,0 +1,159 @@ +--- +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}`. + +### 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 \ No newline at end of file diff --git a/.agents/skills/tailwindcss-development/SKILL.md b/.agents/skills/tailwindcss-development/SKILL.md new file mode 100644 index 00000000..7c8e295e --- /dev/null +++ b/.agents/skills/tailwindcss-development/SKILL.md @@ -0,0 +1,119 @@ +--- +name: tailwindcss-development +description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS." +license: MIT +metadata: + author: laravel +--- + +# Tailwind CSS Development + +## Documentation + +Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. + +## Basic Usage + +- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. +- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). +- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. + +## Tailwind CSS v4 Specifics + +- Always use Tailwind CSS v4 and avoid deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. + +### CSS-First Configuration + +In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: + + +```css +@theme { + --color-brand: oklch(0.72 0.11 178); +} +``` + +### Import Syntax + +In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: + + +```diff +- @tailwind base; +- @tailwind components; +- @tailwind utilities; ++ @import "tailwindcss"; +``` + +### Replaced Utilities + +Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. + +| Deprecated | Replacement | +|------------|-------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + +## Spacing + +Use `gap` utilities instead of margins for spacing between siblings: + + +```html +
+
Item 1
+
Item 2
+
+``` + +## Dark Mode + +If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: + + +```html +
+ Content adapts to color scheme +
+``` + +## Common Patterns + +### Flexbox Layout + + +```html +
+
Left content
+
Right content
+
+``` + +### Grid Layout + + +```html +
+
Card 1
+
Card 2
+
Card 3
+
+``` + +## Common Pitfalls + +- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.) +- Using `@tailwind` directives instead of `@import "tailwindcss"` +- Trying to use `tailwind.config.js` instead of CSS `@theme` directive +- Using margins for spacing between siblings instead of gap utilities +- Forgetting to add dark mode variants when the project uses dark mode \ No newline at end of file diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 00000000..6a8ba1bc --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,7 @@ +[mcp_servers.laravel-boost] +command = "php" +args = ["artisan", "boost:mcp"] +cwd = "/Users/fabianwesner/Herd/shop" + +[features] +goals = true diff --git a/.env.example b/.env.example index c0660ea1..9c785b8a 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ APP_NAME=Laravel APP_ENV=local APP_KEY= APP_DEBUG=true -APP_URL=http://localhost +APP_URL=http://shop.test APP_LOCALE=en APP_FALLBACK_LOCALE=en @@ -27,17 +27,19 @@ DB_CONNECTION=sqlite # DB_USERNAME=root # DB_PASSWORD= -SESSION_DRIVER=database +SESSION_DRIVER=file SESSION_LIFETIME=120 +SESSION_ENCRYPT=true +SESSION_COOKIE=shop_session SESSION_ENCRYPT=false SESSION_PATH=/ SESSION_DOMAIN=null BROADCAST_CONNECTION=log FILESYSTEM_DISK=local -QUEUE_CONNECTION=database +QUEUE_CONNECTION=sync -CACHE_STORE=database +CACHE_STORE=file # CACHE_PREFIX= MEMCACHED_HOST=127.0.0.1 diff --git a/.gitignore b/.gitignore index c7cf1fa6..a4b40244 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ yarn-error.log /.nova /.vscode /.zed +/tests/Browser/Screenshots diff --git a/.playwright-mcp/console-2026-04-18T07-42-21-973Z.log b/.playwright-mcp/console-2026-04-18T07-42-21-973Z.log new file mode 100644 index 00000000..107e16b8 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T07-42-21-973Z.log @@ -0,0 +1,2 @@ +[ 304ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/:49 +[ 469ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-04-18T07-42-29-697Z.log b/.playwright-mcp/console-2026-04-18T07-42-29-697Z.log new file mode 100644 index 00000000..44e42766 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T07-42-29-697Z.log @@ -0,0 +1 @@ +[ 76ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/collections:49 diff --git a/.playwright-mcp/console-2026-04-18T07-42-33-348Z.log b/.playwright-mcp/console-2026-04-18T07-42-33-348Z.log new file mode 100644 index 00000000..6a4230e2 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T07-42-33-348Z.log @@ -0,0 +1 @@ +[ 115ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/collections/featured:49 diff --git a/.playwright-mcp/console-2026-04-18T07-42-37-846Z.log b/.playwright-mcp/console-2026-04-18T07-42-37-846Z.log new file mode 100644 index 00000000..78dff563 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T07-42-37-846Z.log @@ -0,0 +1,6 @@ +[ 233ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://shop.test/products/organic-cotton-t-shirt:0 +[ 237ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:21 +[ 19454ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://shop.test/products/organic-cotton-t-shirt:0 +[ 19461ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:21 +[ 27391ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 +[ 27465ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:49 diff --git a/.playwright-mcp/console-2026-04-18T07-43-07-589Z.log b/.playwright-mcp/console-2026-04-18T07-43-07-589Z.log new file mode 100644 index 00000000..d3b8d65f --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T07-43-07-589Z.log @@ -0,0 +1,2 @@ +[ 91ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:49 +[ 100ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 diff --git a/.playwright-mcp/console-2026-04-18T07-43-15-408Z.log b/.playwright-mcp/console-2026-04-18T07-43-15-408Z.log new file mode 100644 index 00000000..d445da32 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T07-43-15-408Z.log @@ -0,0 +1 @@ +[ 75ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/pages/about:49 diff --git a/.playwright-mcp/console-2026-04-18T07-43-17-415Z.log b/.playwright-mcp/console-2026-04-18T07-43-17-415Z.log new file mode 100644 index 00000000..452802f7 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T07-43-17-415Z.log @@ -0,0 +1 @@ +[ 82ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/search?q=cotton:49 diff --git a/.playwright-mcp/console-2026-04-18T07-43-21-810Z.log b/.playwright-mcp/console-2026-04-18T07-43-21-810Z.log new file mode 100644 index 00000000..8806e7a7 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T07-43-21-810Z.log @@ -0,0 +1 @@ +[ 87ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/cart:49 diff --git a/.playwright-mcp/console-2026-04-18T07-43-24-813Z.log b/.playwright-mcp/console-2026-04-18T07-43-24-813Z.log new file mode 100644 index 00000000..c4f5c291 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T07-43-24-813Z.log @@ -0,0 +1,2 @@ +[ 106ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/does-not-exist:0 +[ 115ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/does-not-exist:43 diff --git a/.playwright-mcp/console-2026-04-18T08-04-27-959Z.log b/.playwright-mcp/console-2026-04-18T08-04-27-959Z.log new file mode 100644 index 00000000..3686f109 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-04-27-959Z.log @@ -0,0 +1,2 @@ +[ 237ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/cart:49 +[ 309ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-04-18T08-04-43-558Z.log b/.playwright-mcp/console-2026-04-18T08-04-43-558Z.log new file mode 100644 index 00000000..a6889113 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-04-43-558Z.log @@ -0,0 +1 @@ +[ 91ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/collections:49 diff --git a/.playwright-mcp/console-2026-04-18T08-04-48-155Z.log b/.playwright-mcp/console-2026-04-18T08-04-48-155Z.log new file mode 100644 index 00000000..d33854c2 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-04-48-155Z.log @@ -0,0 +1 @@ +[ 87ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/collections/featured:49 diff --git a/.playwright-mcp/console-2026-04-18T08-04-54-091Z.log b/.playwright-mcp/console-2026-04-18T08-04-54-091Z.log new file mode 100644 index 00000000..da422c1a --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-04-54-091Z.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/organic-cotton-t-shirt:49 +[ 99ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 +[ 15992ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/livewire-6701cc17/update:0 +[ 16008ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:6 diff --git a/.playwright-mcp/console-2026-04-18T08-06-02-803Z.log b/.playwright-mcp/console-2026-04-18T08-06-02-803Z.log new file mode 100644 index 00000000..bb2753ae --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-06-02-803Z.log @@ -0,0 +1,4 @@ +[ 138ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:49 +[ 174ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 +[ 4756ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/livewire-6701cc17/update:0 +[ 4767ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:6 diff --git a/.playwright-mcp/console-2026-04-18T08-06-32-813Z.log b/.playwright-mcp/console-2026-04-18T08-06-32-813Z.log new file mode 100644 index 00000000..3a9094d3 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-06-32-813Z.log @@ -0,0 +1,4 @@ +[ 93ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt?_=1:49 +[ 103ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 +[ 7228ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/livewire-6701cc17/update:0 +[ 7243ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt?_=1:6 diff --git a/.playwright-mcp/console-2026-04-18T08-07-33-682Z.log b/.playwright-mcp/console-2026-04-18T08-07-33-682Z.log new file mode 100644 index 00000000..97c691f2 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-07-33-682Z.log @@ -0,0 +1,3 @@ +[ 140ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:49 +[ 152ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 +[ 7388ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 diff --git a/.playwright-mcp/console-2026-04-18T08-07-51-938Z.log b/.playwright-mcp/console-2026-04-18T08-07-51-938Z.log new file mode 100644 index 00000000..1288fed6 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-07-51-938Z.log @@ -0,0 +1 @@ +[ 121ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/cart:49 diff --git a/.playwright-mcp/console-2026-04-18T08-07-58-376Z.log b/.playwright-mcp/console-2026-04-18T08-07-58-376Z.log new file mode 100644 index 00000000..437a4484 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-07-58-376Z.log @@ -0,0 +1 @@ +[ 111ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout:49 diff --git a/.playwright-mcp/console-2026-04-18T08-18-52-998Z.log b/.playwright-mcp/console-2026-04-18T08-18-52-998Z.log new file mode 100644 index 00000000..e2dac582 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-18-52-998Z.log @@ -0,0 +1,3 @@ +[ 168ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 +[ 282ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:49 +[ 378ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-04-18T08-26-52-212Z.log b/.playwright-mcp/console-2026-04-18T08-26-52-212Z.log new file mode 100644 index 00000000..16be4546 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-26-52-212Z.log @@ -0,0 +1,6 @@ +[ 110ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/account/login:0 +[ 239ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/login:43 +[ 282ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 +[ 3623ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/account/login:0 +[ 3627ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/login:43 +[ 14000ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/login:49 diff --git a/.playwright-mcp/console-2026-04-18T08-27-08-697Z.log b/.playwright-mcp/console-2026-04-18T08-27-08-697Z.log new file mode 100644 index 00000000..d14e2eae --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-27-08-697Z.log @@ -0,0 +1,4 @@ +[ 73ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/login:49 +[ 14324ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/login:49 +[ 31486ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account:49 +[ 39743ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account:49 diff --git a/.playwright-mcp/console-2026-04-18T08-27-49-983Z.log b/.playwright-mcp/console-2026-04-18T08-27-49-983Z.log new file mode 100644 index 00000000..a41c459c --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-27-49-983Z.log @@ -0,0 +1 @@ +[ 81ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/orders:49 diff --git a/.playwright-mcp/console-2026-04-18T08-27-52-992Z.log b/.playwright-mcp/console-2026-04-18T08-27-52-992Z.log new file mode 100644 index 00000000..578c0c3d --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-27-52-992Z.log @@ -0,0 +1 @@ +[ 91ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/addresses:49 diff --git a/.playwright-mcp/console-2026-04-18T08-39-15-591Z.log b/.playwright-mcp/console-2026-04-18T08-39-15-591Z.log new file mode 100644 index 00000000..cb2ea445 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-39-15-591Z.log @@ -0,0 +1,5 @@ +[ 230ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/:49 +[ 293ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 +[ 8201ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:49 +[ 8214ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 +[ 14052ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 diff --git a/.playwright-mcp/console-2026-04-18T08-41-52-531Z.log b/.playwright-mcp/console-2026-04-18T08-41-52-531Z.log new file mode 100644 index 00000000..33f7401d --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-41-52-531Z.log @@ -0,0 +1,3 @@ +[ 94ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:49 +[ 112ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 +[ 5209ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 diff --git a/.playwright-mcp/console-2026-04-18T08-42-02-646Z.log b/.playwright-mcp/console-2026-04-18T08-42-02-646Z.log new file mode 100644 index 00000000..c3cc0575 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-42-02-646Z.log @@ -0,0 +1,3 @@ +[ 98ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout:49 +[ 86800ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout/confirmation/1001:49 +[ 101025ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout/confirmation/1001:49 diff --git a/.playwright-mcp/console-2026-04-18T08-43-55-862Z.log b/.playwright-mcp/console-2026-04-18T08-43-55-862Z.log new file mode 100644 index 00000000..1e17718c --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-43-55-862Z.log @@ -0,0 +1 @@ +[ 87ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout/confirmation/1001:49 diff --git a/.playwright-mcp/console-2026-04-18T08-44-17-066Z.log b/.playwright-mcp/console-2026-04-18T08-44-17-066Z.log new file mode 100644 index 00000000..09506d99 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-44-17-066Z.log @@ -0,0 +1,2 @@ +[ 91ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout/confirmation/1001:49 +[ 280ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout/confirmation/1001:49 diff --git a/.playwright-mcp/console-2026-04-18T08-44-45-330Z.log b/.playwright-mcp/console-2026-04-18T08-44-45-330Z.log new file mode 100644 index 00000000..542a1b0a --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-44-45-330Z.log @@ -0,0 +1 @@ +[ 136ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout/confirmation/1001:49 diff --git a/.playwright-mcp/console-2026-04-18T08-44-53-484Z.log b/.playwright-mcp/console-2026-04-18T08-44-53-484Z.log new file mode 100644 index 00000000..6b44a145 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-44-53-484Z.log @@ -0,0 +1,2 @@ +[ 82ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/login:49 +[ 26367ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account:49 diff --git a/.playwright-mcp/console-2026-04-18T08-45-34-419Z.log b/.playwright-mcp/console-2026-04-18T08-45-34-419Z.log new file mode 100644 index 00000000..932c191b --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-45-34-419Z.log @@ -0,0 +1 @@ +[ 83ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/orders:49 diff --git a/.playwright-mcp/console-2026-04-18T08-45-49-887Z.log b/.playwright-mcp/console-2026-04-18T08-45-49-887Z.log new file mode 100644 index 00000000..5d505e67 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-45-49-887Z.log @@ -0,0 +1,2 @@ +[ 79ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/login:46 +[ 20950ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin:46 diff --git a/.playwright-mcp/console-2026-04-18T08-46-24-044Z.log b/.playwright-mcp/console-2026-04-18T08-46-24-044Z.log new file mode 100644 index 00000000..a0bcda23 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-46-24-044Z.log @@ -0,0 +1 @@ +[ 102ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/orders/1:46 diff --git a/.playwright-mcp/console-2026-04-18T08-46-55-981Z.log b/.playwright-mcp/console-2026-04-18T08-46-55-981Z.log new file mode 100644 index 00000000..f191f5df --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-46-55-981Z.log @@ -0,0 +1 @@ +[ 82ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/products:46 diff --git a/.playwright-mcp/console-2026-04-18T08-47-07-970Z.log b/.playwright-mcp/console-2026-04-18T08-47-07-970Z.log new file mode 100644 index 00000000..d7239594 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-47-07-970Z.log @@ -0,0 +1 @@ +[ 88ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/settings/shipping:46 diff --git a/.playwright-mcp/console-2026-04-18T08-47-18-512Z.log b/.playwright-mcp/console-2026-04-18T08-47-18-512Z.log new file mode 100644 index 00000000..6dbb4c54 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-47-18-512Z.log @@ -0,0 +1 @@ +[ 71ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/discounts:46 diff --git a/.playwright-mcp/console-2026-04-18T08-47-22-997Z.log b/.playwright-mcp/console-2026-04-18T08-47-22-997Z.log new file mode 100644 index 00000000..e4aaf3d4 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-47-22-997Z.log @@ -0,0 +1 @@ +[ 70ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/analytics:46 diff --git a/.playwright-mcp/page-2026-04-18T07-42-22-458Z.yml b/.playwright-mcp/page-2026-04-18T07-42-22-458Z.yml new file mode 100644 index 00000000..42a7487c --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T07-42-22-458Z.yml @@ -0,0 +1,42 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - generic [ref=e14]: + - generic [ref=e15]: Elevated essentials + - paragraph [ref=e16]: Timeless pieces for modern wardrobes. + - link "Shop the collection" [ref=e18] [cursor=pointer]: + - /url: /collections + - generic [ref=e19]: + - generic [ref=e20]: Featured collections + - link "Featured" [ref=e22] [cursor=pointer]: + - /url: http://shop.test/collections/featured + - generic [ref=e23]: Featured + - generic [ref=e24]: + - generic [ref=e25]: Featured products + - generic [ref=e26]: + - link "Organic Cotton T-Shirt" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/products/organic-cotton-t-shirt + - img [ref=e31] + - generic [ref=e34]: Organic Cotton T-Shirt + - link "Classic Pullover Hoodie" [ref=e36] [cursor=pointer]: + - /url: http://shop.test/products/classic-pullover-hoodie + - img [ref=e39] + - generic [ref=e42]: Classic Pullover Hoodie + - contentinfo [ref=e43]: + - generic [ref=e44]: © 2026 Acme Fashion. All rights reserved. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T07-42-29-798Z.yml b/.playwright-mcp/page-2026-04-18T07-42-29-798Z.yml new file mode 100644 index 00000000..7b401b88 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T07-42-29-798Z.yml @@ -0,0 +1,33 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Collections + - generic [ref=e22]: Collections + - link "Featured" [ref=e24] [cursor=pointer]: + - /url: http://shop.test/collections/featured + - generic [ref=e25]: Featured + - contentinfo [ref=e26]: + - generic [ref=e27]: © 2026 Acme Fashion. All rights reserved. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T07-42-33-517Z.yml b/.playwright-mcp/page-2026-04-18T07-42-33-517Z.yml new file mode 100644 index 00000000..fa468a0f --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T07-42-33-517Z.yml @@ -0,0 +1,51 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Collections" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/collections + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Featured + - generic [ref=e26]: Featured + - paragraph [ref=e28]: Our picks for the season. + - generic [ref=e29]: + - textbox "Search products..." [ref=e31] + - combobox [ref=e33]: + - 'option "Sort: default" [selected]' + - option "Title A-Z" + - option "Title Z-A" + - option "Newest" + - generic [ref=e34]: + - link "Organic Cotton T-Shirt" [ref=e36] [cursor=pointer]: + - /url: http://shop.test/products/organic-cotton-t-shirt + - img [ref=e39] + - generic [ref=e42]: Organic Cotton T-Shirt + - link "Classic Pullover Hoodie" [ref=e44] [cursor=pointer]: + - /url: http://shop.test/products/classic-pullover-hoodie + - img [ref=e47] + - generic [ref=e50]: Classic Pullover Hoodie + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T07-42-38-652Z.yml b/.playwright-mcp/page-2026-04-18T07-42-38-652Z.yml new file mode 100644 index 00000000..c678a6f1 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T07-42-38-652Z.yml @@ -0,0 +1,212 @@ +- generic [ref=e2]: + - generic [ref=e4]: + - generic [ref=e5]: + - img [ref=e7] + - generic [ref=e10]: Internal Server Error + - button "Copy as Markdown" [ref=e11] [cursor=pointer]: + - img [ref=e12] + - generic [ref=e15]: Copy as Markdown + - generic [ref=e18]: + - generic [ref=e19]: + - heading "ErrorException" [level=1] [ref=e20] + - generic [ref=e22]: resources/views/livewire/storefront/products/show.blade.php:17 + - paragraph [ref=e23]: "Undefined property: stdClass::$url" + - generic [ref=e24]: + - generic [ref=e25]: + - generic [ref=e26]: + - generic [ref=e27]: LARAVEL + - generic [ref=e28]: 12.51.0 + - generic [ref=e29]: + - generic [ref=e30]: PHP + - generic [ref=e31]: 8.4.17 + - generic [ref=e32]: + - img [ref=e33] + - text: UNHANDLED + - generic [ref=e36]: CODE 0 + - generic [ref=e38]: + - generic [ref=e39]: + - img [ref=e40] + - text: "500" + - generic [ref=e43]: + - img [ref=e44] + - text: GET + - generic [ref=e47]: http://shop.test/products/organic-cotton-t-shirt + - button [ref=e48] [cursor=pointer]: + - img [ref=e49] + - generic [ref=e53]: + - generic [ref=e54]: + - generic [ref=e55]: + - img [ref=e57] + - heading "Exception trace" [level=3] [ref=e60] + - generic [ref=e61]: + - generic [ref=e62]: + - generic [ref=e63] [cursor=pointer]: + - generic [ref=e66]: + - code [ref=e70]: + - generic [ref=e71]: resources/views/livewire/storefront/products/show.blade.php + - generic [ref=e73]: resources/views/livewire/storefront/products/show.blade.php:17 + - button [ref=e75]: + - img [ref=e76] + - code [ref=e84]: + - generic [ref=e85]: "12" + - generic [ref=e86]: 13
+ - generic [ref=e87]: 14
+ - generic [ref=e88]: 15 @if (! empty($media)) + - generic [ref=e89]: 16
+ - generic [ref=e90]: "17 url }}\" alt=\"{{ $media[0]->alt_text ?? $product->title }}\" class=\"h-full w-full object-cover\" />" + - generic [ref=e91]: 18
+ - generic [ref=e92]: 19 @if (count($media) > 1) + - generic [ref=e93]: 20
+ - generic [ref=e94]: 21 @foreach (array_slice($media, 1, 7) as $item) + - generic [ref=e95]: "22
id }}\" class=\"aspect-square overflow-hidden rounded-md bg-zinc-100 dark:bg-zinc-800\">" + - generic [ref=e96]: "23 url }}\" alt=\"{{ $item->alt_text ?? '' }}\" class=\"h-full w-full object-cover\" loading=\"lazy\" />" + - generic [ref=e97]: 24
+ - generic [ref=e98]: 25 @endforeach + - generic [ref=e99]: 26
+ - generic [ref=e100]: 27 @endif + - generic [ref=e101]: 28 @else + - generic [ref=e102]: "29" + - generic [ref=e104] [cursor=pointer]: + - img [ref=e105] + - generic [ref=e109]: 20 vendor frames + - button [ref=e110]: + - img [ref=e111] + - generic [ref=e116] [cursor=pointer]: + - generic [ref=e119]: + - code [ref=e123]: + - generic [ref=e124]: app/Http/Middleware/ResolveStore.php + - generic [ref=e126]: app/Http/Middleware/ResolveStore.php:36 + - button [ref=e128]: + - img [ref=e129] + - generic [ref=e134] [cursor=pointer]: + - img [ref=e135] + - generic [ref=e139]: 49 vendor frames + - button [ref=e140]: + - img [ref=e141] + - generic [ref=e146] [cursor=pointer]: + - generic [ref=e149]: + - code [ref=e153]: + - generic [ref=e154]: public/index.php + - generic [ref=e156]: public/index.php:20 + - button [ref=e158]: + - img [ref=e159] + - generic [ref=e164] [cursor=pointer]: + - img [ref=e165] + - generic [ref=e169]: 1 vendor frame + - button [ref=e170]: + - img [ref=e171] + - generic [ref=e175]: + - generic [ref=e176]: + - generic [ref=e177]: + - img [ref=e179] + - heading "Queries" [level=3] [ref=e181] + - generic [ref=e183]: 1-7 of 7 + - generic [ref=e184]: + - generic [ref=e185]: + - generic [ref=e186]: + - generic [ref=e187]: + - img [ref=e188] + - generic [ref=e190]: sqlite + - code [ref=e194]: + - generic [ref=e195]: select * from "stores" where "stores"."id" = 1 limit 1 + - generic [ref=e196]: 1.77ms + - generic [ref=e197]: + - generic [ref=e198]: + - generic [ref=e199]: + - img [ref=e200] + - generic [ref=e202]: sqlite + - code [ref=e206]: + - generic [ref=e207]: select exists (select 1 from "main".sqlite_master where name = 'products' and type = 'table') as "exists" + - generic [ref=e208]: 0.04ms + - generic [ref=e209]: + - generic [ref=e210]: + - generic [ref=e211]: + - img [ref=e212] + - generic [ref=e214]: sqlite + - code [ref=e218]: + - generic [ref=e219]: select "id", "title", "handle", "description_html", "tags" from "products" where "store_id" = 1 and "handle" = 'organic-cotton-t-shirt' and "status" = 'active' limit 1 + - generic [ref=e220]: 0.04ms + - generic [ref=e221]: + - generic [ref=e222]: + - generic [ref=e223]: + - img [ref=e224] + - generic [ref=e226]: sqlite + - code [ref=e230]: + - generic [ref=e231]: select exists (select 1 from "main".sqlite_master where name = 'product_variants' and type = 'table') as "exists" + - generic [ref=e232]: 0.02ms + - generic [ref=e233]: + - generic [ref=e234]: + - generic [ref=e235]: + - img [ref=e236] + - generic [ref=e238]: sqlite + - code [ref=e242]: + - generic [ref=e243]: select "id", "sku", "price_amount", "compare_at_amount", "currency", "is_default", "status" from "product_variants" where "product_id" = 1 order by "position" asc + - generic [ref=e244]: 0.1ms + - generic [ref=e245]: + - generic [ref=e246]: + - generic [ref=e247]: + - img [ref=e248] + - generic [ref=e250]: sqlite + - code [ref=e254]: + - generic [ref=e255]: select exists (select 1 from "main".sqlite_master where name = 'product_media' and type = 'table') as "exists" + - generic [ref=e256]: 0.02ms + - generic [ref=e257]: + - generic [ref=e258]: + - generic [ref=e259]: + - img [ref=e260] + - generic [ref=e262]: sqlite + - code [ref=e266]: + - generic [ref=e267]: select "id", "url", "alt_text" from "product_media" where "product_id" = 1 order by "position" asc + - generic [ref=e268]: 0.03ms + - generic [ref=e270]: + - generic [ref=e271]: + - heading "Headers" [level=2] [ref=e272] + - generic [ref=e273]: + - generic [ref=e274]: + - generic [ref=e275]: cookie + - generic [ref=e277]: XSRF-TOKEN=eyJpdiI6IlJINnRyMXFuWFpGby80QjVuclVHRmc9PSIsInZhbHVlIjoiOTJyZGVNY0s5TnVWZDlOWThzNDBTTXFUMXhRd0lyZlNZYytoTTFjQ1lubXI3RHhtZkpwangzRDBTY1JyNlRCd0xORnRiYUlrNnRRV0lNZmgwTU1pakRqOVJ6UHN4UWpJdnBvNzE0SUxGWUxIUThpOUZiWnZPL1FSNUtXakhub1ciLCJtYWMiOiJiOWRmMWMyN2Q2YmFlNzI1MWM4MGYyNzU4YjM3ZTE1NTdjZDdmMmFjMzgxY2UxMDIyN2E5NTJkZTUzZGZiN2MxIiwidGFnIjoiIn0%3D; shop_session=eyJpdiI6Imk5MDRCRnp6Q1FSckhVU1FQZmhpeXc9PSIsInZhbHVlIjoiNnIyMUdHdHozQk5GcHUvNkFMN0FMS2FPMDdTcnA0cnptV1VjTm0ySFdndDlySSt1R2pDbzJZN09VdXhGUHZsQi83R09Udnpiby95NmFrclEyUVd6Vk5ETlo0ai9TOHd4UjhyWlc1RlVLNXArcW9udmljMDU4TTloTUtwbjZQNzkiLCJtYWMiOiI4YmU4OWI0OGI5N2ZhOGVkNDgyMmIzYjE2YWY0ZTc3Zjg1ZjFlYzM4YWYzZTE5NmNlNTUzYmI1MGZjNzMyYWY5IiwidGFnIjoiIn0%3D + - generic [ref=e278]: + - generic [ref=e279]: accept-language + - generic [ref=e281]: en-GB,en-US;q=0.9,en;q=0.8 + - generic [ref=e282]: + - generic [ref=e283]: accept-encoding + - generic [ref=e285]: gzip, deflate + - generic [ref=e286]: + - generic [ref=e287]: accept + - generic [ref=e289]: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 + - generic [ref=e290]: + - generic [ref=e291]: user-agent + - generic [ref=e293]: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 + - generic [ref=e294]: + - generic [ref=e295]: upgrade-insecure-requests + - generic [ref=e297]: "1" + - generic [ref=e298]: + - generic [ref=e299]: connection + - generic [ref=e301]: keep-alive + - generic [ref=e302]: + - generic [ref=e303]: host + - generic [ref=e305]: shop.test + - generic [ref=e306]: + - heading "Body" [level=2] [ref=e307] + - generic [ref=e308]: // No request body + - generic [ref=e309]: + - heading "Routing" [level=2] [ref=e310] + - generic [ref=e311]: + - generic [ref=e312]: + - generic [ref=e313]: controller + - generic [ref=e315]: App\Livewire\Storefront\Products\Show + - generic [ref=e316]: + - generic [ref=e317]: route name + - generic [ref=e319]: storefront.products.show + - generic [ref=e320]: + - generic [ref=e321]: middleware + - generic [ref=e323]: web, storefront + - generic [ref=e324]: + - heading "Routing parameters" [level=2] [ref=e325] + - code [ref=e330]: + - generic [ref=e331]: "{" + - generic [ref=e332]: "\"handle\": \"organic-cotton-t-shirt\"" + - generic [ref=e333]: "}" + - generic [ref=e336]: + - img [ref=e338] + - img [ref=e3376] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T07-43-07-708Z.yml b/.playwright-mcp/page-2026-04-18T07-43-07-708Z.yml new file mode 100644 index 00000000..1d3e83b5 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T07-43-07-708Z.yml @@ -0,0 +1,56 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T07-43-15-507Z.yml b/.playwright-mcp/page-2026-04-18T07-43-15-507Z.yml new file mode 100644 index 00000000..49ca496f --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T07-43-15-507Z.yml @@ -0,0 +1,32 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: About Us + - article [ref=e22]: + - heading "About Us" [level=1] [ref=e23] + - paragraph [ref=e24]: Acme Fashion is a demo store created to showcase the platform. + - contentinfo [ref=e25]: + - generic [ref=e26]: © 2026 Acme Fashion. All rights reserved. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T07-43-17-578Z.yml b/.playwright-mcp/page-2026-04-18T07-43-17-578Z.yml new file mode 100644 index 00000000..ca255515 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T07-43-17-578Z.yml @@ -0,0 +1,35 @@ +- generic [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Search + - generic [ref=e22]: Search + - textbox "What are you looking for?" [active] [ref=e25]: cotton + - link "Organic Cotton T-Shirt" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/products/organic-cotton-t-shirt + - img [ref=e32] + - generic [ref=e35]: Organic Cotton T-Shirt + - contentinfo [ref=e36]: + - generic [ref=e37]: © 2026 Acme Fashion. All rights reserved. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T07-43-21-920Z.yml b/.playwright-mcp/page-2026-04-18T07-43-21-920Z.yml new file mode 100644 index 00000000..66d04376 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T07-43-21-920Z.yml @@ -0,0 +1,37 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Cart + - generic [ref=e22]: Your cart + - generic [ref=e23]: + - img [ref=e25] + - generic [ref=e28]: + - text: Your cart is empty. Browse + - link "our collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/collections + - text: to get started. + - contentinfo [ref=e30]: + - generic [ref=e31]: © 2026 Acme Fashion. All rights reserved. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T07-43-24-950Z.yml b/.playwright-mcp/page-2026-04-18T07-43-24-950Z.yml new file mode 100644 index 00000000..82c607c2 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T07-43-24-950Z.yml @@ -0,0 +1,6 @@ +- generic [ref=e2]: + - paragraph [ref=e3]: "404" + - heading "Page not found" [level=1] [ref=e4] + - paragraph [ref=e5]: The page you are looking for does not exist or has been moved. + - link "Back to home" [ref=e7] [cursor=pointer]: + - /url: / \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-04-28-270Z.yml b/.playwright-mcp/page-2026-04-18T08-04-28-270Z.yml new file mode 100644 index 00000000..7b4850c1 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-04-28-270Z.yml @@ -0,0 +1,54 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Cart + - generic [ref=e22]: Your cart + - generic [ref=e23]: + - img [ref=e25] + - generic [ref=e28]: + - text: Your cart is empty. Browse + - link "our collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/collections + - text: to get started. + - contentinfo [ref=e30]: + - generic [ref=e31]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-04-43-675Z.yml b/.playwright-mcp/page-2026-04-18T08-04-43-675Z.yml new file mode 100644 index 00000000..2e5ed39e --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-04-43-675Z.yml @@ -0,0 +1,50 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Collections + - generic [ref=e22]: Collections + - link "Featured" [ref=e24] [cursor=pointer]: + - /url: http://shop.test/collections/featured + - generic [ref=e25]: Featured + - contentinfo [ref=e26]: + - generic [ref=e27]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-04-48-300Z.yml b/.playwright-mcp/page-2026-04-18T08-04-48-300Z.yml new file mode 100644 index 00000000..3c6206c4 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-04-48-300Z.yml @@ -0,0 +1,68 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Collections" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/collections + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Featured + - generic [ref=e26]: Featured + - paragraph [ref=e28]: Our picks for the season. + - generic [ref=e29]: + - textbox "Search products..." [ref=e31] + - combobox [ref=e33]: + - 'option "Sort: default" [selected]' + - option "Title A-Z" + - option "Title Z-A" + - option "Newest" + - generic [ref=e34]: + - link "Organic Cotton T-Shirt" [ref=e36] [cursor=pointer]: + - /url: http://shop.test/products/organic-cotton-t-shirt + - img [ref=e39] + - generic [ref=e42]: Organic Cotton T-Shirt + - link "Classic Pullover Hoodie" [ref=e44] [cursor=pointer]: + - /url: http://shop.test/products/classic-pullover-hoodie + - img [ref=e47] + - generic [ref=e50]: Classic Pullover Hoodie + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-04-54-210Z.yml b/.playwright-mcp/page-2026-04-18T08-04-54-210Z.yml new file mode 100644 index 00000000..345c45d7 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-04-54-210Z.yml @@ -0,0 +1,73 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-05-12-101Z.yml b/.playwright-mcp/page-2026-04-18T08-05-12-101Z.yml new file mode 100644 index 00000000..eb0e953c --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-05-12-101Z.yml @@ -0,0 +1,81 @@ +- generic [active] [ref=e1]: + - dialog [ref=e53]: + - iframe [ref=e54]: + - generic [ref=f1e2]: + - paragraph [ref=f1e3]: "404" + - heading "Page not found" [level=1] [ref=f1e4] + - paragraph [ref=f1e5]: The page you are looking for does not exist or has been moved. + - link "Back to home" [ref=f1e7] [cursor=pointer]: + - /url: / + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-06-02-997Z.yml b/.playwright-mcp/page-2026-04-18T08-06-02-997Z.yml new file mode 100644 index 00000000..345c45d7 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-06-02-997Z.yml @@ -0,0 +1,73 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-06-09-558Z.yml b/.playwright-mcp/page-2026-04-18T08-06-09-558Z.yml new file mode 100644 index 00000000..ca489fa6 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-06-09-558Z.yml @@ -0,0 +1,81 @@ +- generic [active] [ref=e1]: + - dialog [ref=e53]: + - iframe [ref=e54]: + - generic [ref=f2e2]: + - paragraph [ref=f2e3]: "404" + - heading "Page not found" [level=1] [ref=f2e4] + - paragraph [ref=f2e5]: The page you are looking for does not exist or has been moved. + - link "Back to home" [ref=f2e7] [cursor=pointer]: + - /url: / + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-06-32-938Z.yml b/.playwright-mcp/page-2026-04-18T08-06-32-938Z.yml new file mode 100644 index 00000000..345c45d7 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-06-32-938Z.yml @@ -0,0 +1,73 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-06-42-016Z.yml b/.playwright-mcp/page-2026-04-18T08-06-42-016Z.yml new file mode 100644 index 00000000..5a528416 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-06-42-016Z.yml @@ -0,0 +1,81 @@ +- generic [active] [ref=e1]: + - dialog [ref=e53]: + - iframe [ref=e54]: + - generic [ref=f3e2]: + - paragraph [ref=f3e3]: "404" + - heading "Page not found" [level=1] [ref=f3e4] + - paragraph [ref=f3e5]: The page you are looking for does not exist or has been moved. + - link "Back to home" [ref=f3e7] [cursor=pointer]: + - /url: / + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-07-33-859Z.yml b/.playwright-mcp/page-2026-04-18T08-07-33-859Z.yml new file mode 100644 index 00000000..345c45d7 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-07-33-859Z.yml @@ -0,0 +1,73 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-07-43-009Z.yml b/.playwright-mcp/page-2026-04-18T08-07-43-009Z.yml new file mode 100644 index 00000000..a09982c6 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-07-43-009Z.yml @@ -0,0 +1,94 @@ +- generic [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [active] [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - dialog "Shopping cart" [ref=e55]: + - banner [ref=e56]: + - generic [ref=e57]: Your Cart (1) + - button "Close cart" [ref=e58]: + - img [ref=e60] + - img [ref=e63] + - list [ref=e66]: + - listitem [ref=e67]: + - generic [ref=e69]: + - paragraph [ref=e70]: Organic Cotton T-Shirt + - paragraph [ref=e71]: TSH-S-BLA + - generic [ref=e72]: + - button "Decrease quantity" [ref=e73]: + - img [ref=e75] + - generic [ref=e78]: "-" + - generic [ref=e79]: "1" + - button "Increase quantity" [ref=e80]: + - img [ref=e82] + - generic [ref=e85]: + + - generic [ref=e86]: + - paragraph [ref=e87]: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart" [ref=e88]: + - img [ref=e90] + - img [ref=e93] + - contentinfo [ref=e95]: + - generic [ref=e96]: + - generic [ref=e97]: + - term [ref=e98]: Subtotal + - definition [ref=e99]: 25.00 EUR + - generic [ref=e100]: + - term [ref=e101]: Estimated total + - definition [ref=e102]: 25.00 EUR + - paragraph [ref=e103]: Shipping and taxes calculated at checkout + - link "Checkout" [ref=e104] [cursor=pointer]: + - /url: http://shop.test/checkout + - button "Continue shopping" [ref=e105]: + - img [ref=e107] + - generic [ref=e110]: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-07-52-099Z.yml b/.playwright-mcp/page-2026-04-18T08-07-52-099Z.yml new file mode 100644 index 00000000..a422bcd1 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-07-52-099Z.yml @@ -0,0 +1,117 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Cart + - generic [ref=e22]: Your cart + - generic [ref=e23]: + - list [ref=e25]: + - listitem [ref=e26]: + - generic [ref=e28]: + - paragraph [ref=e29]: Organic Cotton T-Shirt + - paragraph [ref=e30]: TSH-S-BLA + - paragraph [ref=e31]: 25.00 EUR + - generic [ref=e32]: + - button "-" [ref=e33]: + - img [ref=e35] + - generic [ref=e38]: "-" + - generic [ref=e39]: "1" + - button "+" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: + + - button "Remove" [ref=e46]: + - img [ref=e48] + - generic [ref=e51]: Remove + - paragraph [ref=e53]: 25.00 EUR + - complementary [ref=e54]: + - generic [ref=e55]: + - generic [ref=e56]: Discount code + - generic [ref=e57]: + - textbox "Discount code" [ref=e59] + - button "Apply" [ref=e60]: + - img [ref=e62] + - generic [ref=e65]: Apply + - generic [ref=e66]: + - generic [ref=e67]: Summary + - generic [ref=e68]: + - generic [ref=e69]: + - term [ref=e70]: Subtotal + - definition [ref=e71]: 25.00 EUR + - generic [ref=e72]: + - term [ref=e73]: Estimated total + - definition [ref=e74]: 25.00 EUR + - button "Checkout" [ref=e75]: + - img [ref=e77] + - generic [ref=e80]: Checkout + - link "Continue shopping" [ref=e81] [cursor=pointer]: + - /url: http://shop.test/collections + - contentinfo [ref=e82]: + - generic [ref=e83]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (1) + - button "Close cart": + - generic: + - img + - img + - generic: + - list: + - listitem: + - generic: + - paragraph: Organic Cotton T-Shirt + - paragraph: TSH-S-BLA + - generic: + - button "Decrease quantity": + - generic: + - img + - generic: "-" + - generic: "1" + - button "Increase quantity": + - generic: + - img + - generic: + + - generic: + - paragraph: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart": + - generic: + - img + - img + - contentinfo: + - generic: + - generic: + - term: Subtotal + - definition: 25.00 EUR + - generic: + - term: Estimated total + - definition: 25.00 EUR + - paragraph: Shipping and taxes calculated at checkout + - link "Checkout": + - /url: http://shop.test/checkout + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-07-58-525Z.yml b/.playwright-mcp/page-2026-04-18T08-07-58-525Z.yml new file mode 100644 index 00000000..325e4224 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-07-58-525Z.yml @@ -0,0 +1,148 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Cart" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Checkout + - generic [ref=e26]: Checkout + - generic [ref=e27]: + - generic [ref=e28]: + - generic [ref=e29]: + - generic [ref=e30]: 1. Contact & shipping + - generic [ref=e31]: + - generic [ref=e32]: + - generic [ref=e33]: Email + - textbox "Email" [ref=e35] + - generic [ref=e36]: + - generic [ref=e37]: First name + - textbox "First name" [ref=e39] + - generic [ref=e40]: + - generic [ref=e41]: Last name + - textbox "Last name" [ref=e43] + - generic [ref=e44]: + - generic [ref=e45]: Address + - textbox "Address" [ref=e47] + - generic [ref=e48]: + - generic [ref=e49]: Apt / Suite + - textbox "Apt / Suite" [ref=e51] + - generic [ref=e52]: + - generic [ref=e53]: City + - textbox "City" [ref=e55] + - generic [ref=e56]: + - generic [ref=e57]: State / Province + - textbox "State / Province" [ref=e59] + - generic [ref=e60]: + - generic [ref=e61]: Postal code + - textbox "Postal code" [ref=e63] + - generic [ref=e64]: + - generic [ref=e65]: Country code + - textbox "Country code" [ref=e67]: US + - button "Continue" [ref=e68]: + - img [ref=e70] + - generic [ref=e73]: Continue + - generic [ref=e74]: + - generic [ref=e75]: 2. Shipping method + - generic [ref=e76]: + - img [ref=e78] + - generic [ref=e82]: No shipping methods available yet. Continue past step 1 first. + - generic [ref=e83]: + - generic [ref=e84]: 3. Payment + - generic [ref=e85]: + - generic [ref=e86]: + - radio "Credit Card" [checked] [ref=e87] + - text: Credit Card + - generic [ref=e88]: + - radio "PayPal" [ref=e89] + - text: PayPal + - generic [ref=e90]: + - radio "Bank Transfer" [ref=e91] + - text: Bank Transfer + - button "Place order - 25.00 EUR" [ref=e92]: + - img [ref=e94] + - generic [ref=e97]: Place order - 25.00 EUR + - complementary [ref=e98]: + - generic [ref=e99]: + - generic [ref=e100]: Order summary + - generic [ref=e101]: + - generic [ref=e102]: + - term [ref=e103]: Subtotal + - definition [ref=e104]: 25.00 EUR + - generic [ref=e105]: + - term [ref=e106]: Shipping + - definition [ref=e107]: 0.00 EUR + - generic [ref=e108]: + - term [ref=e109]: Total + - definition [ref=e110]: 25.00 EUR + - contentinfo [ref=e111]: + - generic [ref=e112]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (1) + - button "Close cart": + - generic: + - img + - img + - generic: + - list: + - listitem: + - generic: + - paragraph: Organic Cotton T-Shirt + - paragraph: TSH-S-BLA + - generic: + - button "Decrease quantity": + - generic: + - img + - generic: "-" + - generic: "1" + - button "Increase quantity": + - generic: + - img + - generic: + + - generic: + - paragraph: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart": + - generic: + - img + - img + - contentinfo: + - generic: + - generic: + - term: Subtotal + - definition: 25.00 EUR + - generic: + - term: Estimated total + - definition: 25.00 EUR + - paragraph: Shipping and taxes calculated at checkout + - link "Checkout": + - /url: http://shop.test/checkout + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-18-53-378Z.yml b/.playwright-mcp/page-2026-04-18T08-18-53-378Z.yml new file mode 100644 index 00000000..345c45d7 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-18-53-378Z.yml @@ -0,0 +1,73 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-26-52-503Z.yml b/.playwright-mcp/page-2026-04-18T08-26-52-503Z.yml new file mode 100644 index 00000000..82c607c2 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-26-52-503Z.yml @@ -0,0 +1,6 @@ +- generic [ref=e2]: + - paragraph [ref=e3]: "404" + - heading "Page not found" [level=1] [ref=e4] + - paragraph [ref=e5]: The page you are looking for does not exist or has been moved. + - link "Back to home" [ref=e7] [cursor=pointer]: + - /url: / \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-27-08-803Z.yml b/.playwright-mcp/page-2026-04-18T08-27-08-803Z.yml new file mode 100644 index 00000000..ec9315b0 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-27-08-803Z.yml @@ -0,0 +1,56 @@ +- generic [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - generic [ref=e14]: Sign in + - generic [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: Email + - textbox "Email" [active] [ref=e19] + - generic [ref=e20]: + - generic [ref=e21]: Password + - textbox "Password" [ref=e23] + - generic [ref=e24]: + - checkbox "Remember me" [ref=e25] + - generic [ref=e27]: Remember me + - button "Sign in" [ref=e28]: + - img [ref=e30] + - generic [ref=e33]: Sign in + - paragraph [ref=e34]: + - text: No account yet? + - link "Create one" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/account/register + - contentinfo [ref=e36]: + - generic [ref=e37]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-27-41-204Z.yml b/.playwright-mcp/page-2026-04-18T08-27-41-204Z.yml new file mode 100644 index 00000000..3223af0a --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-27-41-204Z.yml @@ -0,0 +1,52 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - generic [ref=e14]: Account dashboard + - paragraph [ref=e15]: Welcome back, Billy. + - generic [ref=e16]: + - link "Your orders Track and manage previous purchases" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/account/orders + - generic [ref=e18]: Your orders + - paragraph [ref=e19]: Track and manage previous purchases + - link "Addresses Manage shipping and billing addresses" [ref=e20] [cursor=pointer]: + - /url: http://shop.test/account/addresses + - generic [ref=e21]: Addresses + - paragraph [ref=e22]: Manage shipping and billing addresses + - button "Sign out End your current session" [ref=e24]: + - generic [ref=e25]: Sign out + - paragraph [ref=e26]: End your current session + - contentinfo [ref=e27]: + - generic [ref=e28]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-27-50-110Z.yml b/.playwright-mcp/page-2026-04-18T08-27-50-110Z.yml new file mode 100644 index 00000000..0aad325c --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-27-50-110Z.yml @@ -0,0 +1,58 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Account" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/account + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Orders + - generic [ref=e26]: Order history + - generic [ref=e27]: + - img [ref=e29] + - generic [ref=e32]: + - text: You have no orders yet. + - link "Start shopping" [ref=e33] [cursor=pointer]: + - /url: http://shop.test/collections + - text: . + - contentinfo [ref=e34]: + - generic [ref=e35]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-27-53-108Z.yml b/.playwright-mcp/page-2026-04-18T08-27-53-108Z.yml new file mode 100644 index 00000000..14af7ee2 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-27-53-108Z.yml @@ -0,0 +1,58 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Account" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/account + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Addresses + - generic [ref=e26]: + - generic [ref=e27]: Your addresses + - button "Add address" [ref=e28]: + - img [ref=e30] + - generic [ref=e33]: Add address + - generic [ref=e34]: + - img [ref=e36] + - generic [ref=e39]: No addresses yet. Add one to speed up checkout. + - contentinfo [ref=e40]: + - generic [ref=e41]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-39-15-903Z.yml b/.playwright-mcp/page-2026-04-18T08-39-15-903Z.yml new file mode 100644 index 00000000..54f77f4a --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-39-15-903Z.yml @@ -0,0 +1,59 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - generic [ref=e14]: + - generic [ref=e15]: Elevated essentials + - paragraph [ref=e16]: Timeless pieces for modern wardrobes. + - link "Shop the collection" [ref=e18] [cursor=pointer]: + - /url: /collections + - generic [ref=e19]: + - generic [ref=e20]: Featured collections + - link "Featured" [ref=e22] [cursor=pointer]: + - /url: http://shop.test/collections/featured + - generic [ref=e23]: Featured + - generic [ref=e24]: + - generic [ref=e25]: Featured products + - generic [ref=e26]: + - link "Organic Cotton T-Shirt" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/products/organic-cotton-t-shirt + - img [ref=e31] + - generic [ref=e34]: Organic Cotton T-Shirt + - link "Classic Pullover Hoodie" [ref=e36] [cursor=pointer]: + - /url: http://shop.test/products/classic-pullover-hoodie + - img [ref=e39] + - generic [ref=e42]: Classic Pullover Hoodie + - contentinfo [ref=e43]: + - generic [ref=e44]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-39-24-839Z.yml b/.playwright-mcp/page-2026-04-18T08-39-24-839Z.yml new file mode 100644 index 00000000..e4c711b6 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-39-24-839Z.yml @@ -0,0 +1,73 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-39-31-590Z.yml b/.playwright-mcp/page-2026-04-18T08-39-31-590Z.yml new file mode 100644 index 00000000..10e6e10c --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-39-31-590Z.yml @@ -0,0 +1,94 @@ +- generic [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [active] [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - dialog "Shopping cart" [ref=e55]: + - banner [ref=e56]: + - generic [ref=e57]: Your Cart (1) + - button "Close cart" [ref=e58]: + - img [ref=e60] + - img [ref=e63] + - list [ref=e66]: + - listitem [ref=e67]: + - generic [ref=e69]: + - paragraph [ref=e70]: Organic Cotton T-Shirt + - paragraph [ref=e71]: TSH-S-BLA + - generic [ref=e72]: + - button "Decrease quantity" [ref=e73]: + - img [ref=e75] + - generic [ref=e78]: "-" + - generic [ref=e79]: "1" + - button "Increase quantity" [ref=e80]: + - img [ref=e82] + - generic [ref=e85]: + + - generic [ref=e86]: + - paragraph [ref=e87]: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart" [ref=e88]: + - img [ref=e90] + - img [ref=e93] + - contentinfo [ref=e95]: + - generic [ref=e96]: + - generic [ref=e97]: + - term [ref=e98]: Subtotal + - definition [ref=e99]: 25.00 EUR + - generic [ref=e100]: + - term [ref=e101]: Estimated total + - definition [ref=e102]: 25.00 EUR + - paragraph [ref=e103]: Shipping and taxes calculated at checkout + - link "Checkout" [ref=e104] [cursor=pointer]: + - /url: http://shop.test/checkout + - button "Continue shopping" [ref=e105]: + - img [ref=e107] + - generic [ref=e110]: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-39-45-003Z.yml b/.playwright-mcp/page-2026-04-18T08-39-45-003Z.yml new file mode 100644 index 00000000..55c62ff6 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-39-45-003Z.yml @@ -0,0 +1,148 @@ +- generic [active] [ref=e111]: + - link "Skip to content" [ref=e112] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e113]: Free shipping on orders over 50 + - banner [ref=e114]: + - generic [ref=e115]: + - link "Acme Fashion" [ref=e116] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e117]: + - link "Collections" [ref=e118] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e119] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e120] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e121] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e122]: + - generic [ref=e123]: + - navigation "Breadcrumb" [ref=e124]: + - list [ref=e125]: + - listitem [ref=e126]: + - link "Home" [ref=e127] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e128] + - listitem [ref=e130]: + - link "Cart" [ref=e131] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e132] + - listitem [ref=e134]: + - generic [ref=e135]: Checkout + - generic [ref=e136]: Checkout + - generic [ref=e137]: + - generic [ref=e138]: + - generic [ref=e139]: + - generic [ref=e140]: 1. Contact & shipping + - generic [ref=e141]: + - generic [ref=e142]: + - generic [ref=e143]: Email + - textbox "Email" [ref=e145] + - generic [ref=e146]: + - generic [ref=e147]: First name + - textbox "First name" [ref=e149] + - generic [ref=e150]: + - generic [ref=e151]: Last name + - textbox "Last name" [ref=e153] + - generic [ref=e154]: + - generic [ref=e155]: Address + - textbox "Address" [ref=e157] + - generic [ref=e158]: + - generic [ref=e159]: Apt / Suite + - textbox "Apt / Suite" [ref=e161] + - generic [ref=e162]: + - generic [ref=e163]: City + - textbox "City" [ref=e165] + - generic [ref=e166]: + - generic [ref=e167]: State / Province + - textbox "State / Province" [ref=e169] + - generic [ref=e170]: + - generic [ref=e171]: Postal code + - textbox "Postal code" [ref=e173] + - generic [ref=e174]: + - generic [ref=e175]: Country code + - textbox "Country code" [ref=e177]: US + - button "Continue" [ref=e178]: + - img [ref=e180] + - generic [ref=e183]: Continue + - generic [ref=e184]: + - generic [ref=e185]: 2. Shipping method + - generic [ref=e186]: + - img [ref=e188] + - generic [ref=e192]: No shipping methods available yet. Continue past step 1 first. + - generic [ref=e193]: + - generic [ref=e194]: 3. Payment + - generic [ref=e195]: + - generic [ref=e196]: + - radio "Credit Card" [checked] [ref=e197] + - text: Credit Card + - generic [ref=e198]: + - radio "PayPal" [ref=e199] + - text: PayPal + - generic [ref=e200]: + - radio "Bank Transfer" [ref=e201] + - text: Bank Transfer + - button "Place order - 25.00 EUR" [ref=e202]: + - img [ref=e204] + - generic [ref=e207]: Place order - 25.00 EUR + - complementary [ref=e208]: + - generic [ref=e209]: + - generic [ref=e210]: Order summary + - generic [ref=e211]: + - generic [ref=e212]: + - term [ref=e213]: Subtotal + - definition [ref=e214]: 25.00 EUR + - generic [ref=e215]: + - term [ref=e216]: Shipping + - definition [ref=e217]: 0.00 EUR + - generic [ref=e218]: + - term [ref=e219]: Total + - definition [ref=e220]: 25.00 EUR + - contentinfo [ref=e221]: + - generic [ref=e222]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (1) + - button "Close cart": + - generic: + - img + - img + - generic: + - list: + - listitem: + - generic: + - paragraph: Organic Cotton T-Shirt + - paragraph: TSH-S-BLA + - generic: + - button "Decrease quantity": + - generic: + - img + - generic: "-" + - generic: "1" + - button "Increase quantity": + - generic: + - img + - generic: + + - generic: + - paragraph: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart": + - generic: + - img + - img + - contentinfo: + - generic: + - generic: + - term: Subtotal + - definition: 25.00 EUR + - generic: + - term: Estimated total + - definition: 25.00 EUR + - paragraph: Shipping and taxes calculated at checkout + - link "Checkout": + - /url: http://shop.test/checkout + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-40-17-855Z.yml b/.playwright-mcp/page-2026-04-18T08-40-17-855Z.yml new file mode 100644 index 00000000..b07ab0a9 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-40-17-855Z.yml @@ -0,0 +1,148 @@ +- generic [ref=e111]: + - link "Skip to content" [ref=e112] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e113]: Free shipping on orders over 50 + - banner [ref=e114]: + - generic [ref=e115]: + - link "Acme Fashion" [ref=e116] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e117]: + - link "Collections" [ref=e118] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e119] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e120] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e121] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e122]: + - generic [ref=e123]: + - navigation "Breadcrumb" [ref=e124]: + - list [ref=e125]: + - listitem [ref=e126]: + - link "Home" [ref=e127] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e128] + - listitem [ref=e130]: + - link "Cart" [ref=e131] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e132] + - listitem [ref=e134]: + - generic [ref=e135]: Checkout + - generic [ref=e136]: Checkout + - generic [ref=e137]: + - generic [ref=e138]: + - generic [ref=e139]: + - generic [ref=e140]: 1. Contact & shipping + - generic [ref=e141]: + - generic [ref=e142]: + - generic [ref=e143]: Email + - textbox "Email" [ref=e145]: buyer@example.com + - generic [ref=e146]: + - generic [ref=e147]: First name + - textbox "First name" [ref=e149]: Billy + - generic [ref=e150]: + - generic [ref=e151]: Last name + - textbox "Last name" [ref=e153]: Buyer + - generic [ref=e154]: + - generic [ref=e155]: Address + - textbox "Address" [ref=e157]: 1 Shop Street + - generic [ref=e158]: + - generic [ref=e159]: Apt / Suite + - textbox "Apt / Suite" [ref=e161] + - generic [ref=e162]: + - generic [ref=e163]: City + - textbox "City" [ref=e165]: Berlin + - generic [ref=e166]: + - generic [ref=e167]: State / Province + - textbox "State / Province" [ref=e169]: BE + - generic [ref=e170]: + - generic [ref=e171]: Postal code + - textbox "Postal code" [ref=e173]: "10115" + - generic [ref=e174]: + - generic [ref=e175]: Country code + - textbox "Country code" [ref=e177]: US + - button "Continue" [active] [ref=e178]: + - img [ref=e180] + - generic [ref=e183]: Continue + - generic [ref=e184]: + - generic [ref=e185]: 2. Shipping method + - generic [ref=e186]: + - img [ref=e188] + - generic [ref=e192]: No shipping methods available yet. Continue past step 1 first. + - generic [ref=e193]: + - generic [ref=e194]: 3. Payment + - generic [ref=e195]: + - generic [ref=e196]: + - radio "Credit Card" [checked] [ref=e197] + - text: Credit Card + - generic [ref=e198]: + - radio "PayPal" [ref=e199] + - text: PayPal + - generic [ref=e200]: + - radio "Bank Transfer" [ref=e201] + - text: Bank Transfer + - button "Place order - 25.00 EUR" [ref=e202]: + - img [ref=e204] + - generic [ref=e207]: Place order - 25.00 EUR + - complementary [ref=e208]: + - generic [ref=e209]: + - generic [ref=e210]: Order summary + - generic [ref=e211]: + - generic [ref=e212]: + - term [ref=e213]: Subtotal + - definition [ref=e214]: 25.00 EUR + - generic [ref=e215]: + - term [ref=e216]: Shipping + - definition [ref=e217]: 0.00 EUR + - generic [ref=e218]: + - term [ref=e219]: Total + - definition [ref=e220]: 25.00 EUR + - contentinfo [ref=e221]: + - generic [ref=e222]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (1) + - button "Close cart": + - generic: + - img + - img + - generic: + - list: + - listitem: + - generic: + - paragraph: Organic Cotton T-Shirt + - paragraph: TSH-S-BLA + - generic: + - button "Decrease quantity": + - generic: + - img + - generic: "-" + - generic: "1" + - button "Increase quantity": + - generic: + - img + - generic: + + - generic: + - paragraph: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart": + - generic: + - img + - img + - contentinfo: + - generic: + - generic: + - term: Subtotal + - definition: 25.00 EUR + - generic: + - term: Estimated total + - definition: 25.00 EUR + - paragraph: Shipping and taxes calculated at checkout + - link "Checkout": + - /url: http://shop.test/checkout + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-41-52-664Z.yml b/.playwright-mcp/page-2026-04-18T08-41-52-664Z.yml new file mode 100644 index 00000000..e4c711b6 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-41-52-664Z.yml @@ -0,0 +1,73 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-41-59-687Z.yml b/.playwright-mcp/page-2026-04-18T08-41-59-687Z.yml new file mode 100644 index 00000000..10e6e10c --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-41-59-687Z.yml @@ -0,0 +1,94 @@ +- generic [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [active] [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - dialog "Shopping cart" [ref=e55]: + - banner [ref=e56]: + - generic [ref=e57]: Your Cart (1) + - button "Close cart" [ref=e58]: + - img [ref=e60] + - img [ref=e63] + - list [ref=e66]: + - listitem [ref=e67]: + - generic [ref=e69]: + - paragraph [ref=e70]: Organic Cotton T-Shirt + - paragraph [ref=e71]: TSH-S-BLA + - generic [ref=e72]: + - button "Decrease quantity" [ref=e73]: + - img [ref=e75] + - generic [ref=e78]: "-" + - generic [ref=e79]: "1" + - button "Increase quantity" [ref=e80]: + - img [ref=e82] + - generic [ref=e85]: + + - generic [ref=e86]: + - paragraph [ref=e87]: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart" [ref=e88]: + - img [ref=e90] + - img [ref=e93] + - contentinfo [ref=e95]: + - generic [ref=e96]: + - generic [ref=e97]: + - term [ref=e98]: Subtotal + - definition [ref=e99]: 25.00 EUR + - generic [ref=e100]: + - term [ref=e101]: Estimated total + - definition [ref=e102]: 25.00 EUR + - paragraph [ref=e103]: Shipping and taxes calculated at checkout + - link "Checkout" [ref=e104] [cursor=pointer]: + - /url: http://shop.test/checkout + - button "Continue shopping" [ref=e105]: + - img [ref=e107] + - generic [ref=e110]: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-42-02-779Z.yml b/.playwright-mcp/page-2026-04-18T08-42-02-779Z.yml new file mode 100644 index 00000000..1673f5d7 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-42-02-779Z.yml @@ -0,0 +1,151 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Cart" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Checkout + - generic [ref=e26]: Checkout + - generic [ref=e27]: + - generic [ref=e28]: + - generic [ref=e29]: + - generic [ref=e30]: 1. Contact & shipping + - generic [ref=e31]: + - generic [ref=e32]: + - generic [ref=e33]: Email + - textbox "Email" [ref=e35] + - generic [ref=e36]: + - generic [ref=e37]: First name + - textbox "First name" [ref=e39] + - generic [ref=e40]: + - generic [ref=e41]: Last name + - textbox "Last name" [ref=e43] + - generic [ref=e44]: + - generic [ref=e45]: Address + - textbox "Address" [ref=e47] + - generic [ref=e48]: + - generic [ref=e49]: Apt / Suite + - textbox "Apt / Suite" [ref=e51] + - generic [ref=e52]: + - generic [ref=e53]: City + - textbox "City" [ref=e55] + - generic [ref=e56]: + - generic [ref=e57]: State / Province + - textbox "State / Province" [ref=e59] + - generic [ref=e60]: + - generic [ref=e61]: Postal code + - textbox "Postal code" [ref=e63] + - generic [ref=e64]: + - generic [ref=e65]: Country code + - textbox "Country code" [ref=e67]: US + - button "Continue" [ref=e68]: + - img [ref=e70] + - generic [ref=e73]: Continue + - generic [ref=e74]: + - generic [ref=e75]: 2. Shipping method + - generic [ref=e76]: + - img [ref=e78] + - generic [ref=e82]: No shipping methods available yet. Continue past step 1 first. + - generic [ref=e83]: + - generic [ref=e84]: 3. Payment + - generic [ref=e85]: + - generic [ref=e86]: + - radio "Credit Card" [checked] [ref=e87] + - text: Credit Card + - generic [ref=e88]: + - radio "PayPal" [ref=e89] + - text: PayPal + - generic [ref=e90]: + - radio "Bank Transfer" [ref=e91] + - text: Bank Transfer + - button "Place order - 29.75 EUR" [ref=e92]: + - img [ref=e94] + - generic [ref=e97]: Place order - 29.75 EUR + - complementary [ref=e98]: + - generic [ref=e99]: + - generic [ref=e100]: Order summary + - generic [ref=e101]: + - generic [ref=e102]: + - term [ref=e103]: Subtotal + - definition [ref=e104]: 25.00 EUR + - generic [ref=e105]: + - term [ref=e106]: Shipping + - definition [ref=e107]: 0.00 EUR + - generic [ref=e108]: + - term [ref=e109]: Tax + - definition [ref=e110]: 4.75 EUR + - generic [ref=e111]: + - term [ref=e112]: Total + - definition [ref=e113]: 29.75 EUR + - contentinfo [ref=e114]: + - generic [ref=e115]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (1) + - button "Close cart": + - generic: + - img + - img + - generic: + - list: + - listitem: + - generic: + - paragraph: Organic Cotton T-Shirt + - paragraph: TSH-S-BLA + - generic: + - button "Decrease quantity": + - generic: + - img + - generic: "-" + - generic: "1" + - button "Increase quantity": + - generic: + - img + - generic: + + - generic: + - paragraph: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart": + - generic: + - img + - img + - contentinfo: + - generic: + - generic: + - term: Subtotal + - definition: 25.00 EUR + - generic: + - term: Estimated total + - definition: 25.00 EUR + - paragraph: Shipping and taxes calculated at checkout + - link "Checkout": + - /url: http://shop.test/checkout + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-42-25-068Z.yml b/.playwright-mcp/page-2026-04-18T08-42-25-068Z.yml new file mode 100644 index 00000000..79691711 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-42-25-068Z.yml @@ -0,0 +1,151 @@ +- generic [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Cart" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Checkout + - generic [ref=e26]: Checkout + - generic [ref=e27]: + - generic [ref=e28]: + - generic [ref=e29]: + - generic [ref=e30]: 1. Contact & shipping + - generic [ref=e31]: + - generic [ref=e32]: + - generic [ref=e33]: Email + - textbox "Email" [ref=e35]: buyer@example.com + - generic [ref=e36]: + - generic [ref=e37]: First name + - textbox "First name" [ref=e39]: Billy + - generic [ref=e40]: + - generic [ref=e41]: Last name + - textbox "Last name" [ref=e43]: Buyer + - generic [ref=e44]: + - generic [ref=e45]: Address + - textbox "Address" [ref=e47]: 1 Shop Street + - generic [ref=e48]: + - generic [ref=e49]: Apt / Suite + - textbox "Apt / Suite" [ref=e51] + - generic [ref=e52]: + - generic [ref=e53]: City + - textbox "City" [ref=e55]: Berlin + - generic [ref=e56]: + - generic [ref=e57]: State / Province + - textbox "State / Province" [ref=e59]: BE + - generic [ref=e60]: + - generic [ref=e61]: Postal code + - textbox "Postal code" [ref=e63]: "10115" + - generic [ref=e64]: + - generic [ref=e65]: Country code + - textbox "Country code" [active] [ref=e67]: US + - button "Continue" [ref=e68]: + - img [ref=e70] + - generic [ref=e73]: Continue + - generic [ref=e74]: + - generic [ref=e75]: 2. Shipping method + - generic [ref=e76]: + - img [ref=e78] + - generic [ref=e82]: No shipping methods available yet. Continue past step 1 first. + - generic [ref=e83]: + - generic [ref=e84]: 3. Payment + - generic [ref=e85]: + - generic [ref=e86]: + - radio "Credit Card" [checked] [ref=e87] + - text: Credit Card + - generic [ref=e88]: + - radio "PayPal" [ref=e89] + - text: PayPal + - generic [ref=e90]: + - radio "Bank Transfer" [ref=e91] + - text: Bank Transfer + - button "Place order - 29.75 EUR" [ref=e92]: + - img [ref=e94] + - generic [ref=e97]: Place order - 29.75 EUR + - complementary [ref=e98]: + - generic [ref=e99]: + - generic [ref=e100]: Order summary + - generic [ref=e101]: + - generic [ref=e102]: + - term [ref=e103]: Subtotal + - definition [ref=e104]: 25.00 EUR + - generic [ref=e105]: + - term [ref=e106]: Shipping + - definition [ref=e107]: 0.00 EUR + - generic [ref=e108]: + - term [ref=e109]: Tax + - definition [ref=e110]: 4.75 EUR + - generic [ref=e111]: + - term [ref=e112]: Total + - definition [ref=e113]: 29.75 EUR + - contentinfo [ref=e114]: + - generic [ref=e115]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (1) + - button "Close cart": + - generic: + - img + - img + - generic: + - list: + - listitem: + - generic: + - paragraph: Organic Cotton T-Shirt + - paragraph: TSH-S-BLA + - generic: + - button "Decrease quantity": + - generic: + - img + - generic: "-" + - generic: "1" + - button "Increase quantity": + - generic: + - img + - generic: + + - generic: + - paragraph: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart": + - generic: + - img + - img + - contentinfo: + - generic: + - generic: + - term: Subtotal + - definition: 25.00 EUR + - generic: + - term: Estimated total + - definition: 25.00 EUR + - paragraph: Shipping and taxes calculated at checkout + - link "Checkout": + - /url: http://shop.test/checkout + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-42-33-505Z.yml b/.playwright-mcp/page-2026-04-18T08-42-33-505Z.yml new file mode 100644 index 00000000..5a98ac28 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-42-33-505Z.yml @@ -0,0 +1,155 @@ +- generic [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Cart" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Checkout + - generic [ref=e26]: Checkout + - generic [ref=e27]: + - generic [ref=e28]: + - generic [ref=e29]: + - generic [ref=e30]: 1. Contact & shipping + - generic [ref=e31]: + - generic [ref=e32]: + - generic [ref=e33]: Email + - textbox "Email" [ref=e35]: buyer@example.com + - generic [ref=e36]: + - generic [ref=e37]: First name + - textbox "First name" [ref=e39]: Billy + - generic [ref=e40]: + - generic [ref=e41]: Last name + - textbox "Last name" [ref=e43]: Buyer + - generic [ref=e44]: + - generic [ref=e45]: Address + - textbox "Address" [ref=e47]: 1 Shop Street + - generic [ref=e48]: + - generic [ref=e49]: Apt / Suite + - textbox "Apt / Suite" [ref=e51] + - generic [ref=e52]: + - generic [ref=e53]: City + - textbox "City" [ref=e55]: Berlin + - generic [ref=e56]: + - generic [ref=e57]: State / Province + - textbox "State / Province" [ref=e59]: BE + - generic [ref=e60]: + - generic [ref=e61]: Postal code + - textbox "Postal code" [ref=e63]: "10115" + - generic [ref=e64]: + - generic [ref=e65]: Country code + - textbox "Country code" [ref=e67]: DE + - button "Continue" [active] [ref=e68]: + - img [ref=e70] + - generic [ref=e73]: Continue + - generic [ref=e74]: + - generic [ref=e75]: 2. Shipping method + - list [ref=e116]: + - listitem [ref=e117]: + - generic [ref=e118] [cursor=pointer]: + - generic [ref=e119]: + - radio "Standard 4.99 EUR" [ref=e120] + - generic [ref=e121]: Standard + - generic [ref=e122]: 4.99 EUR + - generic [ref=e83]: + - generic [ref=e84]: 3. Payment + - generic [ref=e85]: + - generic [ref=e86]: + - radio "Credit Card" [checked] [ref=e87] + - text: Credit Card + - generic [ref=e88]: + - radio "PayPal" [ref=e89] + - text: PayPal + - generic [ref=e90]: + - radio "Bank Transfer" [ref=e91] + - text: Bank Transfer + - button "Place order - 29.75 EUR" [ref=e92]: + - img [ref=e94] + - generic [ref=e97]: Place order - 29.75 EUR + - complementary [ref=e98]: + - generic [ref=e99]: + - generic [ref=e100]: Order summary + - generic [ref=e101]: + - generic [ref=e102]: + - term [ref=e103]: Subtotal + - definition [ref=e104]: 25.00 EUR + - generic [ref=e105]: + - term [ref=e106]: Shipping + - definition [ref=e107]: 0.00 EUR + - generic [ref=e108]: + - term [ref=e109]: Tax + - definition [ref=e110]: 4.75 EUR + - generic [ref=e111]: + - term [ref=e112]: Total + - definition [ref=e113]: 29.75 EUR + - contentinfo [ref=e114]: + - generic [ref=e115]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (1) + - button "Close cart": + - generic: + - img + - img + - generic: + - list: + - listitem: + - generic: + - paragraph: Organic Cotton T-Shirt + - paragraph: TSH-S-BLA + - generic: + - button "Decrease quantity": + - generic: + - img + - generic: "-" + - generic: "1" + - button "Increase quantity": + - generic: + - img + - generic: + + - generic: + - paragraph: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart": + - generic: + - img + - img + - contentinfo: + - generic: + - generic: + - term: Subtotal + - definition: 25.00 EUR + - generic: + - term: Estimated total + - definition: 25.00 EUR + - paragraph: Shipping and taxes calculated at checkout + - link "Checkout": + - /url: http://shop.test/checkout + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-42-45-519Z.yml b/.playwright-mcp/page-2026-04-18T08-42-45-519Z.yml new file mode 100644 index 00000000..76190904 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-42-45-519Z.yml @@ -0,0 +1,155 @@ +- generic [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Cart" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Checkout + - generic [ref=e26]: Checkout + - generic [ref=e27]: + - generic [ref=e28]: + - generic [ref=e29]: + - generic [ref=e30]: 1. Contact & shipping + - generic [ref=e31]: + - generic [ref=e32]: + - generic [ref=e33]: Email + - textbox "Email" [ref=e35]: buyer@example.com + - generic [ref=e36]: + - generic [ref=e37]: First name + - textbox "First name" [ref=e39]: Billy + - generic [ref=e40]: + - generic [ref=e41]: Last name + - textbox "Last name" [ref=e43]: Buyer + - generic [ref=e44]: + - generic [ref=e45]: Address + - textbox "Address" [ref=e47]: 1 Shop Street + - generic [ref=e48]: + - generic [ref=e49]: Apt / Suite + - textbox "Apt / Suite" [ref=e51] + - generic [ref=e52]: + - generic [ref=e53]: City + - textbox "City" [ref=e55]: Berlin + - generic [ref=e56]: + - generic [ref=e57]: State / Province + - textbox "State / Province" [ref=e59]: BE + - generic [ref=e60]: + - generic [ref=e61]: Postal code + - textbox "Postal code" [ref=e63]: "10115" + - generic [ref=e64]: + - generic [ref=e65]: Country code + - textbox "Country code" [ref=e67]: DE + - button "Continue" [ref=e68]: + - img [ref=e70] + - generic [ref=e73]: Continue + - generic [ref=e74]: + - generic [ref=e75]: 2. Shipping method + - list [ref=e116]: + - listitem [ref=e117]: + - generic [ref=e118] [cursor=pointer]: + - generic [ref=e119]: + - radio "Standard 4.99 EUR" [checked] [active] [ref=e120] + - generic [ref=e121]: Standard + - generic [ref=e122]: 4.99 EUR + - generic [ref=e83]: + - generic [ref=e84]: 3. Payment + - generic [ref=e85]: + - generic [ref=e86]: + - radio "Credit Card" [checked] [ref=e87] + - text: Credit Card + - generic [ref=e88]: + - radio "PayPal" [ref=e89] + - text: PayPal + - generic [ref=e90]: + - radio "Bank Transfer" [ref=e91] + - text: Bank Transfer + - button "Place order - 35.69 EUR" [ref=e123]: + - img [ref=e94] + - generic [ref=e97]: Place order - 35.69 EUR + - complementary [ref=e98]: + - generic [ref=e99]: + - generic [ref=e100]: Order summary + - generic [ref=e101]: + - generic [ref=e102]: + - term [ref=e103]: Subtotal + - definition [ref=e104]: 25.00 EUR + - generic [ref=e105]: + - term [ref=e106]: Shipping + - definition [ref=e107]: 4.99 EUR + - generic [ref=e108]: + - term [ref=e109]: Tax + - definition [ref=e110]: 5.70 EUR + - generic [ref=e111]: + - term [ref=e112]: Total + - definition [ref=e113]: 35.69 EUR + - contentinfo [ref=e114]: + - generic [ref=e115]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (1) + - button "Close cart": + - generic: + - img + - img + - generic: + - list: + - listitem: + - generic: + - paragraph: Organic Cotton T-Shirt + - paragraph: TSH-S-BLA + - generic: + - button "Decrease quantity": + - generic: + - img + - generic: "-" + - generic: "1" + - button "Increase quantity": + - generic: + - img + - generic: + + - generic: + - paragraph: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart": + - generic: + - img + - img + - contentinfo: + - generic: + - generic: + - term: Subtotal + - definition: 25.00 EUR + - generic: + - term: Estimated total + - definition: 25.00 EUR + - paragraph: Shipping and taxes calculated at checkout + - link "Checkout": + - /url: http://shop.test/checkout + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-43-11-472Z.yml b/.playwright-mcp/page-2026-04-18T08-43-11-472Z.yml new file mode 100644 index 00000000..fe10ffb9 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-43-11-472Z.yml @@ -0,0 +1,155 @@ +- generic [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Cart" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Checkout + - generic [ref=e26]: Checkout + - generic [ref=e27]: + - generic [ref=e28]: + - generic [ref=e29]: + - generic [ref=e30]: 1. Contact & shipping + - generic [ref=e31]: + - generic [ref=e32]: + - generic [ref=e33]: Email + - textbox "Email" [ref=e35]: buyer@example.com + - generic [ref=e36]: + - generic [ref=e37]: First name + - textbox "First name" [ref=e39]: Billy + - generic [ref=e40]: + - generic [ref=e41]: Last name + - textbox "Last name" [ref=e43]: Buyer + - generic [ref=e44]: + - generic [ref=e45]: Address + - textbox "Address" [ref=e47]: 1 Shop Street + - generic [ref=e48]: + - generic [ref=e49]: Apt / Suite + - textbox "Apt / Suite" [ref=e51] + - generic [ref=e52]: + - generic [ref=e53]: City + - textbox "City" [ref=e55]: Berlin + - generic [ref=e56]: + - generic [ref=e57]: State / Province + - textbox "State / Province" [ref=e59]: BE + - generic [ref=e60]: + - generic [ref=e61]: Postal code + - textbox "Postal code" [ref=e63]: "10115" + - generic [ref=e64]: + - generic [ref=e65]: Country code + - textbox "Country code" [ref=e67]: DE + - button "Continue" [ref=e68]: + - img [ref=e70] + - generic [ref=e73]: Continue + - generic [ref=e74]: + - generic [ref=e75]: 2. Shipping method + - list [ref=e116]: + - listitem [ref=e117]: + - generic [ref=e118] [cursor=pointer]: + - generic [ref=e119]: + - radio "Standard 4.99 EUR" [checked] [ref=e120] + - generic [ref=e121]: Standard + - generic [ref=e122]: 4.99 EUR + - generic [ref=e83]: + - generic [ref=e84]: 3. Payment + - generic [ref=e85]: + - generic [ref=e86]: + - radio "Credit Card" [ref=e87] + - text: Credit Card + - generic [ref=e88]: + - radio "PayPal" [ref=e89] + - text: PayPal + - generic [ref=e90]: + - radio "Bank Transfer" [checked] [active] [ref=e91] + - text: Bank Transfer + - button "Place order - 35.69 EUR" [ref=e123]: + - img [ref=e94] + - generic [ref=e97]: Place order - 35.69 EUR + - complementary [ref=e98]: + - generic [ref=e99]: + - generic [ref=e100]: Order summary + - generic [ref=e101]: + - generic [ref=e102]: + - term [ref=e103]: Subtotal + - definition [ref=e104]: 25.00 EUR + - generic [ref=e105]: + - term [ref=e106]: Shipping + - definition [ref=e107]: 4.99 EUR + - generic [ref=e108]: + - term [ref=e109]: Tax + - definition [ref=e110]: 5.70 EUR + - generic [ref=e111]: + - term [ref=e112]: Total + - definition [ref=e113]: 35.69 EUR + - contentinfo [ref=e114]: + - generic [ref=e115]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (1) + - button "Close cart": + - generic: + - img + - img + - generic: + - list: + - listitem: + - generic: + - paragraph: Organic Cotton T-Shirt + - paragraph: TSH-S-BLA + - generic: + - button "Decrease quantity": + - generic: + - img + - generic: "-" + - generic: "1" + - button "Increase quantity": + - generic: + - img + - generic: + + - generic: + - paragraph: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart": + - generic: + - img + - img + - contentinfo: + - generic: + - generic: + - term: Subtotal + - definition: 25.00 EUR + - generic: + - term: Estimated total + - definition: 25.00 EUR + - paragraph: Shipping and taxes calculated at checkout + - link "Checkout": + - /url: http://shop.test/checkout + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-43-21-769Z.yml b/.playwright-mcp/page-2026-04-18T08-43-21-769Z.yml new file mode 100644 index 00000000..3764bf45 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-43-21-769Z.yml @@ -0,0 +1,155 @@ +- generic [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Cart" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Checkout + - generic [ref=e26]: Checkout + - generic [ref=e27]: + - generic [ref=e28]: + - generic [ref=e29]: + - generic [ref=e30]: 1. Contact & shipping + - generic [ref=e31]: + - generic [ref=e32]: + - generic [ref=e33]: Email + - textbox "Email" [ref=e35]: buyer@example.com + - generic [ref=e36]: + - generic [ref=e37]: First name + - textbox "First name" [ref=e39]: Billy + - generic [ref=e40]: + - generic [ref=e41]: Last name + - textbox "Last name" [ref=e43]: Buyer + - generic [ref=e44]: + - generic [ref=e45]: Address + - textbox "Address" [ref=e47]: 1 Shop Street + - generic [ref=e48]: + - generic [ref=e49]: Apt / Suite + - textbox "Apt / Suite" [ref=e51] + - generic [ref=e52]: + - generic [ref=e53]: City + - textbox "City" [ref=e55]: Berlin + - generic [ref=e56]: + - generic [ref=e57]: State / Province + - textbox "State / Province" [ref=e59]: BE + - generic [ref=e60]: + - generic [ref=e61]: Postal code + - textbox "Postal code" [ref=e63]: "10115" + - generic [ref=e64]: + - generic [ref=e65]: Country code + - textbox "Country code" [ref=e67]: DE + - button "Continue" [ref=e68]: + - img [ref=e70] + - generic [ref=e73]: Continue + - generic [ref=e74]: + - generic [ref=e75]: 2. Shipping method + - list [ref=e116]: + - listitem [ref=e117]: + - generic [ref=e118] [cursor=pointer]: + - generic [ref=e119]: + - radio "Standard 4.99 EUR" [checked] [active] [ref=e120] + - generic [ref=e121]: Standard + - generic [ref=e122]: 4.99 EUR + - generic [ref=e83]: + - generic [ref=e84]: 3. Payment + - generic [ref=e85]: + - generic [ref=e86]: + - radio "Credit Card" [ref=e87] + - text: Credit Card + - generic [ref=e88]: + - radio "PayPal" [ref=e89] + - text: PayPal + - generic [ref=e90]: + - radio "Bank Transfer" [checked] [ref=e91] + - text: Bank Transfer + - button "Place order - 35.69 EUR" [ref=e123]: + - img [ref=e94] + - generic [ref=e97]: Place order - 35.69 EUR + - complementary [ref=e98]: + - generic [ref=e99]: + - generic [ref=e100]: Order summary + - generic [ref=e101]: + - generic [ref=e102]: + - term [ref=e103]: Subtotal + - definition [ref=e104]: 25.00 EUR + - generic [ref=e105]: + - term [ref=e106]: Shipping + - definition [ref=e107]: 4.99 EUR + - generic [ref=e108]: + - term [ref=e109]: Tax + - definition [ref=e110]: 5.70 EUR + - generic [ref=e111]: + - term [ref=e112]: Total + - definition [ref=e113]: 35.69 EUR + - contentinfo [ref=e114]: + - generic [ref=e115]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (1) + - button "Close cart": + - generic: + - img + - img + - generic: + - list: + - listitem: + - generic: + - paragraph: Organic Cotton T-Shirt + - paragraph: TSH-S-BLA + - generic: + - button "Decrease quantity": + - generic: + - img + - generic: "-" + - generic: "1" + - button "Increase quantity": + - generic: + - img + - generic: + + - generic: + - paragraph: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart": + - generic: + - img + - img + - contentinfo: + - generic: + - generic: + - term: Subtotal + - definition: 25.00 EUR + - generic: + - term: Estimated total + - definition: 25.00 EUR + - paragraph: Shipping and taxes calculated at checkout + - link "Checkout": + - /url: http://shop.test/checkout + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-43-55-970Z.yml b/.playwright-mcp/page-2026-04-18T08-43-55-970Z.yml new file mode 100644 index 00000000..acf4b153 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-43-55-970Z.yml @@ -0,0 +1,44 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - img [ref=e14] + - generic [ref=e16]: Thank you for your order + - paragraph [ref=e17]: "Order number: #1001" + - paragraph [ref=e18]: Phase 5 will wire the real order and payment data. + - link "Continue shopping" [ref=e19] [cursor=pointer]: + - /url: http://shop.test + - contentinfo [ref=e20]: + - generic [ref=e21]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-44-17-187Z.yml b/.playwright-mcp/page-2026-04-18T08-44-17-187Z.yml new file mode 100644 index 00000000..d63f019d --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-44-17-187Z.yml @@ -0,0 +1,75 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - generic [ref=e14]: + - img [ref=e15] + - generic [ref=e17]: Thank you for your order + - paragraph [ref=e18]: "Order number: #1001" + - generic [ref=e19]: + - generic [ref=e20]: + - generic [ref=e21]: Order summary + - generic [ref=e22]: pending + - table [ref=e23]: + - rowgroup [ref=e24]: + - row "Item Qty Total" [ref=e25]: + - columnheader "Item" [ref=e26] + - columnheader "Qty" [ref=e27] + - columnheader "Total" [ref=e28] + - rowgroup [ref=e29]: + - row "Organic Cotton T-Shirt TSH-S-BLA 1 0.00 EUR" [ref=e30]: + - cell "Organic Cotton T-Shirt TSH-S-BLA" [ref=e31]: + - generic [ref=e32]: Organic Cotton T-Shirt + - paragraph [ref=e33]: TSH-S-BLA + - cell "1" [ref=e34] + - cell "0.00 EUR" [ref=e35] + - rowgroup [ref=e36]: + - row "Subtotal 25.00 EUR" [ref=e37]: + - cell "Subtotal" [ref=e38] + - cell "25.00 EUR" [ref=e39] + - row "Shipping 4.99 EUR" [ref=e40]: + - cell "Shipping" [ref=e41] + - cell "4.99 EUR" [ref=e42] + - row "Tax 5.70 EUR" [ref=e43]: + - cell "Tax" [ref=e44] + - cell "5.70 EUR" [ref=e45] + - row "Total 35.69 EUR" [ref=e46]: + - cell "Total" [ref=e47] + - cell "35.69 EUR" [ref=e48] + - generic [ref=e51]: Your bank transfer is awaiting payment. We'll update you once it has been confirmed. + - link "Continue shopping" [ref=e53] [cursor=pointer]: + - /url: http://shop.test + - contentinfo [ref=e54]: + - generic [ref=e55]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-44-45-492Z.yml b/.playwright-mcp/page-2026-04-18T08-44-45-492Z.yml new file mode 100644 index 00000000..ee50e7e2 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-44-45-492Z.yml @@ -0,0 +1,75 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - generic [ref=e14]: + - img [ref=e15] + - generic [ref=e17]: Thank you for your order + - paragraph [ref=e18]: "Order number: #1001" + - generic [ref=e19]: + - generic [ref=e20]: + - generic [ref=e21]: Order summary + - generic [ref=e22]: pending + - table [ref=e23]: + - rowgroup [ref=e24]: + - row "Item Qty Total" [ref=e25]: + - columnheader "Item" [ref=e26] + - columnheader "Qty" [ref=e27] + - columnheader "Total" [ref=e28] + - rowgroup [ref=e29]: + - row "Organic Cotton T-Shirt TSH-S-BLA 1 25.00 EUR" [ref=e30]: + - cell "Organic Cotton T-Shirt TSH-S-BLA" [ref=e31]: + - generic [ref=e32]: Organic Cotton T-Shirt + - paragraph [ref=e33]: TSH-S-BLA + - cell "1" [ref=e34] + - cell "25.00 EUR" [ref=e35] + - rowgroup [ref=e36]: + - row "Subtotal 25.00 EUR" [ref=e37]: + - cell "Subtotal" [ref=e38] + - cell "25.00 EUR" [ref=e39] + - row "Shipping 4.99 EUR" [ref=e40]: + - cell "Shipping" [ref=e41] + - cell "4.99 EUR" [ref=e42] + - row "Tax 5.70 EUR" [ref=e43]: + - cell "Tax" [ref=e44] + - cell "5.70 EUR" [ref=e45] + - row "Total 35.69 EUR" [ref=e46]: + - cell "Total" [ref=e47] + - cell "35.69 EUR" [ref=e48] + - generic [ref=e51]: Your bank transfer is awaiting payment. We'll update you once it has been confirmed. + - link "Continue shopping" [ref=e53] [cursor=pointer]: + - /url: http://shop.test + - contentinfo [ref=e54]: + - generic [ref=e55]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-44-53-597Z.yml b/.playwright-mcp/page-2026-04-18T08-44-53-597Z.yml new file mode 100644 index 00000000..34a55d87 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-44-53-597Z.yml @@ -0,0 +1,56 @@ +- generic [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - generic [ref=e14]: Sign in + - generic [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: Email + - textbox "Email" [active] [ref=e19] + - generic [ref=e20]: + - generic [ref=e21]: Password + - textbox "Password" [ref=e23] + - generic [ref=e24]: + - checkbox "Remember me" [ref=e25] + - generic [ref=e27]: Remember me + - button "Sign in" [ref=e28]: + - img [ref=e30] + - generic [ref=e33]: Sign in + - paragraph [ref=e34]: + - text: No account yet? + - link "Create one" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/account/register + - contentinfo [ref=e36]: + - generic [ref=e37]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-45-20-872Z.yml b/.playwright-mcp/page-2026-04-18T08-45-20-872Z.yml new file mode 100644 index 00000000..3223af0a --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-45-20-872Z.yml @@ -0,0 +1,52 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - generic [ref=e14]: Account dashboard + - paragraph [ref=e15]: Welcome back, Billy. + - generic [ref=e16]: + - link "Your orders Track and manage previous purchases" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/account/orders + - generic [ref=e18]: Your orders + - paragraph [ref=e19]: Track and manage previous purchases + - link "Addresses Manage shipping and billing addresses" [ref=e20] [cursor=pointer]: + - /url: http://shop.test/account/addresses + - generic [ref=e21]: Addresses + - paragraph [ref=e22]: Manage shipping and billing addresses + - button "Sign out End your current session" [ref=e24]: + - generic [ref=e25]: Sign out + - paragraph [ref=e26]: End your current session + - contentinfo [ref=e27]: + - generic [ref=e28]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-45-29-103Z.yml b/.playwright-mcp/page-2026-04-18T08-45-29-103Z.yml new file mode 100644 index 00000000..3223af0a --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-45-29-103Z.yml @@ -0,0 +1,52 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - generic [ref=e14]: Account dashboard + - paragraph [ref=e15]: Welcome back, Billy. + - generic [ref=e16]: + - link "Your orders Track and manage previous purchases" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/account/orders + - generic [ref=e18]: Your orders + - paragraph [ref=e19]: Track and manage previous purchases + - link "Addresses Manage shipping and billing addresses" [ref=e20] [cursor=pointer]: + - /url: http://shop.test/account/addresses + - generic [ref=e21]: Addresses + - paragraph [ref=e22]: Manage shipping and billing addresses + - button "Sign out End your current session" [ref=e24]: + - generic [ref=e25]: Sign out + - paragraph [ref=e26]: End your current session + - contentinfo [ref=e27]: + - generic [ref=e28]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-45-34-547Z.yml b/.playwright-mcp/page-2026-04-18T08-45-34-547Z.yml new file mode 100644 index 00000000..0aad325c --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-45-34-547Z.yml @@ -0,0 +1,58 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Account" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/account + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Orders + - generic [ref=e26]: Order history + - generic [ref=e27]: + - img [ref=e29] + - generic [ref=e32]: + - text: You have no orders yet. + - link "Start shopping" [ref=e33] [cursor=pointer]: + - /url: http://shop.test/collections + - text: . + - contentinfo [ref=e34]: + - generic [ref=e35]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-45-49-992Z.yml b/.playwright-mcp/page-2026-04-18T08-45-49-992Z.yml new file mode 100644 index 00000000..6bc74eca --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-45-49-992Z.yml @@ -0,0 +1,16 @@ +- generic [ref=e3]: + - generic [ref=e4]: Admin Login + - paragraph [ref=e5]: Sign in to manage your store + - generic [ref=e6]: + - generic [ref=e7]: + - generic [ref=e8]: Email + - textbox "Email" [active] [ref=e10] + - generic [ref=e11]: + - generic [ref=e12]: Password + - textbox "Password" [ref=e14] + - generic [ref=e15]: + - checkbox "Remember me" [ref=e16] + - generic [ref=e18]: Remember me + - button "Sign in" [ref=e19]: + - img [ref=e21] + - generic [ref=e24]: Sign in \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-46-01-203Z.yml b/.playwright-mcp/page-2026-04-18T08-46-01-203Z.yml new file mode 100644 index 00000000..9a15b409 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-46-01-203Z.yml @@ -0,0 +1,16 @@ +- generic [ref=e3]: + - generic [ref=e4]: Admin Login + - paragraph [ref=e5]: Sign in to manage your store + - generic [ref=e6]: + - generic [ref=e7]: + - generic [ref=e8]: Email + - textbox "Email" [ref=e10]: owner@acme.test + - generic [ref=e11]: + - generic [ref=e12]: Password + - textbox "Password" [active] [ref=e14]: password + - generic [ref=e15]: + - checkbox "Remember me" [ref=e16] + - generic [ref=e18]: Remember me + - button "Sign in" [ref=e19]: + - img [ref=e21] + - generic [ref=e24]: Sign in \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-46-07-518Z.yml b/.playwright-mcp/page-2026-04-18T08-46-07-518Z.yml new file mode 100644 index 00000000..9a15b409 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-46-07-518Z.yml @@ -0,0 +1,16 @@ +- generic [ref=e3]: + - generic [ref=e4]: Admin Login + - paragraph [ref=e5]: Sign in to manage your store + - generic [ref=e6]: + - generic [ref=e7]: + - generic [ref=e8]: Email + - textbox "Email" [ref=e10]: owner@acme.test + - generic [ref=e11]: + - generic [ref=e12]: Password + - textbox "Password" [active] [ref=e14]: password + - generic [ref=e15]: + - checkbox "Remember me" [ref=e16] + - generic [ref=e18]: Remember me + - button "Sign in" [ref=e19]: + - img [ref=e21] + - generic [ref=e24]: Sign in \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-46-19-920Z.yml b/.playwright-mcp/page-2026-04-18T08-46-19-920Z.yml new file mode 100644 index 00000000..1673ed07 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-46-19-920Z.yml @@ -0,0 +1,83 @@ +- generic [ref=e2]: + - complementary [ref=e3]: + - generic [ref=e4]: Acme Fashion + - navigation [ref=e5]: + - link "Dashboard" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/admin + - link "Orders" [ref=e7] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - link "Products" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/admin/products + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - link "Customers" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - link "Discounts" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - link "Analytics" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - generic [ref=e13]: Content + - link "Pages" [ref=e14] [cursor=pointer]: + - /url: http://shop.test/admin/content/pages + - link "Navigation" [ref=e15] [cursor=pointer]: + - /url: http://shop.test/admin/content/navigation + - link "Themes" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/content/themes + - generic [ref=e17]: System + - link "Settings" [ref=e18] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - link "Search" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/search/settings + - link "Apps" [ref=e20] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - link "Developers" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - generic [ref=e22]: + - banner [ref=e23]: + - generic [ref=e25]: Admin + - generic [ref=e26]: + - paragraph [ref=e27]: owner@acme.test + - button "Sign out" [ref=e29]: + - img [ref=e31] + - generic [ref=e34]: Sign out + - main [ref=e35]: + - generic [ref=e36]: + - generic [ref=e37]: + - generic [ref=e38]: Dashboard + - generic [ref=e39]: + - generic [ref=e40]: + - generic [ref=e41]: From + - textbox "From" [ref=e43]: 2026-03-20 + - generic [ref=e45]: + - generic [ref=e46]: To + - textbox "To" [ref=e48]: 2026-04-18 + - generic [ref=e50]: + - generic [ref=e51]: + - paragraph [ref=e52]: Total sales + - generic [ref=e53]: "0.00" + - generic [ref=e54]: + - paragraph [ref=e55]: Orders + - generic [ref=e56]: "0" + - generic [ref=e57]: + - paragraph [ref=e58]: AOV + - generic [ref=e59]: "0.00" + - generic [ref=e60]: + - paragraph [ref=e61]: Conversion + - generic [ref=e62]: 0.00% + - generic [ref=e63]: + - generic [ref=e64]: Recent orders + - table [ref=e65]: + - rowgroup [ref=e66]: + - row "Order Email Status Total" [ref=e67]: + - columnheader "Order" [ref=e68] + - columnheader "Email" [ref=e69] + - columnheader "Status" [ref=e70] + - columnheader "Total" [ref=e71] + - rowgroup [ref=e72]: + - row "#1001 buyer@example.com pending 35.69" [ref=e73]: + - cell "#1001" [ref=e74]: + - link "#1001" [ref=e75] [cursor=pointer]: + - /url: http://shop.test/admin/orders/1 + - cell "buyer@example.com" [ref=e76] + - cell "pending" [ref=e77] + - cell "35.69" [ref=e78] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-46-24-184Z.yml b/.playwright-mcp/page-2026-04-18T08-46-24-184Z.yml new file mode 100644 index 00000000..97cb8a0a --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-46-24-184Z.yml @@ -0,0 +1,99 @@ +- generic [ref=e2]: + - complementary [ref=e3]: + - generic [ref=e4]: Acme Fashion + - navigation [ref=e5]: + - link "Dashboard" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/admin + - link "Orders" [ref=e7] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - link "Products" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/admin/products + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - link "Customers" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - link "Discounts" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - link "Analytics" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - generic [ref=e13]: Content + - link "Pages" [ref=e14] [cursor=pointer]: + - /url: http://shop.test/admin/content/pages + - link "Navigation" [ref=e15] [cursor=pointer]: + - /url: http://shop.test/admin/content/navigation + - link "Themes" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/content/themes + - generic [ref=e17]: System + - link "Settings" [ref=e18] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - link "Search" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/search/settings + - link "Apps" [ref=e20] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - link "Developers" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - generic [ref=e22]: + - banner [ref=e23]: + - generic [ref=e25]: Admin + - generic [ref=e26]: + - paragraph [ref=e27]: owner@acme.test + - button "Sign out" [ref=e29]: + - img [ref=e31] + - generic [ref=e34]: Sign out + - main [ref=e35]: + - generic [ref=e36]: + - generic [ref=e37]: + - generic [ref=e38]: "Order #1001" + - link "Back" [ref=e39] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - generic [ref=e40]: + - button "Fulfill items" [ref=e41]: + - img [ref=e43] + - generic [ref=e46]: Fulfill items + - button "Confirm payment" [ref=e47]: + - img [ref=e49] + - generic [ref=e52]: Confirm payment + - button "Cancel order" [ref=e53]: + - img [ref=e55] + - generic [ref=e58]: Cancel order + - generic [ref=e59]: + - generic [ref=e60]: + - generic [ref=e61]: + - generic [ref=e62]: Line items + - table [ref=e63]: + - rowgroup [ref=e64]: + - row "Title SKU Qty Price Total" [ref=e65]: + - columnheader "Title" [ref=e66] + - columnheader "SKU" [ref=e67] + - columnheader "Qty" [ref=e68] + - columnheader "Price" [ref=e69] + - columnheader "Total" [ref=e70] + - rowgroup [ref=e71]: + - row "Organic Cotton T-Shirt TSH-S-BLA 1 (0 fulfilled) 25.00 25.00" [ref=e72]: + - cell "Organic Cotton T-Shirt" [ref=e73] + - cell "TSH-S-BLA" [ref=e74] + - cell "1 (0 fulfilled)" [ref=e75] + - cell "25.00" [ref=e76] + - cell "25.00" [ref=e77] + - generic [ref=e79]: + - generic [ref=e80]: "Subtotal: 25.00" + - generic [ref=e81]: "Shipping: 4.99" + - generic [ref=e82]: "Tax: 5.70" + - generic [ref=e83]: "Total: 35.69" + - generic [ref=e84]: + - generic [ref=e85]: Timeline + - list [ref=e86]: + - listitem [ref=e87]: Placed at 2026-04-18 08:43 + - generic [ref=e88]: + - generic [ref=e89]: Payment + - list [ref=e90]: + - listitem [ref=e91]: "Method: bank_transfer" + - listitem [ref=e92]: "Financial status: pending" + - listitem [ref=e93]: "Total paid: 35.69" + - generic [ref=e94]: + - generic [ref=e95]: + - generic [ref=e96]: Customer + - generic [ref=e98]: buyer@example.com + - generic [ref=e99]: + - generic [ref=e100]: Shipping address + - generic [ref=e101]: "{ \"first_name\": \"Billy\", \"last_name\": \"Buyer\", \"address1\": \"1 Shop Street\", \"address2\": \"\", \"city\": \"Berlin\", \"province_code\": \"BE\", \"zip\": \"10115\", \"country_code\": \"DE\", \"phone\": \"\" }" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-46-40-422Z.yml b/.playwright-mcp/page-2026-04-18T08-46-40-422Z.yml new file mode 100644 index 00000000..97cb8a0a --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-46-40-422Z.yml @@ -0,0 +1,99 @@ +- generic [ref=e2]: + - complementary [ref=e3]: + - generic [ref=e4]: Acme Fashion + - navigation [ref=e5]: + - link "Dashboard" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/admin + - link "Orders" [ref=e7] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - link "Products" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/admin/products + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - link "Customers" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - link "Discounts" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - link "Analytics" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - generic [ref=e13]: Content + - link "Pages" [ref=e14] [cursor=pointer]: + - /url: http://shop.test/admin/content/pages + - link "Navigation" [ref=e15] [cursor=pointer]: + - /url: http://shop.test/admin/content/navigation + - link "Themes" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/content/themes + - generic [ref=e17]: System + - link "Settings" [ref=e18] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - link "Search" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/search/settings + - link "Apps" [ref=e20] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - link "Developers" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - generic [ref=e22]: + - banner [ref=e23]: + - generic [ref=e25]: Admin + - generic [ref=e26]: + - paragraph [ref=e27]: owner@acme.test + - button "Sign out" [ref=e29]: + - img [ref=e31] + - generic [ref=e34]: Sign out + - main [ref=e35]: + - generic [ref=e36]: + - generic [ref=e37]: + - generic [ref=e38]: "Order #1001" + - link "Back" [ref=e39] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - generic [ref=e40]: + - button "Fulfill items" [ref=e41]: + - img [ref=e43] + - generic [ref=e46]: Fulfill items + - button "Confirm payment" [ref=e47]: + - img [ref=e49] + - generic [ref=e52]: Confirm payment + - button "Cancel order" [ref=e53]: + - img [ref=e55] + - generic [ref=e58]: Cancel order + - generic [ref=e59]: + - generic [ref=e60]: + - generic [ref=e61]: + - generic [ref=e62]: Line items + - table [ref=e63]: + - rowgroup [ref=e64]: + - row "Title SKU Qty Price Total" [ref=e65]: + - columnheader "Title" [ref=e66] + - columnheader "SKU" [ref=e67] + - columnheader "Qty" [ref=e68] + - columnheader "Price" [ref=e69] + - columnheader "Total" [ref=e70] + - rowgroup [ref=e71]: + - row "Organic Cotton T-Shirt TSH-S-BLA 1 (0 fulfilled) 25.00 25.00" [ref=e72]: + - cell "Organic Cotton T-Shirt" [ref=e73] + - cell "TSH-S-BLA" [ref=e74] + - cell "1 (0 fulfilled)" [ref=e75] + - cell "25.00" [ref=e76] + - cell "25.00" [ref=e77] + - generic [ref=e79]: + - generic [ref=e80]: "Subtotal: 25.00" + - generic [ref=e81]: "Shipping: 4.99" + - generic [ref=e82]: "Tax: 5.70" + - generic [ref=e83]: "Total: 35.69" + - generic [ref=e84]: + - generic [ref=e85]: Timeline + - list [ref=e86]: + - listitem [ref=e87]: Placed at 2026-04-18 08:43 + - generic [ref=e88]: + - generic [ref=e89]: Payment + - list [ref=e90]: + - listitem [ref=e91]: "Method: bank_transfer" + - listitem [ref=e92]: "Financial status: pending" + - listitem [ref=e93]: "Total paid: 35.69" + - generic [ref=e94]: + - generic [ref=e95]: + - generic [ref=e96]: Customer + - generic [ref=e98]: buyer@example.com + - generic [ref=e99]: + - generic [ref=e100]: Shipping address + - generic [ref=e101]: "{ \"first_name\": \"Billy\", \"last_name\": \"Buyer\", \"address1\": \"1 Shop Street\", \"address2\": \"\", \"city\": \"Berlin\", \"province_code\": \"BE\", \"zip\": \"10115\", \"country_code\": \"DE\", \"phone\": \"\" }" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-46-56-113Z.yml b/.playwright-mcp/page-2026-04-18T08-46-56-113Z.yml new file mode 100644 index 00000000..719c9fdb --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-46-56-113Z.yml @@ -0,0 +1,90 @@ +- generic [ref=e2]: + - complementary [ref=e3]: + - generic [ref=e4]: Acme Fashion + - navigation [ref=e5]: + - link "Dashboard" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/admin + - link "Orders" [ref=e7] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - link "Products" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/admin/products + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - link "Customers" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - link "Discounts" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - link "Analytics" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - generic [ref=e13]: Content + - link "Pages" [ref=e14] [cursor=pointer]: + - /url: http://shop.test/admin/content/pages + - link "Navigation" [ref=e15] [cursor=pointer]: + - /url: http://shop.test/admin/content/navigation + - link "Themes" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/content/themes + - generic [ref=e17]: System + - link "Settings" [ref=e18] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - link "Search" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/search/settings + - link "Apps" [ref=e20] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - link "Developers" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - generic [ref=e22]: + - banner [ref=e23]: + - generic [ref=e25]: Admin + - generic [ref=e26]: + - paragraph [ref=e27]: owner@acme.test + - button "Sign out" [ref=e29]: + - img [ref=e31] + - generic [ref=e34]: Sign out + - main [ref=e35]: + - generic [ref=e36]: + - generic [ref=e37]: + - generic [ref=e38]: Products + - link "New product" [ref=e39] [cursor=pointer]: + - /url: http://shop.test/admin/products/new + - generic [ref=e40]: + - textbox "Search products..." [ref=e42] + - combobox [ref=e44]: + - option "All statuses" [disabled] + - option "All statuses" [selected] + - option "Draft" + - option "Active" + - option "Archived" + - table [ref=e46]: + - rowgroup [ref=e47]: + - row "Title Status Vendor Type Actions" [ref=e48]: + - columnheader [ref=e49] + - columnheader "Title" [ref=e50] + - columnheader "Status" [ref=e51] + - columnheader "Vendor" [ref=e52] + - columnheader "Type" [ref=e53] + - columnheader "Actions" [ref=e54] + - rowgroup [ref=e55]: + - row "Classic Pullover Hoodie active Acme Apparel Apparel Edit" [ref=e56]: + - cell [ref=e57]: + - checkbox [ref=e58] + - cell "Classic Pullover Hoodie" [ref=e60]: + - link "Classic Pullover Hoodie" [ref=e61] [cursor=pointer]: + - /url: http://shop.test/admin/products/2 + - cell "active" [ref=e62] + - cell "Acme Apparel" [ref=e63] + - cell "Apparel" [ref=e64] + - cell "Edit" [ref=e65]: + - link "Edit" [ref=e66] [cursor=pointer]: + - /url: http://shop.test/admin/products/2 + - row "Organic Cotton T-Shirt active Acme Apparel Apparel Edit" [ref=e67]: + - cell [ref=e68]: + - checkbox [ref=e69] + - cell "Organic Cotton T-Shirt" [ref=e71]: + - link "Organic Cotton T-Shirt" [ref=e72] [cursor=pointer]: + - /url: http://shop.test/admin/products/1 + - cell "active" [ref=e73] + - cell "Acme Apparel" [ref=e74] + - cell "Apparel" [ref=e75] + - cell "Edit" [ref=e76]: + - link "Edit" [ref=e77] [cursor=pointer]: + - /url: http://shop.test/admin/products/1 \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-47-08-093Z.yml b/.playwright-mcp/page-2026-04-18T08-47-08-093Z.yml new file mode 100644 index 00000000..b55244ff --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-47-08-093Z.yml @@ -0,0 +1,104 @@ +- generic [ref=e2]: + - complementary [ref=e3]: + - generic [ref=e4]: Acme Fashion + - navigation [ref=e5]: + - link "Dashboard" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/admin + - link "Orders" [ref=e7] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - link "Products" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/admin/products + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - link "Customers" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - link "Discounts" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - link "Analytics" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - generic [ref=e13]: Content + - link "Pages" [ref=e14] [cursor=pointer]: + - /url: http://shop.test/admin/content/pages + - link "Navigation" [ref=e15] [cursor=pointer]: + - /url: http://shop.test/admin/content/navigation + - link "Themes" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/content/themes + - generic [ref=e17]: System + - link "Settings" [ref=e18] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - link "Search" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/search/settings + - link "Apps" [ref=e20] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - link "Developers" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - generic [ref=e22]: + - banner [ref=e23]: + - generic [ref=e25]: Admin + - generic [ref=e26]: + - paragraph [ref=e27]: owner@acme.test + - button "Sign out" [ref=e29]: + - img [ref=e31] + - generic [ref=e34]: Sign out + - main [ref=e35]: + - generic [ref=e36]: + - generic [ref=e37]: + - generic [ref=e38]: Shipping + - link "Back" [ref=e39] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - generic [ref=e40]: + - generic [ref=e41]: + - generic [ref=e42]: Zone name + - textbox "Zone name" [ref=e44] + - generic [ref=e45]: + - generic [ref=e46]: Countries (comma ISO-2) + - textbox "Countries (comma ISO-2)" [ref=e48] + - button "Add zone" [ref=e49]: + - img [ref=e51] + - generic [ref=e54]: Add zone + - generic [ref=e55]: + - generic [ref=e56]: + - generic [ref=e57]: Germany DE + - button "Remove zone" [ref=e58]: + - img [ref=e60] + - generic [ref=e63]: Remove zone + - list [ref=e64]: + - listitem [ref=e65]: + - generic [ref=e66]: Standard (flat) - 499 cents + - button "Remove" [ref=e67]: + - img [ref=e69] + - generic [ref=e72]: Remove + - generic [ref=e73]: + - textbox "Rate name" [ref=e75] + - combobox [ref=e76]: + - option "flat" [selected] + - option "weight" + - option "price" + - option "carrier" + - spinbutton [ref=e78] + - button "Add rate" [ref=e79]: + - img [ref=e81] + - generic [ref=e84]: Add rate + - generic [ref=e85]: + - generic [ref=e86]: + - generic [ref=e87]: Rest of World US, GB, FR, AT, CH, IT, ES, NL + - button "Remove zone" [ref=e88]: + - img [ref=e90] + - generic [ref=e93]: Remove zone + - list [ref=e94]: + - listitem [ref=e95]: + - generic [ref=e96]: International (flat) - 1499 cents + - button "Remove" [ref=e97]: + - img [ref=e99] + - generic [ref=e102]: Remove + - generic [ref=e103]: + - textbox "Rate name" [ref=e105] + - combobox [ref=e106]: + - option "flat" [selected] + - option "weight" + - option "price" + - option "carrier" + - spinbutton [ref=e108] + - button "Add rate" [ref=e109]: + - img [ref=e111] + - generic [ref=e114]: Add rate \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-47-18-629Z.yml b/.playwright-mcp/page-2026-04-18T08-47-18-629Z.yml new file mode 100644 index 00000000..916efc2f --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-47-18-629Z.yml @@ -0,0 +1,60 @@ +- generic [ref=e2]: + - complementary [ref=e3]: + - generic [ref=e4]: Acme Fashion + - navigation [ref=e5]: + - link "Dashboard" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/admin + - link "Orders" [ref=e7] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - link "Products" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/admin/products + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - link "Customers" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - link "Discounts" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - link "Analytics" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - generic [ref=e13]: Content + - link "Pages" [ref=e14] [cursor=pointer]: + - /url: http://shop.test/admin/content/pages + - link "Navigation" [ref=e15] [cursor=pointer]: + - /url: http://shop.test/admin/content/navigation + - link "Themes" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/content/themes + - generic [ref=e17]: System + - link "Settings" [ref=e18] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - link "Search" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/search/settings + - link "Apps" [ref=e20] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - link "Developers" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - generic [ref=e22]: + - banner [ref=e23]: + - generic [ref=e25]: Admin + - generic [ref=e26]: + - paragraph [ref=e27]: owner@acme.test + - button "Sign out" [ref=e29]: + - img [ref=e31] + - generic [ref=e34]: Sign out + - main [ref=e35]: + - generic [ref=e36]: + - generic [ref=e37]: + - generic [ref=e38]: Discounts + - link "New discount" [ref=e39] [cursor=pointer]: + - /url: http://shop.test/admin/discounts/new + - textbox "Search by code..." [ref=e41] + - table [ref=e44]: + - rowgroup [ref=e45]: + - row "Code Type Value Status Actions" [ref=e46]: + - columnheader "Code" [ref=e47] + - columnheader "Type" [ref=e48] + - columnheader "Value" [ref=e49] + - columnheader "Status" [ref=e50] + - columnheader "Actions" [ref=e51] + - rowgroup [ref=e52]: + - row "No discounts yet." [ref=e53]: + - cell "No discounts yet." [ref=e54] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-47-23-095Z.yml b/.playwright-mcp/page-2026-04-18T08-47-23-095Z.yml new file mode 100644 index 00000000..cf7e4975 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-47-23-095Z.yml @@ -0,0 +1,69 @@ +- generic [ref=e2]: + - complementary [ref=e3]: + - generic [ref=e4]: Acme Fashion + - navigation [ref=e5]: + - link "Dashboard" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/admin + - link "Orders" [ref=e7] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - link "Products" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/admin/products + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - link "Customers" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - link "Discounts" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - link "Analytics" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - generic [ref=e13]: Content + - link "Pages" [ref=e14] [cursor=pointer]: + - /url: http://shop.test/admin/content/pages + - link "Navigation" [ref=e15] [cursor=pointer]: + - /url: http://shop.test/admin/content/navigation + - link "Themes" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/content/themes + - generic [ref=e17]: System + - link "Settings" [ref=e18] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - link "Search" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/search/settings + - link "Apps" [ref=e20] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - link "Developers" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - generic [ref=e22]: + - banner [ref=e23]: + - generic [ref=e25]: Admin + - generic [ref=e26]: + - paragraph [ref=e27]: owner@acme.test + - button "Sign out" [ref=e29]: + - img [ref=e31] + - generic [ref=e34]: Sign out + - main [ref=e35]: + - generic [ref=e36]: + - generic [ref=e37]: + - generic [ref=e38]: Analytics + - generic [ref=e39]: + - generic [ref=e40]: + - generic [ref=e41]: From + - textbox "From" [ref=e43]: 2026-03-20 + - generic [ref=e45]: + - generic [ref=e46]: To + - textbox "To" [ref=e48]: 2026-04-18 + - generic [ref=e50]: + - generic [ref=e51]: + - paragraph [ref=e52]: Revenue + - generic [ref=e53]: "0.00" + - generic [ref=e54]: + - paragraph [ref=e55]: Orders + - generic [ref=e56]: "0" + - generic [ref=e57]: + - paragraph [ref=e58]: Visits + - generic [ref=e59]: "0" + - generic [ref=e60]: + - paragraph [ref=e61]: AOV + - generic [ref=e62]: "0.00" + - generic [ref=e63]: + - generic [ref=e64]: Daily breakdown + - paragraph [ref=e65]: No data yet. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T10-59-02-335Z.yml b/.playwright-mcp/page-2026-04-18T10-59-02-335Z.yml new file mode 100644 index 00000000..ddcd336e --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T10-59-02-335Z.yml @@ -0,0 +1,59 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: https://2026-04-16-claude-code-opus-4-7-xhigh.agentic-engineers.dev + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: https://2026-04-16-claude-code-opus-4-7-xhigh.agentic-engineers.dev/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: https://2026-04-16-claude-code-opus-4-7-xhigh.agentic-engineers.dev/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: https://2026-04-16-claude-code-opus-4-7-xhigh.agentic-engineers.dev/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: https://2026-04-16-claude-code-opus-4-7-xhigh.agentic-engineers.dev/cart + - main [ref=e12]: + - generic [ref=e13]: + - generic [ref=e14]: + - generic [ref=e15]: Elevated essentials + - paragraph [ref=e16]: Timeless pieces for modern wardrobes. + - link "Shop the collection" [ref=e18] [cursor=pointer]: + - /url: /collections + - generic [ref=e19]: + - generic [ref=e20]: Featured collections + - link "Featured" [ref=e22] [cursor=pointer]: + - /url: https://2026-04-16-claude-code-opus-4-7-xhigh.agentic-engineers.dev/collections/featured + - generic [ref=e23]: Featured + - generic [ref=e24]: + - generic [ref=e25]: Featured products + - generic [ref=e26]: + - link "Organic Cotton T-Shirt" [ref=e28] [cursor=pointer]: + - /url: https://2026-04-16-claude-code-opus-4-7-xhigh.agentic-engineers.dev/products/organic-cotton-t-shirt + - img [ref=e31] + - generic [ref=e34]: Organic Cotton T-Shirt + - link "Classic Pullover Hoodie" [ref=e36] [cursor=pointer]: + - /url: https://2026-04-16-claude-code-opus-4-7-xhigh.agentic-engineers.dev/products/classic-pullover-hoodie + - img [ref=e39] + - generic [ref=e42]: Classic Pullover Hoodie + - contentinfo [ref=e43]: + - generic [ref=e44]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 296f2af0..30ccb8ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,3 +23,227 @@ The complete specification is in `specs/`. Start with `specs/09-IMPLEMENTATION-R - `specs/07-SEEDERS-AND-TEST-DATA.md` - Seeders and test data - `specs/08-PLAYWRIGHT-E2E-PLAN.md` - E2E browser tests - `specs/09-IMPLEMENTATION-ROADMAP.md` - Implementation roadmap + +=== + + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications. + +## Foundational Context + +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.4 +- laravel/fortify (FORTIFY) - v1 +- laravel/framework (LARAVEL) - v12 +- laravel/prompts (PROMPTS) - v0 +- 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. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. + +- `developing-with-fortify` — 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-best-practices` — 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. +- `fluxui-development` — 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. +- `livewire-development` — 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. +- `pest-testing` — 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. +- `tailwindcss-development` — 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. + +## Conventions + +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts + +- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important. + +## Application Structure & Architecture + +- Stick to existing directory structure; don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling + +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Documentation Files + +- You must only create documentation files if explicitly requested by the user. + +## Replies + +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +=== boost rules === + +# Laravel Boost + +## Tools + +- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads. +- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker. +- Use `database-schema` to inspect table structure before writing migrations or models. +- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user. +- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries. + +## Searching Documentation (IMPORTANT) + +- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically. +- Pass a `packages` array to scope results when you know which packages are relevant. +- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first. +- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`. + +### Search Syntax + +1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit". +2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order. +3. Combine words and phrases for mixed queries: `middleware "rate limit"`. +4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`. + +## Artisan + +- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. +- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`. +- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory. +- To check environment variables, read the `.env` file directly. + +## Tinker + +- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code. +- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'` + - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` + +=== php rules === + +# PHP + +- Always use curly braces for control structures, even for single-line bodies. +- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private. +- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool` +- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`. +- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic. +- Use array shape type definitions in PHPDoc blocks. + +=== deployments rules === + +# Deployment + +- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications. + +=== herd rules === + +# Laravel Herd + +- The application is served by Laravel Herd at `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs. Never run commands to serve the site. It is always available. +- Use the `herd` CLI to manage services, PHP versions, and sites (e.g. `herd sites`, `herd services:start `, `herd php:list`). Run `herd list` to discover all available commands. + +=== tests rules === + +# Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. + +=== fortify/core rules === + +# Laravel Fortify + +- Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications. +- IMPORTANT: Always use the `search-docs` tool for detailed Laravel Fortify patterns and documentation. +- IMPORTANT: Activate `developing-with-fortify` skill when working with Fortify authentication features. + +=== laravel/core rules === + +# Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`. +- If you're creating a generic PHP class, use `php artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Model Creation + +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options. + +## APIs & Eloquent Resources + +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +## URL Generation + +- When generating links to other pages, prefer named routes and the `route()` function. + +## Testing + +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +## Vite Error + +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + +=== laravel/v12 rules === + +# Laravel 12 + +- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +## Laravel 12 Structure + +- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`. +- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- The `app/Console/Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. +- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration. + +## Database + +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models + +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + +=== livewire/core rules === + +# Livewire + +- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript. +- You can use Alpine.js for client-side interactions instead of JavaScript frameworks. +- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests. + +=== pint/core rules === + +# Laravel Pint Code Formatter + +- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues. + +=== pest/core rules === + +## Pest + +- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`. +- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`. +- Do NOT delete tests without approval. + + diff --git a/README.md b/README.md new file mode 100644 index 00000000..e55cdfa0 --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +/goal Build the complete Laravel shop system from specs/* until all acceptance criteria are implemented, independently verified, and documented. + +Persistent goal mode. Continue until DONE WHEN is fully satisfied or a real blocker occurs. Do not stop early or ask for next steps. + +--- + +GOAL +Fully working shop system based on specs/*. + +--- + +SOURCE OF TRUTH +- Requirements: specs/* +- Progress: specs/progress.md +- App: http://shop.test/ +- Follow existing Laravel architecture unless specs require otherwise. + +--- + +CORE RULES +- Build vertical slices (end-to-end). +- Keep system runnable after each slice. +- No partial implementations. +- Prefer simple, idiomatic Laravel. +- Verification must be evidence-based. +- Use sub-agents for build + independent QA. +- You own final integration and quality. + +--- + +DONE WHEN +All must be true: + +1. All specs/* requirements implemented. +2. Each acceptance criterion mapped to evidence. +3. Pest tests pass. +4. Playwright MCP verifies key customer + admin flows. +5. Code quality checks pass (e.g. pint, phpstan/larastan if added). +6. No critical/high bugs after independent QA. +7. specs/progress.md includes: + - plan, status + - acceptance checklist + - verification evidence + - decisions + open issues + - completion summary +8. Meaningful commits exist. + +--- + +LOOP (REPEAT UNTIL DONE) + +0. PLAN +- Read specs/* +- Extract all acceptance criteria +- Build phased plan in specs/progress.md +- Include dependencies, risks, verification strategy +- Let a planning sub-agent challenge gaps, then refine + +1. IMPLEMENT +- Build next vertical slice using sub-agents: + backend, frontend, integration +- Include logic, UI, tests, real flow + +2. VERIFY (OBJECTIVE) + Run when applicable: +- pest / php artisan test +- pint +- phpstan/larastan (install if useful) +- build/tests for frontend +- Playwright MCP flows + +3. INDEPENDENT QA + Use separate sub-agents: +- QA analyst: compare vs specs, find missing criteria +- QA engineer: verify flows, edge cases +- code reviewer: Laravel conventions, security, validation, structure + +They must look for failures, not confirm success. + +4. EVALUATE + Mark each acceptance criterion: +- done + verified +- partial +- missing + Continue if anything not fully verified. + +5. FIX + Resolve all issues, re-run verification. + +6. TRACK + Update specs/progress.md: +- changes, decisions +- tests + flows run +- QA findings +- open gaps +- next slice + +7. COMMIT + Commit meaningful progress. + +--- + +ANTI-BIAS RULE + +For final validation: +- Use fresh QA analyst (only specs + current state) +- Use fresh QA engineer (Playwright MCP) +- Use fresh code reviewer + +Fix all critical/high findings. +Do not rely on self-judgment. + +--- + +QUALITY BAR +- Idiomatic Laravel +- Clear structure +- Validation + authorization +- Safe DB + transactions +- No dead code +- No duplicated logic +- No ignored failures +- Maintainable + testable + +--- + +FAILURE HANDLING +If stuck: +- re-evaluate plan +- simplify slice +- try alternative approach +- document in specs/progress.md + +If context grows: +- compress state into specs/progress.md +- continue from there + +--- + +PRIORITY +1. Verified acceptance criteria +2. Functional correctness +3. Browser flows +4. Tests + checks +5. Code quality + +--- + +Never stop until DONE WHEN is fully satisfied. diff --git a/app/Actions/SanitizeHtml.php b/app/Actions/SanitizeHtml.php new file mode 100644 index 00000000..f17657fd --- /dev/null +++ b/app/Actions/SanitizeHtml.php @@ -0,0 +1,280 @@ +> + */ + private const array AllowedElements = [ + 'p' => [], + 'br' => [], + 'strong' => [], + 'em' => [], + 'u' => [], + 'ol' => [], + 'ul' => [], + 'li' => [], + 'a' => ['href'], + 'img' => ['src', 'alt'], + 'h1' => [], + 'h2' => [], + 'h3' => [], + 'h4' => [], + 'h5' => [], + 'h6' => [], + 'blockquote' => [], + 'table' => [], + 'thead' => [], + 'tbody' => [], + 'tr' => [], + 'th' => [], + 'td' => [], + 'div' => [], + 'span' => [], + ]; + + /** + * @var list + */ + private const array DangerousElements = [ + 'base', + 'button', + 'canvas', + 'embed', + 'form', + 'iframe', + 'input', + 'link', + 'math', + 'meta', + 'object', + 'script', + 'select', + 'style', + 'svg', + 'textarea', + ]; + + /** + * @var list + */ + private const array EmptyAllowedElements = ['br', 'img']; + + public function __invoke(?string $html): ?string + { + if ($html === null) { + return null; + } + + $html = trim($html); + + if ($html === '') { + return ''; + } + + $document = new DOMDocument('1.0', 'UTF-8'); + $previous = libxml_use_internal_errors(true); + + try { + $document->loadHTML($this->wrapHtml($html), LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); + $root = $this->rootElement($document); + + if (! $root instanceof DOMElement) { + return ''; + } + + $this->sanitizeChildren($root); + $this->removeEmptyElements($root); + + return trim($this->innerHtml($root)); + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + } + + private function wrapHtml(string $html): string + { + return '
'.$html.'
'; + } + + private function rootElement(DOMDocument $document): ?DOMElement + { + $root = (new DOMXPath($document))->query('//*[@id="__sanitize_html_root"]')?->item(0); + + return $root instanceof DOMElement ? $root : null; + } + + private function sanitizeChildren(DOMNode $parent): void + { + foreach ($this->childNodes($parent) as $child) { + if ($child instanceof DOMElement) { + $this->sanitizeElement($child); + + continue; + } + + if ($child->nodeType !== XML_TEXT_NODE) { + $parent->removeChild($child); + } + } + } + + private function sanitizeElement(DOMElement $element): void + { + $tagName = strtolower($element->tagName); + + if (in_array($tagName, self::DangerousElements, true)) { + $element->parentNode?->removeChild($element); + + return; + } + + if (! array_key_exists($tagName, self::AllowedElements)) { + $this->sanitizeChildren($element); + $this->unwrapElement($element); + + return; + } + + $this->sanitizeAttributes($element, self::AllowedElements[$tagName]); + $this->sanitizeChildren($element); + } + + /** + * @param list $allowedAttributes + */ + private function sanitizeAttributes(DOMElement $element, array $allowedAttributes): void + { + foreach ($this->attributeNames($element) as $attributeName) { + $normalizedName = strtolower($attributeName); + + if (! in_array($normalizedName, $allowedAttributes, true)) { + $element->removeAttribute($attributeName); + + continue; + } + + $value = trim($element->getAttribute($attributeName)); + + if ($this->isUrlAttribute($normalizedName) && ! $this->isSafeUrl($normalizedName, $value)) { + $element->removeAttribute($attributeName); + + continue; + } + + $element->setAttribute($normalizedName, $value); + } + } + + private function isUrlAttribute(string $attributeName): bool + { + return in_array($attributeName, ['href', 'src'], true); + } + + private function isSafeUrl(string $attributeName, string $value): bool + { + if ($value === '' || preg_match('/[\x00-\x1F\x7F]/', $value) === 1) { + return false; + } + + $scheme = parse_url($value, PHP_URL_SCHEME); + + if ($scheme === null) { + return true; + } + + $allowedSchemes = $attributeName === 'href' + ? ['http', 'https', 'mailto', 'tel'] + : ['http', 'https']; + + return in_array(strtolower($scheme), $allowedSchemes, true); + } + + private function unwrapElement(DOMElement $element): void + { + $parent = $element->parentNode; + + if (! $parent instanceof DOMNode) { + return; + } + + while ($element->firstChild instanceof DOMNode) { + $parent->insertBefore($element->firstChild, $element); + } + + $parent->removeChild($element); + } + + private function removeEmptyElements(DOMNode $parent): void + { + foreach ($this->childNodes($parent) as $child) { + if (! $child instanceof DOMElement) { + continue; + } + + $this->removeEmptyElements($child); + + if ($this->isEmptyElement($child)) { + $child->parentNode?->removeChild($child); + } + } + } + + private function isEmptyElement(DOMElement $element): bool + { + $tagName = strtolower($element->tagName); + + if ($tagName === 'br') { + return false; + } + + if ($tagName === 'img') { + return ! $element->hasAttribute('src'); + } + + foreach ($element->childNodes as $child) { + if ($child instanceof DOMElement) { + return false; + } + + if ($child->nodeType === XML_TEXT_NODE && trim((string) $child->textContent) !== '') { + return false; + } + } + + return true; + } + + /** + * @return list + */ + private function childNodes(DOMNode $node): array + { + return iterator_to_array($node->childNodes); + } + + /** + * @return list + */ + private function attributeNames(DOMElement $element): array + { + return collect($element->attributes) + ->map(fn ($attribute): string => $attribute->nodeName) + ->all(); + } + + private function innerHtml(DOMElement $element): string + { + return collect($element->childNodes) + ->map(fn (DOMNode $child): string => $element->ownerDocument?->saveHTML($child) ?: '') + ->implode(''); + } +} diff --git a/app/Auth/CustomerUserProvider.php b/app/Auth/CustomerUserProvider.php new file mode 100644 index 00000000..073966e9 --- /dev/null +++ b/app/Auth/CustomerUserProvider.php @@ -0,0 +1,46 @@ + $credentials + */ + public function retrieveByCredentials(array $credentials): ?Authenticatable + { + $credentials = array_filter( + $credentials, + fn (mixed $value, string $key): bool => ! str_contains($key, 'password') && $value !== null, + ARRAY_FILTER_USE_BOTH, + ); + + if ($credentials === []) { + return null; + } + + $query = $this->newModelQuery(); + + foreach ($credentials as $key => $value) { + if (is_array($value) || $value instanceof \Closure) { + continue; + } + + $query->where($key, $value); + } + + $store = app()->bound('current_store') ? app('current_store') : null; + + if (! $store instanceof Store) { + return null; + } + + $query->where('store_id', $store->getKey()); + + return $query->first(); + } +} diff --git a/app/Contracts/PaymentProvider.php b/app/Contracts/PaymentProvider.php new file mode 100644 index 00000000..9a290669 --- /dev/null +++ b/app/Contracts/PaymentProvider.php @@ -0,0 +1,19 @@ + $paymentMethodData + */ + public function charge(Checkout $checkout, PaymentMethod $method, array $paymentMethodData = []): PaymentResult; + + public function refund(Payment $payment, int $amount): RefundResult; +} diff --git a/app/Contracts/TaxProvider.php b/app/Contracts/TaxProvider.php new file mode 100644 index 00000000..8a4451a9 --- /dev/null +++ b/app/Contracts/TaxProvider.php @@ -0,0 +1,15 @@ + $amounts + * @param array $address + */ + public function calculate(array $amounts, int $shippingAmount, TaxSettings $settings, array $address): TaxResult; +} diff --git a/app/Enums/AnalyticsEventType.php b/app/Enums/AnalyticsEventType.php new file mode 100644 index 00000000..32d101de --- /dev/null +++ b/app/Enums/AnalyticsEventType.php @@ -0,0 +1,14 @@ + 'Active', + self::Suspended => 'Suspended', + self::Uninstalled => 'Uninstalled', + }; + } + + public function badgeColor(): string + { + return match ($this) { + self::Active => 'green', + self::Suspended => 'amber', + self::Uninstalled => 'zinc', + }; + } +} diff --git a/app/Enums/AppStatus.php b/app/Enums/AppStatus.php new file mode 100644 index 00000000..5e69fdb6 --- /dev/null +++ b/app/Enums/AppStatus.php @@ -0,0 +1,17 @@ + 'Active', + self::Disabled => 'Disabled', + }; + } +} diff --git a/app/Enums/CartStatus.php b/app/Enums/CartStatus.php new file mode 100644 index 00000000..56a92071 --- /dev/null +++ b/app/Enums/CartStatus.php @@ -0,0 +1,10 @@ + 'Pending', + self::Success => 'Success', + self::Failed => 'Failed', + }; + } + + public function badgeColor(): string + { + return match ($this) { + self::Pending => 'blue', + self::Success => 'green', + self::Failed => 'red', + }; + } +} diff --git a/app/Enums/WebhookEventType.php b/app/Enums/WebhookEventType.php new file mode 100644 index 00000000..68cc95b6 --- /dev/null +++ b/app/Enums/WebhookEventType.php @@ -0,0 +1,58 @@ + 'Order created', + self::OrderPaid => 'Order paid', + self::OrderUpdated => 'Order updated', + self::OrderCancelled => 'Order cancelled', + self::OrderFulfilled => 'Order fulfilled', + self::OrderRefunded => 'Order refunded', + self::ProductCreated => 'Product created', + self::ProductUpdated => 'Product updated', + self::ProductDeleted => 'Product deleted', + self::CustomerCreated => 'Customer created', + self::CheckoutCompleted => 'Checkout completed', + self::FulfillmentCreated => 'Fulfillment created', + self::RefundCreated => 'Refund created', + }; + } + + /** + * @return list + */ + public static function selectable(): array + { + return [ + self::OrderCreated, + self::OrderUpdated, + self::OrderCancelled, + self::ProductCreated, + self::ProductUpdated, + self::ProductDeleted, + self::CustomerCreated, + self::CheckoutCompleted, + self::FulfillmentCreated, + self::RefundCreated, + ]; + } +} diff --git a/app/Enums/WebhookSubscriptionStatus.php b/app/Enums/WebhookSubscriptionStatus.php new file mode 100644 index 00000000..eb19ab0e --- /dev/null +++ b/app/Enums/WebhookSubscriptionStatus.php @@ -0,0 +1,28 @@ + 'Active', + self::Paused => 'Paused', + self::Disabled => 'Disabled', + }; + } + + public function badgeColor(): string + { + return match ($this) { + self::Active => 'green', + self::Paused => 'amber', + self::Disabled => 'zinc', + }; + } +} diff --git a/app/Events/CheckoutCompleted.php b/app/Events/CheckoutCompleted.php new file mode 100644 index 00000000..f980d198 --- /dev/null +++ b/app/Events/CheckoutCompleted.php @@ -0,0 +1,18 @@ +errorCode ?? 'payment_failed', + $result->errorMessage ?? 'Payment could not be processed.', + ); + } +} diff --git a/app/Exceptions/UnserviceableShippingAddressException.php b/app/Exceptions/UnserviceableShippingAddressException.php new file mode 100644 index 00000000..ba2b5144 --- /dev/null +++ b/app/Exceptions/UnserviceableShippingAddressException.php @@ -0,0 +1,13 @@ +authorizeStore($request, $store); + + $validated = $request->validate([ + 'from' => ['required', 'date_format:Y-m-d'], + 'to' => ['required', 'date_format:Y-m-d', 'after_or_equal:from'], + 'granularity' => ['nullable', Rule::in(['day', 'week', 'month'])], + ]); + + $from = Carbon::createFromFormat('Y-m-d', (string) $validated['from'])->startOfDay(); + $to = Carbon::createFromFormat('Y-m-d', (string) $validated['to'])->endOfDay(); + + if ($from->diffInDays($to) > 365) { + throw ValidationException::withMessages([ + 'to' => __('The selected date range may not be greater than 365 days.'), + ]); + } + + $totals = $analytics->totals($store, $from->toDateString(), $to->toDateString()); + + return response()->json([ + 'data' => [ + 'period' => [ + 'from' => $from->toDateString(), + 'to' => $to->toDateString(), + ], + 'summary' => [ + 'orders_count' => $totals['orders_count'], + 'revenue_amount' => $totals['revenue_amount'], + 'aov_amount' => $totals['aov_amount'], + 'visits_count' => $totals['visits_count'], + 'add_to_cart_count' => $totals['add_to_cart_count'], + 'checkout_started_count' => $totals['checkout_started_count'], + 'conversion_rate' => $totals['visits_count'] > 0 ? round($totals['checkout_completed_count'] / $totals['visits_count'], 4) : 0.0, + 'currency' => $store->default_currency, + ], + 'daily' => $this->metricRows($analytics, $store, $from, $to, $validated['granularity'] ?? 'day'), + 'top_products' => $this->topProducts($store, $from, $to), + ], + ]); + } + + private function authorizeStore(Request $request, Store $store): void + { + if (! $request->attributes->has('sanctum_personal_access_token')) { + abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403); + } + + app()->instance('current_store', $store); + } + + /** + * @return list + */ + private function metricRows(AnalyticsService $analytics, Store $store, CarbonInterface $from, CarbonInterface $to, string $granularity): array + { + $metrics = $analytics->getDailyMetrics($store, $from->toDateString(), $to->toDateString()); + + if ($granularity === 'day') { + $metricsByDate = $metrics->keyBy('date'); + + return collect(CarbonPeriod::create($from->copy()->startOfDay(), '1 day', $to->copy()->startOfDay())) + ->map(fn (CarbonInterface $date): array => $this->metricRow($date->toDateString(), collect([$metricsByDate->get($date->toDateString())])->filter())) + ->values() + ->all(); + } + + return $metrics + ->groupBy(fn ($metric): string => $this->periodStart((string) $metric->date, $granularity)) + ->map(fn (Collection $rows, string $date): array => $this->metricRow($date, $rows)) + ->values() + ->all(); + } + + /** + * @param Collection $metrics + * @return array{date: string, orders_count: int, revenue_amount: int, aov_amount: int, visits_count: int, add_to_cart_count: int, checkout_started_count: int} + */ + private function metricRow(string $date, Collection $metrics): array + { + $orders = (int) $metrics->sum('orders_count'); + $revenue = (int) $metrics->sum('revenue_amount'); + + return [ + 'date' => $date, + 'orders_count' => $orders, + 'revenue_amount' => $revenue, + 'aov_amount' => $orders > 0 ? intdiv($revenue, $orders) : 0, + 'visits_count' => (int) $metrics->sum('visits_count'), + 'add_to_cart_count' => (int) $metrics->sum('add_to_cart_count'), + 'checkout_started_count' => (int) $metrics->sum('checkout_started_count'), + ]; + } + + private function periodStart(string $date, string $granularity): string + { + $date = Carbon::parse($date); + + return match ($granularity) { + 'week' => $date->startOfWeek()->toDateString(), + 'month' => $date->startOfMonth()->toDateString(), + default => $date->toDateString(), + }; + } + + /** + * @return list + */ + private function topProducts(Store $store, CarbonInterface $from, CarbonInterface $to): array + { + return OrderLine::query() + ->selectRaw('order_lines.product_id, order_lines.title_snapshot as title, sum(order_lines.quantity) as units_sold, sum(order_lines.total_amount) as revenue_amount') + ->join('orders', 'orders.id', '=', 'order_lines.order_id') + ->where('orders.store_id', $store->getKey()) + ->whereBetween('orders.placed_at', [$from, $to]) + ->whereIn('orders.financial_status', [ + FinancialStatus::Paid->value, + FinancialStatus::PartiallyRefunded->value, + ]) + ->groupBy('order_lines.product_id', 'order_lines.title_snapshot') + ->orderByDesc('revenue_amount') + ->limit(10) + ->get() + ->map(fn (OrderLine $line): array => [ + 'product_id' => $line->product_id === null ? null : (int) $line->product_id, + 'title' => (string) $line->title, + 'units_sold' => (int) $line->units_sold, + 'revenue_amount' => (int) $line->revenue_amount, + ]) + ->all(); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/CollectionController.php b/app/Http/Controllers/Api/Admin/V1/CollectionController.php new file mode 100644 index 00000000..9a4a3744 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/CollectionController.php @@ -0,0 +1,247 @@ +authorizeStore($request, $store); + abort_unless($request->user()?->can('viewAny', Collection::class), 403); + + $validated = $request->validate([ + 'status' => ['nullable', Rule::in(['draft', 'active', 'archived'])], + 'query' => ['nullable', 'string', 'max:255'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $collections = Collection::withoutGlobalScopes() + ->withCount('products') + ->where('store_id', $store->getKey()) + ->when(data_get($validated, 'status'), fn (Builder $query, string $status) => $query->where('status', $status)) + ->when(data_get($validated, 'query'), function (Builder $query, string $search): void { + $query->where('title', 'like', '%'.$search.'%'); + }) + ->latest('updated_at') + ->latest('id') + ->paginate((int) data_get($validated, 'per_page', 25)); + + return CollectionResource::collection($collections); + } + + public function store(Request $request, Store $store): JsonResponse + { + $this->authorizeStore($request, $store); + abort_unless($request->user()?->can('create', Collection::class), 403); + + $validated = $this->validatePayload($request, $store); + + $collection = DB::transaction(function () use ($store, $validated): Collection { + $collection = Collection::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + ...$this->attributesForCreate($validated), + ]); + + if (array_key_exists('product_ids', $validated)) { + $this->replaceProducts($collection, $validated['product_ids']); + } + + return $collection; + }); + + return CollectionResource::make($this->loadCollection($collection)) + ->response() + ->setStatusCode(201); + } + + public function update(Request $request, Store $store, Collection $collection): CollectionResource + { + $this->authorizeStore($request, $store); + $this->abortUnlessCollectionBelongsToStore($collection, $store); + abort_unless($request->user()?->can('update', $collection), 403); + + $validated = $this->validatePayload($request, $store, $collection); + + DB::transaction(function () use ($collection, $validated): void { + $attributes = $this->attributesForUpdate($validated); + + if ($attributes !== []) { + $collection->update($attributes); + } + + if (array_key_exists('product_ids', $validated)) { + $this->replaceProducts($collection, $validated['product_ids']); + } else { + $this->addProducts($collection, $validated['add_product_ids'] ?? []); + $this->removeProducts($collection, $validated['remove_product_ids'] ?? []); + } + }); + + return CollectionResource::make($this->loadCollection($collection->refresh())); + } + + public function destroy(Request $request, Store $store, Collection $collection): JsonResponse + { + $this->authorizeStore($request, $store); + $this->abortUnlessCollectionBelongsToStore($collection, $store); + abort_unless($request->user()?->can('delete', $collection), 403); + + $collection->delete(); + + return response()->json(['message' => 'Collection deleted']); + } + + private function authorizeStore(Request $request, Store $store): void + { + if (! $request->attributes->has('sanctum_personal_access_token')) { + abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403); + } + + app()->instance('current_store', $store); + } + + private function abortUnlessCollectionBelongsToStore(Collection $collection, Store $store): void + { + abort_unless((int) $collection->store_id === $store->getKey(), 404); + } + + /** + * @return array + */ + private function validatePayload(Request $request, Store $store, ?Collection $collection = null): array + { + $creating = $collection === null; + + return $request->validate([ + 'title' => [$creating ? 'required' : 'sometimes', 'string', 'max:255'], + 'handle' => [ + 'sometimes', + 'nullable', + 'string', + 'max:255', + Rule::unique('collections', 'handle') + ->where('store_id', $store->getKey()) + ->ignore($collection?->getKey()), + ], + 'description_html' => ['sometimes', 'nullable', 'string', 'max:65535'], + 'type' => [$creating ? 'required' : 'sometimes', Rule::in(['manual', 'automated'])], + 'status' => ['sometimes', Rule::in(['draft', 'active', 'archived'])], + 'product_ids' => ['sometimes', 'array'], + 'product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $store->getKey())], + 'add_product_ids' => ['sometimes', 'array'], + 'add_product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $store->getKey())], + 'remove_product_ids' => ['sometimes', 'array'], + 'remove_product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $store->getKey())], + ]); + } + + /** + * @param array $validated + * @return array + */ + private function attributesForCreate(array $validated): array + { + $title = (string) $validated['title']; + $handle = filled($validated['handle'] ?? null) ? (string) $validated['handle'] : $title; + + return [ + 'title' => $title, + 'handle' => Str::slug($handle), + 'description_html' => $this->sanitizeHtml($validated['description_html'] ?? null), + 'type' => $validated['type'], + 'status' => $validated['status'] ?? 'active', + ]; + } + + /** + * @param array $validated + * @return array + */ + private function attributesForUpdate(array $validated): array + { + $attributes = Arr::only($validated, ['title', 'description_html', 'type', 'status']); + + if (array_key_exists('description_html', $attributes)) { + $attributes['description_html'] = $this->sanitizeHtml($attributes['description_html']); + } + + if (array_key_exists('handle', $validated) && filled($validated['handle'])) { + $attributes['handle'] = Str::slug((string) $validated['handle']); + } + + return $attributes; + } + + /** + * @param array $productIds + */ + private function replaceProducts(Collection $collection, array $productIds): void + { + $collection->products()->sync($this->positionedProductIds($productIds)); + } + + /** + * @param array $productIds + */ + private function addProducts(Collection $collection, array $productIds): void + { + if ($productIds === []) { + return; + } + + $startPosition = (int) $collection->products()->max('collection_products.position') + 1; + + $collection->products()->syncWithoutDetaching($this->positionedProductIds($productIds, $startPosition)); + } + + /** + * @param array $productIds + */ + private function removeProducts(Collection $collection, array $productIds): void + { + if ($productIds === []) { + return; + } + + $collection->products()->detach($productIds); + } + + /** + * @param array $productIds + * @return array + */ + private function positionedProductIds(array $productIds, int $startPosition = 0): array + { + return collect($productIds) + ->unique() + ->values() + ->mapWithKeys(fn (int $productId, int $position): array => [$productId => ['position' => $startPosition + $position]]) + ->all(); + } + + private function loadCollection(Collection $collection): Collection + { + return $collection->load('products')->loadCount('products'); + } + + private function sanitizeHtml(?string $html): ?string + { + $sanitized = app(SanitizeHtml::class)($html); + + return $sanitized === '' ? null : $sanitized; + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/CustomerController.php b/app/Http/Controllers/Api/Admin/V1/CustomerController.php new file mode 100644 index 00000000..70493ad8 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/CustomerController.php @@ -0,0 +1,77 @@ +authorizeStore($request, $store); + + $validated = $request->validate([ + 'query' => ['nullable', 'string', 'max:255'], + 'marketing_opt_in' => ['nullable', 'boolean'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $customers = Customer::withoutGlobalScopes() + ->withCount('orders') + ->withSum('orders as total_spent_amount', 'total_amount') + ->where('store_id', $store->getKey()) + ->when(data_get($validated, 'query'), function (Builder $query, string $search): void { + $like = '%'.$search.'%'; + + $query->where(function (Builder $query) use ($like): void { + $query + ->where('email', 'like', $like) + ->orWhere('name', 'like', $like); + }); + }) + ->when(array_key_exists('marketing_opt_in', $validated), fn (Builder $query) => $query->where('marketing_opt_in', (bool) $validated['marketing_opt_in'])) + ->latest('created_at') + ->latest('id') + ->paginate((int) data_get($validated, 'per_page', 25)); + + return CustomerResource::collection($customers); + } + + public function show(Request $request, Store $store, Customer $customer): CustomerResource + { + $this->authorizeStore($request, $store); + $this->abortUnlessCustomerBelongsToStore($customer, $store); + + return CustomerResource::make($this->loadCustomer($customer)); + } + + private function authorizeStore(Request $request, Store $store): void + { + if (! $request->attributes->has('sanctum_personal_access_token')) { + abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403); + } + + app()->instance('current_store', $store); + } + + private function abortUnlessCustomerBelongsToStore(Customer $customer, Store $store): void + { + abort_unless((int) $customer->store_id === $store->getKey(), 404); + } + + private function loadCustomer(Customer $customer): Customer + { + return $customer->load([ + 'addresses', + 'orders' => fn ($query) => $query->latest('placed_at')->limit(10), + ]) + ->loadCount('orders') + ->loadSum('orders as total_spent_amount', 'total_amount'); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/DiscountController.php b/app/Http/Controllers/Api/Admin/V1/DiscountController.php new file mode 100644 index 00000000..ba336038 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/DiscountController.php @@ -0,0 +1,269 @@ +authorizeStore($request, $store); + + $validated = $request->validate([ + 'type' => ['nullable', Rule::in(['code', 'automatic'])], + 'status' => ['nullable', Rule::in(['active', 'expired', 'scheduled'])], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $discounts = Discount::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->when(data_get($validated, 'type'), fn (Builder $query, string $type) => $query->where('type', $type)) + ->when(data_get($validated, 'status'), fn (Builder $query, string $status) => $this->applyComputedStatus($query, $status)) + ->latest('created_at') + ->latest('id') + ->paginate((int) data_get($validated, 'per_page', 25)); + + return DiscountResource::collection($discounts); + } + + public function store(Request $request, Store $store): JsonResponse + { + $this->authorizeStore($request, $store); + + $validated = $this->validatePayload($request, $store); + + $discount = Discount::withoutGlobalScopes()->create($this->attributesForCreate($validated, $store)); + + return DiscountResource::make($discount) + ->response() + ->setStatusCode(201); + } + + public function update(Request $request, Store $store, Discount $discount): DiscountResource + { + $this->authorizeStore($request, $store); + $this->abortUnlessDiscountBelongsToStore($discount, $store); + + $validated = $this->validatePayload($request, $store, $discount); + + $discount->update($this->attributesForUpdate($validated, $discount)); + + return DiscountResource::make($discount->refresh()); + } + + public function destroy(Request $request, Store $store, Discount $discount): JsonResponse + { + $this->authorizeStore($request, $store); + $this->abortUnlessDiscountBelongsToStore($discount, $store); + + $discount->delete(); + + return response()->json(['message' => 'Discount deleted']); + } + + private function authorizeStore(Request $request, Store $store): void + { + if (! $request->attributes->has('sanctum_personal_access_token')) { + abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403); + } + + app()->instance('current_store', $store); + } + + private function abortUnlessDiscountBelongsToStore(Discount $discount, Store $store): void + { + abort_unless((int) $discount->store_id === $store->getKey(), 404); + } + + private function applyComputedStatus(Builder $query, string $status): void + { + match ($status) { + 'active' => $query + ->where('status', 'active') + ->where('starts_at', '<=', now()) + ->where(fn (Builder $query) => $query->whereNull('ends_at')->orWhere('ends_at', '>', now())), + 'expired' => $query->where(fn (Builder $query) => $query->where('status', 'expired')->orWhere('ends_at', '<=', now())), + 'scheduled' => $query->where('starts_at', '>', now()), + }; + } + + /** + * @return array + */ + private function validatePayload(Request $request, Store $store, ?Discount $discount = null): array + { + $creating = $discount === null; + $type = (string) $request->input('type', $discount?->type->value ?? 'code'); + $valueType = (string) $request->input('value_type', $discount?->value_type->value ?? 'percent'); + + $validated = $request->validate([ + 'type' => [$creating ? 'required' : 'sometimes', Rule::in(['code', 'automatic'])], + 'code' => [ + Rule::requiredIf($creating && $type === 'code'), + 'nullable', + 'string', + 'max:50', + function (string $attribute, mixed $value, \Closure $fail) use ($store, $discount): void { + if ($value === null || trim((string) $value) === '') { + return; + } + + $normalized = Str::upper(trim((string) $value)); + + if ($discount instanceof Discount && $normalized !== (string) $discount->code) { + $fail(__('The discount code cannot be changed after creation.')); + + return; + } + + $exists = Discount::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->whereRaw('lower(code) = ?', [Str::lower($normalized)]) + ->when($discount instanceof Discount, fn (Builder $query) => $query->whereKeyNot($discount->getKey())) + ->exists(); + + if ($exists) { + $fail(__('The discount code has already been taken.')); + } + }, + ], + 'value_type' => [$creating ? 'required' : 'sometimes', Rule::in(['fixed', 'percent', 'free_shipping'])], + 'value_amount' => $this->valueAmountRules($creating, $valueType), + 'starts_at' => ['sometimes', 'nullable', 'date'], + 'ends_at' => [ + 'sometimes', + 'nullable', + 'date', + function (string $attribute, mixed $value, \Closure $fail) use ($request, $discount): void { + if ($value === null || $value === '') { + return; + } + + $startsAt = $request->input('starts_at', $discount?->starts_at ?? now()); + + if (Carbon::parse($value)->lte(Carbon::parse($startsAt))) { + $fail(__('The end date must be after the start date.')); + } + }, + ], + 'usage_limit' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'status' => ['sometimes', Rule::in(['draft', 'active', 'expired', 'disabled'])], + 'rules_json' => ['sometimes', 'array'], + 'rules_json.minimum_purchase_amount' => ['nullable', 'integer', 'min:0'], + 'rules_json.min_purchase_amount' => ['nullable', 'integer', 'min:0'], + 'rules_json.applicable_product_ids' => ['nullable', 'array'], + 'rules_json.applicable_product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $store->getKey())], + 'rules_json.applicable_collection_ids' => ['nullable', 'array'], + 'rules_json.applicable_collection_ids.*' => ['integer', Rule::exists('collections', 'id')->where('store_id', $store->getKey())], + 'rules_json.customer_eligibility' => ['nullable', Rule::in(['all', 'specific_customers', 'specific_segments'])], + 'rules_json.once_per_customer' => ['nullable', 'boolean'], + 'rules_json.one_per_customer' => ['nullable', 'boolean'], + ]); + + return $validated; + } + + /** + * @return list + */ + private function valueAmountRules(bool $creating, string $valueType): array + { + $presence = $creating ? 'required' : 'sometimes'; + + return match ($valueType) { + 'percent' => [$presence, 'integer', 'min:1', 'max:100'], + 'free_shipping' => ['sometimes', 'nullable', 'integer', 'min:0'], + default => [$presence, 'integer', 'min:1'], + }; + } + + /** + * @param array $validated + * @return array + */ + private function attributesForCreate(array $validated, Store $store): array + { + $type = (string) $validated['type']; + $valueType = (string) $validated['value_type']; + + return [ + 'store_id' => $store->getKey(), + 'type' => $type, + 'code' => $type === 'code' ? Str::upper(trim((string) $validated['code'])) : null, + 'value_type' => $valueType, + 'value_amount' => $valueType === 'free_shipping' ? 0 : (int) $validated['value_amount'], + 'starts_at' => $validated['starts_at'] ?? now(), + 'ends_at' => $validated['ends_at'] ?? null, + 'usage_limit' => $validated['usage_limit'] ?? null, + 'usage_count' => 0, + 'rules_json' => $this->rulesPayload($validated['rules_json'] ?? []), + 'status' => $validated['status'] ?? 'active', + ]; + } + + /** + * @param array $validated + * @return array + */ + private function attributesForUpdate(array $validated, Discount $discount): array + { + $attributes = []; + $valueType = (string) data_get($validated, 'value_type', $discount->value_type->value); + + foreach (['type', 'value_type', 'starts_at', 'ends_at', 'usage_limit', 'status'] as $field) { + if (array_key_exists($field, $validated)) { + $attributes[$field] = $validated[$field]; + } + } + + if (array_key_exists('value_amount', $validated)) { + $attributes['value_amount'] = $valueType === 'free_shipping' ? 0 : (int) $validated['value_amount']; + } elseif (array_key_exists('value_type', $validated) && $valueType === 'free_shipping') { + $attributes['value_amount'] = 0; + } + + if (array_key_exists('rules_json', $validated)) { + $attributes['rules_json'] = $this->rulesPayload($validated['rules_json']); + } + + return $attributes; + } + + /** + * @param array $rules + * @return array + */ + private function rulesPayload(array $rules): array + { + return [ + 'min_purchase_amount' => (int) data_get($rules, 'min_purchase_amount', data_get($rules, 'minimum_purchase_amount', 0)), + 'applicable_product_ids' => $this->integerList(data_get($rules, 'applicable_product_ids', [])), + 'applicable_collection_ids' => $this->integerList(data_get($rules, 'applicable_collection_ids', [])), + 'customer_eligibility' => data_get($rules, 'customer_eligibility', 'all'), + 'one_per_customer' => (bool) data_get($rules, 'one_per_customer', data_get($rules, 'once_per_customer', false)), + ]; + } + + /** + * @return list + */ + private function integerList(mixed $value): array + { + return collect(is_array($value) ? $value : []) + ->map(fn (mixed $id): int => (int) $id) + ->unique() + ->values() + ->all(); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/OrderController.php b/app/Http/Controllers/Api/Admin/V1/OrderController.php new file mode 100644 index 00000000..03791bfc --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/OrderController.php @@ -0,0 +1,84 @@ +authorizeStore($request, $store); + + $validated = $request->validate([ + 'status' => ['nullable', Rule::in(['pending', 'paid', 'fulfilled', 'cancelled', 'refunded'])], + 'financial_status' => ['nullable', Rule::in(['pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided'])], + 'fulfillment_status' => ['nullable', Rule::in(['unfulfilled', 'partial', 'fulfilled'])], + 'query' => ['nullable', 'string', 'max:255'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $orders = Order::withoutGlobalScopes() + ->with('customer') + ->withCount('lines') + ->where('store_id', $store->getKey()) + ->when(data_get($validated, 'status'), fn (Builder $query, string $status) => $query->where('status', $status)) + ->when(data_get($validated, 'financial_status'), fn (Builder $query, string $status) => $query->where('financial_status', $status)) + ->when(data_get($validated, 'fulfillment_status'), fn (Builder $query, string $status) => $query->where('fulfillment_status', $status)) + ->when(data_get($validated, 'query'), function (Builder $query, string $search): void { + $like = '%'.$search.'%'; + + $query->where(function (Builder $query) use ($like): void { + $query + ->where('order_number', 'like', $like) + ->orWhere('email', 'like', $like) + ->orWhereHas('customer', fn (Builder $query) => $query->where('name', 'like', $like)); + }); + }) + ->latest('placed_at') + ->latest('id') + ->paginate((int) data_get($validated, 'per_page', 25)); + + return OrderResource::collection($orders); + } + + public function show(Request $request, Store $store, Order $order): OrderResource + { + $this->authorizeStore($request, $store); + $this->abortUnlessOrderBelongsToStore($order, $store); + + return OrderResource::make($this->loadOrder($order)); + } + + private function authorizeStore(Request $request, Store $store): void + { + if (! $request->attributes->has('sanctum_personal_access_token')) { + abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403); + } + + app()->instance('current_store', $store); + } + + private function abortUnlessOrderBelongsToStore(Order $order, Store $store): void + { + abort_unless((int) $order->store_id === $store->getKey(), 404); + } + + private function loadOrder(Order $order): Order + { + return $order->load([ + 'customer', + 'lines', + 'payments', + 'refunds', + 'fulfillments.lines', + ])->loadCount('lines'); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/OrderExportController.php b/app/Http/Controllers/Api/Admin/V1/OrderExportController.php new file mode 100644 index 00000000..3ade3e3c --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/OrderExportController.php @@ -0,0 +1,61 @@ +authorizeStore($request, $store); + + $validated = $request->validate([ + 'format' => ['nullable', Rule::in(['csv'])], + 'filters' => ['nullable', 'array'], + 'filters.status' => ['nullable', Rule::in(['pending', 'paid', 'fulfilled', 'cancelled', 'refunded'])], + 'filters.financial_status' => ['nullable', Rule::in(['pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided'])], + 'filters.fulfillment_status' => ['nullable', Rule::in(['unfulfilled', 'partial', 'fulfilled'])], + 'filters.query' => ['nullable', 'string', 'max:255'], + 'filters.created_after' => ['nullable', 'date'], + 'filters.created_before' => ['nullable', 'date', 'after_or_equal:filters.created_after'], + ]); + + $export = $exports->create($store, $validated['filters'] ?? []); + + return response()->json([ + 'export_id' => $export->getKey(), + 'status' => $export->status?->value, + 'created_at' => $export->created_at?->toIso8601String(), + ], 202); + } + + public function show(Request $request, Store $store, DataExport $dataExport): DataExportResource + { + $this->authorizeStore($request, $store); + $this->abortUnlessExportBelongsToStore($dataExport, $store); + + return DataExportResource::make($dataExport); + } + + private function authorizeStore(Request $request, Store $store): void + { + if (! $request->attributes->has('sanctum_personal_access_token')) { + abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403); + } + + app()->instance('current_store', $store); + } + + private function abortUnlessExportBelongsToStore(DataExport $export, Store $store): void + { + abort_unless((int) $export->store_id === $store->getKey(), 404); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/OrderFulfillmentController.php b/app/Http/Controllers/Api/Admin/V1/OrderFulfillmentController.php new file mode 100644 index 00000000..39ee2893 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/OrderFulfillmentController.php @@ -0,0 +1,51 @@ +authorizeStore($request, $store); + $this->abortUnlessOrderBelongsToStore($order, $store); + abort_unless($request->user()?->can('createFulfillment', $order), 403); + + try { + $fulfillment = $fulfillments->create($order, $request->lineQuantities(), [ + 'tracking_company' => $request->validated('tracking_company'), + 'tracking_number' => $request->validated('tracking_number'), + 'tracking_url' => $request->validated('tracking_url'), + ]); + + return FulfillmentResource::make($fulfillment->load('lines')) + ->response() + ->setStatusCode(201); + } catch (InvalidFulfillmentOperationException $exception) { + return response()->json(['message' => $exception->getMessage()], 409); + } + } + + private function authorizeStore(Request $request, Store $store): void + { + if (! $request->attributes->has('sanctum_personal_access_token')) { + abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403); + } + + app()->instance('current_store', $store); + } + + private function abortUnlessOrderBelongsToStore(Order $order, Store $store): void + { + abort_unless((int) $order->store_id === $store->getKey(), 404); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/OrderRefundController.php b/app/Http/Controllers/Api/Admin/V1/OrderRefundController.php new file mode 100644 index 00000000..017a564f --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/OrderRefundController.php @@ -0,0 +1,57 @@ +authorizeStore($request, $store); + $this->abortUnlessOrderBelongsToStore($order, $store); + abort_unless($request->user()?->can('createRefund', $order), 403); + + $payload = [ + 'lines' => $request->lineQuantities(), + 'reason' => $request->validated('reason'), + 'restock' => (bool) $request->validated('restock', false), + ]; + + if ($request->validated('amount') !== null) { + $payload['amount'] = $request->validated('amount'); + } + + try { + $refund = $refunds->process($order, $payload); + + return RefundResource::make($refund) + ->response() + ->setStatusCode(201); + } catch (InvalidRefundOperationException $exception) { + return response()->json(['message' => $exception->getMessage()], 409); + } + } + + private function authorizeStore(Request $request, Store $store): void + { + if (! $request->attributes->has('sanctum_personal_access_token')) { + abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403); + } + + app()->instance('current_store', $store); + } + + private function abortUnlessOrderBelongsToStore(Order $order, Store $store): void + { + abort_unless((int) $order->store_id === $store->getKey(), 404); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/PageController.php b/app/Http/Controllers/Api/Admin/V1/PageController.php new file mode 100644 index 00000000..88f4c6e3 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/PageController.php @@ -0,0 +1,255 @@ +authorizeStore($request, $store); + abort_unless($request->user()?->can('viewAny', Page::class), 403); + + $validated = $request->validate([ + 'status' => ['nullable', Rule::in($this->pageStatusValues())], + 'query' => ['nullable', 'string', 'max:255'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $pages = Page::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->when(data_get($validated, 'status'), fn (Builder $query, string $status) => $query->where('status', $status)) + ->when(data_get($validated, 'query'), function (Builder $query, string $search): void { + $query->where(function (Builder $query) use ($search): void { + $query + ->where('title', 'like', '%'.$search.'%') + ->orWhere('handle', 'like', '%'.$search.'%'); + }); + }) + ->latest('updated_at') + ->latest('id') + ->paginate((int) data_get($validated, 'per_page', 25)); + + return PageResource::collection($pages); + } + + public function store(Request $request, Store $store, NavigationService $navigation): JsonResponse + { + $this->authorizeStore($request, $store); + abort_unless($request->user()?->can('create', Page::class), 403); + + $validated = $this->validatePayload($request, $store); + + $page = Page::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + ...$this->attributesForCreate($validated), + ]); + + $this->forgetNavigation($store, $navigation); + + return PageResource::make($page) + ->response() + ->setStatusCode(201); + } + + public function update(Request $request, Store $store, Page $page, NavigationService $navigation): PageResource + { + $this->authorizeStore($request, $store); + $this->abortUnlessPageBelongsToStore($page, $store); + abort_unless($request->user()?->can('update', $page), 403); + + $validated = $this->validatePayload($request, $store, $page); + $attributes = $this->attributesForUpdate($validated, $page); + + if ($attributes !== []) { + $page->update($attributes); + $this->forgetNavigation($store, $navigation); + } + + return PageResource::make($page->refresh()); + } + + public function destroy(Request $request, Store $store, Page $page, NavigationService $navigation): JsonResponse + { + $this->authorizeStore($request, $store); + $this->abortUnlessPageBelongsToStore($page, $store); + abort_unless($request->user()?->can('delete', $page), 403); + + $page->delete(); + $this->forgetNavigation($store, $navigation); + + return response()->json(['message' => 'Page deleted']); + } + + private function authorizeStore(Request $request, Store $store): void + { + if (! $request->attributes->has('sanctum_personal_access_token')) { + abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403); + } + + app()->instance('current_store', $store); + } + + private function abortUnlessPageBelongsToStore(Page $page, Store $store): void + { + abort_unless((int) $page->store_id === $store->getKey(), 404); + } + + /** + * @return array + */ + private function validatePayload(Request $request, Store $store, ?Page $page = null): array + { + $creating = $page === null; + + $validator = Validator::make($request->all(), [ + 'title' => [$creating ? 'required' : 'sometimes', 'string', 'max:255'], + 'handle' => ['sometimes', 'nullable', 'string', 'max:255'], + 'body_html' => ['sometimes', 'nullable', 'string', 'max:65535'], + 'status' => ['sometimes', Rule::in($this->pageStatusValues())], + 'published_at' => ['sometimes', 'nullable', 'date'], + ]); + + $validator->after(function (ValidationValidator $validator) use ($request, $store, $page, $creating): void { + if ($validator->errors()->isNotEmpty()) { + return; + } + + $handle = $this->handleForRequest($request, $page, $creating); + + if ($handle === '') { + $validator->errors()->add('handle', __('The handle must contain at least one letter or number.')); + + return; + } + + $exists = Page::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', $handle) + ->when($page instanceof Page, fn (Builder $query) => $query->whereKeyNot($page->getKey())) + ->exists(); + + if ($exists) { + $validator->errors()->add('handle', __('The handle has already been taken.')); + } + }); + + return $validator->validate(); + } + + /** + * @param array $validated + * @return array + */ + private function attributesForCreate(array $validated): array + { + $status = PageStatus::from(data_get($validated, 'status', PageStatus::Draft->value)); + + return [ + 'title' => $validated['title'], + 'handle' => $this->normalizeHandle($validated['handle'] ?? $validated['title']), + 'body_html' => $this->sanitizeHtml($validated['body_html'] ?? null), + 'status' => $status, + 'published_at' => $this->publishedAtForCreate($validated, $status), + ]; + } + + /** + * @param array $validated + * @return array + */ + private function attributesForUpdate(array $validated, Page $page): array + { + $attributes = Arr::only($validated, ['title', 'body_html']); + + if (array_key_exists('body_html', $attributes)) { + $attributes['body_html'] = $this->sanitizeHtml($attributes['body_html']); + } + + if (array_key_exists('handle', $validated) && filled($validated['handle'])) { + $attributes['handle'] = $this->normalizeHandle($validated['handle']); + } + + if (array_key_exists('status', $validated)) { + $attributes['status'] = PageStatus::from($validated['status']); + } + + if (array_key_exists('published_at', $validated)) { + $attributes['published_at'] = $validated['published_at']; + } elseif (($attributes['status'] ?? $page->status) === PageStatus::Published && $page->published_at === null) { + $attributes['published_at'] = now(); + } + + return $attributes; + } + + private function sanitizeHtml(?string $html): ?string + { + $sanitized = app(SanitizeHtml::class)($html); + + return $sanitized === '' ? null : $sanitized; + } + + /** + * @param array $validated + */ + private function publishedAtForCreate(array $validated, PageStatus $status): mixed + { + if (array_key_exists('published_at', $validated)) { + return $validated['published_at']; + } + + return $status === PageStatus::Published ? now() : null; + } + + private function handleForRequest(Request $request, ?Page $page, bool $creating): string + { + if ($request->exists('handle') && filled($request->input('handle'))) { + return $this->normalizeHandle($request->input('handle')); + } + + if ($creating) { + return $this->normalizeHandle($request->input('title')); + } + + return (string) $page?->handle; + } + + private function normalizeHandle(mixed $value): string + { + return Str::slug((string) $value); + } + + /** + * @return list + */ + private function pageStatusValues(): array + { + return array_map(fn (PageStatus $status): string => $status->value, PageStatus::cases()); + } + + private function forgetNavigation(Store $store, NavigationService $navigation): void + { + NavigationMenu::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->get() + ->each(fn (NavigationMenu $menu): mixed => $navigation->forget($menu)); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/PlatformOrganizationController.php b/app/Http/Controllers/Api/Admin/V1/PlatformOrganizationController.php new file mode 100644 index 00000000..c1106637 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/PlatformOrganizationController.php @@ -0,0 +1,21 @@ +create($request->validated()); + + return OrganizationResource::make($organization) + ->response() + ->setStatusCode(201); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/PlatformStoreController.php b/app/Http/Controllers/Api/Admin/V1/PlatformStoreController.php new file mode 100644 index 00000000..17673bc0 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/PlatformStoreController.php @@ -0,0 +1,50 @@ +validated(); + + $store = DB::transaction(function () use ($request, $validated): Store { + $store = Store::query()->create([ + 'organization_id' => $validated['organization_id'], + 'name' => $validated['name'], + 'handle' => $validated['handle'], + 'status' => 'active', + 'default_currency' => strtoupper((string) $validated['default_currency']), + 'default_locale' => $validated['default_locale'], + 'timezone' => $validated['timezone'], + ]); + + StoreSettings::query()->create([ + 'store_id' => $store->getKey(), + 'settings_json' => [], + ]); + + $user = $request->user(); + + if ($user instanceof \App\Models\User) { + $store->users()->syncWithoutDetaching([ + $user->getKey() => ['role' => 'owner', 'created_at' => now()], + ]); + } + + return $store; + }); + + return StoreResource::make($store) + ->response() + ->setStatusCode(201); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/ProductController.php b/app/Http/Controllers/Api/Admin/V1/ProductController.php new file mode 100644 index 00000000..4f28d083 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/ProductController.php @@ -0,0 +1,685 @@ +authorizeStore($request, $store); + abort_unless($request->user()?->can('viewAny', Product::class), 403); + + $validated = $request->validate([ + 'status' => ['nullable', Rule::in(['draft', 'active', 'archived'])], + 'query' => ['nullable', 'string', 'max:255'], + 'collection_id' => ['nullable', 'integer'], + 'sort' => ['nullable', Rule::in(['title_asc', 'title_desc', 'created_at_asc', 'created_at_desc', 'updated_at_desc'])], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $query = Product::withoutGlobalScopes() + ->with([ + 'media', + 'variants.inventoryItem', + ]) + ->withCount('variants') + ->where('store_id', $store->getKey()) + ->when(data_get($validated, 'status'), fn (Builder $query, string $status) => $query->where('status', $status)) + ->when(data_get($validated, 'collection_id'), function (Builder $query, int $collectionId): void { + $query->whereHas('collections', fn (Builder $query) => $query->withoutGlobalScopes()->whereKey($collectionId)); + }) + ->when(data_get($validated, 'query'), function (Builder $query, string $search): void { + $like = '%'.$search.'%'; + + $query->where(function (Builder $query) use ($like): void { + $query + ->where('title', 'like', $like) + ->orWhere('vendor', 'like', $like) + ->orWhereHas('variants', fn (Builder $query) => $query->withoutGlobalScopes()->where('sku', 'like', $like)); + }); + }); + + $this->applySort($query, (string) data_get($validated, 'sort', 'updated_at_desc')); + + return ProductResource::collection($query->paginate((int) data_get($validated, 'per_page', 25))); + } + + public function show(Request $request, Store $store, Product $product): ProductResource + { + $this->authorizeStore($request, $store); + $this->abortUnlessProductBelongsToStore($product, $store); + abort_unless($request->user()?->can('view', $product), 403); + + return ProductResource::make($this->loadProduct($product)); + } + + public function store(StoreProductRequest $request, Store $store, ProductService $products): JsonResponse + { + $this->authorizeStore($request, $store); + abort_unless($request->user()?->can('create', Product::class), 403); + + $validated = $request->validated(); + + try { + $product = DB::transaction(function () use ($products, $store, $validated): Product { + $product = $products->create($store, $this->attributesForCreate($validated, $store)); + + if (array_key_exists('collections', $validated)) { + $product->collections()->sync($this->integerList($validated['collections'])); + } + + return $product->refresh(); + }); + } catch (InvalidProductTransitionException|InvalidArgumentException|RuntimeException $exception) { + $this->throwProductValidationException($exception); + } + + return ProductResource::make($this->loadProduct($product)) + ->response() + ->setStatusCode(201); + } + + public function update(UpdateProductRequest $request, Store $store, Product $product, ProductService $products): ProductResource + { + $this->authorizeStore($request, $store); + $this->abortUnlessProductBelongsToStore($product, $store); + abort_unless($request->user()?->can('update', $product), 403); + + $validated = $request->validated(); + + if (($validated['status'] ?? null) === ProductStatus::Archived->value) { + abort_unless($request->user()?->can('archive', $product), 403); + } + + try { + $product = DB::transaction(function () use ($products, $store, $product, $validated): Product { + $product = Product::withoutGlobalScopes() + ->whereKey($product->getKey()) + ->lockForUpdate() + ->firstOrFail(); + + $attributes = $this->attributesForUpdate($validated); + $requestedStatus = null; + + if (array_key_exists('status', $attributes)) { + $requestedStatus = ProductStatus::from((string) $attributes['status']); + unset($attributes['status']); + } + + if ($attributes !== []) { + $product = $products->update($product, $attributes); + } + + if (array_key_exists('options', $validated)) { + $this->replaceProductOptions($product, $this->optionPayload($validated)); + } + + if (array_key_exists('variants', $validated)) { + $this->syncProductVariants($product, $store, $validated['variants']); + } + + if (array_key_exists('collections', $validated)) { + $product->collections()->sync($this->integerList($validated['collections'])); + } + + if ($requestedStatus instanceof ProductStatus && $requestedStatus !== $product->refresh()->status) { + $products->transitionStatus($product, $requestedStatus); + } + + return $product->refresh(); + }); + } catch (InvalidProductTransitionException|InvalidArgumentException|RuntimeException $exception) { + $this->throwProductValidationException($exception); + } + + return ProductResource::make($this->loadProduct($product)); + } + + public function destroy(Request $request, Store $store, Product $product, ProductService $products): JsonResponse + { + $this->authorizeStore($request, $store); + $this->abortUnlessProductBelongsToStore($product, $store); + abort_unless($request->user()?->can('archive', $product), 403); + + $products->transitionStatus($product, ProductStatus::Archived); + + return response()->json([ + 'data' => [ + 'id' => $product->getKey(), + 'status' => ProductStatus::Archived->value, + 'updated_at' => $product->refresh()->updated_at?->toIso8601String(), + ], + ]); + } + + public function presignUpload(CreateProductMediaUploadRequest $request, Store $store, Product $product): JsonResponse + { + $this->authorizeStore($request, $store); + $this->abortUnlessProductBelongsToStore($product, $store); + abort_unless($request->user()?->can('update', $product), 403); + + $validated = $request->validated(); + $expiresAt = now()->addMinutes(10); + $storageKey = sprintf( + 'media/originals/%d/%s.%s', + $product->getKey(), + Str::uuid(), + $this->extensionForContentType((string) $validated['content_type']), + ); + $media = ProductMedia::withoutGlobalScopes()->create([ + 'product_id' => $product->getKey(), + 'type' => $this->mediaTypeForContentType((string) $validated['content_type']), + 'storage_key' => $storageKey, + 'alt_text' => $product->title, + 'mime_type' => $validated['content_type'], + 'byte_size' => (int) $validated['byte_size'], + 'position' => $this->nextMediaPosition($product), + 'status' => MediaStatus::Processing, + ]); + $upload = $this->temporaryUploadPayload(Storage::disk('public'), $storageKey, $expiresAt); + + return response()->json([ + 'upload_url' => $upload['url'], + 'method' => 'PUT', + 'headers' => [ + ...$upload['headers'], + 'Content-Type' => $validated['content_type'], + ], + 'storage_key' => $storageKey, + 'media_id' => $media->getKey(), + 'expires_at' => $expiresAt->toIso8601String(), + ], 201); + } + + private function authorizeStore(Request $request, Store $store): void + { + if (! $request->attributes->has('sanctum_personal_access_token')) { + abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403); + } + + app()->instance('current_store', $store); + } + + private function abortUnlessProductBelongsToStore(Product $product, Store $store): void + { + abort_unless((int) $product->store_id === $store->getKey(), 404); + } + + private function loadProduct(Product $product): Product + { + return $product->load([ + 'collections', + 'media', + 'options.values', + 'variants.inventoryItem', + 'variants.optionValues.option', + ])->loadCount('variants'); + } + + private function applySort(Builder $query, string $sort): void + { + match ($sort) { + 'title_asc' => $query->orderBy('title')->orderBy('id'), + 'title_desc' => $query->orderByDesc('title')->orderByDesc('id'), + 'created_at_asc' => $query->orderBy('created_at')->orderBy('id'), + 'created_at_desc' => $query->orderByDesc('created_at')->orderByDesc('id'), + default => $query->orderByDesc('updated_at')->orderByDesc('id'), + }; + } + + /** + * @param array $validated + * @return array + */ + private function attributesForCreate(array $validated, Store $store): array + { + $attributes = [ + 'title' => $validated['title'], + 'description_html' => $this->sanitizeHtml($validated['description_html'] ?? null), + 'vendor' => $validated['vendor'] ?? null, + 'product_type' => $validated['product_type'] ?? null, + 'status' => $validated['status'] ?? ProductStatus::Draft->value, + 'tags' => $this->tagList($validated['tags'] ?? []), + 'options' => $this->optionPayload($validated), + 'variants' => $this->variantPayloads($validated['variants'], $store, true), + ]; + + if (filled($validated['handle'] ?? null)) { + $attributes['handle'] = Str::slug((string) $validated['handle']); + } + + return $attributes; + } + + /** + * @param array $validated + * @return array + */ + private function attributesForUpdate(array $validated): array + { + $attributes = Arr::only($validated, [ + 'title', + 'description_html', + 'vendor', + 'product_type', + 'status', + ]); + + if (array_key_exists('tags', $validated)) { + $attributes['tags'] = $this->tagList($validated['tags']); + } + + if (array_key_exists('description_html', $attributes)) { + $attributes['description_html'] = $this->sanitizeHtml($attributes['description_html']); + } + + if (filled($validated['handle'] ?? null)) { + $attributes['handle'] = Str::slug((string) $validated['handle']); + } + + return $attributes; + } + + private function sanitizeHtml(?string $html): ?string + { + $sanitized = app(SanitizeHtml::class)($html); + + return $sanitized === '' ? null : $sanitized; + } + + /** + * @param array $validated + * @return array}> + */ + private function optionPayload(array $validated): array + { + $declaredOptions = collect($validated['options'] ?? []); + + if ($declaredOptions->isEmpty()) { + $declaredOptions = collect($validated['variants'] ?? []) + ->flatMap(fn (array $variant): array => $variant['option_values'] ?? []) + ->pluck('option_name') + ->filter() + ->unique() + ->values() + ->map(fn (string $name, int $position): array => [ + 'name' => $name, + 'position' => $position + 1, + ]); + } + + return $declaredOptions + ->map(function (array $option, int $position) use ($validated): array { + $name = trim((string) $option['name']); + $values = collect($option['values'] ?? []) + ->merge($this->variantOptionValues($validated['variants'] ?? [], $name)) + ->map(fn (mixed $value): string => trim((string) $value)) + ->filter() + ->unique() + ->values(); + + return [ + 'name' => $name, + 'position' => (int) ($option['position'] ?? ($position + 1)), + 'values' => $values + ->map(fn (string $value, int $valuePosition): array => [ + 'value' => $value, + 'position' => $valuePosition + 1, + ]) + ->all(), + ]; + }) + ->filter(fn (array $option): bool => $option['name'] !== '') + ->values() + ->all(); + } + + /** + * @param array> $variants + * @return list + */ + private function variantOptionValues(array $variants, string $optionName): array + { + return collect($variants) + ->flatMap(fn (array $variant): array => $variant['option_values'] ?? []) + ->filter(fn (array $value): bool => ($value['option_name'] ?? null) === $optionName) + ->pluck('value') + ->values() + ->all(); + } + + /** + * @param array> $variants + * @return array> + */ + private function variantPayloads(array $variants, Store $store, bool $creating = false): array + { + return collect($variants) + ->values() + ->map(fn (array $variant, int $position): array => [ + 'sku' => trim((string) $variant['sku']), + 'barcode' => $variant['barcode'] ?? null, + 'price_amount' => (int) $variant['price_amount'], + 'compare_at_amount' => array_key_exists('compare_at_amount', $variant) ? $variant['compare_at_amount'] : null, + 'currency' => strtoupper((string) ($variant['currency'] ?? $store->default_currency)), + 'weight_g' => array_key_exists('weight_g', $variant) ? $variant['weight_g'] : null, + 'requires_shipping' => (bool) data_get($variant, 'requires_shipping', true), + 'is_default' => (bool) $variant['is_default'], + 'position' => (int) data_get($variant, 'position', $position), + 'status' => data_get($variant, 'status', VariantStatus::Active->value), + 'quantity_on_hand' => data_get($variant, 'inventory.quantity_on_hand', $creating ? 0 : null), + 'inventory_policy' => data_get($variant, 'inventory.policy', $creating ? 'deny' : null), + 'options' => $this->variantOptions($variant), + ]) + ->all(); + } + + /** + * @param array $variant + * @return array + */ + private function variantOptions(array $variant): array + { + return collect($variant['option_values'] ?? []) + ->mapWithKeys(fn (array $optionValue): array => [ + (string) $optionValue['option_name'] => (string) $optionValue['value'], + ]) + ->all(); + } + + /** + * @param array}> $optionPayload + */ + private function replaceProductOptions(Product $product, array $optionPayload): void + { + $syncedOptionIds = []; + + foreach ($optionPayload as $optionData) { + $option = ProductOption::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->where('position', $optionData['position']) + ->first() + ?? $product->options()->create([ + 'name' => $optionData['name'], + 'position' => $optionData['position'], + ]); + + $option->forceFill([ + 'name' => $optionData['name'], + 'position' => $optionData['position'], + ])->save(); + + $syncedOptionIds[] = $option->getKey(); + $syncedValueIds = []; + + foreach ($optionData['values'] as $valueData) { + $value = ProductOptionValue::withoutGlobalScopes() + ->where('product_option_id', $option->getKey()) + ->where('position', $valueData['position']) + ->first() + ?? $option->values()->create([ + 'value' => $valueData['value'], + 'position' => $valueData['position'], + ]); + + $value->forceFill([ + 'value' => $valueData['value'], + 'position' => $valueData['position'], + ])->save(); + + $syncedValueIds[] = $value->getKey(); + } + + ProductOptionValue::withoutGlobalScopes() + ->where('product_option_id', $option->getKey()) + ->when($syncedValueIds !== [], fn (Builder $query) => $query->whereNotIn('id', $syncedValueIds)) + ->delete(); + } + + ProductOption::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->when($syncedOptionIds !== [], fn (Builder $query) => $query->whereNotIn('id', $syncedOptionIds)) + ->delete(); + } + + /** + * @param array> $variants + */ + private function syncProductVariants(Product $product, Store $store, array $variants): void + { + $variantPayloads = $this->variantPayloads($variants, $store); + $syncedVariantIds = []; + + foreach ($variantPayloads as $position => $variantData) { + $variantId = $variants[$position]['id'] ?? null; + $attributes = Arr::only($variantData, [ + 'sku', + 'barcode', + 'price_amount', + 'compare_at_amount', + 'currency', + 'weight_g', + 'requires_shipping', + 'is_default', + 'position', + 'status', + ]); + $variant = $variantId + ? ProductVariant::withoutGlobalScopes()->where('product_id', $product->getKey())->findOrFail($variantId) + : ProductVariant::withoutGlobalScopes()->create([ + 'product_id' => $product->getKey(), + ...$attributes, + ]); + + if ($variantId) { + $variant->forceFill($attributes)->save(); + } + + $this->syncVariantInventory($variant, $store, $variantData); + $variant->optionValues()->sync($this->optionValueIdsForVariant($product, $variantData['options'] ?? [])); + $syncedVariantIds[] = $variant->getKey(); + } + + ProductVariant::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->when($syncedVariantIds !== [], fn (Builder $query) => $query->whereNotIn('id', $syncedVariantIds)) + ->get() + ->each(function (ProductVariant $variant): void { + if ($this->variantHasOrderLines($variant)) { + $variant->forceFill(['status' => VariantStatus::Archived])->save(); + + return; + } + + $variant->delete(); + }); + } + + /** + * @param array $variantData + */ + private function syncVariantInventory(ProductVariant $variant, Store $store, array $variantData): void + { + $inventory = InventoryItem::withoutGlobalScopes() + ->where('variant_id', $variant->getKey()) + ->first(); + $attributes = []; + + if ($variantData['quantity_on_hand'] !== null) { + $attributes['quantity_on_hand'] = (int) $variantData['quantity_on_hand']; + } + + if ($variantData['inventory_policy'] !== null) { + $attributes['policy'] = $variantData['inventory_policy']; + } + + if ($inventory instanceof InventoryItem) { + if ($attributes !== []) { + $inventory->forceFill($attributes)->save(); + } + + return; + } + + InventoryItem::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => (int) ($variantData['quantity_on_hand'] ?? 0), + 'quantity_reserved' => 0, + 'policy' => $variantData['inventory_policy'] ?? 'deny', + ]); + } + + /** + * @param array $options + * @return list + */ + private function optionValueIdsForVariant(Product $product, array $options): array + { + if ($options === []) { + return []; + } + + return collect($options) + ->map(function (string $value, string $optionName) use ($product): int { + $valueId = ProductOptionValue::withoutGlobalScopes() + ->where('value', $value) + ->whereHas('option', function (Builder $query) use ($product, $optionName): void { + $query + ->withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->where('name', $optionName); + }) + ->value('id'); + + if ($valueId === null) { + throw new InvalidArgumentException("Variant option selection [{$optionName}: {$value}] is invalid for this product."); + } + + return (int) $valueId; + }) + ->values() + ->all(); + } + + private function variantHasOrderLines(ProductVariant $variant): bool + { + return Schema::hasTable('order_lines') + && DB::table('order_lines')->where('variant_id', $variant->getKey())->exists(); + } + + /** + * @param array $tags + * @return list + */ + private function tagList(array $tags): array + { + return collect($tags) + ->map(fn (mixed $tag): string => trim((string) $tag)) + ->filter() + ->unique() + ->values() + ->all(); + } + + /** + * @param array $values + * @return list + */ + private function integerList(array $values): array + { + return collect($values) + ->map(fn (mixed $value): int => (int) $value) + ->unique() + ->values() + ->all(); + } + + private function nextMediaPosition(Product $product): int + { + $position = ProductMedia::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->max('position'); + + return $position === null ? 0 : ((int) $position) + 1; + } + + private function mediaTypeForContentType(string $contentType): MediaType + { + return $contentType === 'video/mp4' ? MediaType::Video : MediaType::Image; + } + + private function extensionForContentType(string $contentType): string + { + return match ($contentType) { + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + 'image/avif' => 'avif', + 'video/mp4' => 'mp4', + default => 'bin', + }; + } + + /** + * @return array{url: string, headers: array} + */ + private function temporaryUploadPayload(FilesystemAdapter $disk, string $storageKey, mixed $expiresAt): array + { + try { + $payload = $disk->temporaryUploadUrl($storageKey, $expiresAt); + + return [ + 'url' => (string) $payload['url'], + 'headers' => $payload['headers'] ?? [], + ]; + } catch (Throwable) { + return [ + 'url' => $disk->url($storageKey), + 'headers' => [], + ]; + } + } + + private function throwProductValidationException(Throwable $exception): never + { + throw ValidationException::withMessages([ + 'product' => [$exception->getMessage()], + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/SearchIndexController.php b/app/Http/Controllers/Api/Admin/V1/SearchIndexController.php new file mode 100644 index 00000000..fc3f2eef --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/SearchIndexController.php @@ -0,0 +1,81 @@ +authorizeStore($request, $store); + + $startedAt = microtime(true); + $count = $search->reindex($store); + $duration = (int) ceil(microtime(true) - $startedAt); + + $settings = $this->settings($store); + $settings->forceFill([ + 'updated_at' => now(), + ])->save(); + + return response()->json([ + 'message' => __('Reindex completed.'), + 'job_id' => null, + 'status' => 'completed', + 'documents_count' => $count, + 'last_reindex_duration_seconds' => $duration, + ], 202); + } + + public function status(Request $request, Store $store): JsonResponse + { + $this->authorizeStore($request, $store); + + $productCount = Product::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->count(); + + $documentsCount = DB::table('products_fts') + ->where('store_id', $store->getKey()) + ->distinct() + ->count('product_id'); + + $pendingUpdates = abs($productCount - $documentsCount); + $settings = $this->settings($store); + + return response()->json([ + 'data' => [ + 'store_id' => $store->getKey(), + 'index_status' => $pendingUpdates === 0 ? 'ready' : 'stale', + 'last_reindex_at' => $settings->updated_at?->toIso8601String(), + 'last_reindex_duration_seconds' => 0, + 'documents_count' => $documentsCount, + 'pending_updates' => $pendingUpdates, + ], + ]); + } + + private function authorizeStore(Request $request, Store $store): void + { + if (! $request->attributes->has('sanctum_personal_access_token')) { + abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403); + } + + app()->instance('current_store', $store); + } + + private function settings(Store $store): SearchSettings + { + return SearchSettings::withoutGlobalScopes()->firstOrCreate([ + 'store_id' => $store->getKey(), + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/ShippingRateController.php b/app/Http/Controllers/Api/Admin/V1/ShippingRateController.php new file mode 100644 index 00000000..cfba52fe --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/ShippingRateController.php @@ -0,0 +1,105 @@ +authorizeStore($request, $store); + $this->abortUnlessZoneBelongsToStore($shippingZone, $store); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', Rule::in(array_map(fn (ShippingRateType $type): string => $type->value, ShippingRateType::cases()))], + 'config_json' => ['required', 'array'], + 'config_json.price_amount' => ['nullable', 'integer', 'min:0'], + 'config_json.amount' => ['nullable', 'integer', 'min:0'], + 'config_json.currency' => ['nullable', 'string', 'size:3'], + 'config_json.tiers' => ['nullable', 'array'], + 'config_json.tiers.*.min_weight_g' => ['nullable', 'integer', 'min:0'], + 'config_json.tiers.*.max_weight_g' => ['nullable', 'integer', 'min:1'], + 'config_json.tiers.*.min_order_amount' => ['nullable', 'integer', 'min:0'], + 'config_json.tiers.*.max_order_amount' => ['nullable', 'integer', 'min:1'], + 'config_json.tiers.*.price_amount' => ['nullable', 'integer', 'min:0'], + 'config_json.ranges' => ['nullable', 'array'], + 'is_active' => ['sometimes', 'boolean'], + ]); + + $rate = ShippingRate::withoutGlobalScopes()->create([ + 'zone_id' => $shippingZone->getKey(), + 'name' => $validated['name'], + 'type' => ShippingRateType::from($validated['type']), + 'config_json' => $this->normalizeConfig($validated['type'], $request->input('config_json', []), $store), + 'is_active' => (bool) ($validated['is_active'] ?? true), + ]); + + return ShippingRateResource::make($rate->load('zone.store')) + ->response() + ->setStatusCode(201); + } + + private function authorizeStore(Request $request, Store $store): void + { + if (! $request->attributes->has('sanctum_personal_access_token')) { + abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403); + } + + app()->instance('current_store', $store); + } + + private function abortUnlessZoneBelongsToStore(ShippingZone $zone, Store $store): void + { + abort_unless((int) $zone->store_id === $store->getKey(), 404); + } + + /** + * @param array $config + * @return array + */ + private function normalizeConfig(string $type, array $config, Store $store): array + { + if (in_array($type, [ShippingRateType::Flat->value, ShippingRateType::Carrier->value], true)) { + return [ + 'amount' => (int) ($config['price_amount'] ?? $config['amount'] ?? 0), + 'currency' => strtoupper((string) ($config['currency'] ?? $store->default_currency)), + ]; + } + + if ($type === ShippingRateType::Weight->value) { + return [ + 'currency' => strtoupper((string) ($config['currency'] ?? $store->default_currency)), + 'ranges' => collect($config['tiers'] ?? $config['ranges'] ?? []) + ->map(fn (array $tier): array => [ + 'min_g' => (int) ($tier['min_weight_g'] ?? $tier['min_g'] ?? 0), + 'max_g' => array_key_exists('max_weight_g', $tier) ? $tier['max_weight_g'] : ($tier['max_g'] ?? null), + 'amount' => (int) ($tier['price_amount'] ?? $tier['amount'] ?? 0), + ]) + ->values() + ->all(), + ]; + } + + return [ + 'currency' => strtoupper((string) ($config['currency'] ?? $store->default_currency)), + 'ranges' => collect($config['tiers'] ?? $config['ranges'] ?? []) + ->map(fn (array $tier): array => [ + 'min_amount' => (int) ($tier['min_order_amount'] ?? $tier['min_amount'] ?? 0), + 'max_amount' => array_key_exists('max_order_amount', $tier) ? $tier['max_order_amount'] : ($tier['max_amount'] ?? null), + 'amount' => (int) ($tier['price_amount'] ?? $tier['amount'] ?? 0), + ]) + ->values() + ->all(), + ]; + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/ShippingZoneController.php b/app/Http/Controllers/Api/Admin/V1/ShippingZoneController.php new file mode 100644 index 00000000..b3d139ee --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/ShippingZoneController.php @@ -0,0 +1,159 @@ +authorizeStore($request, $store); + + $zones = ShippingZone::withoutGlobalScopes() + ->with(['rates' => fn ($query) => $query->withoutGlobalScopes()->orderBy('id'), 'store']) + ->where('store_id', $store->getKey()) + ->orderBy('id') + ->get(); + + return ShippingZoneResource::collection($zones); + } + + public function store(Request $request, Store $store): JsonResponse + { + $this->authorizeStore($request, $store); + + $validated = $this->validatePayload($request, $store); + + $zone = ShippingZone::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + ...$this->attributes($validated), + ]); + + return ShippingZoneResource::make($this->loadZone($zone)) + ->response() + ->setStatusCode(201); + } + + public function update(Request $request, Store $store, ShippingZone $shippingZone): ShippingZoneResource + { + $this->authorizeStore($request, $store); + $this->abortUnlessZoneBelongsToStore($shippingZone, $store); + + $validated = $this->validatePayload($request, $store, $shippingZone); + + $shippingZone->update($this->attributes($validated)); + + return ShippingZoneResource::make($this->loadZone($shippingZone->refresh())); + } + + private function authorizeStore(Request $request, Store $store): void + { + if (! $request->attributes->has('sanctum_personal_access_token')) { + abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403); + } + + app()->instance('current_store', $store); + } + + private function abortUnlessZoneBelongsToStore(ShippingZone $zone, Store $store): void + { + abort_unless((int) $zone->store_id === $store->getKey(), 404); + } + + /** + * @return array + */ + private function validatePayload(Request $request, Store $store, ?ShippingZone $zone = null): array + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'countries_json' => ['required', 'array', 'min:1'], + 'countries_json.*' => ['required', 'string', 'size:2'], + 'regions_json' => ['sometimes', 'array'], + 'regions_json.*' => ['string', 'max:20'], + ]); + + $countries = $this->countryCodes($validated['countries_json']); + + if ($countries === []) { + throw ValidationException::withMessages([ + 'countries_json' => __('Enter at least one ISO country code.'), + ]); + } + + $overlap = ShippingZone::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->when($zone instanceof ShippingZone, fn (Builder $query) => $query->whereKeyNot($zone->getKey())) + ->get() + ->first(fn (ShippingZone $existing): bool => array_intersect($countries, $existing->countries_json ?? []) !== []); + + if ($overlap instanceof ShippingZone) { + throw ValidationException::withMessages([ + 'countries_json' => __('One or more countries already belong to another shipping zone.'), + ]); + } + + $validated['countries_json'] = $countries; + $validated['regions_json'] = $this->regionCodes($validated['regions_json'] ?? []); + + return $validated; + } + + /** + * @param array $validated + * @return array + */ + private function attributes(array $validated): array + { + return [ + 'name' => $validated['name'], + 'countries_json' => $validated['countries_json'], + 'regions_json' => $validated['regions_json'], + ]; + } + + /** + * @param list $countries + * @return list + */ + private function countryCodes(array $countries): array + { + return collect($countries) + ->map(fn (string $country): string => strtoupper(trim($country))) + ->filter(fn (string $country): bool => preg_match('/^[A-Z]{2}$/', $country) === 1) + ->unique() + ->values() + ->all(); + } + + /** + * @param list $regions + * @return list + */ + private function regionCodes(array $regions): array + { + return collect($regions) + ->map(fn (string $region): string => strtoupper(trim($region))) + ->filter() + ->unique() + ->values() + ->all(); + } + + private function loadZone(ShippingZone $zone): ShippingZone + { + return $zone->load([ + 'rates' => fn ($query) => $query->withoutGlobalScopes()->orderBy('id'), + 'store', + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/StoreInviteController.php b/app/Http/Controllers/Api/Admin/V1/StoreInviteController.php new file mode 100644 index 00000000..ac6ab94d --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/StoreInviteController.php @@ -0,0 +1,50 @@ +authorizeInvite($request, $store); + + $validated = $request->validated(); + $userId = User::query() + ->where('email', $validated['email']) + ->value('id'); + + if ($userId !== null && DB::table('store_users')->where('store_id', $store->getKey())->where('user_id', $userId)->exists()) { + return response()->json(['message' => 'User already belongs to this store.'], 409); + } + + $invitedAt = now(); + + return response()->json([ + 'data' => [ + 'email' => $validated['email'], + 'role' => $validated['role'], + 'invited_at' => $invitedAt->toIso8601String(), + 'expires_at' => $invitedAt->copy()->addDays(7)->toIso8601String(), + ], + ], 201); + } + + private function authorizeInvite(Request $request, Store $store): void + { + if ($request->attributes->has('sanctum_personal_access_token')) { + return; + } + + $role = $request->user()?->roleForStore($store); + + abort_unless(in_array($role?->value, ['owner', 'admin'], true), 403); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/StoreMembershipController.php b/app/Http/Controllers/Api/Admin/V1/StoreMembershipController.php new file mode 100644 index 00000000..ab273752 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/StoreMembershipController.php @@ -0,0 +1,89 @@ +attributes->get('sanctum_personal_access_token'); + $user = $request->user(); + + abort_unless($user instanceof User && $token instanceof PersonalAccessToken, 401); + + $role = $user->roleForStore($store); + + abort_unless($role !== null, 403); + + $permissions = $this->permissionsForRole($role->value); + + if (! $token->can('*')) { + $permissions = array_values(array_intersect($permissions, $token->abilities ?? [])); + } + + return response()->json([ + 'data' => [ + 'user_id' => $user->getKey(), + 'store_id' => $store->getKey(), + 'role' => $role->value, + 'email' => $user->email, + 'name' => $user->name, + 'permissions' => $permissions, + ], + ]); + } + + /** + * @return list + */ + private function permissionsForRole(string $role): array + { + return match ($role) { + 'owner', 'admin' => [ + 'manage-platform', + 'read-products', + 'write-products', + 'read-collections', + 'write-collections', + 'read-orders', + 'write-orders', + 'read-customers', + 'write-customers', + 'read-discounts', + 'write-discounts', + 'read-content', + 'write-content', + 'read-settings', + 'write-settings', + 'read-analytics', + 'write-themes', + 'manage-apps', + ], + 'staff' => [ + 'read-products', + 'write-products', + 'read-collections', + 'write-collections', + 'read-orders', + 'write-orders', + 'read-customers', + 'read-discounts', + 'write-discounts', + 'read-content', + 'write-content', + 'read-analytics', + ], + default => [ + 'read-orders', + 'read-customers', + ], + }; + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/StoreSettingsController.php b/app/Http/Controllers/Api/Admin/V1/StoreSettingsController.php new file mode 100644 index 00000000..6ea7a049 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/StoreSettingsController.php @@ -0,0 +1,152 @@ +authorizeStore($request, $store); + $this->settings($store); + + return StoreSettingsResource::make($this->loadStore($store)); + } + + public function update(Request $request, Store $store): StoreSettingsResource + { + $this->authorizeStore($request, $store); + + $validated = $request->validate([ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'default_currency' => ['sometimes', 'required', Rule::in($this->currencyOptions())], + 'default_locale' => ['sometimes', 'required', Rule::in(array_keys($this->localeOptions()))], + 'timezone' => ['sometimes', 'required', Rule::in(timezone_identifiers_list())], + 'settings_json' => ['sometimes', 'array'], + 'settings_json.announcement' => ['sometimes', 'array'], + 'settings_json.announcement.enabled' => ['sometimes', 'boolean'], + 'settings_json.announcement.text' => ['nullable', 'string', 'max:255'], + 'settings_json.checkout' => ['sometimes', 'array'], + 'settings_json.checkout.guest_checkout_enabled' => ['sometimes', 'boolean'], + 'settings_json.checkout.customer_accounts_required' => ['sometimes', 'boolean'], + 'settings_json.checkout.phone_number_required' => ['sometimes', 'boolean'], + 'settings_json.checkout.billing_address_enabled' => ['sometimes', 'boolean'], + 'settings_json.checkout.order_notes_enabled' => ['sometimes', 'boolean'], + 'settings_json.checkout.terms_required' => ['sometimes', 'boolean'], + 'settings_json.checkout.terms_url' => ['nullable', 'url', 'max:2048'], + 'settings_json.checkout.payment_hold_hours' => ['sometimes', 'integer', 'min:1', 'max:168'], + 'settings_json.checkout.abandoned_checkout_days' => ['sometimes', 'integer', 'min:1', 'max:60'], + 'settings_json.bank_transfer_cancel_days' => ['sometimes', 'integer', 'min:1', 'max:60'], + 'settings_json.notifications' => ['sometimes', 'array'], + 'settings_json.notifications.sender_name' => ['nullable', 'string', 'max:255'], + 'settings_json.notifications.sender_email' => ['nullable', 'email', 'max:255'], + 'settings_json.notifications.reply_to_email' => ['nullable', 'email', 'max:255'], + 'settings_json.notifications.order_confirmation_enabled' => ['sometimes', 'boolean'], + 'settings_json.notifications.shipping_confirmation_enabled' => ['sometimes', 'boolean'], + 'settings_json.notifications.refund_confirmation_enabled' => ['sometimes', 'boolean'], + 'settings_json.notifications.admin_order_alerts_enabled' => ['sometimes', 'boolean'], + 'settings_json.notifications.low_stock_alerts_enabled' => ['sometimes', 'boolean'], + 'settings_json.notifications.low_stock_threshold' => ['sometimes', 'integer', 'min:0', 'max:1000'], + ]); + + $settingsPayload = $request->input('settings_json'); + + if ($request->exists('settings_json') && is_array($settingsPayload)) { + $this->validateSettingsObject($settingsPayload); + } + + $storeAttributes = Arr::only($validated, [ + 'name', + 'default_currency', + 'default_locale', + 'timezone', + ]); + + if ($storeAttributes !== []) { + $store->forceFill($storeAttributes)->save(); + } + + if ($request->exists('settings_json') && is_array($settingsPayload)) { + $settings = $this->settings($store); + $settings->forceFill([ + 'settings_json' => array_replace_recursive($settings->settings_json ?? [], $settingsPayload), + 'updated_at' => now(), + ])->save(); + } + + return StoreSettingsResource::make($this->loadStore($store->refresh())); + } + + private function authorizeStore(Request $request, Store $store): void + { + if (! $request->attributes->has('sanctum_personal_access_token')) { + abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403); + } + + app()->instance('current_store', $store); + } + + private function settings(Store $store): StoreSettings + { + return StoreSettings::query()->firstOrCreate( + ['store_id' => $store->getKey()], + ['settings_json' => []], + ); + } + + private function loadStore(Store $store): Store + { + return $store->load([ + 'settings', + 'domains' => fn ($query) => $query->orderByDesc('is_primary')->orderBy('hostname'), + ]); + } + + /** + * @param array $settings + */ + private function validateSettingsObject(array $settings): void + { + if ($settings !== [] && array_is_list($settings)) { + throw ValidationException::withMessages([ + 'settings_json' => __('The settings json field must be an object.'), + ]); + } + + foreach (['announcement', 'checkout', 'notifications'] as $section) { + if (array_key_exists($section, $settings) && is_array($settings[$section]) && $settings[$section] !== [] && array_is_list($settings[$section])) { + throw ValidationException::withMessages([ + "settings_json.{$section}" => __('The :section settings must be an object.', ['section' => str_replace('_', ' ', $section)]), + ]); + } + } + } + + /** + * @return array + */ + private function localeOptions(): array + { + return [ + 'en' => 'English', + 'de' => 'German', + 'fr' => 'French', + ]; + } + + /** + * @return list + */ + private function currencyOptions(): array + { + return ['EUR', 'USD', 'GBP', 'CHF']; + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/TaxSettingsController.php b/app/Http/Controllers/Api/Admin/V1/TaxSettingsController.php new file mode 100644 index 00000000..fccf1116 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/TaxSettingsController.php @@ -0,0 +1,107 @@ +authorizeStore($request, $store); + + return TaxSettingsResource::make($this->settings($store)); + } + + public function update(Request $request, Store $store): TaxSettingsResource + { + $this->authorizeStore($request, $store); + + $validated = $request->validate([ + 'mode' => ['required', Rule::in(array_map(fn (TaxMode $mode): string => $mode->value, TaxMode::cases()))], + 'provider' => ['required_if:mode,provider', Rule::in(['none', 'stripe_tax'])], + 'prices_include_tax' => ['required', 'boolean'], + 'config_json' => ['required', 'array'], + 'config_json.default_tax_rate' => ['nullable', 'integer', 'min:0', 'max:10000'], + 'config_json.default_rate_bps' => ['nullable', 'integer', 'min:0', 'max:10000'], + 'config_json.tax_rates' => ['nullable', 'array'], + 'config_json.tax_rates.*.country_code' => ['required_with:config_json.tax_rates', 'string', 'size:2'], + 'config_json.tax_rates.*.rate' => ['required_with:config_json.tax_rates', 'integer', 'min:0', 'max:10000'], + 'config_json.tax_rates.*.name' => ['required_with:config_json.tax_rates', 'string', 'max:50'], + 'config_json.tax_rates.*.shipping_taxed' => ['nullable', 'boolean'], + 'config_json.rates' => ['nullable', 'array'], + 'config_json.rates.*.country' => ['required_with:config_json.rates', 'string', 'size:2'], + 'config_json.rates.*.rate_bps' => ['required_with:config_json.rates', 'integer', 'min:0', 'max:10000'], + 'config_json.rates.*.name' => ['required_with:config_json.rates', 'string', 'max:50'], + ]); + + $settings = TaxSettings::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->getKey()], + [ + 'mode' => TaxMode::from($validated['mode']), + 'provider' => $validated['mode'] === TaxMode::Provider->value ? $validated['provider'] : 'none', + 'prices_include_tax' => (bool) $validated['prices_include_tax'], + 'config_json' => $this->normalizeConfig($request->input('config_json', [])), + ], + ); + + return TaxSettingsResource::make($settings->refresh()); + } + + private function authorizeStore(Request $request, Store $store): void + { + if (! $request->attributes->has('sanctum_personal_access_token')) { + abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403); + } + + app()->instance('current_store', $store); + } + + private function settings(Store $store): TaxSettings + { + return TaxSettings::withoutGlobalScopes()->firstOrCreate( + ['store_id' => $store->getKey()], + [ + 'mode' => TaxMode::Manual, + 'provider' => 'none', + 'prices_include_tax' => false, + 'config_json' => [ + 'name' => 'Tax', + 'default_rate_bps' => 0, + 'shipping_taxable' => true, + 'rates' => [], + ], + ], + ); + } + + /** + * @param array $config + * @return array + */ + private function normalizeConfig(array $config): array + { + $rates = collect($config['tax_rates'] ?? $config['rates'] ?? []) + ->map(fn (array $rate): array => [ + 'country' => strtoupper((string) ($rate['country_code'] ?? $rate['country'])), + 'rate_bps' => (int) ($rate['rate'] ?? $rate['rate_bps']), + 'name' => (string) $rate['name'], + 'shipping_taxed' => (bool) ($rate['shipping_taxed'] ?? true), + ]) + ->values() + ->all(); + + return [ + ...$config, + 'default_rate_bps' => (int) ($config['default_tax_rate'] ?? $config['default_rate_bps'] ?? 0), + 'shipping_taxable' => (bool) ($config['shipping_taxable'] ?? true), + 'rates' => $rates, + ]; + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/ThemeController.php b/app/Http/Controllers/Api/Admin/V1/ThemeController.php new file mode 100644 index 00000000..ef9af1b1 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/ThemeController.php @@ -0,0 +1,93 @@ +authorizeStore($request, $store); + + $validated = $request->validate([ + 'file' => ['required', 'file', 'mimes:zip', 'max:51200'], + 'name' => ['sometimes', 'nullable', 'string', 'max:255'], + ]); + + $archive = $request->file('file'); + + abort_unless($archive instanceof UploadedFile, 422); + + $theme = $installer->install($store, $archive, $validated['name'] ?? null); + + return ThemeResource::make($theme) + ->response() + ->setStatusCode(201); + } + + public function publish(Request $request, Store $store, Theme $theme, ThemeSettingsService $settings): ThemeResource + { + $this->authorizeStore($request, $store); + $this->abortUnlessThemeBelongsToStore($theme, $store); + $this->validatePublishable($theme); + + DB::transaction(function () use ($store, $theme): void { + Theme::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->update([ + 'status' => ThemeStatus::Draft, + 'published_at' => null, + ]); + + $theme->forceFill([ + 'status' => ThemeStatus::Published, + 'published_at' => now(), + ])->save(); + }); + + $settings->forget($store); + + return ThemeResource::make($theme->refresh()->load('settings')->loadCount('files')); + } + + private function authorizeStore(Request $request, Store $store): void + { + if (! $request->attributes->has('sanctum_personal_access_token')) { + abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403); + } + + app()->instance('current_store', $store); + } + + private function abortUnlessThemeBelongsToStore(Theme $theme, Store $store): void + { + abort_unless((int) $theme->store_id === $store->getKey(), 404); + } + + private function validatePublishable(Theme $theme): void + { + $paths = $theme->files() + ->withoutGlobalScopes() + ->pluck('path') + ->all(); + $missing = array_values(array_diff(ThemeArchiveInstaller::requiredPaths(), $paths)); + + if ($missing !== []) { + throw ValidationException::withMessages([ + 'theme' => __('The theme is missing required file: :path', ['path' => $missing[0]]), + ]); + } + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php b/app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php new file mode 100644 index 00000000..7826b15b --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php @@ -0,0 +1,57 @@ +authorizeStore($request, $store); + $this->abortUnlessThemeBelongsToStore($theme, $store); + + $validated = $request->validate([ + 'settings_json' => ['required', 'array'], + ]); + + if ($validated['settings_json'] !== [] && array_is_list($validated['settings_json'])) { + throw ValidationException::withMessages([ + 'settings_json' => __('The settings json field must be an object.'), + ]); + } + + ThemeSettings::withoutGlobalScopes()->updateOrCreate( + ['theme_id' => $theme->getKey()], + [ + 'settings_json' => $validated['settings_json'], + 'updated_at' => now(), + ], + ); + + $settings->forget($store); + + return ThemeResource::make($theme->refresh()->load('settings')->loadCount('files')); + } + + private function authorizeStore(Request $request, Store $store): void + { + if (! $request->attributes->has('sanctum_personal_access_token')) { + abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403); + } + + app()->instance('current_store', $store); + } + + private function abortUnlessThemeBelongsToStore(Theme $theme, Store $store): void + { + abort_unless((int) $theme->store_id === $store->getKey(), 404); + } +} diff --git a/app/Http/Controllers/Api/Apps/V1/DeferredEndpointController.php b/app/Http/Controllers/Api/Apps/V1/DeferredEndpointController.php new file mode 100644 index 00000000..c9e31cc5 --- /dev/null +++ b/app/Http/Controllers/Api/Apps/V1/DeferredEndpointController.php @@ -0,0 +1,16 @@ +json([ + 'message' => 'App API endpoints are deferred for initial implementation.', + ], 501); + } +} diff --git a/app/Http/Controllers/Api/OAuthController.php b/app/Http/Controllers/Api/OAuthController.php new file mode 100644 index 00000000..cf628e22 --- /dev/null +++ b/app/Http/Controllers/Api/OAuthController.php @@ -0,0 +1,26 @@ +notImplemented(); + } + + public function token(): JsonResponse + { + return $this->notImplemented(); + } + + private function notImplemented(): JsonResponse + { + return response()->json([ + 'message' => 'OAuth app ecosystem endpoints are deferred for initial implementation.', + ], 501); + } +} diff --git a/app/Http/Controllers/Api/Storefront/V1/AnalyticsEventController.php b/app/Http/Controllers/Api/Storefront/V1/AnalyticsEventController.php new file mode 100644 index 00000000..64a9d69c --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/V1/AnalyticsEventController.php @@ -0,0 +1,28 @@ +trackBatch($this->currentStore(), $request->validated('events')); + + return response()->json($result, 202); + } + + private function currentStore(): Store + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } +} diff --git a/app/Http/Controllers/Api/Storefront/V1/CartController.php b/app/Http/Controllers/Api/Storefront/V1/CartController.php new file mode 100644 index 00000000..dd92721a --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/V1/CartController.php @@ -0,0 +1,45 @@ +validated(); + + return CartResource::make($this->loadCart($carts->create($this->currentStore()))) + ->response() + ->setStatusCode(201); + } + + public function show(Cart $cart): CartResource + { + return CartResource::make($this->loadCart($cart)); + } + + private function currentStore(): Store + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } + + private function loadCart(Cart $cart): Cart + { + return $cart->load([ + 'lines.variant.product', + 'lines.variant.optionValues.option', + ]); + } +} diff --git a/app/Http/Controllers/Api/Storefront/V1/CartLineController.php b/app/Http/Controllers/Api/Storefront/V1/CartLineController.php new file mode 100644 index 00000000..5401a5e4 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/V1/CartLineController.php @@ -0,0 +1,92 @@ +addLine( + $cart, + (int) $request->validated('variant_id'), + (int) $request->validated('quantity'), + $request->expectedVersion(), + ); + + return CartResource::make($this->loadCart($cart->refresh())) + ->response() + ->setStatusCode(201); + } catch (CartVersionMismatchException $exception) { + return $this->versionMismatch($exception, $cart); + } catch (InsufficientInventoryException|InvalidCartOperationException $exception) { + return response()->json(['message' => $exception->getMessage()], 422); + } + } + + public function update(UpdateCartLineRequest $request, Cart $cart, CartLine $cartLine, CartService $carts): CartResource|JsonResponse + { + abort_unless((int) $cartLine->cart_id === (int) $cart->getKey(), 404); + + try { + $carts->updateLineQuantity( + $cart, + $cartLine->getKey(), + (int) $request->validated('quantity'), + $request->expectedVersion(), + ); + + return CartResource::make($this->loadCart($cart->refresh())); + } catch (CartVersionMismatchException $exception) { + return $this->versionMismatch($exception, $cart); + } catch (InsufficientInventoryException|InvalidCartOperationException $exception) { + return response()->json(['message' => $exception->getMessage()], 422); + } + } + + public function destroy(DestroyCartLineRequest $request, Cart $cart, CartLine $cartLine, CartService $carts): CartResource|JsonResponse + { + abort_unless((int) $cartLine->cart_id === (int) $cart->getKey(), 404); + + try { + $carts->removeLine($cart, $cartLine->getKey(), $request->expectedVersion()); + } catch (CartVersionMismatchException $exception) { + return $this->versionMismatch($exception, $cart); + } catch (InvalidCartOperationException $exception) { + return response()->json(['message' => $exception->getMessage()], 422); + } + + return CartResource::make($this->loadCart($cart->refresh())); + } + + private function versionMismatch(CartVersionMismatchException $exception, Cart $cart): JsonResponse + { + return response()->json([ + 'message' => $exception->getMessage(), + 'expected_cart_version' => $exception->expectedVersion, + 'current_cart_version' => $exception->currentVersion, + 'cart' => CartResource::make($this->loadCart($cart->refresh())), + ], 409); + } + + private function loadCart(Cart $cart): Cart + { + return $cart->load([ + 'lines.variant.product', + 'lines.variant.optionValues.option', + ]); + } +} diff --git a/app/Http/Controllers/Api/Storefront/V1/CheckoutController.php b/app/Http/Controllers/Api/Storefront/V1/CheckoutController.php new file mode 100644 index 00000000..3ca8e18e --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/V1/CheckoutController.php @@ -0,0 +1,226 @@ +findOrFail($request->validated('cart_id')); + abort_unless((int) $cart->store_id === $this->currentStore()->getKey(), 404); + + try { + $checkout = $checkouts->createFromCart($cart); + $checkout->forceFill([ + 'email' => (string) $request->validated('email'), + ])->save(); + $pricing->calculate($checkout); + + return CheckoutResource::make($this->loadCheckout( + $checkout->refresh(), + ))->response()->setStatusCode(201); + } catch (InvalidCheckoutTransitionException $exception) { + return response()->json(['message' => $exception->getMessage()], 422); + } + } + + public function show(Request $request, Checkout $checkout): CheckoutResource + { + $this->authorizeCheckoutAccess($request, $checkout); + + return CheckoutResource::make($this->loadCheckout($checkout)); + } + + public function address(SetCheckoutAddressRequest $request, Checkout $checkout, CheckoutService $checkouts): CheckoutResource|JsonResponse + { + $this->authorizeCheckoutAccess($request, $checkout); + + $addressData = $request->validated(); + $addressData['email'] ??= $checkout->email; + + try { + return CheckoutResource::make($this->loadCheckout($checkouts->setAddress($checkout, $addressData))); + } catch (InvalidCheckoutTransitionException $exception) { + return response()->json(['message' => $exception->getMessage()], 422); + } + } + + public function shippingMethod(SetCheckoutShippingRequest $request, Checkout $checkout, CheckoutService $checkouts): CheckoutResource|JsonResponse + { + $this->authorizeCheckoutAccess($request, $checkout); + + try { + return CheckoutResource::make($this->loadCheckout($checkouts->setShippingMethod( + $checkout, + $request->validated('shipping_rate_id'), + ))); + } catch (InvalidCheckoutTransitionException|UnserviceableShippingAddressException $exception) { + return response()->json(['message' => $exception->getMessage()], 422); + } + } + + public function applyDiscount(ApplyCheckoutDiscountRequest $request, Checkout $checkout, PricingEngine $pricing): CheckoutResource|JsonResponse + { + $this->authorizeCheckoutAccess($request, $checkout); + + $checkout->forceFill([ + 'discount_code' => trim((string) $request->validated('code')) ?: null, + ])->save(); + + try { + $pricing->calculate($checkout); + + return CheckoutResource::make($this->loadCheckout($checkout->refresh())); + } catch (InvalidDiscountException $exception) { + $checkout->forceFill(['discount_code' => null])->save(); + $pricing->calculate($checkout); + + return response()->json([ + 'message' => $exception->getMessage(), + 'reason' => $exception->reasonCode, + ], 422); + } + } + + public function destroyDiscount(Request $request, Checkout $checkout, PricingEngine $pricing): CheckoutResource + { + $this->authorizeCheckoutAccess($request, $checkout); + abort_if($checkout->discount_code === null, 404); + + $checkout->forceFill(['discount_code' => null])->save(); + $pricing->calculate($checkout); + + return CheckoutResource::make($this->loadCheckout($checkout->refresh())); + } + + public function paymentMethod(SelectCheckoutPaymentRequest $request, Checkout $checkout, CheckoutService $checkouts): CheckoutResource|JsonResponse + { + $this->authorizeCheckoutAccess($request, $checkout); + + try { + return CheckoutResource::make($this->loadCheckout($checkouts->selectPaymentMethod( + $checkout, + (string) $request->validated('payment_method'), + ))); + } catch (InsufficientInventoryException|InvalidCheckoutTransitionException $exception) { + return response()->json(['message' => $exception->getMessage()], 422); + } + } + + public function pay(CompleteCheckoutPaymentRequest $request, Checkout $checkout, CheckoutService $checkouts): OrderResource|JsonResponse + { + $this->authorizeCheckoutAccess($request, $checkout); + + try { + if ($checkout->payment_method !== $request->validated('payment_method')) { + $checkout = $checkouts->selectPaymentMethod($checkout, (string) $request->validated('payment_method')); + } + + $order = $checkouts->completeCheckout($checkout, [ + 'card_number' => $request->validated('card_number'), + 'cardholder_name' => $request->validated('card_holder'), + 'expiry' => $request->validated('card_expiry'), + 'cvc' => $request->validated('card_cvc'), + ]); + + return OrderResource::make($this->loadOrder($order)) + ->response() + ->setStatusCode(200); + } catch (PaymentFailedException $exception) { + return response()->json(['message' => $exception->getMessage()], 402); + } catch (InsufficientInventoryException|InvalidCheckoutTransitionException $exception) { + return response()->json(['message' => $exception->getMessage()], 422); + } + } + + private function authorizeCheckoutAccess(Request $request, Checkout $checkout): void + { + abort_unless((int) $checkout->store_id === $this->currentStore()->getKey(), 404); + abort_unless(CheckoutAccessToken::valid($checkout, $this->tokenFromRequest($request)), 404); + } + + private function tokenFromRequest(Request $request): ?string + { + $token = $request->query('token'); + + if (is_string($token) && $token !== '') { + return $token; + } + + $token = $request->header('X-Checkout-Token'); + + return is_string($token) && $token !== '' ? $token : null; + } + + private function currentStore(): Store + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } + + private function loadCheckout(Checkout $checkout): Checkout + { + $checkout = $checkout->load([ + 'cart.lines.variant.product', + 'cart.lines.variant.optionValues.option', + 'store', + ]); + + $checkout->setRelation('availableRates', $this->availableRates($checkout)); + + return $checkout; + } + + private function loadOrder(Order $order): Order + { + return $order->load(['lines', 'payments', 'fulfillments.lines']); + } + + /** + * @return Collection + */ + private function availableRates(Checkout $checkout): Collection + { + if (! is_array($checkout->shipping_address_json) || $checkout->shipping_address_json === []) { + return collect(); + } + + return app(ShippingCalculator::class) + ->getAvailableRates($checkout->store, $checkout->shipping_address_json) + ->map(function (ShippingRate $rate) use ($checkout): ShippingRate { + $rate->setAttribute('calculated_amount', app(ShippingCalculator::class)->calculate($rate, $checkout->cart)); + + return $rate; + }); + } +} diff --git a/app/Http/Controllers/Api/Storefront/V1/OrderController.php b/app/Http/Controllers/Api/Storefront/V1/OrderController.php new file mode 100644 index 00000000..6f88249c --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/V1/OrderController.php @@ -0,0 +1,30 @@ +with(['lines', 'payments', 'fulfillments.lines']) + ->where('store_id', $store->getKey()) + ->where('order_number', urldecode($orderNumber)) + ->firstOrFail(); + + abort_unless(OrderAccessToken::valid($order, $request->query('token')), 404); + + return OrderResource::make($order); + } +} diff --git a/app/Http/Controllers/Api/Storefront/V1/SearchController.php b/app/Http/Controllers/Api/Storefront/V1/SearchController.php new file mode 100644 index 00000000..e04e9111 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/V1/SearchController.php @@ -0,0 +1,62 @@ +validated(); + $filters = $validated['filters'] ?? []; + $query = trim((string) $validated['q']); + $store = $this->currentStore(); + + $results = $search->search( + $store, + $query, + $filters, + (int) ($validated['per_page'] ?? 24), + (string) ($validated['sort'] ?? 'relevance'), + ); + + return response()->json([ + 'query' => $query, + 'results' => SearchProductResource::collection($results->getCollection())->resolve($request), + 'facets' => $search->facets($store, $query, $filters), + 'pagination' => [ + 'current_page' => $results->currentPage(), + 'per_page' => $results->perPage(), + 'total' => $results->total(), + 'last_page' => $results->lastPage(), + ], + ]); + } + + public function suggest(SuggestProductsRequest $request, SearchService $search): JsonResponse + { + $validated = $request->validated(); + $query = trim((string) $validated['q']); + + return response()->json([ + 'query' => $query, + 'suggestions' => $search->suggestions($this->currentStore(), $query, (int) ($validated['limit'] ?? 5)), + ]); + } + + private function currentStore(): Store + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } +} diff --git a/app/Http/Controllers/Storefront/Account/Auth/CustomerPasswordResetController.php b/app/Http/Controllers/Storefront/Account/Auth/CustomerPasswordResetController.php new file mode 100644 index 00000000..6ec0c963 --- /dev/null +++ b/app/Http/Controllers/Storefront/Account/Auth/CustomerPasswordResetController.php @@ -0,0 +1,54 @@ +validated(); + + $passwords->sendResetLink($this->currentStore(), $validated['email']); + + return back()->with('status', __('If an account matches that email, a reset link has been sent.')); + } + + public function update(ResetCustomerPasswordRequest $request, CustomerPasswordResetService $passwords): RedirectResponse + { + $validated = $request->validated(); + + $reset = $passwords->reset( + $this->currentStore(), + $validated['email'], + $validated['token'], + $validated['password'], + ); + + if (! $reset) { + throw ValidationException::withMessages([ + 'email' => __('This password reset link is invalid or has expired.'), + ]); + } + + return redirect() + ->route('account.login') + ->with('status', __('Your password has been reset. You may log in with your new password.')); + } + + private function currentStore(): Store + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } +} diff --git a/app/Http/Middleware/AuthenticateAdminApi.php b/app/Http/Middleware/AuthenticateAdminApi.php new file mode 100644 index 00000000..576cd4e5 --- /dev/null +++ b/app/Http/Middleware/AuthenticateAdminApi.php @@ -0,0 +1,163 @@ +storeFromRoute($request); + + app()->instance('current_store', $store); + + $token = $this->tokenFromRequest($request); + $user = $token?->tokenable; + + if (! $user instanceof User || ! $token instanceof PersonalAccessToken) { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + + if ((int) $token->store_id !== $store->getKey()) { + return response()->json(['message' => 'Forbidden.'], 403); + } + + if (! $this->userCanAccessStore($user, $store, $abilities)) { + return response()->json(['message' => 'Forbidden.'], 403); + } + + if (! $this->hasAbilities($token, $abilities)) { + return response()->json(['message' => 'Forbidden.'], 403); + } + + $token->forceFill(['last_used_at' => now()])->save(); + + return $next($request); + } + + private function storeFromRoute(Request $request): Store + { + $store = $request->route('store'); + + if ($store instanceof Store) { + return $store; + } + + return Store::query()->findOrFail($store); + } + + private function tokenFromRequest(Request $request): ?PersonalAccessToken + { + $token = $request->attributes->get('sanctum_personal_access_token'); + + if ($token instanceof PersonalAccessToken) { + return $token; + } + + $plainTextToken = $request->bearerToken(); + + if (! is_string($plainTextToken) || $plainTextToken === '') { + return null; + } + + $token = PersonalAccessToken::query() + ->with('tokenable') + ->where('token', hash('sha256', $this->plainTokenForHashing($plainTextToken))) + ->first(); + + if (! $token instanceof PersonalAccessToken || $token->isExpired()) { + return null; + } + + $request->attributes->set('sanctum_personal_access_token', $token); + app()->instance('sanctum_personal_access_token', $token); + + return $token; + } + + private function plainTokenForHashing(string $plainTextToken): string + { + if (str_contains($plainTextToken, '|')) { + return (string) str($plainTextToken)->after('|'); + } + + return $plainTextToken; + } + + /** + * @param list $abilities + */ + private function hasAbilities(PersonalAccessToken $token, array $abilities): bool + { + if ($abilities === []) { + return true; + } + + foreach ($abilities as $ability) { + if (! $token->can($ability)) { + return false; + } + } + + return true; + } + + /** + * @param list $abilities + */ + private function userCanAccessStore(User $user, Store $store, array $abilities): bool + { + $role = $user->roleForStore($store); + + if (! $role instanceof StoreUserRole) { + return false; + } + + if ($abilities === []) { + return true; + } + + foreach ($abilities as $ability) { + if (! $this->roleAllowsAbility($role, $ability)) { + return false; + } + } + + return true; + } + + private function roleAllowsAbility(StoreUserRole $role, string $ability): bool + { + return match ($ability) { + 'read-products', + 'read-orders', + 'read-customers', + 'read-collections', + 'read-discounts' => true, + 'write-products', + 'write-collections', + 'write-discounts', + 'read-content', + 'write-content' => in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff], true), + 'write-orders' => in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff], true), + 'read-analytics' => in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff], true), + 'read-settings', + 'write-settings', + 'write-themes', + 'manage-platform' => in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin], true), + default => false, + }; + } +} diff --git a/app/Http/Middleware/AuthenticatePlatformApi.php b/app/Http/Middleware/AuthenticatePlatformApi.php new file mode 100644 index 00000000..5280d207 --- /dev/null +++ b/app/Http/Middleware/AuthenticatePlatformApi.php @@ -0,0 +1,78 @@ +tokenFromRequest($request); + $user = $token?->tokenable; + + if (! $user instanceof User || ! $token instanceof PersonalAccessToken) { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + + if (! $this->userCanManagePlatform($user) || ! $token->can('manage-platform')) { + return response()->json(['message' => 'Forbidden.'], 403); + } + + $token->forceFill(['last_used_at' => now()])->save(); + + return $next($request); + } + + private function userCanManagePlatform(User $user): bool + { + return $user->is_platform_admin === true; + } + + private function tokenFromRequest(Request $request): ?PersonalAccessToken + { + $token = $request->attributes->get('sanctum_personal_access_token'); + + if ($token instanceof PersonalAccessToken) { + return $token; + } + + $plainTextToken = $request->bearerToken(); + + if (! is_string($plainTextToken) || $plainTextToken === '') { + return null; + } + + $token = PersonalAccessToken::query() + ->with('tokenable') + ->where('token', hash('sha256', $this->plainTokenForHashing($plainTextToken))) + ->first(); + + if (! $token instanceof PersonalAccessToken || $token->isExpired()) { + return null; + } + + $request->attributes->set('sanctum_personal_access_token', $token); + app()->instance('sanctum_personal_access_token', $token); + + return $token; + } + + private function plainTokenForHashing(string $plainTextToken): string + { + if (str_contains($plainTextToken, '|')) { + return (string) str($plainTextToken)->after('|'); + } + + return $plainTextToken; + } +} diff --git a/app/Http/Middleware/CheckStoreRole.php b/app/Http/Middleware/CheckStoreRole.php new file mode 100644 index 00000000..2cd47bbc --- /dev/null +++ b/app/Http/Middleware/CheckStoreRole.php @@ -0,0 +1,42 @@ +bound('current_store') ? app('current_store') : null; + $user = $request->user(); + + abort_unless($store instanceof Store && $user, 403); + + $role = $user->roleForStore($store); + + if ($roles === []) { + abort_unless($role !== null, 403); + + return $next($request); + } + + $allowedRoles = array_filter(array_map( + fn (string $role): ?StoreUserRole => StoreUserRole::tryFrom($role), + $roles, + )); + + abort_unless($role !== null && in_array($role, $allowedRoles, true), 403); + + return $next($request); + } +} diff --git a/app/Http/Middleware/EnsureUserEmailIsVerified.php b/app/Http/Middleware/EnsureUserEmailIsVerified.php new file mode 100644 index 00000000..cbce174d --- /dev/null +++ b/app/Http/Middleware/EnsureUserEmailIsVerified.php @@ -0,0 +1,33 @@ +user(); + + if (! $user || ($user instanceof MustVerifyEmail && ! $user->hasVerifiedEmail())) { + if ($request->expectsJson()) { + abort(403, 'Your email address is not verified.'); + } + + return new RedirectResponse(URL::route($redirectToRoute ?: 'verification.notice')); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/ResolveStore.php b/app/Http/Middleware/ResolveStore.php new file mode 100644 index 00000000..2d7f6755 --- /dev/null +++ b/app/Http/Middleware/ResolveStore.php @@ -0,0 +1,79 @@ +is('admin*') + ? $this->resolveAdminStore($request) + : $this->resolveStorefrontStore($request); + + abort_unless($store, 404); + + app()->instance('current_store', $store); + + if (! $request->is('admin*') && $store->status === StoreStatus::Suspended) { + abort(503); + } + + if ($request->is('admin*') && $store->status === StoreStatus::Suspended && ! $request->isMethodSafe()) { + abort(403); + } + + return $next($request); + } + + private function resolveStorefrontStore(Request $request): ?Store + { + $hostname = $request->getHost(); + $cacheKey = "store_domain:{$hostname}"; + + $storeId = Cache::remember($cacheKey, now()->addMinutes(5), function () use ($hostname): ?int { + return StoreDomain::query() + ->where('hostname', $hostname) + ->value('store_id'); + }); + + return $storeId ? Store::query()->find($storeId) : null; + } + + private function resolveAdminStore(Request $request): ?Store + { + $user = $request->user(); + + if (! $user) { + return null; + } + + $storeId = $request->session()->get('current_store_id'); + + if (! $storeId) { + $storeId = $user->stores()->oldest('stores.id')->value('stores.id'); + + if ($storeId) { + $request->session()->put('current_store_id', $storeId); + } + } + + if (! $storeId || ! $user->stores()->whereKey($storeId)->exists()) { + return null; + } + + return Store::query()->find($storeId); + } +} diff --git a/app/Http/Requests/Api/Admin/V1/CreateOrderFulfillmentRequest.php b/app/Http/Requests/Api/Admin/V1/CreateOrderFulfillmentRequest.php new file mode 100644 index 00000000..4e9af842 --- /dev/null +++ b/app/Http/Requests/Api/Admin/V1/CreateOrderFulfillmentRequest.php @@ -0,0 +1,55 @@ +route('store'); + $order = $this->route('order'); + + $store = $store instanceof Store ? $store : Store::query()->find($store); + + if (! $store instanceof Store) { + return false; + } + + app()->instance('current_store', $store); + + if (! $order instanceof Order || (int) $order->store_id !== $store->getKey()) { + return true; + } + + return $this->user()?->can('createFulfillment', $order) ?? false; + } + + /** + * @return array|string> + */ + public function rules(): array + { + return [ + 'line_items' => ['required', 'array', 'min:1'], + 'line_items.*.order_line_id' => ['required', 'integer'], + 'line_items.*.quantity' => ['required', 'integer', 'min:1'], + 'tracking_company' => ['nullable', 'string', 'max:255'], + 'tracking_number' => ['nullable', 'string', 'max:255'], + 'tracking_url' => ['nullable', 'url', 'max:2048'], + ]; + } + + /** + * @return array + */ + public function lineQuantities(): array + { + return collect($this->validated('line_items')) + ->mapWithKeys(fn (array $line): array => [(int) $line['order_line_id'] => (int) $line['quantity']]) + ->all(); + } +} diff --git a/app/Http/Requests/Api/Admin/V1/CreateOrderRefundRequest.php b/app/Http/Requests/Api/Admin/V1/CreateOrderRefundRequest.php new file mode 100644 index 00000000..01c9f7ba --- /dev/null +++ b/app/Http/Requests/Api/Admin/V1/CreateOrderRefundRequest.php @@ -0,0 +1,55 @@ +route('store'); + $order = $this->route('order'); + + $store = $store instanceof Store ? $store : Store::query()->find($store); + + if (! $store instanceof Store) { + return false; + } + + app()->instance('current_store', $store); + + if (! $order instanceof Order || (int) $order->store_id !== $store->getKey()) { + return true; + } + + return $this->user()?->can('createRefund', $order) ?? false; + } + + /** + * @return array|string> + */ + public function rules(): array + { + return [ + 'amount' => ['nullable', 'integer', 'min:1'], + 'reason' => ['nullable', 'string', 'max:500'], + 'restock' => ['sometimes', 'boolean'], + 'line_items' => ['nullable', 'array'], + 'line_items.*.order_line_id' => ['required_with:line_items', 'integer'], + 'line_items.*.quantity' => ['required_with:line_items', 'integer', 'min:1'], + ]; + } + + /** + * @return array + */ + public function lineQuantities(): array + { + return collect($this->validated('line_items', [])) + ->mapWithKeys(fn (array $line): array => [(int) $line['order_line_id'] => (int) $line['quantity']]) + ->all(); + } +} diff --git a/app/Http/Requests/Api/Admin/V1/CreateProductMediaUploadRequest.php b/app/Http/Requests/Api/Admin/V1/CreateProductMediaUploadRequest.php new file mode 100644 index 00000000..a46e1f0b --- /dev/null +++ b/app/Http/Requests/Api/Admin/V1/CreateProductMediaUploadRequest.php @@ -0,0 +1,85 @@ +route('store'); + $product = $this->route('product'); + + $store = $store instanceof Store ? $store : Store::query()->find($store); + + if (! $store instanceof Store) { + return false; + } + + app()->instance('current_store', $store); + + if (! $product instanceof Product || (int) $product->store_id !== $store->getKey()) { + return true; + } + + return $this->user()?->can('update', $product) ?? false; + } + + /** + * @return array|string> + */ + public function rules(): array + { + return [ + 'filename' => ['required', 'string', 'max:255'], + 'content_type' => ['required', Rule::in(['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'video/mp4'])], + 'byte_size' => ['required', 'integer', 'min:1'], + ]; + } + + /** + * @return array + */ + public function after(): array + { + return [ + function (Validator $validator): void { + $this->validateExtension($validator); + $this->validateByteSize($validator); + }, + ]; + } + + private function validateExtension(Validator $validator): void + { + $extension = strtolower(pathinfo((string) $this->input('filename'), PATHINFO_EXTENSION)); + $allowedExtensions = match ((string) $this->input('content_type')) { + 'image/jpeg' => ['jpg', 'jpeg'], + 'image/png' => ['png'], + 'image/webp' => ['webp'], + 'image/avif' => ['avif'], + 'video/mp4' => ['mp4'], + default => [], + }; + + if ($extension === '' || ! in_array($extension, $allowedExtensions, true)) { + $validator->errors()->add('filename', __('The filename extension must match the content type.')); + } + } + + private function validateByteSize(Validator $validator): void + { + $limit = $this->input('content_type') === 'video/mp4' + ? 500 * 1024 * 1024 + : 50 * 1024 * 1024; + + if ((int) $this->input('byte_size') > $limit) { + $validator->errors()->add('byte_size', __('The uploaded media file is too large.')); + } + } +} diff --git a/app/Http/Requests/Api/Admin/V1/StoreInviteRequest.php b/app/Http/Requests/Api/Admin/V1/StoreInviteRequest.php new file mode 100644 index 00000000..1af0a3b0 --- /dev/null +++ b/app/Http/Requests/Api/Admin/V1/StoreInviteRequest.php @@ -0,0 +1,25 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email', 'max:255'], + 'role' => ['required', Rule::in(['owner', 'admin', 'staff', 'support'])], + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/V1/StorePlatformOrganizationRequest.php b/app/Http/Requests/Api/Admin/V1/StorePlatformOrganizationRequest.php new file mode 100644 index 00000000..cbd80a78 --- /dev/null +++ b/app/Http/Requests/Api/Admin/V1/StorePlatformOrganizationRequest.php @@ -0,0 +1,24 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'billing_email' => ['required', 'email', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/V1/StorePlatformStoreRequest.php b/app/Http/Requests/Api/Admin/V1/StorePlatformStoreRequest.php new file mode 100644 index 00000000..f92ef91c --- /dev/null +++ b/app/Http/Requests/Api/Admin/V1/StorePlatformStoreRequest.php @@ -0,0 +1,35 @@ +|string> + */ + public function rules(): array + { + return [ + 'organization_id' => ['required', 'integer', Rule::exists('organizations', 'id')], + 'name' => ['required', 'string', 'max:255'], + 'handle' => [ + 'required', + 'string', + 'max:63', + 'regex:/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/', + Rule::unique('stores', 'handle'), + ], + 'default_currency' => ['required', 'string', 'size:3'], + 'default_locale' => ['required', 'string', 'max:12'], + 'timezone' => ['required', 'timezone:all'], + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/V1/StoreProductRequest.php b/app/Http/Requests/Api/Admin/V1/StoreProductRequest.php new file mode 100644 index 00000000..c5e6b9f6 --- /dev/null +++ b/app/Http/Requests/Api/Admin/V1/StoreProductRequest.php @@ -0,0 +1,157 @@ +routeStore(); + + app()->instance('current_store', $store); + + return $this->user()?->can('create', Product::class) ?? false; + } + + /** + * @return array|string> + */ + public function rules(): array + { + $store = $this->routeStore(); + + return [ + 'title' => ['required', 'string', 'max:255'], + 'handle' => [ + 'sometimes', + 'nullable', + 'string', + 'max:255', + 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/', + Rule::unique('products', 'handle')->where('store_id', $store->getKey()), + ], + 'description_html' => ['sometimes', 'nullable', 'string', 'max:65535'], + 'vendor' => ['sometimes', 'nullable', 'string', 'max:255'], + 'product_type' => ['sometimes', 'nullable', 'string', 'max:255'], + 'status' => ['sometimes', Rule::in(['draft', 'active'])], + 'tags' => ['sometimes', 'array', 'max:50'], + 'tags.*' => ['string', 'max:255'], + 'options' => ['sometimes', 'array', 'max:3'], + 'options.*.name' => ['required_with:options', 'string', 'max:255'], + 'options.*.position' => ['required_with:options', 'integer', 'min:1', 'max:3'], + 'options.*.values' => ['sometimes', 'array', 'max:100'], + 'options.*.values.*' => ['string', 'max:255'], + 'variants' => ['required', 'array', 'min:1', 'max:100'], + 'variants.*.sku' => ['required', 'string', 'max:255'], + 'variants.*.barcode' => ['sometimes', 'nullable', 'string', 'max:255'], + 'variants.*.price_amount' => ['required', 'integer', 'min:0'], + 'variants.*.compare_at_amount' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'variants.*.currency' => ['sometimes', 'string', 'size:3'], + 'variants.*.weight_g' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'variants.*.requires_shipping' => ['sometimes', 'boolean'], + 'variants.*.is_default' => ['required', 'boolean'], + 'variants.*.position' => ['sometimes', 'integer', 'min:0'], + 'variants.*.status' => ['sometimes', Rule::in(['active', 'archived'])], + 'variants.*.option_values' => ['sometimes', 'array'], + 'variants.*.option_values.*.option_name' => ['required_with:variants.*.option_values', 'string', 'max:255'], + 'variants.*.option_values.*.value' => ['required_with:variants.*.option_values', 'string', 'max:255'], + 'variants.*.inventory' => ['sometimes', 'array'], + 'variants.*.inventory.quantity_on_hand' => ['sometimes', 'integer', 'min:0'], + 'variants.*.inventory.policy' => ['sometimes', Rule::in(['deny', 'continue'])], + 'collections' => ['sometimes', 'array'], + 'collections.*' => ['integer', Rule::exists('collections', 'id')->where('store_id', $store->getKey())], + ]; + } + + /** + * @return array + */ + public function after(): array + { + return [ + function (Validator $validator): void { + $this->validateVariantDefaults($validator); + $this->validateVariantPrices($validator); + $this->validateVariantSkus($validator); + }, + ]; + } + + private function routeStore(): Store + { + $store = $this->route('store'); + + return $store instanceof Store ? $store : Store::query()->findOrFail($store); + } + + private function validateVariantDefaults(Validator $validator): void + { + $variants = $this->input('variants', []); + + if (! is_array($variants)) { + return; + } + + $defaultCount = collect($variants) + ->filter(fn (mixed $variant): bool => is_array($variant) && (bool) ($variant['is_default'] ?? false)) + ->count(); + + if ($defaultCount !== 1) { + $validator->errors()->add('variants', __('Exactly one product variant must be marked as default.')); + } + } + + private function validateVariantPrices(Validator $validator): void + { + foreach ($this->input('variants', []) as $index => $variant) { + if (! is_array($variant) || ! isset($variant['compare_at_amount'])) { + continue; + } + + $compareAtAmount = $variant['compare_at_amount']; + + if ($compareAtAmount === null || $compareAtAmount === '') { + continue; + } + + if ((int) $compareAtAmount <= (int) ($variant['price_amount'] ?? 0)) { + $validator->errors()->add("variants.{$index}.compare_at_amount", __('The compare at amount must be greater than the price amount.')); + } + } + } + + private function validateVariantSkus(Validator $validator): void + { + $skus = collect($this->input('variants', [])) + ->map(fn (mixed $variant): string => is_array($variant) ? trim((string) ($variant['sku'] ?? '')) : '') + ->filter() + ->values(); + + if ($skus->duplicates()->isNotEmpty()) { + $validator->errors()->add('variants.0.sku', __('Each variant SKU must be unique for this store.')); + + return; + } + + $conflictingSku = ProductVariant::withoutGlobalScopes() + ->whereIn('sku', $skus->all()) + ->whereHas('product', function (Builder $query): void { + $query + ->withoutGlobalScopes() + ->where('store_id', $this->routeStore()->getKey()); + }) + ->value('sku'); + + if ($conflictingSku !== null) { + $validator->errors()->add('variants.0.sku', __('The SKU [:sku] is already used in this store.', ['sku' => $conflictingSku])); + } + } +} diff --git a/app/Http/Requests/Api/Admin/V1/UpdateProductRequest.php b/app/Http/Requests/Api/Admin/V1/UpdateProductRequest.php new file mode 100644 index 00000000..c507c7c3 --- /dev/null +++ b/app/Http/Requests/Api/Admin/V1/UpdateProductRequest.php @@ -0,0 +1,215 @@ +routeStore(); + $product = $this->routeProduct(); + + app()->instance('current_store', $store); + + if (! $product instanceof Product || (int) $product->store_id !== $store->getKey()) { + return true; + } + + return $this->user()?->can('update', $product) ?? false; + } + + /** + * @return array|string> + */ + public function rules(): array + { + $store = $this->routeStore(); + $product = $this->routeProduct(); + + return [ + 'title' => ['sometimes', 'string', 'max:255'], + 'handle' => [ + 'sometimes', + 'nullable', + 'string', + 'max:255', + 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/', + Rule::unique('products', 'handle') + ->where('store_id', $store->getKey()) + ->ignore($product?->getKey()), + ], + 'description_html' => ['sometimes', 'nullable', 'string', 'max:65535'], + 'vendor' => ['sometimes', 'nullable', 'string', 'max:255'], + 'product_type' => ['sometimes', 'nullable', 'string', 'max:255'], + 'status' => ['sometimes', Rule::in(['draft', 'active', 'archived'])], + 'tags' => ['sometimes', 'array', 'max:50'], + 'tags.*' => ['string', 'max:255'], + 'options' => ['sometimes', 'array', 'max:3'], + 'options.*.name' => ['required_with:options', 'string', 'max:255'], + 'options.*.position' => ['required_with:options', 'integer', 'min:1', 'max:3'], + 'options.*.values' => ['sometimes', 'array', 'max:100'], + 'options.*.values.*' => ['string', 'max:255'], + 'variants' => ['sometimes', 'array', 'min:1', 'max:100'], + 'variants.*.id' => ['sometimes', 'integer'], + 'variants.*.sku' => ['required_with:variants', 'string', 'max:255'], + 'variants.*.barcode' => ['sometimes', 'nullable', 'string', 'max:255'], + 'variants.*.price_amount' => ['required_with:variants', 'integer', 'min:0'], + 'variants.*.compare_at_amount' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'variants.*.currency' => ['sometimes', 'string', 'size:3'], + 'variants.*.weight_g' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'variants.*.requires_shipping' => ['sometimes', 'boolean'], + 'variants.*.is_default' => ['required_with:variants', 'boolean'], + 'variants.*.position' => ['sometimes', 'integer', 'min:0'], + 'variants.*.status' => ['sometimes', Rule::in(['active', 'archived'])], + 'variants.*.option_values' => ['sometimes', 'array'], + 'variants.*.option_values.*.option_name' => ['required_with:variants.*.option_values', 'string', 'max:255'], + 'variants.*.option_values.*.value' => ['required_with:variants.*.option_values', 'string', 'max:255'], + 'variants.*.inventory' => ['sometimes', 'array'], + 'variants.*.inventory.quantity_on_hand' => ['sometimes', 'integer', 'min:0'], + 'variants.*.inventory.policy' => ['sometimes', Rule::in(['deny', 'continue'])], + 'collections' => ['sometimes', 'array'], + 'collections.*' => ['integer', Rule::exists('collections', 'id')->where('store_id', $store->getKey())], + ]; + } + + /** + * @return array + */ + public function after(): array + { + return [ + function (Validator $validator): void { + $this->validateVariantDefaults($validator); + $this->validateVariantPrices($validator); + $this->validateVariantSkus($validator); + $this->validateVariantIds($validator); + }, + ]; + } + + private function routeStore(): Store + { + $store = $this->route('store'); + + return $store instanceof Store ? $store : Store::query()->findOrFail($store); + } + + private function routeProduct(): ?Product + { + $product = $this->route('product'); + + return $product instanceof Product ? $product : null; + } + + private function validateVariantDefaults(Validator $validator): void + { + if (! $this->has('variants')) { + return; + } + + $variants = $this->input('variants', []); + + if (! is_array($variants)) { + return; + } + + $defaultCount = collect($variants) + ->filter(fn (mixed $variant): bool => is_array($variant) && (bool) ($variant['is_default'] ?? false)) + ->count(); + + if ($defaultCount !== 1) { + $validator->errors()->add('variants', __('Exactly one product variant must be marked as default.')); + } + } + + private function validateVariantPrices(Validator $validator): void + { + foreach ($this->input('variants', []) as $index => $variant) { + if (! is_array($variant) || ! isset($variant['compare_at_amount'])) { + continue; + } + + $compareAtAmount = $variant['compare_at_amount']; + + if ($compareAtAmount === null || $compareAtAmount === '') { + continue; + } + + if ((int) $compareAtAmount <= (int) ($variant['price_amount'] ?? 0)) { + $validator->errors()->add("variants.{$index}.compare_at_amount", __('The compare at amount must be greater than the price amount.')); + } + } + } + + private function validateVariantSkus(Validator $validator): void + { + if (! $this->has('variants')) { + return; + } + + $skus = collect($this->input('variants', [])) + ->map(fn (mixed $variant): string => is_array($variant) ? trim((string) ($variant['sku'] ?? '')) : '') + ->filter() + ->values(); + + if ($skus->duplicates()->isNotEmpty()) { + $validator->errors()->add('variants.0.sku', __('Each variant SKU must be unique for this store.')); + + return; + } + + $product = $this->routeProduct(); + + $conflictingSku = ProductVariant::withoutGlobalScopes() + ->whereIn('sku', $skus->all()) + ->whereHas('product', function (Builder $query): void { + $query + ->withoutGlobalScopes() + ->where('store_id', $this->routeStore()->getKey()); + }) + ->when($product instanceof Product, function (Builder $query) use ($product): void { + $query->where('product_id', '!=', $product->getKey()); + }) + ->value('sku'); + + if ($conflictingSku !== null) { + $validator->errors()->add('variants.0.sku', __('The SKU [:sku] is already used in this store.', ['sku' => $conflictingSku])); + } + } + + private function validateVariantIds(Validator $validator): void + { + $product = $this->routeProduct(); + + if (! $product instanceof Product) { + return; + } + + $variantIds = collect($this->input('variants', [])) + ->pluck('id') + ->filter() + ->map(fn (mixed $variantId): int => (int) $variantId) + ->values(); + + if ($variantIds->isEmpty()) { + return; + } + + $validCount = ProductVariant::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->whereIn('id', $variantIds->all()) + ->count(); + + if ($validCount !== $variantIds->count()) { + $validator->errors()->add('variants', __('Variant IDs must belong to the product being updated.')); + } + } +} diff --git a/app/Http/Requests/Api/Storefront/V1/ApplyCheckoutDiscountRequest.php b/app/Http/Requests/Api/Storefront/V1/ApplyCheckoutDiscountRequest.php new file mode 100644 index 00000000..38bbbbb2 --- /dev/null +++ b/app/Http/Requests/Api/Storefront/V1/ApplyCheckoutDiscountRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'code' => ['required', 'string', 'max:50'], + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/V1/CompleteCheckoutPaymentRequest.php b/app/Http/Requests/Api/Storefront/V1/CompleteCheckoutPaymentRequest.php new file mode 100644 index 00000000..17d1e645 --- /dev/null +++ b/app/Http/Requests/Api/Storefront/V1/CompleteCheckoutPaymentRequest.php @@ -0,0 +1,27 @@ +|string> + */ + public function rules(): array + { + return [ + 'payment_method' => ['required', 'in:credit_card,paypal,bank_transfer'], + 'card_number' => ['required_if:payment_method,credit_card', 'string'], + 'card_holder' => ['required_if:payment_method,credit_card', 'string', 'max:255'], + 'card_expiry' => ['required_if:payment_method,credit_card', 'string', 'max:20'], + 'card_cvc' => ['required_if:payment_method,credit_card', 'string', 'max:10'], + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/V1/DestroyCartLineRequest.php b/app/Http/Requests/Api/Storefront/V1/DestroyCartLineRequest.php new file mode 100644 index 00000000..b65785fe --- /dev/null +++ b/app/Http/Requests/Api/Storefront/V1/DestroyCartLineRequest.php @@ -0,0 +1,34 @@ +|string> + */ + public function rules(): array + { + return [ + 'cart_version' => ['required_without:expected_version', 'integer', 'min:1'], + 'expected_version' => ['required_without:cart_version', 'integer', 'min:1'], + ]; + } + + public function expectedVersion(): int + { + return (int) ($this->validated('cart_version') ?? $this->validated('expected_version')); + } +} diff --git a/app/Http/Requests/Api/Storefront/V1/SearchProductsRequest.php b/app/Http/Requests/Api/Storefront/V1/SearchProductsRequest.php new file mode 100644 index 00000000..b1b6dde5 --- /dev/null +++ b/app/Http/Requests/Api/Storefront/V1/SearchProductsRequest.php @@ -0,0 +1,50 @@ +|string> + */ + public function rules(): array + { + return [ + 'q' => ['required', 'string', 'min:1', 'max:200'], + 'filters' => ['nullable', 'array'], + 'filters.collection_id' => ['nullable', 'integer', 'min:1'], + 'filters.price_min' => ['nullable', 'integer', 'min:0'], + 'filters.price_max' => ['nullable', 'integer', 'min:0', 'gte:filters.price_min'], + 'filters.in_stock' => ['nullable', 'boolean'], + 'filters.tags' => ['nullable', 'array', 'max:20'], + 'filters.tags.*' => ['string', 'max:100'], + 'filters.vendor' => ['nullable', 'string', 'max:255'], + 'sort' => ['nullable', Rule::in(['relevance', 'price_asc', 'price_desc', 'newest', 'best_selling'])], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:50'], + ]; + } + + protected function prepareForValidation(): void + { + if (! is_string($this->input('filters'))) { + return; + } + + $decoded = json_decode((string) $this->input('filters'), true); + + $this->merge([ + 'filters' => json_last_error() === JSON_ERROR_NONE && is_array($decoded) + ? $decoded + : '__invalid_filters__', + ]); + } +} diff --git a/app/Http/Requests/Api/Storefront/V1/SelectCheckoutPaymentRequest.php b/app/Http/Requests/Api/Storefront/V1/SelectCheckoutPaymentRequest.php new file mode 100644 index 00000000..f221fc55 --- /dev/null +++ b/app/Http/Requests/Api/Storefront/V1/SelectCheckoutPaymentRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'payment_method' => ['required', 'in:credit_card,paypal,bank_transfer'], + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/V1/SetCheckoutAddressRequest.php b/app/Http/Requests/Api/Storefront/V1/SetCheckoutAddressRequest.php new file mode 100644 index 00000000..e38184e7 --- /dev/null +++ b/app/Http/Requests/Api/Storefront/V1/SetCheckoutAddressRequest.php @@ -0,0 +1,39 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => ['nullable', 'email'], + 'shipping_address' => ['required', 'array'], + 'shipping_address.first_name' => ['required', 'string'], + 'shipping_address.last_name' => ['required', 'string'], + 'shipping_address.address1' => ['required', 'string'], + 'shipping_address.address2' => ['nullable', 'string'], + 'shipping_address.city' => ['required', 'string'], + 'shipping_address.province_code' => ['nullable', 'string'], + 'shipping_address.country' => ['required', 'string', 'size:2'], + 'shipping_address.country_code' => ['nullable', 'string', 'size:2'], + 'shipping_address.postal_code' => ['required', 'string'], + 'billing_address' => ['nullable', 'array'], + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/V1/SetCheckoutShippingRequest.php b/app/Http/Requests/Api/Storefront/V1/SetCheckoutShippingRequest.php new file mode 100644 index 00000000..bacf768e --- /dev/null +++ b/app/Http/Requests/Api/Storefront/V1/SetCheckoutShippingRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'shipping_rate_id' => ['nullable', 'integer', 'exists:shipping_rates,id'], + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/V1/StoreAnalyticsEventsRequest.php b/app/Http/Requests/Api/Storefront/V1/StoreAnalyticsEventsRequest.php new file mode 100644 index 00000000..c3a7c1ea --- /dev/null +++ b/app/Http/Requests/Api/Storefront/V1/StoreAnalyticsEventsRequest.php @@ -0,0 +1,69 @@ +|string> + */ + public function rules(): array + { + $minOccurredAt = now()->subHour()->format('Y-m-d H:i:s'); + $maxOccurredAt = now()->addHour()->format('Y-m-d H:i:s'); + + return [ + 'events' => ['required', 'array', 'min:1', 'max:50'], + 'events.*.type' => ['required', Rule::in(array_column(AnalyticsEventType::cases(), 'value'))], + 'events.*.session_id' => ['required', 'string', 'max:100'], + 'events.*.client_event_id' => ['required', 'string', 'max:100'], + 'events.*.properties' => ['nullable', 'array'], + 'events.*.occurred_at' => ['required', 'date', "after_or_equal:{$minOccurredAt}", "before_or_equal:{$maxOccurredAt}"], + ]; + } + + /** + * @return array + */ + public function after(): array + { + return [ + function (Validator $validator): void { + foreach ($this->input('events', []) as $index => $event) { + $properties = is_array($event) ? ($event['properties'] ?? []) : []; + + if (is_array($properties) && $this->depth($properties) > 3) { + $validator->errors()->add("events.{$index}.properties", __('Properties may not be nested deeper than 3 levels.')); + } + } + }, + ]; + } + + /** + * @param array $value + */ + private function depth(array $value): int + { + if ($value === []) { + return 1; + } + + $childDepth = collect($value) + ->filter(fn (mixed $item): bool => is_array($item)) + ->map(fn (array $item): int => $this->depth($item)) + ->max() ?? 0; + + return 1 + $childDepth; + } +} diff --git a/app/Http/Requests/Api/Storefront/V1/StoreCartLineRequest.php b/app/Http/Requests/Api/Storefront/V1/StoreCartLineRequest.php new file mode 100644 index 00000000..9ab365df --- /dev/null +++ b/app/Http/Requests/Api/Storefront/V1/StoreCartLineRequest.php @@ -0,0 +1,38 @@ +|string> + */ + public function rules(): array + { + return [ + 'variant_id' => ['required', 'integer', 'exists:product_variants,id'], + 'quantity' => ['required', 'integer', 'min:1'], + 'cart_version' => ['nullable', 'integer', 'min:1'], + 'expected_version' => ['nullable', 'integer', 'min:1'], + ]; + } + + public function expectedVersion(): ?int + { + $version = $this->validated('cart_version') ?? $this->validated('expected_version'); + + return $version === null ? null : (int) $version; + } +} diff --git a/app/Http/Requests/Api/Storefront/V1/StoreCartRequest.php b/app/Http/Requests/Api/Storefront/V1/StoreCartRequest.php new file mode 100644 index 00000000..4b9c5694 --- /dev/null +++ b/app/Http/Requests/Api/Storefront/V1/StoreCartRequest.php @@ -0,0 +1,37 @@ +|string> + */ + public function rules(): array + { + $currencyRules = ['nullable', 'string', 'size:3']; + $store = app()->bound('current_store') ? app('current_store') : null; + + if ($store instanceof Store) { + $currencyRules[] = Rule::in([$store->default_currency]); + } + + return [ + 'currency' => $currencyRules, + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/V1/StoreCheckoutRequest.php b/app/Http/Requests/Api/Storefront/V1/StoreCheckoutRequest.php new file mode 100644 index 00000000..0c4b1d8d --- /dev/null +++ b/app/Http/Requests/Api/Storefront/V1/StoreCheckoutRequest.php @@ -0,0 +1,29 @@ +|string> + */ + public function rules(): array + { + return [ + 'cart_id' => ['required', 'integer', 'exists:carts,id'], + 'email' => ['required', 'email'], + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/V1/SuggestProductsRequest.php b/app/Http/Requests/Api/Storefront/V1/SuggestProductsRequest.php new file mode 100644 index 00000000..422f5161 --- /dev/null +++ b/app/Http/Requests/Api/Storefront/V1/SuggestProductsRequest.php @@ -0,0 +1,24 @@ +|string> + */ + public function rules(): array + { + return [ + 'q' => ['required', 'string', 'min:1', 'max:100'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:10'], + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/V1/UpdateCartLineRequest.php b/app/Http/Requests/Api/Storefront/V1/UpdateCartLineRequest.php new file mode 100644 index 00000000..c908b8e1 --- /dev/null +++ b/app/Http/Requests/Api/Storefront/V1/UpdateCartLineRequest.php @@ -0,0 +1,35 @@ +|string> + */ + public function rules(): array + { + return [ + 'quantity' => ['required', 'integer', 'min:0'], + 'cart_version' => ['required_without:expected_version', 'integer', 'min:1'], + 'expected_version' => ['required_without:cart_version', 'integer', 'min:1'], + ]; + } + + public function expectedVersion(): int + { + return (int) ($this->validated('cart_version') ?? $this->validated('expected_version')); + } +} diff --git a/app/Http/Requests/Storefront/Auth/RequestCustomerPasswordResetRequest.php b/app/Http/Requests/Storefront/Auth/RequestCustomerPasswordResetRequest.php new file mode 100644 index 00000000..1a3b1258 --- /dev/null +++ b/app/Http/Requests/Storefront/Auth/RequestCustomerPasswordResetRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/Storefront/Auth/ResetCustomerPasswordRequest.php b/app/Http/Requests/Storefront/Auth/ResetCustomerPasswordRequest.php new file mode 100644 index 00000000..d2420701 --- /dev/null +++ b/app/Http/Requests/Storefront/Auth/ResetCustomerPasswordRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email', 'max:255'], + 'token' => ['required', 'string'], + 'password' => ['required', 'string', 'min:8', 'confirmed'], + ]; + } +} diff --git a/app/Http/Resources/Admin/V1/CollectionResource.php b/app/Http/Resources/Admin/V1/CollectionResource.php new file mode 100644 index 00000000..bd3e2b35 --- /dev/null +++ b/app/Http/Resources/Admin/V1/CollectionResource.php @@ -0,0 +1,36 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'title' => $this->title, + 'handle' => $this->handle, + 'description_html' => $this->description_html, + 'type' => $this->type?->value, + 'status' => $this->status?->value, + 'products_count' => $this->whenCounted('products'), + 'products' => $this->whenLoaded('products', fn () => $this->products->map(fn ($product): array => [ + 'id' => $product->id, + 'title' => $product->title, + 'handle' => $product->handle, + 'position' => $product->pivot?->position, + ])->values()), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Admin/V1/CustomerResource.php b/app/Http/Resources/Admin/V1/CustomerResource.php new file mode 100644 index 00000000..53c57c36 --- /dev/null +++ b/app/Http/Resources/Admin/V1/CustomerResource.php @@ -0,0 +1,45 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'email' => $this->email, + 'name' => $this->name, + 'marketing_opt_in' => $this->marketing_opt_in, + 'orders_count' => $this->whenCounted('orders'), + 'total_spent_amount' => (int) ($this->total_spent_amount ?? 0), + 'addresses' => $this->whenLoaded('addresses', fn () => $this->addresses->map(fn ($address): array => [ + 'id' => $address->id, + 'label' => $address->label, + 'address' => $address->address_json, + 'is_default' => $address->is_default, + ])->values()), + 'orders' => $this->whenLoaded('orders', fn () => $this->orders->map(fn ($order): array => [ + 'id' => $order->id, + 'order_number' => $order->order_number, + 'status' => $order->status?->value, + 'financial_status' => $order->financial_status?->value, + 'fulfillment_status' => $order->fulfillment_status?->value, + 'currency' => $order->currency, + 'total_amount' => $order->total_amount, + 'placed_at' => $order->placed_at?->toIso8601String(), + ])->values()), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Admin/V1/DataExportResource.php b/app/Http/Resources/Admin/V1/DataExportResource.php new file mode 100644 index 00000000..72d5b389 --- /dev/null +++ b/app/Http/Resources/Admin/V1/DataExportResource.php @@ -0,0 +1,48 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'type' => $this->type, + 'status' => $this->status?->value, + 'format' => $this->format, + 'filters' => $this->filters_json ?? [], + 'row_count' => $this->row_count, + 'download_url' => $this->downloadUrl(), + 'download_expires_at' => $this->download_expires_at?->toIso8601String(), + 'error_message' => $this->error_message, + 'created_at' => $this->created_at?->toIso8601String(), + 'completed_at' => $this->completed_at?->toIso8601String(), + 'failed_at' => $this->failed_at?->toIso8601String(), + ]; + } + + private function downloadUrl(): ?string + { + if ($this->status !== ExportStatus::Completed || ! is_string($this->storage_key)) { + return null; + } + + if (! Storage::disk('local')->exists($this->storage_key)) { + return null; + } + + return 'data:text/csv;charset=utf-8,'.rawurlencode(Storage::disk('local')->get($this->storage_key)); + } +} diff --git a/app/Http/Resources/Admin/V1/DiscountResource.php b/app/Http/Resources/Admin/V1/DiscountResource.php new file mode 100644 index 00000000..4e3e10d7 --- /dev/null +++ b/app/Http/Resources/Admin/V1/DiscountResource.php @@ -0,0 +1,34 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'type' => $this->type?->value, + 'code' => $this->code, + 'value_type' => $this->value_type?->value, + 'value_amount' => $this->value_amount, + 'starts_at' => $this->starts_at?->toIso8601String(), + 'ends_at' => $this->ends_at?->toIso8601String(), + 'usage_limit' => $this->usage_limit, + 'usage_count' => $this->usage_count, + 'rules_json' => $this->rules_json ?? [], + 'status' => $this->status?->value, + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Admin/V1/FulfillmentResource.php b/app/Http/Resources/Admin/V1/FulfillmentResource.php new file mode 100644 index 00000000..bba995d4 --- /dev/null +++ b/app/Http/Resources/Admin/V1/FulfillmentResource.php @@ -0,0 +1,34 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'order_id' => $this->order_id, + 'status' => $this->status?->value, + 'tracking_company' => $this->tracking_company, + 'tracking_number' => $this->tracking_number, + 'tracking_url' => $this->tracking_url, + 'lines' => $this->whenLoaded('lines', fn () => $this->lines->map(fn ($line): array => [ + 'id' => $line->id, + 'order_line_id' => $line->order_line_id, + 'quantity' => $line->quantity, + ])->values()), + 'shipped_at' => $this->shipped_at?->toIso8601String(), + 'delivered_at' => $this->delivered_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Admin/V1/OrderResource.php b/app/Http/Resources/Admin/V1/OrderResource.php new file mode 100644 index 00000000..1b7ce5eb --- /dev/null +++ b/app/Http/Resources/Admin/V1/OrderResource.php @@ -0,0 +1,65 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'order_number' => $this->order_number, + 'email' => $this->email, + 'customer' => $this->whenLoaded('customer', fn () => $this->customer ? [ + 'id' => $this->customer->id, + 'name' => $this->customer->name, + 'email' => $this->customer->email, + ] : null), + 'status' => $this->status?->value, + 'financial_status' => $this->financial_status?->value, + 'fulfillment_status' => $this->fulfillment_status?->value, + 'payment_method' => $this->payment_method?->value, + '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, + 'lines_count' => $this->whenCounted('lines'), + 'lines' => $this->whenLoaded('lines', fn () => $this->lines->map(fn ($line): array => [ + 'id' => $line->id, + 'product_id' => $line->product_id, + 'variant_id' => $line->variant_id, + 'title' => $line->title_snapshot, + 'sku' => $line->sku_snapshot, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'total_amount' => $line->total_amount, + ])->values()), + 'payments' => $this->whenLoaded('payments', fn () => $this->payments->map(fn ($payment): array => [ + 'id' => $payment->id, + 'provider' => $payment->provider, + 'method' => $payment->method?->value, + 'provider_payment_id' => $payment->provider_payment_id, + 'status' => $payment->status?->value, + 'amount' => $payment->amount, + 'currency' => $payment->currency, + ])->values()), + 'refunds' => RefundResource::collection($this->whenLoaded('refunds')), + 'fulfillments' => FulfillmentResource::collection($this->whenLoaded('fulfillments')), + 'shipping_address' => $this->shipping_address_json, + 'billing_address' => $this->billing_address_json, + 'placed_at' => $this->placed_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Admin/V1/OrganizationResource.php b/app/Http/Resources/Admin/V1/OrganizationResource.php new file mode 100644 index 00000000..36b3764d --- /dev/null +++ b/app/Http/Resources/Admin/V1/OrganizationResource.php @@ -0,0 +1,25 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'billing_email' => $this->billing_email, + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Admin/V1/PageResource.php b/app/Http/Resources/Admin/V1/PageResource.php new file mode 100644 index 00000000..517a4393 --- /dev/null +++ b/app/Http/Resources/Admin/V1/PageResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'title' => $this->title, + 'handle' => $this->handle, + 'body_html' => $this->body_html, + 'status' => $this->status?->value, + 'published_at' => $this->published_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Admin/V1/ProductResource.php b/app/Http/Resources/Admin/V1/ProductResource.php new file mode 100644 index 00000000..1153fb1a --- /dev/null +++ b/app/Http/Resources/Admin/V1/ProductResource.php @@ -0,0 +1,105 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'title' => $this->title, + 'handle' => $this->handle, + 'description_html' => $this->when($this->relationLoaded('options'), $this->description_html), + 'status' => $this->status?->value, + 'vendor' => $this->vendor, + 'product_type' => $this->product_type, + 'tags' => $this->tags ?? [], + 'variants_count' => $this->whenCounted('variants'), + 'total_inventory' => $this->when($this->relationLoaded('variants'), fn (): int => $this->variants->sum( + fn (ProductVariant $variant): int => $variant->inventoryItem?->quantity_on_hand ?? 0, + )), + 'featured_image' => $this->when($this->relationLoaded('media'), function (): ?array { + $media = $this->media->first(); + + return $media instanceof ProductMedia ? $this->mediaPayload($media) : null; + }), + 'options' => $this->whenLoaded('options', fn () => $this->options->map(fn ($option): array => [ + 'id' => $option->id, + 'name' => $option->name, + 'position' => $option->position, + 'values' => $option->values->map(fn ($value): array => [ + 'id' => $value->id, + 'value' => $value->value, + 'position' => $value->position, + ])->values(), + ])->values()), + 'variants' => $this->when( + $this->relationLoaded('variants') && $this->relationLoaded('options'), + fn () => $this->variants->map(fn (ProductVariant $variant): array => [ + 'id' => $variant->id, + 'sku' => $variant->sku, + 'barcode' => $variant->barcode, + 'price_amount' => $variant->price_amount, + 'compare_at_amount' => $variant->compare_at_amount, + 'currency' => $variant->currency, + 'weight_g' => $variant->weight_g, + 'requires_shipping' => $variant->requires_shipping, + 'is_default' => $variant->is_default, + 'position' => $variant->position, + 'status' => $variant->status?->value, + 'option_values' => $variant->optionValues->map(fn ($value): array => [ + 'option_name' => $value->option?->name, + 'value' => $value->value, + ])->values(), + 'inventory' => [ + 'quantity_on_hand' => $variant->inventoryItem?->quantity_on_hand ?? 0, + 'quantity_reserved' => $variant->inventoryItem?->quantity_reserved ?? 0, + 'policy' => $variant->inventoryItem?->policy?->value, + ], + ])->values(), + ), + 'media' => $this->whenLoaded('media', fn () => $this->media->map(fn (ProductMedia $media): array => $this->mediaPayload($media))->values()), + 'collections' => $this->whenLoaded('collections', fn () => $this->collections->map(fn ($collection): array => [ + 'id' => $collection->id, + 'title' => $collection->title, + 'handle' => $collection->handle, + ])->values()), + 'published_at' => $this->published_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } + + /** + * @return array + */ + private function mediaPayload(ProductMedia $media): array + { + return [ + 'id' => $media->id, + '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, + ]; + } +} diff --git a/app/Http/Resources/Admin/V1/RefundResource.php b/app/Http/Resources/Admin/V1/RefundResource.php new file mode 100644 index 00000000..a2cca9cf --- /dev/null +++ b/app/Http/Resources/Admin/V1/RefundResource.php @@ -0,0 +1,28 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'order_id' => $this->order_id, + 'payment_id' => $this->payment_id, + 'amount' => $this->amount, + 'reason' => $this->reason, + 'status' => $this->status?->value, + 'provider_refund_id' => $this->provider_refund_id, + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Admin/V1/ShippingRateResource.php b/app/Http/Resources/Admin/V1/ShippingRateResource.php new file mode 100644 index 00000000..454614bd --- /dev/null +++ b/app/Http/Resources/Admin/V1/ShippingRateResource.php @@ -0,0 +1,69 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'zone_id' => $this->zone_id, + 'name' => $this->name, + 'type' => $this->type?->value, + 'config_json' => $this->publicConfig(), + 'is_active' => $this->is_active, + ]; + } + + /** + * @return array + */ + private function publicConfig(): array + { + $config = $this->config_json ?? []; + $currency = strtoupper((string) data_get($config, 'currency', data_get($this->zone?->store, 'default_currency', 'EUR'))); + + if (in_array($this->type, [ShippingRateType::Flat, ShippingRateType::Carrier], true)) { + return [ + 'price_amount' => (int) data_get($config, 'price_amount', data_get($config, 'amount', 0)), + 'currency' => $currency, + ]; + } + + if ($this->type === ShippingRateType::Weight) { + return [ + 'currency' => $currency, + 'tiers' => collect(data_get($config, 'tiers', data_get($config, 'ranges', []))) + ->map(fn (array $tier): array => [ + 'min_weight_g' => (int) data_get($tier, 'min_weight_g', data_get($tier, 'min_g', 0)), + 'max_weight_g' => data_get($tier, 'max_weight_g', data_get($tier, 'max_g')), + 'price_amount' => (int) data_get($tier, 'price_amount', data_get($tier, 'amount', 0)), + ]) + ->values() + ->all(), + ]; + } + + return [ + 'currency' => $currency, + 'tiers' => collect(data_get($config, 'tiers', data_get($config, 'ranges', []))) + ->map(fn (array $tier): array => [ + 'min_order_amount' => (int) data_get($tier, 'min_order_amount', data_get($tier, 'min_amount', 0)), + 'max_order_amount' => data_get($tier, 'max_order_amount', data_get($tier, 'max_amount')), + 'price_amount' => (int) data_get($tier, 'price_amount', data_get($tier, 'amount', 0)), + ]) + ->values() + ->all(), + ]; + } +} diff --git a/app/Http/Resources/Admin/V1/ShippingZoneResource.php b/app/Http/Resources/Admin/V1/ShippingZoneResource.php new file mode 100644 index 00000000..a7d80a2f --- /dev/null +++ b/app/Http/Resources/Admin/V1/ShippingZoneResource.php @@ -0,0 +1,26 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'name' => $this->name, + 'countries_json' => $this->countries_json ?? [], + 'regions_json' => $this->regions_json ?? [], + 'rates' => ShippingRateResource::collection($this->whenLoaded('rates')), + ]; + } +} diff --git a/app/Http/Resources/Admin/V1/StoreResource.php b/app/Http/Resources/Admin/V1/StoreResource.php new file mode 100644 index 00000000..8b7e2011 --- /dev/null +++ b/app/Http/Resources/Admin/V1/StoreResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'organization_id' => $this->organization_id, + 'name' => $this->name, + 'handle' => $this->handle, + 'status' => $this->status?->value, + 'default_currency' => $this->default_currency, + 'default_locale' => $this->default_locale, + 'timezone' => $this->timezone, + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Admin/V1/StoreSettingsResource.php b/app/Http/Resources/Admin/V1/StoreSettingsResource.php new file mode 100644 index 00000000..fc04f255 --- /dev/null +++ b/app/Http/Resources/Admin/V1/StoreSettingsResource.php @@ -0,0 +1,42 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'handle' => $this->handle, + 'status' => $this->status?->value, + 'default_currency' => $this->default_currency, + 'default_locale' => $this->default_locale, + 'timezone' => $this->timezone, + 'settings_json' => $this->settings?->settings_json ?? [], + 'settings_updated_at' => $this->settings?->updated_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + 'domains' => $this->whenLoaded('domains', fn (): array => $this->domains + ->map(fn (StoreDomain $domain): array => [ + 'id' => $domain->id, + 'hostname' => $domain->hostname, + 'type' => $domain->type?->value, + 'is_primary' => $domain->is_primary, + 'tls_mode' => $domain->tls_mode, + 'created_at' => $domain->created_at?->toIso8601String(), + ]) + ->values() + ->all()), + ]; + } +} diff --git a/app/Http/Resources/Admin/V1/TaxSettingsResource.php b/app/Http/Resources/Admin/V1/TaxSettingsResource.php new file mode 100644 index 00000000..8fd93044 --- /dev/null +++ b/app/Http/Resources/Admin/V1/TaxSettingsResource.php @@ -0,0 +1,53 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'store_id' => $this->store_id, + 'mode' => $this->mode?->value, + 'provider' => $this->provider, + 'prices_include_tax' => $this->prices_include_tax, + 'config_json' => $this->publicConfig(), + 'updated_at' => null, + ]; + } + + /** + * @return array + */ + private function publicConfig(): array + { + $config = $this->config_json ?? []; + + if ($this->mode?->value === 'provider') { + return Arr::except($config, ['provider_api_key']); + } + + return [ + ...Arr::except($config, ['default_rate_bps', 'rates', 'provider_api_key']), + 'default_tax_rate' => (int) data_get($config, 'default_tax_rate', data_get($config, 'default_rate_bps', 0)), + 'tax_rates' => collect(data_get($config, 'tax_rates', data_get($config, 'rates', []))) + ->map(fn (array $rate): array => [ + 'country_code' => strtoupper((string) data_get($rate, 'country_code', data_get($rate, 'country'))), + 'rate' => (int) data_get($rate, 'rate', data_get($rate, 'rate_bps', 0)), + 'name' => (string) data_get($rate, 'name', 'Tax'), + 'shipping_taxed' => (bool) data_get($rate, 'shipping_taxed', data_get($config, 'shipping_taxable', true)), + ]) + ->values() + ->all(), + ]; + } +} diff --git a/app/Http/Resources/Admin/V1/ThemeResource.php b/app/Http/Resources/Admin/V1/ThemeResource.php new file mode 100644 index 00000000..b3370808 --- /dev/null +++ b/app/Http/Resources/Admin/V1/ThemeResource.php @@ -0,0 +1,30 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'name' => $this->name, + 'version' => $this->version, + 'status' => $this->status?->value, + 'published_at' => $this->published_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + 'files_count' => $this->whenCounted('files'), + 'settings_json' => $this->whenLoaded('settings', fn (): array => $this->settings?->settings_json ?? []), + ]; + } +} diff --git a/app/Http/Resources/Storefront/V1/CartLineResource.php b/app/Http/Resources/Storefront/V1/CartLineResource.php new file mode 100644 index 00000000..27b5112c --- /dev/null +++ b/app/Http/Resources/Storefront/V1/CartLineResource.php @@ -0,0 +1,43 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'variant_id' => $this->variant_id, + 'quantity' => $this->quantity, + 'unit_price_amount' => $this->unit_price_amount, + 'line_subtotal_amount' => $this->line_subtotal_amount, + 'line_discount_amount' => $this->line_discount_amount, + 'line_total_amount' => $this->line_total_amount, + 'product' => $this->whenLoaded('variant', fn (): ?array => $this->variant?->relationLoaded('product') ? [ + 'id' => $this->variant->product?->id, + 'title' => $this->variant->product?->title, + 'handle' => $this->variant->product?->handle, + ] : null), + 'variant' => $this->whenLoaded('variant', fn (): array => [ + 'id' => $this->variant->id, + 'sku' => $this->variant->sku, + 'requires_shipping' => $this->variant->requires_shipping, + 'options' => $this->variant->relationLoaded('optionValues') + ? $this->variant->optionValues->map(fn ($value): array => [ + 'name' => $value->option?->name, + 'value' => $value->value, + ])->values()->all() + : [], + ]), + ]; + } +} diff --git a/app/Http/Resources/Storefront/V1/CartResource.php b/app/Http/Resources/Storefront/V1/CartResource.php new file mode 100644 index 00000000..6d93857f --- /dev/null +++ b/app/Http/Resources/Storefront/V1/CartResource.php @@ -0,0 +1,38 @@ + + */ + public function toArray(Request $request): array + { + $lines = $this->whenLoaded('lines'); + + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'customer_id' => $this->customer_id, + 'currency' => $this->currency, + 'status' => $this->status?->value, + 'cart_version' => $this->cart_version, + 'line_count' => $this->relationLoaded('lines') ? $this->lines->sum('quantity') : null, + 'totals' => $this->relationLoaded('lines') ? [ + 'subtotal' => $this->lines->sum('line_subtotal_amount'), + 'discount' => $this->lines->sum('line_discount_amount'), + 'total' => $this->lines->sum('line_total_amount'), + 'currency' => $this->currency, + ] : null, + 'lines' => CartLineResource::collection($lines), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Storefront/V1/CheckoutResource.php b/app/Http/Resources/Storefront/V1/CheckoutResource.php new file mode 100644 index 00000000..fc7bd349 --- /dev/null +++ b/app/Http/Resources/Storefront/V1/CheckoutResource.php @@ -0,0 +1,40 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'access_token' => CheckoutAccessToken::make($this->resource), + 'store_id' => $this->store_id, + 'cart_id' => $this->cart_id, + 'customer_id' => $this->customer_id, + 'status' => $this->status?->value, + 'payment_method' => $this->payment_method, + 'email' => $this->email, + 'shipping_address' => $this->shipping_address_json, + 'billing_address' => $this->billing_address_json, + 'shipping_method_id' => $this->shipping_method_id, + 'discount_code' => $this->discount_code, + 'totals' => $this->totals_json, + 'tax_provider_snapshot' => $this->tax_provider_snapshot_json, + 'available_shipping_rates' => ShippingRateResource::collection($this->whenLoaded('availableRates')), + 'cart' => new CartResource($this->whenLoaded('cart')), + 'expires_at' => $this->expires_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Storefront/V1/OrderResource.php b/app/Http/Resources/Storefront/V1/OrderResource.php new file mode 100644 index 00000000..f69ca2b3 --- /dev/null +++ b/app/Http/Resources/Storefront/V1/OrderResource.php @@ -0,0 +1,73 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'order_number' => $this->order_number, + 'access_token' => OrderAccessToken::make($this->resource), + 'email' => $this->email, + 'status' => $this->status?->value, + 'financial_status' => $this->financial_status?->value, + 'fulfillment_status' => $this->fulfillment_status?->value, + 'payment_method' => $this->payment_method?->value, + '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, + 'shipping_address' => $this->shipping_address_json, + 'billing_address' => $this->billing_address_json, + 'lines' => $this->whenLoaded('lines', fn () => $this->lines->map(fn ($line): array => [ + 'id' => $line->id, + 'title' => $line->title_snapshot, + 'sku' => $line->sku_snapshot, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'total_amount' => $line->total_amount, + ])->values()), + 'payments' => $this->whenLoaded('payments', fn () => $this->payments->map(fn ($payment): array => [ + 'id' => $payment->id, + 'provider' => $payment->provider, + 'method' => $payment->method?->value, + 'status' => $payment->status?->value, + 'amount' => $payment->amount, + 'currency' => $payment->currency, + ])->values()), + 'fulfillments' => $this->whenLoaded('fulfillments', fn () => $this->fulfillments->map(fn ($fulfillment): array => [ + 'id' => $fulfillment->id, + 'status' => $fulfillment->status?->value, + 'tracking_company' => $fulfillment->tracking_company, + 'tracking_number' => $fulfillment->tracking_number, + 'tracking_url' => $fulfillment->tracking_url, + 'shipped_at' => $fulfillment->shipped_at?->toIso8601String(), + 'delivered_at' => $fulfillment->delivered_at?->toIso8601String(), + ])->values()), + 'bank_transfer_instructions' => $this->payment_method === PaymentMethod::BankTransfer ? [ + 'bank_name' => 'Mock Bank AG', + 'bic' => 'COBADEFFXXX', + 'iban' => 'DE89 3704 0044 0532 0130 00', + 'reference' => $this->order_number, + 'amount' => $this->total_amount, + 'currency' => $this->currency, + ] : null, + 'placed_at' => $this->placed_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Storefront/V1/SearchProductResource.php b/app/Http/Resources/Storefront/V1/SearchProductResource.php new file mode 100644 index 00000000..add13e8b --- /dev/null +++ b/app/Http/Resources/Storefront/V1/SearchProductResource.php @@ -0,0 +1,49 @@ + + */ + public function toArray(Request $request): array + { + $variant = $this->relationLoaded('variants') + ? $this->variants->first() + : null; + + return [ + 'id' => $this->id, + 'title' => $this->title, + 'handle' => $this->handle, + 'vendor' => $this->vendor, + 'product_type' => $this->product_type, + 'price_amount' => $variant?->price_amount, + 'compare_at_amount' => $variant?->compare_at_amount, + 'currency' => $variant?->currency ?? $this->store?->default_currency, + 'image_url' => $this->relationLoaded('media') ? $this->media->first()?->storage_key : null, + 'in_stock' => $variant instanceof ProductVariant ? $this->variantIsInStock($variant) : false, + 'tags' => $this->tags ?? [], + ]; + } + + private function variantIsInStock(ProductVariant $variant): bool + { + $inventory = $variant->inventoryItem; + + if ($inventory === null) { + return false; + } + + return $inventory->availableQuantity() > 0 + || $inventory->policy === InventoryPolicy::Continue; + } +} diff --git a/app/Http/Resources/Storefront/V1/ShippingRateResource.php b/app/Http/Resources/Storefront/V1/ShippingRateResource.php new file mode 100644 index 00000000..827fca51 --- /dev/null +++ b/app/Http/Resources/Storefront/V1/ShippingRateResource.php @@ -0,0 +1,26 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'zone_id' => $this->zone_id, + 'name' => $this->name, + 'type' => $this->type?->value, + 'amount' => $this->when($this->getAttribute('calculated_amount') !== null, $this->getAttribute('calculated_amount')), + 'config' => $this->config_json, + ]; + } +} diff --git a/app/Jobs/AggregateAnalytics.php b/app/Jobs/AggregateAnalytics.php new file mode 100644 index 00000000..173ce72a --- /dev/null +++ b/app/Jobs/AggregateAnalytics.php @@ -0,0 +1,20 @@ +aggregate($this->date ? Carbon::parse($this->date) : null); + } +} diff --git a/app/Jobs/CancelUnpaidBankTransferOrders.php b/app/Jobs/CancelUnpaidBankTransferOrders.php new file mode 100644 index 00000000..20256fae --- /dev/null +++ b/app/Jobs/CancelUnpaidBankTransferOrders.php @@ -0,0 +1,35 @@ +with('store.settings') + ->where('payment_method', PaymentMethod::BankTransfer->value) + ->where('financial_status', FinancialStatus::Pending->value) + ->whereNotNull('placed_at') + ->lazyById() + ->each(function (Order $order) use ($orders): void { + $cancelDays = max(1, (int) data_get($order->store?->settings?->settings_json, 'bank_transfer_cancel_days', 7)); + + if ($order->placed_at->lessThanOrEqualTo(now()->subDays($cancelDays))) { + $orders->cancelUnpaidBankTransferOrder($order); + } + }); + } +} diff --git a/app/Jobs/CleanupAbandonedCarts.php b/app/Jobs/CleanupAbandonedCarts.php new file mode 100644 index 00000000..1f439346 --- /dev/null +++ b/app/Jobs/CleanupAbandonedCarts.php @@ -0,0 +1,37 @@ +where('status', CartStatus::Active) + ->where('updated_at', '<', now()->subDays(14)) + ->orderBy('id') + ->get() + ->each(function (Cart $cart) use ($checkouts): void { + Checkout::withoutGlobalScopes() + ->where('cart_id', $cart->getKey()) + ->whereNotIn('status', [CheckoutStatus::Completed->value, CheckoutStatus::Expired->value]) + ->get() + ->each(fn (Checkout $checkout): Checkout => $checkouts->expireCheckout($checkout)); + + $cart->forceFill(['status' => CartStatus::Abandoned])->save(); + }); + } +} diff --git a/app/Jobs/DeliverWebhook.php b/app/Jobs/DeliverWebhook.php new file mode 100644 index 00000000..1ca0c459 --- /dev/null +++ b/app/Jobs/DeliverWebhook.php @@ -0,0 +1,91 @@ + $payload + */ + public function __construct( + public int $deliveryId, + public string $eventType, + public array $payload, + ) {} + + /** + * @return list + */ + public function backoff(): array + { + return [60, 300, 1800, 7200, 43200]; + } + + /** + * Execute the job. + */ + public function handle(WebhookService $webhooks): void + { + $delivery = WebhookDelivery::query() + ->with('subscription') + ->findOrFail($this->deliveryId); + $subscription = $delivery->subscription; + + if (! $subscription instanceof WebhookSubscription || $subscription->status !== WebhookSubscriptionStatus::Active) { + return; + } + + $attemptCount = max(1, $this->attempts()); + $body = json_encode($this->payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + $timestamp = (string) now()->timestamp; + $signature = $webhooks->sign($timestamp.'.'.$body, (string) $subscription->signing_secret_encrypted); + $recordedFailure = false; + + try { + $response = Http::timeout(5) + ->connectTimeout(2) + ->withHeaders([ + 'X-Platform-Signature' => $signature, + 'X-Platform-Event' => $this->eventType, + 'X-Platform-Delivery-Id' => $delivery->event_id, + 'X-Platform-Timestamp' => $timestamp, + 'Content-Type' => 'application/json', + ]) + ->withBody($body, 'application/json') + ->post($subscription->target_url); + + if ($response->successful()) { + $webhooks->recordSuccess($delivery, $attemptCount, $response->status(), $response->body()); + + return; + } + + $webhooks->recordFailure($delivery, $attemptCount, $response->status(), $response->body()); + $recordedFailure = true; + + throw new RuntimeException("Webhook delivery failed with HTTP {$response->status()}."); + } catch (Throwable $throwable) { + if (! $recordedFailure) { + $webhooks->recordFailure($delivery, $attemptCount, null, $throwable->getMessage()); + } + + throw $throwable; + } + } +} diff --git a/app/Jobs/ExpireAbandonedCheckouts.php b/app/Jobs/ExpireAbandonedCheckouts.php new file mode 100644 index 00000000..82a47a4c --- /dev/null +++ b/app/Jobs/ExpireAbandonedCheckouts.php @@ -0,0 +1,35 @@ +whereNotIn('status', [CheckoutStatus::Completed->value, CheckoutStatus::Expired->value]) + ->where(function ($query): void { + $query + ->where('expires_at', '<', now()) + ->orWhere(function ($query): void { + $query + ->whereNull('expires_at') + ->where('updated_at', '<', now()->subDay()); + }); + }) + ->orderBy('id') + ->get() + ->each(fn (Checkout $checkout): Checkout => $checkouts->expireCheckout($checkout)); + } +} diff --git a/app/Jobs/ProcessMediaUpload.php b/app/Jobs/ProcessMediaUpload.php new file mode 100644 index 00000000..8748696f --- /dev/null +++ b/app/Jobs/ProcessMediaUpload.php @@ -0,0 +1,224 @@ + + */ + private const TARGETS = [ + 'thumbnail' => [150, 150], + 'small' => [300, 300], + 'medium' => [600, 600], + 'large' => [1200, 1200], + ]; + + public function __construct( + public int $productMediaId, + public ?int $storeId = null, + ) {} + + public function handle(): void + { + $media = $this->media(); + + try { + $this->process($media); + } catch (Throwable $throwable) { + $media->forceFill([ + 'status' => MediaStatus::Failed, + ])->save(); + + Log::error('Media processing failed.', [ + 'product_media_id' => $media->getKey(), + 'storage_key' => $media->storage_key, + 'exception' => $throwable->getMessage(), + ]); + + throw $throwable; + } + } + + private function media(): ProductMedia + { + $query = ProductMedia::withoutGlobalScopes()->whereKey($this->productMediaId); + + if ($this->storeId !== null) { + $query->whereHas('product', function (Builder $query): void { + $query + ->withoutGlobalScopes() + ->where('store_id', $this->storeId); + }); + } + + return $query->firstOrFail(); + } + + private function process(ProductMedia $media): void + { + $disk = Storage::disk('public'); + + if (! $disk->exists($media->storage_key)) { + throw new RuntimeException('Media file is missing from public storage.'); + } + + $contents = $disk->get($media->storage_key); + $mimeType = $this->mimeType($contents); + $byteSize = $disk->size($media->storage_key); + + $dimensions = match ($media->type) { + MediaType::Image => $this->processImage($media, $contents, $mimeType), + MediaType::Video => $this->validateVideo($mimeType), + }; + + $media->forceFill([ + 'width' => $dimensions['width'], + 'height' => $dimensions['height'], + 'mime_type' => $mimeType, + 'byte_size' => $byteSize, + 'status' => MediaStatus::Ready, + ])->save(); + } + + /** + * @return array{width: int, height: int} + */ + private function processImage(ProductMedia $media, string $contents, string $mimeType): array + { + if (! str_starts_with($mimeType, 'image/')) { + throw new RuntimeException('Media file is not a supported image.'); + } + + $source = @imagecreatefromstring($contents); + + if ($source === false) { + throw new RuntimeException('Media image could not be decoded.'); + } + + $sourceWidth = imagesx($source); + $sourceHeight = imagesy($source); + $extension = $this->extensionForMime($mimeType); + $disk = Storage::disk('public'); + + foreach (self::TARGETS as $size => [$maxWidth, $maxHeight]) { + [$targetWidth, $targetHeight] = $this->fitDimensions($sourceWidth, $sourceHeight, $maxWidth, $maxHeight); + $resized = imagecreatetruecolor($targetWidth, $targetHeight); + + imagealphablending($resized, false); + imagesavealpha($resized, true); + imagefilledrectangle( + $resized, + 0, + 0, + $targetWidth, + $targetHeight, + imagecolorallocatealpha($resized, 0, 0, 0, 127), + ); + imagecopyresampled($resized, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $sourceWidth, $sourceHeight); + + $basePath = "media/{$media->product_id}/{$media->getKey()}/{$size}"; + + $disk->put("{$basePath}.{$extension}", $this->encodeImage($resized, $extension)); + + if (function_exists('imagewebp')) { + $disk->put("{$basePath}.webp", $this->encodeImage($resized, 'webp')); + } + + imagedestroy($resized); + } + + imagedestroy($source); + + return [ + 'width' => $sourceWidth, + 'height' => $sourceHeight, + ]; + } + + /** + * @return array{width: null, height: null} + */ + private function validateVideo(string $mimeType): array + { + if (! str_starts_with($mimeType, 'video/')) { + throw new RuntimeException('Media file is not a supported video.'); + } + + return [ + 'width' => null, + 'height' => null, + ]; + } + + /** + * @return array{0: int, 1: int} + */ + private function fitDimensions(int $sourceWidth, int $sourceHeight, int $maxWidth, int $maxHeight): array + { + $ratio = min($maxWidth / $sourceWidth, $maxHeight / $sourceHeight, 1); + + return [ + max(1, (int) round($sourceWidth * $ratio)), + max(1, (int) round($sourceHeight * $ratio)), + ]; + } + + private function encodeImage(\GdImage $image, string $extension): string + { + ob_start(); + + $encoded = match ($extension) { + 'jpg' => imagejpeg($image, null, 90), + 'png' => imagepng($image), + 'gif' => imagegif($image), + 'webp' => imagewebp($image, null, 90), + default => false, + }; + + $contents = ob_get_clean(); + + if ($encoded === false || $contents === false) { + throw new RuntimeException('Unable to encode resized media image.'); + } + + return $contents; + } + + private function mimeType(string $contents): string + { + $mimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($contents); + + if (! is_string($mimeType) || $mimeType === '') { + throw new RuntimeException('Unable to determine media MIME type.'); + } + + return $mimeType; + } + + private function extensionForMime(string $mimeType): string + { + return match ($mimeType) { + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + default => throw new RuntimeException("Unsupported image MIME type [{$mimeType}]."), + }; + } +} diff --git a/app/Listeners/DispatchWebhooks.php b/app/Listeners/DispatchWebhooks.php new file mode 100644 index 00000000..0ba50e44 --- /dev/null +++ b/app/Listeners/DispatchWebhooks.php @@ -0,0 +1,156 @@ + $this->dispatchOrder($event->order, WebhookEventType::OrderCreated), + $event instanceof OrderPaid => $this->dispatchOrder($event->order, WebhookEventType::OrderPaid), + $event instanceof OrderCancelled => $this->dispatchOrder($event->order, WebhookEventType::OrderCancelled), + $event instanceof OrderRefunded => $this->dispatchRefund($event), + $event instanceof CheckoutCompleted => $this->dispatchCheckout($event), + $event instanceof FulfillmentCreated => $this->dispatchFulfillment($event->fulfillment, WebhookEventType::FulfillmentCreated), + $event instanceof FulfillmentShipped, + $event instanceof FulfillmentDelivered => $this->dispatchFulfillment($event->fulfillment, WebhookEventType::OrderFulfilled), + $event instanceof ProductCreated => $this->dispatchProduct($event->product, WebhookEventType::ProductCreated), + $event instanceof ProductUpdated => $this->dispatchProduct($event->product, WebhookEventType::ProductUpdated), + $event instanceof ProductDeleted => $this->dispatchProduct($event->product, WebhookEventType::ProductDeleted), + default => null, + }; + } + + private function dispatchRefund(OrderRefunded $event): void + { + $payload = $this->orderPayload($event->order); + $payload['refund'] = [ + 'id' => $event->refund->getKey(), + 'amount' => $event->refund->amount, + 'reason' => $event->refund->reason, + 'status' => $event->refund->status?->value, + ]; + + $store = $this->store($event->order->store_id); + + $this->webhooks->dispatch($store, WebhookEventType::OrderRefunded->value, $payload); + $this->webhooks->dispatch($store, WebhookEventType::RefundCreated->value, $payload); + } + + private function dispatchOrder(Order $order, WebhookEventType $eventType): void + { + $this->webhooks->dispatch($this->store($order->store_id), $eventType->value, $this->orderPayload($order)); + } + + private function dispatchCheckout(CheckoutCompleted $event): void + { + $payload = $this->checkoutPayload($event->checkout); + $payload['order'] = $this->orderPayload($event->order)['order']; + + $this->webhooks->dispatch($this->store($event->checkout->store_id), WebhookEventType::CheckoutCompleted->value, $payload); + } + + private function dispatchFulfillment(Fulfillment $fulfillment, WebhookEventType $eventType): void + { + $order = $fulfillment->order()->withoutGlobalScopes()->firstOrFail(); + + $payload = $this->orderPayload($order); + $payload['fulfillment'] = [ + 'id' => $fulfillment->getKey(), + 'status' => $fulfillment->status?->value, + 'tracking_company' => $fulfillment->tracking_company, + 'tracking_number' => $fulfillment->tracking_number, + 'tracking_url' => $fulfillment->tracking_url, + ]; + + $this->webhooks->dispatch($this->store($order->store_id), $eventType->value, $payload); + } + + private function dispatchProduct(Product $product, WebhookEventType $eventType): void + { + $this->webhooks->dispatch($this->store($product->store_id), $eventType->value, $this->productPayload($product)); + } + + /** + * @return array + */ + private function orderPayload(Order $order): array + { + return [ + 'order' => [ + 'id' => $order->getKey(), + 'order_number' => $order->order_number, + 'status' => $order->status?->value, + 'financial_status' => $order->financial_status?->value, + 'fulfillment_status' => $order->fulfillment_status?->value, + 'currency' => $order->currency, + 'total_amount' => $order->total_amount, + 'placed_at' => $order->placed_at?->toISOString(), + ], + ]; + } + + /** + * @return array + */ + private function checkoutPayload(Checkout $checkout): array + { + return [ + 'checkout' => [ + 'id' => $checkout->getKey(), + 'status' => $checkout->status?->value, + 'payment_method' => $checkout->payment_method, + 'email' => $checkout->email, + 'currency' => data_get($checkout->totals_json, 'currency'), + 'total_amount' => data_get($checkout->totals_json, 'total'), + 'completed_at' => $checkout->updated_at?->toISOString(), + ], + ]; + } + + /** + * @return array + */ + private function productPayload(Product $product): array + { + return [ + 'product' => [ + 'id' => $product->getKey(), + 'title' => $product->title, + 'handle' => $product->handle, + 'status' => $product->status?->value, + 'updated_at' => $product->updated_at?->toISOString(), + ], + ]; + } + + private function store(int $storeId): Store + { + return Store::query()->findOrFail($storeId); + } +} diff --git a/app/Livewire/Admin/Analytics/Index.php b/app/Livewire/Admin/Analytics/Index.php new file mode 100644 index 00000000..c9f32146 --- /dev/null +++ b/app/Livewire/Admin/Analytics/Index.php @@ -0,0 +1,337 @@ + + */ + public array $salesChartData = []; + + public int $maxSalesChartAmount = 1; + + /** + * @var list + */ + public array $topProducts = []; + + /** + * @var list + */ + public array $topReferrers = []; + + public bool $isExporting = false; + + public ?string $exportUrl = null; + + public function mount(AnalyticsService $analytics): void + { + $store = $this->store(); + + $this->authorize('view', $store); + abort_unless($this->canViewAnalytics($store), 403); + + $this->storeId = $store->getKey(); + $this->storeCurrency = $store->default_currency; + + $this->loadAnalytics($analytics); + } + + public function updatedDateRange(AnalyticsService $analytics): void + { + $this->loadAnalytics($analytics); + } + + public function updatedCustomStartDate(AnalyticsService $analytics): void + { + if ($this->dateRange === 'custom') { + $this->loadAnalytics($analytics); + } + } + + public function updatedCustomEndDate(AnalyticsService $analytics): void + { + if ($this->dateRange === 'custom') { + $this->loadAnalytics($analytics); + } + } + + public function updatedChannelFilter(AnalyticsService $analytics): void + { + $this->loadAnalytics($analytics); + } + + public function updatedDeviceFilter(AnalyticsService $analytics): void + { + $this->loadAnalytics($analytics); + } + + public function loadAnalytics(AnalyticsService $analytics): void + { + [$start, $end] = $this->currentRange(); + $store = $this->scopedStore(); + + $totals = $analytics->totals($store, $start->toDateString(), $end->toDateString()); + + $this->totalSales = $totals['revenue_amount']; + $this->ordersCount = $totals['orders_count']; + $this->averageOrderValue = $totals['aov_amount']; + $this->conversionRate = $totals['visits_count'] > 0 + ? round(($totals['checkout_completed_count'] / $totals['visits_count']) * 100, 2) + : 0.0; + $this->visitsCount = $totals['visits_count']; + $this->addToCartCount = $totals['add_to_cart_count']; + $this->checkoutStartedCount = $totals['checkout_started_count']; + $this->checkoutCompletedCount = $totals['checkout_completed_count']; + + $daily = $analytics->getDailyMetrics($store, $start->toDateString(), $end->toDateString())->keyBy('date'); + + $this->salesChartData = collect(CarbonPeriod::create($start->copy()->startOfDay(), '1 day', $end->copy()->startOfDay())) + ->map(function (CarbonInterface $date) use ($daily): array { + $metric = $daily->get($date->toDateString()); + + return [ + 'date' => $date->toDateString(), + 'label' => $date->format('M j'), + 'revenue' => (int) ($metric?->revenue_amount ?? 0), + 'orders' => (int) ($metric?->orders_count ?? 0), + ]; + }) + ->values() + ->all(); + + $this->maxSalesChartAmount = max(1, max(array_column($this->salesChartData, 'revenue') ?: [0])); + $this->loadTopProducts($start, $end); + $this->loadTopReferrers($start, $end); + } + + public function exportCsv(AnalyticsService $analytics): void + { + $this->isExporting = true; + $this->loadAnalytics($analytics); + + $lines = ['date,revenue_amount,orders_count']; + + foreach ($this->salesChartData as $point) { + $lines[] = "{$point['date']},{$point['revenue']},{$point['orders']}"; + } + + $this->exportUrl = 'data:text/csv;charset=utf-8,'.rawurlencode(implode("\n", $lines)); + $this->isExporting = false; + } + + #[Computed] + public function formattedTotalSales(): string + { + return Money::format($this->totalSales, $this->storeCurrency); + } + + #[Computed] + public function formattedAov(): string + { + return Money::format($this->averageOrderValue, $this->storeCurrency); + } + + public function render(): mixed + { + return view('livewire.admin.analytics.index')->layout('layouts.app', [ + 'title' => __('Analytics'), + ]); + } + + private function loadTopProducts(CarbonInterface $start, CarbonInterface $end): void + { + $rows = OrderLine::query() + ->selectRaw('order_lines.title_snapshot as title, sum(order_lines.quantity) as units_sold, sum(order_lines.total_amount) as revenue') + ->join('orders', 'orders.id', '=', 'order_lines.order_id') + ->where('orders.store_id', $this->storeId) + ->whereBetween('orders.placed_at', [$start, $end]) + ->whereIn('orders.financial_status', [ + FinancialStatus::Paid->value, + FinancialStatus::PartiallyRefunded->value, + ]) + ->groupBy('order_lines.product_id', 'order_lines.title_snapshot') + ->orderByDesc('revenue') + ->limit(10) + ->get(); + + $totalRevenue = max(1, (int) $rows->sum('revenue')); + + $this->topProducts = $rows + ->map(fn (OrderLine $line): array => [ + 'title' => (string) $line->title, + 'units_sold' => (int) $line->units_sold, + 'revenue' => (int) $line->revenue, + 'percentage' => round(((int) $line->revenue / $totalRevenue) * 100, 1), + ]) + ->all(); + } + + private function loadTopReferrers(CarbonInterface $start, CarbonInterface $end): void + { + $pageViews = $this->filteredEvents($start, $end, AnalyticsEventType::PageView); + $orders = $this->filteredEvents($start, $end, AnalyticsEventType::CheckoutCompleted); + $ordersBySource = $orders->groupBy(fn (AnalyticsEvent $event): string => $this->sourceFor($event)); + + $this->topReferrers = $pageViews + ->groupBy(fn (AnalyticsEvent $event): string => $this->sourceFor($event)) + ->map(function ($events, string $source) use ($ordersBySource): array { + $sessions = $events->pluck('session_id')->filter()->unique()->count(); + $orders = $ordersBySource->get($source, collect())->count(); + + return [ + 'source' => $source, + 'sessions' => $sessions, + 'orders' => $orders, + 'conversion_rate' => $sessions > 0 ? round(($orders / $sessions) * 100, 2) : 0.0, + ]; + }) + ->sortByDesc('sessions') + ->take(5) + ->values() + ->all(); + } + + private function filteredEvents(CarbonInterface $start, CarbonInterface $end, AnalyticsEventType $type): Collection + { + return AnalyticsEvent::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->where('type', $type->value) + ->whereBetween('occurred_at', [$start, $end]) + ->get() + ->filter(fn (AnalyticsEvent $event): bool => $this->eventMatchesFilters($event)) + ->values(); + } + + private function eventMatchesFilters(AnalyticsEvent $event): bool + { + if ($this->channelFilter !== 'all' && data_get($event->properties_json, 'channel') !== $this->channelFilter) { + return false; + } + + return $this->deviceFilter === 'all' + || data_get($event->properties_json, 'device') === $this->deviceFilter; + } + + private function sourceFor(AnalyticsEvent $event): string + { + $referrer = (string) data_get($event->properties_json, 'referrer', 'direct'); + + if ($referrer === '' || $referrer === 'direct') { + return 'Direct'; + } + + $host = parse_url($referrer, PHP_URL_HOST); + + return $host ? str_replace('www.', '', $host) : 'Direct'; + } + + /** + * @return array{0: CarbonInterface, 1: CarbonInterface} + */ + private function currentRange(): array + { + return match ($this->dateRange) { + 'today' => [now()->startOfDay(), now()->endOfDay()], + 'last_7_days' => [now()->subDays(6)->startOfDay(), now()->endOfDay()], + 'custom' => $this->customRange() ?? [now()->subDays(29)->startOfDay(), now()->endOfDay()], + default => [now()->subDays(29)->startOfDay(), now()->endOfDay()], + }; + } + + /** + * @return array{0: CarbonInterface, 1: CarbonInterface}|null + */ + private function customRange(): ?array + { + if (! $this->customStartDate || ! $this->customEndDate) { + return null; + } + + try { + $start = Carbon::parse($this->customStartDate)->startOfDay(); + $end = Carbon::parse($this->customEndDate)->endOfDay(); + } catch (Throwable) { + return null; + } + + if ($end->lessThan($start)) { + return [$end->startOfDay(), $start->endOfDay()]; + } + + return [$start, $end]; + } + + private function store(): Store + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } + + private function scopedStore(): Store + { + return Store::query()->findOrFail($this->storeId); + } + + private function canViewAnalytics(Store $store): bool + { + $role = auth()->user()?->roleForStoreId($store->getKey()); + + return in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff], true); + } +} diff --git a/app/Livewire/Admin/Apps/Index.php b/app/Livewire/Admin/Apps/Index.php new file mode 100644 index 00000000..60c9010c --- /dev/null +++ b/app/Livewire/Admin/Apps/Index.php @@ -0,0 +1,76 @@ +store(); + + $this->authorize('update', $store); + $this->storeId = $store->getKey(); + } + + public function uninstallApp(int $appId): void + { + $this->authorize('update', $this->scopedStore()); + + AppInstallation::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->where('app_id', $appId) + ->update(['status' => AppInstallationStatus::Uninstalled]); + + session()->flash('status', __('App uninstalled')); + $this->dispatch('toast', type: 'success', message: __('App uninstalled')); + } + + /** + * @return Collection + */ + public function installedApps(): Collection + { + return AppInstallation::withoutGlobalScopes() + ->with(['app', 'webhookSubscriptions']) + ->where('store_id', $this->storeId) + ->whereNot('status', AppInstallationStatus::Uninstalled->value) + ->latest('installed_at') + ->get(); + } + + public function render() + { + return view('livewire.admin.apps.index', [ + 'installedApps' => $this->installedApps(), + ])->layout('layouts.app', [ + 'title' => __('Apps'), + ]); + } + + private function store(): Store + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } + + private function scopedStore(): Store + { + return Store::query()->findOrFail($this->storeId); + } +} diff --git a/app/Livewire/Admin/Apps/Show.php b/app/Livewire/Admin/Apps/Show.php new file mode 100644 index 00000000..547acde6 --- /dev/null +++ b/app/Livewire/Admin/Apps/Show.php @@ -0,0 +1,81 @@ +store(); + + $this->authorize('update', $store); + + $installation = AppInstallation::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->whereKey($installation->getKey()) + ->first(); + + abort_unless($installation instanceof AppInstallation, 404); + + $this->storeId = $store->getKey(); + $this->installationId = $installation->getKey(); + } + + public function uninstallApp(): void + { + $this->authorize('update', $this->scopedStore()); + + $this->installation()->forceFill([ + 'status' => AppInstallationStatus::Uninstalled, + ])->save(); + + session()->flash('status', __('App uninstalled')); + $this->dispatch('toast', type: 'success', message: __('App uninstalled')); + } + + public function render() + { + return view('livewire.admin.apps.show', [ + 'installation' => $this->installation(), + ])->layout('layouts.app', [ + 'title' => __('App detail'), + ]); + } + + private function installation(): AppInstallation + { + return AppInstallation::withoutGlobalScopes() + ->with(['app', 'oauthTokens', 'webhookSubscriptions.deliveries']) + ->where('store_id', $this->storeId) + ->findOrFail($this->installationId); + } + + private function store(): Store + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } + + private function scopedStore(): Store + { + return Store::query()->findOrFail($this->storeId); + } +} diff --git a/app/Livewire/Admin/Auth/Login.php b/app/Livewire/Admin/Auth/Login.php new file mode 100644 index 00000000..158dea7b --- /dev/null +++ b/app/Livewire/Admin/Auth/Login.php @@ -0,0 +1,64 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + 'remember' => ['boolean'], + ]); + + $key = 'admin-login:'.request()->ip(); + + if (RateLimiter::tooManyAttempts($key, 5)) { + throw ValidationException::withMessages([ + 'email' => __('Too many attempts. Try again in :seconds seconds.', [ + 'seconds' => RateLimiter::availableIn($key), + ]), + ]); + } + + if (! Auth::guard('web')->attempt([ + 'email' => $this->email, + 'password' => $this->password, + 'status' => 'active', + ], $this->remember)) { + RateLimiter::hit($key, 60); + + throw ValidationException::withMessages([ + 'email' => __('Invalid credentials'), + ]); + } + + RateLimiter::clear($key); + + if (request()->hasSession()) { + request()->session()->regenerate(); + } + + Auth::user()?->forceFill(['last_login_at' => now()])->save(); + + $this->redirectRoute('admin.dashboard', navigate: true); + } + + public function render(): mixed + { + return view('livewire.admin.auth.login') + ->layout('layouts.auth'); + } +} diff --git a/app/Livewire/Admin/Collections/Form.php b/app/Livewire/Admin/Collections/Form.php new file mode 100644 index 00000000..a28cfaf1 --- /dev/null +++ b/app/Livewire/Admin/Collections/Form.php @@ -0,0 +1,235 @@ + + */ + public array $assignedProductIds = []; + + public function mount(?Collection $collection = null): void + { + if ($collection?->exists) { + $store = app('current_store'); + + abort_unless($store instanceof Store && (int) $collection->store_id === $store->getKey(), 404); + + $this->authorize('update', $collection); + + $this->collection = $collection->load('products'); + $this->fillFromCollection($this->collection); + + return; + } + + $this->authorize('create', Collection::class); + } + + public function updatedTitle(): void + { + if ($this->collection === null && $this->handle === '') { + $this->handle = Str::slug($this->title); + } + } + + public function addProduct(int $productId): void + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + $exists = Product::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->whereKey($productId) + ->exists(); + + if (! $exists) { + return; + } + + if (! in_array($productId, $this->assignedProductIds, true)) { + $this->assignedProductIds[] = $productId; + } + + $this->productSearch = ''; + } + + public function removeProduct(int $productId): void + { + $this->assignedProductIds = array_values(array_filter( + $this->assignedProductIds, + fn (int $assignedProductId): bool => $assignedProductId !== $productId, + )); + } + + public function save(): void + { + $store = app('current_store'); + abort_unless($store instanceof Store, 404); + + $this->authorizeSave(); + + $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => [ + 'required', + 'string', + 'max:255', + Rule::unique('collections', 'handle') + ->where('store_id', $store->getKey()) + ->ignore($this->collection?->getKey()), + ], + 'descriptionHtml' => ['nullable', 'string', 'max:65535'], + 'status' => ['required', Rule::in(['draft', 'active', 'archived'])], + ]); + + $attributes = [ + 'store_id' => $store->getKey(), + 'title' => $this->title, + 'handle' => Str::slug($this->handle), + 'description_html' => $this->sanitizeHtml($this->descriptionHtml), + 'type' => 'manual', + 'status' => $this->status, + ]; + + $collection = $this->collection instanceof Collection + ? tap($this->collection)->update($attributes) + : Collection::query()->create($attributes); + + $collection->products()->sync(collect($this->assignedProductIdsForSync($store)) + ->values() + ->mapWithKeys(fn (int $productId, int $position): array => [$productId => ['position' => $position]]) + ->all()); + + $this->collection = $collection->refresh()->load('products'); + $this->fillFromCollection($this->collection); + + $this->actionMessage = 'Collection saved'; + session()->flash('status', 'Collection saved'); + $this->dispatch('toast', type: 'success', message: __('Collection saved')); + } + + public function searchResults(): SupportCollection + { + if (trim($this->productSearch) === '') { + return collect(); + } + + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return Product::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->whereNotIn('id', $this->assignedProductIds) + ->where(function (Builder $query): void { + $query + ->where('title', 'like', '%'.$this->productSearch.'%') + ->orWhere('handle', 'like', '%'.$this->productSearch.'%'); + }) + ->limit(5) + ->get(); + } + + public function assignedProducts(): SupportCollection + { + if ($this->assignedProductIds === []) { + return collect(); + } + + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return Product::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->whereIn('id', $this->assignedProductIds) + ->get() + ->sortBy(fn (Product $product): int => array_search($product->getKey(), $this->assignedProductIds, true)) + ->values(); + } + + public function render(): mixed + { + return view('livewire.admin.collections.form', [ + 'searchResults' => $this->searchResults(), + 'assignedProducts' => $this->assignedProducts(), + 'isEditing' => $this->collection !== null, + ])->layout('layouts.app', [ + 'title' => $this->collection ? $this->collection->title : __('Add collection'), + ]); + } + + private function fillFromCollection(Collection $collection): void + { + $this->title = $collection->title; + $this->handle = $collection->handle; + $this->descriptionHtml = (string) $collection->description_html; + $this->status = $collection->status->value; + $this->assignedProductIds = $collection->products->pluck('id')->map(fn (int $id): int => $id)->all(); + } + + private function sanitizeHtml(?string $html): ?string + { + $sanitized = app(SanitizeHtml::class)($html); + + return $sanitized === '' ? null : $sanitized; + } + + private function authorizeSave(): void + { + if ($this->collection instanceof Collection) { + $this->authorize('update', $this->collection); + + return; + } + + $this->authorize('create', Collection::class); + } + + /** + * @return list + */ + private function assignedProductIdsForSync(Store $store): array + { + if ($this->assignedProductIds === []) { + return []; + } + + return Product::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->whereIn('id', $this->assignedProductIds) + ->pluck('id') + ->map(fn (int $id): int => $id) + ->all(); + } +} diff --git a/app/Livewire/Admin/Collections/Index.php b/app/Livewire/Admin/Collections/Index.php new file mode 100644 index 00000000..f2f883e4 --- /dev/null +++ b/app/Livewire/Admin/Collections/Index.php @@ -0,0 +1,64 @@ +resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function deleteCollection(int $id): void + { + $collection = Collection::query()->findOrFail($id); + + $this->authorize('delete', $collection); + + $collection->delete(); + + $this->dispatch('toast', type: 'success', message: __('Collection deleted')); + } + + public function collections(): LengthAwarePaginator + { + return Collection::query() + ->withCount('products') + ->when($this->search !== '', function (Builder $query): void { + $query->where('title', 'like', '%'.$this->search.'%'); + }) + ->when($this->statusFilter !== 'all', fn (Builder $query) => $query->where('status', $this->statusFilter)) + ->latest('updated_at') + ->paginate(15); + } + + public function render(): mixed + { + $this->authorize('viewAny', Collection::class); + + return view('livewire.admin.collections.index', [ + 'collections' => $this->collections(), + ])->layout('layouts.app', [ + 'title' => __('Collections'), + ]); + } +} diff --git a/app/Livewire/Admin/Customers/Index.php b/app/Livewire/Admin/Customers/Index.php new file mode 100644 index 00000000..abfd664b --- /dev/null +++ b/app/Livewire/Admin/Customers/Index.php @@ -0,0 +1,66 @@ +storeId = $store->getKey(); + $this->storeCurrency = $store->default_currency; + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function customers(): LengthAwarePaginator + { + return Customer::withoutGlobalScopes() + ->withCount('orders') + ->withSum('orders as total_spent', 'total_amount') + ->where('store_id', $this->storeId) + ->when($this->search !== '', function (Builder $query): void { + $search = '%'.$this->search.'%'; + + $query->where(function (Builder $query) use ($search): void { + $query + ->where('name', 'like', $search) + ->orWhere('email', 'like', $search); + }); + }) + ->latest('created_at') + ->paginate(20); + } + + public function render(): mixed + { + return view('livewire.admin.customers.index', [ + 'customers' => $this->customers(), + ])->layout('layouts.app', [ + 'title' => __('Customers'), + ]); + } +} diff --git a/app/Livewire/Admin/Customers/Show.php b/app/Livewire/Admin/Customers/Show.php new file mode 100644 index 00000000..faa0e793 --- /dev/null +++ b/app/Livewire/Admin/Customers/Show.php @@ -0,0 +1,217 @@ + '', + 'last_name' => '', + 'address1' => '', + 'address2' => '', + 'city' => '', + 'province_code' => '', + 'country' => 'DE', + 'postal_code' => '', + ]; + + public function mount(Customer $customer): void + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + $customer = Customer::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->whereKey($customer->getKey()) + ->first(); + + abort_unless($customer instanceof Customer, 404); + + $this->authorize('view', $customer); + + $this->storeId = $store->getKey(); + $this->customerId = $customer->getKey(); + } + + public function openAddressForm(?int $addressId = null): void + { + $this->editingAddressId = $addressId; + + if ($addressId) { + $address = $this->address($addressId); + $this->addressLabel = (string) $address->label; + $this->addressJson = array_merge($this->emptyAddressJson(), $address->address_json ?? []); + } else { + $this->resetAddressForm(); + } + + $this->modal('address-form')->show(); + } + + public function saveAddress(): void + { + $this->authorize('update', $this->customer()); + + $this->validate([ + 'addressLabel' => ['nullable', 'string', 'max:255'], + 'addressJson.first_name' => ['nullable', 'string', 'max:255'], + 'addressJson.last_name' => ['nullable', 'string', 'max:255'], + 'addressJson.address1' => ['required', 'string', 'max:255'], + 'addressJson.address2' => ['nullable', 'string', 'max:255'], + 'addressJson.city' => ['required', 'string', 'max:255'], + 'addressJson.province_code' => ['nullable', 'string', 'max:255'], + 'addressJson.country' => ['required', 'string', 'size:2'], + 'addressJson.postal_code' => ['required', 'string', 'max:32'], + ]); + + $address = $this->editingAddressId + ? $this->address($this->editingAddressId) + : new CustomerAddress(['customer_id' => $this->customerId]); + + $address->fill([ + 'label' => $this->addressLabel !== '' ? $this->addressLabel : null, + 'address_json' => $this->addressJson, + 'is_default' => $address->exists ? $address->is_default : ! $this->customer()->addresses()->exists(), + ]); + $address->save(); + + $this->resetAddressForm(); + $this->modal('address-form')->close(); + $this->dispatch('toast', type: 'success', message: __('Address saved')); + } + + public function deleteAddress(int $addressId): void + { + $this->authorize('update', $this->customer()); + + $address = $this->address($addressId); + $wasDefault = $address->is_default; + $address->delete(); + + if ($wasDefault) { + $this->setFirstAddressAsDefault(); + } + + $this->dispatch('toast', type: 'success', message: __('Address deleted')); + } + + public function setDefaultAddress(int $addressId): void + { + $this->authorize('update', $this->customer()); + + $address = $this->address($addressId); + + DB::transaction(function () use ($address): void { + CustomerAddress::query() + ->where('customer_id', $this->customerId) + ->update(['is_default' => false]); + + $address->forceFill(['is_default' => true])->save(); + }); + + $this->dispatch('toast', type: 'success', message: __('Default address updated')); + } + + public function orders(): LengthAwarePaginator + { + return Order::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->where('customer_id', $this->customerId) + ->latest('placed_at') + ->latest('id') + ->paginate(10); + } + + public function render(): mixed + { + $customer = $this->customer(); + + return view('livewire.admin.customers.show', [ + 'customer' => $customer, + 'orders' => $this->orders(), + ])->layout('layouts.app', [ + 'title' => $customer->name ?: $customer->email, + ]); + } + + private function customer(): Customer + { + return Customer::withoutGlobalScopes() + ->with(['addresses' => fn ($query) => $query->orderByDesc('is_default')->orderBy('id')]) + ->where('store_id', $this->storeId) + ->whereKey($this->customerId) + ->firstOrFail(); + } + + private function address(int $addressId): CustomerAddress + { + return CustomerAddress::query() + ->where('customer_id', $this->customerId) + ->whereKey($addressId) + ->firstOrFail(); + } + + private function setFirstAddressAsDefault(): void + { + $nextAddress = CustomerAddress::query() + ->where('customer_id', $this->customerId) + ->oldest('id') + ->first(); + + if ($nextAddress instanceof CustomerAddress) { + $nextAddress->forceFill(['is_default' => true])->save(); + } + } + + private function resetAddressForm(): void + { + $this->editingAddressId = null; + $this->addressLabel = ''; + $this->addressJson = $this->emptyAddressJson(); + } + + /** + * @return array{first_name: string, last_name: string, address1: string, address2: string, city: string, province_code: string, country: string, postal_code: string} + */ + private function emptyAddressJson(): array + { + return [ + 'first_name' => '', + 'last_name' => '', + 'address1' => '', + 'address2' => '', + 'city' => '', + 'province_code' => '', + 'country' => 'DE', + 'postal_code' => '', + ]; + } +} diff --git a/app/Livewire/Admin/Dashboard.php b/app/Livewire/Admin/Dashboard.php new file mode 100644 index 00000000..40354abc --- /dev/null +++ b/app/Livewire/Admin/Dashboard.php @@ -0,0 +1,305 @@ + + */ + public array $ordersChartData = []; + + public int $maxOrdersChartCount = 1; + + /** + * @var list + */ + public array $topProducts = []; + + /** + * @var array{visits: int, add_to_cart: int, checkout_started: int, checkout_completed: int} + */ + public array $funnelData = [ + 'visits' => 0, + 'add_to_cart' => 0, + 'checkout_started' => 0, + 'checkout_completed' => 0, + ]; + + public function mount(): void + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + abort_unless($this->canViewAnalytics($store), 403); + + $this->storeId = $store->getKey(); + $this->storeCurrency = $store->default_currency; + + $this->loadKpis(); + } + + public function updatedDateRange(): void + { + $this->loadKpis(); + } + + public function updatedCustomStartDate(): void + { + if ($this->dateRange === 'custom') { + $this->loadKpis(); + } + } + + public function updatedCustomEndDate(): void + { + if ($this->dateRange === 'custom') { + $this->loadKpis(); + } + } + + public function loadKpis(): void + { + [$start, $end] = $this->currentRange(); + [$previousStart, $previousEnd] = $this->previousRange($start, $end); + + $this->ordersCount = $this->ordersBetween($start, $end)->count(); + $previousOrdersCount = $this->ordersBetween($previousStart, $previousEnd)->count(); + + $this->totalSales = (int) $this->revenueOrdersBetween($start, $end)->sum('total_amount'); + $previousTotalSales = (int) $this->revenueOrdersBetween($previousStart, $previousEnd)->sum('total_amount'); + + $revenueOrdersCount = $this->revenueOrdersBetween($start, $end)->count(); + $previousRevenueOrdersCount = $this->revenueOrdersBetween($previousStart, $previousEnd)->count(); + + $this->averageOrderValue = $revenueOrdersCount > 0 ? intdiv($this->totalSales, $revenueOrdersCount) : 0; + $previousAverageOrderValue = $previousRevenueOrdersCount > 0 ? intdiv($previousTotalSales, $previousRevenueOrdersCount) : 0; + + $this->visitorsCount = 0; + $this->salesChange = $this->percentChange($this->totalSales, $previousTotalSales); + $this->ordersChange = $this->percentChange($this->ordersCount, $previousOrdersCount); + $this->aovChange = $this->percentChange($this->averageOrderValue, $previousAverageOrderValue); + $this->visitorsChange = 0.0; + + $this->loadChart(); + $this->loadTopProducts(); + $this->loadFunnel(); + } + + public function loadChart(): void + { + [$start, $end] = $this->currentRange(); + + $counts = $this->ordersBetween($start, $end) + ->selectRaw('date(placed_at) as order_date, count(*) as aggregate') + ->groupBy('order_date') + ->pluck('aggregate', 'order_date'); + + $this->ordersChartData = collect(CarbonPeriod::create($start->copy()->startOfDay(), '1 day', $end->copy()->startOfDay())) + ->map(fn (CarbonInterface $date): array => [ + 'date' => $date->toDateString(), + 'label' => $date->format('M j'), + 'count' => (int) ($counts[$date->toDateString()] ?? 0), + ]) + ->values() + ->all(); + + $this->maxOrdersChartCount = max(1, max(array_column($this->ordersChartData, 'count') ?: [0])); + } + + public function loadTopProducts(): void + { + [$start, $end] = $this->currentRange(); + + $this->topProducts = OrderLine::query() + ->selectRaw('order_lines.title_snapshot as title, sum(order_lines.quantity) as units_sold, sum(order_lines.total_amount) as revenue') + ->join('orders', 'orders.id', '=', 'order_lines.order_id') + ->where('orders.store_id', $this->storeId) + ->whereBetween('orders.placed_at', [$start, $end]) + ->whereIn('orders.financial_status', $this->revenueStatuses()) + ->groupBy('order_lines.product_id', 'order_lines.title_snapshot') + ->orderByDesc('revenue') + ->limit(5) + ->get() + ->map(fn (OrderLine $line): array => [ + 'title' => (string) $line->title, + 'units_sold' => (int) $line->units_sold, + 'revenue' => (int) $line->revenue, + ]) + ->all(); + } + + public function loadFunnel(): void + { + [$start, $end] = $this->currentRange(); + + $this->funnelData = [ + 'visits' => 0, + 'add_to_cart' => Cart::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->whereBetween('created_at', [$start, $end]) + ->count(), + 'checkout_started' => Checkout::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->whereBetween('created_at', [$start, $end]) + ->count(), + 'checkout_completed' => $this->ordersCount, + ]; + } + + #[Computed] + public function formattedTotalSales(): string + { + return Money::format($this->totalSales, $this->storeCurrency); + } + + #[Computed] + public function formattedAov(): string + { + return Money::format($this->averageOrderValue, $this->storeCurrency); + } + + public function render(): mixed + { + return view('livewire.admin.dashboard')->layout('layouts.app', [ + 'title' => __('Dashboard'), + ]); + } + + /** + * @return array{0: CarbonInterface, 1: CarbonInterface} + */ + private function currentRange(): array + { + return match ($this->dateRange) { + 'today' => [now()->startOfDay(), now()->endOfDay()], + 'last_7_days' => [now()->subDays(6)->startOfDay(), now()->endOfDay()], + 'custom' => $this->customRange() ?? [now()->subDays(29)->startOfDay(), now()->endOfDay()], + default => [now()->subDays(29)->startOfDay(), now()->endOfDay()], + }; + } + + /** + * @return array{0: CarbonInterface, 1: CarbonInterface} + */ + private function previousRange(CarbonInterface $start, CarbonInterface $end): array + { + $days = max(1, $start->copy()->startOfDay()->diffInDays($end->copy()->startOfDay()) + 1); + $previousEnd = $start->copy()->subSecond(); + + return [$start->copy()->subDays($days)->startOfDay(), $previousEnd]; + } + + /** + * @return array{0: CarbonInterface, 1: CarbonInterface}|null + */ + private function customRange(): ?array + { + if (! $this->customStartDate || ! $this->customEndDate) { + return null; + } + + try { + $start = Carbon::parse($this->customStartDate)->startOfDay(); + $end = Carbon::parse($this->customEndDate)->endOfDay(); + } catch (Throwable) { + return null; + } + + if ($end->lessThan($start)) { + return [$end->startOfDay(), $start->endOfDay()]; + } + + return [$start, $end]; + } + + /** + * @return Builder + */ + private function ordersBetween(CarbonInterface $start, CarbonInterface $end): Builder + { + return Order::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->whereBetween('placed_at', [$start, $end]); + } + + /** + * @return Builder + */ + private function revenueOrdersBetween(CarbonInterface $start, CarbonInterface $end): Builder + { + return $this->ordersBetween($start, $end) + ->whereIn('financial_status', $this->revenueStatuses()); + } + + /** + * @return list + */ + private function revenueStatuses(): array + { + return [ + FinancialStatus::Paid->value, + FinancialStatus::PartiallyRefunded->value, + ]; + } + + private function percentChange(int $current, int $previous): float + { + if ($previous === 0) { + return $current > 0 ? 100.0 : 0.0; + } + + return round((($current - $previous) / $previous) * 100, 1); + } + + private function canViewAnalytics(Store $store): bool + { + $role = auth()->user()?->roleForStoreId($store->getKey()); + + return in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff], true); + } +} diff --git a/app/Livewire/Admin/Developers/Index.php b/app/Livewire/Admin/Developers/Index.php new file mode 100644 index 00000000..119dc26c --- /dev/null +++ b/app/Livewire/Admin/Developers/Index.php @@ -0,0 +1,218 @@ + + */ + public array $tokenAbilities = [ + 'read-products', + 'write-products', + 'read-orders', + 'write-orders', + 'read-customers', + 'write-customers', + 'read-content', + 'write-content', + 'read-analytics', + ]; + + public function mount(): void + { + $store = $this->store(); + + $this->authorize('update', $store); + $this->storeId = $store->getKey(); + } + + public function generateToken(WebhookService $webhooks): void + { + $this->authorize('update', $this->scopedStore()); + + $this->validate([ + 'newTokenName' => ['required', 'string', 'max:255'], + ], [], [ + 'newTokenName' => 'token name', + ]); + + $user = auth()->user(); + + abort_unless($user instanceof User, 403); + + $result = $webhooks->createApiToken($this->scopedStore(), $this->newTokenName, $this->tokenAbilities, $user); + + $this->generatedToken = $result['plain_text']; + $this->newTokenName = ''; + $this->modal('generate-token')->close(); + $this->dispatch('toast', type: 'success', message: __('API token generated')); + } + + public function revokeToken(int $tokenId): void + { + $this->authorize('update', $this->scopedStore()); + + $token = $this->token($tokenId); + app(AuditLogger::class)->log('api_token.revoked', userId: auth()->id(), storeId: $this->storeId, extra: [ + 'token_name' => $token->name, + ]); + $token->delete(); + + session()->flash('status', __('API token revoked')); + $this->dispatch('toast', type: 'success', message: __('API token revoked')); + } + + public function openWebhookModal(?int $webhookId = null): void + { + $this->editingWebhookId = $webhookId; + + if ($webhookId !== null) { + $webhook = $this->webhook($webhookId); + $this->webhookEventType = $webhook->event_type->value; + $this->webhookUrl = $webhook->target_url; + } else { + $this->webhookEventType = WebhookEventType::OrderCreated->value; + $this->webhookUrl = ''; + } + + $this->modal('webhook-form')->show(); + } + + public function saveWebhook(WebhookService $webhooks): void + { + $this->authorize('update', $this->scopedStore()); + + $this->validate([ + 'webhookEventType' => ['required', Rule::enum(WebhookEventType::class)], + 'webhookUrl' => ['required', 'url', 'max:2048'], + ], [], [ + 'webhookEventType' => 'event type', + 'webhookUrl' => 'endpoint URL', + ]); + + $webhook = $this->editingWebhookId !== null + ? $this->webhook($this->editingWebhookId) + : new WebhookSubscription([ + 'store_id' => $this->storeId, + 'signing_secret_encrypted' => $webhooks->createSigningSecret(), + ]); + + $webhook->forceFill([ + 'event_type' => $this->webhookEventType, + 'target_url' => $this->webhookUrl, + 'status' => WebhookSubscriptionStatus::Active, + ])->save(); + + $this->editingWebhookId = null; + $this->webhookUrl = ''; + $this->modal('webhook-form')->close(); + $this->dispatch('toast', type: 'success', message: __('Webhook saved')); + } + + public function deleteWebhook(int $webhookId): void + { + $this->authorize('update', $this->scopedStore()); + + $this->webhook($webhookId)->delete(); + $this->dispatch('toast', type: 'success', message: __('Webhook deleted')); + } + + /** + * @return Collection + */ + public function tokens(): Collection + { + return PersonalAccessToken::query() + ->where('store_id', $this->storeId) + ->where('tokenable_type', (new User)->getMorphClass()) + ->whereIn('tokenable_id', $this->scopedStore()->users()->pluck('users.id')) + ->latest('created_at') + ->get(); + } + + /** + * @return Collection + */ + public function webhooks(): Collection + { + return WebhookSubscription::withoutGlobalScopes() + ->withCount([ + 'deliveries', + 'deliveries as failed_deliveries_count' => fn ($query) => $query->where('status', 'failed'), + ]) + ->where('store_id', $this->storeId) + ->orderBy('event_type') + ->get(); + } + + public function render() + { + return view('livewire.admin.developers.index', [ + 'tokens' => $this->tokens(), + 'webhooks' => $this->webhooks(), + 'eventTypes' => WebhookEventType::selectable(), + ])->layout('layouts.app', [ + 'title' => __('Developers'), + ]); + } + + private function token(int $tokenId): PersonalAccessToken + { + return PersonalAccessToken::query() + ->where('store_id', $this->storeId) + ->where('tokenable_type', (new User)->getMorphClass()) + ->whereIn('tokenable_id', $this->scopedStore()->users()->pluck('users.id')) + ->findOrFail($tokenId); + } + + private function webhook(int $webhookId): WebhookSubscription + { + return WebhookSubscription::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->findOrFail($webhookId); + } + + private function store(): Store + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } + + private function scopedStore(): Store + { + return Store::query()->findOrFail($this->storeId); + } +} diff --git a/app/Livewire/Admin/Discounts/Form.php b/app/Livewire/Admin/Discounts/Form.php new file mode 100644 index 00000000..6e25c1e7 --- /dev/null +++ b/app/Livewire/Admin/Discounts/Form.php @@ -0,0 +1,418 @@ + + */ + public array $specificProductIds = []; + + /** + * @var array + */ + public array $specificCollectionIds = []; + + public string $usageLimit = ''; + + public bool $onePerCustomer = false; + + public string $startsAt = ''; + + public string $endsAt = ''; + + public bool $isActive = false; + + public string $productSearch = ''; + + public string $collectionSearch = ''; + + public string $storeCurrency = 'EUR'; + + public function mount(?Discount $discount = null): void + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + $this->storeCurrency = $store->default_currency; + + if ($discount?->exists) { + abort_unless((int) $discount->store_id === $store->getKey(), 404); + + $this->authorize('update', $discount); + + $this->discount = $discount; + $this->fillFromDiscount($discount); + + return; + } + + $this->authorize('create', Discount::class); + + $this->startsAt = now()->format('Y-m-d\TH:i'); + } + + public function save(): void + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + $this->authorizeSave(); + $this->normalizeCode(); + + $this->validate([ + 'type' => ['required', Rule::in(['code', 'automatic'])], + 'code' => [ + Rule::requiredIf($this->type === 'code'), + 'nullable', + 'string', + 'max:255', + function (string $attribute, mixed $value, \Closure $fail) use ($store): void { + if ($value === null || trim((string) $value) === '') { + return; + } + + $exists = Discount::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->whereRaw('lower(code) = ?', [Str::lower(trim((string) $value))]) + ->when($this->discount instanceof Discount, fn ($query) => $query->where('id', '!=', $this->discount?->getKey())) + ->exists(); + + if ($exists) { + $fail(__('The discount code has already been taken.')); + } + }, + ], + 'valueType' => ['required', Rule::in(['percent', 'fixed', 'free_shipping'])], + 'valueAmount' => $this->valueAmountRules(), + 'minimumPurchaseAmount' => ['nullable', 'numeric', 'min:0'], + 'usageLimit' => ['nullable', 'integer', 'min:1'], + 'startsAt' => ['required', 'date'], + 'endsAt' => ['nullable', 'date', 'after:startsAt'], + 'onePerCustomer' => ['boolean'], + 'isActive' => ['boolean'], + ], [], [ + 'valueAmount' => 'value amount', + ]); + + $discount = $this->discount instanceof Discount + ? tap($this->discount)->update($this->payload($store)) + : Discount::withoutGlobalScopes()->create($this->payload($store)); + + $this->discount = $discount->refresh(); + $this->fillFromDiscount($this->discount); + + session()->flash('status', 'Discount saved'); + $this->dispatch('toast', type: 'success', message: __('Discount saved')); + } + + public function generateCode(): void + { + do { + $code = Str::upper(Str::random(8)); + } while (Discount::withoutGlobalScopes() + ->where('store_id', $this->storeId()) + ->where('code', $code) + ->exists()); + + $this->code = $code; + } + + public function addProduct(int $productId): void + { + abort_unless($this->productBelongsToStore($productId), 404); + + $this->specificProductIds = collect([...$this->specificProductIds, $productId]) + ->unique() + ->values() + ->all(); + $this->productSearch = ''; + } + + public function removeProduct(int $productId): void + { + $this->specificProductIds = array_values(array_filter( + $this->specificProductIds, + fn (int $selectedProductId): bool => $selectedProductId !== $productId, + )); + } + + public function addCollection(int $collectionId): void + { + abort_unless($this->collectionBelongsToStore($collectionId), 404); + + $this->specificCollectionIds = collect([...$this->specificCollectionIds, $collectionId]) + ->unique() + ->values() + ->all(); + $this->collectionSearch = ''; + } + + public function removeCollection(int $collectionId): void + { + $this->specificCollectionIds = array_values(array_filter( + $this->specificCollectionIds, + fn (int $selectedCollectionId): bool => $selectedCollectionId !== $collectionId, + )); + } + + public function productResults(): SupportCollection + { + if (trim($this->productSearch) === '') { + return collect(); + } + + return Product::withoutGlobalScopes() + ->where('store_id', $this->storeId()) + ->whereNotIn('id', $this->specificProductIds) + ->where('title', 'like', '%'.$this->productSearch.'%') + ->orderBy('title') + ->limit(5) + ->get(); + } + + public function collectionResults(): SupportCollection + { + if (trim($this->collectionSearch) === '') { + return collect(); + } + + return ProductCollection::withoutGlobalScopes() + ->where('store_id', $this->storeId()) + ->whereNotIn('id', $this->specificCollectionIds) + ->where('title', 'like', '%'.$this->collectionSearch.'%') + ->orderBy('title') + ->limit(5) + ->get(); + } + + public function selectedProducts(): SupportCollection + { + return Product::withoutGlobalScopes() + ->where('store_id', $this->storeId()) + ->whereKey($this->specificProductIds) + ->orderBy('title') + ->get(); + } + + public function selectedCollections(): SupportCollection + { + return ProductCollection::withoutGlobalScopes() + ->where('store_id', $this->storeId()) + ->whereKey($this->specificCollectionIds) + ->orderBy('title') + ->get(); + } + + public function render(): mixed + { + return view('livewire.admin.discounts.form', [ + 'isEditing' => $this->discount instanceof Discount, + 'productResults' => $this->productResults(), + 'collectionResults' => $this->collectionResults(), + 'selectedProducts' => $this->selectedProducts(), + 'selectedCollections' => $this->selectedCollections(), + ])->layout('layouts.app', [ + 'title' => $this->discount ? __('Edit discount') : __('Create discount'), + ]); + } + + private function fillFromDiscount(Discount $discount): void + { + $this->type = $discount->type->value; + $this->code = (string) $discount->code; + $this->valueType = $discount->value_type->value; + $this->valueAmount = $discount->value_type === DiscountValueType::Fixed + ? number_format($discount->value_amount / 100, 2, '.', '') + : (string) $discount->value_amount; + $this->minimumPurchaseAmount = data_get($discount->rules_json, 'min_purchase_amount') + ? number_format(((int) data_get($discount->rules_json, 'min_purchase_amount')) / 100, 2, '.', '') + : ''; + $this->specificProductIds = collect(data_get($discount->rules_json, 'applicable_product_ids', [])) + ->map(fn (mixed $id): int => (int) $id) + ->all(); + $this->specificCollectionIds = collect(data_get($discount->rules_json, 'applicable_collection_ids', [])) + ->map(fn (mixed $id): int => (int) $id) + ->all(); + $this->usageLimit = $discount->usage_limit !== null ? (string) $discount->usage_limit : ''; + $this->onePerCustomer = (bool) data_get($discount->rules_json, 'one_per_customer', false); + $this->startsAt = $discount->starts_at->format('Y-m-d\TH:i'); + $this->endsAt = $discount->ends_at?->format('Y-m-d\TH:i') ?? ''; + $this->isActive = $discount->status === DiscountStatus::Active; + } + + /** + * @return array + */ + private function payload(Store $store): array + { + return [ + 'store_id' => $store->getKey(), + 'type' => DiscountType::from($this->type), + 'code' => $this->type === 'code' ? Str::upper(trim($this->code)) : null, + 'value_type' => DiscountValueType::from($this->valueType), + 'value_amount' => $this->normalizedValueAmount(), + 'starts_at' => $this->startsAt, + 'ends_at' => $this->endsAt !== '' ? $this->endsAt : null, + 'usage_limit' => $this->usageLimit !== '' ? (int) $this->usageLimit : null, + 'rules_json' => $this->rulesPayload(), + 'status' => $this->statusForPayload(), + ]; + } + + /** + * @return array + */ + private function rulesPayload(): array + { + return [ + 'min_purchase_amount' => $this->minimumPurchaseAmount !== '' ? Money::fromDecimalString($this->minimumPurchaseAmount) : 0, + 'applicable_product_ids' => $this->validProductIds(), + 'applicable_collection_ids' => $this->validCollectionIds(), + 'one_per_customer' => $this->onePerCustomer, + 'customer_eligibility' => 'all', + ]; + } + + private function normalizedValueAmount(): int + { + return match ($this->valueType) { + 'fixed' => Money::fromDecimalString($this->valueAmount), + 'free_shipping' => 0, + default => (int) round((float) $this->valueAmount), + }; + } + + /** + * @return array + */ + private function validProductIds(): array + { + return Product::withoutGlobalScopes() + ->where('store_id', $this->storeId()) + ->whereKey($this->specificProductIds) + ->pluck('id') + ->map(fn (int $id): int => $id) + ->all(); + } + + /** + * @return array + */ + private function validCollectionIds(): array + { + return ProductCollection::withoutGlobalScopes() + ->where('store_id', $this->storeId()) + ->whereKey($this->specificCollectionIds) + ->pluck('id') + ->map(fn (int $id): int => $id) + ->all(); + } + + private function productBelongsToStore(int $productId): bool + { + return Product::withoutGlobalScopes() + ->where('store_id', $this->storeId()) + ->whereKey($productId) + ->exists(); + } + + private function collectionBelongsToStore(int $collectionId): bool + { + return ProductCollection::withoutGlobalScopes() + ->where('store_id', $this->storeId()) + ->whereKey($collectionId) + ->exists(); + } + + private function storeId(): int + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store->getKey(); + } + + private function authorizeSave(): void + { + if ($this->discount instanceof Discount) { + $this->authorize('update', $this->discount); + + return; + } + + $this->authorize('create', Discount::class); + } + + private function normalizeCode(): void + { + if ($this->type !== DiscountType::Code->value) { + $this->code = ''; + + return; + } + + $this->code = Str::upper(trim($this->code)); + } + + /** + * @return list + */ + private function valueAmountRules(): array + { + return match ($this->valueType) { + DiscountValueType::Percent->value => ['required', 'integer', 'min:1', 'max:100'], + DiscountValueType::FreeShipping->value => ['nullable'], + default => ['required', 'numeric', 'min:0.01'], + }; + } + + private function statusForPayload(): DiscountStatus + { + if ($this->discount?->status === DiscountStatus::Expired) { + return DiscountStatus::Expired; + } + + if ($this->isActive) { + return DiscountStatus::Active; + } + + if ($this->discount?->status === DiscountStatus::Draft || ! $this->discount instanceof Discount) { + return DiscountStatus::Draft; + } + + return DiscountStatus::Disabled; + } +} diff --git a/app/Livewire/Admin/Discounts/Index.php b/app/Livewire/Admin/Discounts/Index.php new file mode 100644 index 00000000..c40555d3 --- /dev/null +++ b/app/Livewire/Admin/Discounts/Index.php @@ -0,0 +1,149 @@ +authorize('viewAny', Discount::class); + + $this->storeId = $store->getKey(); + $this->storeCurrency = $store->default_currency; + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function updatedTypeFilter(): void + { + $this->resetPage(); + } + + public function discounts(): LengthAwarePaginator + { + return Discount::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->when($this->search !== '', function (Builder $query): void { + $search = '%'.$this->search.'%'; + + $query->where(function (Builder $query) use ($search): void { + $query + ->where('code', 'like', $search) + ->orWhere('type', 'like', $search); + }); + }) + ->when($this->statusFilter !== 'all', fn (Builder $query) => $this->applyStatusFilter($query)) + ->when($this->typeFilter !== 'all', fn (Builder $query) => $query->where('type', $this->typeFilter)) + ->latest('starts_at') + ->latest('id') + ->paginate(20); + } + + public function valueLabel(Discount $discount): string + { + return match ($discount->value_type) { + DiscountValueType::Percent => $discount->value_amount.'%', + DiscountValueType::Fixed => Money::format($discount->value_amount, $this->storeCurrency), + DiscountValueType::FreeShipping => 'Free shipping', + }; + } + + public function effectiveStatus(Discount $discount): string + { + if ($discount->status === DiscountStatus::Disabled) { + return 'disabled'; + } + + if ($discount->starts_at->isFuture()) { + return 'scheduled'; + } + + if ($discount->status === DiscountStatus::Expired || ($discount->ends_at !== null && $discount->ends_at->isPast())) { + return 'expired'; + } + + return $discount->status->value; + } + + public function statusColor(string $status): string + { + return match ($status) { + 'active' => 'green', + 'expired' => 'red', + 'scheduled' => 'amber', + default => 'zinc', + }; + } + + public function render(): mixed + { + return view('livewire.admin.discounts.index', [ + 'discounts' => $this->discounts(), + ])->layout('layouts.app', [ + 'title' => __('Discounts'), + ]); + } + + private function applyStatusFilter(Builder $query): void + { + match ($this->statusFilter) { + 'active' => $query + ->where('status', DiscountStatus::Active->value) + ->where('starts_at', '<=', now()) + ->where(function (Builder $query): void { + $query->whereNull('ends_at')->orWhere('ends_at', '>=', now()); + }), + 'expired' => $query->where(function (Builder $query): void { + $query + ->where('status', DiscountStatus::Expired->value) + ->orWhere(function (Builder $query): void { + $query + ->where('status', DiscountStatus::Active->value) + ->where('ends_at', '<', now()); + }); + }), + 'scheduled' => $query + ->where('status', DiscountStatus::Active->value) + ->where('starts_at', '>', now()), + default => null, + }; + } +} diff --git a/app/Livewire/Admin/Inventory/Index.php b/app/Livewire/Admin/Inventory/Index.php new file mode 100644 index 00000000..bd741a86 --- /dev/null +++ b/app/Livewire/Admin/Inventory/Index.php @@ -0,0 +1,56 @@ +resetPage(); + } + + public function updatedStockFilter(): void + { + $this->resetPage(); + } + + public function items(): LengthAwarePaginator + { + return InventoryItem::query() + ->with(['variant.product']) + ->when($this->search !== '', function (Builder $query): void { + $search = '%'.$this->search.'%'; + + $query->whereHas('variant', function (Builder $query) use ($search): void { + $query + ->where('sku', 'like', $search) + ->orWhereHas('product', fn (Builder $query) => $query->where('title', 'like', $search)); + }); + }) + ->when($this->stockFilter === 'low', fn (Builder $query) => $query->whereRaw('(quantity_on_hand - quantity_reserved) BETWEEN 1 AND 10')) + ->when($this->stockFilter === 'out', fn (Builder $query) => $query->whereRaw('(quantity_on_hand - quantity_reserved) <= 0')) + ->orderBy('quantity_on_hand') + ->paginate(20); + } + + public function render(): mixed + { + return view('livewire.admin.inventory.index', [ + 'items' => $this->items(), + ])->layout('layouts.app', [ + 'title' => __('Inventory'), + ]); + } +} diff --git a/app/Livewire/Admin/Navigation/Index.php b/app/Livewire/Admin/Navigation/Index.php new file mode 100644 index 00000000..584bbc4d --- /dev/null +++ b/app/Livewire/Admin/Navigation/Index.php @@ -0,0 +1,515 @@ + + */ + public array $menuItems = []; + + public ?int $editingItemIndex = null; + + public string $itemLabel = ''; + + public string $itemType = 'link'; + + public string $itemUrl = ''; + + public ?int $itemResourceId = null; + + public string $itemParentKey = ''; + + public function mount(): void + { + $store = $this->store(); + + $this->authorize('update', $store); + + $this->storeId = $store->getKey(); + $this->selectedMenuId = $this->menus()->first()?->getKey(); + + if ($this->selectedMenuId !== null) { + $this->loadMenuItems(); + } + } + + public function selectMenu(int $menuId): void + { + $this->authorize('update', $this->scopedStore()); + + $this->selectedMenuId = $this->menu($menuId)->getKey(); + $this->loadMenuItems(); + $this->cancelItem(); + } + + public function addItem(): void + { + $this->editingItemIndex = null; + $this->itemLabel = ''; + $this->itemType = 'link'; + $this->itemUrl = ''; + $this->itemResourceId = null; + $this->itemParentKey = ''; + } + + public function editItem(int $index): void + { + abort_unless(isset($this->menuItems[$index]), 404); + + $item = $this->menuItems[$index]; + + $this->editingItemIndex = $index; + $this->itemLabel = $item['label']; + $this->itemType = $item['type']; + $this->itemUrl = (string) ($item['url'] ?? ''); + $this->itemResourceId = $item['resource_id']; + $this->itemParentKey = (string) ($item['parent_key'] ?? ''); + } + + public function saveItem(): void + { + $this->authorize('update', $this->scopedStore()); + + $validated = $this->validate([ + 'itemLabel' => ['required', 'string', 'max:255'], + 'itemType' => ['required', Rule::in(array_column(NavigationItemType::cases(), 'value'))], + 'itemUrl' => [Rule::requiredIf($this->itemType === NavigationItemType::Link->value), 'nullable', 'string', 'max:255'], + 'itemResourceId' => [Rule::requiredIf($this->itemType !== NavigationItemType::Link->value), 'nullable', 'integer'], + 'itemParentKey' => ['nullable', 'string'], + ], [], [ + 'itemLabel' => 'label', + 'itemType' => 'type', + 'itemUrl' => 'URL', + 'itemResourceId' => 'resource', + 'itemParentKey' => 'parent item', + ]); + + $parentKey = $this->normalizedParentKey($validated['itemParentKey']); + + $currentKey = $this->editingItemIndex !== null ? $this->menuItems[$this->editingItemIndex]['key'] : null; + + if (! $this->validParentKey($parentKey, $currentKey)) { + $this->addError('itemParentKey', __('Select a valid top-level parent item.')); + + return; + } + + if ($this->editingItemIndex !== null + && $parentKey !== null + && $this->itemHasChildren($this->menuItems[$this->editingItemIndex]['key'])) { + $this->addError('itemParentKey', __('Items with children must stay top-level.')); + + return; + } + + if ($this->itemType !== NavigationItemType::Link->value && ! $this->resourceExists($this->itemType, (int) $this->itemResourceId)) { + $this->addError('itemResourceId', __('Select a valid resource for this store.')); + + return; + } + + $item = [ + 'id' => $this->editingItemIndex !== null ? $this->menuItems[$this->editingItemIndex]['id'] : null, + 'key' => $this->editingItemIndex !== null ? $this->menuItems[$this->editingItemIndex]['key'] : 'new-'.Str::uuid()->toString(), + 'parent_key' => $parentKey, + 'label' => $validated['itemLabel'], + 'type' => $validated['itemType'], + 'url' => $this->itemType === NavigationItemType::Link->value ? $validated['itemUrl'] : null, + 'resource_id' => $this->itemType === NavigationItemType::Link->value ? null : (int) $this->itemResourceId, + ]; + + if ($this->editingItemIndex === null) { + $this->menuItems[] = $item; + } else { + $this->menuItems[$this->editingItemIndex] = $item; + } + + $this->cancelItem(); + } + + public function removeItem(int $index): void + { + $removedKey = $this->menuItems[$index]['key'] ?? null; + + unset($this->menuItems[$index]); + + $this->menuItems = array_values(array_filter( + $this->menuItems, + fn (array $item): bool => $removedKey === null || ($item['parent_key'] ?? null) !== $removedKey, + )); + } + + public function moveItemUp(int $index): void + { + if (! isset($this->menuItems[$index])) { + return; + } + + $this->moveSibling($index, -1); + } + + public function moveItemDown(int $index): void + { + if (! isset($this->menuItems[$index])) { + return; + } + + $this->moveSibling($index, 1); + } + + public function reorderItem(string $key, int $position, string $parentKey = 'root'): void + { + $targetParentKey = $this->normalizedParentKey($parentKey === 'root' ? '' : $parentKey); + + if (! $this->validParentKey($targetParentKey, $key) || ($targetParentKey !== null && $this->itemHasChildren($key))) { + return; + } + + $index = $this->itemIndexForKey($key); + + if ($index === null) { + return; + } + + $item = $this->menuItems[$index]; + array_splice($this->menuItems, $index, 1); + + $item['parent_key'] = $targetParentKey; + $siblingIndexes = $this->siblingIndices($targetParentKey); + $insertAt = $siblingIndexes[$position] ?? (count($this->menuItems)); + + if ($position >= count($siblingIndexes) && $siblingIndexes !== []) { + $insertAt = ((int) end($siblingIndexes)) + 1; + } + + array_splice($this->menuItems, $insertAt, 0, [$item]); + $this->menuItems = array_values($this->menuItems); + } + + public function saveMenu(NavigationService $navigation): void + { + $this->authorize('update', $this->scopedStore()); + + $menu = $this->selectedMenu(); + + DB::transaction(function () use ($menu): void { + NavigationItem::withoutGlobalScopes() + ->where('menu_id', $menu->getKey()) + ->delete(); + + $this->persistMenuItems($menu); + }); + + $navigation->forget($menu); + $this->loadMenuItems(); + + session()->flash('status', 'Navigation saved'); + $this->dispatch('toast', type: 'success', message: __('Navigation saved')); + } + + public function cancelItem(): void + { + $this->editingItemIndex = null; + $this->itemLabel = ''; + $this->itemType = 'link'; + $this->itemUrl = ''; + $this->itemResourceId = null; + $this->itemParentKey = ''; + $this->resetErrorBag(['itemLabel', 'itemType', 'itemUrl', 'itemResourceId', 'itemParentKey']); + } + + /** + * @return Collection + */ + public function menus(): Collection + { + return NavigationMenu::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->orderByRaw("case when handle = 'main-menu' then 0 when handle = 'footer-menu' then 1 else 2 end") + ->orderBy('title') + ->get(); + } + + /** + * @return Collection + */ + public function resourcesForType(): Collection + { + return match ($this->itemType) { + NavigationItemType::Page->value => Page::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->orderBy('title') + ->get(['id', 'title']), + NavigationItemType::Collection->value => ProductCollection::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->orderBy('title') + ->get(['id', 'title']), + NavigationItemType::Product->value => Product::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->orderBy('title') + ->limit(100) + ->get(['id', 'title']), + default => collect(), + }; + } + + public function targetLabel(array $item): string + { + return match ($item['type']) { + NavigationItemType::Page->value => 'page: '.$this->resourceTitle(Page::class, $item['resource_id']), + NavigationItemType::Collection->value => 'collection: '.$this->resourceTitle(ProductCollection::class, $item['resource_id']), + NavigationItemType::Product->value => 'product: '.$this->resourceTitle(Product::class, $item['resource_id']), + default => 'link: '.($item['url'] ?: '#'), + }; + } + + /** + * @return array> + */ + public function nestedMenuItems(): array + { + $itemsByParent = collect($this->menuItems) + ->groupBy(fn (array $item): string => $item['parent_key'] ?? 'root', preserveKeys: true); + + $build = function (string $parentKey) use (&$build, $itemsByParent): array { + return $itemsByParent->get($parentKey, collect()) + ->map(fn (array $item, int $index): array => array_merge($item, [ + 'index' => $index, + 'children' => $build($item['key']), + ])) + ->values() + ->all(); + }; + + return $build('root'); + } + + /** + * @return array + */ + public function parentOptions(): array + { + $editingKey = $this->editingItemIndex !== null ? $this->menuItems[$this->editingItemIndex]['key'] : null; + + return collect($this->menuItems) + ->filter(fn (array $item): bool => ($item['parent_key'] ?? null) === null && $item['key'] !== $editingKey) + ->map(fn (array $item): array => [ + 'key' => $item['key'], + 'label' => $item['label'], + ]) + ->values() + ->all(); + } + + public function render(): mixed + { + return view('livewire.admin.navigation.index', [ + 'menus' => $this->menus(), + 'selectedMenu' => $this->selectedMenu(), + 'navigationTree' => $this->nestedMenuItems(), + 'parentOptions' => $this->parentOptions(), + 'resources' => $this->resourcesForType(), + ])->layout('layouts.app', [ + 'title' => __('Navigation'), + ]); + } + + private function store(): Store + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } + + private function scopedStore(): Store + { + return Store::query()->whereKey($this->storeId)->firstOrFail(); + } + + private function selectedMenu(): NavigationMenu + { + abort_unless($this->selectedMenuId !== null, 404); + + return $this->menu($this->selectedMenuId); + } + + private function menu(int $menuId): NavigationMenu + { + return NavigationMenu::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->whereKey($menuId) + ->firstOrFail(); + } + + private function loadMenuItems(): void + { + $this->menuItems = NavigationItem::withoutGlobalScopes() + ->where('menu_id', $this->selectedMenu()->getKey()) + ->orderBy('parent_id') + ->orderBy('position') + ->get() + ->map(fn (NavigationItem $item): array => [ + 'id' => $item->getKey(), + 'key' => (string) $item->getKey(), + 'parent_key' => $item->parent_id === null ? null : (string) $item->parent_id, + 'label' => $item->label, + 'type' => $item->type->value, + 'url' => $item->url, + 'resource_id' => $item->resource_id, + ]) + ->all(); + } + + private function resourceExists(string $type, int $resourceId): bool + { + return match ($type) { + NavigationItemType::Page->value => Page::withoutGlobalScopes()->where('store_id', $this->storeId)->whereKey($resourceId)->exists(), + NavigationItemType::Collection->value => ProductCollection::withoutGlobalScopes()->where('store_id', $this->storeId)->whereKey($resourceId)->exists(), + NavigationItemType::Product->value => Product::withoutGlobalScopes()->where('store_id', $this->storeId)->whereKey($resourceId)->exists(), + default => true, + }; + } + + private function resourceTitle(string $modelClass, mixed $resourceId): string + { + if (! is_numeric($resourceId)) { + return 'Missing'; + } + + return $modelClass::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->whereKey((int) $resourceId) + ->value('title') ?? 'Missing'; + } + + private function persistMenuItems(NavigationMenu $menu): void + { + $itemsByParent = collect($this->menuItems) + ->groupBy(fn (array $item): string => $item['parent_key'] ?? 'root'); + + $persist = function (string $parentKey, ?int $parentId = null) use (&$persist, $itemsByParent, $menu): void { + $itemsByParent->get($parentKey, collect()) + ->values() + ->each(function (array $item, int $position) use (&$persist, $menu, $parentId): void { + $navigationItem = NavigationItem::withoutGlobalScopes()->create([ + 'menu_id' => $menu->getKey(), + 'parent_id' => $parentId, + 'type' => NavigationItemType::from($item['type']), + 'label' => $item['label'], + 'url' => $item['url'], + 'resource_id' => $item['resource_id'], + 'position' => $position, + ]); + + $persist($item['key'], $navigationItem->getKey()); + }); + }; + + $persist('root'); + } + + private function moveSibling(int $index, int $direction): void + { + $parentKey = $this->menuItems[$index]['parent_key'] ?? null; + $siblingIndices = $this->siblingIndices($parentKey); + $siblingPosition = array_search($index, $siblingIndices, true); + + if ($siblingPosition === false) { + return; + } + + $targetSiblingPosition = $siblingPosition + $direction; + + if (! isset($siblingIndices[$targetSiblingPosition])) { + return; + } + + $targetIndex = $siblingIndices[$targetSiblingPosition]; + [$this->menuItems[$index], $this->menuItems[$targetIndex]] = [$this->menuItems[$targetIndex], $this->menuItems[$index]]; + $this->menuItems = array_values($this->menuItems); + } + + /** + * @return list + */ + private function siblingIndices(?string $parentKey): array + { + return array_values(array_keys(array_filter( + $this->menuItems, + fn (array $item): bool => ($item['parent_key'] ?? null) === $parentKey, + ))); + } + + private function normalizedParentKey(?string $parentKey): ?string + { + $parentKey = trim((string) $parentKey); + + return $parentKey === '' ? null : $parentKey; + } + + private function validParentKey(?string $parentKey, ?string $movingKey = null): bool + { + if ($parentKey === null) { + return true; + } + + foreach ($this->menuItems as $item) { + if ($item['key'] === $parentKey + && ($item['parent_key'] ?? null) === null + && $item['key'] !== $movingKey) { + return true; + } + } + + return false; + } + + private function itemHasChildren(string $key): bool + { + foreach ($this->menuItems as $item) { + if (($item['parent_key'] ?? null) === $key) { + return true; + } + } + + return false; + } + + private function itemIndexForKey(string $key): ?int + { + foreach ($this->menuItems as $index => $item) { + if ($item['key'] === $key) { + return $index; + } + } + + return null; + } +} diff --git a/app/Livewire/Admin/Orders/Index.php b/app/Livewire/Admin/Orders/Index.php new file mode 100644 index 00000000..543c55eb --- /dev/null +++ b/app/Livewire/Admin/Orders/Index.php @@ -0,0 +1,131 @@ +storeId = $store->getKey(); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function updatedFinancialStatusFilter(): void + { + $this->resetPage(); + } + + public function updatedFulfillmentStatusFilter(): void + { + $this->resetPage(); + } + + public function updatedDateFrom(): void + { + $this->resetPage(); + } + + public function updatedDateTo(): void + { + $this->resetPage(); + } + + public function orders(): LengthAwarePaginator + { + $dateFrom = $this->parsedDate($this->dateFrom); + $dateTo = $this->parsedDate($this->dateTo, endOfDay: true); + + return Order::withoutGlobalScopes() + ->with('customer') + ->withCount('lines') + ->where('store_id', $this->storeId) + ->when($this->search !== '', function (Builder $query): void { + $search = '%'.$this->search.'%'; + + $query->where(function (Builder $query) use ($search): void { + $query + ->where('order_number', 'like', $search) + ->orWhere('email', 'like', $search) + ->orWhereHas('customer', fn (Builder $query) => $query->where('name', 'like', $search)); + }); + }) + ->when($this->statusFilter !== 'all', fn (Builder $query) => $query->where('status', $this->statusFilter)) + ->when($this->financialStatusFilter !== 'all', fn (Builder $query) => $query->where('financial_status', $this->financialStatusFilter)) + ->when($this->fulfillmentStatusFilter !== 'all', fn (Builder $query) => $query->where('fulfillment_status', $this->fulfillmentStatusFilter)) + ->when($dateFrom instanceof Carbon, fn (Builder $query) => $query->where('placed_at', '>=', $dateFrom)) + ->when($dateTo instanceof Carbon, fn (Builder $query) => $query->where('placed_at', '<=', $dateTo)) + ->latest('placed_at') + ->latest('id') + ->paginate(20); + } + + public function render(): mixed + { + return view('livewire.admin.orders.index', [ + 'orders' => $this->orders(), + 'statuses' => OrderStatus::cases(), + 'financialStatuses' => FinancialStatus::cases(), + 'fulfillmentStatuses' => FulfillmentStatus::cases(), + ])->layout('layouts.app', [ + 'title' => __('Orders'), + ]); + } + + private function parsedDate(string $value, bool $endOfDay = false): ?Carbon + { + if ($value === '') { + return null; + } + + try { + $date = Carbon::parse($value); + + return $endOfDay ? $date->endOfDay() : $date->startOfDay(); + } catch (Throwable) { + return null; + } + } +} diff --git a/app/Livewire/Admin/Orders/Show.php b/app/Livewire/Admin/Orders/Show.php new file mode 100644 index 00000000..cfa3c6f0 --- /dev/null +++ b/app/Livewire/Admin/Orders/Show.php @@ -0,0 +1,256 @@ + + */ + public array $fulfillmentLineQuantities = []; + + public string $trackingCompany = ''; + + public string $trackingNumber = ''; + + public string $trackingUrl = ''; + + public string $actionMessage = ''; + + public function mount(Order $order): void + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + $order = Order::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->whereKey($order->getKey()) + ->first(); + + abort_unless($order instanceof Order, 404); + + $this->authorize('view', $order); + + $this->storeId = $store->getKey(); + $this->orderId = $order->getKey(); + $this->resetFulfillmentLineQuantities($this->order()); + } + + public function confirmBankTransferPayment(OrderService $orders): void + { + $this->authorize('update', $this->order()); + + try { + $orders->confirmBankTransferPayment($this->order()); + $this->resetFulfillmentLineQuantities($this->order()); + $this->actionMessage = __('Payment confirmed'); + $this->dispatch('toast', type: 'success', message: __('Payment confirmed')); + } catch (InvalidOrderOperationException $exception) { + throw ValidationException::withMessages([ + 'orderAction' => $exception->getMessage(), + ]); + } + } + + public function processRefund(RefundService $refunds): void + { + $this->authorize('createRefund', $this->order()); + + $this->validate([ + 'refundAmount' => ['nullable', 'numeric', 'min:0.01'], + 'refundReason' => ['nullable', 'string', 'max:500'], + ]); + + $request = [ + 'reason' => $this->refundReason !== '' ? $this->refundReason : null, + ]; + + if ($this->refundAmount !== '') { + $request['amount'] = Money::fromDecimalString($this->refundAmount); + } + + try { + $refunds->process($this->order(), $request); + $this->refundAmount = ''; + $this->refundReason = ''; + $this->actionMessage = __('Refund processed'); + $this->modal('refund-order')->close(); + $this->dispatch('toast', type: 'success', message: __('Refund processed')); + } catch (InvalidRefundOperationException $exception) { + throw ValidationException::withMessages([ + 'refundAmount' => $exception->getMessage(), + ]); + } + } + + public function createFulfillment(FulfillmentService $fulfillments): void + { + $this->authorize('createFulfillment', $this->order()); + + $this->validate([ + 'fulfillmentLineQuantities' => ['array'], + 'trackingCompany' => ['nullable', 'string', 'max:255'], + 'trackingNumber' => ['nullable', 'string', 'max:255'], + 'trackingUrl' => ['nullable', 'url', 'max:2048'], + ]); + + $lines = collect($this->fulfillmentLineQuantities) + ->mapWithKeys(fn (mixed $quantity, int|string $lineId): array => [(int) $lineId => (int) $quantity]) + ->filter(fn (int $quantity): bool => $quantity > 0) + ->all(); + + if ($lines === []) { + throw ValidationException::withMessages([ + 'fulfillment' => __('At least one fulfillment line is required.'), + ]); + } + + try { + $fulfillments->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, + ]); + + $this->trackingCompany = ''; + $this->trackingNumber = ''; + $this->trackingUrl = ''; + $this->resetFulfillmentLineQuantities($this->order()); + $this->actionMessage = __('Fulfillment created'); + $this->modal('fulfillment-order')->close(); + $this->dispatch('toast', type: 'success', message: __('Fulfillment created')); + } catch (InvalidFulfillmentOperationException $exception) { + throw ValidationException::withMessages([ + 'fulfillment' => $exception->getMessage(), + ]); + } + } + + public function markFulfillmentShipped(int $fulfillmentId, FulfillmentService $fulfillments): void + { + $this->authorize('update', $this->fulfillment($fulfillmentId)); + + try { + $fulfillments->markShipped($this->fulfillment($fulfillmentId)); + $this->actionMessage = __('Fulfillment marked as shipped'); + $this->dispatch('toast', type: 'success', message: __('Fulfillment marked as shipped')); + } catch (InvalidFulfillmentOperationException $exception) { + throw ValidationException::withMessages([ + 'fulfillment' => $exception->getMessage(), + ]); + } + } + + public function markFulfillmentDelivered(int $fulfillmentId, FulfillmentService $fulfillments): void + { + $this->authorize('update', $this->fulfillment($fulfillmentId)); + + try { + $fulfillments->markDelivered($this->fulfillment($fulfillmentId)); + $this->actionMessage = __('Fulfillment marked as delivered'); + $this->dispatch('toast', type: 'success', message: __('Fulfillment marked as delivered')); + } catch (InvalidFulfillmentOperationException $exception) { + throw ValidationException::withMessages([ + 'fulfillment' => $exception->getMessage(), + ]); + } + } + + public function render(): mixed + { + $order = $this->order(); + + return view('livewire.admin.orders.show', [ + 'order' => $order, + 'remainingFulfillmentQuantities' => $this->remainingFulfillmentQuantities($order), + 'refundableAmount' => $this->refundableAmount($order), + ])->layout('layouts.app', [ + 'title' => $order->order_number, + ]); + } + + private function order(): Order + { + return Order::withoutGlobalScopes() + ->with([ + 'customer', + 'lines.fulfillmentLines', + 'payments', + 'refunds', + 'fulfillments.lines.orderLine', + ]) + ->where('store_id', $this->storeId) + ->whereKey($this->orderId) + ->firstOrFail(); + } + + private function fulfillment(int $fulfillmentId): Fulfillment + { + $fulfillment = $this->order() + ->fulfillments + ->firstWhere('id', $fulfillmentId); + + abort_unless($fulfillment instanceof Fulfillment, 404); + + return $fulfillment; + } + + /** + * @return array + */ + private function remainingFulfillmentQuantities(Order $order): array + { + return $order->lines + ->mapWithKeys(function (OrderLine $line): array { + $fulfilled = $line->fulfillmentLines->sum('quantity'); + + return [$line->getKey() => max(0, $line->quantity - $fulfilled)]; + }) + ->all(); + } + + private function resetFulfillmentLineQuantities(Order $order): void + { + $this->fulfillmentLineQuantities = $this->remainingFulfillmentQuantities($order); + } + + private function refundableAmount(Order $order): int + { + $refunded = $order->refunds + ->reject(fn (Refund $refund): bool => $refund->status === RefundStatus::Failed) + ->sum('amount'); + + return max(0, $order->total_amount - $refunded); + } +} diff --git a/app/Livewire/Admin/Pages/Form.php b/app/Livewire/Admin/Pages/Form.php new file mode 100644 index 00000000..947e8cd2 --- /dev/null +++ b/app/Livewire/Admin/Pages/Form.php @@ -0,0 +1,186 @@ +exists) { + abort_unless((int) $page->store_id === $store->getKey(), 404); + + $this->authorize('update', $page); + + $this->page = $page; + $this->fillFromPage($page); + + return; + } + + $this->authorize('create', Page::class); + } + + public function updatedTitle(string $title): void + { + if ($this->page instanceof Page || $this->handle !== '') { + return; + } + + $this->handle = Str::slug($title); + } + + public function save(NavigationService $navigation): void + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + $this->authorizeSave(); + + $this->handle = Str::slug($this->handle); + + $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => [ + 'required', + 'string', + 'max:255', + 'alpha_dash', + Rule::unique('pages', 'handle') + ->where('store_id', $store->getKey()) + ->ignore($this->page?->getKey()), + ], + 'bodyHtml' => ['nullable', 'string'], + 'status' => ['required', Rule::in(array_column(PageStatus::cases(), 'value'))], + 'publishedAt' => ['nullable', 'date'], + ], [], [ + 'bodyHtml' => 'body', + 'publishedAt' => 'published at', + ]); + + $publishedAt = $this->publishedAt !== '' ? $this->publishedAt : null; + + if ($this->status === PageStatus::Published->value && $publishedAt === null) { + $publishedAt = now(); + } + + $page = $this->page instanceof Page + ? tap($this->page)->update($this->payload($store, $publishedAt)) + : Page::withoutGlobalScopes()->create($this->payload($store, $publishedAt)); + + $this->page = $page->refresh(); + $this->fillFromPage($this->page); + $this->forgetNavigation($store, $navigation); + + $this->actionMessage = 'Page saved'; + + session()->flash('status', 'Page saved'); + $this->dispatch('toast', type: 'success', message: __('Page saved')); + } + + public function deletePage(NavigationService $navigation): void + { + abort_unless($this->page instanceof Page, 404); + + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + $this->authorize('delete', $this->page); + + $this->page->delete(); + $this->forgetNavigation($store, $navigation); + + $this->redirectRoute('admin.pages.index', navigate: true); + } + + public function render(): mixed + { + return view('livewire.admin.pages.form', [ + 'isEditing' => $this->page instanceof Page, + ])->layout('layouts.app', [ + 'title' => $this->page ? __('Edit page') : __('Create page'), + ]); + } + + private function fillFromPage(Page $page): void + { + $this->title = $page->title; + $this->handle = $page->handle; + $this->bodyHtml = (string) $page->body_html; + $this->status = $page->status->value; + $this->publishedAt = $page->published_at?->format('Y-m-d\TH:i') ?? ''; + } + + /** + * @return array + */ + private function payload(Store $store, mixed $publishedAt): array + { + return [ + 'store_id' => $store->getKey(), + 'title' => $this->title, + 'handle' => $this->handle, + 'body_html' => $this->sanitizeHtml($this->bodyHtml), + 'status' => PageStatus::from($this->status), + 'published_at' => $publishedAt, + ]; + } + + private function sanitizeHtml(?string $html): ?string + { + $sanitized = app(SanitizeHtml::class)($html); + + return $sanitized === '' ? null : $sanitized; + } + + private function authorizeSave(): void + { + if ($this->page instanceof Page) { + $this->authorize('update', $this->page); + + return; + } + + $this->authorize('create', Page::class); + } + + private function forgetNavigation(Store $store, NavigationService $navigation): void + { + NavigationMenu::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->get() + ->each(fn (NavigationMenu $menu): mixed => $navigation->forget($menu)); + } +} diff --git a/app/Livewire/Admin/Pages/Index.php b/app/Livewire/Admin/Pages/Index.php new file mode 100644 index 00000000..e6bc67b4 --- /dev/null +++ b/app/Livewire/Admin/Pages/Index.php @@ -0,0 +1,76 @@ +authorize('viewAny', Page::class); + + $this->storeId = $store->getKey(); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function pages(): LengthAwarePaginator + { + return Page::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->when($this->search !== '', function (Builder $query): void { + $search = '%'.$this->search.'%'; + + $query->where(function (Builder $query) use ($search): void { + $query + ->where('title', 'like', $search) + ->orWhere('handle', 'like', $search); + }); + }) + ->latest('updated_at') + ->latest('id') + ->paginate(20); + } + + public function statusColor(PageStatus $status): string + { + return match ($status) { + PageStatus::Published => 'green', + PageStatus::Archived => 'red', + default => 'zinc', + }; + } + + public function render(): mixed + { + return view('livewire.admin.pages.index', [ + 'pages' => $this->pages(), + ])->layout('layouts.app', [ + 'title' => __('Pages'), + ]); + } +} diff --git a/app/Livewire/Admin/Products/Form.php b/app/Livewire/Admin/Products/Form.php new file mode 100644 index 00000000..88c29bb0 --- /dev/null +++ b/app/Livewire/Admin/Products/Form.php @@ -0,0 +1,861 @@ + + */ + public array $collectionIds = []; + + /** + * @var array + */ + public array $options = []; + + /** + * @var array}> + */ + public array $variants = []; + + /** + * @var array + */ + public array $media = []; + + /** + * @var array + */ + public array $newMedia = []; + + public function mount(?Product $product = null): void + { + if ($product?->exists) { + $store = app('current_store'); + + abort_unless($store instanceof Store && (int) $product->store_id === $store->getKey(), 404); + + $this->authorize('update', $product); + + $this->product = $product->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections', 'media']); + $this->fillFromProduct($this->product); + + return; + } + + $this->authorize('create', Product::class); + + $this->variants = [[ + 'id' => null, + 'label' => 'Default', + 'sku' => '', + 'price' => '0.00', + 'compareAtPrice' => '', + 'quantity' => 0, + 'requiresShipping' => true, + 'options' => [], + ]]; + } + + public function updatedTitle(): void + { + if ($this->product === null && $this->handle === '') { + $this->handle = Str::slug($this->title); + } + } + + public function addOption(): void + { + $this->options[] = [ + 'name' => '', + 'values' => '', + ]; + } + + public function removeOption(int $index): void + { + unset($this->options[$index]); + $this->options = array_values($this->options); + } + + public function generateVariants(): void + { + $optionPayload = $this->optionPayload(); + + if ($optionPayload === []) { + $template = $this->variants[0] ?? []; + $this->variants = [[ + 'id' => $template['id'] ?? null, + 'label' => 'Default', + 'sku' => $template['sku'] ?? '', + 'price' => $template['price'] ?? '0.00', + 'compareAtPrice' => $template['compareAtPrice'] ?? '', + 'quantity' => (int) ($template['quantity'] ?? 0), + 'requiresShipping' => (bool) ($template['requiresShipping'] ?? true), + 'options' => [], + ]]; + + return; + } + + $existingVariants = collect($this->variants) + ->keyBy(fn (array $variant): string => $this->variantOptionKey($variant['options'] ?? [])); + $template = $this->variants[0] ?? []; + + $this->variants = collect($this->optionCombinations($optionPayload)) + ->map(function (array $options) use ($existingVariants, $template): array { + $existingVariant = $existingVariants->get($this->variantOptionKey($options)); + + return [ + 'id' => $existingVariant['id'] ?? null, + 'label' => $this->variantLabel($options), + 'sku' => $existingVariant['sku'] ?? '', + 'price' => $existingVariant['price'] ?? ($template['price'] ?? '0.00'), + 'compareAtPrice' => $existingVariant['compareAtPrice'] ?? ($template['compareAtPrice'] ?? ''), + 'quantity' => (int) ($existingVariant['quantity'] ?? 0), + 'requiresShipping' => (bool) ($existingVariant['requiresShipping'] ?? ($template['requiresShipping'] ?? true)), + 'options' => $options, + ]; + }) + ->values() + ->all(); + } + + public function save(): void + { + $store = app('current_store'); + abort_unless($store instanceof Store, 404); + + $this->authorizeSave(); + $this->ensureVariantMatrixMatchesOptions(); + + $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'descriptionHtml' => ['nullable', 'string', 'max:65535'], + 'status' => ['required', Rule::in(['draft', 'active', 'archived'])], + 'vendor' => ['nullable', 'string', 'max:255'], + 'productType' => ['nullable', 'string', 'max:255'], + 'tags' => ['nullable', 'string'], + 'handle' => [ + 'required', + 'string', + 'max:255', + Rule::unique('products', 'handle') + ->where('store_id', $store->getKey()) + ->ignore($this->product?->getKey()), + ], + 'variants' => ['required', 'array', 'min:1'], + 'variants.*.sku' => ['nullable', 'string', 'max:255'], + 'variants.*.price' => ['required', 'numeric', 'min:0'], + 'variants.*.compareAtPrice' => ['nullable', 'numeric', 'min:0'], + 'variants.*.quantity' => ['required', 'integer', 'min:0'], + 'variants.*.requiresShipping' => ['boolean'], + 'newMedia' => ['array', 'max:10'], + 'newMedia.*' => ['image', 'mimes:jpg,jpeg,png,gif,webp', 'max:5120'], + ]); + + if (! $this->validateActiveVariantPricing() || ! $this->validateVariantSkus($store)) { + return; + } + + $product = DB::transaction(function () use ($store): Product { + $productService = app(ProductService::class); + + if ($this->product === null) { + $product = $productService->create($store, [ + ...$this->productPayload(), + 'options' => $this->optionPayload(), + 'variants' => $this->variantPayload($store), + ]); + } else { + $payload = $this->productPayload(); + $requestedStatus = ProductStatus::from((string) $payload['status']); + unset($payload['status']); + + $product = $productService->update($this->product, $payload); + $this->syncProductOptions($product); + $this->syncExistingVariants($product, $store); + + if ($requestedStatus !== $product->refresh()->status) { + $productService->transitionStatus($product, $requestedStatus); + } + } + + $product->collections()->sync($this->collectionIdsForSync($store)); + + return $product->refresh(); + }); + + if ($this->newMedia !== []) { + $this->storeNewMedia($product); + } + + $this->product = $product->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections', 'media']); + $this->fillFromProduct($this->product); + + session()->flash('status', 'Product saved'); + $this->dispatch('toast', type: 'success', message: __('Product saved')); + } + + public function uploadMedia(): void + { + $product = $this->productForAction(); + + $this->validate([ + 'newMedia' => ['required', 'array', 'max:10'], + 'newMedia.*' => ['image', 'mimes:jpg,jpeg,png,gif,webp', 'max:5120'], + ]); + + $this->storeNewMedia($product); + + $this->product = $product->refresh()->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections', 'media']); + $this->fillFromProduct($this->product); + + session()->flash('status', 'Media uploaded'); + $this->dispatch('toast', type: 'success', message: __('Media uploaded')); + } + + public function updateMediaAlt(int $mediaId): void + { + $index = $this->mediaIndex($mediaId); + + $this->validate([ + "media.{$index}.altText" => ['nullable', 'string', 'max:255'], + ]); + + $media = $this->mediaRecord($mediaId); + $altText = trim((string) $this->media[$index]['altText']); + + $media->forceFill([ + 'alt_text' => $altText === '' ? null : $altText, + ])->save(); + + $this->refreshProductMedia(); + + session()->flash('status', 'Media updated'); + } + + public function moveMedia(int $mediaId, string $direction): void + { + $ids = collect($this->media)->pluck('id')->map(fn (int $id): int => $id)->all(); + $index = array_search($mediaId, $ids, true); + + if ($index === false) { + return; + } + + $swapIndex = match ($direction) { + 'up' => $index - 1, + 'down' => $index + 1, + default => $index, + }; + + if (! isset($ids[$swapIndex])) { + return; + } + + [$ids[$index], $ids[$swapIndex]] = [$ids[$swapIndex], $ids[$index]]; + + $this->reorderMedia($ids); + } + + /** + * @param array $order + */ + public function reorderMedia(array $order): void + { + $product = $this->productForAction(); + $validIds = ProductMedia::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->whereIn('id', $order) + ->pluck('id') + ->all(); + + $orderedIds = collect($order) + ->map(fn (mixed $mediaId): int => (int) $mediaId) + ->intersect($validIds) + ->values(); + + DB::transaction(function () use ($orderedIds): void { + foreach ($orderedIds as $position => $mediaId) { + ProductMedia::withoutGlobalScopes() + ->whereKey($mediaId) + ->update(['position' => $position]); + } + }); + + $this->refreshProductMedia(); + } + + public function removeMedia(int $mediaId): void + { + $media = $this->mediaRecord($mediaId); + + $media->delete(); + + $this->reorderMedia( + collect($this->media) + ->pluck('id') + ->reject(fn (int $id): bool => $id === $mediaId) + ->values() + ->all(), + ); + + session()->flash('status', 'Media removed'); + } + + public function deleteProduct(): void + { + abort_unless($this->product instanceof Product, 404); + + $this->authorize('archive', $this->product); + + app(ProductService::class)->transitionStatus($this->product, ProductStatus::Archived); + + session()->flash('status', 'Product saved'); + $this->redirectRoute('admin.products.index', navigate: true); + } + + public function render(): mixed + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return view('livewire.admin.products.form', [ + 'availableCollections' => Collection::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->orderBy('title') + ->get(), + 'isEditing' => $this->product !== null, + ])->layout('layouts.app', [ + 'title' => $this->product ? $this->product->title : __('Add product'), + ]); + } + + private function fillFromProduct(Product $product): void + { + $this->title = $product->title; + $this->descriptionHtml = (string) $product->description_html; + $this->status = $product->status->value; + $this->vendor = (string) $product->vendor; + $this->productType = (string) $product->product_type; + $this->tags = implode(', ', $product->tags ?? []); + $this->handle = $product->handle; + $this->publishedAt = $product->published_at?->format('Y-m-d\TH:i'); + $this->collectionIds = $product->collections->pluck('id')->map(fn (int $id): int => $id)->all(); + $this->media = $product->media + ->map(fn (ProductMedia $media): array => [ + 'id' => $media->getKey(), + 'url' => Storage::disk('public')->url($media->storage_key), + 'exists' => Storage::disk('public')->exists($media->storage_key), + 'altText' => (string) $media->alt_text, + 'position' => $media->position, + 'status' => $media->status->value, + ]) + ->values() + ->all(); + $this->options = $product->options + ->map(fn (ProductOption $option): array => [ + 'name' => $option->name, + 'values' => $option->values->pluck('value')->implode(', '), + ]) + ->all(); + $this->variants = $product->variants + ->map(fn (ProductVariant $variant): array => [ + 'id' => $variant->getKey(), + 'label' => $variant->optionValues->isEmpty() + ? 'Default' + : $variant->optionValues->map(fn (ProductOptionValue $value): string => $value->value)->implode(' / '), + 'sku' => (string) $variant->sku, + 'price' => number_format($variant->price_amount / 100, 2, '.', ''), + 'compareAtPrice' => $variant->compare_at_amount ? number_format($variant->compare_at_amount / 100, 2, '.', '') : '', + 'quantity' => $variant->inventoryItem?->quantity_on_hand ?? 0, + 'requiresShipping' => $variant->requires_shipping, + 'options' => $variant->optionValues + ->mapWithKeys(fn (ProductOptionValue $value): array => [$value->option?->name ?? 'Option' => $value->value]) + ->all(), + ]) + ->values() + ->all(); + } + + /** + * @return array + */ + private function productPayload(): array + { + return [ + 'title' => $this->title, + 'description_html' => $this->sanitizeHtml($this->descriptionHtml), + 'status' => $this->status, + 'vendor' => $this->vendor ?: null, + 'product_type' => $this->productType ?: null, + 'tags' => collect(explode(',', $this->tags)) + ->map(fn (string $tag): string => trim($tag)) + ->filter() + ->values() + ->all(), + 'handle' => Str::slug($this->handle), + ]; + } + + private function sanitizeHtml(?string $html): ?string + { + $sanitized = app(SanitizeHtml::class)($html); + + return $sanitized === '' ? null : $sanitized; + } + + /** + * @return array}> + */ + private function optionPayload(): array + { + return collect($this->options) + ->map(function (array $option, int $position): array { + return [ + 'name' => $option['name'], + 'position' => $position, + 'values' => collect(explode(',', $option['values'])) + ->map(fn (string $value): string => trim($value)) + ->filter() + ->values() + ->map(fn (string $value, int $valuePosition): array => [ + 'value' => $value, + 'position' => $valuePosition, + ]) + ->all(), + ]; + }) + ->filter(fn (array $option): bool => $option['name'] !== '' && $option['values'] !== []) + ->values() + ->all(); + } + + /** + * @return array> + */ + private function variantPayload(Store $store): array + { + return collect($this->variants) + ->map(fn (array $variant, int $position): array => [ + 'sku' => $variant['sku'] ?: null, + 'price_amount' => Money::fromDecimalString($variant['price']), + 'compare_at_amount' => $variant['compareAtPrice'] === '' ? null : Money::fromDecimalString($variant['compareAtPrice']), + 'currency' => $store->default_currency, + 'quantity_on_hand' => (int) $variant['quantity'], + 'requires_shipping' => (bool) $variant['requiresShipping'], + 'is_default' => $position === 0, + 'position' => $position, + 'options' => $variant['options'] ?? [], + ]) + ->all(); + } + + private function syncExistingVariants(Product $product, Store $store): void + { + $syncedVariantIds = []; + + foreach ($this->variantPayload($store) as $position => $variantData) { + $variantId = $this->variants[$position]['id'] ?? null; + $variant = $variantId + ? ProductVariant::withoutGlobalScopes()->where('product_id', $product->getKey())->findOrFail($variantId) + : $product->variants()->create(['position' => $position]); + + $variant->forceFill([ + 'sku' => $variantData['sku'], + 'price_amount' => $variantData['price_amount'], + 'compare_at_amount' => $variantData['compare_at_amount'], + 'currency' => $variantData['currency'], + 'requires_shipping' => $variantData['requires_shipping'], + 'is_default' => $position === 0, + 'position' => $position, + ])->save(); + + InventoryItem::withoutGlobalScopes()->updateOrCreate( + ['variant_id' => $variant->getKey()], + [ + 'store_id' => $store->getKey(), + 'quantity_on_hand' => $variantData['quantity_on_hand'], + 'quantity_reserved' => 0, + 'policy' => 'deny', + ], + ); + + $variant->optionValues()->sync($this->optionValueIdsForVariant($product, $variantData['options'] ?? [])); + $syncedVariantIds[] = $variant->getKey(); + } + + ProductVariant::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->when($syncedVariantIds !== [], fn ($query) => $query->whereNotIn('id', $syncedVariantIds)) + ->get() + ->each(function (ProductVariant $variant): void { + if ($this->variantHasOrderLines($variant)) { + $variant->forceFill(['status' => VariantStatus::Archived])->save(); + + return; + } + + $variant->delete(); + }); + } + + private function syncProductOptions(Product $product): void + { + $optionPayload = $this->optionPayload(); + $syncedOptionIds = []; + + foreach ($optionPayload as $optionData) { + $option = ProductOption::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->where('position', $optionData['position']) + ->first() + ?? $product->options()->create([ + 'name' => $optionData['name'], + 'position' => $optionData['position'], + ]); + + $option->forceFill([ + 'name' => $optionData['name'], + 'position' => $optionData['position'], + ])->save(); + + $syncedOptionIds[] = $option->getKey(); + $syncedValueIds = []; + + foreach ($optionData['values'] as $valueData) { + $value = ProductOptionValue::withoutGlobalScopes() + ->where('product_option_id', $option->getKey()) + ->where('position', $valueData['position']) + ->first() + ?? $option->values()->create([ + 'value' => $valueData['value'], + 'position' => $valueData['position'], + ]); + + $value->forceFill([ + 'value' => $valueData['value'], + 'position' => $valueData['position'], + ])->save(); + + $syncedValueIds[] = $value->getKey(); + } + + ProductOptionValue::withoutGlobalScopes() + ->where('product_option_id', $option->getKey()) + ->when($syncedValueIds !== [], fn ($query) => $query->whereNotIn('id', $syncedValueIds)) + ->delete(); + } + + ProductOption::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->when($syncedOptionIds !== [], fn ($query) => $query->whereNotIn('id', $syncedOptionIds)) + ->delete(); + } + + private function ensureVariantMatrixMatchesOptions(): void + { + $desiredKeys = collect($this->optionCombinations($this->optionPayload())) + ->map(fn (array $options): string => $this->variantOptionKey($options)) + ->sort() + ->values() + ->all(); + $currentKeys = collect($this->variants) + ->map(fn (array $variant): string => $this->variantOptionKey($variant['options'] ?? [])) + ->sort() + ->values() + ->all(); + + if ($desiredKeys !== $currentKeys) { + $this->generateVariants(); + } + } + + /** + * @param array}> $options + * @return array> + */ + private function optionCombinations(array $options): array + { + if ($options === []) { + return [[]]; + } + + return collect($options) + ->reduce(function (array $combinations, array $option): array { + $next = []; + + foreach ($combinations as $combination) { + foreach ($option['values'] as $value) { + $next[] = [ + ...$combination, + $option['name'] => $value['value'], + ]; + } + } + + return $next; + }, [[]]); + } + + /** + * @param array $options + */ + private function variantOptionKey(array $options): string + { + ksort($options); + + return collect($options) + ->map(fn (string $value, string $name): string => "{$name}:{$value}") + ->implode('|'); + } + + /** + * @param array $options + */ + private function variantLabel(array $options): string + { + return $options === [] ? 'Default' : implode(' / ', array_values($options)); + } + + /** + * @param array $options + * @return list + */ + private function optionValueIdsForVariant(Product $product, array $options): array + { + if ($options === []) { + return []; + } + + return collect($options) + ->map(function (string $value, string $optionName) use ($product): int { + return (int) ProductOptionValue::withoutGlobalScopes() + ->where('value', $value) + ->whereHas('option', function ($query) use ($optionName, $product): void { + $query + ->withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->where('name', $optionName); + }) + ->value('id'); + }) + ->filter() + ->values() + ->all(); + } + + private function variantHasOrderLines(ProductVariant $variant): bool + { + return Schema::hasTable('order_lines') + && DB::table('order_lines')->where('variant_id', $variant->getKey())->exists(); + } + + private function validateActiveVariantPricing(): bool + { + if ($this->status !== ProductStatus::Active->value) { + return true; + } + + $hasPricedVariant = collect($this->variants) + ->contains(fn (array $variant): bool => Money::fromDecimalString($variant['price']) > 0); + + if ($hasPricedVariant) { + return true; + } + + $this->addError('variants.0.price', __('At least one priced variant is required before activation.')); + + return false; + } + + private function validateVariantSkus(Store $store): bool + { + $skus = collect($this->variants) + ->pluck('sku') + ->map(fn (?string $sku): string => trim((string) $sku)) + ->filter() + ->values(); + + if ($skus->isEmpty()) { + return true; + } + + if ($skus->duplicates()->isNotEmpty()) { + $this->addError('variants.0.sku', __('Each variant SKU must be unique for this store.')); + + return false; + } + + $variantIds = collect($this->variants) + ->pluck('id') + ->filter() + ->map(fn (mixed $variantId): int => (int) $variantId) + ->values() + ->all(); + + $conflictingSku = ProductVariant::withoutGlobalScopes() + ->whereIn('sku', $skus->all()) + ->whereHas('product', function ($query) use ($store): void { + $query + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()); + }) + ->when($variantIds !== [], fn ($query) => $query->whereKeyNot($variantIds)) + ->value('sku'); + + if ($conflictingSku === null) { + return true; + } + + $this->addError('variants.0.sku', __('The SKU [:sku] is already used in this store.', ['sku' => $conflictingSku])); + + return false; + } + + private function storeNewMedia(Product $product): void + { + $this->authorize('update', $product); + + $maxPosition = ProductMedia::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->max('position'); + $position = $maxPosition === null ? 0 : ((int) $maxPosition) + 1; + + foreach ($this->newMedia as $file) { + $extension = strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: 'jpg'); + $storageKey = $file->storeAs( + "media/originals/{$product->getKey()}", + Str::uuid().'.'.$extension, + 'public', + ); + + $media = ProductMedia::withoutGlobalScopes()->create([ + 'product_id' => $product->getKey(), + 'type' => MediaType::Image, + 'storage_key' => $storageKey, + 'alt_text' => $product->title, + 'position' => $position++, + 'status' => MediaStatus::Processing, + ]); + + ProcessMediaUpload::dispatch($media->getKey(), (int) $product->store_id); + } + + $this->reset('newMedia'); + } + + private function productForAction(): Product + { + $store = app('current_store'); + + abort_unless($store instanceof Store && $this->product instanceof Product, 404); + + $product = Product::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->findOrFail($this->product->getKey()); + + $this->authorize('update', $product); + + return $product; + } + + private function mediaRecord(int $mediaId): ProductMedia + { + return ProductMedia::withoutGlobalScopes() + ->where('product_id', $this->productForAction()->getKey()) + ->findOrFail($mediaId); + } + + private function mediaIndex(int $mediaId): int + { + $index = collect($this->media)->search(fn (array $media): bool => $media['id'] === $mediaId); + + abort_if($index === false, 404); + + return (int) $index; + } + + private function refreshProductMedia(): void + { + $this->product = $this->productForAction()->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections', 'media']); + $this->fillFromProduct($this->product); + } + + private function authorizeSave(): void + { + if ($this->product instanceof Product) { + $this->authorize('update', $this->product); + + return; + } + + $this->authorize('create', Product::class); + } + + /** + * @return list + */ + private function collectionIdsForSync(Store $store): array + { + if ($this->collectionIds === []) { + return []; + } + + return Collection::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->whereIn('id', $this->collectionIds) + ->pluck('id') + ->map(fn (int $id): int => $id) + ->all(); + } +} diff --git a/app/Livewire/Admin/Products/Index.php b/app/Livewire/Admin/Products/Index.php new file mode 100644 index 00000000..f264a085 --- /dev/null +++ b/app/Livewire/Admin/Products/Index.php @@ -0,0 +1,153 @@ + + */ + public array $selectedIds = []; + + public bool $selectAll = false; + + public string $sortField = 'updated_at'; + + public string $sortDirection = 'desc'; + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function updatedTypeFilter(): void + { + $this->resetPage(); + } + + public function sortBy(string $field): void + { + if (! in_array($field, ['title', 'updated_at'], true)) { + return; + } + + if ($this->sortField === $field) { + $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; + + return; + } + + $this->sortField = $field; + $this->sortDirection = 'asc'; + } + + public function toggleSelectAll(): void + { + $this->selectAll = ! $this->selectAll; + + $this->selectedIds = $this->selectAll + ? $this->products()->pluck('id')->map(fn (int $id): int => $id)->all() + : []; + } + + public function bulkArchive(): void + { + $this->transitionSelected(ProductStatus::Archived); + } + + public function bulkSetActive(): void + { + $this->transitionSelected(ProductStatus::Active); + } + + public function bulkDelete(): void + { + $this->transitionSelected(ProductStatus::Archived); + } + + public function products(): LengthAwarePaginator + { + return Product::query() + ->with(['variants.inventoryItem']) + ->withCount('variants') + ->when($this->search !== '', function (Builder $query): void { + $search = '%'.$this->search.'%'; + + $query->where(function (Builder $query) use ($search): void { + $query + ->where('title', 'like', $search) + ->orWhere('vendor', 'like', $search) + ->orWhere('product_type', 'like', $search) + ->orWhereHas('variants', fn (Builder $query) => $query->where('sku', 'like', $search)); + }); + }) + ->when($this->statusFilter !== 'all', fn (Builder $query) => $query->where('status', $this->statusFilter)) + ->when($this->typeFilter !== 'all', fn (Builder $query) => $query->where('product_type', $this->typeFilter)) + ->orderBy($this->sortField, $this->sortDirection) + ->paginate(15); + } + + public function productTypes(): Collection + { + return Product::query() + ->whereNotNull('product_type') + ->distinct() + ->orderBy('product_type') + ->pluck('product_type'); + } + + public function render(): mixed + { + $this->authorize('viewAny', Product::class); + + return view('livewire.admin.products.index', [ + 'products' => $this->products(), + 'productTypes' => $this->productTypes(), + ])->layout('layouts.app', [ + 'title' => __('Products'), + ]); + } + + private function transitionSelected(ProductStatus $status): void + { + $service = app(ProductService::class); + + Product::query() + ->whereKey($this->selectedIds) + ->get() + ->each(function (Product $product) use ($service, $status): void { + $this->authorize($status === ProductStatus::Archived ? 'archive' : 'update', $product); + + $service->transitionStatus($product, $status); + }); + + $this->selectedIds = []; + $this->selectAll = false; + + $this->dispatch('toast', type: 'success', message: __('Products updated')); + } +} diff --git a/app/Livewire/Admin/Search/Settings.php b/app/Livewire/Admin/Search/Settings.php new file mode 100644 index 00000000..556f348a --- /dev/null +++ b/app/Livewire/Admin/Search/Settings.php @@ -0,0 +1,164 @@ + + */ + public array $synonymGroups = []; + + public string $stopWords = ''; + + public ?string $lastIndexedAt = null; + + public bool $isReindexing = false; + + public ?int $reindexProgress = null; + + public function mount(): void + { + $store = $this->store(); + + $this->authorize('update', $store); + + $this->storeId = $store->getKey(); + + $settings = $this->settings(); + $this->synonymGroups = collect($settings->synonyms_json ?? []) + ->map(fn (array $group): string => implode(', ', $group)) + ->values() + ->all(); + $this->stopWords = implode(', ', $settings->stop_words_json ?? []); + $this->lastIndexedAt = $settings->updated_at?->toDayDateTimeString(); + } + + 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 + { + $this->authorize('update', $this->scopedStore()); + + $this->validate([ + 'synonymGroups' => ['array', 'max:50'], + 'synonymGroups.*' => ['nullable', 'string', 'max:500'], + 'stopWords' => ['nullable', 'string', 'max:2000'], + ], [], [ + 'synonymGroups.*' => 'synonym group', + 'stopWords' => 'stop words', + ]); + + $settings = $this->settings(); + $settings->forceFill([ + 'synonyms_json' => $this->parseSynonyms(), + 'stop_words_json' => $this->parseWordList($this->stopWords), + ])->save(); + + $this->lastIndexedAt = $settings->updated_at?->toDayDateTimeString(); + + session()->flash('status', 'Search settings saved'); + $this->dispatch('toast', type: 'success', message: __('Search settings saved')); + } + + public function triggerReindex(SearchService $search): void + { + $store = $this->scopedStore(); + + $this->authorize('update', $store); + + $this->isReindexing = true; + $this->reindexProgress = 10; + + $count = $search->reindex($store); + + $settings = $this->settings(); + $settings->touch(); + + $this->lastIndexedAt = $settings->refresh()->updated_at?->toDayDateTimeString(); + $this->reindexProgress = 100; + $this->isReindexing = false; + + session()->flash('status', __('Search index rebuilt for :count products', ['count' => $count])); + $this->dispatch('toast', type: 'success', message: __('Search index rebuilt')); + } + + public function pollReindexStatus(): void + { + $this->reindexProgress = $this->isReindexing ? $this->reindexProgress : null; + } + + public function render(): mixed + { + return view('livewire.admin.search.settings')->layout('layouts.app', [ + 'title' => __('Search settings'), + ]); + } + + /** + * @return list> + */ + private function parseSynonyms(): array + { + return collect($this->synonymGroups) + ->map(fn (string $group): array => $this->parseWordList($group)) + ->filter(fn (array $group): bool => count($group) >= 2) + ->values() + ->all(); + } + + /** + * @return list + */ + private function parseWordList(string $words): array + { + return collect(explode(',', mb_strtolower($words))) + ->map(fn (string $word): string => trim($word)) + ->filter() + ->unique() + ->values() + ->all(); + } + + private function settings(): SearchSettings + { + return SearchSettings::withoutGlobalScopes()->firstOrCreate([ + 'store_id' => $this->storeId ?? $this->store()->getKey(), + ]); + } + + private function store(): Store + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } + + private function scopedStore(): Store + { + return Store::query()->findOrFail($this->storeId); + } +} diff --git a/app/Livewire/Admin/Settings/Checkout.php b/app/Livewire/Admin/Settings/Checkout.php new file mode 100644 index 00000000..ade2a063 --- /dev/null +++ b/app/Livewire/Admin/Settings/Checkout.php @@ -0,0 +1,136 @@ +store(); + + $this->authorize('update', $store); + + $this->storeId = $store->getKey(); + $this->fillFromSettings($this->storeSettings($store)->settings_json ?? []); + } + + public function save(): void + { + $store = $this->scopedStore(); + + $this->authorize('update', $store); + + $validated = $this->validate([ + 'guestCheckoutEnabled' => ['boolean'], + 'customerAccountsRequired' => ['boolean'], + 'phoneNumberRequired' => ['boolean'], + 'billingAddressEnabled' => ['boolean'], + 'orderNotesEnabled' => ['boolean'], + 'termsRequired' => ['boolean'], + 'termsUrl' => ['nullable', 'url', 'max:255'], + 'paymentHoldHours' => ['required', 'integer', 'min:1', 'max:168'], + 'abandonedCheckoutDays' => ['required', 'integer', 'min:1', 'max:90'], + 'bankTransferCancelDays' => ['required', 'integer', 'min:1', 'max:60'], + ]); + + $settings = $this->storeSettings($store); + $settings->forceFill([ + 'settings_json' => array_replace_recursive($settings->settings_json ?? [], [ + 'checkout' => [ + 'guest_checkout_enabled' => $validated['guestCheckoutEnabled'], + 'customer_accounts_required' => $validated['customerAccountsRequired'], + 'phone_number_required' => $validated['phoneNumberRequired'], + 'billing_address_enabled' => $validated['billingAddressEnabled'], + 'order_notes_enabled' => $validated['orderNotesEnabled'], + 'terms_required' => $validated['termsRequired'], + 'terms_url' => $validated['termsUrl'] ?? '', + 'payment_hold_hours' => $validated['paymentHoldHours'], + 'abandoned_checkout_days' => $validated['abandonedCheckoutDays'], + ], + 'bank_transfer_cancel_days' => $validated['bankTransferCancelDays'], + ]), + 'updated_at' => now(), + ])->save(); + + session()->flash('status', 'Checkout settings saved'); + $this->dispatch('toast', type: 'success', message: __('Checkout settings saved')); + } + + public function render(): mixed + { + return view('livewire.admin.settings.checkout') + ->layout('layouts.app', [ + 'title' => __('Checkout settings'), + ]); + } + + /** + * @param array $settings + */ + private function fillFromSettings(array $settings): void + { + $this->guestCheckoutEnabled = (bool) data_get($settings, 'checkout.guest_checkout_enabled', true); + $this->customerAccountsRequired = (bool) data_get($settings, 'checkout.customer_accounts_required', false); + $this->phoneNumberRequired = (bool) data_get($settings, 'checkout.phone_number_required', false); + $this->billingAddressEnabled = (bool) data_get($settings, 'checkout.billing_address_enabled', true); + $this->orderNotesEnabled = (bool) data_get($settings, 'checkout.order_notes_enabled', true); + $this->termsRequired = (bool) data_get($settings, 'checkout.terms_required', false); + $this->termsUrl = (string) data_get($settings, 'checkout.terms_url', ''); + $this->paymentHoldHours = (int) data_get($settings, 'checkout.payment_hold_hours', 24); + $this->abandonedCheckoutDays = (int) data_get($settings, 'checkout.abandoned_checkout_days', 14); + $this->bankTransferCancelDays = (int) data_get($settings, 'bank_transfer_cancel_days', 7); + } + + private function store(): Store + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } + + private function scopedStore(): Store + { + return Store::query()->whereKey($this->storeId)->firstOrFail(); + } + + private function storeSettings(Store $store): StoreSettings + { + return StoreSettings::query()->firstOrCreate( + ['store_id' => $store->getKey()], + ['settings_json' => []], + ); + } +} diff --git a/app/Livewire/Admin/Settings/Index.php b/app/Livewire/Admin/Settings/Index.php new file mode 100644 index 00000000..06610815 --- /dev/null +++ b/app/Livewire/Admin/Settings/Index.php @@ -0,0 +1,260 @@ +store(); + + $this->authorize('update', $store); + + $settings = $this->storeSettings($store)->settings_json ?? []; + + $this->storeId = $store->getKey(); + $this->storeName = $store->name; + $this->storeHandle = $store->handle; + $this->defaultCurrency = $store->default_currency; + $this->defaultLocale = $store->default_locale; + $this->timezone = $store->timezone; + $this->announcementEnabled = (bool) data_get($settings, 'announcement.enabled', false); + $this->announcementText = (string) data_get($settings, 'announcement.text', ''); + $this->guestCheckoutEnabled = (bool) data_get($settings, 'checkout.guest_checkout_enabled', true); + } + + public function save(): void + { + $store = $this->scopedStore(); + + $this->authorize('update', $store); + + $validated = $this->validate([ + 'storeName' => ['required', 'string', 'max:255'], + 'defaultCurrency' => ['required', Rule::in($this->currencyOptions())], + 'defaultLocale' => ['required', Rule::in(array_keys($this->localeOptions()))], + 'timezone' => ['required', Rule::in(timezone_identifiers_list())], + 'announcementEnabled' => ['boolean'], + 'announcementText' => ['nullable', 'string', 'max:255'], + 'guestCheckoutEnabled' => ['boolean'], + ]); + + $store->forceFill([ + 'name' => $validated['storeName'], + 'default_currency' => $validated['defaultCurrency'], + 'default_locale' => $validated['defaultLocale'], + 'timezone' => $validated['timezone'], + ])->save(); + + $settings = $this->storeSettings($store); + $settings->forceFill([ + 'settings_json' => array_replace_recursive($settings->settings_json ?? [], [ + 'announcement' => [ + 'enabled' => $this->announcementEnabled, + 'text' => $this->announcementText, + ], + 'checkout' => [ + 'guest_checkout_enabled' => $this->guestCheckoutEnabled, + ], + ]), + 'updated_at' => now(), + ])->save(); + + session()->flash('status', 'Settings saved'); + $this->dispatch('toast', type: 'success', message: __('Settings saved')); + } + + public function addDomain(): void + { + $store = $this->scopedStore(); + + $this->authorize('update', $store); + + $validated = $this->validate([ + 'newHostname' => [ + 'required', + 'string', + 'max:255', + 'regex:/^[a-z0-9.-]+$/i', + Rule::unique('store_domains', 'hostname'), + ], + 'newType' => ['required', Rule::in(array_column(StoreDomainType::cases(), 'value'))], + ], [], [ + 'newHostname' => 'hostname', + 'newType' => 'domain type', + ]); + + StoreDomain::query()->create([ + 'store_id' => $store->getKey(), + 'hostname' => mb_strtolower(trim($validated['newHostname'])), + 'type' => StoreDomainType::from($validated['newType']), + 'is_primary' => $this->domains()->isEmpty(), + 'tls_mode' => 'managed', + ]); + + $this->reset('newHostname'); + + session()->flash('status', 'Domain added'); + $this->dispatch('toast', type: 'success', message: __('Domain added')); + } + + public function setPrimary(int $domainId): void + { + $store = $this->scopedStore(); + + $this->authorize('update', $store); + + $domain = $this->domain($domainId); + + StoreDomain::query() + ->where('store_id', $store->getKey()) + ->update(['is_primary' => false]); + + $domain->forceFill(['is_primary' => true])->save(); + + session()->flash('status', 'Primary domain updated'); + } + + public function removeDomain(int $domainId): void + { + $store = $this->scopedStore(); + + $this->authorize('update', $store); + + $domain = $this->domain($domainId); + $wasPrimary = $domain->is_primary; + + $domain->delete(); + + if ($wasPrimary) { + StoreDomain::query() + ->where('store_id', $store->getKey()) + ->orderBy('id') + ->first() + ?->forceFill(['is_primary' => true]) + ->save(); + } + + session()->flash('status', 'Domain removed'); + } + + /** + * @return Collection + */ + public function domains(): Collection + { + return StoreDomain::query() + ->where('store_id', $this->storeId) + ->orderByDesc('is_primary') + ->orderBy('hostname') + ->get(); + } + + /** + * @return array + */ + public function localeOptions(): array + { + return [ + 'en' => 'English', + 'de' => 'German', + 'fr' => 'French', + ]; + } + + /** + * @return list + */ + public function currencyOptions(): array + { + return ['EUR', 'USD', 'GBP', 'CHF']; + } + + /** + * @return list + */ + public function timezoneOptions(): array + { + return collect(timezone_identifiers_list()) + ->filter(fn (string $timezone): bool => str_starts_with($timezone, 'Europe/') || str_starts_with($timezone, 'America/')) + ->values() + ->all(); + } + + public function render(): mixed + { + return view('livewire.admin.settings.index', [ + 'domains' => $this->domains(), + 'currencyOptions' => $this->currencyOptions(), + 'localeOptions' => $this->localeOptions(), + 'timezoneOptions' => $this->timezoneOptions(), + ])->layout('layouts.app', [ + 'title' => __('Settings'), + ]); + } + + private function store(): Store + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } + + private function scopedStore(): Store + { + return Store::query()->whereKey($this->storeId)->firstOrFail(); + } + + private function storeSettings(Store $store): StoreSettings + { + return StoreSettings::query()->firstOrCreate( + ['store_id' => $store->getKey()], + ['settings_json' => []], + ); + } + + private function domain(int $domainId): StoreDomain + { + return StoreDomain::query() + ->where('store_id', $this->storeId) + ->whereKey($domainId) + ->firstOrFail(); + } +} diff --git a/app/Livewire/Admin/Settings/Notifications.php b/app/Livewire/Admin/Settings/Notifications.php new file mode 100644 index 00000000..f2e29895 --- /dev/null +++ b/app/Livewire/Admin/Settings/Notifications.php @@ -0,0 +1,131 @@ +store(); + + $this->authorize('update', $store); + + $this->storeId = $store->getKey(); + $this->fillFromSettings($store, $this->storeSettings($store)->settings_json ?? []); + } + + public function save(): void + { + $store = $this->scopedStore(); + + $this->authorize('update', $store); + + $validated = $this->validate([ + 'senderName' => ['required', 'string', 'max:255'], + 'senderEmail' => ['required', 'email', 'max:255'], + 'replyToEmail' => ['nullable', 'email', 'max:255'], + 'orderConfirmationEnabled' => ['boolean'], + 'shippingConfirmationEnabled' => ['boolean'], + 'refundConfirmationEnabled' => ['boolean'], + 'adminOrderAlertsEnabled' => ['boolean'], + 'lowStockAlertsEnabled' => ['boolean'], + 'lowStockThreshold' => ['required', 'integer', 'min:0', 'max:999'], + ]); + + $settings = $this->storeSettings($store); + $settings->forceFill([ + 'settings_json' => array_replace_recursive($settings->settings_json ?? [], [ + 'notifications' => [ + 'sender_name' => $validated['senderName'], + 'sender_email' => $validated['senderEmail'], + 'reply_to_email' => $validated['replyToEmail'] ?? '', + 'order_confirmation_enabled' => $validated['orderConfirmationEnabled'], + 'shipping_confirmation_enabled' => $validated['shippingConfirmationEnabled'], + 'refund_confirmation_enabled' => $validated['refundConfirmationEnabled'], + 'admin_order_alerts_enabled' => $validated['adminOrderAlertsEnabled'], + 'low_stock_alerts_enabled' => $validated['lowStockAlertsEnabled'], + 'low_stock_threshold' => $validated['lowStockThreshold'], + ], + ]), + 'updated_at' => now(), + ])->save(); + + session()->flash('status', 'Notification settings saved'); + $this->dispatch('toast', type: 'success', message: __('Notification settings saved')); + } + + public function render(): mixed + { + return view('livewire.admin.settings.notifications') + ->layout('layouts.app', [ + 'title' => __('Notification settings'), + ]); + } + + /** + * @param array $settings + */ + private function fillFromSettings(Store $store, array $settings): void + { + $this->senderName = (string) data_get($settings, 'notifications.sender_name', $store->name); + $this->senderEmail = (string) data_get($settings, 'notifications.sender_email', 'no-reply@shop.test'); + $this->replyToEmail = (string) data_get($settings, 'notifications.reply_to_email', ''); + $this->orderConfirmationEnabled = (bool) data_get($settings, 'notifications.order_confirmation_enabled', true); + $this->shippingConfirmationEnabled = (bool) data_get($settings, 'notifications.shipping_confirmation_enabled', true); + $this->refundConfirmationEnabled = (bool) data_get($settings, 'notifications.refund_confirmation_enabled', true); + $this->adminOrderAlertsEnabled = (bool) data_get($settings, 'notifications.admin_order_alerts_enabled', true); + $this->lowStockAlertsEnabled = (bool) data_get($settings, 'notifications.low_stock_alerts_enabled', true); + $this->lowStockThreshold = (int) data_get($settings, 'notifications.low_stock_threshold', 5); + } + + private function store(): Store + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } + + private function scopedStore(): Store + { + return Store::query()->whereKey($this->storeId)->firstOrFail(); + } + + private function storeSettings(Store $store): StoreSettings + { + return StoreSettings::query()->firstOrCreate( + ['store_id' => $store->getKey()], + ['settings_json' => []], + ); + } +} diff --git a/app/Livewire/Admin/Settings/Shipping.php b/app/Livewire/Admin/Settings/Shipping.php new file mode 100644 index 00000000..1c602128 --- /dev/null +++ b/app/Livewire/Admin/Settings/Shipping.php @@ -0,0 +1,384 @@ +|null + */ + public ?array $testResult = null; + + public function mount(): void + { + $store = $this->store(); + + $this->authorize('update', $store); + + $this->storeId = $store->getKey(); + } + + public function editZone(int $zoneId): void + { + $zone = $this->zone($zoneId); + + $this->authorize('update', $this->scopedStore()); + + $this->editingZoneId = $zone->getKey(); + $this->zoneName = $zone->name; + $this->zoneCountries = implode(', ', $zone->countries_json ?? []); + } + + public function saveZone(): void + { + $store = $this->scopedStore(); + + $this->authorize('update', $store); + + $validated = $this->validate([ + 'zoneName' => ['required', 'string', 'max:255'], + 'zoneCountries' => ['required', 'string', 'max:255'], + ], [], [ + 'zoneName' => 'zone name', + 'zoneCountries' => 'countries', + ]); + + $countries = $this->countryCodes($validated['zoneCountries']); + + if ($countries === []) { + $this->addError('zoneCountries', __('Enter at least one ISO country code.')); + + return; + } + + $payload = [ + 'store_id' => $store->getKey(), + 'name' => $validated['zoneName'], + 'countries_json' => $countries, + 'regions_json' => [], + ]; + + $this->editingZoneId + ? $this->zone($this->editingZoneId)->update($payload) + : ShippingZone::withoutGlobalScopes()->create($payload); + + $this->resetZoneForm(); + + session()->flash('status', 'Shipping zone saved'); + $this->dispatch('toast', type: 'success', message: __('Shipping zone saved')); + } + + public function deleteZone(int $zoneId): void + { + $this->authorize('update', $this->scopedStore()); + + $this->zone($zoneId)->delete(); + $this->resetZoneForm(); + $this->resetRateForm(); + + session()->flash('status', 'Shipping zone deleted'); + } + + public function addRate(int $zoneId): void + { + $zone = $this->zone($zoneId); + + $this->authorize('update', $this->scopedStore()); + + $this->resetRateForm(); + $this->rateZoneId = $zone->getKey(); + } + + public function editRate(int $rateId): void + { + $rate = $this->rate($rateId); + + $this->authorize('update', $this->scopedStore()); + + $this->editingRateId = $rate->getKey(); + $this->rateZoneId = $rate->zone_id; + $this->rateName = $rate->name; + $this->rateType = $rate->type->value; + $this->rateActive = $rate->is_active; + $this->fillRateConfig($rate); + } + + public function saveRate(): void + { + $store = $this->scopedStore(); + + $this->authorize('update', $store); + + $validated = $this->validate([ + 'rateZoneId' => ['required', 'integer'], + 'rateName' => ['required', 'string', 'max:255'], + 'rateType' => ['required', Rule::in(array_column(ShippingRateType::cases(), 'value'))], + 'rateAmount' => ['required', 'numeric', 'min:0'], + 'minimumWeight' => ['nullable', 'integer', 'min:0'], + 'maximumWeight' => ['nullable', 'integer', 'min:1'], + 'minimumOrderAmount' => ['nullable', 'numeric', 'min:0'], + 'maximumOrderAmount' => ['nullable', 'numeric', 'min:0'], + 'rateActive' => ['boolean'], + ], [], [ + 'rateZoneId' => 'shipping zone', + 'rateName' => 'rate name', + 'rateType' => 'rate type', + 'rateAmount' => 'rate amount', + ]); + + $zone = $this->zone((int) $validated['rateZoneId']); + $payload = [ + 'zone_id' => $zone->getKey(), + 'name' => $validated['rateName'], + 'type' => ShippingRateType::from($validated['rateType']), + 'config_json' => $this->rateConfig(), + 'is_active' => $this->rateActive, + ]; + + $this->editingRateId + ? $this->rate($this->editingRateId)->update($payload) + : ShippingRate::withoutGlobalScopes()->create($payload); + + $this->resetRateForm(); + + session()->flash('status', 'Shipping rate saved'); + $this->dispatch('toast', type: 'success', message: __('Shipping rate saved')); + } + + public function deleteRate(int $rateId): void + { + $this->authorize('update', $this->scopedStore()); + + $this->rate($rateId)->delete(); + $this->resetRateForm(); + + session()->flash('status', 'Shipping rate deleted'); + } + + public function toggleRateActive(int $rateId): void + { + $this->authorize('update', $this->scopedStore()); + + $rate = $this->rate($rateId); + $rate->forceFill(['is_active' => ! $rate->is_active])->save(); + } + + public function testShippingAddress(ShippingCalculator $shipping): void + { + $store = $this->scopedStore(); + + $this->authorize('update', $store); + + $address = [ + 'country' => strtoupper($this->testCountry), + 'province_code' => strtoupper($this->testRegion), + ]; + $zone = $shipping->matchingZone($store, $address); + + $this->testResult = [ + 'zone' => $zone?->name, + 'rates' => $zone instanceof ShippingZone + ? ShippingRate::withoutGlobalScopes() + ->where('zone_id', $zone->getKey()) + ->where('is_active', true) + ->orderBy('id') + ->get() + ->map(fn (ShippingRate $rate): string => $rate->name.' - '.$this->rateSummary($rate)) + ->all() + : [], + ]; + } + + /** + * @return Collection + */ + public function zones(): Collection + { + return ShippingZone::withoutGlobalScopes() + ->with(['rates' => fn ($query) => $query->withoutGlobalScopes()->orderBy('id')]) + ->where('store_id', $this->storeId) + ->orderBy('id') + ->get(); + } + + public function rateSummary(ShippingRate $rate): string + { + if ($rate->type === ShippingRateType::Carrier) { + return 'Carrier fallback '.Money::format((int) data_get($rate->config_json, 'amount', 0), $this->scopedStore()->default_currency); + } + + $amount = match ($rate->type) { + ShippingRateType::Flat => (int) data_get($rate->config_json, 'amount', 0), + default => (int) data_get($rate->config_json, 'ranges.0.amount', 0), + }; + + return Money::format($amount, $this->scopedStore()->default_currency); + } + + public function render(): mixed + { + return view('livewire.admin.settings.shipping', [ + 'zones' => $this->zones(), + ])->layout('layouts.app', [ + 'title' => __('Shipping'), + ]); + } + + private function store(): Store + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } + + private function scopedStore(): Store + { + return Store::query()->whereKey($this->storeId)->firstOrFail(); + } + + private function zone(int $zoneId): ShippingZone + { + return ShippingZone::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->whereKey($zoneId) + ->firstOrFail(); + } + + private function rate(int $rateId): ShippingRate + { + return ShippingRate::withoutGlobalScopes() + ->whereKey($rateId) + ->whereHas('zone', function ($query): void { + $query->withoutGlobalScopes()->where('store_id', $this->storeId); + }) + ->firstOrFail(); + } + + /** + * @return list + */ + private function countryCodes(string $countries): array + { + return collect(explode(',', $countries)) + ->map(fn (string $country): string => strtoupper(trim($country))) + ->filter(fn (string $country): bool => preg_match('/^[A-Z]{2}$/', $country) === 1) + ->unique() + ->values() + ->all(); + } + + /** + * @return array + */ + private function rateConfig(): array + { + $amount = Money::fromDecimalString($this->rateAmount); + + return match ($this->rateType) { + ShippingRateType::Weight->value => [ + 'ranges' => [[ + 'min_g' => (int) ($this->minimumWeight ?: 0), + 'max_g' => $this->maximumWeight !== '' ? (int) $this->maximumWeight : null, + 'amount' => $amount, + ]], + ], + ShippingRateType::Price->value => [ + 'ranges' => [[ + 'min_amount' => Money::fromDecimalString($this->minimumOrderAmount), + 'max_amount' => $this->maximumOrderAmount !== '' ? Money::fromDecimalString($this->maximumOrderAmount) : null, + 'amount' => $amount, + ]], + ], + ShippingRateType::Carrier->value => ['amount' => $amount], + default => ['amount' => $amount], + }; + } + + private function fillRateConfig(ShippingRate $rate): void + { + $amount = match ($rate->type) { + ShippingRateType::Flat, ShippingRateType::Carrier => (int) data_get($rate->config_json, 'amount', 0), + default => (int) data_get($rate->config_json, 'ranges.0.amount', 0), + }; + + $this->rateAmount = number_format($amount / 100, 2, '.', ''); + $this->minimumWeight = (string) data_get($rate->config_json, 'ranges.0.min_g', 0); + $this->maximumWeight = data_get($rate->config_json, 'ranges.0.max_g') !== null + ? (string) data_get($rate->config_json, 'ranges.0.max_g') + : ''; + $this->minimumOrderAmount = number_format(((int) data_get($rate->config_json, 'ranges.0.min_amount', 0)) / 100, 2, '.', ''); + $this->maximumOrderAmount = data_get($rate->config_json, 'ranges.0.max_amount') !== null + ? number_format(((int) data_get($rate->config_json, 'ranges.0.max_amount')) / 100, 2, '.', '') + : ''; + } + + private function resetZoneForm(): void + { + $this->editingZoneId = null; + $this->zoneName = ''; + $this->zoneCountries = ''; + } + + private function resetRateForm(): void + { + $this->editingRateId = null; + $this->rateZoneId = null; + $this->rateName = ''; + $this->rateType = 'flat'; + $this->rateAmount = '0.00'; + $this->minimumWeight = '0'; + $this->maximumWeight = ''; + $this->minimumOrderAmount = '0.00'; + $this->maximumOrderAmount = ''; + $this->rateActive = true; + } +} diff --git a/app/Livewire/Admin/Settings/Taxes.php b/app/Livewire/Admin/Settings/Taxes.php new file mode 100644 index 00000000..c3709dc5 --- /dev/null +++ b/app/Livewire/Admin/Settings/Taxes.php @@ -0,0 +1,169 @@ + + */ + public array $manualRates = []; + + public function mount(): void + { + $store = $this->store(); + + $this->authorize('update', $store); + + $this->storeId = $store->getKey(); + $this->fillFromSettings($this->taxSettings($store)); + } + + public function addManualRate(): void + { + $this->manualRates[] = [ + 'country' => '', + 'name' => 'Tax', + 'rate_percentage' => '0.00', + ]; + } + + public function removeManualRate(int $index): void + { + unset($this->manualRates[$index]); + + $this->manualRates = array_values($this->manualRates); + } + + public function save(): void + { + $store = $this->scopedStore(); + + $this->authorize('update', $store); + + $validated = $this->validate([ + 'mode' => ['required', Rule::in(array_column(TaxMode::cases(), 'value'))], + 'pricesIncludeTax' => ['boolean'], + 'provider' => ['required', Rule::in(['none', 'stripe_tax'])], + 'providerApiKey' => ['nullable', 'string', 'max:255'], + 'manualRates' => ['array'], + 'manualRates.*.country' => ['required', 'string', 'size:2'], + 'manualRates.*.name' => ['required', 'string', 'max:50'], + 'manualRates.*.rate_percentage' => ['required', 'numeric', 'min:0', 'max:100'], + ]); + + TaxSettings::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->getKey()], + [ + 'mode' => TaxMode::from($validated['mode']), + 'provider' => $this->mode === TaxMode::Provider->value ? $this->provider : 'none', + 'prices_include_tax' => $this->pricesIncludeTax, + 'config_json' => [ + 'name' => 'Tax', + 'default_rate_bps' => $this->firstRateBasisPoints(), + 'shipping_taxable' => true, + 'provider_api_key' => $this->mode === TaxMode::Provider->value ? $this->providerApiKey : null, + 'rates' => collect($this->manualRates) + ->map(fn (array $rate): array => [ + 'country' => strtoupper($rate['country']), + 'rate_bps' => (int) round(((float) $rate['rate_percentage']) * 100), + 'name' => $rate['name'], + ]) + ->values() + ->all(), + ], + ], + ); + + session()->flash('status', 'Tax settings saved'); + $this->dispatch('toast', type: 'success', message: __('Tax settings saved')); + } + + public function render(): mixed + { + return view('livewire.admin.settings.taxes') + ->layout('layouts.app', [ + 'title' => __('Taxes'), + ]); + } + + private function store(): Store + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } + + private function scopedStore(): Store + { + return Store::query()->whereKey($this->storeId)->firstOrFail(); + } + + private function taxSettings(Store $store): TaxSettings + { + return TaxSettings::withoutGlobalScopes()->firstOrCreate( + ['store_id' => $store->getKey()], + [ + 'mode' => TaxMode::Manual, + 'provider' => 'none', + 'prices_include_tax' => false, + 'config_json' => [ + 'name' => 'Tax', + 'default_rate_bps' => 0, + 'shipping_taxable' => true, + 'rates' => [], + ], + ], + ); + } + + private function fillFromSettings(TaxSettings $settings): void + { + $this->mode = $settings->mode->value; + $this->pricesIncludeTax = $settings->prices_include_tax; + $this->provider = $settings->provider; + $this->providerApiKey = (string) data_get($settings->config_json, 'provider_api_key', ''); + $this->manualRates = collect(data_get($settings->config_json, 'rates', [])) + ->map(fn (array $rate): array => [ + 'country' => (string) data_get($rate, 'country', ''), + 'name' => (string) data_get($rate, 'name', 'Tax'), + 'rate_percentage' => number_format(((int) data_get($rate, 'rate_bps', 0)) / 100, 2, '.', ''), + ]) + ->values() + ->all(); + + if ($this->manualRates === []) { + $this->addManualRate(); + } + } + + private function firstRateBasisPoints(): int + { + $first = $this->manualRates[0]['rate_percentage'] ?? '0'; + + return (int) round(((float) $first) * 100); + } +} diff --git a/app/Livewire/Admin/Themes/Editor.php b/app/Livewire/Admin/Themes/Editor.php new file mode 100644 index 00000000..1fa28065 --- /dev/null +++ b/app/Livewire/Admin/Themes/Editor.php @@ -0,0 +1,231 @@ + + */ + public array $settings = []; + + public function mount(Theme $theme, ThemeSettingsService $themeSettings): void + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + abort_unless((int) $theme->store_id === $store->getKey(), 404); + + $this->authorize('update', $theme); + + $this->theme = $theme; + $this->settings = array_replace_recursive( + $themeSettings->defaultsForStore($store), + $theme->settings?->settings_json ?? [], + ); + } + + public function selectSection(string $sectionKey): void + { + abort_unless(array_key_exists($sectionKey, $this->sections()), 404); + + $this->selectedSection = $sectionKey; + $this->selectedFileId = null; + $this->fileContents = ''; + } + + public function selectFile(int $fileId): void + { + $file = $this->themeFile($fileId); + + $this->authorize('update', $this->theme); + + $this->selectedFileId = $file->getKey(); + $this->fileContents = Storage::disk('local')->exists($file->storage_key) + ? Storage::disk('local')->get($file->storage_key) + : ''; + } + + public function saveFile(): void + { + abort_unless($this->selectedFileId !== null, 404); + + $this->authorize('update', $this->theme); + + $this->validate([ + 'fileContents' => ['string', 'max:262144'], + ]); + + $file = $this->themeFile($this->selectedFileId); + Storage::disk('local')->put($file->storage_key, $this->fileContents); + + $file->forceFill([ + 'sha256' => hash('sha256', $this->fileContents), + 'byte_size' => strlen($this->fileContents), + ])->save(); + + session()->flash('status', 'Theme file saved'); + $this->dispatch('toast', type: 'success', message: __('Theme file saved')); + } + + public function save(ThemeSettingsService $settings): void + { + $this->authorize('update', $this->theme); + + ThemeSettings::withoutGlobalScopes()->updateOrCreate( + ['theme_id' => $this->theme->getKey()], + [ + 'settings_json' => $this->settings, + 'updated_at' => now(), + ], + ); + + $settings->forget($this->store()); + + session()->flash('status', 'Theme saved'); + $this->dispatch('toast', type: 'success', message: __('Theme saved')); + } + + public function publish(ThemeSettingsService $settings): void + { + $this->authorize('publish', $this->theme); + + DB::transaction(function (): void { + Theme::withoutGlobalScopes() + ->where('store_id', $this->theme->store_id) + ->update([ + 'status' => ThemeStatus::Draft, + 'published_at' => null, + ]); + + $this->theme->forceFill([ + 'status' => ThemeStatus::Published, + 'published_at' => now(), + ])->save(); + }); + + $this->save($settings); + + session()->flash('status', 'Theme saved and published'); + } + + public function refreshPreview(): void + { + $this->dispatch('theme-preview-refresh'); + } + + /** + * @return array}>}> + */ + public function sections(): array + { + return [ + 'announcement' => [ + 'label' => 'Announcement', + 'fields' => [ + ['key' => 'announcement.enabled', 'label' => 'Enabled', 'type' => 'checkbox'], + ['key' => 'announcement.text', 'label' => 'Text', 'type' => 'text'], + ['key' => 'announcement.url', 'label' => 'URL', 'type' => 'text'], + ], + ], + 'header' => [ + 'label' => 'Header', + 'fields' => [ + ['key' => 'header.sticky', 'label' => 'Sticky header', 'type' => 'checkbox'], + ['key' => 'header.main_menu', 'label' => 'Main menu handle', 'type' => 'text'], + ], + ], + 'home' => [ + 'label' => 'Home hero', + 'fields' => [ + ['key' => 'home.hero.eyebrow', 'label' => 'Eyebrow', 'type' => 'text'], + ['key' => 'home.hero.heading', 'label' => 'Heading', 'type' => 'text'], + ['key' => 'home.hero.subheading', 'label' => 'Subheading', 'type' => 'textarea'], + ['key' => 'home.hero.primary_label', 'label' => 'Primary button label', 'type' => 'text'], + ['key' => 'home.hero.primary_url', 'label' => 'Primary button URL', 'type' => 'text'], + ['key' => 'home.featured_product_limit', 'label' => 'Featured products', 'type' => 'number'], + ['key' => 'home.featured_collection_limit', 'label' => 'Featured collections', 'type' => 'number'], + ], + ], + 'footer' => [ + 'label' => 'Footer', + 'fields' => [ + ['key' => 'footer.menu', 'label' => 'Menu handle', 'type' => 'text'], + ['key' => 'footer.tagline', 'label' => 'Tagline', 'type' => 'textarea'], + ], + ], + ]; + } + + public function previewUrl(): string + { + return route('home'); + } + + public function render(): mixed + { + return view('livewire.admin.themes.editor', [ + 'sections' => $this->sections(), + 'activeSection' => $this->sections()[$this->selectedSection], + 'themeFiles' => $this->themeFiles(), + 'selectedFile' => $this->selectedFile(), + 'previewUrl' => $this->previewUrl(), + ])->layout('layouts.app', [ + 'title' => __('Theme editor'), + ]); + } + + private function store(): Store + { + return Store::query()->whereKey($this->theme->store_id)->firstOrFail(); + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + private function themeFiles(): \Illuminate\Database\Eloquent\Collection + { + return ThemeFile::withoutGlobalScopes() + ->where('theme_id', $this->theme->getKey()) + ->orderBy('path') + ->get(); + } + + private function selectedFile(): ?ThemeFile + { + if ($this->selectedFileId === null) { + return null; + } + + return $this->themeFile($this->selectedFileId); + } + + private function themeFile(int $fileId): ThemeFile + { + return ThemeFile::withoutGlobalScopes() + ->where('theme_id', $this->theme->getKey()) + ->whereKey($fileId) + ->firstOrFail(); + } +} diff --git a/app/Livewire/Admin/Themes/Index.php b/app/Livewire/Admin/Themes/Index.php new file mode 100644 index 00000000..ee699fbd --- /dev/null +++ b/app/Livewire/Admin/Themes/Index.php @@ -0,0 +1,164 @@ +authorize('viewAny', Theme::class); + + $this->storeId = $store->getKey(); + } + + public function publishTheme(int $themeId, ThemeSettingsService $settings): void + { + $theme = $this->theme($themeId); + + $this->authorize('publish', $theme); + + DB::transaction(function () use ($theme): void { + Theme::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->update([ + 'status' => ThemeStatus::Draft, + 'published_at' => null, + ]); + + $theme->forceFill([ + 'status' => ThemeStatus::Published, + 'published_at' => now(), + ])->save(); + }); + + $settings->forget($this->store()); + + session()->flash('status', 'Theme published'); + $this->dispatch('toast', type: 'success', message: __('Theme published')); + } + + public function duplicateTheme(int $themeId): void + { + $theme = $this->theme($themeId); + + $this->authorize('create', Theme::class); + + DB::transaction(function () use ($theme): void { + $copy = Theme::withoutGlobalScopes()->create([ + 'store_id' => $this->storeId, + 'name' => $theme->name.' Copy', + 'version' => $theme->version, + 'status' => ThemeStatus::Draft, + 'published_at' => null, + ]); + + $theme->files() + ->withoutGlobalScopes() + ->get() + ->each(function (ThemeFile $file) use ($copy): void { + $contents = Storage::disk('local')->exists($file->storage_key) + ? Storage::disk('local')->get($file->storage_key) + : ''; + $storageKey = "themes/{$copy->getKey()}/{$file->path}"; + + Storage::disk('local')->put($storageKey, $contents); + + ThemeFile::withoutGlobalScopes()->create([ + 'theme_id' => $copy->getKey(), + 'path' => $file->path, + 'storage_key' => $storageKey, + 'sha256' => hash('sha256', $contents), + 'byte_size' => strlen($contents), + ]); + }); + + ThemeSettings::withoutGlobalScopes()->create([ + 'theme_id' => $copy->getKey(), + 'settings_json' => $theme->settings?->settings_json ?? [], + 'updated_at' => now(), + ]); + }); + + session()->flash('status', 'Theme duplicated'); + } + + public function deleteTheme(int $themeId): void + { + $theme = $this->theme($themeId); + + $this->authorize('delete', $theme); + + if ($theme->isPublished()) { + $this->addError('theme', __('Published themes cannot be deleted.')); + + return; + } + + $theme->delete(); + + session()->flash('status', 'Theme deleted'); + } + + /** + * @return Collection + */ + public function themes(): Collection + { + return Theme::withoutGlobalScopes() + ->withCount('files') + ->where('store_id', $this->storeId) + ->orderByRaw("case when status = 'published' then 0 else 1 end") + ->orderBy('name') + ->get(); + } + + public function statusColor(Theme $theme): string + { + return $theme->status === ThemeStatus::Published ? 'green' : 'zinc'; + } + + public function render(): mixed + { + return view('livewire.admin.themes.index', [ + 'themes' => $this->themes(), + ])->layout('layouts.app', [ + 'title' => __('Themes'), + ]); + } + + private function store(): Store + { + return Store::query()->whereKey($this->storeId)->firstOrFail(); + } + + private function theme(int $themeId): Theme + { + return Theme::withoutGlobalScopes() + ->with(['files', 'settings']) + ->where('store_id', $this->storeId) + ->whereKey($themeId) + ->firstOrFail(); + } +} diff --git a/app/Livewire/Storefront/Account/Addresses/Index.php b/app/Livewire/Storefront/Account/Addresses/Index.php new file mode 100644 index 00000000..436e5c3c --- /dev/null +++ b/app/Livewire/Storefront/Account/Addresses/Index.php @@ -0,0 +1,212 @@ + '', + 'last_name' => '', + 'address1' => '', + 'address2' => '', + 'city' => '', + 'province_code' => '', + 'country' => 'DE', + 'postal_code' => '', + ]; + + public function mount(): void + { + $store = app('current_store'); + $customer = Auth::guard('customer')->user(); + + abort_unless($store instanceof Store && $customer instanceof Customer, 404); + + $this->storeId = $store->getKey(); + $this->customerId = $customer->getKey(); + } + + public function openAddressForm(?int $addressId = null): void + { + $this->resetValidation(); + $this->statusMessage = null; + $this->editingAddressId = $addressId; + + if ($addressId !== null) { + $address = $this->addressRecord($addressId); + + $this->addressLabel = (string) $address->label; + $this->address = array_merge($this->emptyAddress(), $address->address_json ?? []); + } else { + $this->resetAddressForm(); + } + + $this->showForm = true; + } + + public function saveAddress(): void + { + $this->validate([ + 'addressLabel' => ['nullable', 'string', 'max:255'], + 'address.first_name' => ['required', 'string', 'max:255'], + 'address.last_name' => ['required', 'string', 'max:255'], + 'address.address1' => ['required', 'string', 'max:255'], + 'address.address2' => ['nullable', 'string', 'max:255'], + 'address.city' => ['required', 'string', 'max:255'], + 'address.province_code' => ['nullable', 'string', 'max:255'], + 'address.country' => ['required', 'string', 'size:2'], + 'address.postal_code' => ['required', 'string', 'max:32'], + ]); + + $address = $this->editingAddressId !== null + ? $this->addressRecord($this->editingAddressId) + : new CustomerAddress(['customer_id' => $this->customerId]); + + $address->fill([ + 'label' => $this->addressLabel !== '' ? $this->addressLabel : null, + 'address_json' => $this->address, + 'is_default' => $address->exists ? $address->is_default : ! $this->customer()->addresses()->exists(), + ]); + $address->save(); + + $this->resetAddressForm(); + $this->showForm = false; + $this->statusMessage = __('Address saved'); + } + + public function deleteAddress(int $addressId): void + { + $address = $this->addressRecord($addressId); + $wasDefault = $address->is_default; + + $address->delete(); + + if ($wasDefault) { + $this->setFirstAddressAsDefault(); + } + + $this->statusMessage = __('Address deleted'); + } + + public function setDefaultAddress(int $addressId): void + { + $address = $this->addressRecord($addressId); + + DB::transaction(function () use ($address): void { + CustomerAddress::query() + ->where('customer_id', $this->customerId) + ->update(['is_default' => false]); + + $address->forceFill(['is_default' => true])->save(); + }); + + $this->statusMessage = __('Default address updated'); + } + + public function cancelAddressForm(): void + { + $this->resetValidation(); + $this->resetAddressForm(); + $this->showForm = false; + } + + public function render(): mixed + { + return view('livewire.storefront.account.addresses.index', [ + 'addresses' => $this->addresses(), + 'customer' => $this->customer(), + ])->layout('layouts.storefront', [ + 'title' => 'Addresses', + ]); + } + + /** + * @return Collection + */ + private function addresses(): Collection + { + return CustomerAddress::query() + ->where('customer_id', $this->customerId) + ->orderByDesc('is_default') + ->orderBy('id') + ->get(); + } + + private function customer(): Customer + { + return Customer::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->whereKey($this->customerId) + ->firstOrFail(); + } + + private function addressRecord(int $addressId): CustomerAddress + { + return CustomerAddress::query() + ->where('customer_id', $this->customerId) + ->whereKey($addressId) + ->firstOrFail(); + } + + private function setFirstAddressAsDefault(): void + { + $nextAddress = CustomerAddress::query() + ->where('customer_id', $this->customerId) + ->oldest('id') + ->first(); + + if ($nextAddress instanceof CustomerAddress) { + $nextAddress->forceFill(['is_default' => true])->save(); + } + } + + private function resetAddressForm(): void + { + $this->editingAddressId = null; + $this->addressLabel = ''; + $this->address = $this->emptyAddress(); + } + + /** + * @return array{first_name: string, last_name: string, address1: string, address2: string, city: string, province_code: string, country: string, postal_code: string} + */ + private function emptyAddress(): array + { + return [ + 'first_name' => '', + 'last_name' => '', + 'address1' => '', + 'address2' => '', + 'city' => '', + 'province_code' => '', + 'country' => 'DE', + 'postal_code' => '', + ]; + } +} diff --git a/app/Livewire/Storefront/Account/Auth/ForgotPassword.php b/app/Livewire/Storefront/Account/Auth/ForgotPassword.php new file mode 100644 index 00000000..50c0e4c5 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/ForgotPassword.php @@ -0,0 +1,45 @@ +storeId = $store->getKey(); + } + + public function send(CustomerPasswordResetService $passwords): void + { + $validated = $this->validate([ + 'email' => ['required', 'email', 'max:255'], + ]); + + $passwords->sendResetLink( + Store::query()->findOrFail($this->storeId), + $validated['email'], + ); + + session()->flash('status', __('If an account matches that email, a reset link has been sent.')); + } + + public function render(): mixed + { + return view('livewire.storefront.account.auth.forgot-password') + ->layout('layouts.auth'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Login.php b/app/Livewire/Storefront/Account/Auth/Login.php new file mode 100644 index 00000000..24f1f459 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Login.php @@ -0,0 +1,83 @@ +storeId = $store->getKey(); + } + + public function login(): void + { + $this->validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + 'remember' => ['boolean'], + ]); + + $key = 'customer-login:'.request()->ip(); + + if (RateLimiter::tooManyAttempts($key, 5)) { + throw ValidationException::withMessages([ + 'email' => __('Too many attempts. Try again in :seconds seconds.', [ + 'seconds' => RateLimiter::availableIn($key), + ]), + ]); + } + + app()->instance('current_store', Store::query()->findOrFail($this->storeId)); + + if (! Auth::guard('customer')->attempt([ + 'email' => $this->email, + 'password' => $this->password, + ], $this->remember)) { + RateLimiter::hit($key, 60); + + throw ValidationException::withMessages([ + 'email' => __('Invalid credentials'), + ]); + } + + RateLimiter::clear($key); + + if (request()->hasSession()) { + request()->session()->regenerate(); + } + + $customer = Auth::guard('customer')->user(); + + if ($customer instanceof Customer && session()->has('cart_id')) { + app(CartService::class)->getOrCreateForSession(Store::query()->findOrFail($this->storeId), $customer); + } + + $this->redirectRoute('account.dashboard', navigate: true); + } + + public function render(): mixed + { + return view('livewire.storefront.account.auth.login') + ->layout('layouts.auth'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Register.php b/app/Livewire/Storefront/Account/Auth/Register.php new file mode 100644 index 00000000..0cc4452d --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Register.php @@ -0,0 +1,74 @@ +storeId = $store->getKey(); + } + + public function register(): void + { + $store = Store::query()->findOrFail($this->storeId); + + app()->instance('current_store', $store); + + $validated = $this->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' => ['boolean'], + ]); + + $customer = Customer::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + 'name' => $validated['name'], + 'email' => $validated['email'], + 'password' => $validated['password'], + 'marketing_opt_in' => $validated['marketing_opt_in'], + ]); + + Auth::guard('customer')->login($customer); + + if (request()->hasSession()) { + request()->session()->regenerate(); + } + + $this->redirectRoute('account.dashboard', navigate: true); + } + + public function render(): mixed + { + return view('livewire.storefront.account.auth.register') + ->layout('layouts.auth'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/ResetPassword.php b/app/Livewire/Storefront/Account/Auth/ResetPassword.php new file mode 100644 index 00000000..33d389f2 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/ResetPassword.php @@ -0,0 +1,65 @@ +storeId = $store->getKey(); + $this->token = $token; + $this->email = (string) request('email', ''); + } + + public function resetPassword(CustomerPasswordResetService $passwords): void + { + $validated = $this->validate([ + 'email' => ['required', 'email', 'max:255'], + 'password' => ['required', 'string', 'min:8', 'confirmed'], + ]); + + $reset = $passwords->reset( + Store::query()->findOrFail($this->storeId), + $validated['email'], + $this->token, + $validated['password'], + ); + + if (! $reset) { + $this->addError('email', __('This password reset link is invalid or has expired.')); + + return; + } + + session()->flash('status', __('Your password has been reset. You may log in with your new password.')); + + $this->redirectRoute('account.login', navigate: true); + } + + public function render(): mixed + { + return view('livewire.storefront.account.auth.reset-password') + ->layout('layouts.auth'); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Index.php b/app/Livewire/Storefront/Account/Orders/Index.php new file mode 100644 index 00000000..0283ee23 --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Index.php @@ -0,0 +1,67 @@ +storeId = $store->getKey(); + $this->isDashboard = request()->routeIs('account.dashboard'); + } + + public function render(): mixed + { + return view('livewire.storefront.account.orders.index', [ + 'customer' => $this->customer(), + 'isDashboard' => $this->isDashboard, + 'orders' => $this->orders($this->isDashboard), + ])->layout('layouts.storefront', [ + 'title' => 'Account', + ]); + } + + /** + * @return Collection + */ + private function orders(bool $isDashboard): Collection + { + return Order::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->where('customer_id', $this->customer()->getKey()) + ->latest('placed_at') + ->latest('id') + ->limit($isDashboard ? 5 : 20) + ->get(); + } + + private function customer(): Customer + { + $customer = Auth::guard('customer')->user(); + + abort_unless($customer instanceof Customer, 403); + + return Customer::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->whereKey($customer->getKey()) + ->firstOrFail(); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Show.php b/app/Livewire/Storefront/Account/Orders/Show.php new file mode 100644 index 00000000..6a62d334 --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Show.php @@ -0,0 +1,69 @@ +user(); + + abort_unless($store instanceof Store && $customer instanceof Customer, 404); + + $order = Order::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('customer_id', $customer->getKey()) + ->whereKey($order->getKey()) + ->first(); + + abort_unless($order instanceof Order, 404); + + $this->storeId = $store->getKey(); + $this->orderId = $order->getKey(); + } + + public function render(): mixed + { + return view('livewire.storefront.account.orders.show', [ + 'customer' => $this->customer(), + 'order' => $this->order(), + ])->layout('layouts.storefront', [ + 'title' => 'Order details', + ]); + } + + private function order(): Order + { + return Order::withoutGlobalScopes() + ->with(['lines', 'payments', 'refunds', 'fulfillments.lines']) + ->where('store_id', $this->storeId) + ->where('customer_id', Auth::guard('customer')->id()) + ->findOrFail($this->orderId); + } + + private function customer(): Customer + { + $customer = Auth::guard('customer')->user(); + + abort_unless($customer instanceof Customer, 403); + + return Customer::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->whereKey($customer->getKey()) + ->firstOrFail(); + } +} diff --git a/app/Livewire/Storefront/Cart/Show.php b/app/Livewire/Storefront/Cart/Show.php new file mode 100644 index 00000000..350223dc --- /dev/null +++ b/app/Livewire/Storefront/Cart/Show.php @@ -0,0 +1,330 @@ +storeId = $this->store()->getKey(); + $this->appliedDiscountCode = trim((string) session('cart_discount_code')) ?: null; + $this->discountCode = $this->appliedDiscountCode ?? ''; + } + + #[On('cart-updated')] + public function refreshCart(): void {} + + public function increaseQuantity(int $lineId): void + { + $line = $this->cartLine($lineId); + + if (! $line instanceof CartLine) { + return; + } + + try { + app(CartService::class)->updateLineQuantity($line->cart, $line->getKey(), $line->quantity + 1); + $this->cartMessage = null; + $this->dispatch('cart-updated'); + } catch (InsufficientInventoryException|InvalidCartOperationException $exception) { + $this->cartMessage = $exception->getMessage(); + } + } + + public function decreaseQuantity(int $lineId): void + { + $line = $this->cartLine($lineId); + + if (! $line instanceof CartLine) { + return; + } + + app(CartService::class)->updateLineQuantity($line->cart, $line->getKey(), $line->quantity - 1); + $this->cartMessage = null; + $this->dispatch('cart-updated'); + } + + public function removeLine(int $lineId): void + { + $line = $this->cartLine($lineId); + + if (! $line instanceof CartLine) { + return; + } + + app(CartService::class)->removeLine($line->cart, $line->getKey()); + $this->cartMessage = null; + $this->dispatch('cart-updated'); + } + + public function applyDiscount(): void + { + $this->validate([ + 'discountCode' => ['required', 'string', 'max:50'], + ]); + + $cart = $this->cart(); + + if (! $cart instanceof Cart) { + return; + } + + $code = trim($this->discountCode); + + try { + $discount = app(DiscountService::class)->validate($code, $this->store(), $cart); + app(DiscountService::class)->calculate($discount, $this->subtotal(), $this->lines()->all()); + } catch (InvalidDiscountException $exception) { + $this->removeDiscount(); + + throw ValidationException::withMessages([ + 'discountCode' => $exception->getMessage(), + ]); + } + + $this->appliedDiscountCode = $code; + $this->discountCode = $code; + session(['cart_discount_code' => $code]); + $this->resetErrorBag('discountCode'); + } + + public function removeDiscount(): void + { + $this->appliedDiscountCode = null; + $this->discountCode = ''; + session()->forget('cart_discount_code'); + $this->resetErrorBag('discountCode'); + } + + public function estimateShipping(): void + { + $this->validate([ + 'shippingCountry' => ['required', 'string', 'size:2'], + 'shippingPostalCode' => ['nullable', 'string', 'max:20'], + 'shippingProvinceCode' => ['nullable', 'string', 'max:20'], + ]); + + $this->resetErrorBag('shippingCountry'); + } + + public function checkout(): void + { + if ($this->lineCount() === 0) { + return; + } + + $cart = $this->cart(); + + if (! $cart instanceof Cart) { + return; + } + + $checkout = app(CheckoutService::class)->createFromCart($cart, $this->customer()); + + $this->redirectRoute('checkout.show', ['checkout' => $checkout->getKey()], navigate: true); + } + + public function store(): Store + { + if (isset($this->storeId)) { + $store = Store::query()->findOrFail($this->storeId); + app()->instance('current_store', $store); + + return $store; + } + + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } + + public function cart(): ?Cart + { + $cart = app(CartService::class)->currentForSession($this->store(), $this->customer()); + + return $cart?->load([ + 'lines.variant.product', + 'lines.variant.optionValues.option', + ]); + } + + /** + * @return Collection + */ + public function lines(): Collection + { + return $this->cart()?->lines->sortBy('id')->values() ?? collect(); + } + + public function lineCount(): int + { + return $this->lines()->sum('quantity'); + } + + public function subtotal(): int + { + return $this->lines()->sum('line_subtotal_amount'); + } + + /** + * @return Collection + */ + public function availableRates(): Collection + { + if (! $this->requiresShipping() || $this->shippingCountry === '') { + return collect(); + } + + return app(ShippingCalculator::class)->getAvailableRates($this->store(), $this->shippingAddress()); + } + + public function requiresShipping(): bool + { + $cart = $this->cart(); + + return $cart instanceof Cart && app(ShippingCalculator::class)->requiresShipping($cart); + } + + /** + * @return array + */ + public function shippingRateAmounts(): array + { + $cart = $this->cart(); + + if (! $cart instanceof Cart) { + return []; + } + + return $this->availableRates() + ->mapWithKeys(fn (ShippingRate $rate): array => [ + $rate->getKey() => app(ShippingCalculator::class)->calculate($rate, $cart) ?? 0, + ]) + ->all(); + } + + public function estimatedShippingAmount(): ?int + { + if (! $this->requiresShipping()) { + return 0; + } + + $amounts = $this->shippingRateAmounts(); + + return $amounts === [] ? null : min($amounts); + } + + public function discountResult(): ?DiscountResult + { + $cart = $this->cart(); + $code = trim((string) $this->appliedDiscountCode); + + if (! $cart instanceof Cart || $code === '') { + return null; + } + + try { + $discount = app(DiscountService::class)->validate($code, $this->store(), $cart); + + return app(DiscountService::class)->calculate($discount, $this->subtotal(), $this->lines()->all()); + } catch (InvalidDiscountException) { + return null; + } + } + + public function discountAmount(): int + { + return $this->discountResult()?->amount ?? 0; + } + + public function discountFreeShipping(): bool + { + return $this->discountResult()?->freeShipping ?? false; + } + + public function estimatedTotal(): int + { + $shipping = $this->discountFreeShipping() ? 0 : ($this->estimatedShippingAmount() ?? 0); + + return max(0, $this->subtotal() - $this->discountAmount() + $shipping); + } + + public function render(): mixed + { + return view('livewire.storefront.cart.show', [ + 'cart' => $this->cart(), + 'lines' => $this->lines(), + 'lineCount' => $this->lineCount(), + 'subtotal' => $this->subtotal(), + 'discountAmount' => $this->discountAmount(), + 'discountFreeShipping' => $this->discountFreeShipping(), + 'rates' => $this->availableRates(), + 'rateAmounts' => $this->shippingRateAmounts(), + 'estimatedShipping' => $this->estimatedShippingAmount(), + 'estimatedTotal' => $this->estimatedTotal(), + 'requiresShipping' => $this->requiresShipping(), + ])->layout('layouts.storefront', [ + 'title' => 'Cart', + ]); + } + + private function customer(): ?Customer + { + $customer = Auth::guard('customer')->user(); + + return $customer instanceof Customer ? $customer : null; + } + + private function cartLine(int $lineId): ?CartLine + { + return $this->lines()->firstWhere('id', $lineId); + } + + /** + * @return array + */ + private function shippingAddress(): array + { + return [ + 'country' => strtoupper($this->shippingCountry), + 'country_code' => strtoupper($this->shippingCountry), + 'postal_code' => $this->shippingPostalCode, + 'province_code' => strtoupper($this->shippingProvinceCode), + ]; + } +} diff --git a/app/Livewire/Storefront/CartDrawer.php b/app/Livewire/Storefront/CartDrawer.php new file mode 100644 index 00000000..58548a67 --- /dev/null +++ b/app/Livewire/Storefront/CartDrawer.php @@ -0,0 +1,146 @@ +storeId = $this->store()->getKey(); + } + + #[On('cart-updated')] + public function refreshCart(): void {} + + public function increaseQuantity(int $lineId): void + { + $line = $this->cartLine($lineId); + + if (! $line instanceof CartLine) { + return; + } + + app(CartService::class)->updateLineQuantity($line->cart, $line->getKey(), $line->quantity + 1); + $this->dispatch('cart-updated'); + } + + public function decreaseQuantity(int $lineId): void + { + $line = $this->cartLine($lineId); + + if (! $line instanceof CartLine) { + return; + } + + app(CartService::class)->updateLineQuantity($line->cart, $line->getKey(), $line->quantity - 1); + $this->dispatch('cart-updated'); + } + + public function removeLine(int $lineId): void + { + $line = $this->cartLine($lineId); + + if (! $line instanceof CartLine) { + return; + } + + app(CartService::class)->removeLine($line->cart, $line->getKey()); + $this->dispatch('cart-updated'); + } + + public function checkout(): void + { + if ($this->lineCount() === 0) { + return; + } + + $cart = $this->cart(); + + if (! $cart instanceof Cart) { + return; + } + + $checkout = app(CheckoutService::class)->createFromCart($cart, $this->customer()); + + $this->redirectRoute('checkout.show', ['checkout' => $checkout->getKey()], navigate: true); + } + + public function store(): Store + { + if (isset($this->storeId)) { + $store = Store::query()->findOrFail($this->storeId); + app()->instance('current_store', $store); + + return $store; + } + + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } + + public function cart(): ?Cart + { + $cart = app(CartService::class)->currentForSession($this->store(), $this->customer()); + + return $cart?->load([ + 'lines.variant.product', + 'lines.variant.optionValues.option', + ]); + } + + /** + * @return Collection + */ + public function lines(): Collection + { + return $this->cart()?->lines->sortBy('id')->values() ?? collect(); + } + + public function lineCount(): int + { + return $this->lines()->sum('quantity'); + } + + public function subtotal(): int + { + return $this->lines()->sum('line_subtotal_amount'); + } + + public function render(): mixed + { + return view('livewire.storefront.cart-drawer', [ + 'cart' => $this->cart(), + 'lines' => $this->lines(), + 'lineCount' => $this->lineCount(), + 'subtotal' => $this->subtotal(), + ]); + } + + private function customer(): ?Customer + { + $customer = Auth::guard('customer')->user(); + + return $customer instanceof Customer ? $customer : null; + } + + private function cartLine(int $lineId): ?CartLine + { + return $this->lines()->firstWhere('id', $lineId); + } +} diff --git a/app/Livewire/Storefront/Checkout/Confirmation.php b/app/Livewire/Storefront/Checkout/Confirmation.php new file mode 100644 index 00000000..4608114a --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Confirmation.php @@ -0,0 +1,83 @@ +where('store_id', $store->getKey()) + ->whereKey($checkout->getKey()) + ->first(); + + abort_unless($checkout instanceof Checkout, 404); + + $order = Order::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('checkout_id', $checkout->getKey()) + ->first(); + + abort_unless($order instanceof Order, 404); + + $customer = Auth::guard('customer')->user(); + $sessionOrderId = session('last_order_id'); + $isCustomerOrder = $customer instanceof Customer && $order->customer_id === $customer->getKey(); + $isSessionOrder = $sessionOrderId !== null && (int) $sessionOrderId === (int) $order->getKey(); + + abort_unless($isCustomerOrder || $isSessionOrder, 404); + + $this->storeId = $store->getKey(); + $this->orderId = $order->getKey(); + } + + public function render(): mixed + { + return view('livewire.storefront.checkout.confirmation', [ + 'order' => $this->order(), + ])->layout('layouts.storefront', [ + 'title' => 'Order confirmation', + ]); + } + + private function order(): Order + { + $customer = Auth::guard('customer')->user(); + $sessionOrderId = session('last_order_id'); + + abort_if(! $customer instanceof Customer && $sessionOrderId === null, 404); + + return Order::withoutGlobalScopes() + ->with(['lines', 'payments', 'fulfillments.lines']) + ->where('store_id', $this->storeId) + ->whereKey($this->orderId) + ->where(function ($query) use ($customer, $sessionOrderId): void { + if ($customer instanceof Customer) { + $query->orWhere('customer_id', $customer->getKey()); + } + + if ($sessionOrderId !== null) { + $query->orWhere('id', (int) $sessionOrderId); + } + }) + ->firstOrFail(); + } +} diff --git a/app/Livewire/Storefront/Checkout/Show.php b/app/Livewire/Storefront/Checkout/Show.php new file mode 100644 index 00000000..38752a14 --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Show.php @@ -0,0 +1,510 @@ + + */ + public array $shippingAddress = [ + 'first_name' => '', + 'last_name' => '', + 'address1' => '', + 'address2' => '', + 'city' => '', + 'province_code' => '', + 'country' => 'DE', + 'postal_code' => '', + ]; + + /** + * @var array + */ + public array $billingAddress = [ + 'first_name' => '', + 'last_name' => '', + 'address1' => '', + 'address2' => '', + 'city' => '', + 'province_code' => '', + 'country' => 'DE', + 'postal_code' => '', + ]; + + public bool $billingSame = true; + + public ?int $selectedShippingRateId = null; + + public string $discountCode = ''; + + public string $paymentMethod = 'credit_card'; + + public string $cardNumber = ''; + + public string $cardName = ''; + + public string $cardExpiry = ''; + + public string $cardCvc = ''; + + public function mount(?Checkout $checkout = null): void + { + $this->storeId = $this->store()->getKey(); + $this->email = $this->customer()?->email ?? ''; + + if ($checkout instanceof Checkout) { + $this->mountCheckout($checkout); + } + + $this->fillFromCheckout($this->checkout()); + } + + public function saveAddress(): void + { + $this->validate([ + 'email' => ['required', 'email'], + 'shippingAddress.first_name' => ['required', 'string'], + 'shippingAddress.last_name' => ['required', 'string'], + 'shippingAddress.address1' => ['required', 'string'], + 'shippingAddress.city' => ['required', 'string'], + 'shippingAddress.country' => ['required', 'string', 'size:2'], + 'shippingAddress.postal_code' => [ + 'required', + 'string', + function (string $attribute, mixed $value, Closure $fail): void { + if (strtoupper($this->shippingAddress['country']) === 'DE' && preg_match('/^\d{5}$/', (string) $value) !== 1) { + $fail('The postal code format is invalid.'); + } + }, + ], + 'billingSame' => ['boolean'], + ]); + + $checkout = $this->checkout(); + + if (! $checkout instanceof Checkout) { + return; + } + + try { + $checkout = app(CheckoutService::class)->setAddress($checkout, [ + 'email' => $this->email, + 'shipping_address' => $this->shippingAddress, + 'billing_address' => $this->billingSame ? $this->shippingAddress : $this->billingAddress, + ]); + + $this->selectedShippingRateId = null; + + if (! $this->requiresShipping()) { + app(CheckoutService::class)->setShippingMethod($checkout, null); + $this->step = 'payment'; + + return; + } + + $this->step = 'shipping'; + } catch (InvalidCheckoutTransitionException $exception) { + throw ValidationException::withMessages([ + 'email' => $exception->getMessage(), + ]); + } + } + + public function selectShippingMethod(): void + { + $checkout = $this->checkout(); + + if (! $checkout instanceof Checkout) { + return; + } + + if ($this->requiresShipping()) { + $this->validate([ + 'selectedShippingRateId' => ['required', 'integer'], + ]); + } + + try { + app(CheckoutService::class)->setShippingMethod($checkout, $this->selectedShippingRateId); + $this->step = 'payment'; + } catch (InvalidCheckoutTransitionException|UnserviceableShippingAddressException $exception) { + throw ValidationException::withMessages([ + 'selectedShippingRateId' => $exception->getMessage(), + ]); + } + } + + public function applyDiscount(): void + { + $checkout = $this->checkout(); + + if (! $checkout instanceof Checkout) { + return; + } + + $checkout->forceFill([ + 'discount_code' => trim($this->discountCode) !== '' ? trim($this->discountCode) : null, + ])->save(); + + try { + app(PricingEngine::class)->calculate($checkout); + $this->resetErrorBag('discountCode'); + } catch (InvalidDiscountException $exception) { + $checkout->forceFill(['discount_code' => null])->save(); + $this->discountCode = ''; + app(PricingEngine::class)->calculate($checkout); + + throw ValidationException::withMessages([ + 'discountCode' => $exception->getMessage(), + ]); + } + } + + public function selectPaymentMethod(): void + { + $this->validate([ + 'paymentMethod' => ['required', 'in:credit_card,paypal,bank_transfer'], + ]); + + $checkout = $this->checkout(); + + if (! $checkout instanceof Checkout) { + return; + } + + try { + app(CheckoutService::class)->selectPaymentMethod($checkout, $this->paymentMethod); + $this->step = 'reserved'; + } catch (InvalidCheckoutTransitionException $exception) { + throw ValidationException::withMessages([ + 'paymentMethod' => $exception->getMessage(), + ]); + } + } + + public function placeOrder(): void + { + $rules = [ + 'paymentMethod' => ['required', 'in:credit_card,paypal,bank_transfer'], + ]; + + if ($this->paymentMethod === 'credit_card') { + $rules = [ + ...$rules, + 'cardNumber' => ['required', 'string'], + 'cardName' => ['nullable', 'string'], + 'cardExpiry' => ['nullable', 'string'], + 'cardCvc' => ['nullable', 'string'], + ]; + } + + $this->validate($rules); + + $checkout = $this->checkout(); + + if (! $checkout instanceof Checkout) { + return; + } + + try { + if ($checkout->status !== CheckoutStatus::PaymentSelected) { + $checkout = app(CheckoutService::class)->selectPaymentMethod($checkout, $this->paymentMethod); + } + + $order = app(CheckoutService::class)->completeCheckout($checkout, [ + 'card_number' => $this->cardNumber, + 'cardholder_name' => $this->cardName, + 'expiry' => $this->cardExpiry, + 'cvc' => $this->cardCvc, + ]); + + session([ + 'last_order_id' => $order->getKey(), + ]); + session()->forget(['cart_id', 'cart_discount_code']); + + $this->redirectRoute('checkout.confirmation', ['checkout' => $checkout->getKey()], navigate: true); + } catch (InvalidCheckoutTransitionException|PaymentFailedException $exception) { + $this->step = 'payment'; + + throw ValidationException::withMessages([ + $this->paymentMethod === 'credit_card' ? 'cardNumber' : 'paymentMethod' => $exception->getMessage(), + ]); + } + } + + public function store(): Store + { + if (isset($this->storeId)) { + $store = Store::query()->findOrFail($this->storeId); + app()->instance('current_store', $store); + + return $store; + } + + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } + + public function cart(): ?Cart + { + if ($this->checkoutId !== null) { + $checkout = Checkout::withoutGlobalScopes() + ->with('cart') + ->where('store_id', $this->storeId) + ->whereKey($this->checkoutId) + ->first(); + + if ($checkout instanceof Checkout) { + return $checkout->cart?->load([ + 'lines.variant.product', + 'lines.variant.optionValues.option', + ]); + } + } + + $cart = app(CartService::class)->currentForSession($this->store(), $this->customer()); + + return $cart?->load([ + 'lines.variant.product', + 'lines.variant.optionValues.option', + ]); + } + + public function checkout(): ?Checkout + { + if ($this->checkoutId !== null) { + $checkout = Checkout::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->whereKey($this->checkoutId) + ->first(); + + if (! $checkout instanceof Checkout) { + return null; + } + + $this->authorizeCheckoutAccess($checkout); + + return $this->syncSessionDiscount($checkout) + ->load(['cart.lines.variant.product', 'cart.lines.variant.optionValues.option']); + } + + $cart = $this->cart(); + + if (! $cart instanceof Cart || $cart->lines->isEmpty()) { + return null; + } + + $checkout = Checkout::withoutGlobalScopes() + ->where('cart_id', $cart->getKey()) + ->whereNotIn('status', [CheckoutStatus::Completed->value, CheckoutStatus::Expired->value]) + ->latest('id') + ->first(); + + if (! $checkout instanceof Checkout) { + $checkout = app(CheckoutService::class)->createFromCart($cart, $this->customer()); + } + + $this->checkoutId = $checkout->getKey(); + $checkout = $this->syncSessionDiscount($checkout); + + return $checkout->load(['cart.lines.variant.product', 'cart.lines.variant.optionValues.option']); + } + + /** + * @return Collection + */ + public function lines(): Collection + { + return $this->cart()?->lines->sortBy('id')->values() ?? collect(); + } + + /** + * @return Collection + */ + public function availableRates(): Collection + { + if ($this->shippingAddress['country'] === '') { + return collect(); + } + + return app(ShippingCalculator::class)->getAvailableRates($this->store(), $this->shippingAddress); + } + + public function requiresShipping(): bool + { + $cart = $this->cart(); + + return $cart instanceof Cart && app(ShippingCalculator::class)->requiresShipping($cart); + } + + /** + * @return array + */ + public function shippingRateAmounts(): array + { + $cart = $this->cart(); + + if (! $cart instanceof Cart) { + return []; + } + + return $this->availableRates() + ->mapWithKeys(fn (ShippingRate $rate): array => [ + $rate->getKey() => app(ShippingCalculator::class)->calculate($rate, $cart) ?? 0, + ]) + ->all(); + } + + public function lineCount(): int + { + return $this->lines()->sum('quantity'); + } + + public function subtotal(): int + { + return $this->lines()->sum('line_subtotal_amount'); + } + + /** + * @return array + */ + public function totals(): array + { + $cart = $this->cart(); + + return $this->checkout()?->totals_json ?? [ + 'subtotal' => $this->subtotal(), + 'discount' => 0, + 'shipping' => 0, + 'tax' => 0, + 'total' => $this->subtotal(), + 'currency' => $cart?->currency ?? $this->store()->default_currency, + ]; + } + + public function render(): mixed + { + return view('livewire.storefront.checkout.show', [ + 'cart' => $this->cart(), + 'checkout' => $this->checkout(), + 'lines' => $this->lines(), + 'lineCount' => $this->lineCount(), + 'rates' => $this->availableRates(), + 'rateAmounts' => $this->shippingRateAmounts(), + 'requiresShipping' => $this->requiresShipping(), + 'totals' => $this->totals(), + ])->layout('layouts.storefront', [ + 'title' => 'Checkout', + ]); + } + + private function customer(): ?Customer + { + $customer = Auth::guard('customer')->user(); + + return $customer instanceof Customer ? $customer : null; + } + + private function mountCheckout(Checkout $checkout): void + { + $checkout = Checkout::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->whereKey($checkout->getKey()) + ->first(); + + abort_unless($checkout instanceof Checkout, 404); + + $this->authorizeCheckoutAccess($checkout); + + $this->checkoutId = $checkout->getKey(); + } + + private function authorizeCheckoutAccess(Checkout $checkout): void + { + $customer = $this->customer(); + $sessionCartId = session('cart_id'); + $isCustomerCheckout = $customer instanceof Customer && $checkout->customer_id === $customer->getKey(); + $isSessionCheckout = $sessionCartId !== null && (int) $sessionCartId === (int) $checkout->cart_id; + + abort_unless($isCustomerCheckout || $isSessionCheckout, 404); + } + + private function fillFromCheckout(?Checkout $checkout): void + { + if (! $checkout instanceof Checkout) { + return; + } + + $this->email = $checkout->email ?: $this->email; + $this->shippingAddress = array_replace($this->shippingAddress, $checkout->shipping_address_json ?? []); + $this->billingAddress = array_replace($this->billingAddress, $checkout->billing_address_json ?? []); + $this->selectedShippingRateId = $checkout->shipping_method_id; + $this->discountCode = (string) ($checkout->discount_code ?? ''); + $this->paymentMethod = (string) ($checkout->payment_method ?? $this->paymentMethod); + $this->step = match ($checkout->status) { + CheckoutStatus::Started => 'address', + CheckoutStatus::Addressed => 'shipping', + CheckoutStatus::ShippingSelected => 'payment', + CheckoutStatus::PaymentSelected => 'reserved', + default => 'address', + }; + } + + private function syncSessionDiscount(Checkout $checkout): Checkout + { + $code = trim((string) session('cart_discount_code')); + + if ($code === '' || $checkout->discount_code !== null) { + return $checkout; + } + + $checkout->forceFill(['discount_code' => $code])->save(); + + try { + app(PricingEngine::class)->calculate($checkout); + } catch (InvalidDiscountException) { + $checkout->forceFill(['discount_code' => null])->save(); + session()->forget('cart_discount_code'); + app(PricingEngine::class)->calculate($checkout); + } + + return $checkout->refresh(); + } +} diff --git a/app/Livewire/Storefront/Collections/Index.php b/app/Livewire/Storefront/Collections/Index.php new file mode 100644 index 00000000..47762294 --- /dev/null +++ b/app/Livewire/Storefront/Collections/Index.php @@ -0,0 +1,30 @@ +withCount(['products' => fn ($query) => $query + ->where('status', 'active') + ->whereNotNull('published_at')]) + ->where('status', 'active') + ->orderBy('title') + ->get(); + } + + public function render(): mixed + { + return view('livewire.storefront.collections.index', [ + 'collections' => $this->collections(), + ])->layout('layouts.storefront', [ + 'title' => __('Collections'), + ]); + } +} diff --git a/app/Livewire/Storefront/Collections/Show.php b/app/Livewire/Storefront/Collections/Show.php new file mode 100644 index 00000000..bf999801 --- /dev/null +++ b/app/Livewire/Storefront/Collections/Show.php @@ -0,0 +1,130 @@ + + */ + public array $types = []; + + /** + * @var array + */ + public array $vendors = []; + + public function mount(string $handle): void + { + $this->handle = $handle; + } + + public function updated(): void + { + $this->resetPage(); + } + + public function clearFilters(): void + { + $this->inStock = false; + $this->minPrice = null; + $this->maxPrice = null; + $this->types = []; + $this->vendors = []; + $this->resetPage(); + } + + public function collection(): Collection + { + return Collection::query() + ->where('handle', $this->handle) + ->where('status', 'active') + ->firstOrFail(); + } + + public function products(): LengthAwarePaginator + { + $collection = $this->collection(); + + return $collection->products() + ->with(['variants.inventoryItem']) + ->withCount('variants') + ->where('products.status', 'active') + ->whereNotNull('products.published_at') + ->when($this->inStock, function (Builder $query): void { + $query->whereHas('variants.inventoryItem', function (Builder $query): void { + $query + ->where('policy', 'continue') + ->orWhereColumn('quantity_on_hand', '>', 'quantity_reserved'); + }); + }) + ->when($this->types !== [], fn (Builder $query) => $query->whereIn('product_type', $this->types)) + ->when($this->vendors !== [], fn (Builder $query) => $query->whereIn('vendor', $this->vendors)) + ->when($this->minPrice !== null, function (Builder $query): void { + $query->whereHas('variants', fn (Builder $query) => $query->where('price_amount', '>=', $this->minPrice * 100)); + }) + ->when($this->maxPrice !== null, function (Builder $query): void { + $query->whereHas('variants', fn (Builder $query) => $query->where('price_amount', '<=', $this->maxPrice * 100)); + }) + ->when($this->sort === 'price_asc', fn (Builder $query) => $query->orderBy(ProductVariant::select('price_amount')->whereColumn('product_id', 'products.id')->orderBy('price_amount')->limit(1))) + ->when($this->sort === 'price_desc', fn (Builder $query) => $query->orderByDesc(ProductVariant::select('price_amount')->whereColumn('product_id', 'products.id')->orderBy('price_amount')->limit(1))) + ->when($this->sort === 'newest', fn (Builder $query) => $query->latest('products.created_at')) + ->paginate(12); + } + + public function productTypes(): SupportCollection + { + return $this->collection()->products() + ->where('products.status', 'active') + ->whereNotNull('products.published_at') + ->whereNotNull('product_type') + ->distinct() + ->orderBy('product_type') + ->pluck('product_type'); + } + + public function productVendors(): SupportCollection + { + return $this->collection()->products() + ->where('products.status', 'active') + ->whereNotNull('products.published_at') + ->whereNotNull('vendor') + ->distinct() + ->orderBy('vendor') + ->pluck('vendor'); + } + + public function render(): mixed + { + $collection = $this->collection(); + + return view('livewire.storefront.collections.show', [ + 'collection' => $collection, + 'products' => $this->products(), + 'productTypes' => $this->productTypes(), + 'productVendors' => $this->productVendors(), + ])->layout('layouts.storefront', [ + 'title' => $collection->title, + ]); + } +} diff --git a/app/Livewire/Storefront/Home.php b/app/Livewire/Storefront/Home.php new file mode 100644 index 00000000..64e2b026 --- /dev/null +++ b/app/Livewire/Storefront/Home.php @@ -0,0 +1,64 @@ +with(['variants.inventoryItem']) + ->withCount('variants') + ->where('status', 'active') + ->whereNotNull('published_at') + ->oldest('id') + ->limit((int) data_get($this->themeSettings(), 'home.featured_product_limit', 8)) + ->get(); + } + + public function featuredCollections(): SupportCollection + { + return Collection::query() + ->withCount('products') + ->where('status', 'active') + ->orderBy('title') + ->limit((int) data_get($this->themeSettings(), 'home.featured_collection_limit', 4)) + ->get(); + } + + /** + * @return array + */ + public function themeSettings(): array + { + return app(ThemeSettingsService::class)->forStore($this->store()); + } + + public function render(): mixed + { + return view('livewire.storefront.home', [ + 'store' => $this->store(), + 'featuredProducts' => $this->featuredProducts(), + 'featuredCollections' => $this->featuredCollections(), + 'themeSettings' => $this->themeSettings(), + ])->layout('layouts.storefront', [ + 'title' => $this->store()->name, + ]); + } +} diff --git a/app/Livewire/Storefront/Pages/Show.php b/app/Livewire/Storefront/Pages/Show.php new file mode 100644 index 00000000..17f9734a --- /dev/null +++ b/app/Livewire/Storefront/Pages/Show.php @@ -0,0 +1,37 @@ +where('handle', $handle) + ->where('status', PageStatus::Published) + ->whereNotNull('published_at') + ->firstOrFail(); + + $this->handle = $page->handle; + $this->title = $page->title; + $this->bodyHtml = (string) $page->body_html; + } + + public function render(): mixed + { + return view('livewire.storefront.pages.show') + ->layout('layouts.storefront', [ + 'title' => $this->title, + ]); + } +} diff --git a/app/Livewire/Storefront/Products/Show.php b/app/Livewire/Storefront/Products/Show.php new file mode 100644 index 00000000..778d6bc8 --- /dev/null +++ b/app/Livewire/Storefront/Products/Show.php @@ -0,0 +1,186 @@ + + */ + public array $selectedOptions = []; + + public int $quantity = 1; + + public function mount(string $handle): void + { + $this->handle = $handle; + $this->storeId = $this->store()->getKey(); + + $variant = $this->product()->variants->firstWhere('is_default', true) + ?? $this->product()->variants->first(); + + if ($variant instanceof ProductVariant) { + $this->selectedOptions = $variant->optionValues + ->mapWithKeys(fn (ProductOptionValue $value): array => [$value->option->name => $value->value]) + ->all(); + } + } + + public function selectOption(string $optionName, string $value): void + { + $this->selectedOptions[$optionName] = $value; + $this->quantity = 1; + } + + public function increaseQuantity(): void + { + $variant = $this->selectedVariant(); + $max = $variant?->inventoryItem?->policy === InventoryPolicy::Deny + ? max(1, $variant->inventoryItem->availableQuantity()) + : null; + + if ($max === null || $this->quantity < $max) { + $this->quantity++; + } + } + + public function decreaseQuantity(): void + { + $this->quantity = max(1, $this->quantity - 1); + } + + public function addToCart(): void + { + $store = $this->store(); + $variant = $this->selectedVariant(); + + if (! $variant instanceof ProductVariant || ! $this->canAddToCart()) { + return; + } + + $customer = Auth::guard('customer')->user(); + $customer = $customer instanceof Customer ? $customer : null; + + app(CartService::class)->addLine( + app(CartService::class)->getOrCreateForSession($store, $customer), + $variant->getKey(), + $this->quantity, + ); + + $this->dispatch('cart-updated'); + $this->dispatch('toast', type: 'success', message: __('Added to cart')); + } + + public function store(): Store + { + if (isset($this->storeId)) { + $store = Store::query()->findOrFail($this->storeId); + app()->instance('current_store', $store); + + return $store; + } + + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } + + public function product(): Product + { + $this->store(); + + return Product::query() + ->with(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections']) + ->where('handle', $this->handle) + ->where('status', 'active') + ->whereNotNull('published_at') + ->firstOrFail(); + } + + public function selectedVariant(): ?ProductVariant + { + $product = $this->product(); + + if ($product->options->isEmpty()) { + return $product->variants->first(); + } + + return $product->variants->first(function (ProductVariant $variant): bool { + $options = $variant->optionValues + ->mapWithKeys(fn (ProductOptionValue $value): array => [$value->option->name => $value->value]) + ->all(); + + return $options === $this->selectedOptions; + }); + } + + public function canAddToCart(): bool + { + $variant = $this->selectedVariant(); + + if (! $variant instanceof ProductVariant || ! $variant->isPurchasable()) { + return false; + } + + $inventory = $variant->inventoryItem; + + return $inventory?->policy === InventoryPolicy::Continue + || ($inventory?->availableQuantity() ?? 0) >= $this->quantity; + } + + /** + * @return array{message: string, class: string} + */ + public function stockState(): array + { + $variant = $this->selectedVariant(); + $inventory = $variant?->inventoryItem; + + if (! $inventory) { + return ['message' => 'Unavailable', 'class' => 'text-red-700 dark:text-red-300']; + } + + if ($inventory->policy === InventoryPolicy::Continue && $inventory->availableQuantity() <= 0) { + return ['message' => 'Available on backorder', 'class' => 'text-blue-700 dark:text-blue-300']; + } + + if ($inventory->availableQuantity() <= 0) { + return ['message' => 'Out of stock', 'class' => 'text-red-700 dark:text-red-300']; + } + + if ($inventory->availableQuantity() <= 10) { + return ['message' => "Only {$inventory->availableQuantity()} left in stock", 'class' => 'text-amber-700 dark:text-amber-300']; + } + + return ['message' => 'In stock', 'class' => 'text-green-700 dark:text-green-300']; + } + + public function render(): mixed + { + $product = $this->product(); + + return view('livewire.storefront.products.show', [ + 'product' => $product, + 'selectedVariant' => $this->selectedVariant(), + 'stockState' => $this->stockState(), + ])->layout('layouts.storefront', [ + 'title' => $product->title, + ]); + } +} diff --git a/app/Livewire/Storefront/Search/Index.php b/app/Livewire/Storefront/Search/Index.php new file mode 100644 index 00000000..9c6b8e09 --- /dev/null +++ b/app/Livewire/Storefront/Search/Index.php @@ -0,0 +1,126 @@ + + */ + public array $types = []; + + /** + * @var array + */ + public array $vendors = []; + + public function mount(): void + { + $this->q = (string) request('q', ''); + } + + public function updated(): void + { + $this->resetPage(); + } + + public function clearFilters(): void + { + $this->sort = 'relevance'; + $this->inStock = false; + $this->minPrice = null; + $this->maxPrice = null; + $this->types = []; + $this->vendors = []; + $this->resetPage(); + } + + public function products(): LengthAwarePaginator + { + return app(SearchService::class)->search( + $this->store(), + $this->q, + $this->filters(), + 12, + $this->sort, + ); + } + + public function productTypes(): Collection + { + return Product::withoutGlobalScopes() + ->where('store_id', $this->store()->getKey()) + ->where('status', 'active') + ->whereNotNull('published_at') + ->whereNotNull('product_type') + ->distinct() + ->orderBy('product_type') + ->pluck('product_type'); + } + + public function productVendors(): Collection + { + return Product::withoutGlobalScopes() + ->where('store_id', $this->store()->getKey()) + ->where('status', 'active') + ->whereNotNull('published_at') + ->whereNotNull('vendor') + ->distinct() + ->orderBy('vendor') + ->pluck('vendor'); + } + + public function render(): mixed + { + return view('livewire.storefront.search.index', [ + 'products' => $this->products(), + 'productTypes' => $this->productTypes(), + 'productVendors' => $this->productVendors(), + ])->layout('layouts.storefront', [ + 'title' => __('Search results'), + ]); + } + + /** + * @return array + */ + private function filters(): array + { + return [ + 'in_stock' => $this->inStock, + 'price_min' => $this->minPrice === null ? null : $this->minPrice * 100, + 'price_max' => $this->maxPrice === null ? null : $this->maxPrice * 100, + 'product_type' => $this->types, + 'vendor' => $this->vendors, + ]; + } + + private function store(): Store + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } +} diff --git a/app/Models/AnalyticsDaily.php b/app/Models/AnalyticsDaily.php new file mode 100644 index 00000000..a5c64080 --- /dev/null +++ b/app/Models/AnalyticsDaily.php @@ -0,0 +1,61 @@ + */ + use BelongsToStore, HasFactory; + + public $incrementing = false; + + public $timestamps = false; + + protected $table = 'analytics_daily'; + + protected $primaryKey = 'store_id'; + + /** + * @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', + ]; + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'orders_count' => 'integer', + 'revenue_amount' => 'integer', + 'aov_amount' => 'integer', + 'visits_count' => 'integer', + 'add_to_cart_count' => 'integer', + 'checkout_started_count' => 'integer', + 'checkout_completed_count' => 'integer', + ]; + } +} diff --git a/app/Models/AnalyticsEvent.php b/app/Models/AnalyticsEvent.php new file mode 100644 index 00000000..4aa15258 --- /dev/null +++ b/app/Models/AnalyticsEvent.php @@ -0,0 +1,67 @@ + */ + use BelongsToStore, HasFactory; + + public const UPDATED_AT = null; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'type', + 'session_id', + 'customer_id', + 'properties_json', + 'client_event_id', + 'occurred_at', + 'created_at', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'properties_json' => '{}', + ]; + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @return BelongsTo + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => AnalyticsEventType::class, + 'properties_json' => 'array', + 'occurred_at' => 'datetime', + 'created_at' => 'datetime', + ]; + } +} diff --git a/app/Models/App.php b/app/Models/App.php new file mode 100644 index 00000000..a0e2cb2e --- /dev/null +++ b/app/Models/App.php @@ -0,0 +1,59 @@ + */ + use HasFactory; + + public const UPDATED_AT = null; + + /** + * @var list + */ + protected $fillable = [ + 'name', + 'status', + 'created_at', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'status' => 'active', + ]; + + /** + * @return HasMany + */ + public function installations(): HasMany + { + return $this->hasMany(AppInstallation::class); + } + + /** + * @return HasMany + */ + public function oauthClients(): HasMany + { + return $this->hasMany(OauthClient::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => AppStatus::class, + 'created_at' => 'datetime', + ]; + } +} diff --git a/app/Models/AppInstallation.php b/app/Models/AppInstallation.php new file mode 100644 index 00000000..1cbc8735 --- /dev/null +++ b/app/Models/AppInstallation.php @@ -0,0 +1,81 @@ + */ + use BelongsToStore, HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'app_id', + 'scopes_json', + 'status', + 'installed_at', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'scopes_json' => '[]', + 'status' => 'active', + ]; + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @return BelongsTo + */ + public function app(): BelongsTo + { + return $this->belongsTo(App::class); + } + + /** + * @return HasMany + */ + public function oauthTokens(): HasMany + { + return $this->hasMany(OauthToken::class, 'installation_id'); + } + + /** + * @return HasMany + */ + public function webhookSubscriptions(): HasMany + { + return $this->hasMany(WebhookSubscription::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'scopes_json' => 'array', + 'status' => AppInstallationStatus::class, + 'installed_at' => 'datetime', + ]; + } +} diff --git a/app/Models/Cart.php b/app/Models/Cart.php new file mode 100644 index 00000000..b4f4657c --- /dev/null +++ b/app/Models/Cart.php @@ -0,0 +1,79 @@ + */ + use BelongsToStore, HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'customer_id', + 'currency', + 'cart_version', + 'status', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'currency' => 'USD', + 'cart_version' => 1, + 'status' => 'active', + ]; + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @return BelongsTo + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** + * @return HasMany + */ + public function lines(): HasMany + { + return $this->hasMany(CartLine::class); + } + + /** + * @return HasMany + */ + public function checkouts(): HasMany + { + return $this->hasMany(Checkout::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'cart_version' => 'integer', + 'status' => CartStatus::class, + ]; + } +} diff --git a/app/Models/CartLine.php b/app/Models/CartLine.php new file mode 100644 index 00000000..00a8816a --- /dev/null +++ b/app/Models/CartLine.php @@ -0,0 +1,91 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'cart_id', + 'variant_id', + 'quantity', + 'unit_price_amount', + 'line_subtotal_amount', + 'line_discount_amount', + 'line_total_amount', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'quantity' => 1, + 'unit_price_amount' => 0, + 'line_subtotal_amount' => 0, + 'line_discount_amount' => 0, + 'line_total_amount' => 0, + ]; + + protected static function booted(): void + { + static::addGlobalScope('current_store', function (Builder $builder): void { + if (! app()->bound('current_store')) { + return; + } + + $store = app('current_store'); + + if (! $store instanceof Store) { + return; + } + + $builder->whereHas('cart', function (Builder $query) use ($store): void { + $query + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()); + }); + }); + } + + /** + * @return BelongsTo + */ + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + /** + * @return BelongsTo + */ + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + /** + * @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', + ]; + } +} diff --git a/app/Models/Checkout.php b/app/Models/Checkout.php new file mode 100644 index 00000000..b4ad5cc9 --- /dev/null +++ b/app/Models/Checkout.php @@ -0,0 +1,89 @@ + */ + use BelongsToStore, HasFactory; + + /** + * @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', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'status' => 'started', + ]; + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @return BelongsTo + */ + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + /** + * @return BelongsTo + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** + * @return BelongsTo + */ + public function shippingRate(): BelongsTo + { + return $this->belongsTo(ShippingRate::class, 'shipping_method_id'); + } + + /** + * @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', + ]; + } +} diff --git a/app/Models/Collection.php b/app/Models/Collection.php new file mode 100644 index 00000000..a925bc15 --- /dev/null +++ b/app/Models/Collection.php @@ -0,0 +1,66 @@ + */ + use BelongsToStore, HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'description_html', + 'type', + 'status', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'type' => 'manual', + 'status' => 'active', + ]; + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @return BelongsToMany + */ + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'collection_products') + ->withPivot('position') + ->orderByPivot('position'); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => CollectionType::class, + 'status' => CollectionStatus::class, + ]; + } +} diff --git a/app/Models/Concerns/BelongsToStore.php b/app/Models/Concerns/BelongsToStore.php new file mode 100644 index 00000000..60187756 --- /dev/null +++ b/app/Models/Concerns/BelongsToStore.php @@ -0,0 +1,26 @@ +store_id || ! app()->bound('current_store')) { + return; + } + + $store = app('current_store'); + + if ($store instanceof Store) { + $model->store_id = $store->getKey(); + } + }); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php new file mode 100644 index 00000000..e2d6dd3e --- /dev/null +++ b/app/Models/Customer.php @@ -0,0 +1,92 @@ + */ + use BelongsToStore, HasFactory, Notifiable; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'email', + 'password', + 'name', + 'marketing_opt_in', + ]; + + /** + * @var list + */ + protected $hidden = [ + 'password_hash', + 'remember_token', + ]; + + public function getAuthPassword(): ?string + { + return $this->password_hash; + } + + public function setPasswordAttribute(?string $value): void + { + $this->attributes['password_hash'] = $value && Hash::needsRehash($value) ? Hash::make($value) : $value; + } + + public function getPasswordAttribute(): ?string + { + return $this->password_hash; + } + + /** + * @return HasMany + */ + public function carts(): HasMany + { + return $this->hasMany(Cart::class); + } + + /** + * @return HasMany + */ + public function checkouts(): HasMany + { + return $this->hasMany(Checkout::class); + } + + /** + * @return HasMany + */ + public function addresses(): HasMany + { + return $this->hasMany(CustomerAddress::class); + } + + /** + * @return HasMany + */ + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'marketing_opt_in' => 'bool', + ]; + } +} diff --git a/app/Models/CustomerAddress.php b/app/Models/CustomerAddress.php new file mode 100644 index 00000000..d635a2e5 --- /dev/null +++ b/app/Models/CustomerAddress.php @@ -0,0 +1,44 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'customer_id', + 'label', + 'address_json', + 'is_default', + ]; + + /** + * @return BelongsTo + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'address_json' => 'array', + 'is_default' => 'bool', + ]; + } +} diff --git a/app/Models/DataExport.php b/app/Models/DataExport.php new file mode 100644 index 00000000..53602c5e --- /dev/null +++ b/app/Models/DataExport.php @@ -0,0 +1,65 @@ + */ + use HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'type', + 'format', + 'status', + 'filters_json', + 'row_count', + 'storage_key', + 'error_message', + 'download_expires_at', + 'completed_at', + 'failed_at', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'type' => 'orders', + 'format' => 'csv', + 'status' => 'queued', + 'filters_json' => '{}', + 'row_count' => 0, + ]; + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => ExportStatus::class, + 'filters_json' => 'array', + 'row_count' => 'integer', + 'download_expires_at' => 'datetime', + 'completed_at' => 'datetime', + 'failed_at' => 'datetime', + ]; + } +} diff --git a/app/Models/Discount.php b/app/Models/Discount.php new file mode 100644 index 00000000..fb145263 --- /dev/null +++ b/app/Models/Discount.php @@ -0,0 +1,71 @@ + */ + use BelongsToStore, HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'type', + 'code', + 'value_type', + 'value_amount', + 'starts_at', + 'ends_at', + 'usage_limit', + 'usage_count', + 'rules_json', + 'status', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'type' => 'code', + 'value_amount' => 0, + 'usage_count' => 0, + 'rules_json' => '{}', + 'status' => 'active', + ]; + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @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, + ]; + } +} diff --git a/app/Models/Fulfillment.php b/app/Models/Fulfillment.php new file mode 100644 index 00000000..cb6f65cf --- /dev/null +++ b/app/Models/Fulfillment.php @@ -0,0 +1,63 @@ + */ + use HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'order_id', + 'status', + 'tracking_company', + 'tracking_number', + 'tracking_url', + 'shipped_at', + 'delivered_at', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'status' => 'pending', + ]; + + /** + * @return BelongsTo + */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** + * @return HasMany + */ + public function lines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => FulfillmentShipmentStatus::class, + 'shipped_at' => 'datetime', + 'delivered_at' => 'datetime', + ]; + } +} diff --git a/app/Models/FulfillmentLine.php b/app/Models/FulfillmentLine.php new file mode 100644 index 00000000..7fc31416 --- /dev/null +++ b/app/Models/FulfillmentLine.php @@ -0,0 +1,50 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'fulfillment_id', + 'order_line_id', + 'quantity', + ]; + + /** + * @return BelongsTo + */ + public function fulfillment(): BelongsTo + { + return $this->belongsTo(Fulfillment::class); + } + + /** + * @return BelongsTo + */ + public function orderLine(): BelongsTo + { + return $this->belongsTo(OrderLine::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'quantity' => 'integer', + ]; + } +} diff --git a/app/Models/InventoryItem.php b/app/Models/InventoryItem.php new file mode 100644 index 00000000..b10e46d8 --- /dev/null +++ b/app/Models/InventoryItem.php @@ -0,0 +1,109 @@ + */ + use BelongsToStore, HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'variant_id', + 'quantity_on_hand', + 'quantity_reserved', + 'policy', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]; + + protected static function booted(): void + { + static::saving(function (InventoryItem $item): void { + $storeId = $item->variantStoreId(); + + if ($storeId === null) { + return; + } + + if (! $item->store_id) { + $item->store_id = $storeId; + + return; + } + + if ((int) $item->store_id !== $storeId) { + throw new InvalidArgumentException('Inventory item store must match the variant product store.'); + } + }); + } + + private function variantStoreId(): ?int + { + $variant = ProductVariant::withoutGlobalScopes() + ->select(['id', 'product_id']) + ->find($this->variant_id); + + if (! $variant instanceof ProductVariant) { + return null; + } + + $storeId = Product::withoutGlobalScopes() + ->whereKey($variant->product_id) + ->value('store_id'); + + return $storeId === null ? null : (int) $storeId; + } + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @return BelongsTo + */ + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + public function availableQuantity(): int + { + return $this->quantity_on_hand - $this->quantity_reserved; + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'quantity_on_hand' => 'integer', + 'quantity_reserved' => 'integer', + 'policy' => InventoryPolicy::class, + ]; + } +} diff --git a/app/Models/NavigationItem.php b/app/Models/NavigationItem.php new file mode 100644 index 00000000..1a031121 --- /dev/null +++ b/app/Models/NavigationItem.php @@ -0,0 +1,97 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'menu_id', + 'parent_id', + 'type', + 'label', + 'url', + 'resource_id', + 'position', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'type' => 'link', + 'position' => 0, + ]; + + protected static function booted(): void + { + static::addGlobalScope('current_store', function (Builder $builder): void { + if (! app()->bound('current_store')) { + return; + } + + $store = app('current_store'); + + if (! $store instanceof Store) { + return; + } + + $builder->whereHas('menu', function (Builder $query) use ($store): void { + $query + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()); + }); + }); + } + + /** + * @return BelongsTo + */ + public function menu(): BelongsTo + { + return $this->belongsTo(NavigationMenu::class, 'menu_id'); + } + + /** + * @return BelongsTo + */ + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + /** + * @return HasMany + */ + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id')->orderBy('position'); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => NavigationItemType::class, + 'parent_id' => 'integer', + 'resource_id' => 'integer', + 'position' => 'integer', + ]; + } +} diff --git a/app/Models/NavigationMenu.php b/app/Models/NavigationMenu.php new file mode 100644 index 00000000..92b22058 --- /dev/null +++ b/app/Models/NavigationMenu.php @@ -0,0 +1,40 @@ + */ + use BelongsToStore, HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'handle', + 'title', + ]; + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @return HasMany + */ + public function items(): HasMany + { + return $this->hasMany(NavigationItem::class, 'menu_id')->orderBy('position'); + } +} diff --git a/app/Models/OauthClient.php b/app/Models/OauthClient.php new file mode 100644 index 00000000..04b39627 --- /dev/null +++ b/app/Models/OauthClient.php @@ -0,0 +1,51 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'app_id', + 'client_id', + 'client_secret_encrypted', + 'redirect_uris_json', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'redirect_uris_json' => '[]', + ]; + + /** + * @return BelongsTo + */ + public function app(): BelongsTo + { + return $this->belongsTo(App::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'client_secret_encrypted' => 'encrypted', + 'redirect_uris_json' => 'array', + ]; + } +} diff --git a/app/Models/OauthToken.php b/app/Models/OauthToken.php new file mode 100644 index 00000000..7b196841 --- /dev/null +++ b/app/Models/OauthToken.php @@ -0,0 +1,62 @@ + */ + use HasFactory; + + public const UPDATED_AT = null; + + /** + * @var list + */ + protected $fillable = [ + 'installation_id', + 'name', + 'access_token_hash', + 'refresh_token_hash', + 'abilities_json', + 'expires_at', + 'last_used_at', + 'created_at', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'abilities_json' => '[]', + ]; + + /** + * @return BelongsTo + */ + public function installation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class, 'installation_id'); + } + + public function isExpired(): bool + { + return $this->expires_at->isPast(); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'abilities_json' => 'array', + 'expires_at' => 'datetime', + 'last_used_at' => 'datetime', + 'created_at' => 'datetime', + ]; + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 00000000..c7d1ba57 --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,135 @@ + */ + use BelongsToStore, HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'checkout_id', + 'customer_id', + 'order_number', + 'payment_method', + 'status', + 'financial_status', + 'fulfillment_status', + 'currency', + 'subtotal_amount', + 'discount_amount', + 'shipping_amount', + 'tax_amount', + 'total_amount', + 'email', + 'billing_address_json', + 'shipping_address_json', + 'placed_at', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'status' => 'pending', + 'financial_status' => 'pending', + 'fulfillment_status' => 'unfulfilled', + 'currency' => 'USD', + 'subtotal_amount' => 0, + 'discount_amount' => 0, + 'shipping_amount' => 0, + 'tax_amount' => 0, + 'total_amount' => 0, + ]; + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @return BelongsTo + */ + public function checkout(): BelongsTo + { + return $this->belongsTo(Checkout::class); + } + + /** + * @return BelongsTo + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** + * @return HasMany + */ + public function lines(): HasMany + { + return $this->hasMany(OrderLine::class); + } + + /** + * @return HasMany + */ + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } + + /** + * @return HasMany + */ + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } + + /** + * @return HasMany + */ + public function fulfillments(): HasMany + { + return $this->hasMany(Fulfillment::class); + } + + /** + * @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', + ]; + } +} diff --git a/app/Models/OrderLine.php b/app/Models/OrderLine.php new file mode 100644 index 00000000..64ca2d58 --- /dev/null +++ b/app/Models/OrderLine.php @@ -0,0 +1,78 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @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', + ]; + + /** + * @return BelongsTo + */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * @return BelongsTo + */ + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + /** + * @return HasMany + */ + public function fulfillmentLines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'quantity' => 'integer', + 'unit_price_amount' => 'integer', + 'total_amount' => 'integer', + 'tax_lines_json' => 'array', + 'discount_allocations_json' => 'array', + ]; + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php new file mode 100644 index 00000000..937745f0 --- /dev/null +++ b/app/Models/Organization.php @@ -0,0 +1,29 @@ + */ + use HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'name', + 'billing_email', + ]; + + /** + * @return HasMany + */ + public function stores(): HasMany + { + return $this->hasMany(Store::class); + } +} diff --git a/app/Models/Page.php b/app/Models/Page.php new file mode 100644 index 00000000..5a47f7e9 --- /dev/null +++ b/app/Models/Page.php @@ -0,0 +1,58 @@ + */ + use BelongsToStore, HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'body_html', + 'status', + 'published_at', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'status' => 'draft', + ]; + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function isPublished(): bool + { + return $this->status === PageStatus::Published && $this->published_at !== null; + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => PageStatus::class, + 'published_at' => 'datetime', + ]; + } +} diff --git a/app/Models/Payment.php b/app/Models/Payment.php new file mode 100644 index 00000000..09e0cb8e --- /dev/null +++ b/app/Models/Payment.php @@ -0,0 +1,60 @@ + */ + use HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'order_id', + 'provider', + 'method', + 'provider_payment_id', + 'status', + 'amount', + 'currency', + 'raw_json_encrypted', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'provider' => 'mock', + 'status' => 'pending', + 'amount' => 0, + 'currency' => 'USD', + ]; + + /** + * @return BelongsTo + */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'method' => PaymentMethod::class, + 'status' => PaymentStatus::class, + 'amount' => 'integer', + 'raw_json_encrypted' => 'encrypted:array', + ]; + } +} diff --git a/app/Models/PersonalAccessToken.php b/app/Models/PersonalAccessToken.php new file mode 100644 index 00000000..864fc39a --- /dev/null +++ b/app/Models/PersonalAccessToken.php @@ -0,0 +1,64 @@ + + */ + protected $fillable = [ + 'store_id', + 'tokenable_type', + 'tokenable_id', + 'name', + 'token', + 'abilities', + 'last_used_at', + 'expires_at', + ]; + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @return MorphTo + */ + public function tokenable(): MorphTo + { + return $this->morphTo(); + } + + public function can(string $ability): bool + { + $abilities = $this->abilities ?? []; + + return in_array('*', $abilities, true) || in_array($ability, $abilities, true); + } + + public function isExpired(): bool + { + return $this->expires_at !== null && $this->expires_at->isPast(); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'abilities' => 'array', + 'last_used_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 00000000..d7c01c02 --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,99 @@ + */ + use BelongsToStore, HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'status', + 'description_html', + 'vendor', + 'product_type', + 'tags', + 'published_at', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'status' => 'draft', + 'tags' => '[]', + ]; + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @return HasMany + */ + public function options(): HasMany + { + return $this->hasMany(ProductOption::class)->orderBy('position'); + } + + /** + * @return HasMany + */ + public function variants(): HasMany + { + return $this->hasMany(ProductVariant::class)->orderBy('position'); + } + + /** + * @return HasMany + */ + public function media(): HasMany + { + return $this->hasMany(ProductMedia::class)->orderBy('position'); + } + + /** + * @return BelongsToMany + */ + public function collections(): BelongsToMany + { + return $this->belongsToMany(Collection::class, 'collection_products') + ->withPivot('position') + ->orderByPivot('position'); + } + + public function isPublished(): bool + { + return $this->status === ProductStatus::Active && $this->published_at !== null; + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => ProductStatus::class, + 'tags' => 'array', + 'published_at' => 'datetime', + ]; + } +} diff --git a/app/Models/ProductMedia.php b/app/Models/ProductMedia.php new file mode 100644 index 00000000..fbb0ed28 --- /dev/null +++ b/app/Models/ProductMedia.php @@ -0,0 +1,95 @@ + */ + use HasFactory; + + public const UPDATED_AT = null; + + /** + * @var list + */ + protected $fillable = [ + 'product_id', + 'type', + 'storage_key', + 'alt_text', + 'width', + 'height', + 'mime_type', + 'byte_size', + 'position', + 'status', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'type' => 'image', + 'position' => 0, + 'status' => 'processing', + ]; + + protected static function booted(): void + { + static::addGlobalScope('current_store', function (Builder $builder): void { + if (! app()->bound('current_store')) { + return; + } + + $store = app('current_store'); + + if (! $store instanceof Store) { + return; + } + + $builder->whereHas('product', function (Builder $query) use ($store): void { + $query + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()); + }); + }); + + static::deleted(function (ProductMedia $media): void { + $disk = Storage::disk('public'); + + $disk->delete($media->storage_key); + $disk->deleteDirectory("media/{$media->product_id}/{$media->getKey()}"); + }); + } + + /** + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => MediaType::class, + 'width' => 'integer', + 'height' => 'integer', + 'byte_size' => 'integer', + 'position' => 'integer', + 'status' => MediaStatus::class, + ]; + } +} diff --git a/app/Models/ProductOption.php b/app/Models/ProductOption.php new file mode 100644 index 00000000..abfca66c --- /dev/null +++ b/app/Models/ProductOption.php @@ -0,0 +1,80 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'product_id', + 'name', + 'position', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'position' => 0, + ]; + + protected static function booted(): void + { + static::addGlobalScope('current_store', function (Builder $builder): void { + if (! app()->bound('current_store')) { + return; + } + + $store = app('current_store'); + + if (! $store instanceof Store) { + return; + } + + $builder->whereHas('product', function (Builder $query) use ($store): void { + $query + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()); + }); + }); + } + + /** + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * @return HasMany + */ + public function values(): HasMany + { + return $this->hasMany(ProductOptionValue::class)->orderBy('position'); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'position' => 'integer', + ]; + } +} diff --git a/app/Models/ProductOptionValue.php b/app/Models/ProductOptionValue.php new file mode 100644 index 00000000..704978dd --- /dev/null +++ b/app/Models/ProductOptionValue.php @@ -0,0 +1,80 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'product_option_id', + 'value', + 'position', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'position' => 0, + ]; + + protected static function booted(): void + { + static::addGlobalScope('current_store', function (Builder $builder): void { + if (! app()->bound('current_store')) { + return; + } + + $store = app('current_store'); + + if (! $store instanceof Store) { + return; + } + + $builder->whereHas('option.product', function (Builder $query) use ($store): void { + $query + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()); + }); + }); + } + + /** + * @return BelongsTo + */ + public function option(): BelongsTo + { + return $this->belongsTo(ProductOption::class, 'product_option_id'); + } + + /** + * @return BelongsToMany + */ + public function variants(): BelongsToMany + { + return $this->belongsToMany(ProductVariant::class, 'variant_option_values', 'product_option_value_id', 'variant_id'); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'position' => 'integer', + ]; + } +} diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php new file mode 100644 index 00000000..080d998c --- /dev/null +++ b/app/Models/ProductVariant.php @@ -0,0 +1,173 @@ + */ + use HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'product_id', + 'sku', + 'barcode', + 'price_amount', + 'compare_at_amount', + 'currency', + 'weight_g', + 'requires_shipping', + 'is_default', + 'position', + 'status', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'price_amount' => 0, + 'currency' => 'USD', + 'requires_shipping' => true, + 'is_default' => false, + 'position' => 0, + 'status' => 'active', + ]; + + protected static function booted(): void + { + static::addGlobalScope('current_store', function (Builder $builder): void { + if (! app()->bound('current_store')) { + return; + } + + $store = app('current_store'); + + if (! $store instanceof Store) { + return; + } + + $builder->whereHas('product', function (Builder $query) use ($store): void { + $query + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()); + }); + }); + + static::saving(function (ProductVariant $variant): void { + $variant->assertSkuIsUniqueForStore(); + }); + + static::created(function (ProductVariant $variant): void { + if ($variant->inventoryItem()->exists()) { + return; + } + + $storeId = Product::withoutGlobalScopes() + ->whereKey($variant->product_id) + ->value('store_id'); + + if (! $storeId) { + return; + } + + InventoryItem::withoutGlobalScopes()->create([ + 'store_id' => $storeId, + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ]); + }); + } + + private function assertSkuIsUniqueForStore(): void + { + $sku = trim((string) $this->sku); + + if ($sku === '') { + return; + } + + $storeId = Product::withoutGlobalScopes() + ->whereKey($this->product_id) + ->value('store_id'); + + if ($storeId === null) { + return; + } + + $query = self::withoutGlobalScopes() + ->where('sku', $sku) + ->whereHas('product', function (Builder $query) use ($storeId): void { + $query + ->withoutGlobalScopes() + ->where('store_id', $storeId); + }); + + if ($this->exists) { + $query->whereKeyNot($this->getKey()); + } + + if ($query->exists()) { + throw new RuntimeException("The SKU [{$sku}] is already used in this store."); + } + } + + /** + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * @return HasOne + */ + public function inventoryItem(): HasOne + { + return $this->hasOne(InventoryItem::class, 'variant_id'); + } + + /** + * @return BelongsToMany + */ + public function optionValues(): BelongsToMany + { + return $this->belongsToMany(ProductOptionValue::class, 'variant_option_values', 'variant_id', 'product_option_value_id'); + } + + public function isPurchasable(): bool + { + return $this->status === VariantStatus::Active; + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'price_amount' => 'integer', + 'compare_at_amount' => 'integer', + 'weight_g' => 'integer', + 'requires_shipping' => 'bool', + 'is_default' => 'bool', + 'position' => 'integer', + 'status' => VariantStatus::class, + ]; + } +} diff --git a/app/Models/Refund.php b/app/Models/Refund.php new file mode 100644 index 00000000..dbeaa42a --- /dev/null +++ b/app/Models/Refund.php @@ -0,0 +1,61 @@ + */ + use HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'order_id', + 'payment_id', + 'amount', + 'reason', + 'status', + 'provider_refund_id', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'amount' => 0, + 'status' => 'pending', + ]; + + /** + * @return BelongsTo + */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** + * @return BelongsTo + */ + public function payment(): BelongsTo + { + return $this->belongsTo(Payment::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'amount' => 'integer', + 'status' => RefundStatus::class, + ]; + } +} diff --git a/app/Models/Scopes/StoreScope.php b/app/Models/Scopes/StoreScope.php new file mode 100644 index 00000000..6768fb8f --- /dev/null +++ b/app/Models/Scopes/StoreScope.php @@ -0,0 +1,26 @@ +bound('current_store')) { + return; + } + + $store = app('current_store'); + + if (! $store instanceof Store) { + return; + } + + $builder->where($model->qualifyColumn('store_id'), $store->getKey()); + } +} diff --git a/app/Models/SearchQuery.php b/app/Models/SearchQuery.php new file mode 100644 index 00000000..a24b0af4 --- /dev/null +++ b/app/Models/SearchQuery.php @@ -0,0 +1,55 @@ + */ + use BelongsToStore, HasFactory; + + public const UPDATED_AT = null; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'query', + 'filters_json', + 'results_count', + 'created_at', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'filters_json' => '{}', + 'results_count' => 0, + ]; + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @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..7231a6bf --- /dev/null +++ b/app/Models/SearchSettings.php @@ -0,0 +1,57 @@ + */ + use BelongsToStore, HasFactory; + + public $incrementing = false; + + public const CREATED_AT = null; + + protected $primaryKey = 'store_id'; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'synonyms_json', + 'stop_words_json', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'synonyms_json' => '[]', + 'stop_words_json' => '[]', + ]; + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'synonyms_json' => 'array', + 'stop_words_json' => 'array', + 'updated_at' => 'datetime', + ]; + } +} diff --git a/app/Models/ShippingRate.php b/app/Models/ShippingRate.php new file mode 100644 index 00000000..a51f8226 --- /dev/null +++ b/app/Models/ShippingRate.php @@ -0,0 +1,78 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'zone_id', + 'name', + 'type', + 'config_json', + 'is_active', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'type' => 'flat', + 'config_json' => '{}', + 'is_active' => true, + ]; + + protected static function booted(): void + { + static::addGlobalScope('current_store', function (Builder $builder): void { + if (! app()->bound('current_store')) { + return; + } + + $store = app('current_store'); + + if (! $store instanceof Store) { + return; + } + + $builder->whereHas('zone', function (Builder $query) use ($store): void { + $query + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()); + }); + }); + } + + /** + * @return BelongsTo + */ + public function zone(): BelongsTo + { + return $this->belongsTo(ShippingZone::class, 'zone_id'); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => ShippingRateType::class, + 'config_json' => 'array', + 'is_active' => 'bool', + ]; + } +} diff --git a/app/Models/ShippingZone.php b/app/Models/ShippingZone.php new file mode 100644 index 00000000..7c0416ae --- /dev/null +++ b/app/Models/ShippingZone.php @@ -0,0 +1,62 @@ + */ + use BelongsToStore, HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'name', + 'countries_json', + 'regions_json', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'countries_json' => '[]', + 'regions_json' => '[]', + ]; + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @return HasMany + */ + public function rates(): HasMany + { + return $this->hasMany(ShippingRate::class, 'zone_id'); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'countries_json' => 'array', + 'regions_json' => 'array', + ]; + } +} diff --git a/app/Models/Store.php b/app/Models/Store.php new file mode 100644 index 00000000..d7b7ec92 --- /dev/null +++ b/app/Models/Store.php @@ -0,0 +1,167 @@ + */ + use HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'organization_id', + 'name', + 'handle', + 'status', + 'default_currency', + 'default_locale', + 'timezone', + ]; + + /** + * @return BelongsTo + */ + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } + + /** + * @return HasMany + */ + public function domains(): HasMany + { + return $this->hasMany(StoreDomain::class); + } + + /** + * @return BelongsToMany + */ + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'store_users') + ->using(StoreUser::class) + ->withPivot('role'); + } + + /** + * @return HasOne + */ + public function settings(): HasOne + { + return $this->hasOne(StoreSettings::class); + } + + /** + * @return HasMany + */ + public function themes(): HasMany + { + return $this->hasMany(Theme::class); + } + + /** + * @return HasMany + */ + public function pages(): HasMany + { + return $this->hasMany(Page::class); + } + + /** + * @return HasMany + */ + public function navigationMenus(): HasMany + { + return $this->hasMany(NavigationMenu::class); + } + + /** + * @return HasMany + */ + public function carts(): HasMany + { + return $this->hasMany(Cart::class); + } + + /** + * @return HasMany + */ + public function checkouts(): HasMany + { + return $this->hasMany(Checkout::class); + } + + /** + * @return HasMany + */ + public function shippingZones(): HasMany + { + return $this->hasMany(ShippingZone::class); + } + + /** + * @return HasOne + */ + public function taxSettings(): HasOne + { + return $this->hasOne(TaxSettings::class); + } + + /** + * @return HasMany + */ + public function discounts(): HasMany + { + return $this->hasMany(Discount::class); + } + + /** + * @return HasMany + */ + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + /** + * @return HasMany + */ + public function appInstallations(): HasMany + { + return $this->hasMany(AppInstallation::class); + } + + /** + * @return HasMany + */ + public function webhookSubscriptions(): HasMany + { + return $this->hasMany(WebhookSubscription::class); + } + + public function isSuspended(): bool + { + return $this->status === StoreStatus::Suspended; + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => StoreStatus::class, + ]; + } +} diff --git a/app/Models/StoreDomain.php b/app/Models/StoreDomain.php new file mode 100644 index 00000000..e0d8022e --- /dev/null +++ b/app/Models/StoreDomain.php @@ -0,0 +1,46 @@ + */ + use HasFactory; + + public const UPDATED_AT = null; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'hostname', + 'type', + 'is_primary', + 'tls_mode', + ]; + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => StoreDomainType::class, + 'is_primary' => 'bool', + ]; + } +} diff --git a/app/Models/StoreSettings.php b/app/Models/StoreSettings.php new file mode 100644 index 00000000..4a5258b3 --- /dev/null +++ b/app/Models/StoreSettings.php @@ -0,0 +1,45 @@ + */ + use HasFactory; + + public $incrementing = false; + + public const CREATED_AT = null; + + protected $primaryKey = 'store_id'; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'settings_json', + ]; + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'settings_json' => 'array', + ]; + } +} diff --git a/app/Models/StoreUser.php b/app/Models/StoreUser.php new file mode 100644 index 00000000..09f8c713 --- /dev/null +++ b/app/Models/StoreUser.php @@ -0,0 +1,36 @@ + + */ + protected $fillable = [ + 'store_id', + 'user_id', + 'role', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'role' => StoreUserRole::class, + ]; + } +} diff --git a/app/Models/TaxSettings.php b/app/Models/TaxSettings.php new file mode 100644 index 00000000..fb6010ae --- /dev/null +++ b/app/Models/TaxSettings.php @@ -0,0 +1,62 @@ + */ + use BelongsToStore, HasFactory; + + public $incrementing = false; + + public $timestamps = false; + + protected $primaryKey = 'store_id'; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'mode', + 'provider', + 'prices_include_tax', + 'config_json', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'mode' => 'manual', + 'provider' => 'none', + 'prices_include_tax' => false, + 'config_json' => '{}', + ]; + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'mode' => TaxMode::class, + 'prices_include_tax' => 'bool', + 'config_json' => 'array', + ]; + } +} diff --git a/app/Models/Theme.php b/app/Models/Theme.php new file mode 100644 index 00000000..734b3a6c --- /dev/null +++ b/app/Models/Theme.php @@ -0,0 +1,75 @@ + */ + use BelongsToStore, HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'name', + 'version', + 'status', + 'published_at', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'status' => 'draft', + ]; + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @return HasMany + */ + public function files(): HasMany + { + return $this->hasMany(ThemeFile::class)->orderBy('path'); + } + + /** + * @return HasOne + */ + public function settings(): HasOne + { + return $this->hasOne(ThemeSettings::class); + } + + public function isPublished(): bool + { + return $this->status === ThemeStatus::Published && $this->published_at !== null; + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => ThemeStatus::class, + 'published_at' => 'datetime', + ]; + } +} diff --git a/app/Models/ThemeFile.php b/app/Models/ThemeFile.php new file mode 100644 index 00000000..8e74f28d --- /dev/null +++ b/app/Models/ThemeFile.php @@ -0,0 +1,73 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'theme_id', + 'path', + 'storage_key', + 'sha256', + 'byte_size', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'byte_size' => 0, + ]; + + protected static function booted(): void + { + static::addGlobalScope('current_store', function (Builder $builder): void { + if (! app()->bound('current_store')) { + return; + } + + $store = app('current_store'); + + if (! $store instanceof Store) { + return; + } + + $builder->whereHas('theme', function (Builder $query) use ($store): void { + $query + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()); + }); + }); + } + + /** + * @return BelongsTo + */ + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'byte_size' => 'integer', + ]; + } +} diff --git a/app/Models/ThemeSettings.php b/app/Models/ThemeSettings.php new file mode 100644 index 00000000..f275be06 --- /dev/null +++ b/app/Models/ThemeSettings.php @@ -0,0 +1,67 @@ + */ + use HasFactory; + + public $incrementing = false; + + public const CREATED_AT = null; + + protected $primaryKey = 'theme_id'; + + /** + * @var list + */ + protected $fillable = [ + 'theme_id', + 'settings_json', + ]; + + protected static function booted(): void + { + static::addGlobalScope('current_store', function (Builder $builder): void { + if (! app()->bound('current_store')) { + return; + } + + $store = app('current_store'); + + if (! $store instanceof Store) { + return; + } + + $builder->whereHas('theme', function (Builder $query) use ($store): void { + $query + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()); + }); + }); + } + + /** + * @return BelongsTo + */ + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'settings_json' => 'array', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 214bea4e..91ee8084 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,14 +2,17 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Enums\StoreUserRole; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; use Laravel\Fortify\TwoFactorAuthenticatable; -class User extends Authenticatable +class User extends Authenticatable implements MustVerifyEmail { /** @use HasFactory<\Database\Factories\UserFactory> */ use HasFactory, Notifiable, TwoFactorAuthenticatable; @@ -22,7 +25,10 @@ class User extends Authenticatable protected $fillable = [ 'name', 'email', + 'status', + 'is_platform_admin', 'password', + 'last_login_at', ]; /** @@ -31,12 +37,57 @@ class User extends Authenticatable * @var list */ protected $hidden = [ - 'password', + 'password_hash', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token', ]; + /** + * @return BelongsToMany + */ + public function stores(): BelongsToMany + { + return $this->belongsToMany(Store::class, 'store_users') + ->using(StoreUser::class) + ->withPivot('role'); + } + + public function roleForStore(Store $store): ?StoreUserRole + { + return $this->roleForStoreId($store->getKey()); + } + + public function roleForStoreId(int $storeId): ?StoreUserRole + { + $role = $this->stores() + ->whereKey($storeId) + ->first() + ?->pivot + ?->role; + + if ($role instanceof StoreUserRole) { + return $role; + } + + return is_string($role) ? StoreUserRole::tryFrom($role) : null; + } + + public function getAuthPassword(): ?string + { + return $this->password_hash; + } + + public function setPasswordAttribute(string $value): void + { + $this->attributes['password_hash'] = Hash::needsRehash($value) ? Hash::make($value) : $value; + } + + public function getPasswordAttribute(): ?string + { + return $this->password_hash; + } + /** * Get the attributes that should be cast. * @@ -46,7 +97,9 @@ protected function casts(): array { return [ 'email_verified_at' => 'datetime', - 'password' => 'hashed', + 'is_platform_admin' => 'boolean', + 'last_login_at' => 'datetime', + 'two_factor_confirmed_at' => 'datetime', ]; } diff --git a/app/Models/WebhookDelivery.php b/app/Models/WebhookDelivery.php new file mode 100644 index 00000000..43a5feaf --- /dev/null +++ b/app/Models/WebhookDelivery.php @@ -0,0 +1,56 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'subscription_id', + 'event_id', + 'attempt_count', + 'status', + 'last_attempt_at', + 'response_code', + 'response_body_snippet', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'attempt_count' => 1, + 'status' => 'pending', + ]; + + /** + * @return BelongsTo + */ + public function subscription(): BelongsTo + { + return $this->belongsTo(WebhookSubscription::class, 'subscription_id'); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => WebhookDeliveryStatus::class, + 'last_attempt_at' => 'datetime', + ]; + } +} diff --git a/app/Models/WebhookSubscription.php b/app/Models/WebhookSubscription.php new file mode 100644 index 00000000..8bd2a1c3 --- /dev/null +++ b/app/Models/WebhookSubscription.php @@ -0,0 +1,74 @@ + */ + use BelongsToStore, HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'app_installation_id', + 'event_type', + 'target_url', + 'signing_secret_encrypted', + 'status', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'status' => 'active', + ]; + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @return BelongsTo + */ + public function appInstallation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class); + } + + /** + * @return HasMany + */ + public function deliveries(): HasMany + { + return $this->hasMany(WebhookDelivery::class, 'subscription_id'); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'event_type' => WebhookEventType::class, + 'signing_secret_encrypted' => 'encrypted', + 'status' => WebhookSubscriptionStatus::class, + ]; + } +} diff --git a/app/Notifications/CustomerResetPassword.php b/app/Notifications/CustomerResetPassword.php new file mode 100644 index 00000000..80dc81ac --- /dev/null +++ b/app/Notifications/CustomerResetPassword.php @@ -0,0 +1,48 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + $resetUrl = route('customer.password.reset', [ + 'token' => $this->token, + 'email' => $notifiable->email, + ]); + + return (new MailMessage) + ->subject(__('Reset your :store password', ['store' => $this->store->name])) + ->line(__('You are receiving this email because we received a password reset request for your account.')) + ->action(__('Reset password'), $resetUrl) + ->line(__('This password reset link will expire in :count minutes.', [ + 'count' => config('auth.passwords.customers.expire', 60), + ])) + ->line(__('If you did not request a password reset, no further action is required.')); + } +} diff --git a/app/Observers/AuditModelObserver.php b/app/Observers/AuditModelObserver.php new file mode 100644 index 00000000..b7513c7b --- /dev/null +++ b/app/Observers/AuditModelObserver.php @@ -0,0 +1,112 @@ +, string> + */ + private array $resourceTypes = [ + Product::class => 'product', + Collection::class => 'collection', + Discount::class => 'discount', + Page::class => 'page', + Theme::class => 'theme', + Order::class => 'order', + Fulfillment::class => 'fulfillment', + Refund::class => 'refund', + NavigationMenu::class => 'navigation_menu', + ShippingZone::class => 'shipping_zone', + StoreSettings::class => 'store_settings', + TaxSettings::class => 'tax_setting', + ]; + + public function created(Model $model): void + { + $this->write($model, 'created'); + } + + public function updated(Model $model): void + { + $this->write($model, 'updated', $this->changes($model)); + } + + public function deleted(Model $model): void + { + $this->write($model, 'deleted'); + } + + public function restored(Model $model): void + { + $this->write($model, 'restored'); + } + + /** + * @param array|null $changes + */ + private function write(Model $model, string $action, ?array $changes = null): void + { + $resourceType = $this->resourceTypes[$model::class] ?? null; + + if ($resourceType === null) { + return; + } + + app(AuditLogger::class)->log( + event: $model instanceof StoreSettings && $action === 'updated' + ? 'store.settings_changed' + : "{$resourceType}.{$action}", + userId: auth()->id(), + storeId: $this->storeId($model), + resourceType: $resourceType, + resourceId: is_numeric($model->getKey()) ? (int) $model->getKey() : null, + changes: $changes, + ); + } + + /** + * @return array + */ + private function changes(Model $model): array + { + return collect($model->getChanges()) + ->except(['updated_at']) + ->mapWithKeys(fn (mixed $newValue, string $key): array => [ + $key => [$model->getOriginal($key), $newValue], + ]) + ->all(); + } + + private function storeId(Model $model): ?int + { + $storeId = $model->getAttribute('store_id'); + + if (is_numeric($storeId)) { + return (int) $storeId; + } + + if (app()->bound('current_store')) { + $store = app('current_store'); + + return $store instanceof \App\Models\Store ? $store->getKey() : null; + } + + return null; + } +} diff --git a/app/Observers/ProductObserver.php b/app/Observers/ProductObserver.php new file mode 100644 index 00000000..5e9d4d75 --- /dev/null +++ b/app/Observers/ProductObserver.php @@ -0,0 +1,47 @@ +syncProduct($product); + + ProductCreated::dispatch($product); + } + + public function updated(Product $product): void + { + app(SearchService::class)->syncProduct($product); + + if ($product->status === ProductStatus::Archived && $product->wasChanged('status')) { + ProductDeleted::dispatch($product); + + return; + } + + ProductUpdated::dispatch($product); + } + + public function deleted(Product $product): void + { + app(SearchService::class)->removeProduct($product->getKey()); + + ProductDeleted::dispatch($product); + } + + public function forceDeleted(Product $product): void + { + app(SearchService::class)->removeProduct($product->getKey()); + + ProductDeleted::dispatch($product); + } +} diff --git a/app/Policies/CollectionPolicy.php b/app/Policies/CollectionPolicy.php new file mode 100644 index 00000000..b2bb5ddd --- /dev/null +++ b/app/Policies/CollectionPolicy.php @@ -0,0 +1,37 @@ +isAnyRole($user); + } + + public function view(User $user, Collection $collection): bool + { + return $this->isAnyRole($user, $this->storeIdForModel($collection)); + } + + public function create(User $user): bool + { + return $this->isOwnerAdminOrStaff($user); + } + + public function update(User $user, Collection $collection): bool + { + return $this->isOwnerAdminOrStaff($user, $this->storeIdForModel($collection)); + } + + public function delete(User $user, Collection $collection): bool + { + return $this->isOwnerOrAdmin($user, $this->storeIdForModel($collection)); + } +} diff --git a/app/Policies/CustomerPolicy.php b/app/Policies/CustomerPolicy.php new file mode 100644 index 00000000..59562d33 --- /dev/null +++ b/app/Policies/CustomerPolicy.php @@ -0,0 +1,27 @@ +isAnyRole($user); + } + + public function view(User $user, Customer $customer): bool + { + return $this->isAnyRole($user, $this->storeIdForModel($customer)); + } + + public function update(User $user, Customer $customer): bool + { + return $this->isOwnerAdminOrStaff($user, $this->storeIdForModel($customer)); + } +} diff --git a/app/Policies/DiscountPolicy.php b/app/Policies/DiscountPolicy.php new file mode 100644 index 00000000..86fe2a12 --- /dev/null +++ b/app/Policies/DiscountPolicy.php @@ -0,0 +1,37 @@ +isAnyRole($user); + } + + public function view(User $user, Discount $discount): bool + { + return $this->isAnyRole($user, $this->storeIdForModel($discount)); + } + + public function create(User $user): bool + { + return $this->isOwnerAdminOrStaff($user); + } + + public function update(User $user, Discount $discount): bool + { + return $this->isOwnerAdminOrStaff($user, $this->storeIdForModel($discount)); + } + + public function delete(User $user, Discount $discount): bool + { + return $this->isOwnerOrAdmin($user, $this->storeIdForModel($discount)); + } +} diff --git a/app/Policies/FulfillmentPolicy.php b/app/Policies/FulfillmentPolicy.php new file mode 100644 index 00000000..17339af9 --- /dev/null +++ b/app/Policies/FulfillmentPolicy.php @@ -0,0 +1,27 @@ +isOwnerAdminOrStaff($user); + } + + 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->update($user, $fulfillment); + } +} diff --git a/app/Policies/OrderPolicy.php b/app/Policies/OrderPolicy.php new file mode 100644 index 00000000..1f433c05 --- /dev/null +++ b/app/Policies/OrderPolicy.php @@ -0,0 +1,42 @@ +isAnyRole($user); + } + + public function view(User $user, Order $order): bool + { + return $this->isAnyRole($user, $this->storeIdForModel($order)); + } + + public function update(User $user, Order $order): bool + { + return $this->isOwnerAdminOrStaff($user, $this->storeIdForModel($order)); + } + + public function cancel(User $user, Order $order): bool + { + return $this->isOwnerOrAdmin($user, $this->storeIdForModel($order)); + } + + public function createFulfillment(User $user, Order $order): bool + { + return $this->isOwnerAdminOrStaff($user, $this->storeIdForModel($order)); + } + + public function createRefund(User $user, Order $order): bool + { + return $this->isOwnerOrAdmin($user, $this->storeIdForModel($order)); + } +} diff --git a/app/Policies/PagePolicy.php b/app/Policies/PagePolicy.php new file mode 100644 index 00000000..5c178015 --- /dev/null +++ b/app/Policies/PagePolicy.php @@ -0,0 +1,37 @@ +isOwnerAdminOrStaff($user); + } + + public function view(User $user, Page $page): bool + { + return $this->isOwnerAdminOrStaff($user, $this->storeIdForModel($page)); + } + + public function create(User $user): bool + { + return $this->isOwnerAdminOrStaff($user); + } + + public function update(User $user, Page $page): bool + { + return $this->isOwnerAdminOrStaff($user, $this->storeIdForModel($page)); + } + + public function delete(User $user, Page $page): bool + { + return $this->isOwnerOrAdmin($user, $this->storeIdForModel($page)); + } +} diff --git a/app/Policies/ProductPolicy.php b/app/Policies/ProductPolicy.php new file mode 100644 index 00000000..54a4330e --- /dev/null +++ b/app/Policies/ProductPolicy.php @@ -0,0 +1,47 @@ +isAnyRole($user); + } + + public function view(User $user, Product $product): bool + { + return $this->isAnyRole($user, $this->storeIdForModel($product)); + } + + public function create(User $user): bool + { + return $this->isOwnerAdminOrStaff($user); + } + + public function update(User $user, Product $product): bool + { + return $this->isOwnerAdminOrStaff($user, $this->storeIdForModel($product)); + } + + public function delete(User $user, Product $product): bool + { + return $this->isOwnerOrAdmin($user, $this->storeIdForModel($product)); + } + + public function archive(User $user, Product $product): bool + { + return $this->delete($user, $product); + } + + public function restore(User $user, Product $product): bool + { + return $this->delete($user, $product); + } +} diff --git a/app/Policies/RefundPolicy.php b/app/Policies/RefundPolicy.php new file mode 100644 index 00000000..3f203d14 --- /dev/null +++ b/app/Policies/RefundPolicy.php @@ -0,0 +1,22 @@ +isOwnerOrAdmin($user); + } + + public function view(User $user, Refund $refund): bool + { + return $this->isOwnerOrAdmin($user, $this->storeIdForModel($refund)); + } +} diff --git a/app/Policies/StorePolicy.php b/app/Policies/StorePolicy.php new file mode 100644 index 00000000..716f66cf --- /dev/null +++ b/app/Policies/StorePolicy.php @@ -0,0 +1,27 @@ +isAnyRole($user, $store->getKey()); + } + + public function update(User $user, Store $store): bool + { + return $this->isOwnerOrAdmin($user, $store->getKey()); + } + + public function delete(User $user, Store $store): bool + { + return $this->isOwnerOnly($user, $store->getKey()); + } +} diff --git a/app/Policies/ThemePolicy.php b/app/Policies/ThemePolicy.php new file mode 100644 index 00000000..2bbb03c2 --- /dev/null +++ b/app/Policies/ThemePolicy.php @@ -0,0 +1,42 @@ +isOwnerOrAdmin($user); + } + + public function view(User $user, Theme $theme): bool + { + return $this->isOwnerOrAdmin($user, $this->storeIdForModel($theme)); + } + + public function create(User $user): bool + { + return $this->isOwnerOrAdmin($user); + } + + public function update(User $user, Theme $theme): bool + { + return $this->isOwnerOrAdmin($user, $this->storeIdForModel($theme)); + } + + public function publish(User $user, Theme $theme): bool + { + return $this->update($user, $theme); + } + + public function delete(User $user, Theme $theme): bool + { + return $this->isOwnerOrAdmin($user, $this->storeIdForModel($theme)); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8a29e6f5..6f30dfb1 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,11 +2,65 @@ namespace App\Providers; +use App\Auth\CustomerUserProvider; +use App\Contracts\PaymentProvider; +use App\Events\CheckoutCompleted; +use App\Events\FulfillmentCreated; +use App\Events\FulfillmentDelivered; +use App\Events\FulfillmentShipped; +use App\Events\OrderCancelled; +use App\Events\OrderCreated; +use App\Events\OrderPaid; +use App\Events\OrderRefunded; +use App\Events\ProductCreated; +use App\Events\ProductDeleted; +use App\Events\ProductUpdated; +use App\Http\Middleware\CheckStoreRole; +use App\Http\Middleware\EnsureUserEmailIsVerified; +use App\Http\Middleware\ResolveStore; +use App\Listeners\DispatchWebhooks; +use App\Models\Collection as ProductCollection; +use App\Models\Customer; +use App\Models\Discount; +use App\Models\Fulfillment; +use App\Models\NavigationMenu; +use App\Models\Order; +use App\Models\Page; +use App\Models\PersonalAccessToken; +use App\Models\Product; +use App\Models\Refund; +use App\Models\ShippingZone; +use App\Models\Store; +use App\Models\StoreSettings; +use App\Models\TaxSettings; +use App\Models\Theme; +use App\Models\User; +use App\Observers\AuditModelObserver; +use App\Observers\ProductObserver; +use App\Services\AnalyticsService; +use App\Services\AuditLogger; +use App\Services\NavigationService; +use App\Services\Payments\MockPaymentProvider; +use App\Services\SearchService; +use App\Services\ThemeSettingsService; +use App\Services\WebhookService; use Carbon\CarbonImmutable; +use Illuminate\Auth\Events\Failed as AuthFailed; +use Illuminate\Auth\Events\Login as AuthLogin; +use Illuminate\Auth\Events\Logout as AuthLogout; +use Illuminate\Auth\Middleware\Authenticate; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; +use Illuminate\View\View as ViewInstance; +use Livewire\Livewire; class AppServiceProvider extends ServiceProvider { @@ -15,7 +69,41 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->bind(PaymentProvider::class, MockPaymentProvider::class); + + Auth::provider('store_scoped_eloquent', function ($app, array $config): CustomerUserProvider { + return new CustomerUserProvider($app['hash'], $config['model']); + }); + + Auth::viaRequest('sanctum-compatible', function (Request $request): ?User { + app()->forgetInstance('sanctum_personal_access_token'); + + $plainTextToken = $request->bearerToken(); + + if (! is_string($plainTextToken) || $plainTextToken === '') { + return null; + } + + $token = PersonalAccessToken::query() + ->with('tokenable') + ->where('token', hash('sha256', $this->plainTokenForHashing($plainTextToken))) + ->first(); + + if (! $token instanceof PersonalAccessToken || $token->isExpired() || ! $token->tokenable instanceof User) { + return null; + } + + $request->attributes->set('sanctum_personal_access_token', $token); + app()->instance('sanctum_personal_access_token', $token); + + return $token->tokenable; + }); + + $this->app->singleton(ThemeSettingsService::class); + $this->app->singleton(NavigationService::class); + $this->app->singleton(SearchService::class); + $this->app->singleton(AnalyticsService::class); + $this->app->singleton(WebhookService::class); } /** @@ -24,6 +112,9 @@ public function register(): void public function boot(): void { $this->configureDefaults(); + $this->configureEventListeners(); + $this->configureLivewireMiddleware(); + $this->configureStorefrontViewData(); } /** @@ -46,5 +137,160 @@ protected function configureDefaults(): void ->uncompromised() : null ); + + RateLimiter::for('api.storefront', function (Request $request): Limit { + return Limit::perMinute(120)->by($request->ip()); + }); + + RateLimiter::for('api.admin', function (Request $request): Limit { + $token = $request->attributes->get('sanctum_personal_access_token'); + + return Limit::perMinute(60)->by(match (true) { + $token instanceof PersonalAccessToken => 'token:'.$token->getKey(), + $request->user() instanceof User => 'user:'.$request->user()->getAuthIdentifier(), + default => 'ip:'.$request->ip(), + }); + }); + + RateLimiter::for('checkout', function (Request $request): Limit { + $sessionId = $request->hasSession() ? $request->session()->getId() : null; + + return Limit::perMinute(10)->by($sessionId ?: $request->ip()); + }); + + RateLimiter::for('search', function (Request $request): Limit { + return Limit::perMinute(30)->by($request->ip()); + }); + + RateLimiter::for('analytics', function (Request $request): Limit { + return Limit::perMinute(60)->by($request->ip()); + }); + + RateLimiter::for('webhooks', function (Request $request): Limit { + return Limit::perMinute(100)->by($request->ip()); + }); + + Product::observe(ProductObserver::class); + foreach ([ + Product::class, + ProductCollection::class, + Discount::class, + Page::class, + Theme::class, + Order::class, + Fulfillment::class, + Refund::class, + NavigationMenu::class, + ShippingZone::class, + StoreSettings::class, + TaxSettings::class, + ] as $model) { + $model::observe(AuditModelObserver::class); + } + + Authenticate::redirectUsing(function (Request $request): string { + if ($request->is('admin*')) { + return route('admin.login'); + } + + if ($request->is('account*')) { + return route('account.login'); + } + + return route('login'); + }); + } + + protected function configureStorefrontViewData(): void + { + View::composer('layouts.storefront', function (ViewInstance $view): void { + $store = app()->bound('current_store') ? app('current_store') : null; + + if (! $store instanceof Store) { + $view->with([ + 'themeSettings' => [], + 'mainNavigation' => [], + 'footerNavigation' => [], + ]); + + return; + } + + $themeSettings = app(ThemeSettingsService::class)->forStore($store); + $navigation = app(NavigationService::class); + + $view->with([ + 'themeSettings' => $themeSettings, + 'mainNavigation' => $navigation->forHandle($store, data_get($themeSettings, 'header.main_menu', 'main-menu')), + 'footerNavigation' => $navigation->forHandle($store, data_get($themeSettings, 'footer.menu', 'footer-menu')), + ]); + }); + } + + protected function configureEventListeners(): void + { + Event::listen(AuthLogin::class, function (AuthLogin $event): void { + $user = $event->user; + + if ($user instanceof User) { + app(AuditLogger::class)->log('auth.login', userId: $user->getKey()); + } + + if ($user instanceof Customer) { + app(AuditLogger::class)->log('customer.login', storeId: (int) $user->store_id, extra: [ + 'customer_id' => $user->getKey(), + ]); + } + }); + + Event::listen(AuthFailed::class, function (AuthFailed $event): void { + $credentials = $event->credentials; + $email = is_string($credentials['email'] ?? null) ? $credentials['email'] : null; + + app(AuditLogger::class)->log( + event: $event->guard === 'customer' ? 'customer.failed_login' : 'auth.failed_login', + extra: ['email' => $email], + ); + }); + + Event::listen(AuthLogout::class, function (AuthLogout $event): void { + if ($event->user instanceof User) { + app(AuditLogger::class)->log('auth.logout', userId: $event->user->getKey()); + } + }); + + foreach ([ + OrderCreated::class, + OrderPaid::class, + OrderCancelled::class, + OrderRefunded::class, + CheckoutCompleted::class, + FulfillmentCreated::class, + FulfillmentShipped::class, + FulfillmentDelivered::class, + ProductCreated::class, + ProductUpdated::class, + ProductDeleted::class, + ] as $event) { + Event::listen($event, DispatchWebhooks::class); + } + } + + protected function configureLivewireMiddleware(): void + { + Livewire::addPersistentMiddleware([ + EnsureUserEmailIsVerified::class, + ResolveStore::class, + CheckStoreRole::class, + ]); + } + + private function plainTokenForHashing(string $plainTextToken): string + { + if (str_contains($plainTextToken, '|')) { + return (string) str($plainTextToken)->after('|'); + } + + return $plainTextToken; } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 44e57aa0..e506c96f 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 @@ -64,9 +63,7 @@ private function configureRateLimiting(): void }); RateLimiter::for('login', function (Request $request) { - $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip()); - - return Limit::perMinute(5)->by($throttleKey); + return Limit::perMinute(5)->by($request->ip()); }); } } diff --git a/app/Services/AnalyticsService.php b/app/Services/AnalyticsService.php new file mode 100644 index 00000000..224c6d88 --- /dev/null +++ b/app/Services/AnalyticsService.php @@ -0,0 +1,157 @@ + $properties + */ + public function track( + Store $store, + string $type, + array $properties = [], + ?string $sessionId = null, + ?int $customerId = null, + ?string $clientEventId = null, + ?CarbonInterface $occurredAt = null, + ): bool { + if ($clientEventId !== null && AnalyticsEvent::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('client_event_id', $clientEventId) + ->exists()) { + return false; + } + + AnalyticsEvent::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + 'type' => AnalyticsEventType::from($type), + 'session_id' => $sessionId, + 'customer_id' => $customerId, + 'properties_json' => $properties, + 'client_event_id' => $clientEventId, + 'occurred_at' => $occurredAt ?? now(), + 'created_at' => now(), + ]); + + return true; + } + + /** + * @param list> $events + * @return array{accepted: int, rejected: int} + */ + public function trackBatch(Store $store, array $events): array + { + $accepted = 0; + $rejected = 0; + + foreach ($events as $event) { + $tracked = $this->track( + $store, + (string) $event['type'], + $event['properties'] ?? [], + $event['session_id'] ?? null, + $event['customer_id'] ?? null, + $event['client_event_id'] ?? null, + isset($event['occurred_at']) ? Carbon::parse($event['occurred_at']) : null, + ); + + $tracked ? $accepted++ : $rejected++; + } + + return [ + 'accepted' => $accepted, + 'rejected' => $rejected, + ]; + } + + public function getDailyMetrics(Store $store, string $startDate, string $endDate): Collection + { + return AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->whereBetween('date', [$startDate, $endDate]) + ->orderBy('date') + ->get(); + } + + public function aggregate(?CarbonInterface $date = null): int + { + $date = ($date ? Carbon::parse($date->toDateString(), 'UTC') : now('UTC')->subDay())->startOfDay(); + $start = $date->copy()->startOfDay(); + $end = $date->copy()->endOfDay(); + $dateString = $date->toDateString(); + + $storeIds = AnalyticsEvent::withoutGlobalScopes() + ->whereBetween('occurred_at', [$start, $end]) + ->distinct() + ->pluck('store_id'); + + $rows = 0; + + foreach ($storeIds as $storeId) { + $events = AnalyticsEvent::withoutGlobalScopes() + ->where('store_id', $storeId) + ->whereBetween('occurred_at', [$start, $end]) + ->get(); + + $checkoutCompleted = $events->where('type', AnalyticsEventType::CheckoutCompleted); + $ordersCount = $checkoutCompleted->count(); + $revenue = (int) $checkoutCompleted->sum(fn (AnalyticsEvent $event): int => (int) data_get($event->properties_json, 'total_amount', 0)); + + DB::table('analytics_daily')->updateOrInsert( + [ + 'store_id' => (int) $storeId, + 'date' => $dateString, + ], + [ + 'orders_count' => $ordersCount, + 'revenue_amount' => $revenue, + 'aov_amount' => $ordersCount > 0 ? intdiv($revenue, $ordersCount) : 0, + 'visits_count' => $events + ->where('type', AnalyticsEventType::PageView) + ->pluck('session_id') + ->filter() + ->unique() + ->count(), + 'add_to_cart_count' => $events->where('type', AnalyticsEventType::AddToCart)->count(), + 'checkout_started_count' => $events->where('type', AnalyticsEventType::CheckoutStarted)->count(), + 'checkout_completed_count' => $ordersCount, + ], + ); + + $rows++; + } + + return $rows; + } + + /** + * @return array{orders_count: int, revenue_amount: int, aov_amount: int, visits_count: int, add_to_cart_count: int, checkout_started_count: int, checkout_completed_count: int} + */ + public function totals(Store $store, string $startDate, string $endDate): array + { + $metrics = $this->getDailyMetrics($store, $startDate, $endDate); + $orders = (int) $metrics->sum('orders_count'); + $revenue = (int) $metrics->sum('revenue_amount'); + + return [ + 'orders_count' => $orders, + 'revenue_amount' => $revenue, + 'aov_amount' => $orders > 0 ? intdiv($revenue, $orders) : 0, + 'visits_count' => (int) $metrics->sum('visits_count'), + 'add_to_cart_count' => (int) $metrics->sum('add_to_cart_count'), + 'checkout_started_count' => (int) $metrics->sum('checkout_started_count'), + 'checkout_completed_count' => (int) $metrics->sum('checkout_completed_count'), + ]; + } +} diff --git a/app/Services/AuditLogger.php b/app/Services/AuditLogger.php new file mode 100644 index 00000000..a626e030 --- /dev/null +++ b/app/Services/AuditLogger.php @@ -0,0 +1,38 @@ +|null $changes + * @param array $extra + */ + public function log( + string $event, + ?int $userId = null, + ?int $storeId = null, + ?string $resourceType = null, + ?int $resourceId = null, + ?array $changes = null, + array $extra = [], + ): void { + $request = request(); + $entry = array_filter([ + 'timestamp' => now()->toIso8601String(), + 'event' => $event, + 'user_id' => $userId, + 'store_id' => $storeId, + 'resource_type' => $resourceType, + 'resource_id' => $resourceId, + 'ip' => $request->ip() ?? 'console', + 'user_agent' => (string) $request->userAgent(), + 'changes' => $changes, + ...$extra, + ], fn (mixed $value): bool => $value !== null); + + Log::channel('audit')->info((string) json_encode($entry, JSON_UNESCAPED_SLASHES)); + } +} diff --git a/app/Services/CartService.php b/app/Services/CartService.php new file mode 100644 index 00000000..30a5a601 --- /dev/null +++ b/app/Services/CartService.php @@ -0,0 +1,314 @@ +create([ + 'store_id' => $store->getKey(), + 'customer_id' => $customer?->getKey(), + 'currency' => $store->default_currency, + 'cart_version' => 1, + 'status' => CartStatus::Active, + ]); + } + + public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart + { + $sessionCartId = session('cart_id'); + + if ($customer instanceof Customer) { + $customerCart = Cart::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('customer_id', $customer->getKey()) + ->where('status', CartStatus::Active) + ->latest('id') + ->first() ?? $this->create($store, $customer); + + if ($sessionCartId) { + $guestCart = Cart::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->whereNull('customer_id') + ->where('status', CartStatus::Active) + ->find($sessionCartId); + + if ($guestCart instanceof Cart && $guestCart->isNot($customerCart)) { + $customerCart = $this->mergeOnLogin($guestCart, $customerCart); + } + + session()->forget('cart_id'); + } + + return $customerCart->refresh(); + } + + if ($sessionCartId) { + $cart = Cart::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->whereNull('customer_id') + ->where('status', CartStatus::Active) + ->find($sessionCartId); + + if ($cart instanceof Cart) { + return $cart; + } + } + + $cart = $this->create($store); + session(['cart_id' => $cart->getKey()]); + + return $cart; + } + + public function currentForSession(Store $store, ?Customer $customer = null): ?Cart + { + if ($customer instanceof Customer) { + $customerCart = Cart::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('customer_id', $customer->getKey()) + ->where('status', CartStatus::Active) + ->latest('id') + ->first(); + + if ($customerCart instanceof Cart) { + return $customerCart; + } + } + + $sessionCartId = session('cart_id'); + + if (! $sessionCartId) { + return null; + } + + return Cart::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('status', CartStatus::Active) + ->where(function ($query) use ($customer): void { + if ($customer instanceof Customer) { + $query + ->whereNull('customer_id') + ->orWhere('customer_id', $customer->getKey()); + + return; + } + + $query->whereNull('customer_id'); + }) + ->find($sessionCartId); + } + + public function addLine(Cart $cart, int $variantId, int $quantity, ?int $expectedVersion = null): CartLine + { + $this->assertPositiveQuantity($quantity); + + return DB::transaction(function () use ($cart, $variantId, $quantity, $expectedVersion): CartLine { + $lockedCart = $this->freshCart($cart); + $this->assertExpectedVersion($lockedCart, $expectedVersion); + $this->assertCartIsActive($lockedCart); + + $variant = $this->purchasableVariant($lockedCart, $variantId); + $line = CartLine::withoutGlobalScopes() + ->where('cart_id', $lockedCart->getKey()) + ->where('variant_id', $variant->getKey()) + ->first(); + $newQuantity = ($line?->quantity ?? 0) + $quantity; + + $this->assertAvailable($variant->inventoryItem, $newQuantity); + + if (! $line instanceof CartLine) { + $line = new CartLine([ + 'cart_id' => $lockedCart->getKey(), + 'variant_id' => $variant->getKey(), + 'unit_price_amount' => $variant->price_amount, + ]); + } + + $this->fillLineAmounts($line, $newQuantity, $line->unit_price_amount ?: $variant->price_amount); + $line->save(); + $this->incrementVersion($lockedCart); + + return $line->refresh(); + }); + } + + public function updateLineQuantity(Cart $cart, int $lineId, int $quantity, ?int $expectedVersion = null): ?CartLine + { + if ($quantity <= 0) { + $this->removeLine($cart, $lineId, $expectedVersion); + + return null; + } + + return DB::transaction(function () use ($cart, $lineId, $quantity, $expectedVersion): CartLine { + $lockedCart = $this->freshCart($cart); + $this->assertExpectedVersion($lockedCart, $expectedVersion); + $this->assertCartIsActive($lockedCart); + + $line = CartLine::withoutGlobalScopes() + ->where('cart_id', $lockedCart->getKey()) + ->findOrFail($lineId); + $variant = $this->purchasableVariant($lockedCart, $line->variant_id); + + $this->assertAvailable($variant->inventoryItem, $quantity); + $this->fillLineAmounts($line, $quantity, $line->unit_price_amount); + $line->save(); + $this->incrementVersion($lockedCart); + + return $line->refresh(); + }); + } + + public function removeLine(Cart $cart, int $lineId, ?int $expectedVersion = null): void + { + DB::transaction(function () use ($cart, $lineId, $expectedVersion): void { + $lockedCart = $this->freshCart($cart); + $this->assertExpectedVersion($lockedCart, $expectedVersion); + $this->assertCartIsActive($lockedCart); + + CartLine::withoutGlobalScopes() + ->where('cart_id', $lockedCart->getKey()) + ->whereKey($lineId) + ->delete(); + + $this->incrementVersion($lockedCart); + }); + } + + public function mergeOnLogin(Cart $guest, Cart $customer): Cart + { + return DB::transaction(function () use ($guest, $customer): Cart { + $guestCart = $this->freshCart($guest); + $customerCart = $this->freshCart($customer); + + CartLine::withoutGlobalScopes() + ->where('cart_id', $guestCart->getKey()) + ->get() + ->each(function (CartLine $guestLine) use ($customerCart): void { + $existingLine = CartLine::withoutGlobalScopes() + ->where('cart_id', $customerCart->getKey()) + ->where('variant_id', $guestLine->variant_id) + ->first(); + + if ($existingLine instanceof CartLine) { + $quantity = max($existingLine->quantity, $guestLine->quantity); + $this->fillLineAmounts($existingLine, $quantity, $existingLine->unit_price_amount); + $existingLine->save(); + $guestLine->delete(); + + return; + } + + $guestLine->forceFill(['cart_id' => $customerCart->getKey()])->save(); + }); + + $guestCart->forceFill(['status' => CartStatus::Abandoned])->save(); + $this->incrementVersion($customerCart); + + return $customerCart->refresh(); + }); + } + + private function freshCart(Cart $cart): Cart + { + return Cart::withoutGlobalScopes() + ->whereKey($cart->getKey()) + ->lockForUpdate() + ->firstOrFail(); + } + + private function purchasableVariant(Cart $cart, int $variantId): ProductVariant + { + $variant = ProductVariant::withoutGlobalScopes() + ->with([ + 'product' => fn ($query) => $query->withoutGlobalScopes(), + 'inventoryItem' => fn ($query) => $query->withoutGlobalScopes(), + ]) + ->findOrFail($variantId); + + if ((int) $variant->product->store_id !== (int) $cart->store_id) { + throw InvalidCartOperationException::because('Variant does not belong to this store.'); + } + + if ($variant->product->status !== ProductStatus::Active || $variant->product->published_at === null) { + throw InvalidCartOperationException::because('Product is not active.'); + } + + if ($variant->status !== VariantStatus::Active) { + throw InvalidCartOperationException::because('Variant is not active.'); + } + + if (! $variant->inventoryItem instanceof InventoryItem) { + throw InvalidCartOperationException::because('Variant inventory is missing.'); + } + + return $variant; + } + + private function assertAvailable(InventoryItem $item, int $quantity): void + { + if (! $this->inventory->checkAvailability($item, $quantity)) { + throw \App\Exceptions\InsufficientInventoryException::forQuantity($item->availableQuantity(), $quantity); + } + } + + private function fillLineAmounts(CartLine $line, int $quantity, int $unitPrice): void + { + $subtotal = $unitPrice * $quantity; + + $line->forceFill([ + 'quantity' => $quantity, + 'unit_price_amount' => $unitPrice, + 'line_subtotal_amount' => $subtotal, + 'line_discount_amount' => 0, + 'line_total_amount' => $subtotal, + ]); + } + + private function incrementVersion(Cart $cart): void + { + $cart->forceFill([ + 'cart_version' => $cart->cart_version + 1, + ])->save(); + } + + private function assertExpectedVersion(Cart $cart, ?int $expectedVersion): void + { + if ($expectedVersion !== null && $expectedVersion !== $cart->cart_version) { + throw new CartVersionMismatchException($expectedVersion, $cart->cart_version); + } + } + + private function assertCartIsActive(Cart $cart): void + { + if ($cart->status !== CartStatus::Active) { + throw InvalidCartOperationException::because('Cart is not active.'); + } + } + + private function assertPositiveQuantity(int $quantity): void + { + if ($quantity <= 0) { + throw InvalidCartOperationException::because('Cart line quantity must be greater than zero.'); + } + } +} diff --git a/app/Services/CheckoutService.php b/app/Services/CheckoutService.php new file mode 100644 index 00000000..10373b66 --- /dev/null +++ b/app/Services/CheckoutService.php @@ -0,0 +1,238 @@ +whereKey($cart->getKey()) + ->lockForUpdate() + ->firstOrFail(); + + if ($cart->status !== CartStatus::Active) { + throw InvalidCheckoutTransitionException::because('Checkout can only start from an active cart.'); + } + + if (! CartLine::withoutGlobalScopes()->where('cart_id', $cart->getKey())->exists()) { + throw InvalidCheckoutTransitionException::because('Checkout cannot start from an empty cart.'); + } + + $existingCheckout = Checkout::withoutGlobalScopes() + ->where('cart_id', $cart->getKey()) + ->whereNotIn('status', [CheckoutStatus::Completed->value, CheckoutStatus::Expired->value]) + ->latest('id') + ->lockForUpdate() + ->first(); + + if ($existingCheckout instanceof Checkout) { + return $existingCheckout; + } + + return Checkout::withoutGlobalScopes()->create([ + 'store_id' => $cart->store_id, + 'cart_id' => $cart->getKey(), + 'customer_id' => $customer?->getKey() ?? $cart->customer_id, + 'status' => CheckoutStatus::Started, + ]); + }); + } + + /** + * @param array $addressData + */ + public function setAddress(Checkout $checkout, array $addressData): Checkout + { + return DB::transaction(function () use ($checkout, $addressData): Checkout { + $checkout = $this->freshCheckout($checkout); + $this->assertStatus($checkout, [CheckoutStatus::Started, CheckoutStatus::Addressed]); + + $email = (string) data_get($addressData, 'email'); + $shippingAddress = data_get($addressData, 'shipping_address', $addressData); + + Validator::make([ + 'email' => $email, + 'shipping_address' => $shippingAddress, + ], [ + 'email' => ['required', 'email'], + 'shipping_address.first_name' => ['required', 'string'], + 'shipping_address.last_name' => ['required', 'string'], + 'shipping_address.address1' => ['required', 'string'], + 'shipping_address.city' => ['required', 'string'], + 'shipping_address.country' => ['required', 'string', 'size:2'], + 'shipping_address.postal_code' => ['required', 'string'], + ])->validate(); + + $checkout->forceFill([ + 'email' => $email, + 'shipping_address_json' => $shippingAddress, + 'billing_address_json' => data_get($addressData, 'billing_address', $shippingAddress), + 'status' => CheckoutStatus::Addressed, + ])->save(); + + $this->pricing->calculate($checkout); + + return $checkout->refresh(); + }); + } + + public function setShippingMethod(Checkout $checkout, ?int $shippingRateId): Checkout + { + return DB::transaction(function () use ($checkout, $shippingRateId): Checkout { + $checkout = $this->freshCheckout($checkout); + $this->assertStatus($checkout, [CheckoutStatus::Addressed, CheckoutStatus::ShippingSelected]); + + if (! $this->shipping->requiresShipping($checkout->cart)) { + $checkout->forceFill([ + 'shipping_method_id' => null, + 'status' => CheckoutStatus::ShippingSelected, + ])->save(); + + $this->pricing->calculate($checkout); + + return $checkout->refresh(); + } + + $availableRates = $this->shipping->getAvailableRates($checkout->store, $checkout->shipping_address_json ?? []); + + if ($availableRates->isEmpty()) { + throw UnserviceableShippingAddressException::forAddress(); + } + + $rate = $availableRates->firstWhere('id', $shippingRateId); + + if (! $rate instanceof ShippingRate) { + throw InvalidCheckoutTransitionException::because('Selected shipping rate is not available for this address.'); + } + + $checkout->forceFill([ + 'shipping_method_id' => $rate->getKey(), + 'status' => CheckoutStatus::ShippingSelected, + ])->save(); + + $this->pricing->calculate($checkout); + + return $checkout->refresh(); + }); + } + + public function selectPaymentMethod(Checkout $checkout, string $paymentMethod): Checkout + { + return DB::transaction(function () use ($checkout, $paymentMethod): Checkout { + $checkout = $this->freshCheckout($checkout); + + if ($checkout->status === CheckoutStatus::PaymentSelected) { + return $checkout; + } + + $this->assertStatus($checkout, [CheckoutStatus::ShippingSelected]); + + if (! in_array($paymentMethod, ['credit_card', 'paypal', 'bank_transfer'], true)) { + throw InvalidCheckoutTransitionException::because('Payment method is invalid.'); + } + + if ($checkout->totals_json === null) { + $this->pricing->calculate($checkout); + } + + CartLine::withoutGlobalScopes() + ->where('cart_id', $checkout->cart_id) + ->get() + ->each(function (CartLine $line): void { + $inventory = InventoryItem::withoutGlobalScopes() + ->where('variant_id', $line->variant_id) + ->firstOrFail(); + + $this->inventory->reserve($inventory, $line->quantity); + }); + + $checkout->forceFill([ + 'payment_method' => $paymentMethod, + 'status' => CheckoutStatus::PaymentSelected, + 'expires_at' => now()->addDay(), + ])->save(); + + return $checkout->refresh(); + }); + } + + public function expireCheckout(Checkout $checkout): Checkout + { + return DB::transaction(function () use ($checkout): Checkout { + $checkout = $this->freshCheckout($checkout); + + if (in_array($checkout->status, [CheckoutStatus::Completed, CheckoutStatus::Expired], true)) { + return $checkout; + } + + if ($checkout->status === CheckoutStatus::PaymentSelected) { + CartLine::withoutGlobalScopes() + ->where('cart_id', $checkout->cart_id) + ->get() + ->each(function (CartLine $line): void { + $inventory = InventoryItem::withoutGlobalScopes() + ->where('variant_id', $line->variant_id) + ->firstOrFail(); + + $this->inventory->release($inventory, $line->quantity); + }); + } + + $checkout->forceFill([ + 'status' => CheckoutStatus::Expired, + ])->save(); + + return $checkout->refresh(); + }); + } + + /** + * @param array $paymentMethodData + */ + public function completeCheckout(Checkout $checkout, array $paymentMethodData = []): Order + { + return $this->orders->createFromCheckout($checkout, $paymentMethodData); + } + + private function freshCheckout(Checkout $checkout): Checkout + { + return Checkout::withoutGlobalScopes() + ->with(['cart', 'store']) + ->whereKey($checkout->getKey()) + ->lockForUpdate() + ->firstOrFail(); + } + + /** + * @param array $allowed + */ + private function assertStatus(Checkout $checkout, array $allowed): void + { + if (! in_array($checkout->status, $allowed, true)) { + throw InvalidCheckoutTransitionException::because("Checkout cannot transition from {$checkout->status->value}."); + } + } +} diff --git a/app/Services/CustomerPasswordResetService.php b/app/Services/CustomerPasswordResetService.php new file mode 100644 index 00000000..cd8b494f --- /dev/null +++ b/app/Services/CustomerPasswordResetService.php @@ -0,0 +1,130 @@ +normalizeEmail($email); + $rateLimitKey = $this->rateLimitKey($store, $email); + + if (RateLimiter::tooManyAttempts($rateLimitKey, 1)) { + return; + } + + RateLimiter::hit($rateLimitKey, $this->throttleSeconds()); + + $customer = $this->findCustomer($store, $email); + + if (! $customer instanceof Customer) { + return; + } + + $token = Str::random(64); + + DB::table($this->table())->updateOrInsert( + [ + 'store_id' => $store->getKey(), + 'email' => $customer->email, + ], + [ + 'token' => Hash::make($token), + 'created_at' => now(), + ], + ); + + $customer->notify(new CustomerResetPassword($token, $store)); + } + + public function reset(Store $store, string $email, string $token, string $password): bool + { + $email = $this->normalizeEmail($email); + + $tokenRecord = DB::table($this->table()) + ->where('store_id', $store->getKey()) + ->whereRaw('lower(email) = ?', [$email]) + ->first(); + + if (! $tokenRecord || $this->tokenExpired($tokenRecord->created_at) || ! Hash::check($token, $tokenRecord->token)) { + return false; + } + + $customer = $this->findCustomer($store, $tokenRecord->email); + + if (! $customer instanceof Customer) { + $this->deleteToken($store, $tokenRecord->email); + + return false; + } + + $customer->forceFill([ + 'password' => $password, + ])->save(); + + $this->deleteToken($store, $tokenRecord->email); + + event(new \Illuminate\Auth\Events\PasswordReset($customer)); + + return true; + } + + private function findCustomer(Store $store, string $email): ?Customer + { + return Customer::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->whereRaw('lower(email) = ?', [$this->normalizeEmail($email)]) + ->first(); + } + + private function deleteToken(Store $store, string $email): void + { + DB::table($this->table()) + ->where('store_id', $store->getKey()) + ->whereRaw('lower(email) = ?', [$this->normalizeEmail($email)]) + ->delete(); + } + + private function normalizeEmail(string $email): string + { + return Str::lower(trim($email)); + } + + private function tokenExpired(?string $createdAt): bool + { + if (! $createdAt) { + return true; + } + + return Carbon::parse($createdAt)->addMinutes($this->expireMinutes())->isPast(); + } + + private function rateLimitKey(Store $store, string $email): string + { + return 'customer-password-reset:'.$store->getKey().':'.sha1($email); + } + + private function table(): string + { + return config('auth.passwords.customers.table', 'customer_password_reset_tokens'); + } + + private function expireMinutes(): int + { + return (int) config('auth.passwords.customers.expire', 60); + } + + private function throttleSeconds(): int + { + return (int) config('auth.passwords.customers.throttle', 60); + } +} diff --git a/app/Services/DiscountService.php b/app/Services/DiscountService.php new file mode 100644 index 00000000..6821970f --- /dev/null +++ b/app/Services/DiscountService.php @@ -0,0 +1,248 @@ +where('store_id', $store->getKey()) + ->where('type', DiscountType::Code) + ->whereRaw('lower(code) = ?', [$normalizedCode]) + ->first(); + + if (! $discount instanceof Discount) { + throw InvalidDiscountException::because('discount_not_found', 'Invalid discount code.'); + } + + $this->validateDiscountForCart($discount, $cart); + + return $discount; + } + + /** + * @return Collection + */ + public function automaticForCart(Store $store, Cart $cart): Collection + { + return Discount::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('type', DiscountType::Automatic->value) + ->where('status', DiscountStatus::Active->value) + ->orderBy('id') + ->get() + ->filter(function (Discount $discount) use ($cart): bool { + try { + $this->validateDiscountForCart($discount, $cart); + + return true; + } catch (InvalidDiscountException) { + return false; + } + }) + ->values(); + } + + private function validateDiscountForCart(Discount $discount, Cart $cart): void + { + if ($discount->status === DiscountStatus::Expired || ($discount->ends_at !== null && $discount->ends_at->isPast())) { + throw InvalidDiscountException::because('discount_expired', 'Discount has expired.'); + } + + if ($discount->status !== DiscountStatus::Active) { + throw InvalidDiscountException::because('discount_not_active', 'Discount is not active.'); + } + + if ($discount->starts_at->isFuture()) { + throw InvalidDiscountException::because('discount_not_yet_active', 'Discount is not active yet.'); + } + + if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) { + throw InvalidDiscountException::because('discount_usage_limit_reached', 'Discount usage limit has been reached.'); + } + + if ($this->onePerCustomer($discount) && $this->customerHasUsedDiscount($discount, $cart)) { + throw InvalidDiscountException::because('discount_usage_limit_reached', 'Discount has already been used by this customer.'); + } + + $lines = $this->cartLines($cart); + $subtotal = $lines->sum('line_subtotal_amount'); + $minimum = (int) data_get($discount->rules_json, 'min_purchase_amount', data_get($discount->rules_json, 'minimum_purchase', 0)); + + if ($minimum > 0 && $subtotal < $minimum) { + throw InvalidDiscountException::because('discount_min_purchase_not_met', 'Cart does not meet the minimum purchase amount.'); + } + + if ($this->qualifyingLines($discount, $lines)->isEmpty()) { + throw InvalidDiscountException::because('discount_not_applicable', 'Discount does not apply to these cart lines.'); + } + } + + /** + * @param array $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, collect($lines)); + $qualifyingSubtotal = $qualifyingLines->sum('line_total_amount'); + + if ($qualifyingSubtotal <= 0) { + return new DiscountResult(0, []); + } + + $discountAmount = match ($discount->value_type) { + DiscountValueType::Percent => (int) round($qualifyingSubtotal * $discount->value_amount / 100), + DiscountValueType::Fixed => min($discount->value_amount, $qualifyingSubtotal), + DiscountValueType::FreeShipping => 0, + }; + + $remaining = $discountAmount; + $allocations = []; + $lastIndex = $qualifyingLines->keys()->last(); + + foreach ($qualifyingLines as $index => $line) { + if ($index === $lastIndex) { + $allocations[$line->getKey()] = $remaining; + + continue; + } + + $allocation = (int) round($discountAmount * $line->line_total_amount / $qualifyingSubtotal); + $allocations[$line->getKey()] = $allocation; + $remaining -= $allocation; + } + + return new DiscountResult($discountAmount, $allocations); + } + + public function applyToCart(Cart $cart, Discount $discount): DiscountResult + { + return DB::transaction(function () use ($cart, $discount): DiscountResult { + $lines = $this->cartLines($cart); + $result = $this->calculate($discount, $lines->sum('line_subtotal_amount'), $lines->all()); + + $lines->each(function (CartLine $line) use ($result): void { + $discountAmount = $result->allocations[$line->getKey()] ?? 0; + + $line->forceFill([ + 'line_discount_amount' => $line->line_discount_amount + $discountAmount, + 'line_total_amount' => max(0, $line->line_total_amount - $discountAmount), + ])->save(); + }); + + return $result; + }); + } + + /** + * @return Collection + */ + private function cartLines(Cart $cart): Collection + { + return CartLine::withoutGlobalScopes() + ->where('cart_id', $cart->getKey()) + ->orderBy('id') + ->get(); + } + + /** + * @param Collection $lines + * @return Collection + */ + private function qualifyingLines(Discount $discount, Collection $lines): Collection + { + $productIds = collect(data_get($discount->rules_json, 'applicable_product_ids', [])) + ->filter() + ->map(fn (mixed $id): int => (int) $id) + ->values(); + $collectionIds = collect(data_get($discount->rules_json, 'applicable_collection_ids', [])) + ->filter() + ->map(fn (mixed $id): int => (int) $id) + ->values(); + + if ($productIds->isEmpty() && $collectionIds->isEmpty()) { + return $lines; + } + + return $lines->filter(function (CartLine $line) use ($productIds, $collectionIds): bool { + $variant = ProductVariant::withoutGlobalScopes() + ->with(['product' => fn ($query) => $query->withoutGlobalScopes()->with('collections')]) + ->find($line->variant_id); + + if (! $variant instanceof ProductVariant || $variant->product === null) { + return false; + } + + if ($productIds->contains((int) $variant->product_id)) { + return true; + } + + return $variant->product->collections + ->pluck('id') + ->map(fn (int $id): int => $id) + ->intersect($collectionIds) + ->isNotEmpty(); + }); + } + + private function onePerCustomer(Discount $discount): bool + { + return (bool) data_get($discount->rules_json, 'one_per_customer', false); + } + + private function customerHasUsedDiscount(Discount $discount, Cart $cart): bool + { + if ($cart->customer_id === null) { + return false; + } + + $discountCode = $discount->code ? mb_strtolower($discount->code) : null; + + return OrderLine::query() + ->whereHas('order', function ($query) use ($discount, $cart): void { + $query + ->withoutGlobalScopes() + ->where('store_id', $discount->store_id) + ->where('customer_id', $cart->customer_id) + ->where('discount_amount', '>', 0); + }) + ->get() + ->contains(function (OrderLine $line) use ($discount, $discountCode): bool { + return collect($line->discount_allocations_json ?? []) + ->contains(function (mixed $allocation) use ($discount, $discountCode): bool { + $allocationDiscountId = data_get($allocation, 'discount_id'); + + if ($allocationDiscountId !== null && (int) $allocationDiscountId === $discount->getKey()) { + return true; + } + + if ($discountCode === null) { + return false; + } + + return mb_strtolower((string) data_get($allocation, 'code', '')) === $discountCode; + }); + }); + } +} diff --git a/app/Services/FulfillmentService.php b/app/Services/FulfillmentService.php new file mode 100644 index 00000000..156f03f7 --- /dev/null +++ b/app/Services/FulfillmentService.php @@ -0,0 +1,221 @@ + $lines + * @param array $trackingData + */ + public function create(Order $order, array $lines, array $trackingData = []): Fulfillment + { + return DB::transaction(function () use ($order, $lines, $trackingData): Fulfillment { + $order = $this->freshOrder($order); + $this->assertCanFulfill($order); + + $requestedLines = $this->normalizeLines($lines); + + if ($requestedLines->isEmpty()) { + throw InvalidFulfillmentOperationException::because('At least one fulfillment line is required.'); + } + + $requestedLines->each(function (int $quantity, int $lineId) use ($order): void { + $line = $order->lines->firstWhere('id', $lineId); + + if (! $line instanceof OrderLine) { + throw InvalidFulfillmentOperationException::because('Fulfillment line does not belong to this order.'); + } + + $remaining = $line->quantity - $this->fulfilledQuantity($line); + + if ($quantity > $remaining) { + throw InvalidFulfillmentOperationException::because('Fulfillment quantity exceeds the unfulfilled quantity.'); + } + }); + + $fulfillment = Fulfillment::query()->create([ + 'order_id' => $order->getKey(), + 'status' => FulfillmentShipmentStatus::Pending, + 'tracking_company' => data_get($trackingData, 'tracking_company'), + 'tracking_number' => data_get($trackingData, 'tracking_number'), + 'tracking_url' => data_get($trackingData, 'tracking_url'), + ]); + + $requestedLines->each(function (int $quantity, int $lineId) use ($fulfillment): void { + $fulfillment->lines()->create([ + 'order_line_id' => $lineId, + 'quantity' => $quantity, + ]); + }); + + $this->updateOrderFulfillmentStatus($order); + + $fulfillment = $fulfillment->refresh()->load('lines.orderLine'); + + event(new FulfillmentCreated($fulfillment)); + + return $fulfillment; + }); + } + + public function markShipped(Fulfillment $fulfillment): Fulfillment + { + return DB::transaction(function () use ($fulfillment): Fulfillment { + $fulfillment = $this->freshFulfillment($fulfillment); + + if ($fulfillment->status !== FulfillmentShipmentStatus::Pending) { + throw InvalidFulfillmentOperationException::because('Only pending fulfillments can be marked as shipped.'); + } + + $fulfillment->forceFill([ + 'status' => FulfillmentShipmentStatus::Shipped, + 'shipped_at' => now(), + ])->save(); + + $fulfillment = $fulfillment->refresh()->load('lines.orderLine'); + + event(new FulfillmentShipped($fulfillment)); + + return $fulfillment; + }); + } + + public function markDelivered(Fulfillment $fulfillment): Fulfillment + { + return DB::transaction(function () use ($fulfillment): Fulfillment { + $fulfillment = $this->freshFulfillment($fulfillment); + + if ($fulfillment->status !== FulfillmentShipmentStatus::Shipped) { + throw InvalidFulfillmentOperationException::because('Only shipped fulfillments can be marked as delivered.'); + } + + $fulfillment->forceFill([ + 'status' => FulfillmentShipmentStatus::Delivered, + 'delivered_at' => now(), + ])->save(); + + $fulfillment = $fulfillment->refresh()->load('lines.orderLine'); + + event(new FulfillmentDelivered($fulfillment)); + + return $fulfillment; + }); + } + + public function autoFulfillDigital(Order $order): ?Fulfillment + { + return DB::transaction(function () use ($order): ?Fulfillment { + $order = $this->freshOrder($order); + + if ($order->fulfillments()->exists() || $order->lines->isEmpty()) { + return null; + } + + $allDigital = $order->lines->every(fn (OrderLine $line): bool => $line->variant?->requires_shipping === false); + + if (! $allDigital) { + return null; + } + + $fulfillment = Fulfillment::query()->create([ + 'order_id' => $order->getKey(), + 'status' => FulfillmentShipmentStatus::Delivered, + 'shipped_at' => now(), + 'delivered_at' => now(), + ]); + + $order->lines->each(function (OrderLine $line) use ($fulfillment): void { + $fulfillment->lines()->create([ + 'order_line_id' => $line->getKey(), + 'quantity' => $line->quantity, + ]); + }); + + $order->forceFill([ + 'status' => OrderStatus::Fulfilled, + 'fulfillment_status' => FulfillmentStatus::Fulfilled, + ])->save(); + + $fulfillment = $fulfillment->refresh()->load('lines.orderLine'); + + event(new FulfillmentCreated($fulfillment)); + event(new FulfillmentDelivered($fulfillment)); + + return $fulfillment; + }); + } + + private function freshOrder(Order $order): Order + { + return Order::withoutGlobalScopes() + ->with([ + 'lines.variant' => fn ($query) => $query->withoutGlobalScopes(), + 'fulfillments.lines', + ]) + ->whereKey($order->getKey()) + ->lockForUpdate() + ->firstOrFail(); + } + + private function freshFulfillment(Fulfillment $fulfillment): Fulfillment + { + return Fulfillment::query() + ->with('lines.orderLine') + ->whereKey($fulfillment->getKey()) + ->lockForUpdate() + ->firstOrFail(); + } + + private function assertCanFulfill(Order $order): void + { + if (! in_array($order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true)) { + throw InvalidFulfillmentOperationException::because('Fulfillment cannot be created until payment is confirmed.'); + } + } + + /** + * @param array $lines + * @return Collection + */ + private function normalizeLines(array $lines): Collection + { + return collect($lines) + ->mapWithKeys(fn (mixed $quantity, int|string $lineId): array => [(int) $lineId => (int) $quantity]) + ->filter(fn (int $quantity): bool => $quantity > 0); + } + + private function fulfilledQuantity(OrderLine $line): int + { + return (int) FulfillmentLine::query() + ->where('order_line_id', $line->getKey()) + ->sum('quantity'); + } + + private function updateOrderFulfillmentStatus(Order $order): void + { + $allFulfilled = $order->lines->every(function (OrderLine $line): bool { + return $this->fulfilledQuantity($line) >= $line->quantity; + }); + + $order->forceFill([ + 'status' => $allFulfilled ? OrderStatus::Fulfilled : $order->status, + 'fulfillment_status' => $allFulfilled ? FulfillmentStatus::Fulfilled : FulfillmentStatus::Partial, + ])->save(); + } +} diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php new file mode 100644 index 00000000..022a8694 --- /dev/null +++ b/app/Services/InventoryService.php @@ -0,0 +1,108 @@ +assertPositiveQuantity($quantity); + + if ($item->policy === InventoryPolicy::Continue) { + return true; + } + + return $this->available($item) >= $quantity; + } + + public function reserve(InventoryItem $item, int $quantity): void + { + $this->assertPositiveQuantity($quantity); + + DB::transaction(function () use ($item, $quantity): void { + $locked = $this->freshItem($item); + + if (! $this->checkAvailability($locked, $quantity)) { + throw InsufficientInventoryException::forQuantity($this->available($locked), $quantity); + } + + $locked->forceFill([ + 'quantity_reserved' => $locked->quantity_reserved + $quantity, + ])->save(); + }); + } + + public function release(InventoryItem $item, int $quantity): void + { + $this->assertPositiveQuantity($quantity); + + DB::transaction(function () use ($item, $quantity): void { + $locked = $this->freshItem($item); + + if ($locked->quantity_reserved < $quantity) { + throw new InvalidArgumentException('Cannot release more inventory than is reserved.'); + } + + $locked->forceFill([ + 'quantity_reserved' => $locked->quantity_reserved - $quantity, + ])->save(); + }); + } + + public function commit(InventoryItem $item, int $quantity): void + { + $this->assertPositiveQuantity($quantity); + + DB::transaction(function () use ($item, $quantity): void { + $locked = $this->freshItem($item); + + if ($locked->quantity_reserved < $quantity) { + throw new InvalidArgumentException('Cannot commit more inventory than is reserved.'); + } + + $locked->forceFill([ + 'quantity_on_hand' => $locked->quantity_on_hand - $quantity, + 'quantity_reserved' => $locked->quantity_reserved - $quantity, + ])->save(); + }); + } + + public function restock(InventoryItem $item, int $quantity): void + { + $this->assertPositiveQuantity($quantity); + + DB::transaction(function () use ($item, $quantity): void { + $locked = $this->freshItem($item); + + $locked->forceFill([ + 'quantity_on_hand' => $locked->quantity_on_hand + $quantity, + ])->save(); + }); + } + + private function available(InventoryItem $item): int + { + return $item->quantity_on_hand - $item->quantity_reserved; + } + + private function freshItem(InventoryItem $item): InventoryItem + { + return InventoryItem::withoutGlobalScopes() + ->whereKey($item->getKey()) + ->lockForUpdate() + ->firstOrFail(); + } + + private function assertPositiveQuantity(int $quantity): void + { + if ($quantity <= 0) { + throw new InvalidArgumentException('Inventory quantity must be greater than zero.'); + } + } +} diff --git a/app/Services/NavigationService.php b/app/Services/NavigationService.php new file mode 100644 index 00000000..4a98dd9d --- /dev/null +++ b/app/Services/NavigationService.php @@ -0,0 +1,136 @@ +}> + */ + public function forHandle(Store $store, string $handle): array + { + $menu = NavigationMenu::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', $handle) + ->first(); + + if ($menu === null) { + return []; + } + + return $this->buildTree($menu); + } + + /** + * @return array}> + */ + public function buildTree(NavigationMenu $menu): array + { + return Cache::remember($this->cacheKey($menu), now()->addMinutes(5), function () use ($menu): array { + $items = $menu->items() + ->with('menu') + ->orderBy('parent_id') + ->orderBy('position') + ->get(); + + $itemsByParent = $items->groupBy(fn (NavigationItem $item): string => $item->parent_id === null ? 'root' : (string) $item->parent_id); + + $build = function (string $parentKey) use (&$build, $itemsByParent): array { + return $itemsByParent->get($parentKey, collect()) + ->sortBy('position') + ->map(fn (NavigationItem $item): array => $this->node($item, $build((string) $item->getKey()))) + ->values() + ->all(); + }; + + return $build('root'); + }); + } + + /** + * @return array{label: string, url: string, type: string, external: bool, children: array} + */ + private function node(NavigationItem $item, array $children): array + { + return [ + 'label' => $item->label, + 'url' => $this->resolveUrl($item), + 'type' => $item->type->value, + 'external' => $this->isExternal((string) ($item->url ?? '')), + 'children' => $children, + ]; + } + + public function resolveUrl(NavigationItem $item): string + { + return match ($item->type) { + NavigationItemType::Link => $item->url ?: '#', + NavigationItemType::Page => $this->pageUrl($item), + NavigationItemType::Collection => $this->collectionUrl($item), + NavigationItemType::Product => $this->productUrl($item), + }; + } + + public function forget(NavigationMenu $menu): void + { + Cache::forget($this->cacheKey($menu)); + } + + private function pageUrl(NavigationItem $item): string + { + $page = Page::withoutGlobalScopes() + ->where('store_id', $this->storeIdFor($item)) + ->find($item->resource_id); + + return $page ? route('pages.show', $page->handle, false) : '#'; + } + + private function collectionUrl(NavigationItem $item): string + { + $collection = ProductCollection::withoutGlobalScopes() + ->where('store_id', $this->storeIdFor($item)) + ->find($item->resource_id); + + return $collection ? route('collections.show', $collection->handle, false) : '#'; + } + + private function productUrl(NavigationItem $item): string + { + $product = Product::withoutGlobalScopes() + ->where('store_id', $this->storeIdFor($item)) + ->find($item->resource_id); + + return $product ? route('products.show', $product->handle, false) : '#'; + } + + private function storeIdFor(NavigationItem $item): ?int + { + if ($item->relationLoaded('menu') && $item->menu !== null) { + return (int) $item->menu->store_id; + } + + return NavigationMenu::withoutGlobalScopes() + ->whereKey($item->menu_id) + ->value('store_id'); + } + + private function isExternal(string $url): bool + { + return Str::startsWith($url, ['http://', 'https://', 'mailto:', 'tel:']); + } + + private function cacheKey(NavigationMenu $menu): string + { + return "navigation:{$menu->store_id}:{$menu->handle}"; + } +} diff --git a/app/Services/OrderExportService.php b/app/Services/OrderExportService.php new file mode 100644 index 00000000..841a483b --- /dev/null +++ b/app/Services/OrderExportService.php @@ -0,0 +1,143 @@ + $filters + */ + public function create(Store $store, array $filters): DataExport + { + $export = DataExport::query()->create([ + 'store_id' => $store->getKey(), + 'type' => 'orders', + 'format' => 'csv', + 'status' => ExportStatus::Queued, + 'filters_json' => $filters, + ]); + + try { + $export->forceFill(['status' => ExportStatus::Processing])->save(); + + [$csv, $rowCount] = $this->csv($store, $filters); + $storageKey = "exports/orders/{$export->getKey()}.csv"; + + Storage::disk('local')->put($storageKey, $csv); + + $export->forceFill([ + 'status' => ExportStatus::Completed, + 'row_count' => $rowCount, + 'storage_key' => $storageKey, + 'download_expires_at' => now()->addHour(), + 'completed_at' => now(), + ])->save(); + } catch (Throwable $exception) { + $export->forceFill([ + 'status' => ExportStatus::Failed, + 'error_message' => $exception->getMessage(), + 'failed_at' => now(), + ])->save(); + + throw $exception; + } + + return $export->refresh(); + } + + /** + * @param array $filters + * @return array{0: string, 1: int} + */ + private function csv(Store $store, array $filters): array + { + $handle = fopen('php://temp', 'r+'); + + if ($handle === false) { + throw new RuntimeException('Unable to open temporary export stream.'); + } + + fputcsv($handle, [ + 'order_number', + 'created_at', + 'status', + 'financial_status', + 'fulfillment_status', + 'customer_email', + 'customer_name', + 'subtotal_amount', + 'discount_amount', + 'shipping_amount', + 'tax_amount', + 'total_amount', + 'currency', + 'shipping_method', + 'tracking_number', + ]); + + $rowCount = 0; + + $this->query($store, $filters) + ->with(['customer', 'fulfillments']) + ->orderBy('id') + ->each(function (Order $order) use ($handle, &$rowCount): void { + fputcsv($handle, [ + $order->order_number, + $order->created_at?->toIso8601String(), + $order->status?->value, + $order->financial_status?->value, + $order->fulfillment_status?->value, + $order->email, + $order->customer?->name, + $order->subtotal_amount, + $order->discount_amount, + $order->shipping_amount, + $order->tax_amount, + $order->total_amount, + $order->currency, + '', + $order->fulfillments->first()?->tracking_number, + ]); + + $rowCount++; + }); + + rewind($handle); + + return [(string) stream_get_contents($handle), $rowCount]; + } + + /** + * @param array $filters + * @return Builder + */ + private function query(Store $store, array $filters): Builder + { + return Order::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->when(data_get($filters, 'status'), fn (Builder $query, string $status) => $query->where('status', $status)) + ->when(data_get($filters, 'financial_status'), fn (Builder $query, string $status) => $query->where('financial_status', $status)) + ->when(data_get($filters, 'fulfillment_status'), fn (Builder $query, string $status) => $query->where('fulfillment_status', $status)) + ->when(data_get($filters, 'created_after'), fn (Builder $query, string $date) => $query->where('created_at', '>=', $date)) + ->when(data_get($filters, 'created_before'), fn (Builder $query, string $date) => $query->where('created_at', '<=', $date)) + ->when(data_get($filters, 'query'), function (Builder $query, string $search): void { + $like = '%'.$search.'%'; + + $query->where(function (Builder $query) use ($like): void { + $query + ->where('order_number', 'like', $like) + ->orWhere('email', 'like', $like) + ->orWhereHas('customer', fn (Builder $query) => $query->where('name', 'like', $like)); + }); + }); + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php new file mode 100644 index 00000000..a1aaaa57 --- /dev/null +++ b/app/Services/OrderService.php @@ -0,0 +1,491 @@ + $paymentMethodData + */ + public function createFromCheckout(Checkout $checkout, array $paymentMethodData = []): Order + { + try { + return DB::transaction(function () use ($checkout, $paymentMethodData): Order { + $checkout = $this->freshCheckout($checkout); + $existingOrder = Order::withoutGlobalScopes() + ->where('checkout_id', $checkout->getKey()) + ->first(); + + if ($existingOrder instanceof Order) { + return $existingOrder->load(['lines', 'payments', 'refunds', 'fulfillments.lines']); + } + + if ($checkout->status !== CheckoutStatus::PaymentSelected) { + throw InvalidCheckoutTransitionException::because("Checkout cannot transition from {$checkout->status->value}."); + } + + if ($checkout->totals_json === null) { + $this->pricing->calculate($checkout); + $checkout = $this->freshCheckout($checkout); + } + + $store = $this->lockedStore($checkout); + $appliedDiscounts = $this->lockedAppliedDiscounts($checkout); + $method = $this->paymentMethod($checkout); + $paymentResult = $this->payments->charge($checkout, $method, $paymentMethodData); + + if (! $paymentResult->success) { + throw PaymentFailedException::fromResult($paymentResult); + } + + $paidImmediately = $paymentResult->status === PaymentStatus::Captured; + $totals = $checkout->totals_json ?? []; + + $order = Order::withoutGlobalScopes()->create([ + 'store_id' => $checkout->store_id, + 'checkout_id' => $checkout->getKey(), + 'customer_id' => $checkout->customer_id, + 'order_number' => $this->nextOrderNumber($store), + 'payment_method' => $method, + 'status' => $paidImmediately ? OrderStatus::Paid : OrderStatus::Pending, + 'financial_status' => $paidImmediately ? FinancialStatus::Paid : FinancialStatus::Pending, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled, + 'currency' => (string) data_get($totals, 'currency', $checkout->cart->currency), + 'subtotal_amount' => (int) data_get($totals, 'subtotal', 0), + 'discount_amount' => (int) data_get($totals, 'discount', 0), + 'shipping_amount' => (int) data_get($totals, 'shipping', 0), + 'tax_amount' => (int) data_get($totals, 'tax', 0), + 'total_amount' => (int) data_get($totals, 'total', 0), + 'email' => $checkout->email, + 'billing_address_json' => $checkout->billing_address_json, + 'shipping_address_json' => $checkout->shipping_address_json, + 'placed_at' => now(), + ]); + + $this->createOrderLines($checkout, $order, $this->discountAllocationsByCartLine($checkout, $appliedDiscounts)); + + $order->payments()->create([ + 'provider' => 'mock', + 'method' => $method, + 'provider_payment_id' => $paymentResult->referenceId, + 'status' => $paymentResult->status, + 'amount' => $order->total_amount, + 'currency' => $order->currency, + 'raw_json_encrypted' => $paymentResult->toArray(), + ]); + + if ($paidImmediately) { + $this->commitReservedInventory($checkout); + } + + $checkout->cart->forceFill([ + 'status' => CartStatus::Converted, + ])->save(); + + $checkout->forceFill([ + 'status' => CheckoutStatus::Completed, + 'expires_at' => null, + ])->save(); + + $this->incrementDiscountUsage($appliedDiscounts); + + if ($paidImmediately) { + $this->fulfillments->autoFulfillDigital($order); + } + + $order = $order->refresh()->load(['lines', 'payments', 'refunds', 'fulfillments.lines']); + $checkout = $checkout->refresh(); + + event(new OrderCreated($order)); + event(new CheckoutCompleted($checkout, $order)); + + if ($paidImmediately) { + event(new OrderPaid($order)); + } + + return $order; + }); + } catch (PaymentFailedException $exception) { + $this->releaseFailedPaymentReservation($checkout); + + throw $exception; + } + } + + public function confirmBankTransferPayment(Order $order): Order + { + return DB::transaction(function () use ($order): Order { + $order = $this->freshOrder($order); + $this->assertPendingBankTransfer($order); + + $order->payments() + ->where('status', PaymentStatus::Pending->value) + ->update(['status' => PaymentStatus::Captured->value]); + + $this->commitReservedOrderInventory($order); + + $order->forceFill([ + 'status' => OrderStatus::Paid, + 'financial_status' => FinancialStatus::Paid, + ])->save(); + + $this->fulfillments->autoFulfillDigital($order); + + $order = $order->refresh()->load(['lines', 'payments', 'refunds', 'fulfillments.lines']); + + event(new OrderPaid($order)); + + return $order; + }); + } + + public function cancelUnpaidBankTransferOrder(Order $order): Order + { + return DB::transaction(function () use ($order): Order { + $order = $this->freshOrder($order); + $this->assertPendingBankTransfer($order); + + $this->releaseReservedOrderInventory($order); + + $order->payments() + ->where('status', PaymentStatus::Pending->value) + ->update(['status' => PaymentStatus::Failed->value]); + + $order->forceFill([ + 'status' => OrderStatus::Cancelled, + 'financial_status' => FinancialStatus::Voided, + ])->save(); + + $order = $order->refresh()->load(['lines', 'payments', 'refunds', 'fulfillments.lines']); + + event(new OrderCancelled($order)); + + return $order; + }); + } + + private function freshCheckout(Checkout $checkout): Checkout + { + return Checkout::withoutGlobalScopes() + ->with(['cart', 'store']) + ->whereKey($checkout->getKey()) + ->lockForUpdate() + ->firstOrFail(); + } + + private function lockedStore(Checkout $checkout): Store + { + return Store::withoutGlobalScopes() + ->whereKey($checkout->store_id) + ->lockForUpdate() + ->firstOrFail(); + } + + private function freshOrder(Order $order): Order + { + return Order::withoutGlobalScopes() + ->with(['lines', 'payments', 'store.settings']) + ->whereKey($order->getKey()) + ->lockForUpdate() + ->firstOrFail(); + } + + private function paymentMethod(Checkout $checkout): PaymentMethod + { + try { + return PaymentMethod::from((string) $checkout->payment_method); + } catch (ValueError) { + throw InvalidCheckoutTransitionException::because('Payment method is invalid.'); + } + } + + private function nextOrderNumber(Store $store): string + { + $settings = StoreSettings::query() + ->where('store_id', $store->getKey()) + ->first() + ?->settings_json ?? []; + $prefix = (string) data_get($settings, 'order_number_prefix', '#'); + $start = max(1, (int) data_get($settings, 'order_number_start', 1001)); + $maxExisting = Order::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->pluck('order_number') + ->map(fn (string $orderNumber): ?int => $this->sequenceFromOrderNumber($orderNumber)) + ->filter() + ->max(); + + return $prefix.((int) max($start - 1, $maxExisting ?? 0) + 1); + } + + private function sequenceFromOrderNumber(string $orderNumber): ?int + { + $digits = preg_replace('/\D+/', '', $orderNumber); + + return $digits === '' ? null : (int) $digits; + } + + /** + * @param array> $discountAllocations + */ + private function createOrderLines(Checkout $checkout, Order $order, array $discountAllocations): void + { + $this->cartLines($checkout)->each(function (CartLine $line) use ($discountAllocations, $order): void { + $variant = $line->variant; + + $order->lines()->create([ + 'product_id' => $variant?->product_id, + 'variant_id' => $variant?->getKey(), + 'title_snapshot' => $this->titleSnapshot($variant), + 'sku_snapshot' => $variant?->sku, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'total_amount' => $line->line_total_amount, + 'tax_lines_json' => [], + 'discount_allocations_json' => $discountAllocations[$line->getKey()] ?? [], + ]); + }); + } + + private function titleSnapshot(?ProductVariant $variant): string + { + $title = $variant?->product?->title ?? 'Product'; + $optionValues = $variant?->optionValues + ->sortBy(fn (ProductOptionValue $value): int => $value->option?->position ?? $value->position) + ->pluck('value') + ->filter() + ->implode(' / '); + + return $optionValues ? "{$title} - {$optionValues}" : $title; + } + + /** + * @return array> + */ + private function discountAllocationsByCartLine(Checkout $checkout, ?Collection $discounts = null): array + { + $lines = $this->cartLines($checkout) + ->map(function (CartLine $line): CartLine { + $simulatedLine = clone $line; + $simulatedLine->forceFill([ + 'line_discount_amount' => 0, + 'line_total_amount' => $line->line_subtotal_amount, + ]); + + return $simulatedLine; + }); + $allocations = []; + + foreach (($discounts ?? $this->appliedDiscounts($checkout)) as $discount) { + $result = $this->discounts->calculate($discount, $lines->sum('line_subtotal_amount'), $lines->all()); + + foreach ($result->allocations as $lineId => $amount) { + if ($amount <= 0) { + continue; + } + + $allocations[(int) $lineId][] = [ + 'discount_id' => $discount->getKey(), + 'code' => $discount->code, + 'amount' => $amount, + ]; + } + + $lines->each(function (CartLine $line) use ($result): void { + $discountAmount = $result->allocations[$line->getKey()] ?? 0; + + $line->forceFill([ + 'line_discount_amount' => $line->line_discount_amount + $discountAmount, + 'line_total_amount' => max(0, $line->line_total_amount - $discountAmount), + ]); + }); + } + + return $allocations; + } + + /** + * @return Collection + */ + private function appliedDiscounts(Checkout $checkout): Collection + { + $discounts = collect(); + $code = trim((string) $checkout->discount_code); + + if ($code !== '') { + $discount = Discount::withoutGlobalScopes() + ->where('store_id', $checkout->store_id) + ->whereRaw('lower(code) = ?', [mb_strtolower($code)]) + ->first(); + + if ($discount instanceof Discount) { + $discounts->push($discount); + } + } + + return $discounts + ->merge($this->discounts->automaticForCart($checkout->store, $checkout->cart)) + ->values(); + } + + /** + * @return Collection + */ + private function lockedAppliedDiscounts(Checkout $checkout): Collection + { + $discountIds = $this->appliedDiscounts($checkout) + ->pluck('id') + ->filter() + ->values(); + + if ($discountIds->isEmpty()) { + return collect(); + } + + $lockedDiscounts = Discount::withoutGlobalScopes() + ->whereIn('id', $discountIds->all()) + ->orderBy('id') + ->lockForUpdate() + ->get() + ->keyBy(fn (Discount $discount): int => $discount->getKey()); + + $discounts = $discountIds + ->map(fn (int $discountId): ?Discount => $lockedDiscounts->get($discountId)) + ->filter() + ->values(); + + $discounts->each(function (Discount $discount): void { + if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) { + throw InvalidCheckoutTransitionException::because('Discount usage limit has been reached.'); + } + }); + + return $discounts; + } + + private function commitReservedInventory(Checkout $checkout): void + { + $this->cartLines($checkout)->each(function (CartLine $line): void { + $this->inventory->commit($this->inventoryItemForVariant($line->variant_id), $line->quantity); + }); + } + + private function commitReservedOrderInventory(Order $order): void + { + $order->lines->each(function (OrderLine $line): void { + if ($line->variant_id !== null) { + $this->inventory->commit($this->inventoryItemForVariant($line->variant_id), $line->quantity); + } + }); + } + + private function releaseReservedOrderInventory(Order $order): void + { + $order->lines->each(function (OrderLine $line): void { + if ($line->variant_id !== null) { + $this->inventory->release($this->inventoryItemForVariant($line->variant_id), $line->quantity); + } + }); + } + + private function releaseFailedPaymentReservation(Checkout $checkout): void + { + DB::transaction(function () use ($checkout): void { + $checkout = $this->freshCheckout($checkout); + + if ($checkout->status !== CheckoutStatus::PaymentSelected) { + return; + } + + $this->cartLines($checkout)->each(function (CartLine $line): void { + $this->inventory->release($this->inventoryItemForVariant($line->variant_id), $line->quantity); + }); + + $checkout->forceFill([ + 'status' => CheckoutStatus::ShippingSelected, + 'payment_method' => null, + 'expires_at' => null, + ])->save(); + }); + } + + /** + * @param Collection $discounts + */ + private function incrementDiscountUsage(Collection $discounts): void + { + $discounts->each(function (Discount $discount): void { + Discount::withoutGlobalScopes() + ->whereKey($discount->getKey()) + ->increment('usage_count'); + }); + } + + private function assertPendingBankTransfer(Order $order): void + { + if ($order->payment_method !== PaymentMethod::BankTransfer || $order->financial_status !== FinancialStatus::Pending) { + throw InvalidOrderOperationException::because('Order is not a pending bank transfer order.'); + } + } + + private function inventoryItemForVariant(int $variantId): InventoryItem + { + return InventoryItem::withoutGlobalScopes() + ->where('variant_id', $variantId) + ->firstOrFail(); + } + + /** + * @return Collection + */ + private function cartLines(Checkout $checkout): Collection + { + return CartLine::withoutGlobalScopes() + ->with([ + 'variant' => fn ($query) => $query->withoutGlobalScopes(), + 'variant.product' => fn ($query) => $query->withoutGlobalScopes(), + 'variant.optionValues' => fn ($query) => $query->withoutGlobalScopes(), + 'variant.optionValues.option' => fn ($query) => $query->withoutGlobalScopes(), + ]) + ->where('cart_id', $checkout->cart_id) + ->orderBy('id') + ->get(); + } +} diff --git a/app/Services/PaymentService.php b/app/Services/PaymentService.php new file mode 100644 index 00000000..84787d40 --- /dev/null +++ b/app/Services/PaymentService.php @@ -0,0 +1,30 @@ + $paymentMethodData + */ + public function charge(Checkout $checkout, PaymentMethod $method, array $paymentMethodData = []): PaymentResult + { + return $this->provider->charge($checkout, $method, $paymentMethodData); + } + + public function refund(Payment $payment, int $amount): RefundResult + { + return $this->provider->refund($payment, $amount); + } +} diff --git a/app/Services/Payments/MockPaymentProvider.php b/app/Services/Payments/MockPaymentProvider.php new file mode 100644 index 00000000..19f7b4c5 --- /dev/null +++ b/app/Services/Payments/MockPaymentProvider.php @@ -0,0 +1,92 @@ + $paymentMethodData + */ + public function charge(Checkout $checkout, PaymentMethod $method, array $paymentMethodData = []): PaymentResult + { + return match ($method) { + PaymentMethod::CreditCard => $this->chargeCreditCard($paymentMethodData), + PaymentMethod::Paypal => $this->captured(), + PaymentMethod::BankTransfer => new PaymentResult( + success: true, + status: PaymentStatus::Pending, + referenceId: $this->reference('bank'), + ), + }; + } + + public function refund(Payment $payment, int $amount): RefundResult + { + if ($amount <= 0) { + return new RefundResult( + success: false, + status: RefundStatus::Failed, + errorCode: 'invalid_refund_amount', + errorMessage: 'Refund amount must be greater than zero.', + ); + } + + return new RefundResult( + success: true, + status: RefundStatus::Processed, + referenceId: $this->reference('refund'), + ); + } + + /** + * @param array $paymentMethodData + */ + private function chargeCreditCard(array $paymentMethodData): PaymentResult + { + $number = preg_replace('/\D+/', '', (string) data_get( + $paymentMethodData, + 'card_number', + data_get($paymentMethodData, 'number', data_get($paymentMethodData, 'card.number', '')), + )); + + return match ($number) { + '4000000000000002' => new PaymentResult( + success: false, + status: PaymentStatus::Failed, + errorCode: 'card_declined', + errorMessage: 'The card was declined.', + ), + '4000000000009995' => new PaymentResult( + success: false, + status: PaymentStatus::Failed, + errorCode: 'insufficient_funds', + errorMessage: 'The card has insufficient funds.', + ), + default => $this->captured(), + }; + } + + private function captured(): PaymentResult + { + return new PaymentResult( + success: true, + status: PaymentStatus::Captured, + referenceId: $this->reference('payment'), + ); + } + + private function reference(string $prefix): string + { + return 'mock_'.$prefix.'_'.Str::lower(Str::random(16)); + } +} diff --git a/app/Services/PricingEngine.php b/app/Services/PricingEngine.php new file mode 100644 index 00000000..f9dad4dc --- /dev/null +++ b/app/Services/PricingEngine.php @@ -0,0 +1,147 @@ +with(['cart.lines']) + ->whereKey($checkout->getKey()) + ->firstOrFail(); + $cart = $checkout->cart; + + $this->resetLineAmounts($checkout); + + $lines = CartLine::withoutGlobalScopes() + ->where('cart_id', $cart->getKey()) + ->orderBy('id') + ->get(); + $subtotal = $lines->sum('line_subtotal_amount'); + $discountAmount = 0; + $freeShipping = false; + + if ($checkout->discount_code) { + $discount = $this->discounts->validate($checkout->discount_code, $checkout->store, $cart); + $discountResult = $this->discounts->applyToCart($cart, $discount); + $discountAmount += $discountResult->amount; + $freeShipping = $freeShipping || $discountResult->freeShipping; + } + + foreach ($this->discounts->automaticForCart($checkout->store, $cart) as $discount) { + $discountResult = $this->discounts->applyToCart($cart, $discount); + $discountAmount += $discountResult->amount; + $freeShipping = $freeShipping || $discountResult->freeShipping; + } + + $lines = CartLine::withoutGlobalScopes() + ->where('cart_id', $cart->getKey()) + ->orderBy('id') + ->get(); + $discountResult = new DiscountResult($discountAmount, [], $freeShipping); + $shippingAmount = $this->shippingAmount($checkout, $discountResult); + $taxSettings = $this->taxSettings($checkout); + $taxResult = $this->taxes->calculateForAmounts( + $lines->pluck('line_total_amount')->map(fn (int $amount): int => $amount)->all(), + $shippingAmount, + $taxSettings, + $checkout->shipping_address_json ?? [], + ); + $discountedSubtotal = $subtotal - $discountResult->amount; + $total = $taxSettings->prices_include_tax + ? $discountedSubtotal + $shippingAmount + : $discountedSubtotal + $shippingAmount + $taxResult->totalAmount; + + $result = new PricingResult( + subtotal: $subtotal, + discount: $discountResult->amount, + shipping: $shippingAmount, + taxLines: $taxResult->taxLines, + taxTotal: $taxResult->totalAmount, + total: $total, + currency: $cart->currency, + ); + + $checkout->forceFill([ + 'tax_provider_snapshot_json' => $taxResult->toArray(), + 'totals_json' => $result->toArray(), + ])->save(); + + return $result; + }); + } + + private function resetLineAmounts(Checkout $checkout): void + { + CartLine::withoutGlobalScopes() + ->where('cart_id', $checkout->cart_id) + ->get() + ->each(function (CartLine $line): void { + $subtotal = $line->unit_price_amount * $line->quantity; + + $line->forceFill([ + 'line_subtotal_amount' => $subtotal, + 'line_discount_amount' => 0, + 'line_total_amount' => $subtotal, + ])->save(); + }); + } + + private function shippingAmount(Checkout $checkout, DiscountResult $discountResult): int + { + if (! $this->shipping->requiresShipping($checkout->cart)) { + return 0; + } + + if ($checkout->shipping_method_id === null) { + return 0; + } + + $rate = ShippingRate::withoutGlobalScopes()->find($checkout->shipping_method_id); + + if (! $rate instanceof ShippingRate) { + throw InvalidCheckoutTransitionException::because('Shipping rate is not available.'); + } + + $amount = $this->shipping->calculate($rate, $checkout->cart); + + if ($amount === null) { + throw InvalidCheckoutTransitionException::because('Shipping rate is not available for this cart.'); + } + + return $discountResult->freeShipping ? 0 : $amount; + } + + private function taxSettings(Checkout $checkout): TaxSettings + { + return TaxSettings::withoutGlobalScopes() + ->where('store_id', $checkout->store_id) + ->first() ?? new TaxSettings([ + 'store_id' => $checkout->store_id, + 'mode' => 'manual', + 'provider' => 'none', + 'prices_include_tax' => false, + 'config_json' => [ + 'default_rate_bps' => 0, + 'shipping_taxable' => false, + ], + ]); + } +} diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php new file mode 100644 index 00000000..89ac46cd --- /dev/null +++ b/app/Services/ProductService.php @@ -0,0 +1,322 @@ + $data + */ + public function create(Store $store, array $data): Product + { + return DB::transaction(function () use ($store, $data): Product { + $requestedStatus = $this->requestedProductStatus($data) ?? ProductStatus::Draft; + + $product = Product::query()->create([ + ...Arr::only($data, [ + 'title', + 'description_html', + 'vendor', + 'product_type', + 'tags', + ]), + 'store_id' => $store->getKey(), + 'handle' => $data['handle'] ?? $this->handles->generate($data['title'], 'products', $store->getKey()), + 'status' => ProductStatus::Draft, + 'published_at' => null, + ]); + + foreach ($data['options'] ?? [] as $optionData) { + $option = $product->options()->create(Arr::only($optionData, ['name', 'position'])); + + foreach ($optionData['values'] ?? [] as $valueData) { + $option->values()->create(Arr::only($valueData, ['value', 'position'])); + } + } + + $variants = $data['variants'] ?? []; + + if ($variants === []) { + $variants = [[ + 'sku' => $data['sku'] ?? null, + 'price_amount' => $data['price_amount'] ?? 0, + 'currency' => $store->default_currency, + 'is_default' => true, + 'position' => 0, + ]]; + } + + foreach ($variants as $variantData) { + $this->createVariant($product, $store, $variantData); + } + + if ($requestedStatus !== ProductStatus::Draft) { + $this->transitionStatus($product->refresh(), $requestedStatus); + } + + return $product->refresh(); + }); + } + + /** + * @param array $data + */ + public function update(Product $product, array $data): Product + { + return DB::transaction(function () use ($product, $data): Product { + $storeId = $product->store_id; + $requestedStatus = $this->requestedProductStatus($data); + + $product->fill(Arr::only($data, [ + 'title', + 'description_html', + 'vendor', + 'product_type', + 'tags', + ])); + + if (array_key_exists('handle', $data)) { + $product->handle = $data['handle']; + } elseif (array_key_exists('title', $data)) { + $product->handle = $this->handles->generate($data['title'], 'products', $storeId, $product->getKey()); + } + + $product->save(); + $product = $product->refresh(); + + if ($requestedStatus !== null && $requestedStatus !== $this->productStatus($product)) { + $this->transitionStatus($product, $requestedStatus); + } + + return $product->refresh(); + }); + } + + public function transitionStatus(Product $product, ProductStatus $newStatus): void + { + $currentStatus = $this->productStatus($product); + + if ($currentStatus === $newStatus) { + return; + } + + if ($newStatus === ProductStatus::Active) { + $this->assertCanActivate($product); + } + + if ($newStatus === ProductStatus::Draft && $this->hasOrderLineReferences($product)) { + throw InvalidProductTransitionException::because('Products with order history cannot be reverted to draft.'); + } + + if (! $this->isAllowedTransition($currentStatus, $newStatus)) { + throw InvalidProductTransitionException::because("Cannot transition product from {$currentStatus->value} to {$newStatus->value}."); + } + + $product->forceFill([ + 'status' => $newStatus, + 'published_at' => $newStatus === ProductStatus::Active + ? ($product->published_at ?? now()) + : $product->published_at, + ])->save(); + + ProductStatusChanged::dispatch($product->refresh(), $currentStatus, $newStatus); + } + + public function delete(Product $product): void + { + if ($this->productStatus($product) !== ProductStatus::Draft || $this->hasOrderLineReferences($product)) { + throw InvalidProductTransitionException::because('Only draft products with no order history can be deleted.'); + } + + $product->delete(); + } + + /** + * @param array $variantData + */ + private function createVariant(Product $product, Store $store, array $variantData): ProductVariant + { + $this->assertSkuIsUnique($store, $variantData['sku'] ?? null); + + $variant = $product->variants()->create([ + ...Arr::only($variantData, [ + 'sku', + 'barcode', + 'price_amount', + 'compare_at_amount', + 'currency', + 'weight_g', + 'requires_shipping', + 'is_default', + 'position', + 'status', + ]), + 'currency' => $variantData['currency'] ?? $store->default_currency, + 'status' => $variantData['status'] ?? VariantStatus::Active, + ]); + + InventoryItem::withoutGlobalScopes()->updateOrCreate( + ['variant_id' => $variant->getKey()], + [ + 'store_id' => $store->getKey(), + 'quantity_on_hand' => $variantData['quantity_on_hand'] ?? 0, + 'quantity_reserved' => 0, + 'policy' => $variantData['inventory_policy'] ?? 'deny', + ], + ); + + $this->syncVariantOptionValues($product, $variant, $variantData); + + return $variant; + } + + /** + * @param array $variantData + */ + private function syncVariantOptionValues(Product $product, ProductVariant $variant, array $variantData): void + { + if (isset($variantData['option_value_ids'])) { + $valueIds = collect($variantData['option_value_ids']) + ->map(fn (mixed $valueId): int => (int) $valueId) + ->values() + ->all(); + + $validCount = ProductOptionValue::query() + ->whereIn('id', $valueIds) + ->whereHas('option', fn ($query) => $query->where('product_id', $product->getKey())) + ->count(); + + if ($validCount !== count($valueIds)) { + throw new InvalidArgumentException('Variant option values must belong to the product being created.'); + } + + $variant->optionValues()->sync($valueIds); + + return; + } + + if (! isset($variantData['options']) || ! is_array($variantData['options'])) { + return; + } + + $valueIds = []; + + foreach ($variantData['options'] as $optionName => $optionValue) { + $valueId = ProductOptionValue::query() + ->where('value', (string) $optionValue) + ->whereHas('option', function ($query) use ($product, $optionName): void { + $query + ->where('product_id', $product->getKey()) + ->where('name', (string) $optionName); + }) + ->value('id'); + + if ($valueId === null) { + throw new InvalidArgumentException("Variant option selection [{$optionName}: {$optionValue}] is invalid for this product."); + } + + $valueIds[] = (int) $valueId; + } + + $variant->optionValues()->sync($valueIds); + } + + private function assertCanActivate(Product $product): void + { + if (trim((string) $product->title) === '') { + throw InvalidProductTransitionException::because('A product title is required before activation.'); + } + + if (! $product->variants()->where('price_amount', '>', 0)->exists()) { + throw InvalidProductTransitionException::because('At least one priced variant is required before activation.'); + } + } + + private function isAllowedTransition(ProductStatus $from, ProductStatus $to): bool + { + return match ($from) { + ProductStatus::Draft => in_array($to, [ProductStatus::Active, ProductStatus::Archived], true), + ProductStatus::Active => in_array($to, [ProductStatus::Archived, ProductStatus::Draft], true), + ProductStatus::Archived => in_array($to, [ProductStatus::Active, ProductStatus::Draft], true), + }; + } + + private function hasOrderLineReferences(Product $product): bool + { + if (! Schema::hasTable('order_lines')) { + return false; + } + + $variantIds = $product->variants()->pluck('id'); + + if ($variantIds->isEmpty()) { + return false; + } + + return DB::table('order_lines') + ->whereIn('variant_id', $variantIds) + ->exists(); + } + + private function assertSkuIsUnique(Store $store, ?string $sku, ?int $excludeVariantId = null): void + { + if ($sku === null || trim($sku) === '') { + return; + } + + $query = ProductVariant::query() + ->where('sku', $sku) + ->whereHas('product', fn ($query) => $query->where('store_id', $store->getKey())); + + if ($excludeVariantId !== null) { + $query->whereKeyNot($excludeVariantId); + } + + if ($query->exists()) { + throw new RuntimeException("The SKU [{$sku}] is already used in this store."); + } + } + + private function productStatus(Product $product): ProductStatus + { + return $product->status instanceof ProductStatus + ? $product->status + : ProductStatus::from($product->status); + } + + /** + * @param array $data + */ + private function requestedProductStatus(array $data): ?ProductStatus + { + if (! array_key_exists('status', $data)) { + return null; + } + + if ($data['status'] instanceof ProductStatus) { + return $data['status']; + } + + return ProductStatus::from((string) $data['status']); + } +} diff --git a/app/Services/RefundService.php b/app/Services/RefundService.php new file mode 100644 index 00000000..5886b7b3 --- /dev/null +++ b/app/Services/RefundService.php @@ -0,0 +1,207 @@ +, reason?: string|null, restock?: bool} $request + */ + public function process(Order $order, array $request = []): Refund + { + return DB::transaction(function () use ($order, $request): Refund { + $order = $this->freshOrder($order); + $payment = $this->refundablePayment($order); + $refundable = $this->refundableAmount($order); + $lineQuantities = $this->lineQuantities($order, $request['lines'] ?? []); + $amount = $this->refundAmount($order, $request, $lineQuantities, $refundable); + + $result = $this->payments->refund($payment, $amount); + + if (! $result->success) { + throw InvalidRefundOperationException::because($result->errorMessage ?? 'Refund could not be processed.'); + } + + $refund = $order->refunds()->create([ + 'payment_id' => $payment->getKey(), + 'amount' => $amount, + 'reason' => data_get($request, 'reason'), + 'status' => $result->status, + 'provider_refund_id' => $result->referenceId, + ]); + + if ((bool) data_get($request, 'restock', false)) { + $this->restock($order, $lineQuantities); + } + + $this->updateOrderFinancialStatus($order, $payment); + + $order = $order->refresh(); + $refund = $refund->refresh(); + + event(new OrderRefunded($order, $refund)); + + return $refund; + }); + } + + private function freshOrder(Order $order): Order + { + return Order::withoutGlobalScopes() + ->with(['lines', 'payments', 'refunds']) + ->whereKey($order->getKey()) + ->lockForUpdate() + ->firstOrFail(); + } + + private function refundablePayment(Order $order): Payment + { + $payment = $order->payments + ->where('status', PaymentStatus::Captured) + ->sortByDesc('id') + ->first(); + + if (! $payment instanceof Payment) { + throw InvalidRefundOperationException::because('Order does not have a captured payment to refund.'); + } + + return $payment; + } + + private function refundableAmount(Order $order): int + { + $refunded = $order->refunds + ->reject(fn (Refund $refund): bool => $refund->status === RefundStatus::Failed) + ->sum('amount'); + + return $order->total_amount - $refunded; + } + + /** + * @param array $request + * @param Collection $lineQuantities + */ + private function refundAmount(Order $order, array $request, Collection $lineQuantities, int $refundable): int + { + $amount = array_key_exists('amount', $request) + ? (int) $request['amount'] + : ($lineQuantities->isNotEmpty() ? $this->lineRefundAmount($order, $lineQuantities) : $refundable); + + if ($amount <= 0) { + throw InvalidRefundOperationException::because('Refund amount must be greater than zero.'); + } + + if ($amount > $refundable) { + throw InvalidRefundOperationException::because('Refund amount exceeds the remaining refundable amount.'); + } + + return $amount; + } + + /** + * @param array $lines + * @return Collection + */ + private function lineQuantities(Order $order, array $lines): Collection + { + return collect($lines) + ->mapWithKeys(fn (mixed $quantity, int|string $lineId): array => [(int) $lineId => (int) $quantity]) + ->filter(fn (int $quantity): bool => $quantity > 0) + ->each(function (int $quantity, int $lineId) use ($order): void { + $line = $order->lines->firstWhere('id', $lineId); + + if (! $line instanceof OrderLine) { + throw InvalidRefundOperationException::because('Refund line does not belong to this order.'); + } + + if ($quantity > $line->quantity) { + throw InvalidRefundOperationException::because('Refund quantity exceeds the ordered quantity.'); + } + }); + } + + /** + * @param Collection $lineQuantities + */ + private function lineRefundAmount(Order $order, Collection $lineQuantities): int + { + return $lineQuantities->reduce(function (int $total, int $quantity, int $lineId) use ($order): int { + $line = $order->lines->firstWhere('id', $lineId); + + if (! $line instanceof OrderLine) { + return $total; + } + + return $total + (int) round($line->total_amount / $line->quantity * $quantity); + }, 0); + } + + /** + * @param Collection $lineQuantities + */ + private function restock(Order $order, Collection $lineQuantities): void + { + $linesToRestock = $lineQuantities->isNotEmpty() + ? $lineQuantities + : $order->lines->mapWithKeys(fn (OrderLine $line): array => [$line->getKey() => $line->quantity]); + + $linesToRestock->each(function (int $quantity, int $lineId) use ($order): void { + $line = $order->lines->firstWhere('id', $lineId); + + if (! $line instanceof OrderLine || $line->variant_id === null) { + return; + } + + $item = InventoryItem::withoutGlobalScopes() + ->where('variant_id', $line->variant_id) + ->first(); + + if ($item instanceof InventoryItem) { + $this->inventory->restock($item, $quantity); + } + }); + } + + private function updateOrderFinancialStatus(Order $order, Payment $payment): void + { + $totalRefunded = $order->refunds() + ->where('status', '!=', RefundStatus::Failed->value) + ->sum('amount'); + + if ($totalRefunded >= $order->total_amount) { + $order->forceFill([ + 'status' => OrderStatus::Refunded, + 'financial_status' => FinancialStatus::Refunded, + ])->save(); + + $payment->forceFill([ + 'status' => PaymentStatus::Refunded, + ])->save(); + + return; + } + + $order->forceFill([ + 'financial_status' => FinancialStatus::PartiallyRefunded, + ])->save(); + } +} diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php new file mode 100644 index 00000000..e674b8e6 --- /dev/null +++ b/app/Services/SearchService.php @@ -0,0 +1,486 @@ + $filters + */ + public function search(Store $store, string $query, array $filters = [], int $perPage = 24, string $sort = 'relevance'): LengthAwarePaginator + { + $query = trim($query); + $perPage = max(1, min(50, $perPage)); + + if ($query === '') { + return $this->browse($store, $filters, $perPage, $sort); + } + + $paginator = $this->baseSearchQuery($store, $query, $filters) + ->tap(fn (QueryBuilder $builder) => $this->applySort($builder, $sort, true)) + ->paginate($perPage, ['products.id'], 'page', Paginator::resolveCurrentPage()); + + $paginator->setCollection($this->hydrateProducts( + collect($paginator->items())->pluck('id')->map(fn (mixed $id): int => (int) $id)->all() + )); + + $this->log($store, $query, $filters, $paginator->total()); + + return $paginator; + } + + public function autocomplete(Store $store, string $prefix, int $limit = 5): Collection + { + $prefix = trim($prefix); + $limit = max(1, min(10, $limit)); + + if ($prefix === '') { + return collect(); + } + + $ids = $this->baseSearchQuery($store, $prefix, []) + ->orderByRaw('products_fts.rank') + ->limit($limit) + ->pluck('products.id') + ->map(fn (mixed $id): int => (int) $id) + ->all(); + + return $this->hydrateProducts($ids); + } + + public function suggestions(Store $store, string $prefix, int $limit = 5): Collection + { + $productLimit = max(1, min(10, $limit)); + + $products = $this->autocomplete($store, $prefix, $productLimit) + ->map(function (Product $product): array { + $variant = $product->variants->first(); + + return [ + 'type' => 'product', + 'title' => $product->title, + 'handle' => $product->handle, + 'image_url' => $product->media->first()?->storage_key, + 'price_amount' => $variant?->price_amount, + 'currency' => $variant?->currency ?? $product->store?->default_currency, + ]; + }); + + $remaining = max(0, $productLimit - $products->count()); + + $collections = $remaining > 0 + ? ProductCollection::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('status', 'active') + ->where('title', 'like', '%'.str_replace(['%', '_'], ['\%', '\_'], trim($prefix)).'%') + ->oldest('title') + ->limit($remaining) + ->get() + ->map(fn (ProductCollection $collection): array => [ + 'type' => 'collection', + 'title' => $collection->title, + 'handle' => $collection->handle, + 'image_url' => null, + ]) + : collect(); + + return $products->concat($collections)->values(); + } + + /** + * @param array $filters + * @return array{vendors: list, tags: list, price_range: array{min: int|null, max: int|null}} + */ + public function facets(Store $store, string $query, array $filters = []): array + { + $ids = trim($query) === '' + ? $this->activeProductQuery($store, $filters)->limit(1000)->pluck('products.id')->all() + : $this->baseSearchQuery($store, $query, $filters)->limit(1000)->pluck('products.id')->all(); + + $ids = collect($ids)->map(fn (mixed $id): int => (int) $id)->all(); + + if ($ids === []) { + return [ + 'vendors' => [], + 'tags' => [], + 'price_range' => ['min' => null, 'max' => null], + ]; + } + + $vendors = Product::withoutGlobalScopes() + ->whereIn('id', $ids) + ->whereNotNull('vendor') + ->selectRaw('vendor as value, COUNT(*) as count') + ->groupBy('vendor') + ->orderBy('vendor') + ->get() + ->map(fn (Product $product): array => [ + 'value' => (string) $product->value, + 'count' => (int) $product->count, + ]) + ->all(); + + $tags = Product::withoutGlobalScopes() + ->whereIn('id', $ids) + ->get(['tags']) + ->flatMap(fn (Product $product): array => $product->tags ?? []) + ->filter() + ->countBy() + ->sortKeys() + ->map(fn (int $count, string $tag): array => [ + 'value' => $tag, + 'count' => $count, + ]) + ->values() + ->all(); + + $priceRange = ProductVariant::withoutGlobalScopes() + ->whereIn('product_id', $ids) + ->selectRaw('MIN(price_amount) as min_price, MAX(price_amount) as max_price') + ->first(); + + return [ + 'vendors' => $vendors, + 'tags' => $tags, + 'price_range' => [ + 'min' => $priceRange?->min_price === null ? null : (int) $priceRange->min_price, + 'max' => $priceRange?->max_price === null ? null : (int) $priceRange->max_price, + ], + ]; + } + + public function syncProduct(Product $product): void + { + DB::table('products_fts') + ->where('product_id', $product->getKey()) + ->delete(); + + DB::table('products_fts')->insert([ + 'store_id' => $product->store_id, + 'product_id' => $product->getKey(), + 'title' => $product->title, + 'description' => trim(strip_tags((string) $product->description_html)), + 'vendor' => (string) $product->vendor, + 'product_type' => (string) $product->product_type, + 'tags' => collect(Arr::wrap($product->tags))->filter()->implode(' '), + ]); + } + + public function removeProduct(int $productId): void + { + DB::table('products_fts') + ->where('product_id', $productId) + ->delete(); + } + + public function reindex(Store $store): int + { + DB::table('products_fts') + ->where('store_id', $store->getKey()) + ->delete(); + + $count = 0; + + Product::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->orderBy('id') + ->chunkById(100, function (Collection $products) use (&$count): void { + $products->each(function (Product $product) use (&$count): void { + $this->syncProduct($product); + $count++; + }); + }); + + return $count; + } + + /** + * @param array $filters + */ + private function browse(Store $store, array $filters, int $perPage, string $sort): LengthAwarePaginator + { + $paginator = $this->activeProductQuery($store, $filters) + ->tap(fn (QueryBuilder $builder) => $this->applySort($builder, $sort, false)) + ->paginate($perPage, ['products.id'], 'page', Paginator::resolveCurrentPage()); + + $paginator->setCollection($this->hydrateProducts( + collect($paginator->items())->pluck('id')->map(fn (mixed $id): int => (int) $id)->all() + )); + + return $paginator; + } + + /** + * @param array $filters + */ + private function baseSearchQuery(Store $store, string $query, array $filters): QueryBuilder + { + $match = $this->toFtsQuery($store, $query); + + if ($match === '') { + return $this->activeProductQuery($store, $filters); + } + + return $this->activeProductQuery($store, $filters) + ->join('products_fts', 'products_fts.product_id', '=', 'products.id') + ->where('products_fts.store_id', $store->getKey()) + ->whereRaw('products_fts MATCH ?', [$match]); + } + + /** + * @param array $filters + */ + private function activeProductQuery(Store $store, array $filters): QueryBuilder + { + $builder = DB::table('products') + ->select('products.id') + ->where('products.store_id', $store->getKey()) + ->where('products.status', ProductStatus::Active->value) + ->whereNotNull('products.published_at'); + + $this->applyFilters($builder, $filters); + + return $builder; + } + + /** + * @param array $filters + */ + private function applyFilters(QueryBuilder $builder, array $filters): void + { + if (isset($filters['collection_id'])) { + $builder->whereExists(function (QueryBuilder $query) use ($filters): void { + $query + ->selectRaw('1') + ->from('collection_products') + ->whereColumn('collection_products.product_id', 'products.id') + ->where('collection_products.collection_id', (int) $filters['collection_id']); + }); + } + + $vendors = collect(Arr::wrap($filters['vendor'] ?? [])) + ->filter(fn (mixed $vendor): bool => filled($vendor)) + ->map(fn (mixed $vendor): string => (string) $vendor) + ->values() + ->all(); + + if ($vendors !== []) { + $builder->whereIn('products.vendor', $vendors); + } + + $productTypes = collect(Arr::wrap($filters['product_type'] ?? [])) + ->filter(fn (mixed $productType): bool => filled($productType)) + ->map(fn (mixed $productType): string => (string) $productType) + ->values() + ->all(); + + if ($productTypes !== []) { + $builder->whereIn('products.product_type', $productTypes); + } + + foreach (Arr::wrap($filters['tags'] ?? []) as $tag) { + if (filled($tag)) { + $builder->where('products.tags', 'like', '%"'.str_replace(['%', '_'], ['\%', '\_'], (string) $tag).'"%'); + } + } + + if (isset($filters['price_min']) || isset($filters['price_max'])) { + $builder->whereExists(function (QueryBuilder $query) use ($filters): void { + $query + ->selectRaw('1') + ->from('product_variants') + ->whereColumn('product_variants.product_id', 'products.id'); + + if (isset($filters['price_min'])) { + $query->where('product_variants.price_amount', '>=', (int) $filters['price_min']); + } + + if (isset($filters['price_max'])) { + $query->where('product_variants.price_amount', '<=', (int) $filters['price_max']); + } + }); + } + + if (filter_var($filters['in_stock'] ?? false, FILTER_VALIDATE_BOOL)) { + $builder->whereExists(function (QueryBuilder $query): void { + $query + ->selectRaw('1') + ->from('product_variants') + ->join('inventory_items', 'inventory_items.variant_id', '=', 'product_variants.id') + ->whereColumn('product_variants.product_id', 'products.id') + ->where(function (QueryBuilder $query): void { + $query + ->whereRaw('inventory_items.quantity_on_hand > inventory_items.quantity_reserved') + ->orWhere('inventory_items.policy', InventoryPolicy::Continue->value); + }); + }); + } + } + + private function applySort(QueryBuilder $builder, string $sort, bool $hasRank): void + { + match ($sort) { + 'price_asc' => $builder + ->orderByRaw('(SELECT MIN(price_amount) FROM product_variants WHERE product_variants.product_id = products.id) ASC') + ->orderBy('products.id'), + 'price_desc' => $builder + ->orderByRaw('(SELECT MIN(price_amount) FROM product_variants WHERE product_variants.product_id = products.id) DESC') + ->orderBy('products.id'), + 'newest' => $builder + ->orderByDesc('products.published_at') + ->orderByDesc('products.id'), + 'best_selling' => $builder + ->orderByRaw('(SELECT COALESCE(SUM(order_lines.quantity), 0) FROM order_lines WHERE order_lines.product_id = products.id) DESC') + ->orderByDesc('products.published_at') + ->orderByDesc('products.id'), + default => $hasRank + ? $builder->orderByRaw('products_fts.rank') + : $builder->orderByDesc('products.published_at')->orderByDesc('products.id'), + }; + } + + /** + * @param list $ids + * @return Collection + */ + private function hydrateProducts(array $ids): Collection + { + if ($ids === []) { + return collect(); + } + + $products = Product::withoutGlobalScopes() + ->with(['store', 'variants.inventoryItem', 'media']) + ->withCount('variants') + ->whereIn('id', $ids) + ->get() + ->keyBy('id'); + + return collect($ids) + ->map(fn (int $id): ?Product => $products->get($id)) + ->filter() + ->values(); + } + + private function toFtsQuery(Store $store, string $query): string + { + $settings = $this->settings($store); + $stopWords = collect($settings?->stop_words_json ?? []) + ->map(fn (mixed $word): string => (string) $word) + ->flatMap(fn (string $word): array => $this->tokens($word)) + ->unique() + ->values(); + $synonyms = $this->synonymExpressions($settings); + $tokens = collect($this->tokens($query)) + ->reject(fn (string $token): bool => $stopWords->contains($token)) + ->take(8) + ->values() + ->all(); + $lastIndex = count($tokens) - 1; + + return collect($tokens) + ->map(function (string $token, int $index) use ($lastIndex, $synonyms): string { + $prefix = $index === $lastIndex; + $expressions = $synonyms[$token] ?? [$this->termExpression([$token], $prefix)]; + + if (count($expressions) === 1) { + return $expressions[0]; + } + + return '('.implode(' OR ', $expressions).')'; + }) + ->implode(' AND '); + } + + /** + * @return list + */ + private function tokens(string $value): array + { + return preg_split('/[^\pL\pN]+/u', mb_strtolower($value), -1, PREG_SPLIT_NO_EMPTY) ?: []; + } + + /** + * @return array> + */ + private function synonymExpressions(?SearchSettings $settings): array + { + $synonyms = []; + + foreach ($settings?->synonyms_json ?? [] as $group) { + $terms = collect(Arr::wrap($group)) + ->map(fn (mixed $term): array => $this->tokens((string) $term)) + ->filter() + ->values(); + $expressions = $terms + ->map(fn (array $tokens): string => $this->termExpression($tokens, true)) + ->unique() + ->values() + ->all(); + + if (count($expressions) < 2) { + continue; + } + + foreach ($terms as $tokens) { + foreach ($tokens as $token) { + $synonyms[$token] = collect($synonyms[$token] ?? []) + ->merge($expressions) + ->unique() + ->values() + ->all(); + } + } + } + + return $synonyms; + } + + /** + * @param list $tokens + */ + private function termExpression(array $tokens, bool $prefix): string + { + if (count($tokens) === 1) { + return $tokens[0].($prefix ? '*' : ''); + } + + return '"'.implode(' ', $tokens).'"'; + } + + private function settings(Store $store): ?SearchSettings + { + return SearchSettings::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->first(); + } + + /** + * @param array $filters + */ + private function log(Store $store, string $query, array $filters, int $resultsCount): void + { + SearchQuery::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + 'query' => mb_substr($query, 0, 200), + 'filters_json' => $filters, + 'results_count' => $resultsCount, + 'created_at' => now(), + ]); + } +} diff --git a/app/Services/ShippingCalculator.php b/app/Services/ShippingCalculator.php new file mode 100644 index 00000000..6d361ba4 --- /dev/null +++ b/app/Services/ShippingCalculator.php @@ -0,0 +1,145 @@ + $address + * @return Collection + */ + public function getAvailableRates(Store $store, array $address): Collection + { + $zone = $this->matchingZone($store, $address); + + if (! $zone instanceof ShippingZone) { + return collect(); + } + + return ShippingRate::withoutGlobalScopes() + ->where('zone_id', $zone->getKey()) + ->where('is_active', true) + ->orderBy('id') + ->get(); + } + + /** + * @param array $address + */ + public function matchingZone(Store $store, array $address): ?ShippingZone + { + $country = strtoupper((string) (data_get($address, 'country_code') ?: data_get($address, 'country'))); + $region = strtoupper((string) data_get($address, 'province_code')); + $bestZone = null; + $bestSpecificity = -1; + + ShippingZone::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->orderBy('id') + ->get() + ->each(function (ShippingZone $zone) use ($country, $region, &$bestZone, &$bestSpecificity): void { + $countries = collect($zone->countries_json)->map(fn (string $code): string => strtoupper($code)); + $regions = collect($zone->regions_json)->map(fn (string $code): string => strtoupper($code)); + + if (! $countries->contains($country)) { + return; + } + + $specificity = $regions->contains($region) ? 2 : 1; + + if ($specificity > $bestSpecificity) { + $bestZone = $zone; + $bestSpecificity = $specificity; + } + }); + + return $bestZone; + } + + public function calculate(ShippingRate $rate, Cart $cart): ?int + { + if (! $this->requiresShipping($cart)) { + return 0; + } + + return match ($rate->type) { + ShippingRateType::Flat => (int) data_get($rate->config_json, 'amount', 0), + ShippingRateType::Weight => $this->weightRate($rate, $cart), + ShippingRateType::Price => $this->priceRate($rate, $cart), + ShippingRateType::Carrier => (int) data_get($rate->config_json, 'amount', 1299), + }; + } + + public function requiresShipping(Cart $cart): bool + { + return $this->physicalLines($cart)->isNotEmpty(); + } + + private function weightRate(ShippingRate $rate, Cart $cart): ?int + { + $totalWeight = $this->physicalLines($cart) + ->sum(function (CartLine $line): int { + $variant = $this->variant($line); + + return ($variant?->weight_g ?? 0) * $line->quantity; + }); + + foreach (data_get($rate->config_json, 'ranges', []) as $range) { + $minimum = (int) data_get($range, 'min_g', 0); + $maximum = data_get($range, 'max_g'); + + if ($totalWeight >= $minimum && ($maximum === null || $totalWeight <= (int) $maximum)) { + return (int) data_get($range, 'amount', 0); + } + } + + return null; + } + + private function priceRate(ShippingRate $rate, Cart $cart): ?int + { + $subtotal = CartLine::withoutGlobalScopes() + ->where('cart_id', $cart->getKey()) + ->sum('line_subtotal_amount'); + + foreach (data_get($rate->config_json, 'ranges', []) as $range) { + $minimum = (int) data_get($range, 'min_amount', 0); + $maximum = data_get($range, 'max_amount'); + + if ($subtotal >= $minimum && ($maximum === null || $subtotal <= (int) $maximum)) { + return (int) data_get($range, 'amount', 0); + } + } + + return null; + } + + /** + * @return Collection + */ + private function physicalLines(Cart $cart): Collection + { + return CartLine::withoutGlobalScopes() + ->where('cart_id', $cart->getKey()) + ->get() + ->filter(function (CartLine $line): bool { + return (bool) $this->variant($line)?->requires_shipping; + }); + } + + private function variant(CartLine $line): ?ProductVariant + { + return ProductVariant::withoutGlobalScopes() + ->whereKey($line->variant_id) + ->first(); + } +} diff --git a/app/Services/Tax/ManualTaxProvider.php b/app/Services/Tax/ManualTaxProvider.php new file mode 100644 index 00000000..ba2f76e3 --- /dev/null +++ b/app/Services/Tax/ManualTaxProvider.php @@ -0,0 +1,97 @@ + $amounts + * @param array $address + */ + public function calculate(array $amounts, int $shippingAmount, TaxSettings $settings, array $address): TaxResult + { + $rate = $this->rateFor($settings, $address); + + if ($rate <= 0) { + return new TaxResult([], 0, 0); + } + + if ((bool) data_get($settings->config_json, 'shipping_taxable', true) && $shippingAmount > 0) { + $amounts[] = $shippingAmount; + } + + $taxAmount = collect($amounts)->sum(fn (int $amount): int => $settings->prices_include_tax + ? $this->extractInclusive($amount, $rate) + : $this->addExclusive($amount, $rate) + ); + + return new TaxResult([ + new TaxLine($this->nameFor($settings, $address), $rate, $taxAmount), + ], $taxAmount, $rate); + } + + /** + * @param array $address + */ + public function rateFor(TaxSettings $settings, array $address): int + { + $country = strtoupper((string) (data_get($address, 'country_code') ?: data_get($address, 'country'))); + $province = strtoupper((string) data_get($address, 'province_code')); + + foreach (data_get($settings->config_json, 'rates', []) as $rate) { + $rateCountry = strtoupper((string) data_get($rate, 'country')); + $rateProvince = strtoupper((string) data_get($rate, 'province_code')); + + if ($rateCountry !== '' && $rateCountry !== $country) { + continue; + } + + if ($rateProvince !== '' && $rateProvince !== $province) { + continue; + } + + return (int) data_get($rate, 'rate_bps', 0); + } + + return (int) data_get($settings->config_json, 'default_rate_bps', 0); + } + + /** + * @param array $address + */ + public function nameFor(TaxSettings $settings, array $address): string + { + $country = strtoupper((string) (data_get($address, 'country_code') ?: data_get($address, 'country'))); + + foreach (data_get($settings->config_json, 'rates', []) as $rate) { + if (strtoupper((string) data_get($rate, 'country')) === $country && data_get($rate, 'name')) { + return (string) data_get($rate, 'name'); + } + } + + return (string) data_get($settings->config_json, 'name', 'Tax'); + } + + public function extractInclusive(int $grossAmount, int $rateBasisPoints): int + { + if ($grossAmount <= 0 || $rateBasisPoints <= 0) { + return 0; + } + + return $grossAmount - intdiv($grossAmount * 10000, 10000 + $rateBasisPoints); + } + + public function addExclusive(int $netAmount, int $rateBasisPoints): int + { + if ($netAmount <= 0 || $rateBasisPoints <= 0) { + return 0; + } + + return (int) round($netAmount * $rateBasisPoints / 10000); + } +} diff --git a/app/Services/Tax/StripeTaxProvider.php b/app/Services/Tax/StripeTaxProvider.php new file mode 100644 index 00000000..2661a6b8 --- /dev/null +++ b/app/Services/Tax/StripeTaxProvider.php @@ -0,0 +1,24 @@ + $amounts + * @param array $address + */ + public function calculate(array $amounts, int $shippingAmount, TaxSettings $settings, array $address): TaxResult + { + if (data_get($settings->config_json, 'fallback') === 'block') { + throw new RuntimeException('Stripe Tax provider is not configured.'); + } + + return new TaxResult([], 0, 0); + } +} diff --git a/app/Services/TaxCalculator.php b/app/Services/TaxCalculator.php new file mode 100644 index 00000000..5c65f293 --- /dev/null +++ b/app/Services/TaxCalculator.php @@ -0,0 +1,48 @@ + $address + */ + public function calculate(int $amount, TaxSettings $settings, array $address): TaxResult + { + return $this->calculateForAmounts([$amount], 0, $settings, $address); + } + + /** + * @param array $amounts + * @param array $address + */ + public function calculateForAmounts(array $amounts, int $shippingAmount, TaxSettings $settings, array $address): TaxResult + { + if ($settings->mode === TaxMode::Provider && $settings->provider === 'stripe_tax') { + return $this->stripe->calculate($amounts, $shippingAmount, $settings, $address); + } + + return $this->manual->calculate($amounts, $shippingAmount, $settings, $address); + } + + public function extractInclusive(int $grossAmount, int $rateBasisPoints): int + { + return $this->manual->extractInclusive($grossAmount, $rateBasisPoints); + } + + public function addExclusive(int $netAmount, int $rateBasisPoints): int + { + return $this->manual->addExclusive($netAmount, $rateBasisPoints); + } +} diff --git a/app/Services/ThemeArchiveInstaller.php b/app/Services/ThemeArchiveInstaller.php new file mode 100644 index 00000000..5388e875 --- /dev/null +++ b/app/Services/ThemeArchiveInstaller.php @@ -0,0 +1,244 @@ + + */ + private const REQUIRED_PATHS = [ + 'layouts/storefront.blade.php', + 'sections/hero.blade.php', + 'sections/featured-products.blade.php', + ]; + + public function __construct(private ThemeSettingsService $settings) {} + + public function install(Store $store, UploadedFile $archive, ?string $name = null): Theme + { + [$files, $manifest] = $this->readArchive($archive); + + $this->validateStructure($files); + + $themeName = trim((string) ($name ?: data_get($manifest, 'name', ''))); + + if ($themeName === '') { + throw ValidationException::withMessages([ + 'name' => __('The theme archive manifest must include a name.'), + ]); + } + + $version = trim((string) data_get($manifest, 'version', '1.0.0')); + $settings = data_get($manifest, 'settings_json', data_get($manifest, 'settings')); + + if (mb_strlen($themeName) > 255) { + throw ValidationException::withMessages([ + 'name' => __('The theme name may not be greater than 255 characters.'), + ]); + } + + if (mb_strlen($version) > 255) { + throw ValidationException::withMessages([ + 'file' => __('The theme archive manifest version may not be greater than 255 characters.'), + ]); + } + + return DB::transaction(function () use ($files, $settings, $store, $themeName, $version): Theme { + $theme = Theme::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + 'name' => $themeName, + 'version' => $version !== '' ? $version : null, + 'status' => ThemeStatus::Draft, + 'published_at' => null, + ]); + + foreach ($files as $path => $contents) { + $storageKey = "themes/{$theme->getKey()}/{$path}"; + + Storage::disk('local')->put($storageKey, $contents); + + ThemeFile::withoutGlobalScopes()->create([ + 'theme_id' => $theme->getKey(), + 'path' => $path, + 'storage_key' => $storageKey, + 'sha256' => hash('sha256', $contents), + 'byte_size' => strlen($contents), + ]); + } + + ThemeSettings::withoutGlobalScopes()->create([ + 'theme_id' => $theme->getKey(), + 'settings_json' => is_array($settings) && ! array_is_list($settings) + ? $settings + : $this->settings->defaultsForStore($store), + 'updated_at' => now(), + ]); + + return $theme->load('settings')->loadCount('files'); + }); + } + + /** + * @return list + */ + public static function requiredPaths(): array + { + return self::REQUIRED_PATHS; + } + + /** + * @return array{0: array, 1: array} + */ + private function readArchive(UploadedFile $archive): array + { + $zip = new ZipArchive; + $realPath = $archive->getRealPath(); + + if (! is_string($realPath) || $zip->open($realPath) !== true) { + throw ValidationException::withMessages([ + 'file' => __('The theme archive could not be opened.'), + ]); + } + + $files = []; + + try { + for ($index = 0; $index < $zip->numFiles; $index++) { + $name = $zip->getNameIndex($index); + + if (! is_string($name)) { + continue; + } + + $path = $this->normalizePath($name); + + if ($path === null) { + continue; + } + + $contents = $zip->getFromIndex($index); + + if (! is_string($contents)) { + throw ValidationException::withMessages([ + 'file' => __('The theme archive contains an unreadable file.'), + ]); + } + + $files[$path] = $contents; + } + } finally { + $zip->close(); + } + + $files = $this->stripRootDirectory($files); + $manifestPath = array_key_exists('theme.json', $files) + ? 'theme.json' + : (array_key_exists('manifest.json', $files) ? 'manifest.json' : null); + + if ($manifestPath === null) { + throw ValidationException::withMessages([ + 'file' => __('The theme archive must contain a theme.json manifest.'), + ]); + } + + $manifest = json_decode($files[$manifestPath], true); + + if (! is_array($manifest) || array_is_list($manifest)) { + throw ValidationException::withMessages([ + 'file' => __('The theme archive manifest must be a valid JSON object.'), + ]); + } + + return [$files, $manifest]; + } + + private function normalizePath(string $name): ?string + { + $path = str_replace('\\', '/', $name); + + if ($path === '' || str_ends_with($path, '/')) { + return null; + } + + if (str_starts_with($path, '/') || str_starts_with($path, '__MACOSX/')) { + throw ValidationException::withMessages([ + 'file' => __('The theme archive contains an invalid file path.'), + ]); + } + + $segments = explode('/', $path); + + foreach ($segments as $segment) { + if ($segment === '' || $segment === '.' || $segment === '..') { + throw ValidationException::withMessages([ + 'file' => __('The theme archive contains an invalid file path.'), + ]); + } + } + + if (end($segments) === '.DS_Store') { + return null; + } + + if (strlen($path) > 255) { + throw ValidationException::withMessages([ + 'file' => __('The theme archive contains a file path that is too long.'), + ]); + } + + return $path; + } + + /** + * @param array $files + * @return array + */ + private function stripRootDirectory(array $files): array + { + if ($files === []) { + return $files; + } + + $paths = array_keys($files); + + if (! collect($paths)->every(fn (string $path): bool => str_contains($path, '/'))) { + return $files; + } + + $root = explode('/', $paths[0], 2)[0]; + + if (! collect($paths)->every(fn (string $path): bool => str_starts_with($path, "{$root}/"))) { + return $files; + } + + return collect($files) + ->mapWithKeys(fn (string $contents, string $path): array => [substr($path, strlen($root) + 1) => $contents]) + ->all(); + } + + /** + * @param array $files + */ + private function validateStructure(array $files): void + { + $missing = array_values(array_diff(self::REQUIRED_PATHS, array_keys($files))); + + if ($missing !== []) { + throw ValidationException::withMessages([ + 'file' => __('The theme archive is missing required file: :path', ['path' => $missing[0]]), + ]); + } + } +} diff --git a/app/Services/ThemeSettingsService.php b/app/Services/ThemeSettingsService.php new file mode 100644 index 00000000..70a2d468 --- /dev/null +++ b/app/Services/ThemeSettingsService.php @@ -0,0 +1,85 @@ + + */ + public function forStore(Store $store): array + { + return Cache::remember($this->cacheKey($store), now()->addMinutes(5), function () use ($store): array { + $theme = $this->publishedTheme($store); + + if ($theme === null) { + return $this->defaultsForStore($store); + } + + $settings = $theme->settings?->settings_json ?? []; + + return array_replace_recursive($this->defaultsForStore($store), $settings); + }); + } + + public function publishedTheme(Store $store): ?Theme + { + return Theme::withoutGlobalScopes() + ->with('settings') + ->where('store_id', $store->getKey()) + ->where('status', ThemeStatus::Published) + ->whereNotNull('published_at') + ->latest('published_at') + ->first(); + } + + public function forget(Store $store): void + { + Cache::forget($this->cacheKey($store)); + } + + /** + * @return array + */ + public function defaultsForStore(Store $store): array + { + return [ + 'announcement' => [ + 'enabled' => true, + 'text' => "Free shipping on orders over 75.00 {$store->default_currency}", + 'url' => null, + ], + 'header' => [ + 'sticky' => true, + 'main_menu' => 'main-menu', + ], + 'footer' => [ + 'menu' => 'footer-menu', + 'tagline' => 'A self-contained demo storefront with scoped catalog data and checkout-ready products.', + ], + 'home' => [ + 'hero' => [ + 'eyebrow' => 'New season essentials', + 'heading' => $store->name, + 'subheading' => 'Browse a scoped demo catalog with variants, inventory states, sale pricing, and digital products.', + 'primary_label' => 'Shop new arrivals', + 'primary_url' => '/collections/new-arrivals', + 'secondary_label' => 'View collections', + 'secondary_url' => '/collections', + ], + 'featured_product_limit' => 8, + 'featured_collection_limit' => 4, + ], + ]; + } + + private function cacheKey(Store $store): string + { + return "theme_settings:{$store->getKey()}"; + } +} diff --git a/app/Services/VariantMatrixService.php b/app/Services/VariantMatrixService.php new file mode 100644 index 00000000..1de8d3aa --- /dev/null +++ b/app/Services/VariantMatrixService.php @@ -0,0 +1,203 @@ +load(['options.values', 'variants.optionValues']); + + $valueGroups = $product->options + ->sortBy('position') + ->map(fn ($option) => $option->values->sortBy('position')->values()) + ->filter(fn (Collection $values): bool => $values->isNotEmpty()) + ->values(); + + if ($valueGroups->isEmpty()) { + $this->ensureDefaultVariant($product); + + return; + } + + $desiredCombinations = $this->cartesianProduct($valueGroups); + $variantsByCombination = $product->variants->keyBy(function (ProductVariant $variant): string { + return $this->combinationKey($variant->optionValues->pluck('id')->all()); + }); + + $template = $product->variants->sortBy('position')->first(); + + foreach ($desiredCombinations as $position => $combination) { + $key = $this->combinationKey($combination); + + if ($variantsByCombination->has($key)) { + continue; + } + + $variant = $this->createVariantFromTemplate($product, $template, $position); + $variant->optionValues()->sync($combination); + } + + $desiredKeys = collect($desiredCombinations) + ->map(fn (array $combination): string => $this->combinationKey($combination)) + ->all(); + + foreach ($product->variants as $variant) { + $key = $this->combinationKey($variant->optionValues->pluck('id')->all()); + + if (in_array($key, $desiredKeys, true)) { + continue; + } + + if ($this->variantHasOrderLines($variant)) { + $variant->forceFill(['status' => VariantStatus::Archived])->save(); + } else { + $variant->delete(); + } + } + }); + } + + private function ensureDefaultVariant(Product $product): void + { + $variants = $product->variants()->with('optionValues')->get(); + + if ($variants->isEmpty()) { + $variant = $product->variants()->create([ + 'price_amount' => 0, + 'currency' => $this->store($product)->default_currency, + 'requires_shipping' => true, + 'is_default' => true, + 'position' => 0, + 'status' => VariantStatus::Active, + ]); + + InventoryItem::withoutGlobalScopes()->firstOrCreate( + ['variant_id' => $variant->getKey()], + [ + 'store_id' => $product->store_id, + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ], + ); + + return; + } + + $defaultVariant = $variants->sortBy('position')->first(); + + $defaultVariant->optionValues()->detach(); + $defaultVariant->forceFill([ + 'is_default' => true, + 'position' => 0, + 'status' => VariantStatus::Active, + ])->save(); + + InventoryItem::withoutGlobalScopes()->firstOrCreate( + ['variant_id' => $defaultVariant->getKey()], + [ + 'store_id' => $product->store_id, + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ], + ); + + foreach ($variants as $variant) { + if ($variant->is($defaultVariant)) { + continue; + } + + if ($this->variantHasOrderLines($variant)) { + $variant->optionValues()->detach(); + $variant->forceFill([ + 'is_default' => false, + 'status' => VariantStatus::Archived, + ])->save(); + } else { + $variant->delete(); + } + } + } + + /** + * @param Collection> $groups + * @return array> + */ + private function cartesianProduct(Collection $groups): array + { + return $groups->reduce( + function (array $carry, Collection $group): array { + $result = []; + + foreach ($carry as $combination) { + foreach ($group as $value) { + $result[] = [...$combination, $value->getKey()]; + } + } + + return $result; + }, + [[]], + ); + } + + /** + * @param array $ids + */ + private function combinationKey(array $ids): string + { + sort($ids); + + return implode(':', $ids); + } + + private function createVariantFromTemplate(Product $product, ?ProductVariant $template, int $position): ProductVariant + { + $variant = $product->variants()->create([ + 'price_amount' => $template?->price_amount ?? 0, + 'compare_at_amount' => $template?->compare_at_amount, + 'currency' => $template?->currency ?? $this->store($product)->default_currency, + 'weight_g' => $template?->weight_g, + 'requires_shipping' => $template?->requires_shipping ?? true, + 'is_default' => false, + 'position' => $position, + 'status' => VariantStatus::Active, + ]); + + InventoryItem::withoutGlobalScopes()->updateOrCreate( + ['variant_id' => $variant->getKey()], + [ + 'store_id' => $product->store_id, + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ], + ); + + return $variant; + } + + private function variantHasOrderLines(ProductVariant $variant): bool + { + return Schema::hasTable('order_lines') + && DB::table('order_lines')->where('variant_id', $variant->getKey())->exists(); + } + + private function store(Product $product): Store + { + return Store::query()->findOrFail($product->store_id); + } +} diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php new file mode 100644 index 00000000..7114b6fb --- /dev/null +++ b/app/Services/WebhookService.php @@ -0,0 +1,157 @@ + $payload + */ + public function dispatch(Store $store, string $eventType, array $payload): void + { + $event = WebhookEventType::tryFrom($eventType); + + if (! $event instanceof WebhookEventType) { + throw new InvalidArgumentException("Unsupported webhook event type [{$eventType}]."); + } + + WebhookSubscription::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('event_type', $event->value) + ->where('status', WebhookSubscriptionStatus::Active->value) + ->get() + ->each(function (WebhookSubscription $subscription) use ($store, $event, $payload): void { + $delivery = WebhookDelivery::query()->create([ + 'subscription_id' => $subscription->getKey(), + 'event_id' => (string) Str::uuid(), + 'attempt_count' => 1, + 'status' => WebhookDeliveryStatus::Pending, + ]); + + DeliverWebhook::dispatch( + $delivery->getKey(), + $event->value, + $this->envelope($store, $event, $delivery->event_id, $payload), + )->onConnection('database')->onQueue('webhooks'); + }); + } + + public function sign(string $payload, string $secret): string + { + return hash_hmac('sha256', $payload, $secret); + } + + public function verify(string $payload, string $signature, string $secret): bool + { + return hash_equals($this->sign($payload, $secret), $signature); + } + + /** + * @param list $abilities + * @return array{token: PersonalAccessToken, plain_text: string} + */ + public function createApiToken(Store $store, string $name, array $abilities, ?User $user = null): array + { + $authenticatedUser = auth()->user(); + $user ??= $authenticatedUser instanceof User + ? $authenticatedUser + : $store->users() + ->wherePivot('role', 'owner') + ->firstOrFail(); + $plainText = 'shop_'.Str::random(48); + $token = PersonalAccessToken::query()->create([ + 'store_id' => $store->getKey(), + 'tokenable_type' => $user->getMorphClass(), + 'tokenable_id' => $user->getKey(), + 'name' => $name, + 'token' => hash('sha256', $plainText), + 'abilities' => $abilities, + 'expires_at' => now()->addYear(), + 'created_at' => now(), + ]); + + app(AuditLogger::class)->log('api_token.created', userId: $user->getKey(), storeId: $store->getKey(), extra: [ + 'token_name' => $name, + 'abilities' => $abilities, + ]); + + return [ + 'token' => $token, + 'plain_text' => $plainText, + ]; + } + + public function createSigningSecret(): string + { + return 'whsec_'.Str::random(40); + } + + public function recordSuccess(WebhookDelivery $delivery, int $attemptCount, int $responseCode, string $responseBody): void + { + $delivery->forceFill([ + 'attempt_count' => $attemptCount, + 'status' => WebhookDeliveryStatus::Success, + 'last_attempt_at' => now(), + 'response_code' => $responseCode, + 'response_body_snippet' => Str::limit($responseBody, 1000, ''), + ])->save(); + } + + public function recordFailure(WebhookDelivery $delivery, int $attemptCount, ?int $responseCode, ?string $responseBody): void + { + $delivery->forceFill([ + 'attempt_count' => $attemptCount, + 'status' => WebhookDeliveryStatus::Failed, + 'last_attempt_at' => now(), + 'response_code' => $responseCode, + 'response_body_snippet' => Str::limit((string) $responseBody, 1000, ''), + ])->save(); + + $subscription = $delivery->subscription()->first(); + + if ($subscription instanceof WebhookSubscription && $this->hasFiveConsecutiveFailures($subscription)) { + $subscription->forceFill([ + 'status' => WebhookSubscriptionStatus::Paused, + ])->save(); + } + } + + public function hasFiveConsecutiveFailures(WebhookSubscription $subscription): bool + { + $recentStatuses = $subscription->deliveries() + ->latest('id') + ->limit(5) + ->pluck('status'); + + return $recentStatuses->count() === 5 + && $recentStatuses->every(fn (WebhookDeliveryStatus|string $status): bool => $status === WebhookDeliveryStatus::Failed || $status === WebhookDeliveryStatus::Failed->value); + } + + /** + * @param array $payload + * @return array + */ + private function envelope(Store $store, WebhookEventType $event, string $eventId, array $payload): array + { + return [ + 'id' => $eventId, + 'api_version' => '2026-05', + 'event_type' => $event->value, + 'store_id' => $store->getKey(), + 'occurred_at' => now()->toISOString(), + 'data' => $payload, + ]; + } +} diff --git a/app/Support/CheckoutAccessToken.php b/app/Support/CheckoutAccessToken.php new file mode 100644 index 00000000..7028b4e6 --- /dev/null +++ b/app/Support/CheckoutAccessToken.php @@ -0,0 +1,43 @@ +store_id, + $checkout->getKey(), + $checkout->cart_id, + $checkout->created_at?->timestamp ?? 0, + ]); + } + + private static function key(): string + { + $key = (string) Config::get('app.key'); + + return Str::startsWith($key, 'base64:') + ? base64_decode(Str::after($key, 'base64:'), true) ?: $key + : $key; + } +} diff --git a/app/Support/HandleGenerator.php b/app/Support/HandleGenerator.php new file mode 100644 index 00000000..905a31fa --- /dev/null +++ b/app/Support/HandleGenerator.php @@ -0,0 +1,38 @@ +exists($table, $storeId, $handle, $excludeId)) { + $handle = "{$base}-{$suffix}"; + $suffix++; + } + + return $handle; + } + + private function exists(string $table, int $storeId, string $handle, ?int $excludeId): bool + { + $query = DB::table($table) + ->where('store_id', $storeId) + ->where('handle', $handle); + + if ($excludeId !== null) { + $query->where('id', '!=', $excludeId); + } + + return $query->exists(); + } +} diff --git a/app/Support/Money.php b/app/Support/Money.php new file mode 100644 index 00000000..074ca696 --- /dev/null +++ b/app/Support/Money.php @@ -0,0 +1,20 @@ +store_id, + $order->getKey(), + $order->order_number, + $order->created_at?->timestamp ?? 0, + ]); + } + + private static function key(): string + { + $key = (string) Config::get('app.key'); + + return Str::startsWith($key, 'base64:') + ? base64_decode(Str::after($key, 'base64:'), true) ?: $key + : $key; + } +} diff --git a/app/Traits/ChecksStoreRole.php b/app/Traits/ChecksStoreRole.php new file mode 100644 index 00000000..56317803 --- /dev/null +++ b/app/Traits/ChecksStoreRole.php @@ -0,0 +1,77 @@ +currentStoreId(); + + if (! $storeId) { + return null; + } + + return $user->roleForStoreId($storeId); + } + + /** + * @param array $roles + */ + public function hasRole(User $user, ?int $storeId, array $roles): bool + { + $role = $this->getStoreRole($user, $storeId); + + return $role !== null && in_array($role, $roles, true); + } + + public function isOwnerOnly(User $user, ?int $storeId = null): bool + { + return $this->hasRole($user, $storeId, [StoreUserRole::Owner]); + } + + public function isOwnerOrAdmin(User $user, ?int $storeId = null): bool + { + return $this->hasRole($user, $storeId, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + public function isOwnerAdminOrStaff(User $user, ?int $storeId = null): bool + { + return $this->hasRole($user, $storeId, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function isAnyRole(User $user, ?int $storeId = null): bool + { + return $this->getStoreRole($user, $storeId) !== null; + } + + protected function currentStoreId(): ?int + { + if (! app()->bound('current_store')) { + return null; + } + + $store = app('current_store'); + + return $store instanceof Store ? $store->getKey() : null; + } + + protected function storeIdForModel(object $model): ?int + { + if (method_exists($model, 'getAttribute')) { + $storeId = $model->getAttribute('store_id'); + + return is_numeric($storeId) ? (int) $storeId : null; + } + + if (property_exists($model, 'store_id')) { + return is_numeric($model->store_id) ? (int) $model->store_id : null; + } + + return null; + } +} diff --git a/app/ValueObjects/DiscountResult.php b/app/ValueObjects/DiscountResult.php new file mode 100644 index 00000000..4062a112 --- /dev/null +++ b/app/ValueObjects/DiscountResult.php @@ -0,0 +1,15 @@ + $allocations + */ + public function __construct( + public int $amount, + public array $allocations, + public bool $freeShipping = false, + ) {} +} diff --git a/app/ValueObjects/PaymentResult.php b/app/ValueObjects/PaymentResult.php new file mode 100644 index 00000000..5274f385 --- /dev/null +++ b/app/ValueObjects/PaymentResult.php @@ -0,0 +1,30 @@ + $this->success, + 'status' => $this->status->value, + 'reference_id' => $this->referenceId, + 'error_code' => $this->errorCode, + 'error_message' => $this->errorMessage, + ]; + } +} diff --git a/app/ValueObjects/PricingResult.php b/app/ValueObjects/PricingResult.php new file mode 100644 index 00000000..73aae4a1 --- /dev/null +++ b/app/ValueObjects/PricingResult.php @@ -0,0 +1,35 @@ + $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, + ) {} + + /** + * @return array{subtotal: int, discount: int, shipping: int, tax_lines: array, 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..55d18fd0 --- /dev/null +++ b/app/ValueObjects/RefundResult.php @@ -0,0 +1,30 @@ + $this->success, + 'status' => $this->status->value, + 'reference_id' => $this->referenceId, + 'error_code' => $this->errorCode, + 'error_message' => $this->errorMessage, + ]; + } +} 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..a1e2d429 --- /dev/null +++ b/app/ValueObjects/TaxResult.php @@ -0,0 +1,27 @@ + $taxLines + */ + public function __construct( + public array $taxLines, + public int $totalAmount, + public int $rate, + ) {} + + /** + * @return array{tax_lines: array, tax_total: int, rate: int} + */ + public function toArray(): array + { + return [ + 'tax_lines' => array_map(fn (TaxLine $line): array => $line->toArray(), $this->taxLines), + 'tax_total' => $this->totalAmount, + 'rate' => $this->rate, + ]; + } +} diff --git a/boost.json b/boost.json new file mode 100644 index 00000000..3a9632d5 --- /dev/null +++ b/boost.json @@ -0,0 +1,17 @@ +{ + "agents": [ + "codex" + ], + "guidelines": true, + "mcp": true, + "nightwatch_mcp": 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..c559c33d 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,9 @@ 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([ + 'admin.api' => AuthenticateAdminApi::class, + 'platform.api' => AuthenticatePlatformApi::class, + 'role.check' => CheckStoreRole::class, + 'store.resolve' => ResolveStore::class, + ]); + + $middleware->appendToGroup('storefront', [ + ResolveStore::class, + ]); + + $middleware->appendToGroup('admin', [ + ResolveStore::class, + CheckStoreRole::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/composer.json b/composer.json index 1f848aaf..a578e1d1 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ }, "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", diff --git a/composer.lock b/composer.lock index e4255dbd..7134a0de 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": "a73f62d24e65543e17c317a1e9b580fa", "packages": [ { "name": "bacon/bacon-qr-code", @@ -6877,35 +6877,36 @@ }, { "name": "laravel/boost", - "version": "v1.0.18", + "version": "v2.4.5", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab" + "reference": "60386c7723ff7cb388b62b6c137597244a9cf2f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", + "url": "https://api.github.com/repos/laravel/boost/zipball/60386c7723ff7cb388b62b6c137597244a9cf2f2", + "reference": "60386c7723ff7cb388b62b6c137597244a9cf2f2", "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.5.1|^0.6.0|^0.7.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 +6928,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,35 +6939,41 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2025-08-16T09:10:03+00:00" + "time": "2026-04-22T13:29:20+00:00" }, { "name": "laravel/mcp", - "version": "v0.1.1", + "version": "v0.7.0", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713" + "reference": "3513b4feca5f1678be4d2261dcfa8e456436d02a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713", + "url": "https://api.github.com/repos/laravel/mcp/zipball/3513b4feca5f1678be4d2261dcfa8e456436d02a", + "reference": "3513b4feca5f1678be4d2261dcfa8e456436d02a", "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" }, "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": { @@ -6982,8 +6989,6 @@ "autoload": { "psr-4": { "Laravel\\Mcp\\": "src/", - "Workbench\\App\\": "workbench/app/", - "Laravel\\Mcp\\Tests\\": "tests/", "Laravel\\Mcp\\Server\\": "src/Server/" } }, @@ -6991,10 +6996,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 +7012,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2025-08-16T09:50:43+00:00" + "time": "2026-04-21T10:23:03+00:00" }, { "name": "laravel/pail", @@ -7153,30 +7163,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 +7220,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", @@ -9974,5 +9985,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..197dcb67 100644 --- a/config/auth.php +++ b/config/auth.php @@ -40,6 +40,16 @@ 'driver' => 'session', 'provider' => 'users', ], + + 'customer' => [ + 'driver' => 'session', + 'provider' => 'customers', + ], + + 'sanctum' => [ + 'driver' => 'sanctum-compatible', + 'provider' => 'users', + ], ], /* @@ -65,6 +75,11 @@ 'model' => env('AUTH_MODEL', App\Models\User::class), ], + 'customers' => [ + 'driver' => 'store_scoped_eloquent', + 'model' => App\Models\Customer::class, + ], + // 'users' => [ // 'driver' => 'database', // 'table' => 'users', @@ -97,6 +112,13 @@ 'expire' => 60, 'throttle' => 60, ], + + 'customers' => [ + 'provider' => 'customers', + 'table' => 'customer_password_reset_tokens', + 'expire' => 60, + 'throttle' => 60, + ], ], /* diff --git a/config/cache.php b/config/cache.php index b32aead2..9289977f 100644 --- a/config/cache.php +++ b/config/cache.php @@ -15,7 +15,7 @@ | */ - 'default' => env('CACHE_STORE', 'database'), + 'default' => env('CACHE_STORE', 'file'), /* |-------------------------------------------------------------------------- diff --git a/config/database.php b/config/database.php index df933e7f..210e1eac 100644 --- a/config/database.php +++ b/config/database.php @@ -37,9 +37,9 @@ 'database' => env('DB_DATABASE', database_path('database.sqlite')), 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), - 'busy_timeout' => null, - 'journal_mode' => null, - 'synchronous' => null, + 'busy_timeout' => 5000, + 'journal_mode' => 'WAL', + 'synchronous' => 'NORMAL', 'transaction_mode' => 'DEFERRED', ], diff --git a/config/fortify.php b/config/fortify.php index ce67e2c3..a0184c76 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -73,7 +73,7 @@ | */ - 'home' => '/dashboard', + 'home' => '/admin', /* |-------------------------------------------------------------------------- @@ -90,6 +90,15 @@ 'domain' => null, + 'paths' => [ + 'password' => [ + 'request' => '/user/forgot-password', + 'email' => '/user/forgot-password', + 'reset' => '/user/reset-password/{token}', + 'update' => '/user/reset-password', + ], + ], + /* |-------------------------------------------------------------------------- | Fortify Routes Middleware diff --git a/config/logging.php b/config/logging.php index 9e998a49..51cfe7a4 100644 --- a/config/logging.php +++ b/config/logging.php @@ -65,6 +65,14 @@ 'replace_placeholders' => true, ], + 'json' => [ + 'driver' => 'single', + 'path' => storage_path('logs/shop-json.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'formatter' => Monolog\Formatter\JsonFormatter::class, + 'replace_placeholders' => true, + ], + 'daily' => [ 'driver' => 'daily', 'path' => storage_path('logs/laravel.log'), @@ -73,6 +81,14 @@ 'replace_placeholders' => true, ], + 'audit' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/audit.log'), + 'level' => 'info', + 'days' => 90, + 'replace_placeholders' => true, + ], + 'slack' => [ 'driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), diff --git a/config/queue.php b/config/queue.php index 79c2c0a2..d0e0f50e 100644 --- a/config/queue.php +++ b/config/queue.php @@ -13,7 +13,7 @@ | */ - 'default' => env('QUEUE_CONNECTION', 'database'), + 'default' => env('QUEUE_CONNECTION', 'sync'), /* |-------------------------------------------------------------------------- diff --git a/config/session.php b/config/session.php index 5b541b75..c0c2f9ed 100644 --- a/config/session.php +++ b/config/session.php @@ -1,7 +1,5 @@ env('SESSION_DRIVER', 'database'), + 'driver' => env('SESSION_DRIVER', 'file'), /* |-------------------------------------------------------------------------- @@ -47,7 +45,7 @@ | */ - 'encrypt' => env('SESSION_ENCRYPT', false), + 'encrypt' => env('SESSION_ENCRYPT', true), /* |-------------------------------------------------------------------------- @@ -129,7 +127,7 @@ 'cookie' => env( 'SESSION_COOKIE', - Str::slug((string) env('APP_NAME', 'laravel')).'-session' + 'shop_session' ), /* @@ -169,7 +167,7 @@ | */ - 'secure' => env('SESSION_SECURE_COOKIE'), + 'secure' => env('SESSION_SECURE_COOKIE', env('APP_ENV') === 'production'), /* |-------------------------------------------------------------------------- diff --git a/database/factories/AnalyticsDailyFactory.php b/database/factories/AnalyticsDailyFactory.php new file mode 100644 index 00000000..4d8c66c0 --- /dev/null +++ b/database/factories/AnalyticsDailyFactory.php @@ -0,0 +1,35 @@ + + */ +class AnalyticsDailyFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $orders = fake()->numberBetween(0, 12); + $revenue = $orders * fake()->numberBetween(2500, 9500); + + return [ + 'store_id' => Store::factory(), + 'date' => now()->toDateString(), + 'orders_count' => $orders, + 'revenue_amount' => $revenue, + 'aov_amount' => $orders > 0 ? intdiv($revenue, $orders) : 0, + 'visits_count' => fake()->numberBetween(20, 200), + 'add_to_cart_count' => fake()->numberBetween(0, 40), + 'checkout_started_count' => fake()->numberBetween(0, 20), + 'checkout_completed_count' => $orders, + ]; + } +} diff --git a/database/factories/AnalyticsEventFactory.php b/database/factories/AnalyticsEventFactory.php new file mode 100644 index 00000000..8f4f87a1 --- /dev/null +++ b/database/factories/AnalyticsEventFactory.php @@ -0,0 +1,47 @@ + + */ +class AnalyticsEventFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $type = fake()->randomElement(AnalyticsEventType::cases()); + + return [ + 'store_id' => Store::factory(), + 'type' => $type, + 'session_id' => 'sess_'.Str::random(16), + 'customer_id' => null, + 'properties_json' => [ + 'url' => fake()->url(), + 'referrer' => fake()->optional()->url(), + ], + 'client_event_id' => 'evt_'.Str::uuid()->toString(), + 'occurred_at' => now(), + 'created_at' => now(), + ]; + } + + public function forCustomer(Customer $customer): static + { + return $this->state(fn (array $attributes): array => [ + 'store_id' => $customer->store_id, + 'customer_id' => $customer->getKey(), + ]); + } +} diff --git a/database/factories/AppFactory.php b/database/factories/AppFactory.php new file mode 100644 index 00000000..a081e008 --- /dev/null +++ b/database/factories/AppFactory.php @@ -0,0 +1,26 @@ + + */ +class AppFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->company().' App', + 'status' => AppStatus::Active, + 'created_at' => now(), + ]; + } +} diff --git a/database/factories/AppInstallationFactory.php b/database/factories/AppInstallationFactory.php new file mode 100644 index 00000000..589ca789 --- /dev/null +++ b/database/factories/AppInstallationFactory.php @@ -0,0 +1,30 @@ + + */ +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' => now(), + ]; + } +} diff --git a/database/factories/CartFactory.php b/database/factories/CartFactory.php new file mode 100644 index 00000000..04b5a850 --- /dev/null +++ b/database/factories/CartFactory.php @@ -0,0 +1,44 @@ + + */ +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, + ]; + } + + public function forCustomer(?Customer $customer = null): static + { + return $this->state(fn (array $attributes): array => [ + 'customer_id' => $customer?->getKey() ?? Customer::factory(), + ]); + } + + public function abandoned(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => CartStatus::Abandoned, + ]); + } +} diff --git a/database/factories/CartLineFactory.php b/database/factories/CartLineFactory.php new file mode 100644 index 00000000..92c08f9e --- /dev/null +++ b/database/factories/CartLineFactory.php @@ -0,0 +1,34 @@ + + */ +class CartLineFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $quantity = fake()->numberBetween(1, 3); + $unitPrice = fake()->numberBetween(1000, 10000); + + 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, + ]; + } +} diff --git a/database/factories/CheckoutFactory.php b/database/factories/CheckoutFactory.php new file mode 100644 index 00000000..fc22313e --- /dev/null +++ b/database/factories/CheckoutFactory.php @@ -0,0 +1,46 @@ + + */ +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' => null, + 'shipping_address_json' => null, + 'billing_address_json' => null, + 'shipping_method_id' => null, + 'discount_code' => null, + 'tax_provider_snapshot_json' => null, + 'totals_json' => null, + 'expires_at' => null, + ]; + } + + public function forCustomer(?Customer $customer = null): static + { + return $this->state(fn (array $attributes): array => [ + 'customer_id' => $customer?->getKey() ?? Customer::factory(), + ]); + } +} diff --git a/database/factories/CollectionFactory.php b/database/factories/CollectionFactory.php new file mode 100644 index 00000000..1f86d43d --- /dev/null +++ b/database/factories/CollectionFactory.php @@ -0,0 +1,48 @@ + + */ +class CollectionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $title = Str::title(fake()->words(2, true)); + + return [ + 'store_id' => Store::factory(), + 'title' => $title, + 'handle' => Str::slug($title), + 'description_html' => '

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

', + 'type' => CollectionType::Manual, + 'status' => CollectionStatus::Active, + ]; + } + + public function draft(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => CollectionStatus::Draft, + ]); + } + + public function automated(): static + { + return $this->state(fn (array $attributes): array => [ + 'type' => CollectionType::Automated, + ]); + } +} diff --git a/database/factories/CustomerAddressFactory.php b/database/factories/CustomerAddressFactory.php new file mode 100644 index 00000000..cf003aa2 --- /dev/null +++ b/database/factories/CustomerAddressFactory.php @@ -0,0 +1,43 @@ + + */ +class CustomerAddressFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'customer_id' => Customer::factory(), + 'label' => fake()->randomElement(['Shipping', 'Billing', 'Home', 'Office']), + 'address_json' => [ + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'address1' => fake()->streetAddress(), + 'address2' => null, + 'city' => fake()->city(), + 'province_code' => null, + 'country' => 'DE', + 'postal_code' => fake()->postcode(), + ], + 'is_default' => false, + ]; + } + + public function default(): static + { + return $this->state(fn (array $attributes): array => [ + 'is_default' => true, + ]); + } +} diff --git a/database/factories/CustomerFactory.php b/database/factories/CustomerFactory.php new file mode 100644 index 00000000..bf0a1d99 --- /dev/null +++ b/database/factories/CustomerFactory.php @@ -0,0 +1,35 @@ + + */ +class CustomerFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'email' => fake()->unique()->safeEmail(), + 'password' => 'password', + 'name' => fake()->name(), + 'marketing_opt_in' => false, + ]; + } + + public function guest(): static + { + return $this->state(fn (array $attributes): array => [ + 'password' => null, + ]); + } +} diff --git a/database/factories/DataExportFactory.php b/database/factories/DataExportFactory.php new file mode 100644 index 00000000..e1354099 --- /dev/null +++ b/database/factories/DataExportFactory.php @@ -0,0 +1,33 @@ + + */ +class DataExportFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'type' => 'orders', + 'format' => 'csv', + 'status' => ExportStatus::Completed, + 'filters_json' => [], + 'row_count' => fake()->numberBetween(1, 25), + 'storage_key' => 'exports/orders/'.fake()->uuid().'.csv', + 'download_expires_at' => now()->addHour(), + 'completed_at' => now(), + ]; + } +} diff --git a/database/factories/DiscountFactory.php b/database/factories/DiscountFactory.php new file mode 100644 index 00000000..ea271cc4 --- /dev/null +++ b/database/factories/DiscountFactory.php @@ -0,0 +1,53 @@ + + */ +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' => fake()->unique()->bothify('SAVE##'), + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addMonth(), + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => ['customer_eligibility' => 'all'], + 'status' => DiscountStatus::Active, + ]; + } + + public function fixed(int $amount = 500): static + { + return $this->state(fn (array $attributes): array => [ + 'value_type' => DiscountValueType::Fixed, + 'value_amount' => $amount, + ]); + } + + public function freeShipping(): static + { + return $this->state(fn (array $attributes): array => [ + 'value_type' => DiscountValueType::FreeShipping, + 'value_amount' => 0, + ]); + } +} diff --git a/database/factories/FulfillmentFactory.php b/database/factories/FulfillmentFactory.php new file mode 100644 index 00000000..40f320a1 --- /dev/null +++ b/database/factories/FulfillmentFactory.php @@ -0,0 +1,57 @@ + + */ +class FulfillmentFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'status' => FulfillmentShipmentStatus::Pending, + 'tracking_company' => null, + 'tracking_number' => null, + 'tracking_url' => null, + 'shipped_at' => null, + 'delivered_at' => null, + ]; + } + + public function withTracking(): static + { + return $this->state(fn (array $attributes): array => [ + 'tracking_company' => fake()->randomElement(['DHL', 'UPS', 'DPD']), + 'tracking_number' => fake()->bothify('??##########'), + 'tracking_url' => fake()->url(), + ]); + } + + public function shipped(): static + { + return $this->withTracking()->state(fn (array $attributes): array => [ + 'status' => FulfillmentShipmentStatus::Shipped, + 'shipped_at' => now(), + ]); + } + + public function delivered(): static + { + return $this->withTracking()->state(fn (array $attributes): array => [ + 'status' => FulfillmentShipmentStatus::Delivered, + 'shipped_at' => now()->subDay(), + 'delivered_at' => now(), + ]); + } +} diff --git a/database/factories/FulfillmentLineFactory.php b/database/factories/FulfillmentLineFactory.php new file mode 100644 index 00000000..2ebc901d --- /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' => fake()->numberBetween(1, 3), + ]; + } +} diff --git a/database/factories/InventoryItemFactory.php b/database/factories/InventoryItemFactory.php new file mode 100644 index 00000000..76b05df5 --- /dev/null +++ b/database/factories/InventoryItemFactory.php @@ -0,0 +1,59 @@ + + */ +class InventoryItemFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'variant_id' => fn (): int => ProductVariant::withoutEvents( + fn (): int => ProductVariant::factory()->create()->getKey(), + ), + 'store_id' => fn (array $attributes): mixed => ProductVariant::query() + ->with('product') + ->find($attributes['variant_id']) + ?->product + ?->store_id ?? Store::factory(), + 'quantity_on_hand' => fake()->numberBetween(0, 100), + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ]; + } + + public function outOfStock(): static + { + return $this->state(fn (array $attributes): array => [ + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + ]); + } + + public function continuePolicy(): static + { + return $this->state(fn (array $attributes): array => [ + 'policy' => InventoryPolicy::Continue, + ]); + } + + public function lowStock(): static + { + return $this->state(fn (array $attributes): array => [ + 'quantity_on_hand' => fake()->numberBetween(1, 3), + 'quantity_reserved' => 0, + ]); + } +} diff --git a/database/factories/NavigationItemFactory.php b/database/factories/NavigationItemFactory.php new file mode 100644 index 00000000..578c35fc --- /dev/null +++ b/database/factories/NavigationItemFactory.php @@ -0,0 +1,58 @@ + + */ +class NavigationItemFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'menu_id' => NavigationMenu::factory(), + 'parent_id' => null, + 'type' => NavigationItemType::Link, + 'label' => fake()->words(2, true), + 'url' => '/'.fake()->slug(), + 'resource_id' => null, + 'position' => 0, + ]; + } + + public function page(int $pageId): static + { + return $this->state(fn (array $attributes): array => [ + 'type' => NavigationItemType::Page, + 'url' => null, + 'resource_id' => $pageId, + ]); + } + + public function collection(int $collectionId): static + { + return $this->state(fn (array $attributes): array => [ + 'type' => NavigationItemType::Collection, + 'url' => null, + 'resource_id' => $collectionId, + ]); + } + + public function product(int $productId): static + { + return $this->state(fn (array $attributes): array => [ + '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..2655d2be --- /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()->words(2, true)); + + return [ + 'store_id' => Store::factory(), + 'handle' => Str::slug($title).'-'.fake()->unique()->numberBetween(1000, 9999), + 'title' => $title, + ]; + } +} diff --git a/database/factories/OauthClientFactory.php b/database/factories/OauthClientFactory.php new file mode 100644 index 00000000..345801d2 --- /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' => 'app_client_'.Str::random(24), + 'client_secret_encrypted' => 'secret_'.Str::random(40), + 'redirect_uris_json' => [fake()->url().'/oauth/callback'], + ]; + } +} diff --git a/database/factories/OauthTokenFactory.php b/database/factories/OauthTokenFactory.php new file mode 100644 index 00000000..d44eff32 --- /dev/null +++ b/database/factories/OauthTokenFactory.php @@ -0,0 +1,34 @@ + + */ +class OauthTokenFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $token = 'shop_'.Str::random(48); + + return [ + 'installation_id' => AppInstallation::factory(), + 'name' => fake()->words(2, true), + 'access_token_hash' => hash('sha256', $token), + 'refresh_token_hash' => hash('sha256', 'refresh_'.Str::random(48)), + 'abilities_json' => ['read-products', 'read-orders'], + 'expires_at' => now()->addYear(), + 'last_used_at' => null, + 'created_at' => now(), + ]; + } +} diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php new file mode 100644 index 00000000..130e4c2c --- /dev/null +++ b/database/factories/OrderFactory.php @@ -0,0 +1,153 @@ + + */ +class OrderFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $subtotal = fake()->numberBetween(2500, 25000); + $shipping = fake()->randomElement([0, 499, 799]); + $tax = 0; + + return [ + 'store_id' => Store::factory(), + 'checkout_id' => null, + 'customer_id' => null, + 'order_number' => '#'.fake()->unique()->numberBetween(1001, 9999), + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Paid, + 'financial_status' => FinancialStatus::Paid, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled, + 'currency' => 'EUR', + 'subtotal_amount' => $subtotal, + 'discount_amount' => 0, + 'shipping_amount' => $shipping, + 'tax_amount' => $tax, + 'total_amount' => $subtotal + $shipping + $tax, + 'email' => fake()->safeEmail(), + 'billing_address_json' => $this->address(), + 'shipping_address_json' => $this->address(), + 'placed_at' => now(), + ]; + } + + public function forCheckout(?Checkout $checkout = null): static + { + return $this->state(fn (array $attributes): array => [ + 'checkout_id' => $checkout?->getKey() ?? Checkout::factory(), + ]); + } + + public function forCustomer(?Customer $customer = null): static + { + return $this->state(fn (array $attributes): array => [ + 'customer_id' => $customer?->getKey() ?? Customer::factory(), + ]); + } + + public function paid(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => OrderStatus::Paid, + 'financial_status' => FinancialStatus::Paid, + ]); + } + + public function pending(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + ]); + } + + public function creditCard(): static + { + return $this->state(fn (array $attributes): array => [ + 'payment_method' => PaymentMethod::CreditCard, + ]); + } + + public function paypal(): static + { + return $this->state(fn (array $attributes): array => [ + 'payment_method' => PaymentMethod::Paypal, + ]); + } + + public function bankTransfer(): static + { + return $this->state(fn (array $attributes): array => [ + 'payment_method' => PaymentMethod::BankTransfer, + 'status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + ]); + } + + public function cancelled(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => OrderStatus::Cancelled, + 'financial_status' => FinancialStatus::Voided, + ]); + } + + public function partiallyFulfilled(): static + { + return $this->state(fn (array $attributes): array => [ + 'fulfillment_status' => FulfillmentStatus::Partial, + ]); + } + + public function fulfilled(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => OrderStatus::Fulfilled, + 'financial_status' => FinancialStatus::Paid, + 'fulfillment_status' => FulfillmentStatus::Fulfilled, + ]); + } + + public function refunded(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => OrderStatus::Refunded, + 'financial_status' => FinancialStatus::Refunded, + ]); + } + + /** + * @return array + */ + private function address(): array + { + return [ + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'address1' => fake()->streetAddress(), + 'address2' => null, + 'city' => fake()->city(), + 'province_code' => null, + 'country' => 'DE', + 'postal_code' => fake()->postcode(), + ]; + } +} diff --git a/database/factories/OrderLineFactory.php b/database/factories/OrderLineFactory.php new file mode 100644 index 00000000..a4f2d257 --- /dev/null +++ b/database/factories/OrderLineFactory.php @@ -0,0 +1,37 @@ + + */ +class OrderLineFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $quantity = fake()->numberBetween(1, 3); + $unitPrice = fake()->numberBetween(1000, 10000); + + return [ + 'order_id' => Order::factory(), + 'product_id' => null, + 'variant_id' => ProductVariant::factory(), + 'title_snapshot' => fake()->words(3, true), + 'sku_snapshot' => 'SKU-'.fake()->unique()->numerify('####'), + 'quantity' => $quantity, + 'unit_price_amount' => $unitPrice, + 'total_amount' => $unitPrice * $quantity, + 'tax_lines_json' => [], + 'discount_allocations_json' => [], + ]; + } +} diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php new file mode 100644 index 00000000..a991973b --- /dev/null +++ b/database/factories/OrganizationFactory.php @@ -0,0 +1,24 @@ + + */ +class OrganizationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->company(), + 'billing_email' => fake()->unique()->companyEmail(), + ]; + } +} diff --git a/database/factories/PageFactory.php b/database/factories/PageFactory.php new file mode 100644 index 00000000..ab9189cc --- /dev/null +++ b/database/factories/PageFactory.php @@ -0,0 +1,49 @@ + + */ +class PageFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $title = Str::title(fake()->words(2, true)); + + return [ + 'store_id' => Store::factory(), + 'title' => $title, + 'handle' => Str::slug($title).'-'.fake()->unique()->numberBetween(1000, 9999), + 'body_html' => '

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

', + 'status' => PageStatus::Draft, + 'published_at' => null, + ]; + } + + public function published(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => PageStatus::Published, + 'published_at' => now(), + ]); + } + + public function archived(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => PageStatus::Archived, + 'published_at' => now()->subDay(), + ]); + } +} diff --git a/database/factories/PaymentFactory.php b/database/factories/PaymentFactory.php new file mode 100644 index 00000000..902d7816 --- /dev/null +++ b/database/factories/PaymentFactory.php @@ -0,0 +1,78 @@ + + */ +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_payment_'.fake()->unique()->lexify('????????????????'), + 'status' => PaymentStatus::Captured, + 'amount' => fake()->numberBetween(2500, 25000), + 'currency' => 'EUR', + 'raw_json_encrypted' => [ + 'success' => true, + 'status' => PaymentStatus::Captured->value, + ], + ]; + } + + public function pending(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => PaymentStatus::Pending, + ]); + } + + public function failed(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => PaymentStatus::Failed, + 'raw_json_encrypted' => [ + 'success' => false, + 'status' => PaymentStatus::Failed->value, + 'error_code' => 'card_declined', + ], + ]); + } + + public function refunded(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => PaymentStatus::Refunded, + ]); + } + + public function paypal(): static + { + return $this->state(fn (array $attributes): array => [ + 'method' => PaymentMethod::Paypal, + ]); + } + + public function bankTransfer(): static + { + return $this->state(fn (array $attributes): array => [ + 'method' => PaymentMethod::BankTransfer, + 'status' => PaymentStatus::Pending, + 'provider_payment_id' => 'mock_bank_'.fake()->unique()->lexify('????????????????'), + ]); + } +} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php new file mode 100644 index 00000000..01b12921 --- /dev/null +++ b/database/factories/ProductFactory.php @@ -0,0 +1,80 @@ + + */ +class ProductFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $title = Str::title(fake()->words(3, true)); + + return [ + 'store_id' => Store::factory(), + 'title' => $title, + 'handle' => Str::slug($title), + 'status' => ProductStatus::Active, + 'description_html' => '

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

', + 'vendor' => fake()->company(), + 'product_type' => fake()->randomElement(['Shirts', 'Pants', 'Shoes', 'Accessories', 'Electronics', 'Books']), + 'tags' => fake()->randomElements(['new', 'sale', 'trending', 'popular', 'limited'], fake()->numberBetween(1, 3)), + 'published_at' => now(), + ]; + } + + public function draft(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => ProductStatus::Draft, + 'published_at' => null, + ]); + } + + public function archived(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => ProductStatus::Archived, + ]); + } + + public function withVariants(int $count): static + { + return $this->afterCreating(function ($product) use ($count): void { + ProductVariant::factory() + ->count($count) + ->for($product) + ->create(); + }); + } + + public function withDefaultVariant(int $price = 2499): static + { + return $this->afterCreating(function ($product) use ($price): void { + $variant = ProductVariant::factory() + ->default() + ->for($product) + ->create([ + 'price_amount' => $price, + 'currency' => $product->store->default_currency, + ]); + + InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->update([ + 'quantity_on_hand' => 50, + ]); + }); + } +} diff --git a/database/factories/ProductMediaFactory.php b/database/factories/ProductMediaFactory.php new file mode 100644 index 00000000..21c71cd9 --- /dev/null +++ b/database/factories/ProductMediaFactory.php @@ -0,0 +1,51 @@ + + */ +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' => 'products/'.fake()->uuid().'.jpg', + 'alt_text' => fake()->sentence(3), + 'width' => 1200, + 'height' => 1200, + 'mime_type' => 'image/jpeg', + 'byte_size' => fake()->numberBetween(50_000, 500_000), + 'position' => 0, + 'status' => MediaStatus::Ready, + ]; + } + + public function processing(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => MediaStatus::Processing, + ]); + } + + public function video(): static + { + return $this->state(fn (array $attributes): array => [ + 'type' => MediaType::Video, + 'storage_key' => 'products/'.fake()->uuid().'.mp4', + 'mime_type' => 'video/mp4', + ]); + } +} 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..cb164414 --- /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()->randomElement(['S', 'M', 'L', 'Black', 'White', 'Navy']), + 'position' => 0, + ]; + } +} diff --git a/database/factories/ProductVariantFactory.php b/database/factories/ProductVariantFactory.php new file mode 100644 index 00000000..95b3b4a7 --- /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' => 'SKU-'.fake()->unique()->numerify('####').'-'.fake()->lexify('???'), + 'barcode' => fake()->ean13(), + 'price_amount' => fake()->numberBetween(999, 19999), + 'compare_at_amount' => null, + 'currency' => 'EUR', + 'weight_g' => fake()->numberBetween(100, 5000), + 'requires_shipping' => true, + 'is_default' => false, + 'position' => 0, + 'status' => VariantStatus::Active, + ]; + } + + public function onSale(): static + { + return $this->state(fn (array $attributes): array => [ + 'compare_at_amount' => fake()->numberBetween(20000, 39999), + 'price_amount' => fake()->numberBetween(9999, 19999), + ]); + } + + public function digital(): static + { + return $this->state(fn (array $attributes): array => [ + 'requires_shipping' => false, + 'weight_g' => 0, + ]); + } + + public function default(): static + { + return $this->state(fn (array $attributes): array => [ + 'is_default' => true, + ]); + } + + public function archived(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => VariantStatus::Archived, + ]); + } +} diff --git a/database/factories/RefundFactory.php b/database/factories/RefundFactory.php new file mode 100644 index 00000000..70aace2b --- /dev/null +++ b/database/factories/RefundFactory.php @@ -0,0 +1,54 @@ + + */ +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' => fake()->numberBetween(500, 5000), + 'reason' => fake()->optional()->sentence(), + 'status' => RefundStatus::Pending, + 'provider_refund_id' => null, + ]; + } + + public function processed(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => RefundStatus::Processed, + 'provider_refund_id' => 'mock_refund_'.fake()->unique()->lexify('????????????????'), + ]); + } + + public function failed(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => RefundStatus::Failed, + ]); + } + + public function forPayment(Payment $payment): static + { + return $this->state(fn (array $attributes): array => [ + 'order_id' => $payment->order_id, + 'payment_id' => $payment->getKey(), + ]); + } +} diff --git a/database/factories/SearchQueryFactory.php b/database/factories/SearchQueryFactory.php new file mode 100644 index 00000000..0c07600e --- /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' => fake()->words(2, true), + 'filters_json' => [], + 'results_count' => fake()->numberBetween(0, 12), + 'created_at' => now(), + ]; + } +} diff --git a/database/factories/SearchSettingsFactory.php b/database/factories/SearchSettingsFactory.php new file mode 100644 index 00000000..09695567 --- /dev/null +++ b/database/factories/SearchSettingsFactory.php @@ -0,0 +1,29 @@ + + */ +class SearchSettingsFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'synonyms_json' => [ + ['t-shirt', 'tee', 'tshirt'], + ['sneakers', 'trainers'], + ], + 'stop_words_json' => ['the', 'a', 'an'], + ]; + } +} diff --git a/database/factories/ShippingRateFactory.php b/database/factories/ShippingRateFactory.php new file mode 100644 index 00000000..cebc9c85 --- /dev/null +++ b/database/factories/ShippingRateFactory.php @@ -0,0 +1,55 @@ + + */ +class ShippingRateFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'zone_id' => ShippingZone::factory(), + 'name' => 'Standard Shipping', + 'type' => ShippingRateType::Flat, + 'config_json' => ['amount' => 799], + 'is_active' => true, + ]; + } + + public function weight(): static + { + return $this->state(fn (array $attributes): array => [ + 'type' => ShippingRateType::Weight, + 'config_json' => [ + 'ranges' => [ + ['min_g' => 0, 'max_g' => 500, 'amount' => 499], + ['min_g' => 501, 'max_g' => 2000, 'amount' => 899], + ], + ], + ]); + } + + public function price(): static + { + return $this->state(fn (array $attributes): array => [ + 'type' => ShippingRateType::Price, + 'config_json' => [ + 'ranges' => [ + ['min_amount' => 0, 'max_amount' => 7500, 'amount' => 799], + ['min_amount' => 7501, 'amount' => 0], + ], + ], + ]); + } +} diff --git a/database/factories/ShippingZoneFactory.php b/database/factories/ShippingZoneFactory.php new file mode 100644 index 00000000..3b3dd4cf --- /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().' Shipping', + 'countries_json' => ['DE'], + 'regions_json' => [], + ]; + } +} diff --git a/database/factories/StoreDomainFactory.php b/database/factories/StoreDomainFactory.php new file mode 100644 index 00000000..821f9c0a --- /dev/null +++ b/database/factories/StoreDomainFactory.php @@ -0,0 +1,50 @@ + + */ +class StoreDomainFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'hostname' => fake()->unique()->domainName(), + 'type' => StoreDomainType::Storefront, + 'is_primary' => true, + 'tls_mode' => 'managed', + ]; + } + + public function admin(): static + { + return $this->state(fn (array $attributes): array => [ + 'type' => StoreDomainType::Admin, + ]); + } + + public function api(): static + { + return $this->state(fn (array $attributes): array => [ + 'type' => StoreDomainType::Api, + ]); + } + + public function secondary(): static + { + return $this->state(fn (array $attributes): array => [ + 'is_primary' => false, + ]); + } +} diff --git a/database/factories/StoreFactory.php b/database/factories/StoreFactory.php new file mode 100644 index 00000000..7aeb78bb --- /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 + { + $name = fake()->company().' Store'; + + return [ + 'organization_id' => Organization::factory(), + 'name' => $name, + 'handle' => Str::slug(fake()->unique()->words(2, true)), + 'status' => StoreStatus::Active, + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ]; + } + + public function suspended(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => StoreStatus::Suspended, + ]); + } +} diff --git a/database/factories/StoreSettingsFactory.php b/database/factories/StoreSettingsFactory.php new file mode 100644 index 00000000..5be754d5 --- /dev/null +++ b/database/factories/StoreSettingsFactory.php @@ -0,0 +1,49 @@ + + */ +class StoreSettingsFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'settings_json' => [ + 'checkout' => [ + 'guest_checkout_enabled' => true, + 'customer_accounts_required' => false, + 'phone_number_required' => false, + 'billing_address_enabled' => true, + 'order_notes_enabled' => true, + 'terms_required' => false, + 'terms_url' => '', + 'payment_hold_hours' => 24, + 'abandoned_checkout_days' => 14, + ], + 'bank_transfer_cancel_days' => 7, + 'notifications' => [ + 'sender_name' => 'Acme Store', + 'sender_email' => 'no-reply@shop.test', + 'reply_to_email' => '', + 'order_confirmation_enabled' => true, + 'shipping_confirmation_enabled' => true, + 'refund_confirmation_enabled' => true, + 'admin_order_alerts_enabled' => true, + 'low_stock_alerts_enabled' => true, + 'low_stock_threshold' => 5, + ], + ], + ]; + } +} diff --git a/database/factories/TaxSettingsFactory.php b/database/factories/TaxSettingsFactory.php new file mode 100644 index 00000000..16c9a2c6 --- /dev/null +++ b/database/factories/TaxSettingsFactory.php @@ -0,0 +1,43 @@ + + */ +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' => [ + 'name' => 'VAT', + 'default_rate_bps' => 1900, + 'shipping_taxable' => true, + 'rates' => [ + ['country' => 'DE', 'rate_bps' => 1900, 'name' => 'VAT'], + ], + ], + ]; + } + + public function inclusive(): static + { + return $this->state(fn (array $attributes): array => [ + 'prices_include_tax' => true, + ]); + } +} diff --git a/database/factories/ThemeFactory.php b/database/factories/ThemeFactory.php new file mode 100644 index 00000000..2487c0e2 --- /dev/null +++ b/database/factories/ThemeFactory.php @@ -0,0 +1,37 @@ + + */ +class ThemeFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'name' => fake()->words(2, true).' theme', + 'version' => '1.0.0', + 'status' => ThemeStatus::Draft, + 'published_at' => null, + ]; + } + + public function published(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => ThemeStatus::Published, + 'published_at' => now(), + ]); + } +} diff --git a/database/factories/ThemeFileFactory.php b/database/factories/ThemeFileFactory.php new file mode 100644 index 00000000..58de6f43 --- /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 = 'sections/'.fake()->unique()->slug().'.blade.php'; + + return [ + 'theme_id' => Theme::factory(), + 'path' => $path, + 'storage_key' => 'themes/'.Str::random(12).'/'.$path, + 'sha256' => hash('sha256', $path.fake()->uuid()), + 'byte_size' => fake()->numberBetween(128, 4096), + ]; + } +} diff --git a/database/factories/ThemeSettingsFactory.php b/database/factories/ThemeSettingsFactory.php new file mode 100644 index 00000000..c1863530 --- /dev/null +++ b/database/factories/ThemeSettingsFactory.php @@ -0,0 +1,39 @@ + + */ +class ThemeSettingsFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'theme_id' => Theme::factory(), + 'settings_json' => [ + 'announcement' => [ + 'enabled' => true, + 'text' => 'Free shipping on orders over 75.00 EUR', + 'url' => null, + ], + 'header' => [ + 'sticky' => true, + 'main_menu' => 'main-menu', + ], + 'footer' => [ + 'menu' => 'footer-menu', + 'tagline' => 'A self-contained demo storefront.', + ], + ], + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 80da5ac7..c6add620 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(), + 'status' => 'active', 'password' => static::$password ??= Hash::make('password'), + 'last_login_at' => fake()->dateTimeBetween('-30 days'), 'remember_token' => Str::random(10), 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, @@ -56,4 +58,11 @@ public function withTwoFactor(): static 'two_factor_confirmed_at' => now(), ]); } + + public function disabled(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => 'disabled', + ]); + } } diff --git a/database/factories/WebhookDeliveryFactory.php b/database/factories/WebhookDeliveryFactory.php new file mode 100644 index 00000000..d036508d --- /dev/null +++ b/database/factories/WebhookDeliveryFactory.php @@ -0,0 +1,32 @@ + + */ +class WebhookDeliveryFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'subscription_id' => WebhookSubscription::factory(), + 'event_id' => (string) Str::uuid(), + 'attempt_count' => 1, + 'status' => WebhookDeliveryStatus::Pending, + 'last_attempt_at' => null, + 'response_code' => null, + 'response_body_snippet' => null, + ]; + } +} diff --git a/database/factories/WebhookSubscriptionFactory.php b/database/factories/WebhookSubscriptionFactory.php new file mode 100644 index 00000000..16a6a111 --- /dev/null +++ b/database/factories/WebhookSubscriptionFactory.php @@ -0,0 +1,32 @@ + + */ +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' => WebhookEventType::OrderCreated, + 'target_url' => fake()->url().'/webhooks/shop', + 'signing_secret_encrypted' => 'whsec_'.Str::random(40), + 'status' => WebhookSubscriptionStatus::Active, + ]; + } +} 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..9f5fd46f 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -13,10 +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')->index(); + $table->boolean('is_platform_admin')->default(false)->index(); $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); + $table->timestamp('last_login_at')->nullable(); + $table->text('two_factor_secret')->nullable(); + $table->text('two_factor_recovery_codes')->nullable(); + $table->timestamp('two_factor_confirmed_at')->nullable(); $table->rememberToken(); $table->timestamps(); }); 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..41104110 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,9 +12,17 @@ public function up(): void { Schema::table('users', function (Blueprint $table) { - $table->text('two_factor_secret')->after('password')->nullable(); - $table->text('two_factor_recovery_codes')->after('two_factor_secret')->nullable(); - $table->timestamp('two_factor_confirmed_at')->after('two_factor_recovery_codes')->nullable(); + if (! Schema::hasColumn('users', 'two_factor_secret')) { + $table->text('two_factor_secret')->nullable(); + } + + if (! Schema::hasColumn('users', 'two_factor_recovery_codes')) { + $table->text('two_factor_recovery_codes')->nullable(); + } + + if (! Schema::hasColumn('users', 'two_factor_confirmed_at')) { + $table->timestamp('two_factor_confirmed_at')->nullable(); + } }); } @@ -24,11 +32,7 @@ public function up(): void public function down(): void { Schema::table('users', function (Blueprint $table) { - $table->dropColumn([ - 'two_factor_secret', - 'two_factor_recovery_codes', - 'two_factor_confirmed_at', - ]); + // }); } }; diff --git a/database/migrations/2026_05_03_211409_create_organizations_table.php b/database/migrations/2026_05_03_211409_create_organizations_table.php new file mode 100644 index 00000000..f49d3cdd --- /dev/null +++ b/database/migrations/2026_05_03_211409_create_organizations_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('name'); + $table->string('billing_email')->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('organizations'); + } +}; diff --git a/database/migrations/2026_05_03_211413_create_stores_table.php b/database/migrations/2026_05_03_211413_create_stores_table.php new file mode 100644 index 00000000..1cd6b553 --- /dev/null +++ b/database/migrations/2026_05_03_211413_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')->index(); + $table->string('default_currency', 3)->default('USD'); + $table->string('default_locale')->default('en'); + $table->string('timezone')->default('UTC'); + $table->timestamps(); + + $table->index('organization_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('stores'); + } +}; diff --git a/database/migrations/2026_05_03_211416_create_store_domains_table.php b/database/migrations/2026_05_03_211416_create_store_domains_table.php new file mode 100644 index 00000000..3548e721 --- /dev/null +++ b/database/migrations/2026_05_03_211416_create_store_domains_table.php @@ -0,0 +1,35 @@ +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'); + $table->index(['store_id', 'is_primary']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('store_domains'); + } +}; diff --git a/database/migrations/2026_05_03_211419_create_store_settings_table.php b/database/migrations/2026_05_03_211419_create_store_settings_table.php new file mode 100644 index 00000000..33fc0d5f --- /dev/null +++ b/database/migrations/2026_05_03_211419_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_05_03_211425_create_store_users_table.php b/database/migrations/2026_05_03_211425_create_store_users_table.php new file mode 100644 index 00000000..8d8e602c --- /dev/null +++ b/database/migrations/2026_05_03_211425_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_05_03_211556_create_customers_table.php b/database/migrations/2026_05_03_211556_create_customers_table.php new file mode 100644 index 00000000..47c0ddfc --- /dev/null +++ b/database/migrations/2026_05_03_211556_create_customers_table.php @@ -0,0 +1,35 @@ +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']); + $table->index('store_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('customers'); + } +}; diff --git a/database/migrations/2026_05_03_212158_create_customer_password_reset_tokens_table.php b/database/migrations/2026_05_03_212158_create_customer_password_reset_tokens_table.php new file mode 100644 index 00000000..a9ff55e8 --- /dev/null +++ b/database/migrations/2026_05_03_212158_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_05_03_213856_create_products_table.php b/database/migrations/2026_05_03_213856_create_products_table.php new file mode 100644 index 00000000..e797c38a --- /dev/null +++ b/database/migrations/2026_05_03_213856_create_products_table.php @@ -0,0 +1,43 @@ +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'); + $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_05_03_213857_create_product_options_table.php b/database/migrations/2026_05_03_213857_create_product_options_table.php new file mode 100644 index 00000000..55c82f03 --- /dev/null +++ b/database/migrations/2026_05_03_213857_create_product_options_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->unsignedInteger('position')->default(0); + + $table->index('product_id'); + $table->unique(['product_id', 'position']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_options'); + } +}; diff --git a/database/migrations/2026_05_03_213858_create_product_option_values_table.php b/database/migrations/2026_05_03_213858_create_product_option_values_table.php new file mode 100644 index 00000000..74f0908f --- /dev/null +++ b/database/migrations/2026_05_03_213858_create_product_option_values_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('product_option_id')->constrained()->cascadeOnDelete(); + $table->string('value'); + $table->unsignedInteger('position')->default(0); + + $table->index('product_option_id'); + $table->unique(['product_option_id', 'position']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_option_values'); + } +}; diff --git a/database/migrations/2026_05_03_213859_create_product_variants_table.php b/database/migrations/2026_05_03_213859_create_product_variants_table.php new file mode 100644 index 00000000..ec1ede9f --- /dev/null +++ b/database/migrations/2026_05_03_213859_create_product_variants_table.php @@ -0,0 +1,44 @@ +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('product_id'); + $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_05_03_213900_create_variant_option_values_table.php b/database/migrations/2026_05_03_213900_create_variant_option_values_table.php new file mode 100644 index 00000000..034422bd --- /dev/null +++ b/database/migrations/2026_05_03_213900_create_variant_option_values_table.php @@ -0,0 +1,30 @@ +foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->foreignId('product_option_value_id')->constrained()->cascadeOnDelete(); + + $table->primary(['variant_id', 'product_option_value_id']); + $table->index('product_option_value_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('variant_option_values'); + } +}; diff --git a/database/migrations/2026_05_03_213901_create_inventory_items_table.php b/database/migrations/2026_05_03_213901_create_inventory_items_table.php new file mode 100644 index 00000000..bbd2d75a --- /dev/null +++ b/database/migrations/2026_05_03_213901_create_inventory_items_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('variant_id')->unique()->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->index('store_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('inventory_items'); + } +}; diff --git a/database/migrations/2026_05_03_213902_create_collections_table.php b/database/migrations/2026_05_03_213902_create_collections_table.php new file mode 100644 index 00000000..db3c3c92 --- /dev/null +++ b/database/migrations/2026_05_03_213902_create_collections_table.php @@ -0,0 +1,37 @@ +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'); + $table->index(['store_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('collections'); + } +}; diff --git a/database/migrations/2026_05_03_213903_create_collection_products_table.php b/database/migrations/2026_05_03_213903_create_collection_products_table.php new file mode 100644 index 00000000..3f884d42 --- /dev/null +++ b/database/migrations/2026_05_03_213903_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_05_03_213904_create_product_media_table.php b/database/migrations/2026_05_03_213904_create_product_media_table.php new file mode 100644 index 00000000..cc114e10 --- /dev/null +++ b/database/migrations/2026_05_03_213904_create_product_media_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->enum('type', ['image', 'video'])->default('image'); + $table->string('storage_key'); + $table->string('alt_text')->nullable(); + $table->unsignedInteger('width')->nullable(); + $table->unsignedInteger('height')->nullable(); + $table->string('mime_type')->nullable(); + $table->unsignedInteger('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'); + $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_05_03_223824_create_themes_table.php b/database/migrations/2026_05_03_223824_create_themes_table.php new file mode 100644 index 00000000..72925274 --- /dev/null +++ b/database/migrations/2026_05_03_223824_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_05_03_223830_create_theme_files_table.php b/database/migrations/2026_05_03_223830_create_theme_files_table.php new file mode 100644 index 00000000..ce4d96dc --- /dev/null +++ b/database/migrations/2026_05_03_223830_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->unsignedInteger('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_05_03_223837_create_theme_settings_table.php b/database/migrations/2026_05_03_223837_create_theme_settings_table.php new file mode 100644 index 00000000..90a84785 --- /dev/null +++ b/database/migrations/2026_05_03_223837_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_05_03_223841_create_pages_table.php b/database/migrations/2026_05_03_223841_create_pages_table.php new file mode 100644 index 00000000..2e52d953 --- /dev/null +++ b/database/migrations/2026_05_03_223841_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_05_03_223844_create_navigation_menus_table.php b/database/migrations/2026_05_03_223844_create_navigation_menus_table.php new file mode 100644 index 00000000..bf2a3af0 --- /dev/null +++ b/database/migrations/2026_05_03_223844_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_05_03_223848_create_navigation_items_table.php b/database/migrations/2026_05_03_223848_create_navigation_items_table.php new file mode 100644 index 00000000..62eed274 --- /dev/null +++ b/database/migrations/2026_05_03_223848_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_05_03_225400_create_carts_table.php b/database/migrations/2026_05_03_225400_create_carts_table.php new file mode 100644 index 00000000..e1ceaa08 --- /dev/null +++ b/database/migrations/2026_05_03_225400_create_carts_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->string('currency', 3)->default('USD'); + $table->unsignedInteger('cart_version')->default(1); + $table->enum('status', ['active', 'converted', 'abandoned'])->default('active'); + $table->timestamps(); + + $table->index('store_id'); + $table->index('customer_id'); + $table->index(['store_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('carts'); + } +}; diff --git a/database/migrations/2026_05_03_225405_create_cart_lines_table.php b/database/migrations/2026_05_03_225405_create_cart_lines_table.php new file mode 100644 index 00000000..b9c423fc --- /dev/null +++ b/database/migrations/2026_05_03_225405_create_cart_lines_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('cart_id')->constrained()->cascadeOnDelete(); + $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->unsignedInteger('quantity')->default(1); + $table->integer('unit_price_amount')->default(0); + $table->integer('line_subtotal_amount')->default(0); + $table->integer('line_discount_amount')->default(0); + $table->integer('line_total_amount')->default(0); + + $table->index('cart_id'); + $table->unique(['cart_id', 'variant_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cart_lines'); + } +}; diff --git a/database/migrations/2026_05_03_225409_create_checkouts_table.php b/database/migrations/2026_05_03_225409_create_checkouts_table.php new file mode 100644 index 00000000..7bb912eb --- /dev/null +++ b/database/migrations/2026_05_03_225409_create_checkouts_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('cart_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->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->unsignedBigInteger('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'); + $table->index('cart_id'); + $table->index('customer_id'); + $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_05_03_225413_create_shipping_zones_table.php b/database/migrations/2026_05_03_225413_create_shipping_zones_table.php new file mode 100644 index 00000000..a7a316bd --- /dev/null +++ b/database/migrations/2026_05_03_225413_create_shipping_zones_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->text('countries_json')->default('[]'); + $table->text('regions_json')->default('[]'); + + $table->index('store_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shipping_zones'); + } +}; diff --git a/database/migrations/2026_05_03_225418_create_shipping_rates_table.php b/database/migrations/2026_05_03_225418_create_shipping_rates_table.php new file mode 100644 index 00000000..b757136e --- /dev/null +++ b/database/migrations/2026_05_03_225418_create_shipping_rates_table.php @@ -0,0 +1,34 @@ +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'); + $table->index(['zone_id', 'is_active']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shipping_rates'); + } +}; diff --git a/database/migrations/2026_05_03_225422_create_tax_settings_table.php b/database/migrations/2026_05_03_225422_create_tax_settings_table.php new file mode 100644 index 00000000..cb46de1b --- /dev/null +++ b/database/migrations/2026_05_03_225422_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_05_03_225427_create_discounts_table.php b/database/migrations/2026_05_03_225427_create_discounts_table.php new file mode 100644 index 00000000..b9332d8b --- /dev/null +++ b/database/migrations/2026_05_03_225427_create_discounts_table.php @@ -0,0 +1,43 @@ +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->unsignedInteger('usage_limit')->nullable(); + $table->unsignedInteger('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'); + $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_05_04_000408_create_customer_addresses_table.php b/database/migrations/2026_05_04_000408_create_customer_addresses_table.php new file mode 100644 index 00000000..56489c98 --- /dev/null +++ b/database/migrations/2026_05_04_000408_create_customer_addresses_table.php @@ -0,0 +1,33 @@ +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'); + $table->index(['customer_id', 'is_default']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('customer_addresses'); + } +}; diff --git a/database/migrations/2026_05_04_000408_create_orders_table.php b/database/migrations/2026_05_04_000408_create_orders_table.php new file mode 100644 index 00000000..f8c42c68 --- /dev/null +++ b/database/migrations/2026_05_04_000408_create_orders_table.php @@ -0,0 +1,54 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('checkout_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('customer_id')->nullable()->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->unique('checkout_id'); + $table->index('store_id'); + $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_05_04_000410_create_order_lines_table.php b/database/migrations/2026_05_04_000410_create_order_lines_table.php new file mode 100644 index 00000000..0777991d --- /dev/null +++ b/database/migrations/2026_05_04_000410_create_order_lines_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('variant_id')->nullable()->constrained('product_variants')->nullOnDelete(); + $table->string('title_snapshot'); + $table->string('sku_snapshot')->nullable(); + $table->unsignedInteger('quantity')->default(1); + $table->integer('unit_price_amount')->default(0); + $table->integer('total_amount')->default(0); + $table->text('tax_lines_json')->default('[]'); + $table->text('discount_allocations_json')->default('[]'); + + $table->index('order_id'); + $table->index('product_id'); + $table->index('variant_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('order_lines'); + } +}; diff --git a/database/migrations/2026_05_04_000411_create_payments_table.php b/database/migrations/2026_05_04_000411_create_payments_table.php new file mode 100644 index 00000000..0a684594 --- /dev/null +++ b/database/migrations/2026_05_04_000411_create_payments_table.php @@ -0,0 +1,40 @@ +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->timestamps(); + + $table->index('order_id'); + $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_05_04_000412_create_refunds_table.php b/database/migrations/2026_05_04_000412_create_refunds_table.php new file mode 100644 index 00000000..aba527a0 --- /dev/null +++ b/database/migrations/2026_05_04_000412_create_refunds_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('payment_id')->constrained()->cascadeOnDelete(); + $table->integer('amount')->default(0); + $table->string('reason')->nullable(); + $table->enum('status', ['pending', 'processed', 'failed'])->default('pending'); + $table->string('provider_refund_id')->nullable(); + $table->timestamps(); + + $table->index('order_id'); + $table->index('payment_id'); + $table->index('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('refunds'); + } +}; diff --git a/database/migrations/2026_05_04_000413_create_fulfillments_table.php b/database/migrations/2026_05_04_000413_create_fulfillments_table.php new file mode 100644 index 00000000..f670d54f --- /dev/null +++ b/database/migrations/2026_05_04_000413_create_fulfillments_table.php @@ -0,0 +1,38 @@ +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->timestamps(); + + $table->index('order_id'); + $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_05_04_000414_create_fulfillment_lines_table.php b/database/migrations/2026_05_04_000414_create_fulfillment_lines_table.php new file mode 100644 index 00000000..c90a023b --- /dev/null +++ b/database/migrations/2026_05_04_000414_create_fulfillment_lines_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('fulfillment_id')->constrained()->cascadeOnDelete(); + $table->foreignId('order_line_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('quantity')->default(1); + + $table->index('fulfillment_id'); + $table->unique(['fulfillment_id', 'order_line_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('fulfillment_lines'); + } +}; diff --git a/database/migrations/2026_05_04_030847_create_search_settings_table.php b/database/migrations/2026_05_04_030847_create_search_settings_table.php new file mode 100644 index 00000000..a566052f --- /dev/null +++ b/database/migrations/2026_05_04_030847_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_05_04_030848_create_products_fts_table.php b/database/migrations/2026_05_04_030848_create_products_fts_table.php new file mode 100644 index 00000000..ae1e870f --- /dev/null +++ b/database/migrations/2026_05_04_030848_create_products_fts_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->text('query'); + $table->text('filters_json')->nullable(); + $table->unsignedInteger('results_count')->default(0); + $table->timestamp('created_at')->nullable(); + + $table->index('store_id', 'idx_search_queries_store_id'); + $table->index(['store_id', 'created_at'], 'idx_search_queries_store_created'); + $table->index(['store_id', 'query'], 'idx_search_queries_store_query'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('search_queries'); + } +}; diff --git a/database/migrations/2026_05_04_032640_create_analytics_events_table.php b/database/migrations/2026_05_04_032640_create_analytics_events_table.php new file mode 100644 index 00000000..f9f641b7 --- /dev/null +++ b/database/migrations/2026_05_04_032640_create_analytics_events_table.php @@ -0,0 +1,41 @@ +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', 'idx_analytics_events_store_id'); + $table->index(['store_id', 'type'], 'idx_analytics_events_store_type'); + $table->index(['store_id', 'created_at'], 'idx_analytics_events_store_created'); + $table->index('session_id', 'idx_analytics_events_session'); + $table->index('customer_id', 'idx_analytics_events_customer'); + $table->unique(['store_id', 'client_event_id'], 'idx_analytics_events_client_event'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('analytics_events'); + } +}; diff --git a/database/migrations/2026_05_04_032641_create_analytics_dailies_table.php b/database/migrations/2026_05_04_032641_create_analytics_dailies_table.php new file mode 100644 index 00000000..38358d9a --- /dev/null +++ b/database/migrations/2026_05_04_032641_create_analytics_dailies_table.php @@ -0,0 +1,37 @@ +foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('date'); + $table->unsignedInteger('orders_count')->default(0); + $table->unsignedBigInteger('revenue_amount')->default(0); + $table->unsignedInteger('aov_amount')->default(0); + $table->unsignedInteger('visits_count')->default(0); + $table->unsignedInteger('add_to_cart_count')->default(0); + $table->unsignedInteger('checkout_started_count')->default(0); + $table->unsignedInteger('checkout_completed_count')->default(0); + + $table->primary(['store_id', 'date']); + $table->index(['store_id', 'date'], 'idx_analytics_daily_store_date'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('analytics_daily'); + } +}; diff --git a/database/migrations/2026_05_04_034556_create_apps_table.php b/database/migrations/2026_05_04_034556_create_apps_table.php new file mode 100644 index 00000000..e1ab164c --- /dev/null +++ b/database/migrations/2026_05_04_034556_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', 'idx_apps_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('apps'); + } +}; diff --git a/database/migrations/2026_05_04_034601_create_app_installations_table.php b/database/migrations/2026_05_04_034601_create_app_installations_table.php new file mode 100644 index 00000000..aa82220a --- /dev/null +++ b/database/migrations/2026_05_04_034601_create_app_installations_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('app_id')->constrained('apps')->cascadeOnDelete(); + $table->json('scopes_json')->default('[]'); + $table->string('status')->default('active'); + $table->timestamp('installed_at')->nullable(); + + $table->unique(['store_id', 'app_id'], 'idx_app_installations_store_app'); + $table->index('store_id', 'idx_app_installations_store_id'); + $table->index('app_id', 'idx_app_installations_app_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('app_installations'); + } +}; diff --git a/database/migrations/2026_05_04_034606_create_oauth_clients_table.php b/database/migrations/2026_05_04_034606_create_oauth_clients_table.php new file mode 100644 index 00000000..23ab71df --- /dev/null +++ b/database/migrations/2026_05_04_034606_create_oauth_clients_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('app_id')->constrained('apps')->cascadeOnDelete(); + $table->string('client_id')->unique('idx_oauth_clients_client_id'); + $table->text('client_secret_encrypted'); + $table->json('redirect_uris_json')->default('[]'); + + $table->index('app_id', 'idx_oauth_clients_app_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_clients'); + } +}; diff --git a/database/migrations/2026_05_04_034611_create_oauth_tokens_table.php b/database/migrations/2026_05_04_034611_create_oauth_tokens_table.php new file mode 100644 index 00000000..35334576 --- /dev/null +++ b/database/migrations/2026_05_04_034611_create_oauth_tokens_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('installation_id')->constrained('app_installations')->cascadeOnDelete(); + $table->string('name')->nullable(); + $table->string('access_token_hash')->unique('idx_oauth_tokens_access_hash'); + $table->string('refresh_token_hash')->nullable(); + $table->json('abilities_json')->default('[]'); + $table->timestamp('expires_at'); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('installation_id', 'idx_oauth_tokens_installation_id'); + $table->index('expires_at', 'idx_oauth_tokens_expires_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_tokens'); + } +}; diff --git a/database/migrations/2026_05_04_034616_create_webhook_subscriptions_table.php b/database/migrations/2026_05_04_034616_create_webhook_subscriptions_table.php new file mode 100644 index 00000000..428beff0 --- /dev/null +++ b/database/migrations/2026_05_04_034616_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->string('target_url', 2048); + $table->text('signing_secret_encrypted'); + $table->string('status')->default('active'); + + $table->index('store_id', 'idx_webhook_subscriptions_store_id'); + $table->index(['store_id', 'event_type'], 'idx_webhook_subscriptions_store_event'); + $table->index('app_installation_id', 'idx_webhook_subscriptions_installation'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_subscriptions'); + } +}; diff --git a/database/migrations/2026_05_04_034621_create_webhook_deliveries_table.php b/database/migrations/2026_05_04_034621_create_webhook_deliveries_table.php new file mode 100644 index 00000000..1bdadafd --- /dev/null +++ b/database/migrations/2026_05_04_034621_create_webhook_deliveries_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('subscription_id')->constrained('webhook_subscriptions')->cascadeOnDelete(); + $table->uuid('event_id'); + $table->unsignedSmallInteger('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('subscription_id', 'idx_webhook_deliveries_subscription_id'); + $table->index('event_id', 'idx_webhook_deliveries_event_id'); + $table->index('status', 'idx_webhook_deliveries_status'); + $table->index('last_attempt_at', 'idx_webhook_deliveries_last_attempt'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_deliveries'); + } +}; diff --git a/database/migrations/2026_05_04_055004_add_parent_id_to_navigation_items_table.php b/database/migrations/2026_05_04_055004_add_parent_id_to_navigation_items_table.php new file mode 100644 index 00000000..e93cdb34 --- /dev/null +++ b/database/migrations/2026_05_04_055004_add_parent_id_to_navigation_items_table.php @@ -0,0 +1,35 @@ +foreignId('parent_id') + ->nullable() + ->after('menu_id') + ->constrained('navigation_items') + ->cascadeOnDelete(); + + $table->index(['menu_id', 'parent_id', 'position'], 'idx_navigation_items_menu_parent_position'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('navigation_items', function (Blueprint $table) { + $table->dropIndex('idx_navigation_items_menu_parent_position'); + $table->dropConstrainedForeignId('parent_id'); + }); + } +}; diff --git a/database/migrations/2026_05_04_074802_create_data_exports_table.php b/database/migrations/2026_05_04_074802_create_data_exports_table.php new file mode 100644 index 00000000..27a68617 --- /dev/null +++ b/database/migrations/2026_05_04_074802_create_data_exports_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('type')->default('orders'); + $table->string('format', 20)->default('csv'); + $table->enum('status', ['queued', 'processing', 'completed', 'failed'])->default('queued'); + $table->text('filters_json')->default('{}'); + $table->unsignedInteger('row_count')->default(0); + $table->string('storage_key')->nullable(); + $table->text('error_message')->nullable(); + $table->timestamp('download_expires_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + $table->timestamps(); + + $table->index('store_id'); + $table->index(['store_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('data_exports'); + } +}; diff --git a/database/migrations/2026_05_04_180719_create_personal_access_tokens_table.php b/database/migrations/2026_05_04_180719_create_personal_access_tokens_table.php new file mode 100644 index 00000000..3ab5f720 --- /dev/null +++ b/database/migrations/2026_05_04_180719_create_personal_access_tokens_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->morphs('tokenable'); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + + $table->index(['store_id', 'tokenable_type', 'tokenable_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/seeders/AnalyticsSeeder.php b/database/seeders/AnalyticsSeeder.php new file mode 100644 index 00000000..4df18331 --- /dev/null +++ b/database/seeders/AnalyticsSeeder.php @@ -0,0 +1,146 @@ +get()->each(function (Store $store): void { + $this->seedDailyMetrics($store); + + if ($store->handle === 'acme-fashion') { + $this->seedEventStream($store); + } + }); + } + + private function seedDailyMetrics(Store $store): void + { + foreach (range(0, 29) as $daysAgo) { + $date = now()->subDays($daysAgo)->toDateString(); + $orders = max(0, 10 - ($daysAgo % 6)); + $revenue = $orders * (5200 + (($daysAgo % 5) * 800)); + + DB::table('analytics_daily')->updateOrInsert( + [ + 'store_id' => $store->getKey(), + 'date' => $date, + ], + [ + 'orders_count' => $orders, + 'revenue_amount' => $revenue, + 'aov_amount' => $orders > 0 ? intdiv($revenue, $orders) : 0, + 'visits_count' => 160 + (($daysAgo * 7) % 80), + 'add_to_cart_count' => 42 + ($daysAgo % 13), + 'checkout_started_count' => 18 + ($daysAgo % 8), + 'checkout_completed_count' => $orders, + ], + ); + } + } + + private function seedEventStream(Store $store): void + { + $customer = Customer::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->first(); + + $types = [ + AnalyticsEventType::PageView, + AnalyticsEventType::PageView, + AnalyticsEventType::PageView, + AnalyticsEventType::ProductView, + AnalyticsEventType::ProductView, + AnalyticsEventType::AddToCart, + AnalyticsEventType::CheckoutStarted, + AnalyticsEventType::CheckoutCompleted, + AnalyticsEventType::Search, + AnalyticsEventType::RemoveFromCart, + ]; + + $referrers = [ + 'https://google.com/search?q=acme+fashion', + 'https://instagram.com/acme-fashion', + 'direct', + 'https://newsletter.example/acme', + ]; + + foreach (range(1, 210) as $index) { + $type = $types[$index % count($types)]; + $occurredAt = now() + ->subDays($index % 7) + ->setTime($index % 24, ($index * 7) % 60, 0); + + AnalyticsEvent::withoutGlobalScopes()->updateOrCreate( + [ + 'store_id' => $store->getKey(), + 'client_event_id' => "seed_evt_{$store->handle}_{$index}", + ], + [ + 'type' => $type, + 'session_id' => 'seed_sess_'.($index % 48), + 'customer_id' => $index % 5 === 0 ? $customer?->getKey() : null, + 'properties_json' => $this->propertiesFor($type, $index, $referrers[$index % count($referrers)]), + 'occurred_at' => $occurredAt, + 'created_at' => $occurredAt, + ], + ); + } + } + + /** + * @return array + */ + private function propertiesFor(AnalyticsEventType $type, int $index, string $referrer): array + { + $base = [ + 'channel' => $index % 4 === 0 ? 'api' : 'storefront', + 'device' => ['desktop', 'mobile', 'tablet'][$index % 3], + 'referrer' => $referrer, + ]; + + return match ($type) { + AnalyticsEventType::PageView => [ + ...$base, + 'url' => $index % 2 === 0 ? '/' : '/collections/t-shirts', + ], + AnalyticsEventType::ProductView => [ + ...$base, + 'product_id' => ($index % 20) + 1, + 'product_title' => 'Seeded product '.$index, + 'url' => '/products/classic-cotton-t-shirt', + ], + AnalyticsEventType::AddToCart, AnalyticsEventType::RemoveFromCart => [ + ...$base, + 'product_id' => ($index % 20) + 1, + 'variant_id' => ($index % 120) + 1, + 'quantity' => 1 + ($index % 3), + 'price_amount' => 2499 + (($index % 5) * 500), + ], + AnalyticsEventType::CheckoutStarted => [ + ...$base, + 'cart_id' => $index, + 'subtotal_amount' => 4500 + (($index % 9) * 500), + ], + AnalyticsEventType::CheckoutCompleted => [ + ...$base, + 'order_id' => $index, + 'order_number' => 'SEED-'.$index, + 'total_amount' => 6500 + (($index % 9) * 750), + ], + AnalyticsEventType::Search => [ + ...$base, + 'query' => ['cotton', 'hoodie', 'jeans', 'sneakers'][$index % 4], + 'results_count' => 3 + ($index % 8), + ], + }; + } +} diff --git a/database/seeders/AppSeeder.php b/database/seeders/AppSeeder.php new file mode 100644 index 00000000..948ca8db --- /dev/null +++ b/database/seeders/AppSeeder.php @@ -0,0 +1,106 @@ +get()->each(function (Store $store): void { + $this->seedInstalledApps($store); + }); + } + + private function seedInstalledApps(Store $store): void + { + $apps = [ + [ + 'name' => 'Inventory Sync', + 'scopes' => ['read-products', 'write-products', 'read-orders'], + 'events' => [WebhookEventType::OrderCreated, WebhookEventType::ProductUpdated], + ], + [ + 'name' => 'Fulfillment Bridge', + 'scopes' => ['read-orders', 'write-orders'], + 'events' => [WebhookEventType::OrderCreated, WebhookEventType::FulfillmentCreated], + ], + ]; + + foreach ($apps as $index => $appData) { + $app = AppModel::query()->updateOrCreate( + ['name' => $appData['name']], + [ + 'status' => AppStatus::Active, + 'created_at' => now()->subMonths(3 - $index), + ], + ); + + $installation = AppInstallation::withoutGlobalScopes()->updateOrCreate( + [ + 'store_id' => $store->getKey(), + 'app_id' => $app->getKey(), + ], + [ + 'scopes_json' => $appData['scopes'], + 'status' => AppInstallationStatus::Active, + 'installed_at' => now()->subWeeks(8 - ($index * 3)), + ], + ); + + OauthClient::query()->updateOrCreate( + ['client_id' => 'app_client_'.$store->handle.'_'.Str::slug($app->name)], + [ + 'app_id' => $app->getKey(), + 'client_secret_encrypted' => 'secret_'.Str::random(40), + 'redirect_uris_json' => ["https://{$store->handle}.integrations.example/oauth/callback"], + ], + ); + + OauthToken::query()->updateOrCreate( + [ + 'installation_id' => $installation->getKey(), + 'name' => $app->name.' token', + ], + [ + 'access_token_hash' => hash('sha256', 'shop_seed_'.$store->handle.'_'.Str::slug($app->name)), + 'refresh_token_hash' => hash('sha256', 'refresh_seed_'.$store->handle.'_'.Str::slug($app->name)), + 'abilities_json' => $appData['scopes'], + 'expires_at' => now()->addYear(), + 'last_used_at' => $index === 0 ? now()->subHours(2) : null, + 'created_at' => $installation->installed_at, + ], + ); + + foreach ($appData['events'] as $eventType) { + WebhookSubscription::withoutGlobalScopes()->updateOrCreate( + [ + 'store_id' => $store->getKey(), + 'app_installation_id' => $installation->getKey(), + 'event_type' => $eventType->value, + ], + [ + 'target_url' => "https://{$store->handle}.integrations.example/webhooks/{$eventType->value}", + 'signing_secret_encrypted' => 'whsec_'.Str::random(40), + 'status' => WebhookSubscriptionStatus::Active, + ], + ); + } + } + } +} diff --git a/database/seeders/CartLineSeeder.php b/database/seeders/CartLineSeeder.php new file mode 100644 index 00000000..ec63763f --- /dev/null +++ b/database/seeders/CartLineSeeder.php @@ -0,0 +1,16 @@ + [ + ['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' => '

Featured electronics for tenant isolation tests.

'], + ['title' => 'Accessories', 'handle' => 'accessories', 'description_html' => '

Electronics accessories.

'], + ], + ]; + + 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/CustomerAddressSeeder.php b/database/seeders/CustomerAddressSeeder.php new file mode 100644 index 00000000..01dd4018 --- /dev/null +++ b/database/seeders/CustomerAddressSeeder.php @@ -0,0 +1,133 @@ +addresses() as $storeHandle => $addressesByEmail) { + $store = Store::query()->where('handle', $storeHandle)->firstOrFail(); + + foreach ($addressesByEmail as $email => $addresses) { + $customer = Customer::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('email', $email) + ->firstOrFail(); + + CustomerAddress::query() + ->where('customer_id', $customer->getKey()) + ->update(['is_default' => false]); + + foreach ($addresses as $address) { + CustomerAddress::query()->updateOrCreate( + [ + 'customer_id' => $customer->getKey(), + 'label' => $address['label'], + ], + [ + 'address_json' => $address['address_json'], + 'is_default' => $address['is_default'], + ], + ); + } + } + } + } + + /** + * @return array}>>> + */ + private function addresses(): array + { + return [ + 'acme-fashion' => [ + 'customer@acme.test' => [ + [ + 'label' => 'Home', + 'is_default' => true, + 'address_json' => $this->address('John', 'Doe', 'Hauptstrasse 1', null, 'Berlin', null, '10115', '+49 30 12345678'), + ], + [ + 'label' => 'Work', + 'is_default' => false, + 'address_json' => $this->address('John', 'Doe', 'Friedrichstrasse 100', 'Acme Corp, 3rd Floor', 'Berlin', null, '10117', '+49 30 87654321'), + ], + ], + 'jane@example.com' => [ + [ + 'label' => 'Home', + 'is_default' => true, + 'address_json' => $this->address('Jane', 'Smith', 'Schillerstrasse 45', null, 'Munich', 'BY', '80336'), + ], + ], + 'michael@example.com' => [ + ['label' => 'Home', 'is_default' => true, 'address_json' => $this->address('Michael', 'Brown', 'Kantstrasse 12', null, 'Berlin', null, '10623')], + ], + 'sarah@example.com' => [ + ['label' => 'Home', 'is_default' => true, 'address_json' => $this->address('Sarah', 'Wilson', 'Lindenweg 8', null, 'Hamburg', null, '20095')], + ], + 'david@example.com' => [ + ['label' => 'Home', 'is_default' => true, 'address_json' => $this->address('David', 'Lee', 'Brueckenstrasse 22', null, 'Cologne', null, '50667')], + ], + 'emma@example.com' => [ + ['label' => 'Home', 'is_default' => true, 'address_json' => $this->address('Emma', 'Garcia', 'Marktstrasse 5', null, 'Leipzig', null, '04109')], + ], + 'james@example.com' => [ + ['label' => 'Home', 'is_default' => true, 'address_json' => $this->address('James', 'Taylor', 'Rosenstrasse 17', null, 'Stuttgart', null, '70173')], + ], + 'lisa@example.com' => [ + ['label' => 'Home', 'is_default' => true, 'address_json' => $this->address('Lisa', 'Anderson', 'Bahnhofstrasse 31', null, 'Dusseldorf', null, '40210')], + ], + 'robert@example.com' => [ + ['label' => 'Home', 'is_default' => true, 'address_json' => $this->address('Robert', 'Martinez', 'Goethestrasse 9', null, 'Frankfurt', null, '60313')], + ], + 'anna@example.com' => [ + ['label' => 'Home', 'is_default' => true, 'address_json' => $this->address('Anna', 'Thomas', 'Lessingstrasse 14', null, 'Nuremberg', null, '90402')], + ], + ], + 'acme-electronics' => [ + 'techfan@example.com' => [ + ['label' => 'Home', 'is_default' => true, 'address_json' => $this->address('Tech', 'Fan', 'Silicon Allee 7', null, 'Berlin', null, '10119')], + ], + 'gadgetlover@example.com' => [ + ['label' => 'Home', 'is_default' => true, 'address_json' => $this->address('Gadget', 'Lover', 'Elektronstrasse 3', null, 'Munich', 'BY', '80331')], + ], + ], + ]; + } + + /** + * @return array + */ + private function address( + string $firstName, + string $lastName, + string $address1, + ?string $address2, + string $city, + ?string $provinceCode, + string $postalCode, + ?string $phone = null, + ): array { + return [ + 'first_name' => $firstName, + 'last_name' => $lastName, + 'address1' => $address1, + 'address2' => $address2, + 'city' => $city, + 'province_code' => $provinceCode, + 'country' => 'DE', + 'postal_code' => $postalCode, + 'phone' => $phone, + ]; + } +} diff --git a/database/seeders/CustomerSeeder.php b/database/seeders/CustomerSeeder.php new file mode 100644 index 00000000..aaa78cb9 --- /dev/null +++ b/database/seeders/CustomerSeeder.php @@ -0,0 +1,59 @@ +customers() as $storeHandle => $customers) { + $store = Store::query()->where('handle', $storeHandle)->firstOrFail(); + + foreach ($customers as $customer) { + Customer::withoutGlobalScopes()->updateOrCreate( + [ + 'store_id' => $store->getKey(), + 'email' => $customer['email'], + ], + [ + 'name' => $customer['name'], + 'password' => 'password', + 'marketing_opt_in' => $customer['marketing_opt_in'], + ], + ); + } + } + } + + /** + * @return array> + */ + private function customers(): array + { + return [ + 'acme-fashion' => [ + ['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], + ], + 'acme-electronics' => [ + ['email' => 'techfan@example.com', 'name' => 'Tech Fan', 'marketing_opt_in' => true], + ['email' => 'gadgetlover@example.com', 'name' => 'Gadget Lover', 'marketing_opt_in' => false], + ], + ]; + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef2..a92014ba 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,34 @@ 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, + AppSeeder::class, + UserSeeder::class, + StoreUserSeeder::class, + StoreSettingsSeeder::class, + SearchSettingsSeeder::class, + CollectionSeeder::class, + ProductSeeder::class, + ThemeSeeder::class, + ThemeFileSeeder::class, + ThemeSettingsSeeder::class, + PageSeeder::class, + NavigationMenuSeeder::class, + NavigationItemSeeder::class, + TaxSettingsSeeder::class, + ShippingZoneSeeder::class, + ShippingRateSeeder::class, + DiscountSeeder::class, + CustomerSeeder::class, + CustomerAddressSeeder::class, + OrderSeeder::class, + PaymentSeeder::class, + FulfillmentSeeder::class, + RefundSeeder::class, + AnalyticsSeeder::class, ]); } } diff --git a/database/seeders/DiscountSeeder.php b/database/seeders/DiscountSeeder.php new file mode 100644 index 00000000..763a1116 --- /dev/null +++ b/database/seeders/DiscountSeeder.php @@ -0,0 +1,54 @@ +get()->each(function (Store $store): void { + foreach ($this->discounts() as $discount) { + Discount::withoutGlobalScopes()->updateOrCreate( + [ + 'store_id' => $store->getKey(), + 'code' => $discount['code'], + ], + [ + 'type' => 'code', + 'value_type' => $discount['value_type'], + 'value_amount' => $discount['value_amount'], + 'starts_at' => $discount['starts_at'] ?? now()->subDay(), + 'ends_at' => $discount['ends_at'] ?? now()->addMonth(), + 'usage_limit' => $discount['usage_limit'] ?? null, + 'usage_count' => $discount['usage_count'] ?? 0, + 'rules_json' => $discount['rules_json'], + 'status' => $discount['status'] ?? 'active', + ], + ); + } + }); + } + + /** + * @return array, starts_at?: \Illuminate\Support\Carbon, ends_at?: \Illuminate\Support\Carbon, usage_limit?: int|null, usage_count?: int, status?: string}> + */ + private function discounts(): array + { + return [ + ['code' => 'WELCOME10', 'value_type' => 'percent', 'value_amount' => 10, 'usage_count' => 3, 'rules_json' => ['min_purchase_amount' => 2000]], + ['code' => 'FLAT5', 'value_type' => 'fixed', 'value_amount' => 500, 'rules_json' => []], + ['code' => 'SAVE10', 'value_type' => 'percent', 'value_amount' => 10, 'rules_json' => ['customer_eligibility' => 'all']], + ['code' => '5OFF', 'value_type' => 'fixed', 'value_amount' => 500, 'rules_json' => ['min_purchase_amount' => 2500, 'customer_eligibility' => 'all']], + ['code' => 'FREESHIP', 'value_type' => 'free_shipping', 'value_amount' => 0, 'rules_json' => ['customer_eligibility' => 'all']], + ['code' => 'EXPIRED20', 'value_type' => 'percent', 'value_amount' => 20, 'ends_at' => now()->subDay(), 'rules_json' => [], 'status' => 'expired'], + ['code' => 'MAXED', 'value_type' => 'percent', 'value_amount' => 10, 'usage_limit' => 5, 'usage_count' => 5, 'rules_json' => []], + ]; + } +} diff --git a/database/seeders/FulfillmentLineSeeder.php b/database/seeders/FulfillmentLineSeeder.php new file mode 100644 index 00000000..28b2f40f --- /dev/null +++ b/database/seeders/FulfillmentLineSeeder.php @@ -0,0 +1,16 @@ +fulfillments() as $storeHandle => $fulfillments) { + $store = Store::query()->where('handle', $storeHandle)->firstOrFail(); + $orders = Order::withoutGlobalScopes() + ->with(['lines.product']) + ->where('store_id', $store->getKey()) + ->whereIn('order_number', array_keys($fulfillments)) + ->get() + ->keyBy('order_number'); + + Fulfillment::query() + ->whereIn('order_id', $orders->pluck('id')) + ->get() + ->each(function (Fulfillment $fulfillment): void { + $fulfillment->lines()->delete(); + $fulfillment->delete(); + }); + + foreach ($fulfillments as $orderNumber => $fulfillmentData) { + $order = $orders->get($orderNumber); + + if (! $order instanceof Order) { + continue; + } + + $fulfillment = Fulfillment::query()->create([ + 'order_id' => $order->getKey(), + 'status' => $fulfillmentData['status'], + 'tracking_company' => $fulfillmentData['tracking_company'], + 'tracking_number' => $fulfillmentData['tracking_number'], + 'tracking_url' => null, + 'shipped_at' => $this->timestamp($order, $fulfillmentData['shipped_at']), + 'delivered_at' => $this->timestamp($order, $fulfillmentData['delivered_at']), + ]); + + $this->createLines($fulfillment, $order, $fulfillmentData['lines']); + } + } + }); + } + + /** + * @param array|null $lineQuantities + */ + private function createLines(Fulfillment $fulfillment, Order $order, ?array $lineQuantities): void + { + $lines = $lineQuantities === null + ? $order->lines + : $order->lines->filter(function (OrderLine $line) use ($lineQuantities): bool { + $handle = $line->product?->handle; + + return is_string($handle) && array_key_exists($handle, $lineQuantities); + }); + + $lines->each(function (OrderLine $line) use ($fulfillment, $lineQuantities): void { + $handle = $line->product?->handle; + $quantity = $lineQuantities === null || ! is_string($handle) + ? $line->quantity + : $lineQuantities[$handle]; + + $fulfillment->lines()->create([ + 'order_line_id' => $line->getKey(), + 'quantity' => $quantity, + ]); + }); + } + + private function timestamp(Order $order, mixed $value): mixed + { + if ($value === 'placed_at') { + return $order->placed_at; + } + + if (is_int($value)) { + return now()->subDays($value); + } + + return null; + } + + /** + * @return array|null}>> + */ + private function fulfillments(): array + { + return [ + 'acme-fashion' => [ + '#1002' => $this->fulfillment(FulfillmentShipmentStatus::Delivered, 'DHL', 'DHL1234567890', 8, 7), + '#1003' => $this->fulfillment(FulfillmentShipmentStatus::Shipped, 'DHL', 'DHL9876543210', 3, null, [ + 'premium-slim-fit-jeans' => 1, + ]), + '#1007' => $this->fulfillment(FulfillmentShipmentStatus::Delivered, 'DHL', 'DHL1112223334', 18, 17), + '#1008' => $this->fulfillment(FulfillmentShipmentStatus::Delivered, 'UPS', 'UPS5556667778', 10, 9), + '#1011' => $this->fulfillment(FulfillmentShipmentStatus::Delivered, 'FedEx', 'FX9998887776', 23, 22), + '#1014' => $this->fulfillment(FulfillmentShipmentStatus::Delivered, null, null, 'placed_at', 'placed_at'), + ], + 'acme-electronics' => [ + '#5001' => $this->fulfillment(FulfillmentShipmentStatus::Delivered, 'DHL', 'DHL5001000001', 5, 4), + ], + ]; + } + + /** + * @param array|null $lines + * @return array{status: FulfillmentShipmentStatus, tracking_company: string|null, tracking_number: string|null, shipped_at: int|string|null, delivered_at: int|string|null, lines: array|null} + */ + private function fulfillment( + FulfillmentShipmentStatus $status, + ?string $trackingCompany, + ?string $trackingNumber, + int|string|null $shippedAt, + int|string|null $deliveredAt, + ?array $lines = null, + ): array { + return [ + 'status' => $status, + 'tracking_company' => $trackingCompany, + 'tracking_number' => $trackingNumber, + 'shipped_at' => $shippedAt, + 'delivered_at' => $deliveredAt, + 'lines' => $lines, + ]; + } +} diff --git a/database/seeders/InventoryItemSeeder.php b/database/seeders/InventoryItemSeeder.php new file mode 100644 index 00000000..6aa07f65 --- /dev/null +++ b/database/seeders/InventoryItemSeeder.php @@ -0,0 +1,16 @@ +get()->each(function (Store $store): void { + $menus = NavigationMenu::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->get() + ->keyBy('handle'); + + $this->replaceItems($menus->get('main-menu'), $this->mainMenuItems($store)); + $this->replaceItems($menus->get('footer-menu'), $this->footerMenuItems($store)); + }); + } + + /** + * @param array> $items + */ + private function replaceItems(?NavigationMenu $menu, array $items): void + { + if ($menu === null) { + return; + } + + NavigationItem::withoutGlobalScopes() + ->where('menu_id', $menu->getKey()) + ->delete(); + + $this->createItems($menu, $items); + } + + /** + * @param array> $items + */ + private function createItems(NavigationMenu $menu, array $items, ?int $parentId = null): void + { + foreach ($items as $position => $item) { + $navigationItem = NavigationItem::withoutGlobalScopes()->create([ + 'menu_id' => $menu->getKey(), + 'parent_id' => $parentId, + 'type' => $item['type'], + 'label' => $item['label'], + 'url' => $item['url'] ?? null, + 'resource_id' => $item['resource_id'] ?? null, + 'position' => $position, + ]); + + if (($item['children'] ?? []) !== []) { + $this->createItems($menu, array_values($item['children']), $navigationItem->getKey()); + } + } + } + + /** + * @return array> + */ + private function mainMenuItems(Store $store): array + { + $newArrivals = $this->collection($store, 'new-arrivals') ?? $this->collection($store, 'featured'); + $secondaryCollection = $this->collection($store, 't-shirts') ?? $this->collection($store, 'accessories'); + $tertiaryCollection = $this->collection($store, 'pants-jeans'); + $sale = $this->collection($store, 'sale'); + $about = $this->page($store, 'about'); + + return array_values(array_filter([ + ['label' => 'Home', 'type' => 'link', 'url' => '/'], + [ + 'label' => 'Shop', + 'type' => 'link', + 'url' => '/collections', + 'children' => array_values(array_filter([ + $newArrivals ? ['label' => $newArrivals->title, 'type' => 'collection', 'resource_id' => $newArrivals->getKey()] : null, + $secondaryCollection ? ['label' => $secondaryCollection->title, 'type' => 'collection', 'resource_id' => $secondaryCollection->getKey()] : null, + $tertiaryCollection ? ['label' => $tertiaryCollection->title, 'type' => 'collection', 'resource_id' => $tertiaryCollection->getKey()] : null, + $sale ? ['label' => 'Sale', 'type' => 'collection', 'resource_id' => $sale->getKey()] : null, + ])), + ], + ['label' => 'Search', 'type' => 'link', 'url' => '/search'], + $about ? ['label' => 'About', 'type' => 'page', 'resource_id' => $about->getKey()] : null, + ])); + } + + /** + * @return array> + */ + private function footerMenuItems(Store $store): array + { + $sale = $this->collection($store, 'sale'); + $about = $this->page($store, 'about'); + $faq = $this->page($store, 'faq'); + + return array_values(array_filter([ + $sale ? ['label' => 'Sale', 'type' => 'collection', 'resource_id' => $sale->getKey()] : null, + $about ? ['label' => 'About', 'type' => 'page', 'resource_id' => $about->getKey()] : null, + $faq ? ['label' => 'FAQ', 'type' => 'page', 'resource_id' => $faq->getKey()] : null, + ['label' => 'Account', 'type' => 'link', 'url' => '/account'], + ])); + } + + private function collection(Store $store, string $handle): ?ProductCollection + { + return ProductCollection::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', $handle) + ->first(); + } + + private function page(Store $store, string $handle): ?Page + { + return Page::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', $handle) + ->first(); + } +} diff --git a/database/seeders/NavigationMenuSeeder.php b/database/seeders/NavigationMenuSeeder.php new file mode 100644 index 00000000..1f9bf6e1 --- /dev/null +++ b/database/seeders/NavigationMenuSeeder.php @@ -0,0 +1,31 @@ +get()->each(function (Store $store): void { + foreach ([ + 'main-menu' => 'Main Menu', + 'footer-menu' => 'Footer Menu', + ] as $handle => $title) { + NavigationMenu::withoutGlobalScopes()->updateOrCreate( + [ + 'store_id' => $store->getKey(), + 'handle' => $handle, + ], + ['title' => $title], + ); + } + }); + } +} diff --git a/database/seeders/OrderLineSeeder.php b/database/seeders/OrderLineSeeder.php new file mode 100644 index 00000000..ba80af87 --- /dev/null +++ b/database/seeders/OrderLineSeeder.php @@ -0,0 +1,16 @@ +orders() as $storeHandle => $orders) { + $store = Store::query()->where('handle', $storeHandle)->firstOrFail(); + + $this->resetSeededOrders($store, array_column($orders, 'order_number')); + + foreach ($orders as $orderData) { + $this->createOrder($store, $orderData); + } + } + + $this->reservePendingInventory(); + }); + } + + /** + * @param list $orderNumbers + */ + private function resetSeededOrders(Store $store, array $orderNumbers): void + { + Order::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->whereIn('order_number', $orderNumbers) + ->get() + ->each(function (Order $order): void { + $order->refunds()->delete(); + $order->payments()->delete(); + + $order->fulfillments() + ->get() + ->each(function (Fulfillment $fulfillment): void { + $fulfillment->lines()->delete(); + $fulfillment->delete(); + }); + + $order->lines()->delete(); + }); + } + + /** + * @param array $orderData + */ + private function createOrder(Store $store, array $orderData): void + { + $customer = Customer::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('email', $orderData['customer_email']) + ->firstOrFail(); + $address = $this->defaultAddress($customer); + + $order = Order::withoutGlobalScopes()->updateOrCreate( + [ + 'store_id' => $store->getKey(), + 'order_number' => $orderData['order_number'], + ], + [ + 'customer_id' => $customer->getKey(), + 'payment_method' => $orderData['payment_method'], + 'status' => $orderData['status'], + 'financial_status' => $orderData['financial_status'], + 'fulfillment_status' => $orderData['fulfillment_status'], + 'currency' => $store->default_currency, + 'subtotal_amount' => $orderData['subtotal_amount'], + 'discount_amount' => $orderData['discount_amount'], + 'shipping_amount' => $orderData['shipping_amount'], + 'tax_amount' => $orderData['tax_amount'], + 'total_amount' => $orderData['total_amount'], + 'email' => $customer->email, + 'billing_address_json' => $address, + 'shipping_address_json' => $address, + 'placed_at' => $orderData['placed_at'], + ], + ); + + foreach ($orderData['lines'] as $lineData) { + $this->createOrderLine($store, $order, $lineData); + } + } + + /** + * @param array $lineData + */ + private function createOrderLine(Store $store, Order $order, array $lineData): void + { + $product = Product::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', $lineData['product']) + ->firstOrFail(); + $variant = $this->variant($product, $lineData['options'] ?? []); + $discountAmount = (int) ($lineData['discount_amount'] ?? 0); + + $order->lines()->create([ + 'product_id' => $product->getKey(), + 'variant_id' => $variant->getKey(), + 'title_snapshot' => $this->titleSnapshot($product, $lineData['options'] ?? []), + 'sku_snapshot' => $variant->sku, + 'quantity' => $lineData['quantity'], + 'unit_price_amount' => $lineData['unit_price_amount'], + 'total_amount' => $lineData['total_amount'], + 'tax_lines_json' => [], + 'discount_allocations_json' => $discountAmount > 0 ? [[ + 'discount_id' => $this->welcomeDiscount($store)->getKey(), + 'code' => 'WELCOME10', + 'amount' => $discountAmount, + ]] : [], + ]); + } + + /** + * @param array $options + */ + private function variant(Product $product, array $options): ProductVariant + { + $query = ProductVariant::withoutGlobalScopes() + ->where('product_id', $product->getKey()); + + foreach ($options as $optionName => $value) { + $query->whereHas('optionValues', function ($query) use ($optionName, $value): void { + $query + ->withoutGlobalScopes() + ->where('value', $value) + ->whereHas('option', fn ($query) => $query + ->withoutGlobalScopes() + ->where('name', $optionName)); + }); + } + + return $query->oldest('position')->firstOrFail(); + } + + /** + * @param array $options + */ + private function titleSnapshot(Product $product, array $options): string + { + if ($options === []) { + return $product->title; + } + + return $product->title.' - '.implode(' / ', array_values($options)); + } + + /** + * @return array + */ + private function defaultAddress(Customer $customer): array + { + $address = CustomerAddress::query() + ->where('customer_id', $customer->getKey()) + ->orderByDesc('is_default') + ->oldest('id') + ->firstOrFail(); + + return $address->address_json ?? []; + } + + private function welcomeDiscount(Store $store): Discount + { + return Discount::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('code', 'WELCOME10') + ->firstOrFail(); + } + + private function reservePendingInventory(): void + { + $reservations = Order::withoutGlobalScopes() + ->with('lines') + ->where('financial_status', FinancialStatus::Pending->value) + ->whereIn('order_number', ['#1005', '#5003']) + ->get() + ->flatMap(fn (Order $order) => $order->lines) + ->filter(fn ($line): bool => $line->variant_id !== null) + ->groupBy('variant_id') + ->map(fn ($lines): int => (int) $lines->sum('quantity')); + + $reservations->each(function (int $quantity, int $variantId): void { + InventoryItem::withoutGlobalScopes() + ->where('variant_id', $variantId) + ->update(['quantity_reserved' => $quantity]); + }); + } + + /** + * @return array>> + */ + private function orders(): array + { + return [ + 'acme-fashion' => [ + $this->order('#1001', 'customer@acme.test', PaymentMethod::CreditCard, OrderStatus::Paid, FinancialStatus::Paid, FulfillmentStatus::Unfulfilled, 4998, 0, 499, 798, 5497, now()->subDays(2), [ + $this->line('classic-cotton-t-shirt', ['Size' => 'S', 'Color' => 'White'], 2, 2499, 4998), + ]), + $this->order('#1002', 'customer@acme.test', PaymentMethod::CreditCard, OrderStatus::Fulfilled, FinancialStatus::Paid, FulfillmentStatus::Fulfilled, 8498, 0, 499, 1357, 8997, now()->subDays(10), [ + $this->line('organic-hoodie', ['Size' => 'M'], 1, 5999, 5999), + $this->line('classic-cotton-t-shirt', ['Size' => 'L', 'Color' => 'Black'], 1, 2499, 2499), + ]), + $this->order('#1003', 'jane@example.com', PaymentMethod::CreditCard, OrderStatus::Paid, FinancialStatus::Paid, FulfillmentStatus::Partial, 11498, 0, 499, 1836, 11997, now()->subDays(5), [ + $this->line('premium-slim-fit-jeans', ['Size' => '32', 'Color' => 'Blue'], 1, 7999, 7999), + $this->line('leather-belt', ['Size' => 'L/XL', 'Color' => 'Brown'], 1, 3499, 3499), + ]), + $this->order('#1004', 'customer@acme.test', PaymentMethod::CreditCard, OrderStatus::Cancelled, FinancialStatus::Refunded, FulfillmentStatus::Unfulfilled, 2499, 0, 499, 399, 2998, now()->subDays(15), [ + $this->line('classic-cotton-t-shirt', ['Size' => 'M', 'Color' => 'Navy'], 1, 2499, 2499), + ]), + $this->order('#1005', 'jane@example.com', PaymentMethod::BankTransfer, OrderStatus::Pending, FinancialStatus::Pending, FulfillmentStatus::Unfulfilled, 3499, 0, 499, 559, 3998, now()->subHours(2), [ + $this->line('leather-belt', ['Size' => 'S/M', 'Color' => 'Black'], 1, 3499, 3499), + ]), + $this->order('#1006', 'michael@example.com', PaymentMethod::CreditCard, OrderStatus::Paid, FinancialStatus::Paid, FulfillmentStatus::Unfulfilled, 11999, 0, 499, 1916, 12498, now()->subDay(), [ + $this->line('running-sneakers', ['Size' => 'EU 42', 'Color' => 'Black'], 1, 11999, 11999), + ]), + $this->order('#1007', 'sarah@example.com', PaymentMethod::Paypal, OrderStatus::Fulfilled, FinancialStatus::Paid, FulfillmentStatus::Fulfilled, 9997, 0, 499, 1596, 10496, now()->subDays(20), [ + $this->line('v-neck-linen-tee', ['Size' => 'M', 'Color' => 'Beige'], 2, 3499, 6998), + $this->line('wool-scarf', ['Color' => 'Grey'], 1, 2999, 2999), + ]), + $this->order('#1008', 'david@example.com', PaymentMethod::CreditCard, OrderStatus::Paid, FinancialStatus::PartiallyRefunded, FulfillmentStatus::Fulfilled, 8498, 0, 499, 1357, 8997, now()->subDays(12), [ + $this->line('cargo-pants', ['Size' => '32', 'Color' => 'Khaki'], 1, 5499, 5499), + $this->line('graphic-print-tee', ['Size' => 'L'], 1, 2999, 2999), + ]), + $this->order('#1009', 'emma@example.com', PaymentMethod::CreditCard, OrderStatus::Paid, FinancialStatus::Paid, FulfillmentStatus::Unfulfilled, 4498, 0, 499, 718, 4997, now()->subDays(3), [ + $this->line('canvas-tote-bag', ['Color' => 'Natural'], 1, 1999, 1999), + $this->line('bucket-hat', ['Size' => 'S/M', 'Color' => 'Black'], 1, 2499, 2499), + ]), + $this->order('#1010', 'customer@acme.test', PaymentMethod::Paypal, OrderStatus::Paid, FinancialStatus::Paid, FulfillmentStatus::Unfulfilled, 49999, 0, 499, 7983, 50498, now()->subDay(), [ + $this->line('cashmere-overcoat', ['Size' => 'M', 'Color' => 'Camel'], 1, 49999, 49999), + ]), + $this->order('#1011', 'james@example.com', PaymentMethod::CreditCard, OrderStatus::Paid, FinancialStatus::Paid, FulfillmentStatus::Fulfilled, 2799, 0, 499, 447, 3298, now()->subDays(25), [ + $this->line('striped-polo-shirt', ['Size' => 'XL'], 1, 2799, 2799), + ]), + $this->order('#1012', 'lisa@example.com', PaymentMethod::CreditCard, OrderStatus::Paid, FinancialStatus::Paid, FulfillmentStatus::Unfulfilled, 7998, 0, 499, 1277, 8497, now()->subDays(4), [ + $this->line('chino-shorts', ['Size' => '34', 'Color' => 'Navy'], 2, 3999, 7998), + ]), + $this->order('#1013', 'robert@example.com', PaymentMethod::CreditCard, OrderStatus::Paid, FinancialStatus::Paid, FulfillmentStatus::Unfulfilled, 7998, 0, 499, 1277, 8497, now()->subDay(), [ + $this->line('wide-leg-trousers', ['Size' => 'M'], 1, 4999, 4999), + $this->line('wool-scarf', ['Color' => 'Burgundy'], 1, 2999, 2999), + ]), + $this->order('#1014', 'anna@example.com', PaymentMethod::CreditCard, OrderStatus::Paid, FinancialStatus::Paid, FulfillmentStatus::Fulfilled, 5000, 0, 0, 798, 5000, now()->subDays(14), [ + $this->line('gift-card', ['Amount' => '50 EUR'], 1, 5000, 5000), + ]), + $this->order('#1015', 'customer@acme.test', PaymentMethod::BankTransfer, OrderStatus::Paid, FinancialStatus::Paid, FulfillmentStatus::Unfulfilled, 5498, 550, 499, 790, 5447, now(), [ + $this->line('classic-cotton-t-shirt', ['Size' => 'M', 'Color' => 'White'], 1, 2499, 2499, 250), + $this->line('graphic-print-tee', ['Size' => 'M'], 1, 2999, 2999, 300), + ]), + ], + 'acme-electronics' => [ + $this->order('#5001', 'techfan@example.com', PaymentMethod::CreditCard, OrderStatus::Fulfilled, FinancialStatus::Paid, FulfillmentStatus::Fulfilled, 121298, 0, 0, 0, 121298, now()->subDays(6), [ + $this->line('pro-laptop-15', ['Storage' => '512GB'], 1, 119999, 119999), + $this->line('usb-c-cable-2m', [], 1, 1299, 1299), + ]), + $this->order('#5002', 'gadgetlover@example.com', PaymentMethod::CreditCard, OrderStatus::Paid, FinancialStatus::Paid, FulfillmentStatus::Unfulfilled, 14999, 0, 0, 0, 14999, now()->subDays(2), [ + $this->line('wireless-headphones', ['Color' => 'Black'], 1, 14999, 14999), + ]), + $this->order('#5003', 'techfan@example.com', PaymentMethod::BankTransfer, OrderStatus::Pending, FinancialStatus::Pending, FulfillmentStatus::Unfulfilled, 4999, 0, 0, 0, 4999, now()->subHours(6), [ + $this->line('monitor-stand', [], 1, 4999, 4999), + ]), + ], + ]; + } + + /** + * @param list> $lines + * @return array + */ + private function order( + string $orderNumber, + string $customerEmail, + PaymentMethod $paymentMethod, + OrderStatus $status, + FinancialStatus $financialStatus, + FulfillmentStatus $fulfillmentStatus, + int $subtotalAmount, + int $discountAmount, + int $shippingAmount, + int $taxAmount, + int $totalAmount, + mixed $placedAt, + array $lines, + ): array { + return [ + 'order_number' => $orderNumber, + 'customer_email' => $customerEmail, + 'payment_method' => $paymentMethod, + 'status' => $status, + 'financial_status' => $financialStatus, + 'fulfillment_status' => $fulfillmentStatus, + 'subtotal_amount' => $subtotalAmount, + 'discount_amount' => $discountAmount, + 'shipping_amount' => $shippingAmount, + 'tax_amount' => $taxAmount, + 'total_amount' => $totalAmount, + 'placed_at' => $placedAt, + 'lines' => $lines, + ]; + } + + /** + * @param array $options + * @return array + */ + private function line( + string $product, + array $options, + int $quantity, + int $unitPriceAmount, + int $totalAmount, + int $discountAmount = 0, + ): array { + return [ + 'product' => $product, + 'options' => $options, + 'quantity' => $quantity, + 'unit_price_amount' => $unitPriceAmount, + 'total_amount' => $totalAmount, + 'discount_amount' => $discountAmount, + ]; + } +} diff --git a/database/seeders/OrganizationSeeder.php b/database/seeders/OrganizationSeeder.php new file mode 100644 index 00000000..ef2ae42e --- /dev/null +++ b/database/seeders/OrganizationSeeder.php @@ -0,0 +1,20 @@ +updateOrCreate( + ['billing_email' => 'billing@acme.test'], + ['name' => 'Acme Commerce Group'], + ); + } +} diff --git a/database/seeders/PageSeeder.php b/database/seeders/PageSeeder.php new file mode 100644 index 00000000..b3004357 --- /dev/null +++ b/database/seeders/PageSeeder.php @@ -0,0 +1,57 @@ +get()->each(function (Store $store): void { + foreach ($this->pagesFor($store) as $page) { + Page::withoutGlobalScopes()->updateOrCreate( + [ + 'store_id' => $store->getKey(), + 'handle' => $page['handle'], + ], + [ + 'title' => $page['title'], + 'body_html' => $page['body_html'], + 'status' => 'published', + 'published_at' => now(), + ], + ); + } + }); + } + + /** + * @return array + */ + private function pagesFor(Store $store): array + { + return [ + [ + 'handle' => 'about', + 'title' => 'About', + 'body_html' => "

{$store->name} is the demo storefront for this self-contained shop platform.

The catalog includes multi-variant products, sale pricing, inventory rules, backorder handling, and digital product examples for checkout and storefront testing.

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

Shipping, payment, returns, and account flows are implemented progressively according to the project roadmap.

', + ], + [ + 'handle' => 'shipping', + 'title' => 'Shipping', + 'body_html' => '

Shipping zones, rates, and tax-aware checkout calculations are seeded in the checkout phase.

', + ], + ]; + } +} diff --git a/database/seeders/PaymentSeeder.php b/database/seeders/PaymentSeeder.php new file mode 100644 index 00000000..9128f08b --- /dev/null +++ b/database/seeders/PaymentSeeder.php @@ -0,0 +1,102 @@ +payments() as $storeHandle => $payments) { + $store = Store::query()->where('handle', $storeHandle)->firstOrFail(); + $orders = Order::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->whereIn('order_number', array_keys($payments)) + ->get() + ->keyBy('order_number'); + + Payment::query() + ->whereIn('order_id', $orders->pluck('id')) + ->delete(); + + foreach ($payments as $orderNumber => $paymentData) { + $order = $orders->get($orderNumber); + + if (! $order instanceof Order) { + continue; + } + + Payment::query()->create([ + 'order_id' => $order->getKey(), + 'provider' => 'mock', + 'method' => $paymentData['method'], + 'provider_payment_id' => $paymentData['provider_payment_id'], + 'status' => $paymentData['status'], + 'amount' => $paymentData['amount'], + 'currency' => $order->currency, + 'raw_json_encrypted' => [ + 'success' => true, + 'status' => $paymentData['status']->value, + 'reference_id' => $paymentData['provider_payment_id'], + ], + ]); + } + } + }); + } + + /** + * @return array> + */ + private function payments(): array + { + return [ + 'acme-fashion' => [ + '#1001' => $this->payment(PaymentMethod::CreditCard, 'mock_test_order1001', PaymentStatus::Captured, 5497), + '#1002' => $this->payment(PaymentMethod::CreditCard, 'mock_test_order1002', PaymentStatus::Captured, 8997), + '#1003' => $this->payment(PaymentMethod::CreditCard, 'mock_test_order1003', PaymentStatus::Captured, 11997), + '#1004' => $this->payment(PaymentMethod::CreditCard, 'mock_test_order1004', PaymentStatus::Refunded, 2998), + '#1005' => $this->payment(PaymentMethod::BankTransfer, 'mock_test_order1005', PaymentStatus::Pending, 3998), + '#1006' => $this->payment(PaymentMethod::CreditCard, 'mock_test_order1006', PaymentStatus::Captured, 12498), + '#1007' => $this->payment(PaymentMethod::Paypal, 'mock_test_order1007', PaymentStatus::Captured, 10496), + '#1008' => $this->payment(PaymentMethod::CreditCard, 'mock_test_order1008', PaymentStatus::Captured, 8997), + '#1009' => $this->payment(PaymentMethod::CreditCard, 'mock_test_order1009', PaymentStatus::Captured, 4997), + '#1010' => $this->payment(PaymentMethod::Paypal, 'mock_test_order1010', PaymentStatus::Captured, 50498), + '#1011' => $this->payment(PaymentMethod::CreditCard, 'mock_test_order1011', PaymentStatus::Captured, 3298), + '#1012' => $this->payment(PaymentMethod::CreditCard, 'mock_test_order1012', PaymentStatus::Captured, 8497), + '#1013' => $this->payment(PaymentMethod::CreditCard, 'mock_test_order1013', PaymentStatus::Captured, 8497), + '#1014' => $this->payment(PaymentMethod::CreditCard, 'mock_test_order1014', PaymentStatus::Captured, 5000), + '#1015' => $this->payment(PaymentMethod::BankTransfer, 'mock_test_order1015', PaymentStatus::Captured, 5447), + ], + 'acme-electronics' => [ + '#5001' => $this->payment(PaymentMethod::CreditCard, 'mock_test_order5001', PaymentStatus::Captured, 121298), + '#5002' => $this->payment(PaymentMethod::CreditCard, 'mock_test_order5002', PaymentStatus::Captured, 14999), + '#5003' => $this->payment(PaymentMethod::BankTransfer, 'mock_test_order5003', PaymentStatus::Pending, 4999), + ], + ]; + } + + /** + * @return array{method: PaymentMethod, provider_payment_id: string, status: PaymentStatus, amount: int} + */ + private function payment(PaymentMethod $method, string $providerPaymentId, PaymentStatus $status, int $amount): array + { + return [ + 'method' => $method, + 'provider_payment_id' => $providerPaymentId, + 'status' => $status, + 'amount' => $amount, + ]; + } +} diff --git a/database/seeders/ProductMediaSeeder.php b/database/seeders/ProductMediaSeeder.php new file mode 100644 index 00000000..e4ccec7b --- /dev/null +++ b/database/seeders/ProductMediaSeeder.php @@ -0,0 +1,16 @@ +seedProducts('acme-fashion', $this->fashionProducts()); + $this->seedProducts('acme-electronics', $this->electronicsProducts()); + }); + } + + /** + * @param array> $products + */ + private function seedProducts(string $storeHandle, array $products): void + { + $store = Store::query()->where('handle', $storeHandle)->firstOrFail(); + $collections = ProductCollection::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->get() + ->keyBy('handle'); + + foreach ($products as $index => $data) { + $product = Product::withoutGlobalScopes()->updateOrCreate( + [ + 'store_id' => $store->getKey(), + 'handle' => $data['handle'], + ], + [ + ...Arr::only($data, ['title', 'status', 'description_html', 'vendor', 'product_type', 'tags', 'published_at']), + 'store_id' => $store->getKey(), + ], + ); + + $this->replaceCatalogChildren($product); + + $optionValueIds = $this->createOptions($product, $data['options'] ?? []); + $this->createVariants($store, $product, $data, $optionValueIds); + + $collectionIds = collect($data['collections'] ?? []) + ->map(fn (string $handle): ?int => $collections->get($handle)?->getKey()) + ->filter() + ->mapWithKeys(fn (int $collectionId): array => [$collectionId => ['position' => $index]]) + ->all(); + + $product->collections()->sync($collectionIds); + } + } + + private function replaceCatalogChildren(Product $product): void + { + ProductMedia::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->get() + ->each + ->delete(); + + ProductVariant::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->get() + ->each + ->delete(); + + ProductOption::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->delete(); + } + + /** + * @param array> $options + * @return array> + */ + private function createOptions(Product $product, array $options): array + { + $optionValueIds = []; + + foreach ($options as $optionPosition => $valuesByName) { + $optionName = (string) array_key_first($valuesByName); + $option = ProductOption::withoutGlobalScopes()->create([ + 'product_id' => $product->getKey(), + 'name' => $optionName, + 'position' => $optionPosition, + ]); + + foreach (array_values($valuesByName[$optionName]) as $valuePosition => $value) { + $optionValue = ProductOptionValue::withoutGlobalScopes()->create([ + 'product_option_id' => $option->getKey(), + 'value' => $value, + 'position' => $valuePosition, + ]); + + $optionValueIds[$optionName][$value] = $optionValue->getKey(); + } + } + + return $optionValueIds; + } + + /** + * @param array $data + * @param array> $optionValueIds + */ + private function createVariants(Store $store, Product $product, array $data, array $optionValueIds): void + { + $variants = $data['variants'] ?? $this->variantDefinitions($data); + + foreach ($variants as $position => $variantData) { + $variant = ProductVariant::withoutGlobalScopes()->create([ + 'product_id' => $product->getKey(), + 'sku' => $variantData['sku'] ?? $this->sku($data['sku_prefix'] ?? Str::upper(Str::slug($data['handle'], '-')), $variantData['options'] ?? []), + 'barcode' => $variantData['barcode'] ?? null, + 'price_amount' => $variantData['price_amount'], + 'compare_at_amount' => $variantData['compare_at_amount'] ?? null, + 'currency' => $store->default_currency, + 'weight_g' => $variantData['weight_g'] ?? 250, + 'requires_shipping' => $variantData['requires_shipping'] ?? true, + 'is_default' => $position === 0, + 'position' => $position, + 'status' => $variantData['status'] ?? 'active', + ]); + + InventoryItem::withoutGlobalScopes()->updateOrCreate( + ['variant_id' => $variant->getKey()], + [ + 'store_id' => $store->getKey(), + 'quantity_on_hand' => $variantData['quantity_on_hand'] ?? 0, + 'quantity_reserved' => 0, + 'policy' => $variantData['policy'] ?? 'deny', + ], + ); + + $selectedOptions = $variantData['options'] ?? []; + + if ($selectedOptions !== []) { + $variant->optionValues()->sync($this->selectedOptionValueIds($selectedOptions, $optionValueIds)); + } + } + } + + /** + * @param array $data + * @return array> + */ + private function variantDefinitions(array $data): array + { + $options = $data['options'] ?? []; + $defaults = $data['variant_defaults']; + + if ($options === []) { + return [[ + ...$defaults, + 'options' => [], + 'sku' => $data['sku'] ?? $this->sku($data['sku_prefix'] ?? Str::upper(Str::slug($data['handle'], '-')), []), + ]]; + } + + return collect($this->optionCombinations($options)) + ->map(fn (array $selection): array => [ + ...$defaults, + 'options' => $selection, + ]) + ->all(); + } + + /** + * @param array>> $options + * @return array> + */ + private function optionCombinations(array $options): array + { + $combinations = [[]]; + + foreach ($options as $valuesByName) { + $optionName = (string) array_key_first($valuesByName); + $next = []; + + foreach ($combinations as $combination) { + foreach ($valuesByName[$optionName] as $value) { + $next[] = [ + ...$combination, + $optionName => $value, + ]; + } + } + + $combinations = $next; + } + + return $combinations; + } + + /** + * @param array $selectedOptions + * @param array> $optionValueIds + * @return array + */ + private function selectedOptionValueIds(array $selectedOptions, array $optionValueIds): array + { + return collect($selectedOptions) + ->map(fn (string $value, string $optionName): int => $optionValueIds[$optionName][$value]) + ->values() + ->all(); + } + + /** + * @param array $selectedOptions + */ + private function sku(string $prefix, array $selectedOptions): string + { + $parts = collect($selectedOptions) + ->values() + ->map(fn (string $value): string => $this->skuToken($value)) + ->all(); + + return collect([$prefix, ...$parts]) + ->filter() + ->implode('-'); + } + + private function skuToken(string $value): string + { + return [ + 'White' => 'WHT', + 'Black' => 'BLK', + 'Navy' => 'NVY', + 'Blue' => 'BLU', + 'Brown' => 'BRN', + 'Olive' => 'OLV', + 'Sky Blue' => 'SKY', + 'Beige' => 'BGE', + 'Khaki' => 'KHK', + 'Sand' => 'SND', + 'Burgundy' => 'BUR', + 'Natural' => 'NAT', + 'Grey' => 'GRY', + 'Camel' => 'CAM', + 'Charcoal' => 'CHA', + 'Silver' => 'SLV', + 'Red' => 'RED', + '25 EUR' => '25', + '50 EUR' => '50', + '100 EUR' => '100', + '256GB' => '256', + '512GB' => '512', + '1TB' => '1TB', + 'S/M' => 'SM', + 'L/XL' => 'LXL', + ][$value] ?? Str::upper(Str::slug($value, '')); + } + + /** + * @return array> + */ + private function fashionProducts(): array + { + return [ + $this->product('Classic Cotton T-Shirt', 'classic-cotton-t-shirt', 'Acme Basics', 'T-Shirts', ['new', 'popular'], ['new-arrivals', 't-shirts'], '

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

', [['Size' => ['S', 'M', 'L', 'XL']], ['Color' => ['White', 'Black', 'Navy']]], ['price_amount' => 2499, 'weight_g' => 200, 'quantity_on_hand' => 15], 'ACME-CTSH'), + $this->product('Premium Slim Fit Jeans', 'premium-slim-fit-jeans', 'Acme Denim', 'Pants', ['new', 'sale'], ['new-arrivals', 'pants-jeans', 'sale'], '

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

', [['Size' => ['28', '30', '32', '34', '36']], ['Color' => ['Blue', 'Black']]], ['price_amount' => 7999, 'compare_at_amount' => 9999, 'weight_g' => 800, 'quantity_on_hand' => 8], 'ACME-JNS'), + $this->product('Organic Hoodie', 'organic-hoodie', 'Acme Basics', 'Hoodies', ['new', 'trending'], ['new-arrivals'], '

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

', [['Size' => ['S', 'M', 'L', 'XL']]], ['price_amount' => 5999, 'weight_g' => 500, 'quantity_on_hand' => 20], 'ACME-HOOD'), + $this->product('Leather Belt', 'leather-belt', 'Acme Accessories', 'Accessories', ['popular'], [], '

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

', [['Size' => ['S/M', 'L/XL']], ['Color' => ['Brown', 'Black']]], ['price_amount' => 3499, 'weight_g' => 150, 'quantity_on_hand' => 25], 'ACME-BELT'), + $this->product('Running Sneakers', 'running-sneakers', 'Acme Sport', 'Shoes', ['trending'], ['new-arrivals'], '

Lightweight running sneakers with responsive cushioning and breathable mesh upper.

', [['Size' => ['EU 38', 'EU 39', 'EU 40', 'EU 41', 'EU 42', 'EU 43', 'EU 44']], ['Color' => ['White', 'Black']]], ['price_amount' => 11999, 'weight_g' => 600, 'quantity_on_hand' => 5], 'ACME-RUN'), + $this->product('Graphic Print Tee', 'graphic-print-tee', 'Acme Basics', 'T-Shirts', ['new'], ['t-shirts'], '

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

', [['Size' => ['S', 'M', 'L', 'XL']]], ['price_amount' => 2999, 'weight_g' => 210, 'quantity_on_hand' => 18], 'ACME-GTEE'), + $this->product('V-Neck Linen Tee', 'v-neck-linen-tee', 'Acme Basics', 'T-Shirts', ['popular'], ['t-shirts'], '

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

', [['Size' => ['S', 'M', 'L']], ['Color' => ['Beige', 'Olive', 'Sky Blue']]], ['price_amount' => 3499, 'weight_g' => 180, 'quantity_on_hand' => 12], 'ACME-VNECK'), + $this->product('Striped Polo Shirt', 'striped-polo-shirt', 'Acme Basics', 'T-Shirts', ['sale'], ['t-shirts', 'sale'], '

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

', [['Size' => ['S', 'M', 'L', 'XL']]], ['price_amount' => 2799, 'compare_at_amount' => 3999, 'weight_g' => 250, 'quantity_on_hand' => 10], 'ACME-POLO'), + $this->product('Cargo Pants', 'cargo-pants', 'Acme Workwear', 'Pants', ['popular'], ['pants-jeans'], '

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

', [['Size' => ['30', '32', '34', '36']], ['Color' => ['Khaki', 'Olive', 'Black']]], ['price_amount' => 5499, 'weight_g' => 700, 'quantity_on_hand' => 14], 'ACME-CARGO'), + $this->product('Chino Shorts', 'chino-shorts', 'Acme Basics', 'Pants', ['new', 'trending'], ['pants-jeans', 'new-arrivals'], '

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

', [['Size' => ['30', '32', '34', '36']], ['Color' => ['Navy', 'Sand']]], ['price_amount' => 3999, 'weight_g' => 350, 'quantity_on_hand' => 16], 'ACME-CHINO'), + $this->product('Wide Leg Trousers', 'wide-leg-trousers', 'Acme Denim', 'Pants', ['sale'], ['pants-jeans', 'sale'], '

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

', [['Size' => ['S', 'M', 'L']]], ['price_amount' => 4999, 'compare_at_amount' => 6999, 'weight_g' => 550, 'quantity_on_hand' => 7], 'ACME-WIDE'), + $this->product('Wool Scarf', 'wool-scarf', 'Acme Accessories', 'Accessories', ['popular'], [], '

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

', [['Color' => ['Grey', 'Burgundy', 'Navy']]], ['price_amount' => 2999, 'weight_g' => 120, 'quantity_on_hand' => 30], 'ACME-SCARF'), + $this->product('Canvas Tote Bag', 'canvas-tote-bag', 'Acme Accessories', 'Accessories', ['trending'], [], '

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

', [['Color' => ['Natural', 'Black']]], ['price_amount' => 1999, 'weight_g' => 300, 'quantity_on_hand' => 40], 'ACME-TOTE'), + $this->product('Bucket Hat', 'bucket-hat', 'Acme Accessories', 'Accessories', ['new', 'trending'], ['new-arrivals'], '

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

', [['Size' => ['S/M', 'L/XL']], ['Color' => ['Beige', 'Black', 'Olive']]], ['price_amount' => 2499, 'weight_g' => 80, 'quantity_on_hand' => 22], 'ACME-HAT'), + $this->product('Unreleased Winter Jacket', 'unreleased-winter-jacket', 'Acme Outerwear', 'Jackets', ['limited'], [], '

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

', [['Size' => ['S', 'M', 'L', 'XL']]], ['price_amount' => 14999, 'weight_g' => 900, 'quantity_on_hand' => 0], 'ACME-WINTER', 'draft', null), + $this->product('Discontinued Raincoat', 'discontinued-raincoat', 'Acme Outerwear', 'Jackets', [], [], '

Lightweight waterproof raincoat. This product has been discontinued.

', [['Size' => ['M', 'L']]], ['price_amount' => 8999, 'weight_g' => 400, 'quantity_on_hand' => 3], 'ACME-RAIN', 'archived', now()->subMonths(6)), + $this->product('Limited Edition Sneakers', 'limited-edition-sneakers', 'Acme Sport', 'Shoes', ['limited'], [], '

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

', [['Size' => ['EU 40', 'EU 42', 'EU 44']]], ['price_amount' => 15999, 'weight_g' => 650, 'quantity_on_hand' => 0, 'policy' => 'deny'], 'ACME-LIMITED'), + $this->product('Backorder Denim Jacket', 'backorder-denim-jacket', 'Acme Denim', 'Jackets', ['popular'], [], '

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

', [['Size' => ['S', 'M', 'L', 'XL']]], ['price_amount' => 9999, 'weight_g' => 750, 'quantity_on_hand' => 0, 'policy' => 'continue'], 'ACME-BACKORDER'), + $this->product('Gift Card', 'gift-card', 'Acme Fashion', 'Gift Cards', ['popular'], [], '

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

', [['Amount' => ['25 EUR', '50 EUR', '100 EUR']]], ['price_amount' => 2500, 'weight_g' => 0, 'requires_shipping' => false, 'quantity_on_hand' => 9999], 'ACME-GIFT', variants: [ + ['options' => ['Amount' => '25 EUR'], 'sku' => 'ACME-GIFT-25', 'price_amount' => 2500, 'weight_g' => 0, 'requires_shipping' => false, 'quantity_on_hand' => 9999], + ['options' => ['Amount' => '50 EUR'], 'sku' => 'ACME-GIFT-50', 'price_amount' => 5000, 'weight_g' => 0, 'requires_shipping' => false, 'quantity_on_hand' => 9999], + ['options' => ['Amount' => '100 EUR'], 'sku' => 'ACME-GIFT-100', 'price_amount' => 10000, 'weight_g' => 0, 'requires_shipping' => false, 'quantity_on_hand' => 9999], + ]), + $this->product('Cashmere Overcoat', 'cashmere-overcoat', 'Acme Premium', 'Jackets', ['limited', 'new'], ['new-arrivals'], '

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

', [['Size' => ['S', 'M', 'L']], ['Color' => ['Camel', 'Charcoal']]], ['price_amount' => 49999, 'weight_g' => 1200, 'quantity_on_hand' => 3], 'ACME-COAT'), + ]; + } + + /** + * @return array> + */ + private function electronicsProducts(): array + { + return [ + $this->product('Pro Laptop 15', 'pro-laptop-15', 'TechCorp', 'Laptops', ['featured'], ['featured'], '

Performance laptop for tenant isolation tests.

', [['Storage' => ['256GB', '512GB', '1TB']]], ['price_amount' => 99999, 'weight_g' => 1800, 'quantity_on_hand' => 10], 'TECH-LAPTOP', variants: [ + ['options' => ['Storage' => '256GB'], 'sku' => 'TECH-LAPTOP-256', 'price_amount' => 99999, 'weight_g' => 1800, 'quantity_on_hand' => 10], + ['options' => ['Storage' => '512GB'], 'sku' => 'TECH-LAPTOP-512', 'price_amount' => 119999, 'weight_g' => 1800, 'quantity_on_hand' => 10], + ['options' => ['Storage' => '1TB'], 'sku' => 'TECH-LAPTOP-1TB', 'price_amount' => 149999, 'weight_g' => 1800, 'quantity_on_hand' => 10], + ]), + $this->product('Wireless Headphones', 'wireless-headphones', 'AudioMax', 'Audio', ['featured'], ['featured'], '

Wireless headphones for tenant isolation tests.

', [['Color' => ['Black', 'Silver']]], ['price_amount' => 14999, 'weight_g' => 250, 'quantity_on_hand' => 25], 'TECH-HEADPHONES'), + $this->product('USB-C Cable 2m', 'usb-c-cable-2m', 'CablePro', 'Cables', ['accessories'], ['accessories'], '

Two meter USB-C cable.

', [], ['price_amount' => 1299, 'weight_g' => 50, 'quantity_on_hand' => 200], 'TECH-CABLE'), + $this->product('Mechanical Keyboard', 'mechanical-keyboard', 'KeyTech', 'Peripherals', ['featured'], ['featured'], '

Mechanical keyboard with selectable switch types.

', [['Switch Type' => ['Red', 'Blue', 'Brown']]], ['price_amount' => 12999, 'weight_g' => 1100, 'quantity_on_hand' => 15], 'TECH-KEYBOARD'), + $this->product('Monitor Stand', 'monitor-stand', 'DeskGear', 'Accessories', ['accessories'], ['accessories'], '

Desktop monitor stand.

', [], ['price_amount' => 4999, 'weight_g' => 2500, 'quantity_on_hand' => 30], 'TECH-STAND'), + ]; + } + + /** + * @param array>> $options + * @param array $variantDefaults + * @param array>|null $variants + * @return array + */ + private function product( + string $title, + string $handle, + string $vendor, + string $productType, + array $tags, + array $collections, + string $descriptionHtml, + array $options, + array $variantDefaults, + string $skuPrefix, + string $status = 'active', + mixed $publishedAt = null, + ?array $variants = null, + ): array { + return [ + 'title' => $title, + 'handle' => $handle, + 'status' => $status, + 'vendor' => $vendor, + 'product_type' => $productType, + 'tags' => $tags, + 'collections' => $collections, + 'description_html' => $descriptionHtml, + 'published_at' => $status === 'draft' ? null : ($publishedAt ?? now()), + 'options' => $options, + 'variant_defaults' => [ + 'compare_at_amount' => null, + 'requires_shipping' => true, + 'policy' => 'deny', + ...$variantDefaults, + ], + 'sku_prefix' => $skuPrefix, + 'variants' => $variants, + ]; + } +} diff --git a/database/seeders/ProductVariantSeeder.php b/database/seeders/ProductVariantSeeder.php new file mode 100644 index 00000000..da732dd0 --- /dev/null +++ b/database/seeders/ProductVariantSeeder.php @@ -0,0 +1,16 @@ +refunds() as $storeHandle => $refunds) { + $store = Store::query()->where('handle', $storeHandle)->firstOrFail(); + $orders = Order::withoutGlobalScopes() + ->with('payments') + ->where('store_id', $store->getKey()) + ->whereIn('order_number', array_keys($refunds)) + ->get() + ->keyBy('order_number'); + + Refund::query() + ->whereIn('order_id', $orders->pluck('id')) + ->delete(); + + foreach ($refunds as $orderNumber => $refundData) { + $order = $orders->get($orderNumber); + + if (! $order instanceof Order) { + continue; + } + + $payment = $order->payments->first(); + + if ($payment === null) { + continue; + } + + Refund::query()->create([ + 'order_id' => $order->getKey(), + 'payment_id' => $payment->getKey(), + 'amount' => $refundData['amount'], + 'reason' => $refundData['reason'], + 'status' => RefundStatus::Processed, + 'provider_refund_id' => $refundData['provider_refund_id'], + ]); + } + } + }); + } + + /** + * @return array> + */ + private function refunds(): array + { + return [ + 'acme-fashion' => [ + '#1004' => [ + 'amount' => 2998, + 'reason' => 'Customer requested cancellation', + 'provider_refund_id' => 'mock_re_test_order1004', + ], + '#1008' => [ + 'amount' => 2999, + 'reason' => 'Item returned', + 'provider_refund_id' => 'mock_re_test_order1008', + ], + ], + ]; + } +} diff --git a/database/seeders/SearchSettingsSeeder.php b/database/seeders/SearchSettingsSeeder.php new file mode 100644 index 00000000..fcf347cc --- /dev/null +++ b/database/seeders/SearchSettingsSeeder.php @@ -0,0 +1,31 @@ +get() + ->each(function (Store $store): void { + SearchSettings::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->getKey()], + [ + 'synonyms_json' => [ + ['t-shirt', 'tee', 'tshirt'], + ['sneakers', 'trainers', 'kicks'], + ], + 'stop_words_json' => ['the', 'a', 'an', 'is', 'are'], + ], + ); + }); + } +} diff --git a/database/seeders/ShippingRateSeeder.php b/database/seeders/ShippingRateSeeder.php new file mode 100644 index 00000000..fd902e41 --- /dev/null +++ b/database/seeders/ShippingRateSeeder.php @@ -0,0 +1,59 @@ +get()->each(function (ShippingZone $zone): void { + foreach ($this->ratesForZone($zone) as $rate) { + ShippingRate::withoutGlobalScopes()->updateOrCreate( + [ + 'zone_id' => $zone->getKey(), + 'name' => $rate['name'], + ], + [ + 'type' => $rate['type'], + 'config_json' => $rate['config_json'], + 'is_active' => true, + ], + ); + } + }); + } + + /** + * @return array}> + */ + private function ratesForZone(ShippingZone $zone): array + { + return match ($zone->name) { + 'Domestic' => [ + ['name' => 'Standard Shipping', 'type' => 'flat', 'config_json' => ['amount' => 499]], + ['name' => 'Express Shipping', 'type' => 'flat', 'config_json' => ['amount' => 999]], + [ + 'name' => 'Free Shipping Over 75', + 'type' => 'price', + 'config_json' => [ + 'ranges' => [ + ['min_amount' => 0, 'max_amount' => 7499, 'amount' => 499], + ['min_amount' => 7500, 'amount' => 0], + ], + ], + ], + ], + 'International' => [ + ['name' => 'International Shipping', 'type' => 'flat', 'config_json' => ['amount' => 1499]], + ], + default => [], + }; + } +} diff --git a/database/seeders/ShippingZoneSeeder.php b/database/seeders/ShippingZoneSeeder.php new file mode 100644 index 00000000..c3b86987 --- /dev/null +++ b/database/seeders/ShippingZoneSeeder.php @@ -0,0 +1,44 @@ +get()->each(function (Store $store): void { + ShippingZone::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->delete(); + + ShippingZone::withoutGlobalScopes()->updateOrCreate( + [ + 'store_id' => $store->getKey(), + 'name' => 'Domestic', + ], + [ + 'countries_json' => ['DE'], + 'regions_json' => [], + ], + ); + + ShippingZone::withoutGlobalScopes()->updateOrCreate( + [ + 'store_id' => $store->getKey(), + 'name' => 'International', + ], + [ + 'countries_json' => ['AT', 'CH', 'US', 'GB', 'CA', 'AU'], + 'regions_json' => [], + ], + ); + }); + } +} diff --git a/database/seeders/StoreDomainSeeder.php b/database/seeders/StoreDomainSeeder.php new file mode 100644 index 00000000..32377ff3 --- /dev/null +++ b/database/seeders/StoreDomainSeeder.php @@ -0,0 +1,37 @@ + ['shop.test', 'acme-fashion.test'], + 'acme-electronics' => ['acme-electronics.test'], + ]; + + foreach ($domainsByStore as $storeHandle => $hostnames) { + $store = Store::query()->where('handle', $storeHandle)->firstOrFail(); + + foreach ($hostnames as $index => $hostname) { + StoreDomain::query()->updateOrCreate( + ['hostname' => $hostname], + [ + 'store_id' => $store->getKey(), + 'type' => 'storefront', + 'is_primary' => $index === 0, + 'tls_mode' => 'managed', + ], + ); + } + } + } +} diff --git a/database/seeders/StoreSeeder.php b/database/seeders/StoreSeeder.php new file mode 100644 index 00000000..236da0f6 --- /dev/null +++ b/database/seeders/StoreSeeder.php @@ -0,0 +1,35 @@ +where('billing_email', 'billing@acme.test')->firstOrFail(); + + foreach ([ + ['handle' => 'acme-fashion', 'name' => 'Acme Fashion'], + ['handle' => 'acme-electronics', 'name' => 'Acme Electronics'], + ] 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..a92ca63d --- /dev/null +++ b/database/seeders/StoreSettingsSeeder.php @@ -0,0 +1,53 @@ +each(function (Store $store): void { + StoreSettings::query()->updateOrCreate( + ['store_id' => $store->getKey()], + [ + 'settings_json' => [ + 'announcement' => [ + 'enabled' => true, + 'text' => 'Free shipping on orders over 75.00 EUR', + ], + 'checkout' => [ + 'guest_checkout_enabled' => true, + 'customer_accounts_required' => false, + 'phone_number_required' => false, + 'billing_address_enabled' => true, + 'order_notes_enabled' => true, + 'terms_required' => false, + 'terms_url' => '', + 'payment_hold_hours' => 24, + 'abandoned_checkout_days' => 14, + ], + 'bank_transfer_cancel_days' => 7, + 'notifications' => [ + 'sender_name' => $store->name, + 'sender_email' => 'no-reply@shop.test', + 'reply_to_email' => '', + 'order_confirmation_enabled' => true, + 'shipping_confirmation_enabled' => true, + 'refund_confirmation_enabled' => true, + 'admin_order_alerts_enabled' => true, + 'low_stock_alerts_enabled' => true, + 'low_stock_threshold' => 5, + ], + ], + ], + ); + }); + } +} diff --git a/database/seeders/StoreUserSeeder.php b/database/seeders/StoreUserSeeder.php new file mode 100644 index 00000000..4cc4b594 --- /dev/null +++ b/database/seeders/StoreUserSeeder.php @@ -0,0 +1,32 @@ +where('email', 'admin@acme.test')->firstOrFail(); + + Store::query()->each(function (Store $store) use ($user): void { + DB::table('store_users')->updateOrInsert( + [ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + ], + [ + 'role' => 'owner', + 'created_at' => now(), + ], + ); + }); + } +} diff --git a/database/seeders/TaxSettingsSeeder.php b/database/seeders/TaxSettingsSeeder.php new file mode 100644 index 00000000..03ff97e4 --- /dev/null +++ b/database/seeders/TaxSettingsSeeder.php @@ -0,0 +1,37 @@ +get()->each(function (Store $store): void { + TaxSettings::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->getKey()], + [ + 'mode' => 'manual', + 'provider' => 'none', + 'prices_include_tax' => true, + 'config_json' => [ + 'name' => 'VAT', + 'default_rate_bps' => 1900, + 'shipping_taxable' => true, + 'rates' => [ + ['country' => 'DE', 'rate_bps' => 1900, 'name' => 'VAT'], + ['country' => 'AT', 'rate_bps' => 2000, 'name' => 'VAT'], + ['country' => 'CH', 'rate_bps' => 770, 'name' => 'VAT'], + ], + ], + ], + ); + }); + } +} diff --git a/database/seeders/ThemeFileSeeder.php b/database/seeders/ThemeFileSeeder.php new file mode 100644 index 00000000..a352a2be --- /dev/null +++ b/database/seeders/ThemeFileSeeder.php @@ -0,0 +1,52 @@ +get()->each(function (Theme $theme): void { + foreach ([ + 'layouts/storefront.blade.php', + 'sections/hero.blade.php', + 'sections/featured-products.blade.php', + ] as $path) { + $contents = $this->contents($theme, $path); + $storageKey = "themes/{$theme->getKey()}/{$path}"; + + Storage::disk('local')->put($storageKey, $contents); + + ThemeFile::withoutGlobalScopes()->updateOrCreate( + [ + 'theme_id' => $theme->getKey(), + 'path' => $path, + ], + [ + 'storage_key' => $storageKey, + 'sha256' => hash('sha256', $contents), + 'byte_size' => strlen($contents), + ], + ); + } + }); + } + + private function contents(Theme $theme, string $path): string + { + return match ($path) { + 'layouts/storefront.blade.php' => "name}'\">\n {{ \$slot }}\n\n", + 'sections/hero.blade.php' => "
\n

{{ data_get(\$settings, 'home.hero.heading') }}

\n
\n", + 'sections/featured-products.blade.php' => "
\n @foreach(\$products as \$product)\n
{{ \$product->title }}
\n @endforeach\n
\n", + default => '', + }; + } +} diff --git a/database/seeders/ThemeSeeder.php b/database/seeders/ThemeSeeder.php new file mode 100644 index 00000000..2cdd4123 --- /dev/null +++ b/database/seeders/ThemeSeeder.php @@ -0,0 +1,30 @@ +orderBy('id')->get()->each(function (Store $store): void { + Theme::withoutGlobalScopes()->updateOrCreate( + [ + 'store_id' => $store->getKey(), + 'name' => "{$store->name} Default", + ], + [ + 'version' => '1.0.0', + 'status' => 'published', + 'published_at' => now(), + ], + ); + }); + } +} diff --git a/database/seeders/ThemeSettingsSeeder.php b/database/seeders/ThemeSettingsSeeder.php new file mode 100644 index 00000000..ff2f47fe --- /dev/null +++ b/database/seeders/ThemeSettingsSeeder.php @@ -0,0 +1,30 @@ +with('store') + ->get() + ->each(function (Theme $theme): void { + ThemeSettings::withoutGlobalScopes()->updateOrCreate( + ['theme_id' => $theme->getKey()], + [ + 'settings_json' => app(ThemeSettingsService::class)->defaultsForStore($theme->store), + 'updated_at' => now(), + ], + ); + }); + } +} diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php new file mode 100644 index 00000000..d88906ad --- /dev/null +++ b/database/seeders/UserSeeder.php @@ -0,0 +1,27 @@ +updateOrCreate( + ['email' => 'admin@acme.test'], + [ + 'name' => 'Acme Admin', + 'password' => 'password', + 'status' => 'active', + 'is_platform_admin' => true, + 'email_verified_at' => now(), + 'last_login_at' => now()->subDay(), + ], + ); + } +} 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/public/favicon.svg b/public/favicon.svg index e4e710e0..4f764da8 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1,3 +1,5 @@ - - + + + + diff --git a/resources/views/components/app-logo.blade.php b/resources/views/components/app-logo.blade.php index 26e8f686..d4319fd9 100644 --- a/resources/views/components/app-logo.blade.php +++ b/resources/views/components/app-logo.blade.php @@ -3,13 +3,13 @@ ]) @if($sidebar) - + @else - + diff --git a/resources/views/components/desktop-user-menu.blade.php b/resources/views/components/desktop-user-menu.blade.php index 5b386c5c..03618c19 100644 --- a/resources/views/components/desktop-user-menu.blade.php +++ b/resources/views/components/desktop-user-menu.blade.php @@ -1,3 +1,5 @@ +@php($logoutRoute = request()->routeIs('admin.*') ? route('admin.logout') : route('logout')) + only('name') }} @@ -22,7 +24,7 @@ {{ __('Settings') }} -
+
@csrf +
+ diff --git a/resources/views/components/storefront/breadcrumbs.blade.php b/resources/views/components/storefront/breadcrumbs.blade.php new file mode 100644 index 00000000..f1528304 --- /dev/null +++ b/resources/views/components/storefront/breadcrumbs.blade.php @@ -0,0 +1,15 @@ +@props(['items' => []]) + + diff --git a/resources/views/components/storefront/price.blade.php b/resources/views/components/storefront/price.blade.php new file mode 100644 index 00000000..3b1e8f89 --- /dev/null +++ b/resources/views/components/storefront/price.blade.php @@ -0,0 +1,13 @@ +@props([ + 'amount', + 'currency' => 'EUR', + 'compareAt' => null, +]) + +class('inline-flex flex-wrap items-baseline gap-2') }}> + {{ \App\Support\Money::format((int) $amount, $currency) }} + + @if ($compareAt && $compareAt > $amount) + {{ \App\Support\Money::format((int) $compareAt, $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..93b7164b --- /dev/null +++ b/resources/views/components/storefront/product-card.blade.php @@ -0,0 +1,40 @@ +@props(['product']) + +@php + $variant = $product->variants->first(); + $isSoldOut = $product->variants->isNotEmpty() + && $product->variants->every(fn ($variant) => $variant->inventoryItem?->policy?->value === 'deny' && $variant->inventoryItem?->availableQuantity() <= 0); + $isOnSale = $variant?->compare_at_amount && $variant->compare_at_amount > $variant->price_amount; +@endphp + + diff --git a/resources/views/layouts/app/sidebar.blade.php b/resources/views/layouts/app/sidebar.blade.php index ea25506b..fd4010a5 100644 --- a/resources/views/layouts/app/sidebar.blade.php +++ b/resources/views/layouts/app/sidebar.blade.php @@ -4,17 +4,83 @@ @include('partials.head') + @php($logoutRoute = request()->routeIs('admin.*') ? route('admin.logout') : route('logout')) + - + - + {{ __('Dashboard') }} + + + {{ __('Analytics') }} + + + + {{ __('Apps') }} + + + + {{ __('Developers') }} + + + + + + {{ __('Products') }} + + + + {{ __('Collections') }} + + + + {{ __('Inventory') }} + + + + + + {{ __('Orders') }} + + + + {{ __('Customers') }} + + + + {{ __('Discounts') }} + + + + + + {{ __('Pages') }} + + + + {{ __('Navigation') }} + + + + {{ __('Themes') }} + + + + + + {{ __('Settings') }} + + + + {{ __('Search') }} + @@ -72,7 +138,7 @@ -
+ @csrf + + + @include('partials.head') + + + @php($store = app()->bound('current_store') ? app('current_store') : null) + @php($announcement = data_get($themeSettings ?? [], 'announcement', [])) + @php($mainLinks = ($mainNavigation ?? []) !== [] ? $mainNavigation : [['label' => 'Collections', 'url' => route('collections.index'), 'external' => false], ['label' => 'Search', 'url' => route('search.index'), 'external' => false]]) + @php($footerLinks = ($footerNavigation ?? []) !== [] ? $footerNavigation : [['label' => 'Collections', 'url' => route('collections.index'), 'external' => false], ['label' => 'Search', 'url' => route('search.index'), 'external' => false]]) + + + Skip to main content + + + @if (data_get($announcement, 'enabled', false)) +
+ @if (data_get($announcement, 'url')) + + {{ data_get($announcement, 'text') }} + + @else + {{ data_get($announcement, 'text') }} + @endif +
+ @endif + +
data_get($themeSettings ?? [], 'header.sticky', true), + ])> +
+ + {{ $store?->name ?? config('app.name') }} + + + + +
+ + + + + + + + +
+
+
+ + +
+
+

{{ $store?->name ?? config('app.name') }}

+
+ + + + +
+
+ + + +
+ {{ $slot }} +
+ +
+
+
+

{{ $store?->name ?? config('app.name') }}

+

{{ data_get($themeSettings ?? [], 'footer.tagline') }}

+
+ +
+

Shop

+
+ @foreach ($footerLinks as $item) + + {{ $item['label'] }} + + @foreach (($item['children'] ?? []) as $child) + + {{ $child['label'] }} + + @endforeach + @endforeach +
+
+ +
+

Customer

+
+ Account + About +
+
+
+
+ + @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..cc5d594e --- /dev/null +++ b/resources/views/livewire/admin/analytics/index.blade.php @@ -0,0 +1,170 @@ +
+
+
+ Analytics + Sales, traffic, and conversion signals for the selected store. +
+ +
+ + Today + Last 7 days + Last 30 days + Custom + + + + + + + All channels + Storefront + API + + + + All devices + Desktop + Mobile + Tablet + +
+
+ +
+ + Export CSV + Exporting... + + + @if ($exportUrl) + + Download + + @endif +
+ +
+ @foreach ([ + ['label' => 'Revenue', 'value' => $this->formattedTotalSales, 'icon' => 'banknotes'], + ['label' => 'Orders', 'value' => number_format($ordersCount), 'icon' => 'shopping-bag'], + ['label' => 'Average order', 'value' => $this->formattedAov, 'icon' => 'receipt-percent'], + ['label' => 'Conversion', 'value' => number_format($conversionRate, 2).'%', 'icon' => 'chart-bar'], + ] as $metric) +
+
+
+
{{ $metric['label'] }}
+
{{ $metric['value'] }}
+
+ +
+ +
+
+
+ @endforeach +
+ +
+
+
+ Sales over time + Daily revenue and order volume +
+
+ +
+ @foreach ($salesChartData as $point) + @php + $height = $point['revenue'] === 0 ? 2 : max(8, (int) round(($point['revenue'] / $maxSalesChartAmount) * 100)); + @endphp + +
+
+ @if ($loop->first || $loop->last || $loop->iteration % 7 === 0) + + @endif +
+ @endforeach +
+
+ +
+ Conversion funnel + Traffic progression through checkout + +
+ @foreach ([ + ['label' => 'Visits', 'value' => $visitsCount], + ['label' => 'Add to cart', 'value' => $addToCartCount], + ['label' => 'Checkout started', 'value' => $checkoutStartedCount], + ['label' => 'Checkout completed', 'value' => $checkoutCompletedCount], + ] as $step) +
+
{{ $step['label'] }}
+
{{ number_format($step['value']) }}
+
+ @endforeach +
+
+ +
+
+ Top products + +
+ + + + + + + + + + + + @forelse ($topProducts as $product) + + + + + + + + @empty + + + + @endforelse + +
RankProductUnitsRevenueShare
{{ $loop->iteration }}{{ $product['title'] }}{{ number_format($product['units_sold']) }}{{ \App\Support\Money::format($product['revenue'], $storeCurrency) }}{{ number_format($product['percentage'], 1) }}%
No product sales in this range.
+
+
+ +
+ Top referrers + +
+ @forelse ($topReferrers as $referrer) +
+
+
{{ $referrer['source'] }}
+ {{ number_format($referrer['conversion_rate'], 2) }}% +
+
+
{{ number_format($referrer['sessions']) }} sessions
+
{{ number_format($referrer['orders']) }} orders
+
+
+ @empty +
+ + No referrer activity in this range. +
+ @endforelse +
+
+
+
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..fd069231 --- /dev/null +++ b/resources/views/livewire/admin/apps/index.blade.php @@ -0,0 +1,64 @@ +
+
+
+ Apps + Installed integrations and their store permissions. +
+
+ + @if (session('status')) + + {{ session('status') }} + + @endif + +
+ @forelse ($installedApps as $installation) + + @empty +
+ + No apps installed + Installed integrations will appear here. +
+ @endforelse +
+
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..61522d98 --- /dev/null +++ b/resources/views/livewire/admin/apps/show.blade.php @@ -0,0 +1,99 @@ +
+
+
+ {{ $installation->app->name }} + Installed {{ $installation->installed_at?->toDayDateTimeString() ?? 'recently' }} +
+ +
+ {{ $installation->status->label() }} + + Uninstall + +
+
+ + @if (session('status')) + + {{ session('status') }} + + @endif + +
+
+
+ Webhook subscriptions + +
+ + + + + + + + + + + @forelse ($installation->webhookSubscriptions as $webhook) + + + + + + + @empty + + + + @endforelse + +
EventURLStatusDeliveries
{{ $webhook->event_type->value }}{{ $webhook->target_url }} + {{ $webhook->status->label() }} + {{ $webhook->deliveries->count() }}
No webhooks registered for this app.
+
+
+ +
+ API usage + +
+ + + + + + + + + + @forelse ($installation->oauthTokens as $token) + + + + + + @empty + + + + @endforelse + +
TokenLast usedExpires
{{ $token->name ?? 'API token' }}{{ $token->last_used_at?->diffForHumans() ?? 'Never' }}{{ $token->expires_at->toFormattedDateString() }}
No tokens issued.
+
+
+
+ + +
+
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..5e82f002 --- /dev/null +++ b/resources/views/livewire/admin/auth/login.blade.php @@ -0,0 +1,31 @@ +
+ + + + + + + + + + + {{ __('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..048b1b6f --- /dev/null +++ b/resources/views/livewire/admin/collections/form.blade.php @@ -0,0 +1,95 @@ +
+
+ + Home + Collections + {{ $isEditing ? $title : 'Add collection' }} + + + {{ $isEditing ? $title : 'Add collection' }} +
+ + @if ($actionMessage !== '' || session('status')) + {{ $actionMessage !== '' ? $actionMessage : session('status') }} + @endif + +
+
+
+
+ + + + + + + +
+
+ +
+ Products + +
+ + + @if ($searchResults->isNotEmpty()) +
+ @foreach ($searchResults as $product) +
+
+
{{ $product->title }}
+
/{{ $product->handle }}
+
+ Add +
+ @endforeach +
+ @endif + +
+ Assigned products + + @forelse ($assignedProducts as $product) +
+
+
+ +
+
+
{{ $product->title }}
+
/{{ $product->handle }}
+
+
+ + Remove +
+ @empty + No products assigned. + @endforelse +
+
+
+
+ + + +
+
+ Discard + + Save + Saving... + +
+
+
+
diff --git a/resources/views/livewire/admin/collections/index.blade.php b/resources/views/livewire/admin/collections/index.blade.php new file mode 100644 index 00000000..22e1e895 --- /dev/null +++ b/resources/views/livewire/admin/collections/index.blade.php @@ -0,0 +1,80 @@ +
+
+
+ Collections + Group products for storefront browsing and merchandising. +
+ + + Create collection + +
+ +
+ + + + All statuses + Active + Draft + Archived + +
+ +
+
+ + + + + + + + + + + + @forelse ($collections as $collection) + @php + $statusColor = match ($collection->status->value) { + 'active' => 'green', + 'archived' => 'red', + default => 'zinc', + }; + @endphp + + + + + + + + + @empty + + + + @endforelse + +
TitleProductsStatusUpdated
+ + {{ $collection->title }} + +
/{{ $collection->handle }}
+
{{ $collection->products_count }}{{ Str::title($collection->status->value) }}{{ $collection->updated_at?->diffForHumans() }} + Delete +
+
+
+ +
+ No collections found + Create a collection to organize your products. + Create collection +
+
+
+
+ + {{ $collections->links() }} +
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..0db2b2e1 --- /dev/null +++ b/resources/views/livewire/admin/customers/index.blade.php @@ -0,0 +1,57 @@ +
+
+ Customers + Search customer accounts and review order value. +
+ +
+ +
+ +
+
+ + + + + + + + + + + + @forelse ($customers as $customer) + + + + + + + + @empty + + + + @endforelse + +
NameEmailOrdersTotal spentCreated
+ + {{ $customer->name ?: 'Unnamed customer' }} + + {{ $customer->email }}{{ $customer->orders_count }} + + {{ $customer->created_at?->format('M j, Y') }}
+
+
+ +
+ No customers found + Customer accounts will appear here after registration or checkout. +
+
+
+
+ + {{ $customers->links() }} +
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..d91f4087 --- /dev/null +++ b/resources/views/livewire/admin/customers/show.blade.php @@ -0,0 +1,184 @@ +
+
+
+ {{ $customer->name ?: 'Unnamed customer' }} + {{ $customer->email }} +
+ + + Customers + +
+ +
+
+
+ Customer info + +
+
+
Name
+
{{ $customer->name ?: 'Unnamed customer' }}
+
+
+
Email
+
{{ $customer->email }}
+
+
+
Created
+
{{ $customer->created_at?->format('M j, Y') }}
+
+
+
Marketing
+
+ + {{ $customer->marketing_opt_in ? 'Opted in' : 'Not opted in' }} + +
+
+
+
+ +
+
+ Order history +
+ +
+ + + + + + + + + + + @forelse ($orders as $order) + @php + $statusColor = match ($order->financial_status->value) { + 'paid' => 'green', + 'partially_refunded' => 'amber', + 'refunded', 'voided' => 'red', + default => 'zinc', + }; + @endphp + + + + + + + + @empty + + + + @endforelse + +
OrderDateStatusTotal
+ + {{ $order->order_number }} + + {{ $order->placed_at?->format('M j, Y') }} + {{ Str::headline($order->financial_status->value) }} + + +
No orders for this customer.
+
+
+ + {{ $orders->links() }} +
+ + +
+ + +
+
+ {{ $editingAddressId ? 'Edit address' : 'Add address' }} +
+ + + +
+ + +
+ + +
+
+ +
+ + + + + Germany + United States + United Kingdom + France + Netherlands + +
+ + + + +
+ + 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..7e44c892 --- /dev/null +++ b/resources/views/livewire/admin/dashboard.blade.php @@ -0,0 +1,142 @@ +
+
+
+ Dashboard + Sales, orders, and product movement for the selected store. +
+ +
+ + Today + Last 7 days + Last 30 days + Custom + + + + +
+
+ +
+ @foreach ([ + ['label' => 'Total sales', 'value' => $this->formattedTotalSales, 'change' => $salesChange, 'icon' => 'banknotes'], + ['label' => 'Orders', 'value' => number_format($ordersCount), 'change' => $ordersChange, 'icon' => 'shopping-bag'], + ['label' => 'Average order', 'value' => $this->formattedAov, 'change' => $aovChange, 'icon' => 'receipt-percent'], + ['label' => 'Visitors', 'value' => number_format($visitorsCount), 'change' => $visitorsChange, 'icon' => 'users'], + ] as $metric) + @php + $change = (float) $metric['change']; + $changeColor = $change > 0 ? 'text-emerald-600 dark:text-emerald-400' : ($change < 0 ? 'text-rose-600 dark:text-rose-400' : 'text-zinc-500 dark:text-zinc-400'); + @endphp + +
+
+
+
{{ $metric['label'] }}
+
{{ $metric['value'] }}
+
+ +
+ +
+
+ +
+ + {{ $change > 0 ? '+' : '' }}{{ number_format($change, 1) }}% +
+
+ @endforeach +
+ +
+
+
+
+ Orders over time + Daily order count +
+
+ +
+ @foreach ($ordersChartData as $point) + @php + $height = $point['count'] === 0 ? 2 : max(8, (int) round(($point['count'] / $maxOrdersChartCount) * 100)); + @endphp + +
+
+ @if ($loop->first || $loop->last || $loop->iteration % 7 === 0) + + @endif +
+ @endforeach +
+
+ +
+ Top products + Ranked by order-line revenue + +
+ @forelse ($topProducts as $product) +
+
+
{{ $product['title'] }}
+
{{ number_format($product['units_sold']) }} sold
+
+ +
+ {{ \App\Support\Money::format($product['revenue'], $storeCurrency) }} +
+
+ @empty +
+ + No product sales in this range. +
+ @endforelse +
+
+
+ +
+
+
+ Conversion funnel + Current range activity +
+
+ + @php + $maxFunnelValue = max(1, max($funnelData)); + $funnelSteps = [ + 'visits' => 'Visits', + 'add_to_cart' => 'Carts', + 'checkout_started' => 'Checkouts', + 'checkout_completed' => 'Orders', + ]; + @endphp + +
+ @foreach ($funnelSteps as $key => $label) + @php + $value = (int) $funnelData[$key]; + $width = $value === 0 ? 2 : max(8, (int) round(($value / $maxFunnelValue) * 100)); + @endphp + +
+
+
{{ $label }}
+
{{ number_format($value) }}
+
+ +
+
+
+
+ @endforeach +
+
+
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..f9af99d6 --- /dev/null +++ b/resources/views/livewire/admin/developers/index.blade.php @@ -0,0 +1,159 @@ +
+
+
+ Developers + API access and outbound webhook subscriptions. +
+
+ + @if (session('status')) + + {{ session('status') }} + + @endif + + @if ($generatedToken) + +
+
Copy this token now. It will not be shown again.
+ {{ $generatedToken }} +
+
+ @endif + +
+
+
+ API tokens + Personal access tokens for store integrations. +
+ + + Generate new token + +
+ +
+ + + + + + + + + + + + @forelse ($tokens as $token) + + + + + + + + @empty + + + + @endforelse + +
NameTypeLast usedCreatedActions
{{ $token->name ?? 'API token' }}Admin API{{ $token->last_used_at?->diffForHumans() ?? 'Never' }}{{ $token->created_at?->toFormattedDateString() ?? 'Unknown' }} + + Revoke + +
No API tokens created.
+
+
+ + + +
+
+
+ Webhooks + Send real-time event notifications to external endpoints. +
+ + + Add webhook + +
+ +
+ + + + + + + + + + + @forelse ($webhooks as $webhook) + + + + + + + @empty + + + + @endforelse + +
Event typeURLStatusActions
{{ $webhook->event_type->value }}{{ $webhook->target_url }} + {{ $webhook->status->label() }} + +
+ Edit + Delete +
+
No webhook subscriptions created.
+
+
+ + +
+ Generate API token + + + + +
+ + Cancel + + Generate +
+ +
+ + +
+ {{ $editingWebhookId ? 'Edit webhook' : 'Add webhook' }} + + + @foreach ($eventTypes as $eventType) + + {{ $eventType->value }} + + @endforeach + + + + + + +
+ + Cancel + + Save +
+ +
+
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..acb4ea7f --- /dev/null +++ b/resources/views/livewire/admin/discounts/form.blade.php @@ -0,0 +1,158 @@ +
+
+
+ {{ $isEditing ? 'Edit discount' : 'Create discount' }} + Configure the promotion type, value, eligibility, limits, and dates. +
+ + + Discounts + +
+ + @if (session('status')) + {{ session('status') }} + @endif + +
+
+ + + + + +
+ + @if ($type === 'code') +
+ Code +
+
+ + +
+
+ Generate +
+
+
+ @endif + +
+ Value + +
+ + + + + + + @if ($valueType !== 'free_shipping') +
+ + +
+ @endif +
+
+ +
+ Conditions + +
+
+ + +
+ +
+
+ + + @if ($productResults->isNotEmpty()) +
+ @foreach ($productResults as $product) + + @endforeach +
+ @endif + +
+ @foreach ($selectedProducts as $product) +
+ {{ $product->title }} + +
+ @endforeach +
+
+ +
+ + + @if ($collectionResults->isNotEmpty()) +
+ @foreach ($collectionResults as $collection) + + @endforeach +
+ @endif + +
+ @foreach ($selectedCollections as $collection) +
+ {{ $collection->title }} + +
+ @endforeach +
+
+
+
+
+ +
+ Usage limits + +
+ +
+ +
+
+ +
+ +
+ Active dates + +
+ + +
+
+ + +
+
+ +
+ +
+ +
+
+ Discard + + Save + Saving... + +
+
+
+
diff --git a/resources/views/livewire/admin/discounts/index.blade.php b/resources/views/livewire/admin/discounts/index.blade.php new file mode 100644 index 00000000..dfad0876 --- /dev/null +++ b/resources/views/livewire/admin/discounts/index.blade.php @@ -0,0 +1,95 @@ +
+
+
+ Discounts + Manage discount codes, automatic promotions, and eligibility rules. +
+ + @can('create', App\Models\Discount::class) + + Create discount + + @endcan +
+ +
+ + + + All statuses + Active + Scheduled + Expired + + + + All types + Code + Automatic + +
+ +
+
+ + + + + + + + + + + + + @forelse ($discounts as $discount) + @php + $status = $this->effectiveStatus($discount); + $statusColor = $this->statusColor($status); + $typeColor = $discount->type->value === 'automatic' ? 'sky' : 'zinc'; + @endphp + + + + + + + + + + @empty + + + + @endforelse + +
CodeTypeValueUsageStatusDates
+ + {{ $discount->code ?: 'Automatic' }} + + + {{ Str::headline($discount->type->value) }} + {{ $this->valueLabel($discount) }}{{ $discount->usage_count }} / {{ $discount->usage_limit ?? 'unlimited' }} + {{ Str::headline($status) }} + + {{ $discount->starts_at?->format('M j, Y') }} + - + {{ $discount->ends_at?->format('M j, Y') ?? 'No end' }} +
+
+
+ +
+ No discounts found + Discounts will appear here after creation. + @can('create', App\Models\Discount::class) + Create discount + @endcan +
+
+
+
+ + {{ $discounts->links() }} +
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..29cf981e --- /dev/null +++ b/resources/views/livewire/admin/inventory/index.blade.php @@ -0,0 +1,57 @@ +
+
+ Inventory + Review stock levels by product variant. +
+ +
+ + + + All stock + Low stock + Out of stock + +
+ +
+
+ + + + + + + + + + + + + @forelse ($items as $item) + + + + + + + + + @empty + + + + @endforelse + +
ProductSKUOn handReservedAvailablePolicy
+ + {{ $item->variant->product->title }} + + {{ $item->variant->sku ?: '-' }}{{ $item->quantity_on_hand }}{{ $item->quantity_reserved }}{{ $item->availableQuantity() }} + {{ Str::title($item->policy->value) }} +
No inventory items found.
+
+
+ + {{ $items->links() }} +
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..ec1bbed4 --- /dev/null +++ b/resources/views/livewire/admin/navigation/index.blade.php @@ -0,0 +1,141 @@ +
+
+ Navigation + Storefront menus and ordered links. +
+ + @if (session('status')) + {{ session('status') }} + @endif + +
+
+ @foreach ($menus as $menu) + + @endforeach +
+ +
+
+
+ {{ $selectedMenu->title }} + {{ count($menuItems) }} items +
+ + Add item +
+ +
+ @forelse ($navigationTree as $item) +
+
+
+
+ + + + + +
+ +
+
{{ $item['label'] }}
+
{{ $this->targetLabel($item) }}
+
+
+ +
+ Edit + Remove +
+
+ + @if ($item['children'] !== []) +
+ @foreach ($item['children'] as $child) +
+
+
+ + + + + +
+ +
+
{{ $child['label'] }}
+
{{ $this->targetLabel($child) }}
+
+
+ +
+ Edit + Remove +
+
+ @endforeach +
+ @endif +
+ @empty +
No menu items.
+ @endforelse +
+ +
+ Save menu +
+
+ +
+ {{ $editingItemIndex === null ? 'Add item' : 'Edit item' }} + +
+ + + + + Top level + @foreach ($parentOptions as $parent) + {{ $parent['label'] }} + @endforeach + + + + + Custom link + Page + Collection + Product + + + @if ($itemType === 'link') + + + @else + + Select resource + @foreach ($resources as $resource) + {{ $resource->title }} + @endforeach + + + @endif + +
+ Cancel + {{ $editingItemIndex === null ? 'Add item' : 'Update 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..25a9ca9d --- /dev/null +++ b/resources/views/livewire/admin/orders/index.blade.php @@ -0,0 +1,107 @@ +
+
+ Orders + Review orders, payment status, and fulfillment progress. +
+ +
+ + + + All orders + @foreach ($statuses as $status) + {{ Str::title($status->value) }} + @endforeach + + + + All financial + @foreach ($financialStatuses as $status) + {{ Str::headline($status->value) }} + @endforeach + + + + All fulfillment + @foreach ($fulfillmentStatuses as $status) + {{ Str::headline($status->value) }} + @endforeach + + + + +
+ +
+
+ + + + + + + + + + + + + + @forelse ($orders as $order) + @php + $financialColor = match ($order->financial_status->value) { + 'paid' => 'green', + 'partially_refunded' => 'amber', + 'refunded', 'voided' => 'red', + default => 'zinc', + }; + $fulfillmentColor = match ($order->fulfillment_status->value) { + 'fulfilled' => 'green', + 'partial' => 'amber', + default => 'zinc', + }; + @endphp + + + + + + + + + + + @empty + + + + @endforelse + +
OrderCustomerStatusFulfillmentItemsTotalPlaced
+ + {{ $order->order_number }} + +
{{ Str::headline($order->status->value) }}
+
+
{{ $order->customer?->name ?: 'Guest' }}
+
{{ $order->email }}
+
+ {{ Str::headline($order->financial_status->value) }} + + {{ Str::headline($order->fulfillment_status->value) }} + {{ $order->lines_count }} + + {{ $order->placed_at?->format('M j, Y H:i') }}
+
+
+ +
+ No orders found + Orders will appear here after checkout completion. +
+
+
+
+ + {{ $orders->links() }} +
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..53d8918c --- /dev/null +++ b/resources/views/livewire/admin/orders/show.blade.php @@ -0,0 +1,335 @@ +
+
+
+ + Orders + + +
+ {{ $order->order_number }} + {{ $order->email }} · {{ $order->placed_at?->format('M j, Y H:i') }} +
+
+ +
+ @if ($order->payment_method === \App\Enums\PaymentMethod::BankTransfer && $order->financial_status === \App\Enums\FinancialStatus::Pending) + + Confirm payment + + @endif + + @if (in_array($order->financial_status, [\App\Enums\FinancialStatus::Paid, \App\Enums\FinancialStatus::PartiallyRefunded], true) && $refundableAmount > 0) + + + Refund + + + @endif + + @if (in_array($order->financial_status, [\App\Enums\FinancialStatus::Paid, \App\Enums\FinancialStatus::PartiallyRefunded], true) && collect($remainingFulfillmentQuantities)->sum() > 0) + + + Create fulfillment + + + @endif +
+
+ + @if ($actionMessage !== '') + {{ $actionMessage }} + @endif + + @error('orderAction') +
+ {{ $message }} +
+ @enderror + + @error('fulfillment') +
+ {{ $message }} +
+ @enderror + + @if (! in_array($order->financial_status, [\App\Enums\FinancialStatus::Paid, \App\Enums\FinancialStatus::PartiallyRefunded], true) && collect($remainingFulfillmentQuantities)->sum() > 0) + + Cannot create fulfillment. Payment must be confirmed before items can be fulfilled. Current financial status: {{ Str::headline($order->financial_status->value) }}. + + @endif + +
+
+
Order
+
+ {{ Str::headline($order->status->value) }} +
+
+
+
Payment
+
+ + {{ Str::headline($order->financial_status->value) }} + +
+
+
+
Fulfillment
+
+ + {{ Str::headline($order->fulfillment_status->value) }} + +
+
+
+
Total
+
+ {{ \App\Support\Money::format($order->total_amount, $order->currency) }} +
+
+
+ +
+
+
+
+ Line items +
+
+ @foreach ($order->lines as $line) +
+
+
{{ $line->title_snapshot }}
+
{{ $line->sku_snapshot ?: 'No SKU' }}
+
+
Qty {{ $line->quantity }}
+
{{ \App\Support\Money::format($line->total_amount, $order->currency) }}
+
+ @endforeach +
+
+ +
+
+ Payments +
+
+ @foreach ($order->payments as $payment) +
+
+
{{ Str::headline($payment->method->value) }}
+
{{ $payment->provider_payment_id }}
+
+
+ {{ Str::headline($payment->status->value) }} + {{ \App\Support\Money::format($payment->amount, $payment->currency) }} +
+
+ @endforeach +
+
+ +
+
+ Timeline +
+
+
+
+
+
Order placed
+
{{ $order->placed_at?->format('M j, Y H:i') }}
+
+
+ + @foreach ($order->payments as $payment) +
+
+
+
+ {{ $payment->status === \App\Enums\PaymentStatus::Captured ? 'Payment received' : Str::headline($payment->status->value) }} +
+
{{ Str::headline($payment->method->value) }} · {{ \App\Support\Money::format($payment->amount, $payment->currency) }}
+
+
+ @endforeach + + @foreach ($order->fulfillments as $fulfillment) +
+
+
+
Fulfillment created
+
{{ Str::headline($fulfillment->status->value) }}
+
+
+ @endforeach + + @foreach ($order->refunds as $refund) +
+
+
+
Refunded
+
{{ \App\Support\Money::format($refund->amount, $order->currency) }} · {{ Str::headline($refund->status->value) }}
+
+
+ @endforeach +
+
+ +
+
+ Fulfillments +
+
+ @forelse ($order->fulfillments as $fulfillment) +
+
+
+ {{ Str::headline($fulfillment->status->value) }} + @if ($fulfillment->tracking_number) +
{{ $fulfillment->tracking_company }} {{ $fulfillment->tracking_number }}
+ @endif +
+
+ @if ($fulfillment->status === \App\Enums\FulfillmentShipmentStatus::Pending) + Mark as shipped + @endif + @if ($fulfillment->status === \App\Enums\FulfillmentShipmentStatus::Shipped) + Mark as delivered + @endif +
+
+
+ @foreach ($fulfillment->lines as $line) +
+ {{ $line->orderLine?->title_snapshot }} · Qty {{ $line->quantity }} +
+ @endforeach +
+
+ @empty +
No fulfillments yet.
+ @endforelse +
+
+
+ + +
+ + +
+
+ Process refund + Refundable amount: {{ \App\Support\Money::format($refundableAmount, $order->currency) }} +
+ + + + + +
+ + Cancel + + Process refund +
+ +
+ + +
+
+ Create fulfillment + Select the remaining quantities included in this shipment. +
+ +
+ @foreach ($order->lines as $line) + @php($remaining = $remainingFulfillmentQuantities[$line->getKey()] ?? 0) + + @endforeach +
+ + +
+ + +
+ +
+
+ + +
+ + Cancel + + Create fulfillment +
+ +
+
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..2bfacc15 --- /dev/null +++ b/resources/views/livewire/admin/pages/form.blade.php @@ -0,0 +1,64 @@ +
+
+
+ {{ $isEditing ? 'Edit page' : 'Create page' }} + Title, handle, publication state, and HTML body. +
+ +
+ @if ($isEditing) + Delete + @endif + Pages +
+
+ + @if ($actionMessage !== '' || session('status')) + {{ $actionMessage !== '' ? $actionMessage : session('status') }} + @endif + +
+
+
+
+ + + + + + + + +
+
+
+ +
+
+ Publishing + +
+ + Draft + Published + Archived + + + + + +
+
+
+ +
+
+ Discard + + Save + Saving... + +
+
+
+
diff --git a/resources/views/livewire/admin/pages/index.blade.php b/resources/views/livewire/admin/pages/index.blade.php new file mode 100644 index 00000000..bf23d1e5 --- /dev/null +++ b/resources/views/livewire/admin/pages/index.blade.php @@ -0,0 +1,53 @@ +
+
+
+ Pages + Static storefront content pages. +
+ + @can('create', App\Models\Page::class) + + Create page + + @endcan +
+ + + +
+
+ + + + + + + + + + + @forelse ($pages as $page) + + + + + + + @empty + + + + @endforelse + +
TitleHandleStatusUpdated
+ + {{ $page->title }} + + /{{ $page->handle }} + {{ Str::headline($page->status->value) }} + {{ $page->updated_at?->format('M j, Y') }}
No pages found.
+
+
+ + {{ $pages->links() }} +
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..b3fa0065 --- /dev/null +++ b/resources/views/livewire/admin/products/form.blade.php @@ -0,0 +1,221 @@ +
+
+
+ + Home + Products + {{ $isEditing ? $title : 'Add product' }} + + + {{ $isEditing ? $title : 'Add product' }} +
+ + @if ($isEditing) + Archive + @endif +
+ + @if (session('status')) + {{ session('status') }} + @endif + +
+
+
+
+ + + + + +
+
+ +
+
+ Media + {{ count($media) }} {{ Str::plural('item', count($media)) }} +
+ +
+
+ + + @if ($isEditing) + + Upload + + @endif +
+ + + + +
+
+
+
+ +
+ @forelse ($media as $index => $item) +
+
+ @if ($item['exists']) + {{ $item['altText'] }} + @else +
+ +
+ @endif +
+ +
+
+ + {{ Str::headline($item['status']) }} + + +
+ + + +
+
+ +
+ + + Save alt text +
+
+
+ @empty +
+ No media for this product. +
+ @endforelse +
+
+ +
+
+ Variants + {{ count($variants) }} {{ Str::plural('variant', count($variants)) }} +
+ +
+ + + + + + + + + + + + + @foreach ($variants as $index => $variant) + + + + + + + + + @endforeach + +
VariantSKUPriceCompareQtyShip
{{ $variant['label'] }} + + + + + + + + + +
+
+
+ +
+
+ Options +
+ Generate variants + Add option +
+
+ +
+ @forelse ($options as $index => $option) +
+ + +
+ Remove +
+
+ @empty + No options for this product. + @endforelse +
+
+
+ + + +
+
+ Discard + + Save + Saving... + +
+
+
+
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..b102f7af --- /dev/null +++ b/resources/views/livewire/admin/products/index.blade.php @@ -0,0 +1,118 @@ +
+
+
+ Products + Manage catalog items, variants, status, and inventory. +
+ + + Add product + +
+ +
+ + + + All statuses + Active + Draft + Archived + + + + All types + @foreach ($productTypes as $type) + {{ $type }} + @endforeach + +
+ + @if (count($selectedIds) > 0) +
+ {{ count($selectedIds) }} products selected + +
+ Set active + Archive + Delete +
+
+ @endif + +
+
+ + + + + + + + + + + + + + @forelse ($products as $product) + @php + $inventory = $product->variants->sum(fn ($variant) => $variant->inventoryItem?->quantity_on_hand ?? 0); + $statusColor = match ($product->status->value) { + 'active' => 'green', + 'archived' => 'red', + default => 'zinc', + }; + @endphp + + + + + + + + + + + @empty + + + + @endforelse + +
+ + ProductStatusInventoryTypeVendor + +
+ + +
+
+ +
+ +
+ + {{ $product->title }} + +
{{ $product->variants_count }} variants
+
+
+
+ {{ Str::title($product->status->value) }} + {{ $inventory }}{{ $product->product_type ?: '-' }}{{ $product->vendor ?: '-' }}{{ $product->updated_at?->diffForHumans() }}
+
+
+ +
+ No products found + Adjust your filters or add a product. + Add product +
+
+
+
+ + {{ $products->links() }} +
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..56e3ad25 --- /dev/null +++ b/resources/views/livewire/admin/search/settings.blade.php @@ -0,0 +1,74 @@ +
+
+
+ Search settings + Tune storefront search behavior and rebuild the local SQLite index. +
+ + + Reindex now + +
+ + @if (session('status')) + + {{ session('status') }} + + @endif + +
+
+
+ Synonyms + Group equivalent search terms with comma-separated words. +
+ +
+ @foreach ($synonymGroups as $index => $group) +
+ + +
+ + @endforeach +
+ + + Add synonym group + +
+ + + +
+
+ Stop words + Separate excluded words with commas. +
+ + + +
+ + + +
+
+
+ Search index + Last indexed: {{ $lastIndexedAt ?? 'Never' }} +
+ + @if ($reindexProgress !== null) + {{ $reindexProgress }}% complete + @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..b074ed57 --- /dev/null +++ b/resources/views/livewire/admin/settings/checkout.blade.php @@ -0,0 +1,76 @@ +
+
+
+ Checkout + Customer account, payment hold, and checkout policy settings. +
+ +
+ General + Shipping + Taxes + Checkout + Notifications +
+
+ + @if (session('status')) + {{ session('status') }} + @endif + +
+
+
+
+ Accounts + Sign-in and contact requirements. +
+ +
+ + + + +
+
+ + + +
+
+ Checkout form + Customer-facing fields and terms. +
+ +
+ + + + +
+
+ + + +
+
+ Timing + Reservation and cleanup windows. +
+ +
+ + + +
+
+ +
+ + Save checkout + 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..c3f17e99 --- /dev/null +++ b/resources/views/livewire/admin/settings/index.blade.php @@ -0,0 +1,147 @@ +
+
+
+ Store Settings + Store defaults, checkout preferences, and domains. +
+ +
+ General + Shipping + Taxes + Checkout + Notifications +
+
+ + @if (session('status')) + {{ session('status') }} + @endif + +
+
+
+
+ Store details + Basic storefront identity. +
+ +
+ + + +
+
+ + + +
+
+ Defaults + Currency, language, and timezone. +
+ +
+ + @foreach ($currencyOptions as $currency) + {{ $currency }} + @endforeach + + + + @foreach ($localeOptions as $locale => $label) + {{ $label }} + @endforeach + + + + @foreach ($timezoneOptions as $timezoneOption) + {{ $timezoneOption }} + @endforeach + +
+
+ + + +
+
+ Checkout + Customer-facing storefront defaults. +
+ +
+ + + +
+
+ +
+ + Save settings + Saving... + +
+
+
+ +
+
+
+ Domains + Hostnames connected to this store. +
+ +
+ + + Storefront + Admin + API + +
+ Add +
+
+
+ +
+ + + + + + + + + + + + @foreach ($domains as $domain) + + + + + + + + @endforeach + +
HostnameTypePrimaryTLSActions
{{ $domain->hostname }}{{ Str::headline($domain->type->value) }} + @if ($domain->is_primary) + Primary + @else + - + @endif + {{ Str::headline($domain->tls_mode) }} +
+ @unless ($domain->is_primary) + Set primary + @endunless + Delete +
+
+
+
+
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..10f7a4dc --- /dev/null +++ b/resources/views/livewire/admin/settings/notifications.blade.php @@ -0,0 +1,74 @@ +
+
+
+ Notifications + Sender identity, customer emails, and admin alerts. +
+ +
+ General + Shipping + Taxes + Checkout + Notifications +
+
+ + @if (session('status')) + {{ session('status') }} + @endif + +
+
+
+
+ Sender + Outbound email identity. +
+ +
+ + + +
+
+ + + +
+
+ Customer emails + Transactional storefront messages. +
+ +
+ + + +
+
+ + + +
+
+ Admin alerts + Operational alert preferences. +
+ +
+ + + +
+
+ +
+ + Save notifications + 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..30f3d725 --- /dev/null +++ b/resources/views/livewire/admin/settings/shipping.blade.php @@ -0,0 +1,165 @@ +
+
+
+ Shipping + Zones, rates, and address matching. +
+ +
+ General + Shipping + Taxes + Checkout + Notifications +
+
+ + @if (session('status')) + {{ session('status') }} + @endif + +
+
+ @foreach ($zones as $zone) +
+
+
+ {{ $zone->name }} + {{ implode(', ', $zone->countries_json ?? []) }} +
+ +
+ Edit + Delete +
+
+ +
+ + + + + + + + + + + + @forelse ($zone->rates as $rate) + + + + + + + + @empty + + + + @endforelse + +
NameTypeConfigActiveActions
{{ $rate->name }}{{ Str::headline($rate->type->value) }}{{ $this->rateSummary($rate) }} + + +
+ Edit + Delete +
+
No rates configured.
+
+ +
+ Add rate +
+
+ @endforeach +
+ +
+
+ {{ $editingZoneId ? 'Edit zone' : 'Add zone' }} + +
+ + + + + + +
+ Cancel + Save zone +
+
+
+ + @if ($rateZoneId) +
+ {{ $editingRateId ? 'Edit rate' : 'Add rate' }} + +
+ + + Flat + Weight + Price + Carrier + + + + @if ($rateType === 'weight') +
+ + +
+ @endif + + @if ($rateType === 'price') +
+ + +
+ @endif + + + +
+ Cancel + Save rate +
+
+
+ @endif + +
+ Test address + +
+ + +
+ +
+ Test +
+ + @if ($testResult) +
+ @if ($testResult['zone']) +
Matched zone: {{ $testResult['zone'] }}
+
    + @foreach ($testResult['rates'] as $rate) +
  • {{ $rate }}
  • + @endforeach +
+ @else +
No shipping zone matches this address.
+ @endif +
+ @endif +
+
+
+
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..3f89924d --- /dev/null +++ b/resources/views/livewire/admin/settings/taxes.blade.php @@ -0,0 +1,73 @@ +
+
+
+ Tax Settings + Manual tax rates and provider mode. +
+ +
+ General + Shipping + Taxes + Checkout + Notifications +
+
+ + @if (session('status')) + {{ session('status') }} + @endif + +
+
+ + + + + +
+ + @if ($mode === 'manual') +
+
+ Manual rates + Add rate +
+ +
+ @foreach ($manualRates as $index => $rate) +
+ + + + +
+ @endforeach +
+
+ @else +
+ Provider + +
+ + None + Stripe Tax + + +
+
+ @endif + +
+ +
+ +
+ + Save taxes + 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..87795899 --- /dev/null +++ b/resources/views/livewire/admin/themes/editor.blade.php @@ -0,0 +1,104 @@ +
+
+ Themes + +
+ Save + Save and publish +
+
+ + @if (session('status')) + {{ session('status') }} + @endif + +
+
+ Sections + +
+ @foreach ($sections as $key => $section) + + @endforeach +
+ + + + Files + +
+ @foreach ($themeFiles as $file) + + @endforeach +
+
+ +
+
+
+ Preview + {{ $theme->name }} +
+ Refresh +
+ + +
+ +
+
+ {{ $selectedFile ? $selectedFile->path : $activeSection['label'] }} + + @if ($selectedFile) + {{ \Illuminate\Support\Number::fileSize($selectedFile->byte_size) }} + @endif +
+ +
+ @if ($selectedFile) + + + @else + @foreach ($activeSection['fields'] as $field) + @php($model = 'settings.'.str_replace('.', '.', $field['key'])) + + @if ($field['type'] === 'checkbox') + + @elseif ($field['type'] === 'textarea') + + @elseif ($field['type'] === 'number') + + @else + + @endif + @endforeach + @endif +
+ +
+ + {{ $selectedFile ? 'Save file' : 'Save settings' }} + Saving... + +
+
+
+
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..f43d602f --- /dev/null +++ b/resources/views/livewire/admin/themes/index.blade.php @@ -0,0 +1,45 @@ +
+
+ Themes + Published and draft storefront themes. +
+ + @if (session('status')) + {{ session('status') }} + @endif + + + +
+ @foreach ($themes as $theme) +
+
+
+ +
+
+ +
+
+
+ {{ $theme->name }} + v{{ $theme->version }} · {{ $theme->files_count }} files +
+ {{ Str::headline($theme->status->value) }} +
+ +
+ Customize + @unless ($theme->isPublished()) + Publish + @endunless + Duplicate + @unless ($theme->isPublished()) + Delete + @endunless +
+
+
+ @endforeach +
+
diff --git a/resources/views/livewire/auth/forgot-password.blade.php b/resources/views/livewire/auth/forgot-password.blade.php index 4af48477..6e6abf75 100644 --- a/resources/views/livewire/auth/forgot-password.blade.php +++ b/resources/views/livewire/auth/forgot-password.blade.php @@ -5,7 +5,7 @@ -
+ @csrf @@ -25,7 +25,7 @@
{{ __('Or, return to') }} - {{ __('log in') }} + {{ __('log in') }}
diff --git a/resources/views/livewire/auth/reset-password.blade.php b/resources/views/livewire/auth/reset-password.blade.php index 1b6bd538..29f73a3a 100644 --- a/resources/views/livewire/auth/reset-password.blade.php +++ b/resources/views/livewire/auth/reset-password.blade.php @@ -5,7 +5,7 @@ - + @csrf 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..8f08a3fa --- /dev/null +++ b/resources/views/livewire/storefront/account/addresses/index.blade.php @@ -0,0 +1,115 @@ + + + +
+
+
+

Addresses

+

Saved shipping details for faster checkout.

+
+ + Add address +
+ + @if ($statusMessage) + + {{ $statusMessage }} + + @endif + + @if ($showForm) +
+ +
+ {{ $editingAddressId ? 'Edit address' : 'Add address' }} +
+ + + +
+ + + +
+ + +
+ +
+ +
+ + + + + + + Germany + Austria + Switzerland + United States + United Kingdom + +
+ +
+ + + + + +
+ +
+ Cancel + Save +
+ +
+ @endif + +
+ @forelse ($addresses as $addressRecord) + @php($addressData = $addressRecord->address_json ?? []) + +
+
+
+
+

{{ $addressRecord->label ?: 'Address' }}

+ @if ($addressRecord->is_default) + Default + @endif +
+
+

{{ trim(data_get($addressData, 'first_name').' '.data_get($addressData, 'last_name')) }}

+

{{ data_get($addressData, 'address1') }}

+ @if (data_get($addressData, 'address2')) +

{{ data_get($addressData, 'address2') }}

+ @endif +

{{ trim(data_get($addressData, 'postal_code').' '.data_get($addressData, 'city')) }}

+

{{ data_get($addressData, 'country') }}

+
+
+
+ +
+ Edit + @unless ($addressRecord->is_default) + Set default + @endunless + Delete +
+
+ @empty +
+ +

No addresses saved.

+
+ @endforelse +
+
+
diff --git a/resources/views/livewire/storefront/account/auth/forgot-password.blade.php b/resources/views/livewire/storefront/account/auth/forgot-password.blade.php new file mode 100644 index 00000000..5e236af0 --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/forgot-password.blade.php @@ -0,0 +1,29 @@ +
+ + + @if (session('status')) + + {{ session('status') }} + + @endif + +
+ + + + {{ __('Send reset link') }} + + + +
+ {{ __('Back to log in') }} +
+
diff --git a/resources/views/livewire/storefront/account/auth/login.blade.php b/resources/views/livewire/storefront/account/auth/login.blade.php new file mode 100644 index 00000000..9476aec1 --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/login.blade.php @@ -0,0 +1,40 @@ +
+ + +
+ + + + +
+ {{ __('Forgot your password?') }} +
+ + + + + {{ __('Log in') }} + + + +
+ {{ __('New customer?') }} + {{ __('Create an account') }} +
+
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..f3afb658 --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/register.blade.php @@ -0,0 +1,52 @@ +
+ + +
+ + + + + + + + + + + + {{ __('Create account') }} + + + +
+ {{ __('Already have an account?') }} + {{ __('Log in') }} +
+
diff --git a/resources/views/livewire/storefront/account/auth/reset-password.blade.php b/resources/views/livewire/storefront/account/auth/reset-password.blade.php new file mode 100644 index 00000000..ef1e5156 --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/reset-password.blade.php @@ -0,0 +1,41 @@ +
+ + +
+ + + + + + + + {{ __('Reset password') }} + + + +
+ {{ __('Back to log in') }} +
+
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..723237b0 --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/index.blade.php @@ -0,0 +1,71 @@ + + + +
+ @if ($isDashboard) +
+

Welcome back, {{ Str::before($customer->name ?: $customer->email, ' ') }}!

+

Review orders and manage saved checkout details.

+
+ +
+ + +

Order history

+

View all your orders.

+
+ + + +

Addresses

+

Manage your addresses.

+
+ +
+ @csrf + +
+
+ @else +
+

Order History

+

All orders placed from this account.

+
+ @endif + +
+
+

{{ $isDashboard ? 'Recent Orders' : 'Orders' }}

+
+ + @forelse ($orders as $order) + +
+

{{ $order->order_number }}

+

{{ $order->placed_at?->format('M j, Y') }}

+
+
+ {{ Str::headline($order->financial_status->value) }} + {{ Str::headline($order->fulfillment_status->value) }} +
+ +
+ @empty +
+ +

No orders yet

+ + Browse products + +
+ @endforelse +
+
+
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..ed58dbae --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/show.blade.php @@ -0,0 +1,106 @@ + + + +
+
+
+

Order {{ $order->order_number }}

+

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

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

{{ $line->title_snapshot }}

+

Qty {{ $line->quantity }}

+
+ +
+ @endforeach +
+
+ +
+
+ Shipping Address + @php($shippingAddress = $order->shipping_address_json ?? []) +
+

{{ trim(data_get($shippingAddress, 'first_name').' '.data_get($shippingAddress, 'last_name')) }}

+

{{ data_get($shippingAddress, 'address1') }}

+ @if (data_get($shippingAddress, 'address2')) +

{{ data_get($shippingAddress, 'address2') }}

+ @endif +

{{ trim(data_get($shippingAddress, 'postal_code').' '.data_get($shippingAddress, 'city')) }}

+

{{ data_get($shippingAddress, 'country') }}

+
+
+ +
+ Payment +
+

{{ Str::headline($order->payment_method->value) }}

+
+ {{ Str::headline($order->financial_status->value) }} + {{ Str::headline($order->fulfillment_status->value) }} +
+
+
+
+ + @if ($order->fulfillments->isNotEmpty()) +
+ Fulfillment +
+ @foreach ($order->fulfillments as $fulfillment) +
+
+ {{ Str::headline($fulfillment->status->value) }} + @if ($fulfillment->tracking_number) + {{ $fulfillment->tracking_number }} + @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..b1168497 --- /dev/null +++ b/resources/views/livewire/storefront/cart-drawer.blade.php @@ -0,0 +1,70 @@ + +
+
+
+ Cart + {{ $lineCount }} {{ Str::plural('item', $lineCount) }} +
+ + + View cart + +
+ + @if ($lines->isEmpty()) +
+ +

Your cart is empty.

+ + Browse products + +
+ @else +
+ @foreach ($lines as $line) +
+ + + + +
+
+
+ + {{ $line->variant->product->title }} + +

+ {{ $line->variant->optionValues->map(fn ($value) => $value->option->name.': '.$value->value)->implode(' / ') ?: ($line->variant->sku ?: 'Default') }} +

+
+ + +
+ +
+
+ + {{ $line->quantity }} + +
+ + +
+
+
+ @endforeach +
+ +
+
+ Subtotal + +
+ + + Checkout + +
+ @endif +
+
diff --git a/resources/views/livewire/storefront/cart/show.blade.php b/resources/views/livewire/storefront/cart/show.blade.php new file mode 100644 index 00000000..7478f668 --- /dev/null +++ b/resources/views/livewire/storefront/cart/show.blade.php @@ -0,0 +1,172 @@ +
+ + +
+
+
+
+

Your Cart

+

{{ $lineCount }} {{ Str::plural('item', $lineCount) }}

+
+ + + Continue shopping + +
+ + @if ($cartMessage) + {{ $cartMessage }} + @endif + + @if ($lines->isEmpty()) +
+ +

Your cart is empty

+ + Browse products + +
+ @else +
+ @foreach ($lines as $line) +
+ + + + +
+ + {{ $line->variant->product->title }} + +

+ {{ $line->variant->optionValues->map(fn ($value) => $value->option->name.': '.$value->value)->implode(' / ') ?: ($line->variant->sku ?: 'Default') }} +

+ +
+ +
+
+ + {{ $line->quantity }} + +
+ +
+ + +
+
+
+ @endforeach +
+ @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..51a9e3e0 --- /dev/null +++ b/resources/views/livewire/storefront/checkout/confirmation.blade.php @@ -0,0 +1,106 @@ +
+ @php + $paymentMethodLabel = match ($order->payment_method) { + \App\Enums\PaymentMethod::CreditCard => 'Credit Card', + \App\Enums\PaymentMethod::Paypal => 'PayPal', + \App\Enums\PaymentMethod::BankTransfer => 'Bank Transfer', + }; + @endphp + + + +
+
+
+ Order placed +

+ Thank you for your order! +

+

+ Order {{ $order->order_number }} has been placed. Confirmation sent to {{ $order->email }}. +

+
+ + @if ($order->payment_method === \App\Enums\PaymentMethod::BankTransfer) +
+ Bank transfer +
+
+
Bank
+
Mock Bank AG
+
+
+
BIC
+
COBADEFFXXX
+
+
+
IBAN
+
DE89 3704 0044 0532 0130 00
+
+
+
Reference
+
{{ $order->order_number }}
+
+
+
Amount
+
{{ \App\Support\Money::format($order->total_amount, $order->currency) }}
+
+
+
+ @endif + +
+ Items +
+ @foreach ($order->lines as $line) +
+
+

{{ $line->title_snapshot }}

+

Qty {{ $line->quantity }}

+
+ +
+ @endforeach +
+
+
+ + +
+
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..a0469fa6 --- /dev/null +++ b/resources/views/livewire/storefront/checkout/show.blade.php @@ -0,0 +1,265 @@ +
+ + +
+
+
+

Checkout

+
+ @foreach (['address' => 'Address', 'shipping' => 'Shipping', 'payment' => 'Payment'] as $key => $label) + @php($active = $step === $key || ($key === 'payment' && $step === 'reserved')) +
$active, + 'border-zinc-200 text-zinc-600 dark:border-zinc-800 dark:text-zinc-400' => ! $active, + ])> + {{ $label }} +
+ @endforeach +
+
+ + @if (! $cart || $lines->isEmpty()) +
+ +

Your cart is empty

+ + Browse products + +
+ @else +
+
+
+ Contact and address + Delivery details +
+ + @if ($checkout?->status !== \App\Enums\CheckoutStatus::Started) + Saved + @endif +
+ +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+
+ + +
+ + +
+ + Germany + Austria + Switzerland + United States + + +
+
+ + + +
+ + Continue + +
+ + +
+
+
+ Shipping + {{ $requiresShipping ? 'Available rates' : 'Digital delivery' }} +
+ + @if ($checkout?->status === \App\Enums\CheckoutStatus::ShippingSelected || $checkout?->status === \App\Enums\CheckoutStatus::PaymentSelected) + Selected + @endif +
+ + @if ($step === 'address') +

Pending address

+ @elseif (! $requiresShipping) +
+ Digital delivery + 0.00 +
+ @else +
+ @forelse ($rates as $rate) + @php($selected = (int) $selectedShippingRateId === (int) $rate->getKey()) + + @empty +

No rates are available for this address.

+ @endforelse +
+ + + @endif + + @if ($step !== 'address') +
+ + Continue + +
+ @endif +
+ +
+
+
+ Payment + Method +
+ + @if ($checkout?->status === \App\Enums\CheckoutStatus::PaymentSelected) + Reserved + @endif +
+ + @if ($step === 'address' || $step === 'shipping') +

Pending shipping

+ @else +
+
+ + + Apply + +
+ + + + Credit Card + PayPal + Bank Transfer + + + + @if ($paymentMethod === 'credit_card') +
+
+ + +
+ + + +
+ @endif + + @if ($paymentMethod === 'paypal') +

Your PayPal payment will be processed securely.

+ @endif + + @if ($paymentMethod === 'bank_transfer') +

After placing your order, bank transfer instructions will be shown on the confirmation page.

+ @endif + + + @if ($paymentMethod === 'paypal') + Pay with PayPal + @elseif ($paymentMethod === 'credit_card') + Pay now + @else + Place order + @endif + +
+ @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..5552c3a8 --- /dev/null +++ b/resources/views/livewire/storefront/collections/index.blade.php @@ -0,0 +1,20 @@ +
+ + +
+

Collections

+

Browse curated groups of products from this store.

+
+ +
+ @foreach ($collections as $collection) + +
+ +
+

{{ $collection->title }}

+

{{ $collection->products_count }} products

+
+ @endforeach +
+
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..651d4047 --- /dev/null +++ b/resources/views/livewire/storefront/collections/show.blade.php @@ -0,0 +1,84 @@ +
+ + +
+

{{ $collection->title }}

+ @if ($collection->description_html) +
{!! $collection->description_html !!}
+ @endif +
+ +
+ + +
+
+

{{ $products->total() }} products

+ + + Featured + Price: Low to High + Price: High to Low + Newest + +
+ + @if ($products->isEmpty()) +
+
+ +

No products found

+

Try adjusting your filters or browse another collection.

+ Clear filters +
+
+ @else +
+ @foreach ($products as $product) + + @endforeach +
+ + {{ $products->links() }} + @endif +
+
+
diff --git a/resources/views/livewire/storefront/home.blade.php b/resources/views/livewire/storefront/home.blade.php new file mode 100644 index 00000000..7ffb6103 --- /dev/null +++ b/resources/views/livewire/storefront/home.blade.php @@ -0,0 +1,67 @@ +
+ @php($hero = data_get($themeSettings, 'home.hero', [])) + +
+
+
+
+

{{ data_get($hero, 'eyebrow') }}

+

{{ data_get($hero, 'heading', $store->name) }}

+

{{ data_get($hero, 'subheading') }}

+
+ +
+ {{ data_get($hero, 'primary_label', 'Shop') }} + {{ data_get($hero, 'secondary_label', 'View collections') }} +
+
+ +
+

Hero products

+ + @foreach ($featuredProducts->take(4) as $product) +
+ +
+ @endforeach +
+
+
+ +
+
+
+

Featured collections

+

Curated paths through the current store catalog.

+
+ All collections +
+ +
+ @foreach ($featuredCollections as $collection) + +
+ +
+

{{ $collection->title }}

+

{{ $collection->products_count }} products

+
+ @endforeach +
+
+ +
+
+
+

Featured products

+

Active products currently published for {{ $store->name }}.

+
+
+ +
+ @foreach ($featuredProducts as $product) + + @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..69d7bc0a --- /dev/null +++ b/resources/views/livewire/storefront/pages/show.blade.php @@ -0,0 +1,11 @@ +
+ + +
+

{{ $title }}

+ +
+ {!! $bodyHtml !!} +
+
+
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..1ea420b0 --- /dev/null +++ b/resources/views/livewire/storefront/products/show.blade.php @@ -0,0 +1,102 @@ +
+ + +
+
+ +
+ +
+
+
+ @if ($selectedVariant?->compare_at_amount && $selectedVariant->compare_at_amount > $selectedVariant->price_amount) + Sale + @endif + @foreach ($product->tags ?? [] as $tag) + {{ $tag }} + @endforeach +
+ +

{{ $product->title }}

+ + @if ($selectedVariant) + + @endif +
+ + @if ($product->options->isNotEmpty()) +
+ @foreach ($product->options as $option) +
+ {{ $option->name }} + +
+ @foreach ($option->values as $value) + @php($selected = ($selectedOptions[$option->name] ?? null) === $value->value) + + @endforeach +
+
+ @endforeach +
+ @endif + +
+

{{ $stockState['message'] }}

+ +
+ + + +
+ + + {{ $this->canAddToCart() ? 'Add to cart' : 'Sold out' }} + +
+ + @if ($product->description_html) +
+ {!! $product->description_html !!} +
+ @endif + +
+
+
Vendor
+
{{ $product->vendor ?: '-' }}
+
+
+
Product type
+
{{ $product->product_type ?: '-' }}
+
+ @if ($selectedVariant) +
+
SKU
+
{{ $selectedVariant->sku ?: '-' }}
+
+
+
Shipping
+
{{ $selectedVariant->requires_shipping ? 'Required' : 'Digital delivery' }}
+
+ @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..14688bef --- /dev/null +++ b/resources/views/livewire/storefront/search/index.blade.php @@ -0,0 +1,86 @@ +
+ + +
+

+ @if (trim($q) !== '') + {{ $products->total() }} results for "{{ $q }}" + @else + Search products + @endif +

+ + +
+ +
+ + +
+
+

{{ $products->total() }} products

+ + + Relevance + Price: Low to High + Price: High to Low + Newest + +
+ + @if ($products->isEmpty()) +
+
+ +

No products found

+

Try another search term or adjust your filters.

+ Clear filters +
+
+ @else +
+ @foreach ($products as $product) + + @endforeach +
+ + {{ $products->links() }} + @endif +
+
+
diff --git a/resources/views/partials/head.blade.php b/resources/views/partials/head.blade.php index dce80588..0de1ff9e 100644 --- a/resources/views/partials/head.blade.php +++ b/resources/views/partials/head.blade.php @@ -3,9 +3,7 @@ {{ $title ?? config('app.name') }} - - diff --git a/resources/views/storefront/account/dashboard.blade.php b/resources/views/storefront/account/dashboard.blade.php new file mode 100644 index 00000000..baffa3b8 --- /dev/null +++ b/resources/views/storefront/account/dashboard.blade.php @@ -0,0 +1,5 @@ + +
+ +
+
diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 00000000..170dad05 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,180 @@ +prefix('storefront/v1') + ->name('api.storefront.v1.') + ->group(function (): void { + Route::middleware('throttle:api.storefront')->group(function (): void { + Route::post('carts', [CartController::class, 'store'])->name('carts.store'); + Route::get('carts/{cart}', [CartController::class, 'show'])->name('carts.show'); + Route::post('carts/{cart}/lines', [CartLineController::class, 'store'])->name('carts.lines.store'); + Route::put('carts/{cart}/lines/{cartLine}', [CartLineController::class, 'update'])->name('carts.lines.update'); + Route::delete('carts/{cart}/lines/{cartLine}', [CartLineController::class, 'destroy'])->name('carts.lines.destroy'); + }); + + Route::middleware('throttle:search')->group(function (): void { + Route::get('search', [StorefrontSearchController::class, 'index'])->name('search.index'); + Route::get('search/suggest', [StorefrontSearchController::class, 'suggest'])->name('search.suggest'); + }); + + Route::middleware('throttle:analytics')->group(function (): void { + Route::post('analytics/events', [StorefrontAnalyticsEventController::class, 'store'])->name('analytics.events.store'); + }); + + Route::middleware('throttle:checkout')->group(function (): void { + Route::post('checkouts', [CheckoutController::class, 'store'])->name('checkouts.store'); + Route::get('checkouts/{checkout}', [CheckoutController::class, 'show'])->name('checkouts.show'); + Route::put('checkouts/{checkout}/address', [CheckoutController::class, 'address'])->name('checkouts.address'); + Route::put('checkouts/{checkout}/shipping-method', [CheckoutController::class, 'shippingMethod'])->name('checkouts.shipping-method'); + Route::post('checkouts/{checkout}/apply-discount', [CheckoutController::class, 'applyDiscount'])->name('checkouts.apply-discount'); + Route::delete('checkouts/{checkout}/discount', [CheckoutController::class, 'destroyDiscount'])->name('checkouts.discount.destroy'); + Route::put('checkouts/{checkout}/payment-method', [CheckoutController::class, 'paymentMethod'])->name('checkouts.payment-method'); + Route::post('checkouts/{checkout}/pay', [CheckoutController::class, 'pay'])->name('checkouts.pay'); + Route::get('orders/{orderNumber}', [StorefrontOrderController::class, 'show'])->name('orders.show'); + }); + }); + +Route::prefix('admin/v1/platform') + ->name('api.admin.v1.platform.') + ->middleware(['auth:sanctum', 'throttle:api.admin', 'platform.api']) + ->group(function (): void { + Route::post('organizations', [AdminPlatformOrganizationController::class, 'store'])->name('organizations.store'); + Route::post('stores', [AdminPlatformStoreController::class, 'store'])->name('stores.store'); + }); + +Route::prefix('admin/v1/stores/{store}') + ->name('api.admin.v1.') + ->middleware(['auth:sanctum', 'throttle:api.admin']) + ->group(function (): void { + Route::middleware('admin.api')->group(function (): void { + Route::get('me', [AdminStoreMembershipController::class, 'show'])->name('stores.me'); + }); + + Route::middleware('admin.api:manage-platform')->group(function (): void { + Route::post('invites', [AdminStoreInviteController::class, 'store'])->name('stores.invites.store'); + }); + + Route::middleware('admin.api:read-products')->group(function (): void { + Route::get('products', [AdminProductController::class, 'index'])->name('products.index'); + Route::get('products/{product}', [AdminProductController::class, 'show'])->name('products.show'); + }); + + Route::middleware('admin.api:write-products')->group(function (): void { + Route::post('products', [AdminProductController::class, 'store'])->name('products.store'); + Route::put('products/{product}', [AdminProductController::class, 'update'])->name('products.update'); + Route::delete('products/{product}', [AdminProductController::class, 'destroy'])->name('products.destroy'); + Route::post('products/{product}/media/presign-upload', [AdminProductController::class, 'presignUpload'])->name('products.media.presign-upload'); + }); + + Route::middleware('admin.api:read-customers')->group(function (): void { + Route::get('customers', [AdminCustomerController::class, 'index'])->name('customers.index'); + Route::get('customers/{customer}', [AdminCustomerController::class, 'show'])->name('customers.show'); + }); + + Route::middleware('admin.api:read-collections')->group(function (): void { + Route::get('collections', [AdminCollectionController::class, 'index'])->name('collections.index'); + }); + + Route::middleware('admin.api:write-collections')->group(function (): void { + Route::post('collections', [AdminCollectionController::class, 'store'])->name('collections.store'); + Route::put('collections/{collection}', [AdminCollectionController::class, 'update'])->name('collections.update'); + Route::delete('collections/{collection}', [AdminCollectionController::class, 'destroy'])->name('collections.destroy'); + }); + + Route::middleware('admin.api:read-discounts')->group(function (): void { + Route::get('discounts', [AdminDiscountController::class, 'index'])->name('discounts.index'); + }); + + Route::middleware('admin.api:write-discounts')->group(function (): void { + Route::post('discounts', [AdminDiscountController::class, 'store'])->name('discounts.store'); + Route::put('discounts/{discount}', [AdminDiscountController::class, 'update'])->name('discounts.update'); + Route::delete('discounts/{discount}', [AdminDiscountController::class, 'destroy'])->name('discounts.destroy'); + }); + + Route::middleware('admin.api:read-content')->group(function (): void { + Route::get('pages', [AdminPageController::class, 'index'])->name('pages.index'); + }); + + Route::middleware('admin.api:write-content')->group(function (): void { + Route::post('pages', [AdminPageController::class, 'store'])->name('pages.store'); + Route::put('pages/{page}', [AdminPageController::class, 'update'])->name('pages.update'); + Route::delete('pages/{page}', [AdminPageController::class, 'destroy'])->name('pages.destroy'); + }); + + Route::middleware('admin.api:read-settings')->group(function (): void { + Route::get('search/status', [AdminSearchIndexController::class, 'status'])->name('search.status'); + Route::get('settings', [AdminStoreSettingsController::class, 'show'])->name('settings.show'); + Route::get('shipping/zones', [AdminShippingZoneController::class, 'index'])->name('shipping.zones.index'); + Route::get('tax/settings', [AdminTaxSettingsController::class, 'show'])->name('tax.settings.show'); + }); + + Route::middleware('admin.api:write-settings')->group(function (): void { + Route::post('search/reindex', [AdminSearchIndexController::class, 'reindex'])->name('search.reindex'); + Route::put('settings', [AdminStoreSettingsController::class, 'update'])->name('settings.update'); + Route::post('shipping/zones', [AdminShippingZoneController::class, 'store'])->name('shipping.zones.store'); + Route::put('shipping/zones/{shippingZone}', [AdminShippingZoneController::class, 'update'])->name('shipping.zones.update'); + Route::post('shipping/zones/{shippingZone}/rates', [AdminShippingRateController::class, 'store'])->name('shipping.zones.rates.store'); + Route::put('tax/settings', [AdminTaxSettingsController::class, 'update'])->name('tax.settings.update'); + }); + + Route::middleware('admin.api:write-themes')->group(function (): void { + Route::post('themes', [AdminThemeController::class, 'store'])->name('themes.store'); + Route::post('themes/{theme}/publish', [AdminThemeController::class, 'publish'])->name('themes.publish'); + Route::put('themes/{theme}/settings', [AdminThemeSettingsController::class, 'update'])->name('themes.settings.update'); + }); + + Route::middleware('admin.api:read-analytics')->group(function (): void { + Route::get('analytics/summary', [AdminAnalyticsSummaryController::class, 'show'])->name('analytics.summary'); + }); + + Route::middleware('admin.api:read-orders')->group(function (): void { + Route::post('exports/orders', [AdminOrderExportController::class, 'store'])->name('exports.orders.store'); + Route::get('exports/{dataExport}', [AdminOrderExportController::class, 'show'])->name('exports.show'); + Route::get('orders', [AdminOrderController::class, 'index'])->name('orders.index'); + Route::get('orders/{order}', [AdminOrderController::class, 'show'])->name('orders.show'); + }); + + Route::middleware('admin.api:write-orders')->group(function (): void { + Route::post('orders/{order}/fulfillments', [AdminOrderFulfillmentController::class, 'store'])->name('orders.fulfillments.store'); + Route::post('orders/{order}/refunds', [AdminOrderRefundController::class, 'store'])->name('orders.refunds.store'); + }); + }); + +Route::middleware('throttle:60,1') + ->prefix('apps/v1/stores/{store}') + ->name('api.apps.v1.') + ->group(function (): void { + Route::get('products', DeferredAppEndpointController::class)->name('products.index'); + Route::get('orders', DeferredAppEndpointController::class)->name('orders.index'); + Route::get('customers', DeferredAppEndpointController::class)->name('customers.index'); + }); diff --git a/routes/console.php b/routes/console.php index 3c9adf1a..6296966e 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')->timezone('UTC'); diff --git a/routes/web.php b/routes/web.php index f755f111..5becd552 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,12 +1,209 @@ name('home'); +Route::middleware(['storefront'])->group(function (): void { + Route::livewire('/', StorefrontHome::class)->name('home'); + Route::livewire('collections', StorefrontCollectionsIndex::class)->name('collections.index'); + Route::livewire('collections/{handle}', StorefrontCollectionShow::class)->name('collections.show'); + Route::livewire('products/{handle}', StorefrontProductShow::class)->name('products.show'); + Route::livewire('cart', StorefrontCartShow::class)->name('cart.show'); + Route::livewire('checkout/{checkout}/confirmation', StorefrontCheckoutConfirmation::class) + ->whereNumber('checkout') + ->name('checkout.confirmation'); + Route::get('checkout/confirmation/{order}', function (Order $order) { + abort_if($order->checkout_id === null, 404); -Route::view('dashboard', 'dashboard') + return redirect()->route('checkout.confirmation', ['checkout' => $order->checkout_id]); + })->whereNumber('order')->name('checkout.confirmation.legacy'); + Route::livewire('checkout/{checkout?}', StorefrontCheckoutShow::class) + ->whereNumber('checkout') + ->name('checkout.show'); + Route::livewire('search', StorefrontSearchIndex::class)->name('search.index'); + Route::livewire('pages/{handle}', StorefrontPageShow::class)->name('pages.show'); + + Route::livewire('forgot-password', CustomerForgotPassword::class) + ->middleware('guest:customer') + ->name('customer.password.request'); + + Route::post('forgot-password', [CustomerPasswordResetController::class, 'send']) + ->middleware('guest:customer') + ->name('customer.password.email'); + + Route::livewire('reset-password/{token}', CustomerResetPassword::class) + ->middleware('guest:customer') + ->name('customer.password.reset'); + + Route::post('reset-password', [CustomerPasswordResetController::class, 'update']) + ->middleware('guest:customer') + ->name('customer.password.update'); +}); + +Route::livewire('admin/login', AdminLogin::class) + ->middleware('guest') + ->name('admin.login'); + +Route::get('admin/forgot-password', [FortifyPasswordResetLinkController::class, 'create']) + ->middleware('guest') + ->name('admin.password.request'); + +Route::post('admin/forgot-password', [FortifyPasswordResetLinkController::class, 'store']) + ->middleware('guest') + ->name('admin.password.email'); + +Route::get('admin/reset-password/{token}', [FortifyNewPasswordController::class, 'create']) + ->middleware('guest') + ->name('admin.password.reset'); + +Route::post('admin/reset-password', [FortifyNewPasswordController::class, 'store']) + ->middleware('guest') + ->name('admin.password.update'); + +Route::post('admin/logout', function () { + Auth::guard('web')->logout(); + + request()->session()->invalidate(); + request()->session()->regenerateToken(); + + return redirect()->route('admin.login'); +})->middleware('auth')->name('admin.logout'); + +Route::get('oauth/authorize', [OAuthController::class, 'authorize']) + ->middleware('auth') + ->name('oauth.authorize'); + +Route::post('oauth/token', [OAuthController::class, 'token']) + ->name('oauth.token'); + +Route::middleware(['auth', EnsureUserEmailIsVerified::class, 'admin'])->prefix('admin')->name('admin.')->group(function (): void { + Route::livewire('/', AdminDashboard::class)->name('dashboard'); + Route::livewire('analytics', AdminAnalyticsIndex::class)->name('analytics.index'); + Route::livewire('apps', AdminAppsIndex::class)->name('apps.index'); + Route::livewire('apps/{installation}', AdminAppShow::class)->name('apps.show'); + Route::livewire('developers', AdminDevelopersIndex::class)->name('developers.index'); + Route::livewire('products', AdminProductsIndex::class)->name('products.index'); + Route::livewire('products/create', AdminProductForm::class)->name('products.create'); + Route::livewire('products/{product}/edit', AdminProductForm::class)->name('products.edit'); + Route::livewire('inventory', AdminInventoryIndex::class)->name('inventory.index'); + Route::livewire('orders', AdminOrdersIndex::class)->name('orders.index'); + Route::livewire('orders/{order}', AdminOrderShow::class)->name('orders.show'); + Route::livewire('customers', AdminCustomersIndex::class)->name('customers.index'); + Route::livewire('customers/{customer}', AdminCustomerShow::class)->name('customers.show'); + Route::livewire('discounts', AdminDiscountsIndex::class)->name('discounts.index'); + Route::livewire('discounts/create', AdminDiscountForm::class)->name('discounts.create'); + Route::livewire('discounts/{discount}/edit', AdminDiscountForm::class)->name('discounts.edit'); + Route::livewire('pages', AdminPagesIndex::class)->name('pages.index'); + Route::livewire('pages/create', AdminPageForm::class)->name('pages.create'); + Route::livewire('pages/{page}/edit', AdminPageForm::class)->name('pages.edit'); + Route::livewire('navigation', AdminNavigationIndex::class)->name('navigation.index'); + Route::livewire('themes', AdminThemesIndex::class)->name('themes.index'); + Route::livewire('themes/{theme}/editor', AdminThemeEditor::class)->name('themes.editor'); + Route::livewire('settings', AdminSettingsIndex::class)->name('settings.index'); + Route::livewire('settings/shipping', AdminSettingsShipping::class)->name('settings.shipping'); + Route::livewire('settings/taxes', AdminSettingsTaxes::class)->name('settings.taxes'); + Route::livewire('settings/checkout', AdminSettingsCheckout::class)->name('settings.checkout'); + Route::livewire('settings/notifications', AdminSettingsNotifications::class)->name('settings.notifications'); + Route::livewire('search/settings', AdminSearchSettings::class)->name('search.settings'); + Route::livewire('collections', AdminCollectionsIndex::class)->name('collections.index'); + Route::livewire('collections/create', AdminCollectionForm::class)->name('collections.create'); + Route::livewire('collections/{collection}/edit', AdminCollectionForm::class)->name('collections.edit'); +}); + +Route::middleware(['storefront'])->group(function (): void { + Route::livewire('account/login', CustomerLogin::class) + ->middleware('guest:customer') + ->name('account.login'); + + Route::livewire('account/register', CustomerRegister::class) + ->middleware('guest:customer') + ->name('account.register'); + + Route::livewire('account/forgot-password', CustomerForgotPassword::class) + ->middleware('guest:customer') + ->name('account.password.request'); + + Route::livewire('account/reset-password/{token}', CustomerResetPassword::class) + ->middleware('guest:customer') + ->name('account.password.reset'); + + Route::livewire('account', CustomerOrdersIndex::class) + ->middleware('auth:customer') + ->name('account.dashboard'); + + Route::livewire('account/orders', CustomerOrdersIndex::class) + ->middleware('auth:customer') + ->name('account.orders.index'); + + Route::livewire('account/orders/{order}', CustomerOrderShow::class) + ->middleware('auth:customer') + ->name('account.orders.show'); + + Route::livewire('account/addresses', CustomerAddressesIndex::class) + ->middleware('auth:customer') + ->name('account.addresses.index'); + + Route::post('account/logout', function () { + Auth::guard('customer')->logout(); + + request()->session()->invalidate(); + request()->session()->regenerateToken(); + + return redirect()->route('account.login'); + })->middleware('auth:customer')->name('account.logout'); +}); + +Route::redirect('dashboard', 'admin') ->middleware(['auth', 'verified']) ->name('dashboard'); diff --git a/specs/progress.md b/specs/progress.md new file mode 100644 index 00000000..0375b7ce --- /dev/null +++ b/specs/progress.md @@ -0,0 +1,728 @@ +# Shop Build Progress + +## Objective + +Build a complete, self-contained Laravel shop system from `specs/*`, with implemented acceptance criteria mapped to evidence, passing automated checks, verified browser flows, independent QA, and meaningful commits. + +## Current Status + +- Status: final verification passed; commit pending +- Active slice: Final post-QA hardening and completion commit +- Started from: Laravel Livewire/Fortify starter kit with no shop domain tables or models present +- Last updated: 2026-05-04 + +## Phased Plan + +1. Foundation: SQLite config, tenancy tables/models/factories/seeders, store resolution middleware, store scoping, admin/customer auth foundations, policies, and Phase 1 tests. +2. Catalog: product, variant, inventory, collection, media schema/models/services, admin catalog CRUD, storefront browsing, and tests. +3. Storefront theme/layout: theme/page/navigation data, storefront layout, product/collection/search/page views, and smoke browser verification. +4. Cart/checkout/pricing: cart API/UI, discount, shipping, tax, checkout state machine, and tests. +5. Payments/orders/fulfillment: mock PSP, order creation, refunds, fulfillments, customer account order views, and tests. +6. Admin panel: dashboard and all admin list/form/detail pages from Spec 03, with Livewire/Flux interactions. +7. Search/analytics/apps/webhooks: SQLite search, event aggregation, developer token/webhook UI, delivery jobs, and tests. +8. Final verification: full Pest suite, Pint, frontend build, Playwright MCP customer/admin flows, independent QA, fixes, and completion audit. + +## Acceptance Checklist + +| Area | Criteria Source | Status | Evidence | +| --- | --- | --- | --- | +| Database schema | `specs/01-DATABASE-SCHEMA.md` | complete | Phase 1 tenancy/auth tables, Phase 2 catalog tables, Phase 3 theme/content/navigation tables including `navigation_items.parent_id` nesting, Phase 4 cart/checkout/pricing tables, Phase 5 order/payment/fulfillment tables, Laravel framework runtime tables including `personal_access_tokens`, and Phase 7 search/analytics/apps/webhooks/export tables are implemented: search_settings, search_queries, SQLite FTS5 `products_fts`, analytics_events, analytics_daily, apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, webhook_deliveries, and data_exports. | +| Routes/API | `specs/02-API-ROUTES.md` | complete | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout/{checkoutId}`, `/checkout/{checkoutId}/confirmation`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/forgot-password`, `/reset-password/{token}`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders`, `/account/orders/{order}`, `/account/addresses`, `/account/logout`, `/admin/login`, `/admin/forgot-password`, `/admin/reset-password/{token}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`; `/checkout` remains as the optional empty-cart compatibility entry and `/checkout/confirmation/{order}` redirects to the checkout-id confirmation route. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes platform organization/store creation, store invite and membership endpoints, store-scoped product list/detail/create/update/archive/media presign, customer/order list/detail, collection CRUD, discount CRUD, content page CRUD, store settings read/update, search status/reindex endpoints, analytics summary, order export creation/status, shipping zone/rate settings, tax settings read/update, theme upload/publish/settings update, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with `auth:sanctum` and scoped `personal_access_tokens` bearer authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. | +| Admin UI | `specs/03-ADMIN-UI.md` | complete | Flux/Livewire admin shell now includes dashboard KPI/date-range reporting, analytics reporting with filters/export, catalog/product/collection/inventory management, product option matrix generation, product media upload/alt text/reorder/delete controls, order/customer/discount management, page index/form, nested navigation menu editor with drag sorting and parent selection, theme grid, theme settings editor with iframe preview, theme file list/editor, general settings, domain management, shipping zone/rate management, tax settings, checkout policy settings, notification settings, search settings/reindexing, installed app list/detail, and developer token/webhook management with auth protection and store scoping. | +| Storefront UI | `specs/04-STOREFRONT-UI.md` | complete | Storefront layout with desktop navigation and mobile hamburger flyout, home, collections index/detail, product detail, SQLite-backed search with filters/sort/pagination, breadcrumbs, price display, product cards, DB-backed pages, seeded navigation menus with one-level dropdown children, cached theme settings, product add-to-cart persistence, cart drawer, cart page with discount and shipping estimates plus stock-limit feedback, checkout address/shipping/discount/payment-selection/order-submission UI, order confirmation, customer password reset forms at the spec root paths with account-path aliases, customer account overview/navigation/logout, customer order list/detail views, and customer address book list/create/update/default/delete controls render seeded and runtime data. | +| Business logic | `specs/05-BUSINESS-LOGIC.md` | complete | `ResolveStore`, `BelongsToStore`, `StoreScope`, role checks, customer guard provider, store-scoped `CustomerPasswordResetService`, catalog product lifecycle transitions, SKU uniqueness, variant matrix rebuilding, inventory reserve/release/commit/restock, automatic variant inventory, variant option mapping, media resize/cleanup job, `SanitizeHtml` allow-listing for rich product/page/collection content, structured audit logging for auth, API-token, and resource mutation events, `SearchService` with per-store synonym expansion and stop-word removal, product observer FTS sync, search query logging, search API rate limiting, `AnalyticsService`, analytics event deduplication, daily analytics aggregation, scheduled `AggregateAnalytics`, analytics API rate limiting, `WebhookService`, timestamped HMAC signing/verification, database-queued `DeliverWebhook`, delivery logging/retry backoff metadata, webhook circuit breaker pausing, domain-event webhook dispatch listener for checkout/order/product lifecycle events, developer token generation backed by store-scoped `personal_access_tokens`, `OrderExportService`, `CartService`, `DiscountService` including one-per-customer code discount redemption checks and automatic/code order-line attribution, `ShippingCalculator`, `TaxCalculator`, `PricingEngine`, checkout transitions through completion, checkout expiration, abandoned cart cleanup, session cart lookup, active checkout reuse for cart handoff, cart discount handoff to checkout, customer login cart merge, product add-to-cart mutations, storefront cart stock-limit feedback for deny-policy inventory, `PaymentService`, mock PSP, idempotent order creation with checkout/cart/store/discount locks, checkout UI/API order submission, session/customer/token-gated order confirmation and lookup, customer order-history scoping, customer address book management, customer logout session invalidation, admin dashboard order aggregates with support-role exclusion, admin analytics reporting, admin customer address management, admin discount rule management, admin content/theme/navigation/settings/search/apps/developers orchestration, admin order UI/API action orchestration, `RefundService`, `FulfillmentService`, bank-transfer payment confirmation, and unpaid bank-transfer cancellation are implemented. | +| Auth/security | `specs/06-AUTH-AND-SECURITY.md` | complete | Admin Livewire login/logout/reset, customer guard/provider, customer login/register/logout, customer password reset GET/POST routes at `/forgot-password` and `/reset-password/{token}` with store-scoped hashed tokens, reset-link throttling, generic reset responses, login rate limiting, session hardening, concrete resource policies for API and Livewire mutations, encrypted app client/webhook secrets, HTML sanitization for rich content inputs, one-time display developer token generation, named per-token admin API throttling, explicit platform-admin separation from store-owner roles, token-gated checkout API access, and a Sanctum-compatible hashed bearer-token admin/platform API middleware with store/role/ability/token-store enforcement are implemented. | +| Seed/test data | `specs/07-SEEDERS-AND-TEST-DATA.md` | complete | Seeders create Acme Fashion and Acme Electronics stores/domains/settings/admin access with checkout and notification defaults, 2 apps, 4 app installations, 4 OAuth clients, 4 OAuth tokens, 8 webhook subscriptions, 2 search settings rows, 25 indexed products, 60 analytics_daily rows, 210 analytics_events rows, 6 collections, 127 variants, 127 inventory rows, 206 variant option pivots, 2 themes, 6 theme files with local disk contents and metadata, 2 theme settings rows, 6 pages, 4 navigation menus, 21 navigation items including 6 child items, 2 tax settings rows, 2 shipping zones, 6 shipping rates, 7 discounts, 12 customers, 13 customer addresses, 18 orders, 26 order lines, 18 payments, 7 fulfillments, 11 fulfillment lines, 2 refunds, and no product media/runtime carts. | +| Browser E2E plan | `specs/08-PLAYWRIGHT-E2E-PLAN.md` | complete | Manual Playwright MCP smoke coverage has verified storefront catalog browsing on `shop.test` and `acme-fashion.test`, product detail, add-to-cart, cart drawer/page, checkout through order completion, order confirmation, customer order list/detail, SQLite-backed search and search API JSON, analytics event ingestion API, DB-backed content pages, seeded nested navigation dropdowns, admin dashboard on desktop/mobile, admin analytics, admin app list/detail, admin developers token/webhook flows, admin product/collection/inventory/order/customer/discount/content/navigation/settings/theme/search pages, admin checkout/notification settings pages, admin product media section rendering, admin analytics CSV export, admin customer address creation, admin discount creation/editing/filtering, admin page creation, admin nested navigation rendering/item creation/save, admin theme editor preview, admin theme file list/editor, admin search reindexing, admin fulfillment creation/shipping/delivery, and auth flows without console warnings/errors on successful pages. Automated Pest browser coverage now checks all 18 Spec 08 browser suites with 143 tests: Suite 1 smoke pages/critical-page batch, Suite 2 admin authentication login/validation/redirect/logout/sidebar navigation interactions, Suite 3 admin product listing/create/edit/archive/search/status-filter/draft visibility interactions, Suite 4 admin order listing/filtering/detail/timeline/fulfillment/refund/bank-payment/guard/shipment interactions, Suite 5 admin discount seeded listing/percent/fixed/free-shipping creation/edit/status interactions, Suite 6 admin general/domain/shipping/tax settings interactions, Suite 7 storefront browsing, Suite 8 cart flow, Suite 9 checkout flow, Suite 10 customer account registration/login/orders/addresses/logout, Suite 11 inventory policy enforcement, Suite 12 tenant isolation, Suite 13 responsive/mobile flows, Suite 14 accessibility checks, Suite 15 admin collection listing/create/edit interactions, Suite 16 admin customer listing/detail/order-history/address interactions, Suite 17 admin page listing/create/edit interactions, and Suite 18 admin analytics dashboard/KPI/funnel interactions. | +| Roadmap phases | `specs/09-IMPLEMENTATION-ROADMAP.md` | complete | Phase 1 foundation, Phase 2 catalog data/UI, Phase 3 theme/content/navigation data with storefront consumption, Phase 4 cart/checkout/pricing backend foundation, Phase 4 storefront cart/checkout UI through order completion, the Phase 4 cart/checkout REST API surface, cart-page estimates, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, Phase 5 storefront order completion/customer order surfaces, Phase 5 admin order management, Phase 5 order API surfaces, Phase 6 admin dashboard/customer/discount/content/settings/theme/navigation management, Phase 7 search, Phase 7 analytics, Phase 7 apps/webhooks, and Phase 8 final verification are implemented. Post-audit QA hardening closed the admin bearer-token auth, API/Livewire policy enforcement, rich-content sanitization, webhook event, checkout route, and idempotency race gaps. | + +## Verification Evidence + +- 2026-05-03: `mcp__laravel_boost__.application_info` confirmed PHP 8.4, Laravel 12.51.0, Livewire 4.1.4, Flux 2.12.0, Pest 4.3.2, SQLite. +- 2026-05-03: `mcp__laravel_boost__.database_schema(summary: true)` returned no tables for the current configured database. +- 2026-05-03: `git status --short` showed no current worktree changes before implementation. +- 2026-05-03: `mcp__laravel_boost__.search_docs` consulted Laravel 12 middleware/auth/authorization/migration docs plus Livewire 4 and Pest 4 docs before code changes. +- 2026-05-03: Planning challenger sub-agent found the repo was still a starter kit and produced the acceptance checklist by vertical slice. Risks added here: missing Sanctum dependency, password_hash/Fortify coordination, route/API absence, and E2E count inconsistencies. +- 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed on real `database/database.sqlite` after creating the missing SQLite file. +- 2026-05-03: `php artisan test --compact` passed: 45 tests, 121 assertions. +- 2026-05-03: `vendor/bin/pint --dirty --format agent` passed after formatting changes. +- 2026-05-03: `php artisan route:list --path=admin` showed `/admin`, `/admin/login`, `/admin/logout`; `php artisan route:list --path=account` showed `/account`, `/account/login`, `/account/register`. +- 2026-05-03: Boost schema summary confirmed Phase 1 tables: organizations, stores, store_domains, store_users, store_settings, customers, customer_password_reset_tokens, users with password_hash. +- 2026-05-03: Playwright MCP verified `http://shop.test/` loads, `http://shop.test/admin/login` renders, admin login with `admin@acme.test` / `password` reaches `/admin`, `http://shop.test/account/login` renders with no console warnings/errors after the Livewire layout fix, and customer login with `customer@acme.test` / `password` reaches `/account`. +- 2026-05-03: Independent Phase 2 QA agents reported no critical findings. High findings on lifecycle bypass, child tenant scoping, inventory store mismatch, variant option mapping, simplified seed data, and media status-only processing were addressed in the catalog data layer. +- 2026-05-03: `php artisan test --compact tests/Feature/Catalog` passed: 19 tests, 71 assertions. +- 2026-05-03: `php artisan test --compact` passed: 64 tests, 192 assertions. +- 2026-05-03: `vendor/bin/pint --dirty --format agent` fixed import/order/spacing, followed by passing catalog and full Pest suites. +- 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed after the Phase 2 seed update. +- 2026-05-03: Boost schema summary confirmed Phase 2 catalog tables. Boost query counts after fresh seed: stores 2, products 25, variants 127, inventory_items 127, collections 6, product_media 0, variant_option_values 206. +- 2026-05-03: `mcp__laravel_boost__.search_docs` consulted Livewire 4 full-page component routing/testing docs and Flux UI form/input/select/checkbox/button docs before catalog UI changes. +- 2026-05-03: `php artisan route:list --path=admin` showed 10 admin routes including product, collection, and inventory catalog pages. `php artisan route:list --path=collections`, `--path=products`, `--path=search`, and `--path=pages` showed storefront catalog/content routes. +- 2026-05-03: `php artisan test --compact tests/Feature/Catalog/CatalogUiTest.php` passed: 4 tests, 44 assertions. +- 2026-05-03: `php artisan test --compact tests/Feature/Catalog` passed: 23 tests, 115 assertions. +- 2026-05-03: `php artisan test --compact` passed: 68 tests, 236 assertions. +- 2026-05-03: `vendor/bin/pint --dirty --format agent` passed after formatting catalog UI changes. +- 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed after catalog UI test changes. +- 2026-05-03: `npm run build` passed with Vite production assets generated under ignored `public/build`. +- 2026-05-03: Playwright MCP verified `http://shop.test/`, `/collections/t-shirts`, `/products/classic-cotton-t-shirt`, `/search?q=Cotton`, `/pages/about`, `/admin/products`, `/admin/products/create`, `/admin/collections`, and `/admin/inventory` render with no console warnings/errors. +- 2026-05-03: `mcp__laravel_boost__.search_docs` consulted Laravel 12 migration/model/factory/seeder/cache docs and Pest 4 database testing docs before Phase 3 data-layer changes. +- 2026-05-03: Phase 3 explorer QA confirmed the required theme/page/navigation migrations, models, enums, factories, seeders, services, and tests; noted the spec tree-navigation mismatch because `navigation_items` has no `parent_id`. +- 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed after adding Phase 3 migrations and seeders. +- 2026-05-03: Boost schema summaries confirmed Phase 3 tables: themes, theme_files, theme_settings, pages, navigation_menus, navigation_items. Boost query counts after fresh seed: themes 2, theme_files 6, theme_settings 2, pages 6, navigation_menus 4, navigation_items 17. +- 2026-05-03: `php artisan test --compact tests/Feature/Storefront/ThemeDataTest.php` passed: 6 tests, 31 assertions. +- 2026-05-03: `php artisan test --compact tests/Feature/Storefront tests/Feature/Catalog` passed: 29 tests, 146 assertions. +- 2026-05-03: `php artisan test --compact` passed: 74 tests, 267 assertions. +- 2026-05-03: `vendor/bin/pint --dirty --format agent` passed after Phase 3 changes. +- 2026-05-03: `npm run build` passed after Phase 3 layout changes. +- 2026-05-03: Playwright MCP verified `http://shop.test/` renders seeded main navigation and `http://shop.test/pages/faq` renders DB-backed page content with no console warnings/errors. +- 2026-05-03: `mcp__laravel_boost__.search_docs` consulted Laravel 12 migration/job/scheduling/transaction docs and Pest 4 database testing docs before Phase 4 backend changes. +- 2026-05-03: Phase 4 explorer QA mapped the cart, checkout, discount, shipping, tax, pricing, and cleanup-job boundary; order creation/payment capture were kept for Phase 5 because order/payment tables are not implemented yet. +- 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed after adding Phase 4 migrations and seeders. +- 2026-05-03: Boost schema summary confirmed Phase 4 tables: carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts. Boost query counts after fresh seed: carts 0, cart_lines 0, checkouts 0, shipping_zones 2, shipping_rates 6, tax_settings 2, discounts 6. +- 2026-05-03: `php artisan test --compact tests/Feature/Cart tests/Feature/Checkout` passed: 11 tests, 55 assertions. +- 2026-05-03: `vendor/bin/pint --dirty --format agent` fixed checkout/pricing style and import order, then passed after job test coverage was added. +- 2026-05-03: `php artisan test --compact` passed: 85 tests, 322 assertions. +- 2026-05-03: `mcp__laravel_boost__.search_docs` consulted Livewire 4 full-page components, forms, validation, session, events, redirects, Flux modal/form controls, and Pest docs before Phase 4 cart/checkout UI changes. +- 2026-05-03: `php artisan route:list --path=cart` and `php artisan route:list --path=checkout` confirmed `/cart` and `/checkout` Livewire routes. +- 2026-05-03: `php artisan test --compact tests/Feature/Storefront/CartCheckoutUiTest.php` passed: 4 tests, 19 assertions. +- 2026-05-03: `php artisan test --compact tests/Feature/Storefront tests/Feature/Cart tests/Feature/Checkout tests/Feature/Catalog tests/Feature/Foundation/CustomerAuthTest.php` passed: 47 tests, 236 assertions. +- 2026-05-03: `vendor/bin/pint --dirty --format agent` passed after Phase 4 UI changes. +- 2026-05-03: `php artisan test --compact` passed: 89 tests, 341 assertions. +- 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed after the Phase 4 UI changes. +- 2026-05-03: `npm run build` passed after the cart/checkout Blade changes. +- 2026-05-03: Playwright MCP verified `http://shop.test/products/classic-cotton-t-shirt` add-to-cart, `http://shop.test/cart`, and `http://shop.test/checkout` render and progress through shipping, discount, and reserve-items payment selection with Livewire update requests returning 200 and no new console warnings/errors. Missing favicon links were fixed with `public/favicon.svg`. +- 2026-05-03: `mcp__laravel_boost__.search_docs` consulted Laravel 12 JSON API tests/resources/form requests/route model binding docs and Pest 4 JSON expectation docs before the Phase 4 API changes. +- 2026-05-03: `php artisan route:list --path=api/storefront/v1` confirmed 12 storefront API routes for carts, cart lines, checkouts, checkout address, shipping method, discount apply/remove, and payment method selection. +- 2026-05-03: `php artisan test --compact tests/Feature/Api/StorefrontCartApiTest.php tests/Feature/Api/StorefrontCheckoutApiTest.php` passed: 6 tests, 73 assertions. +- 2026-05-03: `php artisan test --compact tests/Feature/Api tests/Feature/Cart tests/Feature/Checkout tests/Feature/Storefront` passed: 27 tests, 178 assertions. +- 2026-05-03: `php artisan test --compact` passed after the cart/checkout API changes: 95 tests, 414 assertions. +- 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed after the cart/checkout API changes. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Livewire 4 form/computed-property validation docs, Flux input/select/button docs, Laravel validation docs, and Pest docs before the cart estimate UI changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Storefront/CartCheckoutUiTest.php` passed after the cart estimate UI changes: 5 tests, 30 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Storefront tests/Feature/Cart tests/Feature/Checkout tests/Feature/Api` passed after the cart estimate UI changes: 28 tests, 189 assertions. +- 2026-05-04: `npm run build` passed after the cart estimate Blade changes. +- 2026-05-04: Playwright MCP verified `http://shop.test/products/classic-cotton-t-shirt` add-to-cart, `http://shop.test/cart` discount application and shipping rates, and checkout handoff with discounted totals at `http://shop.test/checkout`; Livewire requests returned 200 and browser console had no warnings/errors. +- 2026-05-04: `php artisan test --compact` passed after the cart estimate UI changes: 96 tests, 425 assertions. +- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after the cart estimate UI changes. +- 2026-05-04: `mcp__laravel_boost__.application_info` confirmed Laravel 12.51.0, PHP 8.4, Livewire 4.1.4, Flux 2.12.0, Pest 4.3.2, and SQLite before Phase 5 order/payment changes. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 transaction, enum/encrypted cast, and Pest database testing docs before Phase 5 order/payment changes. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the Phase 5 order/payment backend foundation changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Payments tests/Feature/Orders` passed after adding mock PSP and order completion coverage: 11 tests, 64 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Cart tests/Feature/Checkout tests/Feature/Payments tests/Feature/Orders` passed after the order completion changes: 22 tests, 119 assertions. +- 2026-05-04: `php artisan test --compact` passed after the Phase 5 order/payment backend foundation: 107 tests, 489 assertions. +- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after adding Phase 5 order/payment/fulfillment migrations and factories. +- 2026-05-04: Boost schema summaries confirmed Phase 5 runtime tables: orders, order_lines, payments, refunds, fulfillments, and fulfillment_lines. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 job/scheduling, transaction, event, relationship aggregate, and Pest testing docs before refund/fulfillment service changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Orders` passed after adding refund, fulfillment, and bank-transfer service coverage: 15 tests, 86 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Cart tests/Feature/Checkout tests/Feature/Payments tests/Feature/Orders` passed after refund/fulfillment changes: 32 tests, 167 assertions. +- 2026-05-04: `php artisan test --compact` passed after refund/fulfillment changes: 117 tests, 537 assertions. +- 2026-05-04: `php artisan schedule:list` confirmed `CancelUnpaidBankTransferOrders` is scheduled daily alongside checkout/cart cleanup jobs. +- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after refund/fulfillment service changes. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Livewire 4 redirects/forms/testing docs, Flux UI form/button/input docs, Laravel validation/redirect docs, and Pest docs before storefront order-completion UI changes. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after storefront order-completion UI changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Storefront/OrderViewsTest.php` passed: 4 tests, 23 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Storefront tests/Feature/Cart tests/Feature/Checkout tests/Feature/Orders` passed after storefront order-completion changes: 41 tests, 225 assertions. +- 2026-05-04: `php artisan test --compact` passed after storefront order-completion changes: 121 tests, 560 assertions. +- 2026-05-04: `npm run build` passed after checkout confirmation and customer account Blade changes. +- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after storefront order-completion changes. +- 2026-05-04: `mcp__laravel_boost__.get_absolute_url(path: "/products/classic-cotton-t-shirt")` resolved `http://shop.test/products/classic-cotton-t-shirt` before browser verification. +- 2026-05-04: Playwright MCP verified product add-to-cart through checkout order completion, `http://shop.test/checkout/confirmation/1`, `http://shop.test/account`, and `http://shop.test/account/orders/1`; order number, line item, totals, payment/fulfillment badges, and account order history rendered with no console warnings/errors. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Livewire 4 full-page components/actions/security/testing docs, Flux UI button/modal/form docs, Laravel authorization docs, and Pest docs before admin order-management UI changes. +- 2026-05-04: `php artisan make:livewire Admin/Orders/Index --class --no-interaction`, `php artisan make:livewire Admin/Orders/Show --class --no-interaction`, and `php artisan make:test --pest Admin/OrderManagementTest --no-interaction` created the admin order slice files. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after admin order-management changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin/OrderManagementTest.php` passed: 5 tests, 30 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin tests/Feature/Orders tests/Feature/Catalog/CatalogUiTest.php` passed after admin order-management changes: 24 tests, 160 assertions. +- 2026-05-04: `php artisan route:list --path=admin/orders` confirmed `admin.orders.index` and `admin.orders.show` Livewire routes. +- 2026-05-04: `php artisan test --compact` passed after admin order-management changes: 126 tests, 590 assertions. +- 2026-05-04: `npm run build` passed after admin order Blade/sidebar changes. +- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after admin order-management changes. +- 2026-05-04: `mcp__laravel_boost__.get_absolute_url(path: "/admin/orders")` resolved `http://shop.test/admin/orders` before browser verification. +- 2026-05-04: Playwright MCP verified a seeded storefront checkout order appears in `http://shop.test/admin/orders`, `http://shop.test/admin/orders/1` shows line items/payments/totals/refund and fulfillment controls, and admin fulfillment creation, mark shipped, and mark delivered work with no current console warnings/errors. `browser_logs` only returned older May 3 auth-page warnings from a previously fixed issue. +- 2026-05-04: `mcp__laravel_boost__.application_info` reconfirmed Laravel 12.51.0, PHP 8.4, Livewire 4.1.4, Flux 2.12.0, Pest 4.3.2, and SQLite before the order API slice. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 JSON API resources, form requests, validation, route model binding, auth/rate limiting, and Pest JSON testing docs before the order API changes. +- 2026-05-04: `php artisan make:controller`, `php artisan make:request`, `php artisan make:resource`, `php artisan make:class`, and `php artisan make:test --pest` created the storefront/admin order API controllers, requests, resources, support token class, and tests. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the order API changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Api/StorefrontOrderApiTest.php tests/Feature/Api/AdminOrderApiTest.php` passed: 5 tests, 38 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Api tests/Feature/Cart tests/Feature/Checkout tests/Feature/Orders tests/Feature/Payments` passed after the order API changes: 43 tests, 278 assertions. +- 2026-05-04: `php artisan route:list --path=api/storefront/v1 --except-vendor` showed 14 storefront API routes including checkout payment completion and token-gated order lookup. +- 2026-05-04: `php artisan route:list --path=api/admin/v1 --except-vendor` showed 4 admin order API routes for order list/detail, fulfillment creation, and refund creation. +- 2026-05-04: `php artisan test --compact` passed after the order API changes: 131 tests, 628 assertions. +- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after the order API changes. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Livewire 4 full-page/form/testing docs, Flux UI input/select/button/badge docs, Laravel aggregate/query docs, and Pest docs before the admin dashboard changes. +- 2026-05-04: `php artisan make:livewire Admin/Dashboard --class --no-interaction` and `php artisan make:test --pest Admin/DashboardTest --no-interaction` created the admin dashboard slice files. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin dashboard changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin/DashboardTest.php` passed: 2 tests, 13 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin` passed after the admin dashboard changes: 7 tests, 43 assertions. +- 2026-05-04: `php artisan route:list --name=admin.dashboard` confirmed `/admin` is now the Livewire admin dashboard route. +- 2026-05-04: `npm run build` passed after the admin dashboard Blade changes. +- 2026-05-04: `php artisan test --compact` passed after the admin dashboard changes: 133 tests, 641 assertions. +- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after the admin dashboard changes. +- 2026-05-04: `mcp__laravel_boost__.get_absolute_url(path: "/admin")` resolved `http://shop.test/admin` before browser verification. +- 2026-05-04: Playwright MCP verified `http://shop.test/admin` renders the dashboard on desktop and 390px mobile viewports with KPI cards, chart panel, top-products panel, and funnel counters; current Playwright console checks reported no warnings/errors. Boost `browser_logs` only returned older May 3 account-login warnings from a previously fixed issue. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Livewire 4 full-page/pagination/form/testing docs, Flux UI input/select/button/badge/modal/table docs, Laravel relationship aggregate docs, and Pest docs before the admin customer-management changes. +- 2026-05-04: `php artisan make:livewire Admin/Customers/Index --class --no-interaction`, `php artisan make:livewire Admin/Customers/Show --class --no-interaction`, and `php artisan make:test --pest Admin/CustomerManagementTest --no-interaction` created the admin customer slice files. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin customer-management changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin/CustomerManagementTest.php` passed: 4 tests, 26 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin` passed after the admin customer-management changes: 11 tests, 69 assertions. +- 2026-05-04: `php artisan route:list --path=admin/customers` confirmed `admin.customers.index` and `admin.customers.show` Livewire routes. +- 2026-05-04: `npm run build` passed after the customer admin Blade/sidebar changes. +- 2026-05-04: `php artisan test --compact` passed after the admin customer-management changes: 137 tests, 667 assertions. +- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after the admin customer-management changes. +- 2026-05-04: `mcp__laravel_boost__.get_absolute_url(path: "/admin/customers")` resolved `http://shop.test/admin/customers` before browser verification. +- 2026-05-04: Playwright MCP verified `http://shop.test/admin/customers` renders the customer table, seeded customer detail renders at `/admin/customers/1`, and the address modal creates a default address; current Playwright console checks reported no warnings/errors. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Livewire 4 full-page/forms/security/testing/persistent-middleware docs, Flux UI radio/checkbox/input/select/button/badge/table/switch docs, Laravel validation/authorization/email-verification docs, Fortify email-verification docs, and Pest docs before the admin discount-management changes. +- 2026-05-04: `php artisan make:livewire Admin/Discounts/Index --class --no-interaction`, `php artisan make:livewire Admin/Discounts/Form --class --no-interaction`, and `php artisan make:test --pest Admin/DiscountManagementTest --no-interaction` created the admin discount slice files. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin discount-management changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin/DiscountManagementTest.php` passed: 8 tests, 58 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Checkout/PricingServicesTest.php` passed after automatic discount pricing changes: 5 tests, 23 assertions. +- 2026-05-04: `php artisan route:list --name=admin.discounts` confirmed `admin.discounts.index`, `admin.discounts.create`, and `admin.discounts.edit` Livewire routes. +- 2026-05-04: `npm run build` passed after the discount admin Blade/sidebar changes. +- 2026-05-04: `php artisan test --compact` passed after the admin discount-management changes: 147 tests, 733 assertions. +- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after browser verification to restore the local SQLite database to seeded fixtures. +- 2026-05-04: `mcp__laravel_boost__.get_absolute_url(path: "/admin/discounts")` resolved `http://shop.test/admin/discounts` before browser verification. +- 2026-05-04: Playwright MCP verified `http://shop.test/admin/discounts` renders seeded discounts, `/admin/discounts/create` saves a browser-created active code discount through Livewire, the status switch label updates live, `/admin/discounts/{discount}/edit` renders the saved rule controls, search filters to the created discount, the type filter updates the table, Livewire update requests return 200, and current Playwright console checks report no warnings/errors. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Livewire 4 full-page/forms/testing docs, Flux UI input/select/textarea/checkbox/radio/button/table/switch docs, Laravel validation docs, and Pest docs before the admin content/settings/theme/navigation changes. +- 2026-05-04: `php artisan make:livewire` created `Admin/Settings/Index`, `Admin/Settings/Shipping`, `Admin/Settings/Taxes`, `Admin/Pages/Index`, `Admin/Pages/Form`, `Admin/Navigation/Index`, `Admin/Themes/Index`, and `Admin/Themes/Editor`; `php artisan make:test --pest` created `Admin/ContentManagementTest` and `Admin/SettingsManagementTest`. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin content/settings/theme/navigation changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin/ContentManagementTest.php` passed: 5 tests, 38 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin/SettingsManagementTest.php` passed: 4 tests, 29 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin` passed after the admin content/settings/theme/navigation changes: 28 tests, 194 assertions. +- 2026-05-04: `php artisan route:list --name=admin.pages`, `--name=admin.navigation`, `--name=admin.themes`, and `--name=admin.settings` confirmed 9 new Livewire admin routes for pages, navigation, themes, and settings. +- 2026-05-04: `npm run build` passed after the admin content/settings/theme/navigation Blade/sidebar changes. +- 2026-05-04: `php artisan test --compact` passed after the admin content/settings/theme/navigation changes: 156 tests, 800 assertions. +- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after browser verification to restore the local SQLite database to seeded fixtures. +- 2026-05-04: `mcp__laravel_boost__.get_absolute_url(path: "/admin/pages")` resolved `http://shop.test/admin/pages` before browser verification. +- 2026-05-04: Playwright MCP verified `http://shop.test/admin/pages`, `/admin/pages/create`, `/admin/navigation`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/themes`, and `/admin/themes/1/editor`; browser smoke saved a page, added/saved a navigation item, rendered settings/shipping/tax pages, rendered the theme editor iframe preview, and current Playwright console checks reported no warnings/errors. Boost `browser_logs` only returned older May 3 account-login warnings from a previously fixed issue. +- 2026-05-04: `mcp__laravel_boost__.application_info` and schema summary reconfirmed Laravel 12.51.0/PHP 8.4/Livewire 4.1.4/Flux 2.12.0/Pest 4.3.2/SQLite and showed search/analytics/app tables were still missing before the search slice. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 search/rate limiting/JSON API/scheduling docs, Livewire 4 forms/testing docs, Flux UI input/table/badge docs, and Pest 4 JSON testing docs before the search changes. +- 2026-05-04: `php artisan make:model`, `make:migration`, `make:class`, `make:observer`, `make:controller`, `make:request`, `make:resource`, `make:livewire`, and `make:test --pest` created the search settings/query models, FTS migration, search service, product observer, storefront search API classes, admin search settings component, and search tests. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the search changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Search/SearchServiceTest.php tests/Feature/Api/StorefrontSearchApiTest.php tests/Feature/Admin/SearchSettingsTest.php` passed: 10 tests, 46 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Api tests/Feature/Search tests/Feature/Admin/SearchSettingsTest.php tests/Feature/Catalog/CatalogUiTest.php` passed: 25 tests, 201 assertions. +- 2026-05-04: `php artisan route:list --path=api/storefront/v1/search --except-vendor` confirmed `GET /api/storefront/v1/search` and `GET /api/storefront/v1/search/suggest`; `php artisan route:list --path=admin/search` confirmed `GET /admin/search/settings`. +- 2026-05-04: `npm run build` passed after the search Blade/sidebar changes. +- 2026-05-04: `php artisan test --compact` passed after the search changes: 166 tests, 846 assertions. +- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after the search changes; Boost query counts after browser smoke: search_settings 2, search_queries 2, products_fts 25. +- 2026-05-04: `mcp__laravel_boost__.get_absolute_url` resolved `http://shop.test/search?q=Cotton`, `http://shop.test/api/storefront/v1/search?q=Cotton`, and `http://shop.test/admin/search/settings` before browser verification. +- 2026-05-04: Playwright MCP verified the SQLite-backed storefront search page shows 5 Cotton results with filters, the storefront search API returns JSON product/facet data for Cotton, and admin search settings renders and rebuilds the index for 20 products; current Playwright console checks reported no warnings/errors. Boost `browser_logs` only returned older May 3 account-login warnings from a previously fixed issue. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 migration/validation/rate limiting/scheduling/aggregation docs, Livewire 4 form/testing docs, Flux UI select/button/table/badge docs, and Pest 4 docs before the analytics changes. +- 2026-05-04: `php artisan make:enum`, `make:model`, `make:seeder`, `make:class`, `make:job`, `make:controller`, `make:request`, `make:livewire`, and `make:test --pest` created the analytics event enum, models, migrations, seeders, service, aggregation job, storefront analytics API classes, admin analytics component, and analytics tests. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` fixed import/order spacing after the analytics changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Analytics/AnalyticsServiceTest.php tests/Feature/Api/StorefrontAnalyticsApiTest.php tests/Feature/Admin/AnalyticsDashboardTest.php` passed: 7 tests, 35 assertions. +- 2026-05-04: `php artisan route:list --path=api/storefront/v1/analytics --except-vendor` confirmed `POST /api/storefront/v1/analytics/events`; `php artisan route:list --path=admin/analytics` confirmed `GET /admin/analytics`. +- 2026-05-04: `php artisan schedule:list` confirmed `App\Jobs\AggregateAnalytics` is scheduled daily at 01:00 UTC. +- 2026-05-04: `php artisan test --compact tests/Feature/Api tests/Feature/Analytics tests/Feature/Admin/AnalyticsDashboardTest.php tests/Feature/Admin/DashboardTest.php` passed: 24 tests, 185 assertions. +- 2026-05-04: `npm run build` passed after the analytics Blade/sidebar changes. +- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after the analytics changes. +- 2026-05-04: `php artisan test --compact` passed after the analytics changes: 173 tests, 881 assertions. +- 2026-05-04: `mcp__laravel_boost__.get_absolute_url` resolved `http://shop.test/admin/analytics` and `http://shop.test/api/storefront/v1/analytics/events` before browser verification. +- 2026-05-04: Playwright MCP verified `http://shop.test/admin/analytics` renders metrics, charts, top referrers, and CSV export; browser fetch to `/api/storefront/v1/analytics/events` returned 202 with 1 accepted event and current Playwright console checks reported no warnings/errors. Boost `browser_logs` only returned older May 3 account-login warnings from a previously fixed issue. +- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after browser verification to restore seeded fixtures; Boost query counts after reset: analytics_daily 60, analytics_events 210, search_settings 2, products_fts 25. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 HTTP client, queue, validation, events, and testing docs plus Livewire 4, Flux UI, and Pest 4 docs before the apps/webhooks changes. +- 2026-05-04: `php artisan make:enum`, `make:model`, `make:seeder`, `make:class`, `make:job`, `make:listener`, `make:livewire`, and `make:test --pest` created the app/webhook enums, models, migrations, seeders, webhook service/job/listener, admin apps/developers components, and tests. +- 2026-05-04: `php artisan test --compact tests/Feature/Webhooks/WebhookSignatureTest.php tests/Feature/Webhooks/WebhookDeliveryTest.php tests/Feature/Admin/AppsDevelopersTest.php` passed: 9 tests, 28 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Webhooks tests/Feature/Admin/AppsDevelopersTest.php tests/Feature/Orders tests/Feature/Catalog/ProductServiceTest.php tests/Feature/Admin/AnalyticsDashboardTest.php` passed: 33 tests, 136 assertions. +- 2026-05-04: `php artisan route:list --path=admin/apps` confirmed `/admin/apps` and `/admin/apps/{installation}`; `php artisan route:list --path=admin/developers` confirmed `/admin/developers`; `php artisan event:list` confirmed `DispatchWebhooks` registered for order, refund, fulfillment, and product status events. +- 2026-05-04: Boost query counts after seed confirmed apps 2, app_installations 4, oauth_clients 4, oauth_tokens 4, webhook_subscriptions 8, and webhook_deliveries 0. +- 2026-05-04: `npm run build` passed after the apps/developers Blade/sidebar changes. +- 2026-05-04: Playwright MCP verified `http://shop.test/admin/apps`, `/admin/apps/1`, and `/admin/developers`; browser smoke generated a one-time API token, created a webhook subscription, and current Playwright console checks reported no warnings/errors. Boost `browser_logs` only returned older May 3 account-login warnings from a previously fixed issue. +- 2026-05-04: `php artisan test --compact` passed after the apps/webhooks changes: 182 tests, 909 assertions. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the apps/webhooks changes. +- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after browser verification and full tests to restore seeded fixtures. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 password reset, notification, validation, and rate limiting docs plus Livewire 4, Flux UI, and Pest 4 docs before the customer password reset gap fix. +- 2026-05-04: `php artisan make:class Services/CustomerPasswordResetService --no-interaction`, `php artisan make:notification CustomerResetPassword --no-interaction`, `php artisan make:livewire Storefront/Account/Auth/ForgotPassword --class --no-interaction`, `php artisan make:livewire Storefront/Account/Auth/ResetPassword --class --no-interaction`, and `php artisan make:test Auth/CustomerPasswordResetTest --pest --no-interaction` created the customer password reset service, notification, Livewire pages, and tests. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` fixed formatting after the customer password reset changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Auth/CustomerPasswordResetTest.php` passed: 5 tests, 31 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Auth/CustomerPasswordResetTest.php tests/Feature/Auth/PasswordResetTest.php tests/Feature/Foundation/CustomerAuthTest.php` passed: 12 tests, 55 assertions. +- 2026-05-04: `php artisan test --compact` passed after the customer password reset changes: 187 tests, 940 assertions. +- 2026-05-04: `php artisan route:list | rg "account/(forgot|reset)| forgot-password | reset-password"` confirmed customer reset routes under `/account/*` and existing Fortify reset routes at the root paths remain registered separately. +- 2026-05-04: `npm run build` passed after the customer password reset Blade changes. +- 2026-05-04: Playwright MCP verified `http://shop.test/account/forgot-password` renders in a fresh browser session, submits an unknown email through Livewire, shows the generic reset-link response, and reports no current console warnings/errors. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Livewire 4 file upload/testing docs, Flux file input docs, Laravel file validation/storage testing docs, and Pest 4 docs before the product media admin UI changes. +- 2026-05-04: `php artisan make:test Admin/ProductMediaManagementTest --pest --no-interaction` created focused product media management coverage. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding product form media upload code. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin/ProductMediaManagementTest.php` passed: 4 tests, 24 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin/ProductMediaManagementTest.php tests/Feature/Catalog/MediaProcessingTest.php tests/Feature/Catalog/CatalogUiTest.php` passed: 9 tests, 81 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin` passed after the product media UI changes: 41 tests, 246 assertions. +- 2026-05-04: `php artisan test --compact` passed after the product media UI changes: 191 tests, 964 assertions. +- 2026-05-04: `npm run build` passed after the product media Blade changes. +- 2026-05-04: `mcp__laravel_boost__.get_absolute_url(path: "/admin/products/1/edit")` resolved `http://shop.test/admin/products/1/edit`; Playwright MCP verified the product edit page renders the Media section and current console checks report no warnings/errors. +- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after product media browser verification to restore seeded fixtures. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 query/JSON testing docs and Pest 4 dataset/testing docs before the discount redemption-history changes. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the discount service/order allocation changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Checkout/PricingServicesTest.php tests/Feature/Orders/OrderServiceTest.php` passed: 11 tests, 63 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Checkout tests/Feature/Orders tests/Feature/Api/StorefrontCheckoutApiTest.php tests/Feature/Storefront/CartCheckoutUiTest.php` passed after the discount redemption-history changes: 33 tests, 196 assertions. +- 2026-05-04: `php artisan test --compact` passed after the discount redemption-history changes: 192 tests, 966 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 query/database docs and Pest 4 testing docs before the search synonym/stop-word parser changes. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the search synonym/stop-word parser changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Search/SearchServiceTest.php` passed after the search synonym/stop-word parser changes: 5 tests, 15 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Search/SearchServiceTest.php tests/Feature/Api/StorefrontSearchApiTest.php tests/Feature/Admin/SearchSettingsTest.php` passed after the search synonym/stop-word parser changes: 12 tests, 50 assertions. +- 2026-05-04: `php artisan test --compact` passed after the search synonym/stop-word parser changes: 194 tests, 970 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 database/JSON testing docs and Pest 4 docs before the automatic discount attribution changes. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the automatic discount attribution changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Orders/OrderServiceTest.php tests/Feature/Checkout/PricingServicesTest.php` passed after the automatic discount attribution changes: 13 tests, 67 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Checkout tests/Feature/Orders tests/Feature/Api/StorefrontCheckoutApiTest.php tests/Feature/Storefront/CartCheckoutUiTest.php` passed after the automatic discount attribution changes: 35 tests, 200 assertions. +- 2026-05-04: `php artisan test --compact` passed after the automatic discount attribution changes: 196 tests, 974 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Livewire 4 full-page/form/testing docs, Flux UI form controls, Laravel 12 validation docs, and Pest 4 docs before the checkout/notification settings changes. +- 2026-05-04: `php artisan make:livewire Admin/Settings/Checkout --class --no-interaction` and `php artisan make:livewire Admin/Settings/Notifications --class --no-interaction` created the new admin settings screens. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` fixed import order after the checkout/notification settings changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin/SettingsManagementTest.php` passed after the checkout/notification settings changes: 6 tests, 54 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin` passed after the checkout/notification settings changes: 43 tests, 271 assertions. +- 2026-05-04: `php artisan route:list --name=admin.settings` confirmed 5 settings routes, including `/admin/settings/checkout` and `/admin/settings/notifications`. +- 2026-05-04: `npm run build` passed after the checkout/notification settings Blade changes. +- 2026-05-04: `php artisan test --compact` passed after the checkout/notification settings changes: 198 tests, 999 assertions. +- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after the checkout/notification settings changes. +- 2026-05-04: `mcp__laravel_boost__.get_absolute_url` resolved `http://shop.test/admin/settings/checkout` and `http://shop.test/admin/settings/notifications` before browser verification. +- 2026-05-04: Playwright MCP verified `http://shop.test/admin/settings/checkout` and `http://shop.test/admin/settings/notifications` render with the full settings tab set; current Playwright console checks reported no warnings/errors. Boost `browser_logs` only returned older May 3 account-login warnings from a previously fixed issue. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Livewire 4 nested array form docs, Laravel 12 relationship sync/validation docs, Flux UI controls, and Pest 4 expectations before the product option matrix changes. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the product option matrix changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Catalog/CatalogUiTest.php` passed after the product option matrix changes and route smoke stabilization: 5 tests, 55 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Catalog tests/Feature/Admin/ProductMediaManagementTest.php` passed after the product option matrix changes: 28 tests, 150 assertions. +- 2026-05-04: `npm run build` passed after the product option matrix Blade change. +- 2026-05-04: `php artisan test --compact` passed after the product option matrix changes: 199 tests, 1010 assertions. +- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after the product option matrix changes. +- 2026-05-04: `mcp__laravel_boost__.get_absolute_url(path: "/admin/products/create")` resolved `http://shop.test/admin/products/create` before browser verification. +- 2026-05-04: Playwright MCP verified `http://shop.test/admin/products/create` renders the product form after the matrix control changes; current Playwright console checks reported no warnings/errors. Boost `browser_logs` only returned older May 3 account-login warnings from a previously fixed issue. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Livewire 4 textarea/form/testing docs, Flux UI textarea/form controls, Laravel 12 JSON settings and route model binding docs, and Pest 4 docs before the theme file editor changes. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the theme file editor changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin/ContentManagementTest.php` passed after the theme file editor changes: 6 tests, 47 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin` passed after the theme file editor changes: 44 tests, 280 assertions. +- 2026-05-04: `npm run build` passed after the theme editor Blade changes. +- 2026-05-04: `php artisan test --compact` passed after the theme file editor changes: 200 tests, 1019 assertions. +- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after the theme file editor changes. +- 2026-05-04: `mcp__laravel_boost__.get_absolute_url(path: "/admin/themes/1/editor")` resolved `http://shop.test/admin/themes/1/editor` before browser verification. +- 2026-05-04: Playwright MCP verified `http://shop.test/admin/themes/1/editor` renders the theme file list and switches to the `sections/hero.blade.php` file editor textarea; current Playwright console checks reported no warnings/errors. Boost `browser_logs` only returned older May 3 account-login warnings from a previously fixed issue. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 policy authorization docs and Pest 4 architecture/testing docs before the policy typing refactor. +- 2026-05-04: `rg "object \\$" app/Policies tests/Feature/Foundation/AuthorizationTest.php` found no remaining generic policy model parameters after the policy typing refactor. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the policy typing refactor. +- 2026-05-04: `php artisan test --compact tests/Feature/Foundation/AuthorizationTest.php` passed after the policy typing refactor: 5 tests, 68 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Foundation` passed after the policy typing refactor: 13 tests, 102 assertions. +- 2026-05-04: `php artisan test --compact` passed after the policy typing refactor: 201 tests, 1075 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 custom guard/middleware and bearer-token authentication docs plus Pest 4 testing docs before the admin API token middleware changes. +- 2026-05-04: `php artisan make:middleware AuthenticateAdminApi --no-interaction` created the admin API token middleware. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` fixed import order after the admin API token middleware changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminOrderApiTest.php` passed after the admin API token middleware changes: 6 tests, 25 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminOrderApiTest.php tests/Feature/Admin/AppsDevelopersTest.php tests/Feature/Webhooks tests/Feature/Orders` passed after the admin API token middleware changes: 32 tests, 143 assertions. +- 2026-05-04: `php artisan route:list --path=api/admin/v1 --except-vendor` confirmed the four admin order API routes remain registered. +- 2026-05-04: `php artisan test --compact` passed after the admin API token middleware changes: 204 tests, 1083 assertions. +- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after the admin API token middleware changes. +- 2026-05-04: Playwright MCP initially confirmed `http://acme-fashion.test/` reached Herd's site-not-found page, then `herd sites | rg "shop|acme"` confirmed only `shop.test` was linked for the project. +- 2026-05-04: `herd link acme-fashion --no-interaction` linked the current project to `http://acme-fashion.test` for the E2E spec host without changing tracked repo files. +- 2026-05-04: Playwright MCP verified `http://acme-fashion.test/` renders the seeded Acme Fashion storefront after the Herd link; current Playwright console checks reported no warnings/errors. Boost `browser_logs` only returned older May 3 account-login warnings from a previously fixed issue. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 nullable foreign-key migration docs, Livewire 4 nested form and `wire:sort` docs, Flux form/select/button docs, Tailwind 4 UI utility docs, and Pest 4 docs before the nested navigation changes. +- 2026-05-04: `php artisan make:migration add_parent_id_to_navigation_items_table --table=navigation_items --no-interaction` created the navigation nesting migration. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the nested navigation changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Storefront/ThemeDataTest.php tests/Feature/Admin/ContentManagementTest.php` passed after the nested navigation changes: 12 tests, 86 assertions. +- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after adding `navigation_items.parent_id`; Boost query count confirmed 21 navigation items with 6 child items. +- 2026-05-04: `npm run build` passed after the nested navigation Blade changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin tests/Feature/Storefront tests/Feature/Catalog` passed after the nested navigation changes: 83 tests, 498 assertions. +- 2026-05-04: `php artisan test --compact` passed after the nested navigation changes: 204 tests, 1091 assertions. +- 2026-05-04: `mcp__laravel_boost__.get_absolute_url(path: "/admin/navigation")` resolved `http://shop.test/admin/navigation` before browser verification. +- 2026-05-04: Playwright MCP verified `http://shop.test/admin/navigation` renders nested menu items and the parent selector, and `http://shop.test/` renders the `Shop` dropdown with collection child links; current Playwright console checks reported no warnings/errors. Boost `browser_logs` only returned older May 3 account-login warnings from a previously fixed issue. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 API resource, route middleware group, bearer token, form request validation, and JSON HTTP testing docs plus Pest 4 docs before the admin catalog API changes. +- 2026-05-04: `php artisan make:controller Api/Admin/V1/ProductController --no-interaction`, `php artisan make:controller Api/Admin/V1/CustomerController --no-interaction`, `php artisan make:resource Admin/V1/ProductResource --no-interaction`, `php artisan make:resource Admin/V1/CustomerResource --no-interaction`, `php artisan make:controller Api/OAuthController --no-interaction`, `php artisan make:controller Api/Apps/V1/DeferredEndpointController --no-interaction`, and `php artisan make:test Api/AdminCatalogApiTest --pest --no-interaction` created the admin catalog API controllers/resources, deferred app/OAuth controllers, and tests. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin catalog API changes. +- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 8 admin API routes: product/customer/order list/detail plus order fulfillment/refund mutations. +- 2026-05-04: `php artisan route:list --path=oauth --except-vendor` confirmed `/oauth/authorize` and `/oauth/token`; `php artisan route:list --path=api/apps/v1 --except-vendor` confirmed 3 deferred app API routes. +- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminCatalogApiTest.php` passed after the admin catalog API changes: 5 tests, 34 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin catalog API changes: 25 tests, 194 assertions. +- 2026-05-04: `php artisan test --compact` passed after the admin catalog API changes: 209 tests, 1125 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest 4 browser testing, smoke testing, `visit`, viewport, Laravel browser testing, and JavaScript-error assertion docs before adding automated browser smoke coverage. +- 2026-05-04: `php artisan make:test ../Browser/SmokeTest --pest --no-interaction` created the Pest browser smoke test file at `tests/Browser/SmokeTest.php`; `tests/Pest.php` now binds `tests/Browser` to the Laravel test case, `phpunit.xml` includes the Browser suite in the default test run, and `.gitignore` excludes Pest browser screenshots. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the browser smoke test changes. +- 2026-05-04: `php artisan test --compact tests/Browser/SmokeTest.php` passed after adding automated browser smoke coverage: 3 tests, 26 assertions. +- 2026-05-04: `php artisan test --compact` passed with the Browser suite included in the default run after adding automated browser smoke coverage: 212 tests, 1151 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 API resource controller, route group middleware, scoped exists validation, and JSON API testing docs plus Pest 4 docs before the admin collection API changes. +- 2026-05-04: `php artisan make:controller Api/Admin/V1/CollectionController --no-interaction`, `php artisan make:resource Admin/V1/CollectionResource --no-interaction`, and `php artisan make:test Api/AdminCollectionApiTest --pest --no-interaction` created the collection API controller/resource and tests. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin collection API changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminCollectionApiTest.php` passed after the admin collection API changes: 3 tests, 24 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin collection API changes: 28 tests, 218 assertions. +- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 12 admin API routes, including collection list/create/update/delete. +- 2026-05-04: `php artisan test --compact` passed after the admin collection API changes: 215 tests, 1175 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 validation, date validation, prohibited/unique update-field validation, JSON API testing, and Pest 4 docs before the admin discount API changes. +- 2026-05-04: `php artisan make:controller Api/Admin/V1/DiscountController --no-interaction`, `php artisan make:resource Admin/V1/DiscountResource --no-interaction`, and `php artisan make:test Api/AdminDiscountApiTest --pest --no-interaction` created the discount API controller/resource and tests. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin discount API changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminDiscountApiTest.php` passed after the admin discount API changes: 3 tests, 25 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin discount API changes: 31 tests, 243 assertions. +- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 16 admin API routes, including discount list/create/update/delete. +- 2026-05-04: `php artisan test --compact` passed after the admin discount API changes: 218 tests, 1200 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 scoped unique validation, route model binding, API resources/pagination, JSON API validation testing, and Pest 4 docs before the admin page API changes. +- 2026-05-04: `php artisan make:controller Api/Admin/V1/PageController --no-interaction`, `php artisan make:resource Admin/V1/PageResource --no-interaction`, and `php artisan make:test Api/AdminPageApiTest --pest --no-interaction` created the content page API controller/resource and tests. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin page API changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminPageApiTest.php` passed after the admin page API changes: 3 tests, 26 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin page API changes: 34 tests, 269 assertions. +- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 20 admin API routes, including page list/create/update/delete. +- 2026-05-04: `php artisan test --compact` passed after the admin page API changes: 221 tests, 1226 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 JSON responses, 202 Accepted response testing, controller DI, database query counting, and route middleware docs before the admin search index API changes. +- 2026-05-04: `php artisan make:controller Api/Admin/V1/SearchIndexController --no-interaction` and `php artisan make:test Api/AdminSearchIndexApiTest --pest --no-interaction` created the search index API controller and tests. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin search index API changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminSearchIndexApiTest.php` passed after the admin search index API changes: 2 tests, 16 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin search index API changes: 36 tests, 285 assertions. +- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 22 admin API routes, including search status and reindex. +- 2026-05-04: `php artisan test --compact` passed after the admin search index API changes: 223 tests, 1242 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 date validation, JSON API testing, query aggregates, and validated input docs before the admin analytics summary API changes. +- 2026-05-04: `php artisan make:controller Api/Admin/V1/AnalyticsSummaryController --no-interaction` and `php artisan make:test Api/AdminAnalyticsSummaryApiTest --pest --no-interaction` created the analytics summary API controller and tests. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin analytics summary API changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminAnalyticsSummaryApiTest.php` passed after the admin analytics summary API changes: 3 tests, 26 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin analytics summary API changes: 39 tests, 311 assertions. +- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 23 admin API routes, including analytics summary. +- 2026-05-04: `php artisan test --compact` passed after the admin analytics summary API changes: 226 tests, 1268 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 array/boolean validation, update-or-create patterns, JSON API assertions, and Pest 4 docs before the admin tax settings API changes. +- 2026-05-04: `php artisan make:controller Api/Admin/V1/TaxSettingsController --no-interaction`, `php artisan make:resource Admin/V1/TaxSettingsResource --no-interaction`, and `php artisan make:test Api/AdminTaxSettingsApiTest --pest --no-interaction` created the tax settings API controller/resource and tests. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin tax settings API changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminTaxSettingsApiTest.php` passed after the admin tax settings API changes: 3 tests, 26 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin tax settings API changes: 42 tests, 337 assertions. +- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 25 admin API routes, including tax settings read/update. +- 2026-05-04: `php artisan test --compact` passed after the admin tax settings API changes: 229 tests, 1294 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 nested array validation, scoped nested resources, database transactions, JSON API assertions, and Pest 4 docs before the admin shipping settings API changes. +- 2026-05-04: `php artisan make:controller Api/Admin/V1/ShippingZoneController --no-interaction`, `php artisan make:controller Api/Admin/V1/ShippingRateController --no-interaction`, `php artisan make:resource Admin/V1/ShippingZoneResource --no-interaction`, `php artisan make:resource Admin/V1/ShippingRateResource --no-interaction`, and `php artisan make:test Api/AdminShippingSettingsApiTest --pest --no-interaction` created the shipping settings API controllers/resources and tests. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin shipping settings API changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminShippingSettingsApiTest.php` passed after the admin shipping settings API changes: 3 tests, 27 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin shipping settings API changes: 45 tests, 364 assertions. +- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 29 admin API routes, including shipping zone list/create/update and rate create. +- 2026-05-04: `php artisan test --compact` passed after the admin shipping settings API changes: 232 tests, 1321 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 file upload validation, scoped route model binding, JSON API testing, and Pest 4 docs before the admin theme API changes. +- 2026-05-04: `php artisan make:class Services/ThemeArchiveInstaller --no-interaction`, `php artisan make:controller Api/Admin/V1/ThemeController --no-interaction`, `php artisan make:controller Api/Admin/V1/ThemeSettingsController --no-interaction`, `php artisan make:resource Admin/V1/ThemeResource --no-interaction`, and `php artisan make:test Api/AdminThemeApiTest --pest --no-interaction` created the theme archive installer, API controllers/resource, and tests. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin theme API changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminThemeApiTest.php` passed after the admin theme API changes: 4 tests, 29 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin theme API changes: 49 tests, 393 assertions. +- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores/{store}/themes --except-vendor` confirmed 3 theme API routes: upload/install, publish, and settings update. +- 2026-05-04: `php artisan test --compact` passed after the admin theme API changes: 236 tests, 1350 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 nested JSON array validation, update-or-create settings patterns, JSON API validation assertions, and Pest 4 docs before the admin store settings API changes. +- 2026-05-04: `php artisan make:controller Api/Admin/V1/StoreSettingsController --no-interaction`, `php artisan make:resource Admin/V1/StoreSettingsResource --no-interaction`, and `php artisan make:test Api/AdminStoreSettingsApiTest --pest --no-interaction` created the general store settings API controller/resource and tests. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin store settings API changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminStoreSettingsApiTest.php` passed after the admin store settings API changes: 3 tests, 35 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminStoreSettingsApiTest.php tests/Feature/Api/AdminThemeApiTest.php` passed after the admin store settings API changes: 7 tests, 64 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin store settings API changes: 52 tests, 428 assertions. +- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores/{store}/settings --except-vendor` confirmed 2 general settings API routes: read and update. +- 2026-05-04: `php artisan test --compact` passed after the admin store settings API changes: 239 tests, 1385 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 streamed/download responses, local storage behavior, model/migration/factory generation, and Pest 4 docs before the admin order export API changes. +- 2026-05-04: `php artisan make:enum ExportStatus --no-interaction`, `php artisan make:model DataExport --migration --factory --no-interaction`, `php artisan make:class Services/OrderExportService --no-interaction`, `php artisan make:controller Api/Admin/V1/OrderExportController --no-interaction`, `php artisan make:resource Admin/V1/DataExportResource --no-interaction`, and `php artisan make:test Api/AdminOrderExportApiTest --pest --no-interaction` created the export status enum, durable export model/table, export service, API controller/resource, and tests. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin order export API changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminOrderExportApiTest.php` passed after the admin order export API changes: 3 tests, 23 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminOrderExportApiTest.php tests/Feature/Api/AdminOrderApiTest.php` passed after the admin order export API changes: 9 tests, 48 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin order export API changes: 55 tests, 451 assertions. +- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores/{store}/exports --except-vendor` confirmed 2 order export API routes: create and status. +- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after the admin order export API changes and confirmed the `data_exports` migration runs with the seed suite. +- 2026-05-04: `php artisan test --compact` passed after the admin order export API changes: 242 tests, 1408 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 database inspection and Pest 4 database assertion docs before adding SQLite CHECK constraint coverage. +- 2026-05-04: `php artisan make:test Foundation/DatabaseConstraintTest --pest --no-interaction` created representative database constraint coverage. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding database constraint coverage. +- 2026-05-04: `php artisan test --compact tests/Feature/Foundation/DatabaseConstraintTest.php` passed: 1 test, 6 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Foundation` passed after adding database constraint coverage: 14 tests, 108 assertions. +- 2026-05-04: `php artisan test --compact` passed after adding database constraint coverage: 243 tests, 1414 assertions. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after expanding automated browser smoke coverage. +- 2026-05-04: `php artisan test --compact tests/Browser/SmokeTest.php` passed after expanding browser coverage to storefront account auth/reset pages and the full admin navigation surface: 4 tests, 62 assertions. +- 2026-05-04: `php artisan test --compact` passed after expanding browser smoke coverage: 244 tests, 1450 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Fortify password reset view/custom route docs before adding admin password reset aliases. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding admin password reset aliases. +- 2026-05-04: `php artisan test --compact tests/Feature/Auth/PasswordResetTest.php tests/Feature/Auth/CustomerPasswordResetTest.php` passed after adding admin password reset aliases: 10 tests, 45 assertions. +- 2026-05-04: `php artisan route:list --path=admin/forgot-password` and `php artisan route:list --path=admin/reset-password` confirmed the admin forgot-password and reset-password GET/POST routes. +- 2026-05-04: `php artisan test --compact tests/Feature/Auth` passed after adding admin password reset aliases: 24 tests, 78 assertions. +- 2026-05-04: `php artisan test --compact` passed after adding admin password reset aliases: 245 tests, 1456 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Fortify password reset route/view docs, Livewire full-page route docs, and Pest browser testing docs before moving customer password reset to the spec root paths. +- 2026-05-04: `php artisan make:controller Storefront/Account/Auth/CustomerPasswordResetController --no-interaction`, `php artisan make:request Storefront/Auth/RequestCustomerPasswordResetRequest --no-interaction`, and `php artisan make:request Storefront/Auth/ResetCustomerPasswordRequest --no-interaction` created the standard POST route handlers and validation requests for customer password reset. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the customer password reset route changes. +- 2026-05-04: `php artisan route:list` confirmed customer reset routes at `/forgot-password`, `POST /forgot-password`, `/reset-password/{token}`, and `POST /reset-password`, while Fortify's generic user reset routes remain available under `/user/forgot-password` and `/user/reset-password/{token}`. +- 2026-05-04: `php artisan test --compact tests/Feature/Auth/CustomerPasswordResetTest.php tests/Feature/Auth/PasswordResetTest.php` passed after moving customer reset to the spec root paths: 11 tests, 58 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser/SmokeTest.php` passed after adding browser smoke coverage for the customer root reset pages and account aliases: 4 tests, 66 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Auth` passed after moving customer reset to the spec root paths: 25 tests, 91 assertions. +- 2026-05-04: `php artisan test --compact` passed after moving customer reset to the spec root paths: 246 tests, 1473 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser testing and Laravel auth/session testing docs before adding the Spec 08 admin authentication browser suite. +- 2026-05-04: `php artisan make:test Browser/Admin/AuthenticationTest --pest --no-interaction` created the admin authentication browser test, which was moved into the Spec 08 browser directory structure. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding the admin authentication browser suite and admin logout route fix. +- 2026-05-04: `php artisan test --compact tests/Browser/Admin/AuthenticationTest.php` passed for the new Suite 2 admin auth browser coverage: 10 tests, 54 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser/SmokeTest.php tests/Browser/Admin/AuthenticationTest.php` passed after adding the admin auth suite: 14 tests, 120 assertions. +- 2026-05-04: `php artisan test --compact` passed after adding the admin auth browser suite: 256 tests, 1527 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser testing and Laravel database seeding docs before adding the Spec 08 storefront browsing browser suite. +- 2026-05-04: `php artisan make:test Browser/Storefront/BrowsingTest --pest --no-interaction` created the storefront browsing browser test, which was moved into the Spec 08 browser directory structure. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding the storefront browsing browser suite. +- 2026-05-04: `php artisan test --compact tests/Browser/Storefront/BrowsingTest.php` passed for the new Suite 7 storefront browsing coverage: 15 tests, 56 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser/SmokeTest.php tests/Browser/Admin/AuthenticationTest.php tests/Browser/Storefront/BrowsingTest.php` passed after adding the storefront browsing suite: 29 tests, 176 assertions. +- 2026-05-04: `php artisan test --compact` passed after adding the storefront browsing browser suite: 271 tests, 1583 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser testing, Livewire form submission, Flux input, and Laravel database seeding docs before adding the Spec 08 cart-flow browser suite and aligning discount fixtures. +- 2026-05-04: `php artisan make:test Browser/Storefront/CartTest --pest --no-interaction` created the cart-flow browser test, which was moved into the Spec 08 browser directory structure. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding the cart browser suite, spec discount fixtures, and cart/checkout copy updates. +- 2026-05-04: `php artisan test --compact tests/Browser/Storefront/CartTest.php` passed for the new Suite 8 cart-flow browser coverage: 12 tests, 53 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Storefront/CartCheckoutUiTest.php tests/Feature/Api/StorefrontCheckoutApiTest.php tests/Feature/Checkout/PricingServicesTest.php` passed after aligning discount fixtures and cart/checkout copy: 13 tests, 91 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser/SmokeTest.php tests/Browser/Admin/AuthenticationTest.php tests/Browser/Storefront/BrowsingTest.php tests/Browser/Storefront/CartTest.php` passed after adding the cart suite: 41 tests, 229 assertions. +- 2026-05-04: `php artisan test --compact` passed after adding the cart-flow browser suite: 283 tests, 1636 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser testing, Livewire form validation/submission, and Laravel database seeding docs before adding the Spec 08 checkout-flow browser suite and aligning shipping/tax fixtures. +- 2026-05-04: `php artisan make:test Browser/Storefront/CheckoutTest --pest --no-interaction` created the checkout-flow browser test, which was moved into the Spec 08 browser directory structure. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding the checkout browser suite, domestic/international shipping fixtures, tax-inclusive storefront defaults, and checkout payment/confirmation UI updates. +- 2026-05-04: `php artisan test --compact tests/Feature/Storefront/CartCheckoutUiTest.php tests/Feature/Api/StorefrontCheckoutApiTest.php tests/Feature/Api/AdminShippingSettingsApiTest.php tests/Feature/Api/AdminTaxSettingsApiTest.php` passed after the shipping/tax checkout changes: 13 tests, 119 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser/Storefront/CheckoutTest.php` passed for the new Suite 9 checkout-flow browser coverage: 13 tests, 90 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser/SmokeTest.php tests/Browser/Admin/AuthenticationTest.php tests/Browser/Storefront/BrowsingTest.php tests/Browser/Storefront/CartTest.php tests/Browser/Storefront/CheckoutTest.php` passed after adding the checkout suite: 54 tests, 319 assertions. +- 2026-05-04: `php artisan test --compact` reached the expanded browser section but failed in Pest's browser WebSocket client at PHP's 128 MB memory limit; `php -d memory_limit=512M vendor/bin/pest --compact` passed the full suite after the checkout-flow browser suite: 296 tests, 1726 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser testing, Laravel customer-guard auth/session logout, Livewire validation/form submission, Flux form/button/select, and Tailwind/Flux dark-mode docs before adding the Spec 08 customer-account browser suite and address book UI. +- 2026-05-04: `php artisan make:livewire Storefront/Account/Addresses/Index --class --no-interaction`, `php artisan make:test Storefront/CustomerAccountTest --pest --no-interaction`, and `php artisan make:test Browser/Storefront/CustomerAccountTest --pest --no-interaction` created the customer address component plus focused feature and browser suites. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding customer account routes, address book UI, seeded default customer address, and Suite 10 browser tests. +- 2026-05-04: `php artisan test --compact tests/Feature/Storefront/CustomerAccountTest.php tests/Feature/Storefront/OrderViewsTest.php tests/Feature/Foundation/CustomerAuthTest.php` passed after customer account changes: 13 tests, 69 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser/Storefront/CustomerAccountTest.php` passed for the new Suite 10 customer-account browser coverage: 12 tests, 67 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser/SmokeTest.php tests/Browser/Admin/AuthenticationTest.php tests/Browser/Storefront/BrowsingTest.php tests/Browser/Storefront/CartTest.php tests/Browser/Storefront/CheckoutTest.php tests/Browser/Storefront/CustomerAccountTest.php` passed after adding Suite 10: 66 tests, 386 assertions. +- 2026-05-04: `php -d memory_limit=512M vendor/bin/pest --compact` passed the full suite after Suite 10: 314 tests, 1823 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser testing, Livewire form action/validation testing, and Laravel transaction/testing docs before adding the Spec 08 inventory browser suite and cart stock-limit feedback. +- 2026-05-04: `php artisan make:test Browser/Storefront/InventoryTest --pest --no-interaction` created the inventory browser test, which was moved into the Spec 08 browser directory structure. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding cart stock-limit feedback and the inventory browser suite. +- 2026-05-04: `php artisan test --compact tests/Feature/Storefront/CartCheckoutUiTest.php tests/Feature/Cart/CartServiceTest.php tests/Feature/Catalog/InventoryServiceTest.php` passed after the inventory slice: 11 tests, 60 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser/Storefront/InventoryTest.php` passed for the new Suite 11 inventory browser coverage: 4 tests, 21 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser/SmokeTest.php tests/Browser/Admin/AuthenticationTest.php tests/Browser/Storefront/BrowsingTest.php tests/Browser/Storefront/CartTest.php tests/Browser/Storefront/CheckoutTest.php tests/Browser/Storefront/CustomerAccountTest.php tests/Browser/Storefront/InventoryTest.php` passed after adding Suite 11: 70 tests, 407 assertions. +- 2026-05-04: `php -d memory_limit=512M vendor/bin/pest --compact` passed the full suite after Suite 11: 319 tests, 1846 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser testing, host/session auth testing, and Laravel database testing docs before adding the Spec 08 tenant-isolation browser suite. +- 2026-05-04: `php artisan make:test Browser/Storefront/TenantIsolationTest --pest --no-interaction` created the tenant-isolation browser test, which was moved into the Spec 08 browser directory structure. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding the tenant-isolation browser suite. +- 2026-05-04: `php artisan test --compact tests/Browser/Storefront/TenantIsolationTest.php` passed for the new Suite 12 tenant-isolation browser coverage: 5 tests, 37 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser/SmokeTest.php tests/Browser/Admin/AuthenticationTest.php tests/Browser/Storefront/BrowsingTest.php tests/Browser/Storefront/CartTest.php tests/Browser/Storefront/CheckoutTest.php tests/Browser/Storefront/CustomerAccountTest.php tests/Browser/Storefront/InventoryTest.php tests/Browser/Storefront/TenantIsolationTest.php` passed after adding Suite 12: 75 tests, 444 assertions. +- 2026-05-04: `php -d memory_limit=512M vendor/bin/pest --compact` passed the full suite after Suite 12: 324 tests, 1883 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Flux modal, Pest browser viewport, and Tailwind responsive docs before adding the Spec 08 responsive/mobile browser suite and storefront mobile navigation flyout. +- 2026-05-04: `php artisan make:test Browser/Storefront/ResponsiveTest --pest --no-interaction` created the responsive browser test, which was moved into the Spec 08 browser directory structure. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding the responsive browser suite. +- 2026-05-04: `php artisan test --compact tests/Browser/Storefront/ResponsiveTest.php` passed for the new Suite 13 responsive/mobile browser coverage: 8 tests, 46 assertions. +- 2026-05-04: `npm run build` passed after the storefront mobile navigation Blade/Tailwind changes. +- 2026-05-04: `php artisan test --compact tests/Browser/SmokeTest.php tests/Browser/Admin/AuthenticationTest.php tests/Browser/Storefront/BrowsingTest.php tests/Browser/Storefront/CartTest.php tests/Browser/Storefront/CheckoutTest.php tests/Browser/Storefront/CustomerAccountTest.php tests/Browser/Storefront/InventoryTest.php tests/Browser/Storefront/TenantIsolationTest.php tests/Browser/Storefront/ResponsiveTest.php` passed after adding Suite 13: 83 tests, 490 assertions. +- 2026-05-04: `php -d memory_limit=512M vendor/bin/pest --compact` passed the full suite after Suite 13: 332 tests, 1929 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser accessibility/ARIA/keyboard docs and Livewire/Flux form-label docs before adding the Spec 08 accessibility browser suite and related Blade accessibility fixes. +- 2026-05-04: `php artisan make:test Browser/Storefront/AccessibilityTest --pest --no-interaction` created the accessibility browser test, which was moved into the Spec 08 browser directory structure. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding the accessibility browser suite and Blade accessibility fixes. +- 2026-05-04: `php artisan test --compact tests/Browser/Storefront/AccessibilityTest.php` passed for the new Suite 14 accessibility browser coverage: 11 tests, 42 assertions. +- 2026-05-04: `npm run build` passed after the storefront accessibility Blade changes. +- 2026-05-04: `php artisan test --compact tests/Browser/SmokeTest.php tests/Browser/Admin/AuthenticationTest.php tests/Browser/Storefront/BrowsingTest.php tests/Browser/Storefront/CartTest.php tests/Browser/Storefront/CheckoutTest.php tests/Browser/Storefront/CustomerAccountTest.php tests/Browser/Storefront/InventoryTest.php tests/Browser/Storefront/TenantIsolationTest.php tests/Browser/Storefront/ResponsiveTest.php tests/Browser/Storefront/AccessibilityTest.php` passed after adding Suite 14: 94 tests, 532 assertions. +- 2026-05-04: `php -d memory_limit=512M vendor/bin/pest --compact` passed the full suite after Suite 14: 343 tests, 1971 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser testing, Livewire form/file-upload docs, Flux input/select/button/table docs, and Laravel browser/session testing docs before adding the Spec 08 admin product-management browser suite. +- 2026-05-04: `php artisan make:test --pest Browser/Admin/ProductManagementTest --no-interaction` created the Suite 3 product-management browser test, which was moved into the Spec 08 browser directory structure. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding Suite 3 and the product form save-button test marker. +- 2026-05-04: `php artisan test --compact tests/Browser/Admin/ProductManagementTest.php` passed for the new Suite 3 admin product-management browser coverage: 7 tests, 98 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser` passed after adding Suite 3: 101 tests, 630 assertions. +- 2026-05-04: `php -d memory_limit=512M vendor/bin/pest --compact` passed the full suite after Suite 3: 350 tests, 2069 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser testing, Livewire modal/form/action docs, Flux modal/button/callout docs, Laravel factories/session testing docs, and project order feature tests before adding the Spec 08 admin order-management browser suite. +- 2026-05-04: `php artisan make:test --pest Browser/Admin/OrderManagementTest --no-interaction` created the Suite 4 order-management browser test, which was moved into the Spec 08 browser directory structure. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding Suite 4 and the order-detail UI action messages/timeline/fulfillment guard. +- 2026-05-04: `php artisan test --compact tests/Browser/Admin/OrderManagementTest.php` passed for the new Suite 4 admin order-management browser coverage: 11 tests, 146 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin/OrderManagementTest.php tests/Feature/Orders` passed after the order-detail UI changes: 22 tests, 120 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser/Admin/OrderManagementTest.php tests/Browser/Admin/ProductManagementTest.php` passed after hardening authenticated admin browser entry points: 18 tests, 190 assertions. +- 2026-05-04: `npm run build` passed after the order-detail Blade changes. +- 2026-05-04: `php artisan test --compact tests/Browser` passed after adding Suite 4: 112 tests, 722 assertions. +- 2026-05-04: `php -d memory_limit=512M vendor/bin/pest --compact` passed the full suite after Suite 4: 361 tests, 2161 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser testing, Livewire form validation, Flux modal/input/select/button, and Laravel database factory/session testing docs before adding Suite 5 admin discount-management browser coverage. +- 2026-05-04: `php artisan make:test --pest Browser/Admin/DiscountManagementTest --no-interaction` created the Suite 5 discount-management browser test, which was moved into the Spec 08 browser directory structure. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding Suite 5 and the discount form save hook. +- 2026-05-04: `php artisan test --compact tests/Browser/Admin/DiscountManagementTest.php` passed for the new Suite 5 admin discount-management browser coverage: 6 tests, 55 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin/DiscountManagementTest.php` passed after the discount browser slice: 8 tests, 58 assertions. +- 2026-05-04: `npm run build` passed after the discount form Blade save-hook change. +- 2026-05-04: `php artisan test --compact tests/Browser/Admin/DiscountManagementTest.php tests/Browser/Admin/ProductManagementTest.php tests/Browser/Admin/OrderManagementTest.php` passed the combined admin management browser subset: 24 tests, 245 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser` passed after adding Suite 5: 118 tests, 777 assertions. +- 2026-05-04: `php -d memory_limit=512M vendor/bin/pest --compact` passed the full suite after Suite 5: 367 tests, 2216 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser testing, Livewire settings form validation, Flux input/select/switch/button, and Laravel authenticated session testing docs before adding Suite 6 admin settings browser coverage. +- 2026-05-04: `php artisan make:test --pest Browser/Admin/SettingsTest --no-interaction` created the Suite 6 settings browser test, which was moved into the Spec 08 browser directory structure. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding Suite 6 and the settings form/rate/tax save hooks. +- 2026-05-04: `php artisan test --compact tests/Browser/Admin/SettingsTest.php` passed for the new Suite 6 admin settings browser coverage: 7 tests, 58 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin/SettingsManagementTest.php` passed after the settings browser slice: 6 tests, 54 assertions. +- 2026-05-04: `npm run build` passed after the settings Blade hook and heading changes. +- 2026-05-04: `php artisan test --compact tests/Browser/Admin/SettingsTest.php tests/Browser/Admin/DiscountManagementTest.php tests/Browser/Admin/ProductManagementTest.php tests/Browser/Admin/OrderManagementTest.php` passed the combined admin management browser subset: 31 tests, 303 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser` passed after adding Suite 6: 125 tests, 835 assertions. +- 2026-05-04: `php -d memory_limit=512M vendor/bin/pest --compact` passed the full suite after Suite 6: 374 tests, 2274 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser testing, Livewire form/search/selection docs, Flux input/select/checkbox/button docs, and Laravel factory/browser testing docs before adding Suite 15 admin collections browser coverage. +- 2026-05-04: `php artisan make:test --pest Browser/Admin/CollectionManagementTest --no-interaction` created the Suite 15 collection-management browser test, which was moved into the Spec 08 browser directory structure. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding Suite 15, collection form save feedback, and the product status-filter pagination hardening. +- 2026-05-04: `php artisan test --compact tests/Browser/Admin/CollectionManagementTest.php` passed for the new Suite 15 admin collection-management browser coverage: 3 tests, 27 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin/ContentManagementTest.php` passed after the collection browser slice: 6 tests, 52 assertions. +- 2026-05-04: `npm run build` passed after the collection admin Blade changes. +- 2026-05-04: `php artisan test --compact tests/Browser/Admin/ProductManagementTest.php tests/Browser/Admin/CollectionManagementTest.php` passed after hardening the product active-filter assertion: 10 tests, 104 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser/Admin/CollectionManagementTest.php tests/Browser/Admin/SettingsTest.php tests/Browser/Admin/DiscountManagementTest.php tests/Browser/Admin/ProductManagementTest.php tests/Browser/Admin/OrderManagementTest.php` passed the combined admin management browser subset: 34 tests, 330 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser` passed after adding Suite 15: 128 tests, 862 assertions. +- 2026-05-04: `php -d memory_limit=512M vendor/bin/pest --compact` passed the full suite after Suite 15: 377 tests, 2301 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser testing, Livewire pagination/relationship docs, Flux/Blade attribute docs, and Laravel relationship docs before adding Suite 16 admin customer-management browser coverage. +- 2026-05-04: `php artisan make:test --pest Browser/Admin/CustomerManagementTest --no-interaction` created the Suite 16 customer-management browser test, which was moved into the Spec 08 browser directory structure. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding Suite 16. +- 2026-05-04: `php artisan test --compact tests/Browser/Admin/CustomerManagementTest.php` passed for the new Suite 16 admin customer-management browser coverage: 3 tests, 23 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin/CustomerManagementTest.php tests/Feature/Storefront/CustomerAccountTest.php tests/Feature/Storefront/OrderViewsTest.php` passed after the customer browser slice: 14 tests, 79 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser/Admin/CustomerManagementTest.php tests/Browser/Admin/CollectionManagementTest.php tests/Browser/Admin/SettingsTest.php tests/Browser/Admin/DiscountManagementTest.php tests/Browser/Admin/ProductManagementTest.php tests/Browser/Admin/OrderManagementTest.php` passed the combined admin management browser subset: 37 tests, 353 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser` passed after adding Suite 16: 131 tests, 885 assertions. +- 2026-05-04: `php -d memory_limit=512M vendor/bin/pest --compact` passed the full suite after Suite 16: 380 tests, 2324 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser testing, Livewire form submission/validation docs, Flux textarea/input/button docs, and Laravel session/browser testing docs before adding Suite 17 admin pages browser coverage. +- 2026-05-04: `php artisan make:test --pest Browser/Admin/PageManagementTest --no-interaction` created the Suite 17 page-management browser test, which was moved into the Spec 08 browser directory structure. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding Suite 17, the page form save hook, and visible page-save action feedback. +- 2026-05-04: `php artisan test --compact tests/Browser/Admin/PageManagementTest.php` passed for the new Suite 17 admin page-management browser coverage: 3 tests, 21 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin/ContentManagementTest.php` passed after the page browser slice: 6 tests, 52 assertions. +- 2026-05-04: `npm run build` passed after the page admin Blade changes. +- 2026-05-04: `php artisan test --compact tests/Browser/Admin/PageManagementTest.php tests/Browser/Admin/CustomerManagementTest.php tests/Browser/Admin/CollectionManagementTest.php tests/Browser/Admin/SettingsTest.php tests/Browser/Admin/DiscountManagementTest.php tests/Browser/Admin/ProductManagementTest.php tests/Browser/Admin/OrderManagementTest.php` passed the combined admin management browser subset: 40 tests, 374 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser` passed after adding Suite 17: 134 tests, 906 assertions. +- 2026-05-04: `php -d memory_limit=512M vendor/bin/pest --compact` passed the full suite after Suite 17: 383 tests, 2345 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser navigation/assertion docs, Livewire dashboard/session docs, and Laravel view rendering docs before adding Suite 18 admin analytics browser coverage. +- 2026-05-04: `php artisan make:test --pest Browser/Admin/AnalyticsTest --no-interaction` created the Suite 18 analytics browser test, which was moved into the Spec 08 browser directory structure. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding Suite 18 and the analytics dashboard conversion-funnel panel. +- 2026-05-04: `php artisan test --compact tests/Browser/Admin/AnalyticsTest.php` passed for the new Suite 18 admin analytics browser coverage: 3 tests, 22 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Admin/AnalyticsDashboardTest.php tests/Feature/Analytics/AnalyticsServiceTest.php tests/Feature/Api/AdminAnalyticsSummaryApiTest.php` passed after the analytics browser slice: 8 tests, 47 assertions. +- 2026-05-04: `npm run build` passed after the analytics Blade changes. +- 2026-05-04: `php artisan test --compact tests/Browser/Admin/AnalyticsTest.php tests/Browser/Admin/PageManagementTest.php tests/Browser/Admin/CustomerManagementTest.php tests/Browser/Admin/CollectionManagementTest.php tests/Browser/Admin/SettingsTest.php tests/Browser/Admin/DiscountManagementTest.php tests/Browser/Admin/ProductManagementTest.php tests/Browser/Admin/OrderManagementTest.php` passed the combined admin management browser subset: 43 tests, 396 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser` passed after adding Suite 18: 137 tests, 928 assertions. +- 2026-05-04: `php -d memory_limit=512M vendor/bin/pest --compact` passed the full suite after Suite 18: 386 tests, 2368 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser multi-page visit and Laravel authenticated-session docs before aligning Suite 1 smoke coverage to the 10-test Spec 08 shape. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after splitting the browser smoke suite into 10 named tests. +- 2026-05-04: `php artisan test --compact tests/Browser/SmokeTest.php` passed after the Suite 1 count alignment: 10 tests, 86 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser` passed with all 18 Spec 08 browser files and the expected total count: 143 tests, 948 assertions. +- 2026-05-04: `php -d memory_limit=512M vendor/bin/pest --compact` passed the full suite after the Suite 1 count alignment: 392 tests, 2388 assertions. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 seeding/database-testing docs and Pest 4 testing docs before completing the seed/test-data slice. +- 2026-05-04: `php artisan make:test --pest Seeders/SeededOrderDataTest --no-interaction` created focused seeded customer/order/payment/fulfillment/refund coverage. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding deterministic customer/address/order/payment/fulfillment/refund seed data and updating fixture-dependent tests. +- 2026-05-04: `php artisan test --compact tests/Feature/Seeders/SeededOrderDataTest.php` passed for the seed-data coverage: 1 test, 32 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Seeders/SeededOrderDataTest.php tests/Feature/Storefront/CustomerAccountTest.php tests/Feature/Storefront/OrderViewsTest.php tests/Feature/Admin/DashboardTest.php tests/Feature/Admin/CustomerManagementTest.php tests/Feature/Admin/OrderManagementTest.php tests/Feature/Api/StorefrontOrderApiTest.php` passed after replacing ad hoc fixed-order fixtures with seeded data: 24 tests, 176 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature` passed after the seed-data slice: 249 tests, 1472 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser/Admin/OrderManagementTest.php` passed after switching the admin order browser suite to seeded `#1001`, `#1002`, and `#1005`: 11 tests, 113 assertions. +- 2026-05-04: `php artisan test --compact tests/Browser` passed after seeded order/payment data was added to `DatabaseSeeder`: 143 tests, 948 assertions. +- 2026-05-04: `php -d memory_limit=512M vendor/bin/pest --compact` passed the full suite after the seed-data slice: 393 tests, 2421 assertions. +- 2026-05-04: Final audit found the Spec 02 admin product write API gap; `mcp__laravel_boost__.search_docs` consulted Laravel 12 FormRequest/API resource/JSON API testing and filesystem upload URL docs plus Pest 4 docs before closing it. +- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed the admin product API now includes `POST /products`, `PUT /products/{product}`, `DELETE /products/{product}`, and `POST /products/{product}/media/presign-upload` alongside the read routes. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin product API write-route changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminCatalogApiTest.php` passed after adding product create/update/archive/media-presign/token-ability coverage: 7 tests, 63 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin product API write-route changes: 57 tests, 480 assertions. +- 2026-05-04: Final audit found the remaining Spec 02 platform/membership API route gap; `mcp__laravel_boost__.search_docs` consulted Laravel 12 route middleware, FormRequest, transaction, and HTTP API testing docs before closing it. +- 2026-05-04: `php artisan route:list --path=api/admin/v1 --except-vendor` confirmed `POST /api/admin/v1/platform/organizations`, `POST /api/admin/v1/platform/stores`, `GET /api/admin/v1/stores/{store}/me`, and `POST /api/admin/v1/stores/{store}/invites`. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the platform/membership API changes. +- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminPlatformApiTest.php` passed for platform organization/store creation, membership, invite, role, and token ability coverage: 4 tests, 24 assertions. +- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the platform/membership API changes: 61 tests, 504 assertions. +- 2026-05-04: Final schema audit found the Spec 01 `personal_access_tokens` table gap; `mcp__laravel_boost__.search_docs` consulted Laravel 12 migration/schema/index and Pest database assertion docs before closing it. +- 2026-05-04: `php artisan test --compact tests/Feature/Foundation/DatabaseConstraintTest.php` passed after adding the `personal_access_tokens` schema assertion: 2 tests, 10 assertions. +- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after adding `2026_05_04_180719_create_personal_access_tokens_table.php`. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the `personal_access_tokens` schema change. +- 2026-05-04: `php artisan test --compact tests/Feature/Foundation tests/Feature/Api` passed after the schema and platform API audit fixes: 76 tests, 616 assertions. +- 2026-05-04: `mcp__laravel_boost__.database_schema(summary: true)` confirmed `personal_access_tokens` now exists with tokenable, token, abilities, last-used, expiry, and timestamp columns. +- 2026-05-04: Final audit reconfirmed the application context with `mcp__laravel_boost__.application_info`: PHP 8.4, Laravel 12.51.0, Livewire 4.1.4, Flux 2.12.0, Pest 4.3.2, SQLite. +- 2026-05-04: Final route audit with `php artisan route:list --path=api --except-vendor` confirmed 64 API routes, including storefront cart/checkout/order/search/analytics routes, admin platform/store/product/customer/order/content/settings/search/analytics/theme/shipping/tax/export routes, and deferred app routes. +- 2026-05-04: Final schema audit with `mcp__laravel_boost__.database_schema(summary: true)` confirmed all shop/runtime tables, including `personal_access_tokens`, are present in the configured SQLite database. +- 2026-05-04: Final formatter check `vendor/bin/pint --dirty --format agent` passed. +- 2026-05-04: Final production frontend build `npm run build` passed with Vite assets generated under `public/build`. +- 2026-05-04: Final database reset `php artisan migrate:fresh --seed --no-interaction` passed from an empty SQLite schema through all seeders. +- 2026-05-04: Final full suite `php -d memory_limit=512M vendor/bin/pest --compact` passed: 400 tests, 2478 assertions, duration 597.43s. +- 2026-05-04: Fresh post-audit QA identified high-priority gaps in admin API bearer auth, Livewire/API policy enforcement, checkout-id web routes, checkout/order idempotency coverage, rich HTML sanitization, and webhook event coverage. +- 2026-05-04: `mcp__laravel_boost__.application_info` reconfirmed PHP 8.4, Laravel 12.51.0, Livewire 4.1.4, Flux 2.12.0, Pest 4.3.2, and SQLite before the post-audit hardening pass. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 custom guard, FormRequest authorization, transaction/lock, Livewire authorization/testing, and Pest 4 docs before the post-audit hardening changes. +- 2026-05-04: `php artisan route:list --name=checkout` confirmed checkout-id web routes: `checkout/{checkout?}`, `checkout/{checkout}/confirmation`, the legacy `checkout/confirmation/{order}` redirect, and storefront checkout API routes. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the post-audit API authorization, sanitizer, checkout-route, idempotency, and webhook changes. +- 2026-05-04: Focused post-audit regression tests passed: `php artisan test --compact tests/Feature/Api/AdminCatalogApiTest.php tests/Feature/Api/AdminPlatformApiTest.php tests/Feature/Api/AdminOrderApiTest.php tests/Feature/Api/AdminCollectionApiTest.php tests/Feature/Api/AdminPageApiTest.php tests/Feature/Security/HtmlSanitizationTest.php tests/Feature/Checkout/CheckoutServiceTest.php tests/Feature/Api/StorefrontOrderApiTest.php tests/Feature/Storefront/OrderViewsTest.php tests/Feature/Admin/OrderManagementTest.php tests/Feature/Admin/AppsDevelopersTest.php` with 54 tests and 799 assertions. +- 2026-05-04: Broader API/webhook/security/checkout regression tests passed: `php artisan test --compact tests/Feature/Api tests/Feature/Webhooks tests/Feature/Security tests/Feature/Checkout tests/Feature/Storefront/OrderViewsTest.php tests/Feature/Admin/OrderManagementTest.php tests/Feature/Admin/AppsDevelopersTest.php` with 106 tests and 1182 assertions. +- 2026-05-04: Admin feature regression tests passed: `php artisan test --compact tests/Feature/Admin` with 45 tests and 291 assertions. +- 2026-05-04: Affected browser regression suites passed: `php artisan test --compact tests/Browser/Storefront/CheckoutTest.php tests/Browser/Storefront/AccessibilityTest.php tests/Browser/Storefront/ResponsiveTest.php` with 32 tests and 178 assertions. +- 2026-05-04: Admin API regression tests passed after replacing stale token attribute names with `sanctum_personal_access_token`: `php artisan test --compact tests/Feature/Api` with 63 tests and 518 assertions. +- 2026-05-04: Final formatter check `vendor/bin/pint --dirty --format agent` passed after the checkout Livewire test alignment. +- 2026-05-04: Final production frontend build `npm run build` passed with Vite assets generated under `public/build`. +- 2026-05-04: Full Pest rerun passed after the checkout Livewire test alignment: `php -d memory_limit=512M vendor/bin/pest --compact` with 416 tests and 3030 assertions, duration 602.29s. +- 2026-05-04: Final database reset `php artisan migrate:fresh --seed --no-interaction` passed before manual browser verification. +- 2026-05-04: Playwright MCP verified the post-audit customer flow on `http://shop.test`: product detail add-to-cart, cart drawer checkout redirect to `/checkout/1`, address/shipping/discount/payment submission, and order confirmation at `/checkout/1/confirmation` for order `#1016`. +- 2026-05-04: Playwright MCP verified admin order management for the same order: `/admin/orders` listed `#1016`, `/admin/orders/19` showed payment, discount, total, timeline, customer, and line-item details, and admin fulfillment creation succeeded with visible `Fulfillment created` feedback and fulfilled order status. +- 2026-05-04: Playwright MCP verified `/admin/developers` generates a one-time `shop_...` Admin API token backed by `personal_access_tokens` and lists it with type `Admin API`. +- 2026-05-04: Playwright MCP verified the optional `/checkout` compatibility route renders the empty-cart state cleanly; current Playwright console checks reported 0 warnings and 0 errors. Boost `browser_logs` still contained older May 4 `/checkout` Flux errors from before the final verification refresh, not from the current Playwright run. +- 2026-05-04: Fresh independent QA identified final blockers: admin API tokens were not store-scoped, storefront checkout API reads/mutations were vulnerable to checkout-id guessing, the default test command could still hit Pest browser memory limits, required audit logging was missing, support users could view dashboard analytics, admin API routes used a generic limiter, and platform management was tied to store-owner status rather than a platform-admin boundary. +- 2026-05-04: `mcp__laravel_boost__.application_info` reconfirmed PHP 8.4, Laravel 12.51.0, Livewire 4.1.4, Flux 2.12.0, Pest 4.3.2, and SQLite before the final blocker pass. +- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 named rate limiter, auth event, and Pest/logging docs before the final blocker pass. +- 2026-05-04: Focused final blocker tests passed: `php artisan test --compact tests/Feature/Admin/DashboardTest.php tests/Feature/Api/AdminPlatformApiTest.php tests/Feature/Foundation/AuditLoggingTest.php tests/Feature/Foundation/DatabaseConstraintTest.php` with 13 tests and 73 assertions. +- 2026-05-04: `vendor/bin/pint --dirty --format agent` fixed final PHP formatting after the audit/rate-limit/platform/dashboard changes. +- 2026-05-04: Broader final blocker regression tests passed: `php artisan test --compact tests/Feature/Admin/DashboardTest.php tests/Feature/Api/AdminPlatformApiTest.php tests/Feature/Foundation/AuditLoggingTest.php tests/Feature/Foundation/DatabaseConstraintTest.php tests/Feature/Api/StorefrontCheckoutApiTest.php tests/Feature/Api/StorefrontOrderApiTest.php tests/Feature/Api/AdminCatalogApiTest.php tests/Feature/Admin/AppsDevelopersTest.php` with 29 tests and 225 assertions. +- 2026-05-04: Default full suite passed with the project phpunit memory configuration: `php artisan test --compact` with 422 tests, 3065 assertions, duration 606.43s. +- 2026-05-04: Final database reset `php artisan migrate:fresh --seed --no-interaction` passed after the final blocker fixes. +- 2026-05-04: Final production frontend build `npm run build` passed after the final blocker fixes. + +## Decisions + +- Follow the roadmap order strictly. Phase 1 is required before catalog, checkout, admin, storefront, and QA flows can be meaningfully implemented. +- Preserve the starter Fortify conventions where they do not conflict with the shop specs. +- Use integer minor units for all money columns and JSON text columns cast through Eloquent as specified. +- Keep `password_hash` as the database column for admin users and customers while exposing `password`/`getAuthPassword()` at the model layer for Fortify/Laravel auth compatibility. +- Seed both `shop.test` and `acme-fashion.test` as storefront domains for the first store so the goal URL and E2E spec URL can both resolve. +- Herd needs a site link for non-project-folder aliases; `acme-fashion.test` is linked to the same local project path as `shop.test` so the seeded domain can be verified in browser E2E flows. +- Customer Livewire auth components persist `storeId` from the initial storefront request because Livewire update requests do not run through the original storefront route middleware. +- The seed specification contains an arithmetic conflict: Acme Fashion's per-product variant table sums to 117 variants, while the prose says 107. The implementation follows the concrete per-product table, resulting in 117 Fashion variants and 10 Electronics variants, 127 total. +- Child catalog models without a `store_id` column are tenant-scoped through their parent product relationship. Inventory keeps its denormalized `store_id` and enforces that it matches the variant product store at save time. +- Storefront content pages now resolve from the `pages` table and only published pages are rendered. +- The catalog admin form intentionally blocks active products without a priced variant and duplicate in-store SKUs during UI edits, mirroring service-layer invariants where the current CRUD surface touches product variants directly. +- The product admin form generates variant rows from the option cartesian product, preserves matching variant edits by option combination, and syncs product option values plus variant pivots on save; stale variants are deleted or archived if order-line history exists. +- Navigation items use a nullable `parent_id` for one-level storefront dropdowns; `NavigationService::buildTree()` groups items recursively by parent while the admin editor enforces top-level parents for child items. +- Cart, checkout, and pricing runtime records are not seeded; only deterministic tax, shipping, and discount configuration is seeded. Runtime carts/checkouts are created by services and tests. +- The cart drawer reads the current session/customer cart without creating an empty cart on every storefront render; carts are created on first add-to-cart or checkout/cart service mutation. +- The checkout UI reserves inventory by selecting a payment method, then submits payment through `CheckoutService::completeCheckout()` so failed card payments can release reservations and return customers to payment selection. +- Order confirmation access is allowed for the authenticated owning customer or for the session that just placed the order via `last_order_id`. +- Customer account order list/detail routes use the `customer` guard and reload orders through explicit store/customer constraints. +- The cart REST API exposes `cart_version` as the public optimistic concurrency field while the service layer keeps its `expectedVersion` argument; `expected_version` remains accepted as a compatibility alias in API requests. +- Cart page discount codes are validated with `DiscountService`, saved in session, and applied to the checkout when the customer proceeds. +- Admin discount forms persist minimum purchase, product eligibility, collection eligibility, and `one_per_customer` in the existing `discounts.rules_json` keys consumed by `DiscountService`; code discounts now check customer order-line allocation history before allowing another use. +- `PricingEngine` applies an explicit checkout discount code first, then active automatic discounts returned by `DiscountService`. +- Order creation replays the same code-then-automatic discount sequence in memory so `order_lines.discount_allocations_json` keeps separate `{discount_id, code, amount}` entries for every line-item discount while preserving aggregate cart line totals. +- `orders.checkout_id` is intentionally added beyond the original schema table to enforce idempotent checkout completion without duplicate orders. +- Failed card payments release reserved inventory and move the checkout back to `shipping_selected` so customers can retry payment selection. +- Bank transfer order completion creates a pending order and payment while keeping inventory reserved until the later admin payment-confirmation flow. +- Inventory commit/release/restock locks by primary key without the current-store global scope because scheduled jobs can operate across stores in one run. +- Admin order Livewire components persist locked `storeId`/`orderId` state and reload orders without global scopes but with explicit store constraints before each service-backed action. +- Admin order actions deliberately delegate to `OrderService`, `RefundService`, and `FulfillmentService` so UI validation cannot bypass payment, refund, fulfillment, or inventory guards. +- Storefront order lookup uses a deterministic HMAC access token derived from store/order identity so confirmation links can be token-gated without adding a new runtime column. +- Admin REST API routes use `auth:sanctum` plus the `admin.api` middleware with hashed `personal_access_tokens` bearer tokens; the current implemented surfaces are platform organization/store creation, store membership/invite endpoints, product read/create/update/archive/media presign, customer/order list/detail, collection CRUD, discount CRUD, content page CRUD, search status/reindex, analytics summary, order export creation/status, shipping zone/rate settings, tax settings read/update, theme upload/publish/settings update, and order fulfillment/refund mutations. +- Admin dashboard sales and top-product metrics are derived from paid or partially refunded orders; the dedicated analytics page now reads analytics event and daily aggregate tables. +- Customer addresses are scoped through the locked customer id because `customer_addresses` intentionally belongs to customers and has no direct `store_id` column. +- Admin/store Livewire route middleware is registered with Livewire persistent middleware so component POST requests keep `current_store` binding and store-role checks after the initial full-page route load. +- Admin routes use a custom `EnsureUserEmailIsVerified` middleware so verified-user gating works for full-page Livewire routes without relying on the stock redirect middleware path that fails during Livewire component redirects in this app. +- Admin settings are split across `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, and `/admin/settings/notifications`; domains are managed on the general settings page because the current route surface does not need a separate domains route. +- The tax settings API accepts the spec-facing `default_tax_rate`/`tax_rates` payload shape and normalizes it to the existing internal `default_rate_bps`/`rates` config consumed by the calculator and admin UI. +- The shipping settings API accepts spec-facing `price_amount`/`tiers` rate config keys and normalizes them to the existing `amount`/`ranges` config consumed by `ShippingCalculator`. +- The theme upload API imports ZIP archives with `theme.json` or `manifest.json`, requires the same core template paths seeded for default themes, stores each imported file on the local disk under the new theme id, and seeds theme settings from the manifest or the store defaults. +- The theme publish API validates required files before switching the store to exactly one published theme and flushing cached theme settings. +- The store settings API exposes the general settings UI surface as `/api/admin/v1/stores/{store}/settings`: store name/currency/locale/timezone fields update the `stores` row, while `settings_json` patches are recursively merged into `store_settings` so checkout and notification defaults are preserved unless explicitly changed. +- The order export API adds a small `data_exports` table to support the spec's export status endpoint; this local app generates CSV synchronously into the local disk, marks the export `completed` inside the create request, and returns a data URL from the status response instead of a cloud presigned URL. +- Checkout and notification settings persist in `store_settings.settings_json`; `bank_transfer_cancel_days` remains a root-level key because the existing cancellation job already consumes it from that location. +- Admin navigation persists nested ordered menu items with Livewire `wire:sort`, up/down sibling controls, and a parent selector; children are saved under their selected top-level parent with per-parent positions. +- The theme editor uses the `ThemeSettingsService` default settings shape as the editable schema and writes back to `theme_settings.settings_json`. +- Theme files are seeded and edited through local disk storage at `theme_files.storage_key`; saving a file updates the stored contents plus `sha256` and `byte_size` metadata. +- Theme duplication copies each file's local disk contents to a new storage key for the copied theme instead of sharing the original file storage path. +- Search uses a local SQLite FTS5 virtual table (`products_fts`) with `SearchService` as the only query/sync boundary; product observer events keep the index synchronized for create/update/delete. +- Admin search reindex API runs the existing local `SearchService::reindex()` synchronously and returns `202 Accepted` with `completed` status because this self-contained app has no separate durable search job state table. +- Search settings apply at query time: per-store stop words are removed, synonym groups expand into sanitized SQLite FTS5 `OR` terms, and terms are joined with explicit `AND`; the admin page can rebuild the per-store index synchronously for this self-contained app. +- Search API rate limiting is registered as `search` (30/minute per IP), analytics ingestion uses the `analytics` limiter (60/minute per IP), and a `webhooks` limiter is registered for future inbound app endpoints. +- Analytics ingestion stores raw events with client-event deduplication and keeps aggregation separate in `analytics_daily`; admin analytics reads pre-aggregated daily metrics for KPIs and raw events for referrers. +- Seeded analytics metrics are deterministic demo data and remain independent from seeded orders so analytics-table behavior can be tested separately from order fixtures. +- The admin analytics CSV export is generated as a data URL for the self-contained local app rather than creating persistent export files/jobs. +- OAuth/Passport app authorization remains deferred per the roadmap; `/oauth/*` and `/api/apps/v1/*` now return explicit `501 Not Implemented` responses, while `oauth_clients` and `oauth_tokens` remain seeded deferred-app fixtures and the developer-token UI writes active Admin API credentials to `personal_access_tokens`. +- Admin order API routes use the `admin.api` middleware with scoped hashed `personal_access_tokens`; token requests require `read-orders` for GET routes and `write-orders` for refund/fulfillment mutations, role checks decide whether staff/support can use those abilities, and successful requests update `last_used_at`. +- Outbound webhook delivery jobs are forced onto the database queue and `webhooks` queue name so domain events enqueue delivery work instead of making external HTTP requests inline when the default queue connection is `sync`. +- Webhook signatures follow the security spec message shape of `{timestamp}.{json_body}` with HMAC-SHA256, while the delivery payload is wrapped with `id`, `api_version`, `event_type`, `store_id`, `occurred_at`, and `data`. +- Webhook circuit breaker state is derived from the latest five delivery rows for a subscription because the schema does not include a dedicated consecutive-failure counter column. +- Customer password resets use a custom store-scoped service instead of Laravel's stock password broker token repository because the `customer_password_reset_tokens` table includes `store_id` in the primary key and the stock repository does not write tenant columns. +- Customer password reset pages now use the spec root routes `/forgot-password` and `/reset-password/{token}` with POST handlers at the same root paths; `/account/forgot-password` and `/account/reset-password/{token}` remain as compatibility aliases, while Fortify's generic starter user reset named routes are preserved under `/user/forgot-password` and `/user/reset-password/{token}`. +- Admin password reset aliases are routed under `/admin/forgot-password` and `/admin/reset-password/{token}` using Fortify's existing password reset controllers; the shared Fortify views post to admin routes when rendered from admin reset pages. +- Admin user-menu logout forms route to `/admin/logout` when rendered on admin routes so browser logout returns to `/admin/login`; the same shared menu keeps Fortify's generic `/logout` route on non-admin starter settings routes. +- Product media uploads use Livewire temporary file uploads on the existing product form, create `ProductMedia` rows in `processing` status, and dispatch the existing `ProcessMediaUpload` job instead of adding a second media-processing path. +- Resource policies now type concrete model parameters for Product, Collection, Customer, Discount, Order, Fulfillment, Refund, Page, and Theme actions; `ChecksStoreRole::storeIdForModel()` stays generic because it is the shared store-id extraction helper. +- Laravel's SQLite `enum()` migrations generate database `CHECK` clauses in this app; `DatabaseConstraintTest` locks representative constraints across tenancy, roles, catalog, orders, tax settings, and exports. +- Automated browser coverage now combines smoke coverage with Suite 2 admin-auth interactions, Suite 3 admin product-management interactions, Suite 4 admin order-management interactions, Suite 5 admin discount-management interactions, Suite 6 admin settings interactions, Suite 7 storefront browsing interactions, Suite 8 cart-flow interactions, Suite 9 checkout-flow interactions, Suite 10 customer-account interactions, Suite 11 inventory-policy interactions, Suite 12 tenant-isolation interactions, Suite 13 responsive/mobile interactions, Suite 14 accessibility interactions, Suite 15 admin collection-management interactions, Suite 16 admin customer-management interactions, Suite 17 admin page-management interactions, and Suite 18 admin analytics interactions: storefront catalog/content/search/cart, storefront account auth/reset pages, the full authenticated admin navigation surface, admin login validation, protected-route redirects, logout, sidebar navigation, admin product listing/create/edit/archive/search/status-filter/draft visibility guards, admin order listing/filtering/detail/timeline/customer info, fulfillment creation, refund processing, bank-transfer payment confirmation, unpaid fulfillment guard, shipped/delivered fulfillment transitions, admin discount seeded-code listing, percent/fixed/free-shipping discount creation, discount value editing, status badges, admin store settings persistence, domain listing, seeded shipping zone/rate display, shipping rate creation, tax settings display and prices-include-tax persistence, admin collection seeded-listing, collection creation, collection description editing, admin customer listing, customer detail, customer order history, customer address section rendering, admin page seeded-listing, page creation, page body editing, admin analytics sidebar navigation, revenue/orders KPI labels, and conversion-funnel labels, mobile storefront rendering, seeded collection/product/search/static page browsing, sale/compare-at pricing, draft-product storefront visibility guards, stock/backorder messaging, main-navigation clicks, add-to-cart, cart quantity updates/removal, multi-product carts, subtotal/total display, valid/invalid/expired/maxed/free-shipping/fixed discount code handling, domestic/international checkout shipping, checkout discount carryover, address validation, empty-cart checkout protection, credit-card/PayPal/bank-transfer order completion, payment decline handling, payment-method UI switching, customer registration validation, customer login validation, account auth redirects, customer order history/detail, address book create/update, customer logout, deny-policy sold-out blocking, continue-policy backorder add-to-cart, in-stock status display, deny-policy cart stock-limit feedback, storefront product/collection/search store isolation, admin product/order store isolation, customer account store isolation, mobile hamburger navigation, mobile product/cart/checkout flows, mobile collection filtering, tablet admin login/sidebar navigation, console-clean home/cart pages, heading hierarchy, form labels, product image placeholder accessible text, checkout error linkage, keyboard navigation, and search input labeling. +- The admin product-management browser suite uses Laravel `actingAs()` and `withSession()` to create deterministic authenticated browser visits; the separate Suite 2 admin-auth suite owns login-form coverage so persisted browser cookies from earlier tests cannot leak into product CRUD assertions. +- The admin order-management browser suite uses the deterministic seeded `#1001`, `#1002`, and `#1005` order fixtures; only inventory reservations needed by bank-transfer confirmation are normalized in the test setup. +- Admin order detail now shows visible action feedback, a simple event timeline, and an explicit unpaid-fulfillment guard. Refund and fulfillment buttons are only exposed when the current financial/fulfillment state allows the backing service action. +- The admin discount-management browser suite uses deterministic authenticated browser visits like the product/order management suites; Suite 2 remains the only browser suite responsible for login-form behavior, while Suite 5 covers discount listing, three value-type create flows, editing, and status badges. +- The admin settings browser suite keeps domains on the general settings page because the implemented UI uses a domains section rather than a separate domains tab; Suite 6 still covers the spec-visible domain list plus general, shipping, and tax settings flows. +- The admin collection-management browser suite uses deterministic authenticated browser visits and visible save feedback. The product active-status browser assertion searches within the active filter because the seeded active product list is paginated and individual products are not guaranteed to stay on page 1 as product fixtures evolve. +- The admin customer-management browser suite uses deterministic authenticated browser visits and the seeded `#1001` order for `customer@acme.test`. +- The admin page-management browser suite uses deterministic authenticated browser visits; the page form exposes visible action feedback because Livewire's session flash was not reliably visible after the create flow switched into edit mode. +- The admin analytics browser suite uses seeded `analytics_daily` metrics rather than order aggregates because the analytics page is intentionally backed by analytics tables; the dashboard now labels the revenue KPI per Spec 08 and exposes the existing analytics totals as a visible conversion funnel. +- The browser smoke suite now follows the 10-test Spec 08 shape exactly; the final smoke test retains the earlier broad critical-page batch for public, account, admin-login, and authenticated admin pages. +- Pest's per-visit browser `host` option is temporary, so host-sensitive storefront browser tests set the Playwright host for the whole test lifecycle to keep clicked absolute URLs resolving through `shop.test`. +- The discount seeder now includes the Spec 07/08 codes `WELCOME10`, `FLAT5`, `FREESHIP`, `EXPIRED20`, and `MAXED` while preserving the earlier implementation/test fixture codes `SAVE10` and `5OFF`. +- Expired discount codes return an expired validation message before the generic non-active status message so storefront/cart validation can present the expected customer-facing error. +- Shipping seed data now follows the Spec 07/08 checkout fixtures: `Domestic` covers Germany with `Standard Shipping` at 4.99 EUR, while `International` covers AT/CH/US/GB/CA/AU with a 14.99 EUR international rate. +- Storefront seeded tax settings use tax-inclusive pricing so checkout browser totals match the storefront spec examples while tax amounts remain visible as extracted totals. +- The checkout payment UI keeps the existing service-backed reservation path internally, but the browser-facing action is a single payment button (`Pay now`, `Pay with PayPal`, or `Place order`) that calls `placeOrder()`. +- The seeded customer now has a deterministic default `Home` address so account address-book flows and browser tests have a stable read/edit fixture without seeding runtime orders. +- The storefront mobile menu uses a Flux modal flyout with regular `wire:navigate` links; `flux:modal.close` is only used for Flux's own close button because wrapping plain anchors produced Flux JS errors in browser tests. +- Product detail placeholders use `role="img"` and descriptive `aria-label` text until seeded media is available, and the checkout email field has an explicit `aria-describedby` target for validation errors. +- Storefront checkout links now use checkout ids: cart checkout creates or reuses an active checkout and redirects to `/checkout/{checkoutId}`, successful order completion redirects to `/checkout/{checkoutId}/confirmation`, and `/checkout/confirmation/{order}` remains only as a legacy redirect. +- Storefront checkout API responses include an HMAC checkout access token and checkout API read/mutation/payment endpoints require that token, preventing checkout id guessing without adding a new runtime column. +- Rich text accepted through product, page, and collection Livewire/API surfaces is sanitized through the shared DOM allow-list before persistence so script/style/event-handler payloads cannot be stored. +- Checkout and order creation now lock the cart/checkout/store rows and applied discounts where needed so retrying a payment or racing a checkout cannot create duplicate active checkouts, duplicate orders, duplicate order numbers, or exceed discount usage limits. +- Admin API developer tokens are stored with `store_id`, developer UI listings are store-scoped, and admin API middleware rejects a token issued for a different store even if the same user belongs to both stores. +- Platform management requires `users.is_platform_admin` and the `manage-platform` token ability; ordinary store owners cannot create platform organizations or stores. +- Admin API routes use the named `api.admin` limiter, keyed by the authenticated personal access token when present, then by user id, then by IP. +- Audit logging writes structured daily entries to the `audit` channel for admin auth events, failed logins, API-token create/revoke events, and store-resource mutations, with 90-day retention configured. +- Support users remain allowed through the generic admin store-role middleware for read-only areas but are explicitly blocked from the dashboard and analytics surfaces. + +## Known Verification Note + +- The previous full-suite memory issue is resolved by setting `memory_limit=512M` in `phpunit.xml`; the default `php artisan test --compact` command now passes. + +## Completion Summary + +Completion is pending the final commit. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, deterministic Spec 07 customer/address/order/payment/fulfillment/refund seed data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer account order/address/logout views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, platform/store-membership APIs, product/customer/collection/discount/content page/store settings/search/analytics/shipping/tax/theme/order/export API surfaces, admin product create/update/archive/media-presign API surfaces, deferred OAuth/app route stubs, all 18 Spec 08 automated browser suites, outbound webhook delivery foundations, spec-root customer password reset routes, representative database CHECK constraint coverage, store-scoped Sanctum-compatible Admin API bearer tokens, named per-token admin API rate limiting, platform-admin authorization, audit logging, policy-backed API/Livewire mutations, rich HTML sanitization, checkout-id routes, checkout API access tokens, and checkout/order idempotency hardening are implemented. Final post-audit verification has passed with Pint, Vite build, fresh migrate/seed, default full Pest, and Playwright MCP customer/admin smoke flows. diff --git a/tests/Browser/Admin/AnalyticsTest.php b/tests/Browser/Admin/AnalyticsTest.php new file mode 100644 index 00000000..6bfe0b64 --- /dev/null +++ b/tests/Browser/Admin/AnalyticsTest.php @@ -0,0 +1,69 @@ +seed(DatabaseSeeder::class); +}); + +afterEach(function (): void { + Playwright::setHost(null); +}); + +function adminAnalyticsBrowserHost(): array +{ + return ['host' => 'shop.test']; +} + +function adminAnalyticsBrowserAuthenticate(mixed $testCase): Store +{ + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $user = User::query()->where('email', 'admin@acme.test')->firstOrFail(); + + $testCase->actingAs($user); + $testCase->withSession(['current_store_id' => $store->getKey()]); + + return $store; +} + +function adminAnalyticsBrowserOpenAnalytics(mixed $testCase): mixed +{ + adminAnalyticsBrowserAuthenticate($testCase); + + return visit('/admin', adminAnalyticsBrowserHost()) + ->wait(1) + ->assertPathIs('/admin') + ->assertSee('Dashboard') + ->click('a[href$="/admin/analytics"]') + ->wait(1) + ->assertPathIs('/admin/analytics') + ->assertSee('Analytics') + ->assertNoJavaScriptErrors(); +} + +test('shows the analytics dashboard', function (): void { + adminAnalyticsBrowserOpenAnalytics($this) + ->assertSee('Analytics') + ->assertNoJavaScriptErrors(); +}); + +test('shows sales data', function (): void { + adminAnalyticsBrowserOpenAnalytics($this) + ->assertSee('Orders') + ->assertSee('Revenue') + ->assertNoJavaScriptErrors(); +}); + +test('shows conversion funnel data', function (): void { + adminAnalyticsBrowserOpenAnalytics($this) + ->assertSee('Visits') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Admin/AuthenticationTest.php b/tests/Browser/Admin/AuthenticationTest.php new file mode 100644 index 00000000..2c023f41 --- /dev/null +++ b/tests/Browser/Admin/AuthenticationTest.php @@ -0,0 +1,135 @@ +seed(DatabaseSeeder::class); +}); + +function adminAuthenticationHost(): array +{ + return ['host' => 'shop.test']; +} + +function adminAuthenticationLogin(): mixed +{ + return visit('/admin/login', adminAuthenticationHost()) + ->fill('input[type=email]', 'admin@acme.test') + ->fill('input[type=password]', 'password') + ->click('@admin-login-button') + ->wait(1) + ->assertPathIs('/admin') + ->assertSee('Dashboard') + ->assertNoJavaScriptErrors(); +} + +function adminAuthenticationSubmitWithoutBrowserRequired(string $email, string $password): mixed +{ + $page = visit('/admin/login', adminAuthenticationHost()); + + $page->script('() => document.querySelectorAll("[required]").forEach((element) => element.removeAttribute("required"))'); + + if ($email !== '') { + $page->fill('input[type=email]', $email); + } + + if ($password !== '') { + $page->fill('input[type=password]', $password); + } + + return $page->click('@admin-login-button')->wait(1); +} + +test('can log in as admin', function (): void { + adminAuthenticationLogin(); +}); + +test('shows error for invalid credentials', function (): void { + visit('/admin/login', adminAuthenticationHost()) + ->fill('input[type=email]', 'admin@acme.test') + ->fill('input[type=password]', 'wrongpassword') + ->click('@admin-login-button') + ->wait(1) + ->assertPathIs('/admin/login') + ->assertSee('Invalid credentials') + ->assertNoJavaScriptErrors(); +}); + +test('shows error for empty email', function (): void { + adminAuthenticationSubmitWithoutBrowserRequired('', 'password') + ->assertPathIs('/admin/login') + ->assertSee('email field is required') + ->assertNoJavaScriptErrors(); +}); + +test('shows error for empty password', function (): void { + adminAuthenticationSubmitWithoutBrowserRequired('admin@acme.test', '') + ->assertPathIs('/admin/login') + ->assertSee('password field is required') + ->assertNoJavaScriptErrors(); +}); + +test('redirects unauthenticated users to login from dashboard', function (): void { + visit('/admin', adminAuthenticationHost()) + ->wait(1) + ->assertPathIs('/admin/login') + ->assertSee('Sign in') + ->assertNoJavaScriptErrors(); +}); + +test('redirects unauthenticated users to login from products', function (): void { + visit('/admin/products', adminAuthenticationHost()) + ->wait(1) + ->assertPathIs('/admin/login') + ->assertSee('Sign in') + ->assertNoJavaScriptErrors(); +}); + +test('can log out', function (): void { + adminAuthenticationLogin() + ->click('@sidebar-menu-button') + ->click('button[data-test="logout-button"]:visible') + ->wait(1) + ->assertPathIs('/admin/login') + ->assertSee('Sign in') + ->assertNoJavaScriptErrors(); +}); + +test('can navigate through admin sidebar sections', function (): void { + $page = adminAuthenticationLogin(); + + foreach ([ + '/admin/products' => 'Products', + '/admin/orders' => 'Orders', + '/admin/customers' => 'Customers', + '/admin/discounts' => 'Discounts', + '/admin/settings' => 'Store Settings', + ] as $path => $heading) { + $page->click("a[href$=\"{$path}\"]") + ->wait(1) + ->assertPathIs($path) + ->assertSee($heading) + ->assertNoJavaScriptErrors(); + } +}); + +test('can navigate to analytics from sidebar', function (): void { + adminAuthenticationLogin() + ->click('a[href$="/admin/analytics"]') + ->wait(1) + ->assertPathIs('/admin/analytics') + ->assertSee('Analytics') + ->assertNoJavaScriptErrors(); +}); + +test('can navigate to themes from sidebar', function (): void { + adminAuthenticationLogin() + ->click('a[href$="/admin/themes"]') + ->wait(1) + ->assertPathIs('/admin/themes') + ->assertSee('Themes') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Admin/CollectionManagementTest.php b/tests/Browser/Admin/CollectionManagementTest.php new file mode 100644 index 00000000..8845aba6 --- /dev/null +++ b/tests/Browser/Admin/CollectionManagementTest.php @@ -0,0 +1,119 @@ +seed(DatabaseSeeder::class); +}); + +afterEach(function (): void { + Playwright::setHost(null); +}); + +function adminCollectionBrowserHost(): array +{ + return ['host' => 'shop.test']; +} + +function adminCollectionBrowserStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function adminCollectionBrowserAuthenticate(mixed $testCase): Store +{ + $store = adminCollectionBrowserStore(); + $user = User::query()->where('email', 'admin@acme.test')->firstOrFail(); + + $testCase->actingAs($user); + $testCase->withSession(['current_store_id' => $store->getKey()]); + + return $store; +} + +function adminCollectionOpenCollections(mixed $testCase): mixed +{ + adminCollectionBrowserAuthenticate($testCase); + + return visit('/admin/collections', adminCollectionBrowserHost()) + ->wait(1) + ->assertPathIs('/admin/collections') + ->assertSee('Collections') + ->assertNoJavaScriptErrors(); +} + +function adminCollectionSave(mixed $page): mixed +{ + return $page + ->click('button[data-test="collection-save-button"]') + ->wait(1) + ->assertSee('Collection saved') + ->assertNoJavaScriptErrors(); +} + +test('shows the collection list with seeded collections', function (): void { + adminCollectionOpenCollections($this) + ->assertSee('T-Shirts') + ->assertSee('New Arrivals') + ->assertNoJavaScriptErrors(); +}); + +test('can create a new collection', function (): void { + $store = adminCollectionBrowserAuthenticate($this); + $page = adminCollectionOpenCollections($this) + ->click('a[href$="/admin/collections/create"]') + ->wait(1) + ->assertPathIs('/admin/collections/create') + ->assertSee('Add collection') + ->assertNoJavaScriptErrors(); + + $page + ->fill('input[wire\\:model\\.live\\.debounce\\.300ms="title"]', 'E2E Test Collection') + ->fill('input[wire\\:model="handle"]', 'e2e-test-collection') + ->fill('textarea[wire\\:model="descriptionHtml"]', 'A collection created by the E2E test suite.'); + + adminCollectionSave($page) + ->navigate('/admin/collections') + ->wait(1) + ->assertPathIs('/admin/collections') + ->assertSee('E2E Test Collection') + ->assertNoJavaScriptErrors(); + + $this->assertDatabaseHas('collections', [ + 'store_id' => $store->getKey(), + 'title' => 'E2E Test Collection', + 'handle' => 'e2e-test-collection', + 'description_html' => 'A collection created by the E2E test suite.', + ]); +}); + +test('can edit a collection', function (): void { + $store = adminCollectionBrowserAuthenticate($this); + + $page = adminCollectionOpenCollections($this) + ->click('a:has-text("T-Shirts")') + ->wait(1) + ->assertPathContains('/admin/collections/') + ->assertValue('input[wire\\:model\\.live\\.debounce\\.300ms="title"]', 'T-Shirts') + ->assertNoJavaScriptErrors(); + + $page->fill('textarea[wire\\:model="descriptionHtml"]', 'Updated description for T-Shirts collection.'); + + adminCollectionSave($page); + + $collection = Collection::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('title', 'T-Shirts') + ->firstOrFail(); + + expect($collection->description_html)->toBe('Updated description for T-Shirts collection.'); +}); diff --git a/tests/Browser/Admin/CustomerManagementTest.php b/tests/Browser/Admin/CustomerManagementTest.php new file mode 100644 index 00000000..c3cb764b --- /dev/null +++ b/tests/Browser/Admin/CustomerManagementTest.php @@ -0,0 +1,108 @@ +seed(DatabaseSeeder::class); +}); + +afterEach(function (): void { + Playwright::setHost(null); +}); + +function adminCustomerBrowserHost(): array +{ + return ['host' => 'shop.test']; +} + +function adminCustomerBrowserAuthenticate(mixed $testCase): Store +{ + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $user = User::query()->where('email', 'admin@acme.test')->firstOrFail(); + + $testCase->actingAs($user); + $testCase->withSession(['current_store_id' => $store->getKey()]); + + return $store; +} + +function adminCustomerBrowserCustomer(Store $store): Customer +{ + return Customer::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('email', 'customer@acme.test') + ->firstOrFail(); +} + +function adminCustomerBrowserOrder(Store $store, Customer $customer): Order +{ + return Order::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('customer_id', $customer->getKey()) + ->where('order_number', '#1001') + ->firstOrFail(); +} + +function adminCustomerBrowserOpenCustomers(mixed $testCase): mixed +{ + adminCustomerBrowserAuthenticate($testCase); + + return visit('/admin/customers', adminCustomerBrowserHost()) + ->wait(1) + ->assertPathIs('/admin/customers') + ->assertSee('Customers') + ->assertNoJavaScriptErrors(); +} + +function adminCustomerBrowserOpenCustomer(mixed $testCase): mixed +{ + return adminCustomerBrowserOpenCustomers($testCase) + ->assertSee('John Doe') + ->click('a:has-text("John Doe")') + ->wait(1) + ->assertPathContains('/admin/customers/') + ->assertNoJavaScriptErrors(); +} + +test('shows the customer list', function (): void { + adminCustomerBrowserOpenCustomers($this) + ->assertSee('customer@acme.test') + ->assertSee('John Doe') + ->assertNoJavaScriptErrors(); +}); + +test('shows customer detail with order history', function (): void { + $store = adminCustomerBrowserAuthenticate($this); + $customer = adminCustomerBrowserCustomer($store); + adminCustomerBrowserOrder($store, $customer); + + visit('/admin/customers', adminCustomerBrowserHost()) + ->wait(1) + ->assertPathIs('/admin/customers') + ->click('a:has-text("John Doe")') + ->wait(1) + ->assertPathContains('/admin/customers/') + ->assertSee('John Doe') + ->assertSee('customer@acme.test') + ->assertSee('Order history') + ->assertSee('#1001') + ->assertNoJavaScriptErrors(); +}); + +test('shows customer addresses', function (): void { + adminCustomerBrowserOpenCustomer($this) + ->assertSee('John Doe') + ->assertSee('Addresses') + ->assertSee('Home') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Admin/DiscountManagementTest.php b/tests/Browser/Admin/DiscountManagementTest.php new file mode 100644 index 00000000..0a949fa3 --- /dev/null +++ b/tests/Browser/Admin/DiscountManagementTest.php @@ -0,0 +1,208 @@ +seed(DatabaseSeeder::class); +}); + +afterEach(function (): void { + Playwright::setHost(null); +}); + +function adminDiscountHost(): array +{ + return ['host' => 'shop.test']; +} + +function adminDiscountStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function adminDiscountAuthenticate(mixed $testCase): Store +{ + $store = adminDiscountStore(); + $user = User::query()->where('email', 'admin@acme.test')->firstOrFail(); + + $testCase->actingAs($user); + $testCase->withSession(['current_store_id' => $store->getKey()]); + + return $store; +} + +function adminDiscountOpenDiscounts(mixed $testCase): mixed +{ + adminDiscountAuthenticate($testCase); + + return visit('/admin/discounts', adminDiscountHost()) + ->wait(1) + ->assertPathIs('/admin/discounts') + ->assertSee('Discounts') + ->assertNoJavaScriptErrors(); +} + +function adminDiscountOpenCreateForm(mixed $testCase): mixed +{ + return adminDiscountOpenDiscounts($testCase) + ->click('a[href$="/admin/discounts/create"]') + ->wait(1) + ->assertPathIs('/admin/discounts/create') + ->assertSee('Create discount') + ->assertNoJavaScriptErrors(); +} + +function adminDiscountFillCodeForm( + mixed $page, + string $code, + string $valueType, + string $startsAt = '2026-01-01T00:00', + string $endsAt = '', + ?string $valueAmount = null, +): mixed { + $page + ->fill('input[wire\\:model="code"]', $code) + ->fill('input[wire\\:model="startsAt"]', $startsAt); + + if ($endsAt !== '') { + $page->fill('input[wire\\:model="endsAt"]', $endsAt); + } + + $page + ->click($valueType) + ->wait(1); + + if ($valueAmount !== null) { + $page->fill('input[wire\\:model="valueAmount"]', $valueAmount); + } + + return $page; +} + +function adminDiscountSave(mixed $page): mixed +{ + return $page + ->click('button[data-test="discount-save-button"]') + ->wait(1) + ->assertSee('Discount saved') + ->assertNoJavaScriptErrors(); +} + +test('shows seeded discount codes', function (): void { + adminDiscountOpenDiscounts($this) + ->assertSee('WELCOME10') + ->assertSee('FLAT5') + ->assertSee('FREESHIP') + ->assertNoJavaScriptErrors(); +}); + +test('can create a new percentage discount code', function (): void { + $store = adminDiscountAuthenticate($this); + $page = adminDiscountOpenCreateForm($this); + + adminDiscountFillCodeForm( + page: $page, + code: 'E2ETEST25', + valueType: 'Percentage', + startsAt: '2026-01-01T00:00', + endsAt: '2026-12-31T23:59', + valueAmount: '25', + ); + + adminDiscountSave($page) + ->click('ui-sidebar a[href$="/admin/discounts"]') + ->wait(1) + ->assertPathIs('/admin/discounts') + ->assertSee('E2ETEST25') + ->assertNoJavaScriptErrors(); + + $this->assertDatabaseHas('discounts', [ + 'store_id' => $store->getKey(), + 'code' => 'E2ETEST25', + 'value_type' => 'percent', + 'value_amount' => 25, + ]); +}); + +test('can create a fixed amount discount code', function (): void { + $store = adminDiscountAuthenticate($this); + $page = adminDiscountOpenCreateForm($this); + + adminDiscountFillCodeForm( + page: $page, + code: 'E2EFLAT10', + valueType: 'Fixed amount', + startsAt: '2026-01-01T00:00', + valueAmount: '10.00', + ); + + adminDiscountSave($page); + + $this->assertDatabaseHas('discounts', [ + 'store_id' => $store->getKey(), + 'code' => 'E2EFLAT10', + 'value_type' => 'fixed', + 'value_amount' => 1000, + ]); +}); + +test('can create a free shipping discount code', function (): void { + $store = adminDiscountAuthenticate($this); + $page = adminDiscountOpenCreateForm($this); + + adminDiscountFillCodeForm( + page: $page, + code: 'E2EFREESHIP', + valueType: 'Free shipping', + startsAt: '2026-01-01T00:00', + ); + + adminDiscountSave($page); + + $this->assertDatabaseHas('discounts', [ + 'store_id' => $store->getKey(), + 'code' => 'E2EFREESHIP', + 'value_type' => 'free_shipping', + 'value_amount' => 0, + ]); +}); + +test('can edit a discount', function (): void { + $store = adminDiscountAuthenticate($this); + + $page = adminDiscountOpenDiscounts($this) + ->click('a:has-text("WELCOME10")') + ->wait(1) + ->assertPathContains('/admin/discounts/') + ->assertSee('Edit discount') + ->assertValue('input[wire\\:model="code"]', 'WELCOME10') + ->assertNoJavaScriptErrors(); + + $page->fill('input[wire\\:model="valueAmount"]', '15'); + + adminDiscountSave($page); + + $this->assertDatabaseHas('discounts', [ + 'store_id' => $store->getKey(), + 'code' => 'WELCOME10', + 'value_type' => 'percent', + 'value_amount' => 15, + ]); +}); + +test('shows discount status indicators', function (): void { + adminDiscountOpenDiscounts($this) + ->assertSee('WELCOME10') + ->assertSee('Active') + ->assertSee('EXPIRED20') + ->assertSee('Expired') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Admin/OrderManagementTest.php b/tests/Browser/Admin/OrderManagementTest.php new file mode 100644 index 00000000..6e6b8d5e --- /dev/null +++ b/tests/Browser/Admin/OrderManagementTest.php @@ -0,0 +1,217 @@ +seed(DatabaseSeeder::class); +}); + +afterEach(function (): void { + Playwright::setHost(null); +}); + +function adminOrderHost(): array +{ + return ['host' => 'shop.test']; +} + +function adminOrderAuthenticate(mixed $testCase): void +{ + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $user = User::query()->where('email', 'admin@acme.test')->firstOrFail(); + + $testCase->actingAs($user); + $testCase->withSession(['current_store_id' => $store->getKey()]); +} + +/** + * @return array{store: Store, paid: Order, fulfilled: Order, bank: Order} + */ +function adminOrderFixtures(): array +{ + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $orders = Order::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->whereIn('order_number', ['#1001', '#1002', '#1005']) + ->get() + ->keyBy('order_number'); + + $bank = $orders->get('#1005'); + + if (! $bank instanceof Order) { + abort(500, 'Seeded bank transfer order is missing.'); + } + + $bankLine = $bank->lines() + ->whereNotNull('variant_id') + ->firstOrFail(); + + InventoryItem::withoutGlobalScopes() + ->where('variant_id', $bankLine->variant_id) + ->update([ + 'quantity_on_hand' => 20, + 'quantity_reserved' => $bankLine->quantity, + ]); + + return [ + 'store' => $store, + 'paid' => $orders->get('#1001'), + 'fulfilled' => $orders->get('#1002'), + 'bank' => $bank, + ]; +} + +function adminOrderOpenOrders(mixed $testCase): mixed +{ + adminOrderFixtures(); + adminOrderAuthenticate($testCase); + + return visit('/admin/orders', adminOrderHost()) + ->wait(1) + ->assertPathIs('/admin/orders') + ->assertSee('Orders') + ->assertNoJavaScriptErrors(); +} + +function adminOrderOpenOrder(mixed $testCase, string $orderNumber): mixed +{ + return adminOrderOpenOrders($testCase) + ->assertSee($orderNumber) + ->click("a:has-text(\"{$orderNumber}\")") + ->wait(1) + ->assertSee($orderNumber) + ->assertNoJavaScriptErrors(); +} + +function adminOrderCreateFulfillmentInBrowser(mixed $page): mixed +{ + return $page + ->click('button[data-test="fulfillment-modal-button"]') + ->wait(1) + ->fill('input[wire\\:model="trackingCompany"]', 'DHL') + ->fill('input[wire\\:model="trackingNumber"]', 'DHL123456789') + ->click('button[data-test="fulfillment-submit-button"]') + ->wait(1) + ->assertSee('Fulfillment created') + ->assertSee('DHL') + ->assertSee('DHL123456789') + ->assertNoJavaScriptErrors(); +} + +test('shows the order list with seeded orders', function (): void { + adminOrderOpenOrders($this) + ->assertSee('#1001') + ->assertNoJavaScriptErrors(); +}); + +test('can filter orders by status', function (): void { + adminOrderOpenOrders($this) + ->select('select[wire\\:model\\.live="financialStatusFilter"]', 'paid') + ->wait(1) + ->assertSee('#1001') + ->assertDontSee('#1005') + ->assertNoJavaScriptErrors() + ->select('select[wire\\:model\\.live="fulfillmentStatusFilter"]', 'fulfilled') + ->wait(1) + ->assertSee('#1002') + ->assertNoJavaScriptErrors() + ->select('select[wire\\:model\\.live="financialStatusFilter"]', 'all') + ->select('select[wire\\:model\\.live="fulfillmentStatusFilter"]', 'all') + ->wait(1) + ->assertSee('#1001') + ->assertSee('#1005') + ->assertNoJavaScriptErrors(); +}); + +test('shows order detail with line items and totals', function (): void { + adminOrderOpenOrder($this, '#1001') + ->assertSee('Paid') + ->assertSee('Unfulfilled') + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('Subtotal') + ->assertSee('Shipping') + ->assertSee('Tax') + ->assertSee('Total') + ->assertNoJavaScriptErrors(); +}); + +test('shows order timeline events', function (): void { + adminOrderOpenOrder($this, '#1001') + ->assertSee('Timeline') + ->assertSee('Order placed') + ->assertNoJavaScriptErrors(); +}); + +test('can create a fulfillment', function (): void { + adminOrderCreateFulfillmentInBrowser(adminOrderOpenOrder($this, '#1001')); +}); + +test('can process a refund', function (): void { + adminOrderOpenOrder($this, '#1001') + ->click('button[data-test="refund-modal-button"]') + ->wait(1) + ->fill('input[wire\\:model="refundAmount"]', '10.00') + ->fill('textarea[wire\\:model="refundReason"]', 'Customer requested partial refund') + ->click('button[data-test="refund-submit-button"]') + ->wait(1) + ->assertSee('Refund processed') + ->assertSee('Partially Refunded') + ->assertNoJavaScriptErrors(); +}); + +test('shows customer information in order detail', function (): void { + adminOrderOpenOrder($this, '#1001') + ->assertSee('customer@acme.test') + ->assertNoJavaScriptErrors(); +}); + +test('can confirm bank transfer payment', function (): void { + adminOrderOpenOrder($this, '#1005') + ->assertSee('Pending') + ->assertPresent('button[data-test="confirm-payment-button"]') + ->click('button[data-test="confirm-payment-button"]') + ->wait(1) + ->assertSee('Payment confirmed') + ->assertSee('Paid') + ->assertDontSee('Confirm payment') + ->assertNoJavaScriptErrors(); +}); + +test('shows fulfillment guard for unpaid order', function (): void { + adminOrderOpenOrder($this, '#1005') + ->assertSee('Cannot create fulfillment') + ->assertSee('Payment must be confirmed before items can be fulfilled') + ->assertScript('document.querySelector("button[data-test=\"fulfillment-modal-button\"]") === null') + ->assertNoJavaScriptErrors(); +}); + +test('can mark fulfillment as shipped', function (): void { + $page = adminOrderCreateFulfillmentInBrowser(adminOrderOpenOrder($this, '#1001')); + + $page->click('button[data-test="mark-fulfillment-shipped-button"]') + ->wait(1) + ->assertSee('Shipped') + ->assertNoJavaScriptErrors(); +}); + +test('can mark fulfillment as delivered', function (): void { + $page = adminOrderCreateFulfillmentInBrowser(adminOrderOpenOrder($this, '#1001')); + + $page->click('button[data-test="mark-fulfillment-shipped-button"]') + ->wait(1) + ->click('button[data-test="mark-fulfillment-delivered-button"]') + ->wait(1) + ->assertSee('Delivered') + ->assertSee('Fulfilled') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Admin/PageManagementTest.php b/tests/Browser/Admin/PageManagementTest.php new file mode 100644 index 00000000..51490675 --- /dev/null +++ b/tests/Browser/Admin/PageManagementTest.php @@ -0,0 +1,109 @@ +seed(DatabaseSeeder::class); +}); + +afterEach(function (): void { + Playwright::setHost(null); +}); + +function adminPageBrowserHost(): array +{ + return ['host' => 'shop.test']; +} + +function adminPageBrowserAuthenticate(mixed $testCase): Store +{ + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $user = User::query()->where('email', 'admin@acme.test')->firstOrFail(); + + $testCase->actingAs($user); + $testCase->withSession(['current_store_id' => $store->getKey()]); + + return $store; +} + +function adminPageBrowserOpenPages(mixed $testCase): mixed +{ + adminPageBrowserAuthenticate($testCase); + + return visit('/admin/pages', adminPageBrowserHost()) + ->wait(1) + ->assertPathIs('/admin/pages') + ->assertSee('Pages') + ->assertNoJavaScriptErrors(); +} + +function adminPageBrowserSave(mixed $page): mixed +{ + return $page + ->click('button[data-test="page-save-button"]') + ->wait(1) + ->assertSee('Page saved') + ->assertNoJavaScriptErrors(); +} + +test('shows the pages list', function (): void { + adminPageBrowserOpenPages($this) + ->assertSee('About') + ->assertNoJavaScriptErrors(); +}); + +test('can create a new page', function (): void { + $store = adminPageBrowserAuthenticate($this); + $page = adminPageBrowserOpenPages($this) + ->click('a[href$="/admin/pages/create"]') + ->wait(1) + ->assertPathIs('/admin/pages/create') + ->assertSee('Create page') + ->assertNoJavaScriptErrors(); + + $page + ->fill('input[wire\\:model\\.live\\.debounce\\.300ms="title"]', 'FAQ') + ->fill('input[wire\\:model="handle"]', 'faq-browser') + ->fill('textarea[wire\\:model="bodyHtml"]', 'Frequently asked questions content here.'); + + adminPageBrowserSave($page); + + $this->assertDatabaseHas('pages', [ + 'store_id' => $store->getKey(), + 'title' => 'FAQ', + 'handle' => 'faq-browser', + 'body_html' => 'Frequently asked questions content here.', + ]); +}); + +test('can edit an existing page', function (): void { + $store = adminPageBrowserAuthenticate($this); + $about = Page::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', 'about') + ->firstOrFail(); + + $page = visit('/admin/pages', adminPageBrowserHost()) + ->wait(1) + ->assertPathIs('/admin/pages') + ->click('a:has-text("About")') + ->wait(1) + ->assertPathIs('/admin/pages/'.$about->getKey().'/edit') + ->assertSee('Edit page') + ->assertNoJavaScriptErrors(); + + $page->fill('textarea[wire\\:model="bodyHtml"]', 'Updated about page content.'); + + adminPageBrowserSave($page); + + expect($about->refresh()->body_html)->toBe('Updated about page content.'); +}); diff --git a/tests/Browser/Admin/ProductManagementTest.php b/tests/Browser/Admin/ProductManagementTest.php new file mode 100644 index 00000000..78997260 --- /dev/null +++ b/tests/Browser/Admin/ProductManagementTest.php @@ -0,0 +1,225 @@ +seed(DatabaseSeeder::class); +}); + +afterEach(function (): void { + Playwright::setHost(null); +}); + +function adminProductHost(): array +{ + return ['host' => 'shop.test']; +} + +function adminProductAuthenticate(mixed $testCase): void +{ + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $user = User::query()->where('email', 'admin@acme.test')->firstOrFail(); + + $testCase->actingAs($user); + $testCase->withSession(['current_store_id' => $store->getKey()]); +} + +function adminProductOpenProducts(mixed $testCase): mixed +{ + adminProductAuthenticate($testCase); + + return visit('/admin/products', adminProductHost()) + ->wait(1) + ->assertPathIs('/admin/products') + ->assertSee('Products') + ->assertNoJavaScriptErrors(); +} + +function adminProductFillProductForm( + mixed $page, + string $title, + string $handle, + string $sku, + string $price, + string $quantity, + string $description = '', + string $vendor = '', + string $productType = '', +): mixed { + $page + ->fill('input[wire\\:model\\.live\\.debounce\\.300ms="title"]', $title) + ->fill('input[wire\\:model="handle"]', $handle) + ->fill('input[wire\\:model="variants.0.sku"]', $sku) + ->fill('input[wire\\:model="variants.0.price"]', $price) + ->fill('input[wire\\:model="variants.0.quantity"]', $quantity); + + if ($description !== '') { + $page->fill('textarea[wire\\:model="descriptionHtml"]', $description); + } + + if ($vendor !== '') { + $page->fill('input[wire\\:model="vendor"]', $vendor); + } + + if ($productType !== '') { + $page->fill('input[wire\\:model="productType"]', $productType); + } + + return $page->wait(1); +} + +function adminProductSave(mixed $page): mixed +{ + return $page + ->click('button[data-test="product-save-button"]') + ->wait(1) + ->assertSee('Product saved') + ->assertNoJavaScriptErrors(); +} + +function adminProductReturnToList(mixed $page): mixed +{ + return $page + ->click('ui-sidebar a[href$="/admin/products"]') + ->wait(1) + ->assertPathIs('/admin/products') + ->assertSee('Products') + ->assertNoJavaScriptErrors(); +} + +test('shows the product list with seeded products', function (): void { + adminProductOpenProducts($this) + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('Premium Slim Fit Jeans') + ->assertNoJavaScriptErrors(); +}); + +test('can create a new product', function (): void { + $page = adminProductOpenProducts($this) + ->click('a[href$="/admin/products/create"]') + ->wait(1) + ->assertPathIs('/admin/products/create') + ->assertSee('Add product'); + + adminProductFillProductForm( + $page, + 'Test Product Created by E2E', + 'test-product-created-by-e2e', + 'E2E-TEST-001', + '29.99', + '50', + 'This product was created by the E2E test suite.', + 'Test Vendor', + 'T-Shirts', + ); + + adminProductSave($page); + + adminProductReturnToList($page) + ->assertSee('Test Product Created by E2E') + ->assertNoJavaScriptErrors(); +}); + +test('can edit an existing product title', function (): void { + $page = adminProductOpenProducts($this) + ->assertSee('Classic Cotton T-Shirt') + ->click('a:has-text("Classic Cotton T-Shirt")') + ->wait(1) + ->assertSee('Classic Cotton T-Shirt'); + + $page + ->fill('input[wire\\:model\\.live\\.debounce\\.300ms="title"]', 'Classic Cotton T-Shirt Updated') + ->wait(1); + + adminProductSave($page); + + adminProductReturnToList($page) + ->assertSee('Classic Cotton T-Shirt Updated') + ->assertNoJavaScriptErrors(); +}); + +test('can archive a product', function (): void { + $page = adminProductOpenProducts($this) + ->click('a[href$="/admin/products/create"]') + ->wait(1) + ->assertPathIs('/admin/products/create') + ->assertSee('Add product'); + + adminProductFillProductForm( + $page, + 'Product To Archive', + 'product-to-archive', + 'E2E-ARCHIVE-001', + '19.99', + '10', + ); + + adminProductSave($page); + + adminProductReturnToList($page) + ->assertSee('Product To Archive') + ->click('a:has-text("Product To Archive")') + ->wait(1) + ->assertSee('Product To Archive') + ->select('select[wire\\:model="status"]', 'archived'); + + adminProductSave($page); + + adminProductReturnToList($page) + ->assertDontSee('Product To Archive') + ->assertNoJavaScriptErrors(); +}); + +test('shows draft products only in admin and not storefront', function (): void { + $page = adminProductOpenProducts($this) + ->select('select[wire\\:model\\.live="statusFilter"]', 'draft') + ->wait(1) + ->assertSee('Unreleased Winter Jacket') + ->assertSee('Draft') + ->assertDontSee('Classic Cotton T-Shirt') + ->assertNoJavaScriptErrors(); + + $page->navigate('/collections/t-shirts') + ->wait(1) + ->assertPathIs('/collections/t-shirts') + ->assertDontSee('Unreleased Winter Jacket') + ->assertNoJavaScriptErrors() + ->navigate('/search?q=draft') + ->wait(1) + ->assertPathIs('/search') + ->assertDontSee('Unreleased Winter Jacket') + ->assertNoJavaScriptErrors(); +}); + +test('can search products in admin', function (): void { + adminProductOpenProducts($this) + ->fill('input[wire\\:model\\.live\\.debounce\\.300ms="search"]', 'Cotton') + ->wait(1) + ->assertSee('Classic Cotton T-Shirt') + ->assertDontSee('Premium Slim Fit Jeans') + ->assertNoJavaScriptErrors(); +}); + +test('can filter products by status in admin', function (): void { + adminProductOpenProducts($this) + ->select('select[wire\\:model\\.live="statusFilter"]', 'draft') + ->wait(1) + ->assertSee('Unreleased Winter Jacket') + ->assertDontSee('Classic Cotton T-Shirt') + ->assertNoJavaScriptErrors() + ->select('select[wire\\:model\\.live="statusFilter"]', 'active') + ->wait(1) + ->fill('input[wire\\:model\\.live\\.debounce\\.300ms="search"]', 'Classic') + ->wait(1) + ->assertSee('Classic Cotton T-Shirt') + ->assertDontSee('Unreleased Winter Jacket') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Admin/SettingsTest.php b/tests/Browser/Admin/SettingsTest.php new file mode 100644 index 00000000..2be491c9 --- /dev/null +++ b/tests/Browser/Admin/SettingsTest.php @@ -0,0 +1,165 @@ +seed(DatabaseSeeder::class); +}); + +afterEach(function (): void { + Playwright::setHost(null); +}); + +function adminSettingsBrowserHost(): array +{ + return ['host' => 'shop.test']; +} + +function adminSettingsBrowserStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function adminSettingsBrowserAuthenticate(mixed $testCase): Store +{ + $store = adminSettingsBrowserStore(); + $user = User::query()->where('email', 'admin@acme.test')->firstOrFail(); + + $testCase->actingAs($user); + $testCase->withSession(['current_store_id' => $store->getKey()]); + + return $store; +} + +function adminSettingsOpen(mixed $testCase): mixed +{ + adminSettingsBrowserAuthenticate($testCase); + + return visit('/admin/settings', adminSettingsBrowserHost()) + ->wait(1) + ->assertPathIs('/admin/settings') + ->assertSee('Store Settings') + ->assertNoJavaScriptErrors(); +} + +function adminSettingsOpenShipping(mixed $testCase): mixed +{ + return adminSettingsOpen($testCase) + ->click('a[href$="/admin/settings/shipping"]') + ->wait(1) + ->assertPathIs('/admin/settings/shipping') + ->assertSee('Shipping') + ->assertNoJavaScriptErrors(); +} + +function adminSettingsOpenTaxes(mixed $testCase): mixed +{ + return adminSettingsOpen($testCase) + ->click('a[href$="/admin/settings/taxes"]') + ->wait(1) + ->assertPathIs('/admin/settings/taxes') + ->assertSee('Tax Settings') + ->assertNoJavaScriptErrors(); +} + +test('can view store settings', function (): void { + adminSettingsOpen($this) + ->assertValue('input[wire\\:model="storeName"]', 'Acme Fashion') + ->assertNoJavaScriptErrors(); +}); + +test('can update store name', function (): void { + $store = adminSettingsBrowserAuthenticate($this); + + $page = adminSettingsOpen($this) + ->fill('input[wire\\:model="storeName"]', 'Acme Fashion Updated') + ->click('button[data-test="settings-save-button"]') + ->wait(1) + ->assertSee('Settings saved') + ->assertNoJavaScriptErrors(); + + expect($store->refresh()->name)->toBe('Acme Fashion Updated'); + + $page + ->navigate('/admin/settings') + ->wait(1) + ->assertValue('input[wire\\:model="storeName"]', 'Acme Fashion Updated') + ->assertNoJavaScriptErrors(); +}); + +test('can view shipping zones', function (): void { + adminSettingsOpenShipping($this) + ->assertSee('Domestic') + ->assertSee('Standard Shipping') + ->assertSee('4.99') + ->assertNoJavaScriptErrors(); +}); + +test('can add a new shipping rate to an existing zone', function (): void { + $store = adminSettingsBrowserAuthenticate($this); + + adminSettingsOpenShipping($this) + ->click('button[data-test="add-rate-domestic"]') + ->wait(1) + ->assertSee('Add rate') + ->fill('input[wire\\:model="rateName"]', 'Overnight Shipping') + ->fill('input[wire\\:model="rateAmount"]', '14.99') + ->click('button[data-test="shipping-rate-save-button"]') + ->wait(1) + ->assertSee('Shipping rate saved') + ->assertSee('Overnight Shipping') + ->assertSee('14.99') + ->assertNoJavaScriptErrors(); + + $domestic = ShippingZone::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('name', 'Domestic') + ->firstOrFail(); + + $rate = ShippingRate::withoutGlobalScopes() + ->where('zone_id', $domestic->getKey()) + ->where('name', 'Overnight Shipping') + ->firstOrFail(); + + expect(data_get($rate->config_json, 'amount'))->toBe(1499); +}); + +test('can view tax settings', function (): void { + adminSettingsOpenTaxes($this) + ->assertSee('Manual rates') + ->assertNoJavaScriptErrors(); +}); + +test('can update tax inclusion setting', function (): void { + $store = adminSettingsBrowserAuthenticate($this); + + adminSettingsOpenTaxes($this) + ->click('Prices include tax') + ->wait(1) + ->click('button[data-test="tax-settings-save-button"]') + ->wait(1) + ->assertSee('Tax settings saved') + ->assertNoJavaScriptErrors(); + + $settings = TaxSettings::withoutGlobalScopes()->whereKey($store->getKey())->firstOrFail(); + + expect($settings->prices_include_tax)->toBeFalse(); +}); + +test('can view domain settings', function (): void { + adminSettingsOpen($this) + ->assertSee('Domains') + ->assertSee('acme-fashion.test') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/SmokeTest.php b/tests/Browser/SmokeTest.php new file mode 100644 index 00000000..50d5afec --- /dev/null +++ b/tests/Browser/SmokeTest.php @@ -0,0 +1,197 @@ +seed(DatabaseSeeder::class); +}); + +function browserSmokeStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function browserSmokeHost(): array +{ + return ['host' => 'shop.test']; +} + +function browserSmokeProduct(string $handle = 'classic-cotton-t-shirt'): Product +{ + $store = browserSmokeStore(); + + return Product::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', $handle) + ->firstOrFail(); +} + +function browserSmokeCollection(string $handle = 'new-arrivals'): ProductCollection +{ + $store = browserSmokeStore(); + + return ProductCollection::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', $handle) + ->firstOrFail(); +} + +function browserSmokePage(string $handle = 'about'): Page +{ + $store = browserSmokeStore(); + + return Page::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', $handle) + ->firstOrFail(); +} + +test('loads the storefront home page', function (): void { + $store = browserSmokeStore(); + + visit('/', browserSmokeHost()) + ->assertSee($store->name) + ->assertNoJavaScriptErrors(); +}); + +test('loads a collection page', function (): void { + $collection = browserSmokeCollection(); + + visit("/collections/{$collection->handle}", browserSmokeHost()) + ->assertSee($collection->title) + ->assertNoJavaScriptErrors(); +}); + +test('loads a product page', function (): void { + $product = browserSmokeProduct(); + + visit("/products/{$product->handle}", browserSmokeHost()) + ->assertSee($product->title) + ->assertNoJavaScriptErrors(); +}); + +test('loads the cart page', function (): void { + visit('/cart', browserSmokeHost()) + ->assertSee('Cart') + ->assertNoJavaScriptErrors(); +}); + +test('loads the customer login page', function (): void { + visit('/account/login', browserSmokeHost()) + ->assertSee('Log in') + ->assertNoJavaScriptErrors(); +}); + +test('loads the admin login page', function (): void { + visit('/admin/login', browserSmokeHost()) + ->assertSee('Sign in') + ->assertNoJavaScriptErrors(); +}); + +test('loads the about page', function (): void { + $page = browserSmokePage(); + + visit("/pages/{$page->handle}", browserSmokeHost()) + ->assertSee($page->title) + ->assertNoJavaScriptErrors(); +}); + +test('loads the search page', function (): void { + visit('/search?q=shirt', browserSmokeHost()) + ->assertSee('Search') + ->assertSee('Classic Cotton T-Shirt') + ->assertNoJavaScriptErrors(); +}); + +test('loads all collections listing', function (): void { + visit('/collections', browserSmokeHost()) + ->assertSee('Collections') + ->assertSee('New Arrivals') + ->assertNoJavaScriptErrors(); +}); + +test('has no errors on critical pages', function (): void { + $store = browserSmokeStore(); + $product = browserSmokeProduct(); + $collection = browserSmokeCollection(); + $page = browserSmokePage(); + + $pages = visit([ + '/', + '/collections', + "/collections/{$collection->handle}", + "/products/{$product->handle}", + '/search?q=shirt', + "/pages/{$page->handle}", + '/cart', + '/account/login', + '/account/register', + '/forgot-password', + '/reset-password/test-token?email=customer@example.test', + '/account/forgot-password', + '/account/reset-password/test-token?email=customer@example.test', + '/admin/login', + ], browserSmokeHost()); + + $pages->assertNoJavaScriptErrors(); + + [$home, $collections, $collectionPage, $productPage, $search, $contentPage, $cart, $login, $register, $forgotPassword, $resetPassword, $accountForgotPassword, $accountResetPassword, $adminLogin] = $pages; + + $home->assertSee($store->name); + $collections->assertSee('Collections'); + $collectionPage->assertSee($collection->title); + $productPage->assertSee($product->title); + $search->assertSee('Search'); + $contentPage->assertSee($page->title); + $cart->assertSee('Cart'); + $login->assertSee('Log in'); + $register->assertSee('Create an account'); + $forgotPassword->assertSee('Reset password'); + $resetPassword->assertSee('New password'); + $accountForgotPassword->assertSee('Reset password'); + $accountResetPassword->assertSee('New password'); + $adminLogin->assertSee('Sign in'); + + $expected = [ + '/admin' => 'Dashboard', + '/admin/analytics' => 'Analytics', + '/admin/apps' => 'Apps', + '/admin/developers' => 'Developers', + '/admin/products' => 'Products', + '/admin/collections' => 'Collections', + '/admin/inventory' => 'Inventory', + '/admin/orders' => 'Orders', + '/admin/customers' => 'Customers', + '/admin/discounts' => 'Discounts', + '/admin/pages' => 'Pages', + '/admin/navigation' => 'Navigation', + '/admin/themes' => 'Themes', + '/admin/settings' => 'Settings', + '/admin/settings/shipping' => 'Shipping', + '/admin/settings/taxes' => 'Taxes', + '/admin/settings/checkout' => 'Checkout', + '/admin/settings/notifications' => 'Notifications', + '/admin/search/settings' => 'Search', + ]; + + $user = User::query()->where('email', 'admin@acme.test')->firstOrFail(); + + $this->actingAs($user); + $this->withSession(['current_store_id' => $store->getKey()]); + + $adminPages = visit(array_keys($expected), browserSmokeHost()); + + $adminPages->assertNoJavaScriptErrors(); + + foreach ($adminPages as $index => $adminPage) { + $adminPage->assertSee(array_values($expected)[$index]); + } +}); diff --git a/tests/Browser/Storefront/AccessibilityTest.php b/tests/Browser/Storefront/AccessibilityTest.php new file mode 100644 index 00000000..5780de4c --- /dev/null +++ b/tests/Browser/Storefront/AccessibilityTest.php @@ -0,0 +1,210 @@ +seed(DatabaseSeeder::class); +}); + +afterEach(function (): void { + Playwright::setHost(null); +}); + +function storefrontAccessibilityHost(): array +{ + return ['host' => 'shop.test']; +} + +function storefrontAccessibilityAddClassicToCart(): mixed +{ + return visit('/products/classic-cotton-t-shirt', storefrontAccessibilityHost()) + ->click('M') + ->wait(1) + ->click('Black') + ->wait(1) + ->click('Add to cart') + ->wait(1); +} + +function storefrontAccessibilityCheckoutStart(): mixed +{ + return storefrontAccessibilityAddClassicToCart() + ->navigate('/cart') + ->wait(1) + ->click('main button:has-text("Checkout")') + ->wait(1) + ->assertPathBeginsWith('/checkout/'); +} + +test('home page has no javascript errors or console warnings', function (): void { + visit('/', storefrontAccessibilityHost()) + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs() + ->assertNoAccessibilityIssues(); +}); + +test('home page has proper heading hierarchy', function (): void { + visit('/', storefrontAccessibilityHost()) + ->assertSee('Acme Fashion') + ->assertScript('document.querySelectorAll("h1").length === 1') + ->assertScript('document.querySelector("h1").textContent.includes("Acme Fashion")') + ->assertScript(<<<'JS' +function() { + let previous = 0; + + for (const heading of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) { + const level = Number(heading.tagName.slice(1)); + + if (previous !== 0 && level > previous + 1) { + return false; + } + + previous = level; + } + + return true; +} +JS) + ->assertNoJavaScriptErrors(); +}); + +test('product page has proper aria labels for variant selectors', function (): void { + visit('/products/classic-cotton-t-shirt', storefrontAccessibilityHost()) + ->assertSee('Size') + ->assertSee('Color') + ->assertVisible('button:has-text("Add to cart")') + ->assertButtonEnabled('button:has-text("Add to cart")') + ->assertScript(<<<'JS' +function() { + const optionGroups = Array.from(document.querySelectorAll('main fieldset')); + + return optionGroups.length >= 2 + && optionGroups.every((group) => group.querySelector('legend')?.textContent.trim().length > 0) + && optionGroups.every((group) => Array.from(group.querySelectorAll('button')).every((button) => button.hasAttribute('aria-pressed'))); +} +JS) + ->assertNoJavaScriptErrors(); +}); + +test('product page images or placeholders have accessible text', function (): void { + visit('/products/classic-cotton-t-shirt', storefrontAccessibilityHost()) + ->assertScript(<<<'JS' +function() { + const images = Array.from(document.querySelectorAll('main img')); + + if (images.length > 0) { + return images.every((image) => image.getAttribute('alt')?.trim().length > 0); + } + + const placeholder = document.querySelector('[data-test="product-image-placeholder"]'); + + return placeholder?.getAttribute('role') === 'img' + && placeholder.getAttribute('aria-label')?.includes('Classic Cotton T-Shirt'); +} +JS) + ->assertNoJavaScriptErrors(); +}); + +test('customer login form has accessible labels', function (): void { + visit('/account/login', storefrontAccessibilityHost()) + ->assertSee('Email address') + ->assertSee('Password') + ->assertScript(<<<'JS' +function() { + return Array.from(document.querySelectorAll('form input')).every((input) => { + return input.labels.length > 0 + || input.getAttribute('aria-label') + || input.closest('[data-flux-field]')?.querySelector('[data-flux-label]'); + }); +} +JS) + ->assertNoJavaScriptErrors(); +}); + +test('admin login form has accessible labels', function (): void { + visit('/admin/login', storefrontAccessibilityHost()) + ->assertSee('Email address') + ->assertSee('Password') + ->assertScript(<<<'JS' +function() { + return Array.from(document.querySelectorAll('form input')).every((input) => { + return input.labels.length > 0 + || input.getAttribute('aria-label') + || input.closest('[data-flux-field]')?.querySelector('[data-flux-label]'); + }); +} +JS) + ->assertNoJavaScriptErrors(); +}); + +test('checkout form has accessible labels', function (): void { + storefrontAccessibilityCheckoutStart() + ->assertSee('Email') + ->assertScript(<<<'JS' +function() { + return Array.from(document.querySelectorAll('form[wire\\:submit="saveAddress"] input, form[wire\\:submit="saveAddress"] select')).every((control) => { + return control.labels.length > 0 + || control.getAttribute('aria-label') + || control.closest('[data-flux-field]')?.querySelector('[data-flux-label]'); + }); +} +JS) + ->assertNoJavaScriptErrors(); +}); + +test('checkout validation errors are accessible', function (): void { + storefrontAccessibilityCheckoutStart() + ->click('form[wire\\:submit="saveAddress"] button[type="submit"]') + ->wait(1) + ->assertSee('email field is required') + ->assertScript(<<<'JS' +function() { + const email = document.querySelector('input[wire\\:model="email"]'); + const describedBy = email?.getAttribute('aria-describedby'); + const error = describedBy ? document.getElementById(describedBy) : null; + + return email?.getAttribute('aria-invalid') === 'true' + && Boolean(error) + && error.textContent.includes('email field is required'); +} +JS) + ->assertNoJavaScriptErrors(); +}); + +test('can navigate storefront with keyboard only', function (): void { + $page = visit('/', storefrontAccessibilityHost()); + + $page->script('() => document.querySelector(\'a[href="#main-content"]\').focus()'); + + $page->assertScript('document.activeElement.textContent.includes("Skip to main content")'); + + $page->script('() => document.querySelector(\'nav[aria-label="Main navigation"] a[href$="/collections"]\').focus()'); + + $page + ->wait(0.2) + ->assertScript('document.activeElement.matches(\'nav[aria-label="Main navigation"] a[href$="/collections"]\')') + ->keys('nav[aria-label="Main navigation"] a[href$="/collections"]', 'Enter') + ->wait(1) + ->assertPathIs('/collections') + ->assertNoJavaScriptErrors(); +}); + +test('cart page has no console errors or warnings', function (): void { + visit('/cart', storefrontAccessibilityHost()) + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs() + ->assertNoAccessibilityIssues(); +}); + +test('search page has proper form labels', function (): void { + visit('/search?q=shirt', storefrontAccessibilityHost()) + ->assertSee('Search results') + ->assertScript('document.querySelector("main input[aria-label=\"Search products\"]") !== null') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Storefront/BrowsingTest.php b/tests/Browser/Storefront/BrowsingTest.php new file mode 100644 index 00000000..d99d83f4 --- /dev/null +++ b/tests/Browser/Storefront/BrowsingTest.php @@ -0,0 +1,143 @@ +seed(DatabaseSeeder::class); +}); + +afterEach(function (): void { + Playwright::setHost(null); +}); + +function storefrontBrowsingHost(): array +{ + return ['host' => 'shop.test']; +} + +test('shows featured products on home page', function (): void { + visit('/', storefrontBrowsingHost()) + ->assertSee('Acme Fashion') + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('24.99') + ->assertNoJavaScriptErrors(); +}); + +test('shows collection with product grid', function (): void { + visit('/collections/t-shirts', storefrontBrowsingHost()) + ->assertSee('T-Shirts') + ->assertSee('Classic Cotton T-Shirt') + ->assertNoJavaScriptErrors(); +}); + +test('can navigate from collection to product', function (): void { + visit('/collections/t-shirts', storefrontBrowsingHost()) + ->click('a[href$="/products/classic-cotton-t-shirt"]') + ->wait(1) + ->assertPathIs('/products/classic-cotton-t-shirt') + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('24.99') + ->assertSee('Add to cart') + ->assertNoJavaScriptErrors(); +}); + +test('shows product detail with variant options', function (): void { + visit('/products/classic-cotton-t-shirt', storefrontBrowsingHost()) + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('24.99') + ->assertSee('Size') + ->assertSee('Color') + ->assertNoJavaScriptErrors(); +}); + +test('shows size and color option values', function (): void { + visit('/products/classic-cotton-t-shirt', storefrontBrowsingHost()) + ->assertSee('S') + ->assertSee('M') + ->assertSee('L') + ->assertSee('XL') + ->assertSee('Black') + ->assertSee('White') + ->assertSee('Navy') + ->assertNoJavaScriptErrors(); +}); + +test('shows sale and compare at pricing for sale product', function (): void { + visit('/products/premium-slim-fit-jeans', storefrontBrowsingHost()) + ->assertSee('Premium Slim Fit Jeans') + ->assertSee('79.99') + ->assertSee('99.99') + ->assertPresent('.line-through') + ->assertNoJavaScriptErrors(); +}); + +test('shows search results for valid query', function (): void { + visit('/search?q=cotton', storefrontBrowsingHost()) + ->assertSee('Classic Cotton T-Shirt') + ->assertNoJavaScriptErrors(); +}); + +test('shows no results message for invalid query', function (): void { + visit('/search?q=zznonexistentproductzz', storefrontBrowsingHost()) + ->assertSee('No products found') + ->assertNoJavaScriptErrors(); +}); + +test('does not show draft products on storefront collections', function (): void { + visit('/collections', storefrontBrowsingHost()) + ->assertDontSee('Unreleased Winter Jacket') + ->assertNoJavaScriptErrors(); +}); + +test('does not show draft products in search results', function (): void { + visit('/search?q=draft', storefrontBrowsingHost()) + ->assertDontSee('Unreleased Winter Jacket') + ->assertNoJavaScriptErrors(); +}); + +test('shows out of stock messaging for deny policy product', function (): void { + visit('/products/limited-edition-sneakers', storefrontBrowsingHost()) + ->assertSee('Limited Edition Sneakers') + ->assertSee('Out of stock') + ->assertSee('Sold out') + ->assertButtonDisabled('button:has-text("Sold out")') + ->assertDontSee('Add to cart') + ->assertNoJavaScriptErrors(); +}); + +test('shows backorder messaging for continue policy product', function (): void { + visit('/products/backorder-denim-jacket', storefrontBrowsingHost()) + ->assertSee('Backorder Denim Jacket') + ->assertSee('Available on backorder') + ->assertSee('Add to cart') + ->assertButtonEnabled('button:has-text("Add to cart")') + ->assertNoJavaScriptErrors(); +}); + +test('shows new arrivals collection', function (): void { + visit('/collections/new-arrivals', storefrontBrowsingHost()) + ->assertSee('New Arrivals') + ->assertNoJavaScriptErrors(); +}); + +test('shows static about page', function (): void { + visit('/pages/about', storefrontBrowsingHost()) + ->assertSee('About') + ->assertNoJavaScriptErrors(); +}); + +test('navigates between pages using the main navigation', function (): void { + visit('/', storefrontBrowsingHost()) + ->hover('nav[aria-label="Main navigation"] a[href$="/collections"]') + ->click('nav[aria-label="Main navigation"] a[href$="/collections/t-shirts"]') + ->wait(1) + ->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..4e968207 --- /dev/null +++ b/tests/Browser/Storefront/CartTest.php @@ -0,0 +1,143 @@ +seed(DatabaseSeeder::class); +}); + +afterEach(function (): void { + Playwright::setHost(null); +}); + +function storefrontCartHost(): array +{ + return ['host' => 'shop.test']; +} + +function storefrontCartAddClassicTShirt(): mixed +{ + return visit('/products/classic-cotton-t-shirt', storefrontCartHost()) + ->click('M') + ->wait(1) + ->click('Black') + ->wait(1) + ->click('Add to cart') + ->wait(1); +} + +function storefrontCartPageWithClassicTShirt(): mixed +{ + return storefrontCartAddClassicTShirt() + ->navigate('/cart') + ->wait(1) + ->assertSee('Your Cart') + ->assertSee('Classic Cotton T-Shirt'); +} + +function storefrontCartApplyDiscount(string $code): mixed +{ + return storefrontCartPageWithClassicTShirt() + ->fill('input[wire\\:model="discountCode"]', $code) + ->click('button:has-text("Apply")') + ->wait(1); +} + +test('can add product to cart', function (): void { + storefrontCartAddClassicTShirt() + ->navigate('/cart') + ->wait(1) + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('24.99') + ->assertNoJavaScriptErrors(); +}); + +test('can view cart with added item', function (): void { + storefrontCartPageWithClassicTShirt() + ->assertSee('24.99') + ->assertNoJavaScriptErrors(); +}); + +test('can update quantity in cart', function (): void { + storefrontCartPageWithClassicTShirt() + ->click('main button[aria-label="Increase Classic Cotton T-Shirt quantity"]') + ->wait(1) + ->assertSee('2 items') + ->assertSee('49.98') + ->assertNoJavaScriptErrors(); +}); + +test('can remove item from cart', function (): void { + storefrontCartPageWithClassicTShirt() + ->click('main button[aria-label="Remove Classic Cotton T-Shirt"]') + ->wait(1) + ->assertSee('Your cart is empty') + ->assertNoJavaScriptErrors(); +}); + +test('can add multiple different products', function (): void { + storefrontCartAddClassicTShirt() + ->navigate('/products/premium-slim-fit-jeans') + ->wait(1) + ->click('Add to cart') + ->wait(1) + ->navigate('/cart') + ->wait(1) + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('Premium Slim Fit Jeans') + ->assertNoJavaScriptErrors(); +}); + +test('can apply valid discount code welcome ten', function (): void { + storefrontCartApplyDiscount('WELCOME10') + ->assertSee('WELCOME10') + ->assertSee('Discount') + ->assertSee('2.50') + ->assertNoJavaScriptErrors(); +}); + +test('shows error for invalid discount code', function (): void { + storefrontCartApplyDiscount('INVALID') + ->assertSee('Invalid discount code') + ->assertNoJavaScriptErrors(); +}); + +test('shows error for expired discount code', function (): void { + storefrontCartApplyDiscount('EXPIRED20') + ->assertSee('expired') + ->assertNoJavaScriptErrors(); +}); + +test('shows error for maxed out discount code', function (): void { + storefrontCartApplyDiscount('MAXED') + ->assertSee('usage limit') + ->assertNoJavaScriptErrors(); +}); + +test('can apply free shipping discount', function (): void { + storefrontCartApplyDiscount('FREESHIP') + ->assertSee('FREESHIP') + ->assertSee('Free shipping') + ->assertNoJavaScriptErrors(); +}); + +test('can apply flat five discount for fixed amount off', function (): void { + storefrontCartApplyDiscount('FLAT5') + ->assertSee('FLAT5') + ->assertSee('5.00') + ->assertNoJavaScriptErrors(); +}); + +test('shows subtotal and total in cart', function (): void { + storefrontCartPageWithClassicTShirt() + ->assertSee('Subtotal') + ->assertSee('Estimated total') + ->assertSee('24.99') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Storefront/CheckoutTest.php b/tests/Browser/Storefront/CheckoutTest.php new file mode 100644 index 00000000..c9ce6a1e --- /dev/null +++ b/tests/Browser/Storefront/CheckoutTest.php @@ -0,0 +1,281 @@ +seed(DatabaseSeeder::class); +}); + +afterEach(function (): void { + Playwright::setHost(null); +}); + +function storefrontCheckoutHost(): array +{ + return ['host' => 'shop.test']; +} + +function storefrontCheckoutStart(): mixed +{ + return visit('/products/classic-cotton-t-shirt', storefrontCheckoutHost()) + ->click('M') + ->wait(1) + ->click('Black') + ->wait(1) + ->click('Add to cart') + ->wait(1) + ->navigate('/cart') + ->wait(1) + ->click('main button:has-text("Checkout")') + ->wait(1) + ->assertPathBeginsWith('/checkout/') + ->assertSee('Checkout'); +} + +/** + * @param array $overrides + */ +function storefrontCheckoutFillAddress(mixed $page, array $overrides = []): mixed +{ + $address = [ + 'email' => 'test-buyer@example.com', + 'first_name' => 'Test', + 'last_name' => 'Buyer', + 'address1' => 'Teststrasse 1', + 'city' => 'Berlin', + 'postal_code' => '10115', + 'country' => 'DE', + ]; + + $address = array_replace($address, $overrides); + + return $page + ->fill('input[wire\\:model="email"]', $address['email']) + ->fill('input[wire\\:model="shippingAddress.first_name"]', $address['first_name']) + ->fill('input[wire\\:model="shippingAddress.last_name"]', $address['last_name']) + ->fill('input[wire\\:model="shippingAddress.address1"]', $address['address1']) + ->fill('input[wire\\:model="shippingAddress.city"]', $address['city']) + ->fill('input[wire\\:model="shippingAddress.postal_code"]', $address['postal_code']) + ->select('select[wire\\:model="shippingAddress.country"]', $address['country']); +} + +function storefrontCheckoutSubmitAddress(mixed $page): mixed +{ + return $page + ->click('form[wire\\:submit="saveAddress"] button[type="submit"]') + ->wait(1); +} + +function storefrontCheckoutReachShipping(array $address = []): mixed +{ + return storefrontCheckoutSubmitAddress( + storefrontCheckoutFillAddress(storefrontCheckoutStart(), $address), + ); +} + +function storefrontCheckoutReachPayment(array $address = []): mixed +{ + return storefrontCheckoutReachShipping($address) + ->click('button:has-text("Standard Shipping")') + ->wait(1) + ->click('button[wire\\:click="selectShippingMethod"]') + ->wait(1) + ->assertSee('Payment') + ->assertSee('Pay now'); +} + +function storefrontCheckoutFillSuccessfulCard(mixed $page): mixed +{ + return $page + ->fill('input[wire\\:model="cardNumber"]', '4242 4242 4242 4242') + ->fill('input[wire\\:model="cardName"]', 'Test Buyer') + ->fill('input[wire\\:model="cardExpiry"]', '12/28') + ->fill('input[wire\\:model="cardCvc"]', '123'); +} + +function storefrontCheckoutApplyCartDiscount(string $code): mixed +{ + return visit('/products/classic-cotton-t-shirt', storefrontCheckoutHost()) + ->click('M') + ->wait(1) + ->click('Black') + ->wait(1) + ->click('Add to cart') + ->wait(1) + ->navigate('/cart') + ->wait(1) + ->fill('input[wire\\:model="discountCode"]', $code) + ->click('button:has-text("Apply")') + ->wait(1); +} + +test('completes full checkout with credit card', function (): void { + storefrontCheckoutFillSuccessfulCard(storefrontCheckoutReachPayment()) + ->assertSee('29.98') + ->click('button:has-text("Pay now")') + ->wait(2) + ->assertPathIs('/checkout/*/confirmation') + ->assertSee('Thank you') + ->assertSee('#1016') + ->assertNoJavaScriptErrors(); +}); + +test('shows shipping methods based on german address', function (): void { + storefrontCheckoutReachShipping([ + 'email' => 'test@example.com', + 'first_name' => 'Hans', + 'last_name' => 'Mueller', + 'address1' => 'Berliner Str. 10', + 'city' => 'Munich', + 'postal_code' => '80331', + 'country' => 'DE', + ]) + ->assertSee('Standard Shipping') + ->assertSee('4.99') + ->assertNoJavaScriptErrors(); +}); + +test('shows international shipping methods for non german address', function (): void { + storefrontCheckoutReachShipping([ + 'email' => 'test@example.com', + 'first_name' => 'John', + 'last_name' => 'Smith', + 'address1' => '123 Main St', + 'city' => 'New York', + 'postal_code' => '10001', + 'country' => 'US', + ]) + ->assertSee('International Shipping') + ->assertSee('14.99') + ->assertNoJavaScriptErrors(); +}); + +test('applies discount during checkout', function (): void { + $page = storefrontCheckoutApplyCartDiscount('FLAT5') + ->assertSee('FLAT5') + ->click('main button:has-text("Checkout")') + ->wait(1) + ->assertPathBeginsWith('/checkout/'); + + storefrontCheckoutSubmitAddress(storefrontCheckoutFillAddress($page)) + ->click('button:has-text("Standard Shipping")') + ->wait(1) + ->click('button[wire\\:click="selectShippingMethod"]') + ->wait(1) + ->assertSee('FLAT5') + ->assertSee('5.00') + ->assertSee('24.98') + ->assertNoJavaScriptErrors(); +}); + +test('validates required contact email', function (): void { + storefrontCheckoutStart() + ->click('form[wire\\:submit="saveAddress"] button[type="submit"]') + ->wait(1) + ->assertSee('email field is required') + ->assertNoJavaScriptErrors(); +}); + +test('validates required shipping address fields', function (): void { + storefrontCheckoutStart() + ->fill('input[wire\\:model="email"]', 'test@example.com') + ->click('form[wire\\:submit="saveAddress"] button[type="submit"]') + ->wait(1) + ->assertSee('first name field is required') + ->assertSee('last name field is required') + ->assertSee('address1 field is required') + ->assertSee('city field is required') + ->assertSee('postal code field is required') + ->assertNoJavaScriptErrors(); +}); + +test('validates invalid postal code format', function (): void { + storefrontCheckoutSubmitAddress(storefrontCheckoutFillAddress(storefrontCheckoutStart(), [ + 'postal_code' => 'INVALID', + ])) + ->assertSee('postal code format is invalid') + ->assertNoJavaScriptErrors(); +}); + +test('prevents checkout with empty cart', function (): void { + visit('/cart', storefrontCheckoutHost()) + ->assertSee('Your cart is empty') + ->assertButtonDisabled('main button:has-text("Checkout")') + ->assertNoJavaScriptErrors(); +}); + +test('completes checkout with paypal', function (): void { + storefrontCheckoutReachPayment() + ->select('select[wire\\:model\\.live="paymentMethod"]', 'paypal') + ->wait(1) + ->assertSee('Pay with PayPal') + ->click('button:has-text("Pay with PayPal")') + ->wait(2) + ->assertPathIs('/checkout/*/confirmation') + ->assertSee('Thank you') + ->assertSee('PayPal') + ->assertNoJavaScriptErrors(); +}); + +test('completes checkout with bank transfer', function (): void { + storefrontCheckoutReachPayment() + ->select('select[wire\\:model\\.live="paymentMethod"]', 'bank_transfer') + ->wait(1) + ->assertSee('bank transfer instructions') + ->click('button:has-text("Place order")') + ->wait(2) + ->assertPathIs('/checkout/*/confirmation') + ->assertSee('Thank you') + ->assertSee('IBAN') + ->assertSee('BIC') + ->assertSee('Reference') + ->assertSee('#1016') + ->assertNoJavaScriptErrors(); +}); + +test('shows error for declined credit card', function (): void { + storefrontCheckoutReachPayment() + ->fill('input[wire\\:model="cardNumber"]', '4000 0000 0000 0002') + ->fill('input[wire\\:model="cardName"]', 'Test Buyer') + ->fill('input[wire\\:model="cardExpiry"]', '12/28') + ->fill('input[wire\\:model="cardCvc"]', '123') + ->click('button:has-text("Pay now")') + ->wait(2) + ->assertPathBeginsWith('/checkout/') + ->assertSee('declined') + ->assertNoJavaScriptErrors(); +}); + +test('shows error for insufficient funds', function (): void { + storefrontCheckoutReachPayment() + ->fill('input[wire\\:model="cardNumber"]', '4000 0000 0000 9995') + ->fill('input[wire\\:model="cardName"]', 'Test Buyer') + ->fill('input[wire\\:model="cardExpiry"]', '12/28') + ->fill('input[wire\\:model="cardCvc"]', '123') + ->click('button:has-text("Pay now")') + ->wait(2) + ->assertPathBeginsWith('/checkout/') + ->assertSee('insufficient') + ->assertNoJavaScriptErrors(); +}); + +test('switches between payment method forms', function (): void { + storefrontCheckoutReachPayment() + ->assertSee('Card number') + ->assertSee('Pay now') + ->select('select[wire\\:model\\.live="paymentMethod"]', 'paypal') + ->wait(1) + ->assertMissing('input[wire\\:model="cardNumber"]') + ->assertSee('Pay with PayPal') + ->select('select[wire\\:model\\.live="paymentMethod"]', 'bank_transfer') + ->wait(1) + ->assertSee('Place order') + ->assertSee('bank transfer instructions') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Storefront/CustomerAccountTest.php b/tests/Browser/Storefront/CustomerAccountTest.php new file mode 100644 index 00000000..c6efdc3a --- /dev/null +++ b/tests/Browser/Storefront/CustomerAccountTest.php @@ -0,0 +1,214 @@ +seed(DatabaseSeeder::class); +}); + +afterEach(function (): void { + Playwright::setHost(null); +}); + +function storefrontAccountHost(): array +{ + return ['host' => 'shop.test']; +} + +function storefrontAccountLogin(): mixed +{ + return visit('/account/login', storefrontAccountHost()) + ->fill('input[type=email]', 'customer@acme.test') + ->fill('input[type=password]', 'password') + ->click('@customer-login-button') + ->wait(1) + ->assertPathIs('/account') + ->assertSee('My Account') + ->assertSee('John Doe') + ->assertNoJavaScriptErrors(); +} + +function storefrontAccountStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function storefrontAccountCustomer(): Customer +{ + $store = storefrontAccountStore(); + + return Customer::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('email', 'customer@acme.test') + ->firstOrFail(); +} + +/** + * @return Collection + */ +function storefrontAccountSeededOrders(): Collection +{ + $store = storefrontAccountStore(); + $customer = storefrontAccountCustomer(); + + return Order::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('customer_id', $customer->getKey()) + ->whereIn('order_number', ['#1001', '#1002', '#1004']) + ->orderBy('order_number') + ->get(); +} + +test('can register a new customer', function (): void { + visit('/account/register', storefrontAccountHost()) + ->fill('input[wire\\:model="name"]', 'New Customer') + ->fill('input[wire\\:model="email"]', 'new-customer-e2e@example.com') + ->fill('input[wire\\:model="password"]', 'password123') + ->fill('input[wire\\:model="password_confirmation"]', 'password123') + ->click('@customer-register-button') + ->wait(1) + ->assertPathIs('/account') + ->assertSee('My Account') + ->assertNoJavaScriptErrors(); +}); + +test('shows validation errors for duplicate email registration', function (): void { + visit('/account/register', storefrontAccountHost()) + ->fill('input[wire\\:model="name"]', 'Duplicate Customer') + ->fill('input[wire\\:model="email"]', 'customer@acme.test') + ->fill('input[wire\\:model="password"]', 'password123') + ->fill('input[wire\\:model="password_confirmation"]', 'password123') + ->click('@customer-register-button') + ->wait(1) + ->assertPathIs('/account/register') + ->assertSee('already been taken') + ->assertNoJavaScriptErrors(); +}); + +test('shows validation errors for mismatched passwords', function (): void { + visit('/account/register', storefrontAccountHost()) + ->fill('input[wire\\:model="name"]', 'Test Customer') + ->fill('input[wire\\:model="email"]', 'mismatch@example.com') + ->fill('input[wire\\:model="password"]', 'password123') + ->fill('input[wire\\:model="password_confirmation"]', 'different456') + ->click('@customer-register-button') + ->wait(1) + ->assertPathIs('/account/register') + ->assertSee('password') + ->assertNoJavaScriptErrors(); +}); + +test('can log in as existing customer', function (): void { + storefrontAccountLogin(); +}); + +test('shows error for invalid customer credentials', function (): void { + visit('/account/login', storefrontAccountHost()) + ->fill('input[type=email]', 'customer@acme.test') + ->fill('input[type=password]', 'wrongpassword') + ->click('@customer-login-button') + ->wait(1) + ->assertPathIs('/account/login') + ->assertSee('Invalid credentials') + ->assertNoJavaScriptErrors(); +}); + +test('redirects unauthenticated customers to login', function (): void { + visit('/account', storefrontAccountHost()) + ->wait(1) + ->assertPathIs('/account/login') + ->assertSee('Log in') + ->assertNoJavaScriptErrors(); +}); + +test('shows order history for logged in customer', function (): void { + storefrontAccountSeededOrders(); + + storefrontAccountLogin() + ->click('nav[aria-label="Account navigation"] a[href$="/account/orders"]') + ->wait(1) + ->assertPathIs('/account/orders') + ->assertSee('#1001') + ->assertSee('#1002') + ->assertSee('#1004') + ->assertNoJavaScriptErrors(); +}); + +test('shows order detail for customer order', function (): void { + $orders = storefrontAccountSeededOrders(); + $order = $orders->first(); + + storefrontAccountLogin() + ->click('nav[aria-label="Account navigation"] a[href$="/account/orders"]') + ->wait(1) + ->click('a[href$="/account/orders/'.$order->getKey().'"]') + ->wait(1) + ->assertPathIs('/account/orders/'.$order->getKey()) + ->assertSee('#1001') + ->assertSee('Subtotal') + ->assertSee('Total') + ->assertNoJavaScriptErrors(); +}); + +test('can view addresses', function (): void { + storefrontAccountLogin() + ->click('nav[aria-label="Account navigation"] a[href$="/account/addresses"]') + ->wait(1) + ->assertPathIs('/account/addresses') + ->assertSee('Hauptstrasse 1') + ->assertSee('Berlin') + ->assertNoJavaScriptErrors(); +}); + +test('can add a new address', function (): void { + storefrontAccountLogin() + ->click('nav[aria-label="Account navigation"] a[href$="/account/addresses"]') + ->wait(1) + ->click('button:has-text("Add address")') + ->wait(1) + ->fill('input[wire\\:model="address.first_name"]', 'John') + ->fill('input[wire\\:model="address.last_name"]', 'Doe') + ->fill('input[wire\\:model="address.address1"]', 'New Street 42') + ->fill('input[wire\\:model="address.city"]', 'Hamburg') + ->fill('input[wire\\:model="address.postal_code"]', '20095') + ->select('select[wire\\:model="address.country"]', 'DE') + ->click('form[wire\\:submit="saveAddress"] button[type="submit"]') + ->wait(1) + ->assertSee('Address saved') + ->assertSee('New Street 42') + ->assertSee('Hamburg') + ->assertNoJavaScriptErrors(); +}); + +test('can edit an existing address', function (): void { + storefrontAccountLogin() + ->click('nav[aria-label="Account navigation"] a[href$="/account/addresses"]') + ->wait(1) + ->click('article:has-text("Hauptstrasse 1") button:has-text("Edit")') + ->wait(1) + ->fill('input[wire\\:model="address.city"]', 'Frankfurt') + ->click('form[wire\\:submit="saveAddress"] button[type="submit"]') + ->wait(1) + ->assertSee('Address saved') + ->assertSee('Frankfurt') + ->assertNoJavaScriptErrors(); +}); + +test('can log out', function (): void { + storefrontAccountLogin() + ->click('@customer-logout-button') + ->wait(1) + ->assertPathIs('/account/login') + ->assertSee('Log in') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Storefront/InventoryTest.php b/tests/Browser/Storefront/InventoryTest.php new file mode 100644 index 00000000..826c357b --- /dev/null +++ b/tests/Browser/Storefront/InventoryTest.php @@ -0,0 +1,125 @@ +seed(DatabaseSeeder::class); +}); + +afterEach(function (): void { + Playwright::setHost(null); +}); + +function storefrontInventoryHost(): array +{ + return ['host' => 'shop.test']; +} + +function storefrontInventoryStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +/** + * @param array $options + */ +function storefrontInventoryVariant(string $handle, array $options): ProductVariant +{ + $store = storefrontInventoryStore(); + $product = Product::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', $handle) + ->firstOrFail(); + + return ProductVariant::withoutGlobalScopes() + ->with(['optionValues.option']) + ->where('product_id', $product->getKey()) + ->get() + ->first(function (ProductVariant $variant) use ($options): bool { + $variantOptions = $variant->optionValues + ->mapWithKeys(fn (ProductOptionValue $value): array => [$value->option->name => $value->value]) + ->all(); + + return $variantOptions === $options; + }) ?? throw new RuntimeException('Variant fixture not found.'); +} + +test('blocks add to cart for out of stock deny policy product', function (): void { + visit('/products/limited-edition-sneakers', storefrontInventoryHost()) + ->assertSee('Limited Edition Sneakers') + ->assertSee('Out of stock') + ->assertSee('Sold out') + ->assertButtonDisabled('button:has-text("Sold out")') + ->assertDontSee('Add to cart') + ->assertNoJavaScriptErrors(); +}); + +test('allows add to cart for out of stock continue policy product', function (): void { + visit('/products/backorder-denim-jacket', storefrontInventoryHost()) + ->assertSee('Backorder Denim Jacket') + ->assertSee('Available on backorder') + ->click('Add to cart') + ->wait(1) + ->navigate('/cart') + ->wait(1) + ->assertSee('Backorder Denim Jacket') + ->assertNoJavaScriptErrors(); +}); + +test('shows correct stock status for in stock product', function (): void { + visit('/products/classic-cotton-t-shirt', storefrontInventoryHost()) + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('In stock') + ->assertButtonEnabled('button:has-text("Add to cart")') + ->assertDontSee('Sold out') + ->assertDontSee('Available on backorder') + ->assertNoJavaScriptErrors(); +}); + +test('prevents adding more than available stock for deny policy product', function (): void { + $variant = storefrontInventoryVariant('classic-cotton-t-shirt', [ + 'Size' => 'M', + 'Color' => 'Black', + ]); + + InventoryItem::withoutGlobalScopes() + ->where('variant_id', $variant->getKey()) + ->firstOrFail() + ->forceFill([ + 'quantity_on_hand' => 2, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ]) + ->save(); + + visit('/products/classic-cotton-t-shirt', storefrontInventoryHost()) + ->click('M') + ->wait(1) + ->click('Black') + ->wait(1) + ->assertSee('Only 2 left in stock') + ->click('Add to cart') + ->wait(1) + ->navigate('/cart') + ->wait(1) + ->click('main button[aria-label="Increase Classic Cotton T-Shirt quantity"]') + ->wait(1) + ->assertSee('2 items') + ->click('main button[aria-label="Increase Classic Cotton T-Shirt quantity"]') + ->wait(1) + ->assertSee('Only 2 units are available') + ->assertSee('2 items') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Storefront/ResponsiveTest.php b/tests/Browser/Storefront/ResponsiveTest.php new file mode 100644 index 00000000..4e8971c1 --- /dev/null +++ b/tests/Browser/Storefront/ResponsiveTest.php @@ -0,0 +1,193 @@ +seed(DatabaseSeeder::class); +}); + +afterEach(function (): void { + Playwright::setHost(null); +}); + +function storefrontResponsiveHost(): array +{ + return ['host' => 'shop.test']; +} + +function storefrontResponsiveMobileVisit(string $path): mixed +{ + return visit($path, storefrontResponsiveHost()) + ->resize(375, 812) + ->wait(1); +} + +function storefrontResponsiveTabletVisit(string $path): mixed +{ + return visit($path, storefrontResponsiveHost()) + ->resize(768, 1024) + ->wait(1); +} + +function storefrontResponsiveAssertNoHorizontalScroll(mixed $page): mixed +{ + return $page->assertScript('document.documentElement.scrollWidth <= document.documentElement.clientWidth'); +} + +function storefrontResponsiveAddClassicToCartOnMobile(): mixed +{ + return storefrontResponsiveMobileVisit('/products/classic-cotton-t-shirt') + ->click('M') + ->wait(1) + ->click('Black') + ->wait(1) + ->click('Add to cart') + ->wait(1); +} + +function storefrontResponsiveCartWithClassicOnMobile(): mixed +{ + return storefrontResponsiveAddClassicToCartOnMobile() + ->navigate('/cart') + ->wait(1) + ->assertSee('Your Cart') + ->assertSee('Classic Cotton T-Shirt'); +} + +function storefrontResponsiveFillAddress(mixed $page): mixed +{ + return $page + ->fill('input[wire\\:model="email"]', 'mobile-buyer@example.com') + ->fill('input[wire\\:model="shippingAddress.first_name"]', 'Mobile') + ->fill('input[wire\\:model="shippingAddress.last_name"]', 'Buyer') + ->fill('input[wire\\:model="shippingAddress.address1"]', 'Responsive Strasse 1') + ->fill('input[wire\\:model="shippingAddress.city"]', 'Berlin') + ->fill('input[wire\\:model="shippingAddress.postal_code"]', '10115') + ->select('select[wire\\:model="shippingAddress.country"]', 'DE'); +} + +function storefrontResponsiveAdminLogin(): mixed +{ + return storefrontResponsiveTabletVisit('/admin/login') + ->fill('input[type=email]', 'admin@acme.test') + ->fill('input[type=password]', 'password') + ->click('@admin-login-button') + ->wait(1) + ->assertPathIs('/admin') + ->assertSee('Dashboard'); +} + +test('mobile home shows hamburger navigation without horizontal overflow', function (): void { + $page = storefrontResponsiveMobileVisit('/') + ->assertSee('Acme Fashion') + ->assertVisible('@mobile-menu-button') + ->assertScript('document.querySelector("nav[aria-label=\"Main navigation\"]").offsetParent === null'); + + storefrontResponsiveAssertNoHorizontalScroll($page) + ->click('@mobile-menu-button') + ->wait(1) + ->assertVisible('nav[aria-label="Mobile navigation"]') + ->assertSee('Collections') + ->assertNoJavaScriptErrors(); +}); + +test('mobile product detail is stacked and keeps purchase controls usable', function (): void { + $page = storefrontResponsiveMobileVisit('/products/classic-cotton-t-shirt') + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('24.99') + ->assertSee('Add to cart') + ->assertButtonEnabled('button:has-text("Add to cart")') + ->assertScript(<<<'JS' +function() { + const columns = Array.from(document.querySelectorAll('main section > div.mt-8.grid > div')); + + if (columns.length < 2) { + return false; + } + + const media = columns[0].getBoundingClientRect(); + const details = columns[1].getBoundingClientRect(); + + return Math.abs(media.left - details.left) <= 2 && details.top > media.bottom; +} +JS); + + storefrontResponsiveAssertNoHorizontalScroll($page) + ->assertNoJavaScriptErrors(); +}); + +test('mobile shoppers can add a product to the cart', function (): void { + storefrontResponsiveAddClassicToCartOnMobile() + ->navigate('/cart') + ->wait(1) + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('24.99') + ->assertNoJavaScriptErrors(); +}); + +test('mobile cart keeps checkout available', function (): void { + $page = storefrontResponsiveCartWithClassicOnMobile() + ->assertSee('Summary') + ->assertVisible('main button:has-text("Checkout")'); + + storefrontResponsiveAssertNoHorizontalScroll($page) + ->assertNoJavaScriptErrors(); +}); + +test('mobile checkout reaches shipping methods after address entry', function (): void { + $page = storefrontResponsiveCartWithClassicOnMobile() + ->click('main button:has-text("Checkout")') + ->wait(1) + ->assertPathBeginsWith('/checkout/') + ->assertSee('Checkout'); + + storefrontResponsiveFillAddress($page) + ->click('form[wire\\:submit="saveAddress"] button[type="submit"]') + ->wait(1) + ->assertSee('Standard Shipping') + ->assertNoJavaScriptErrors(); + + storefrontResponsiveAssertNoHorizontalScroll($page); +}); + +test('tablet admin login works at responsive width', function (): void { + storefrontResponsiveAdminLogin() + ->assertNoJavaScriptErrors(); +}); + +test('tablet admin sidebar navigation reaches products and orders', function (): void { + $page = storefrontResponsiveAdminLogin() + ->click('button[aria-label="Toggle sidebar"]') + ->wait(1) + ->click('a[href$="/admin/products"]') + ->wait(1) + ->assertPathIs('/admin/products') + ->assertSee('Products'); + + $page->click('button[aria-label="Toggle sidebar"]') + ->wait(1) + ->click('a[href$="/admin/orders"]') + ->wait(1) + ->assertPathIs('/admin/orders') + ->assertSee('Orders') + ->assertNoJavaScriptErrors(); +}); + +test('mobile collection keeps filters accessible', function (): void { + $page = storefrontResponsiveMobileVisit('/collections/t-shirts') + ->assertSee('T-Shirts') + ->assertSee('Filters') + ->assertSee('Classic Cotton T-Shirt') + ->fill('input[aria-label="Maximum price"]', '10') + ->wait(1) + ->assertSee('No products found'); + + storefrontResponsiveAssertNoHorizontalScroll($page) + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Storefront/TenantIsolationTest.php b/tests/Browser/Storefront/TenantIsolationTest.php new file mode 100644 index 00000000..f5f53b2f --- /dev/null +++ b/tests/Browser/Storefront/TenantIsolationTest.php @@ -0,0 +1,111 @@ +seed(DatabaseSeeder::class); +}); + +afterEach(function (): void { + Playwright::setHost(null); +}); + +function tenantIsolationHost(): array +{ + return ['host' => 'acme-fashion.test']; +} + +function tenantIsolationStore(string $handle): Store +{ + return Store::query()->where('handle', $handle)->firstOrFail(); +} + +function tenantIsolationAdminLogin(): mixed +{ + return visit('/admin/login', tenantIsolationHost()) + ->fill('input[type=email]', 'admin@acme.test') + ->fill('input[type=password]', 'password') + ->click('@admin-login-button') + ->wait(1) + ->assertPathIs('/admin') + ->assertSee('Dashboard') + ->assertNoJavaScriptErrors(); +} + +function tenantIsolationCustomerLogin(): mixed +{ + return visit('/account/login', tenantIsolationHost()) + ->fill('input[type=email]', 'customer@acme.test') + ->fill('input[type=password]', 'password') + ->click('@customer-login-button') + ->wait(1) + ->assertPathIs('/account') + ->assertSee('My Account') + ->assertSee('John Doe') + ->assertNoJavaScriptErrors(); +} + +test('storefront only shows current store products', function (): void { + visit('/', tenantIsolationHost()) + ->assertSee('Acme Fashion') + ->assertSee('Classic Cotton T-Shirt') + ->assertDontSee('Pro Laptop 15') + ->assertDontSee('Wireless Headphones') + ->assertNoJavaScriptErrors(); +}); + +test('storefront collections only contain current store products', function (): void { + visit('/collections/t-shirts', tenantIsolationHost()) + ->assertSee('T-Shirts') + ->assertSee('Classic Cotton T-Shirt') + ->assertDontSee('Pro Laptop 15') + ->assertDontSee('Wireless Headphones') + ->assertNoJavaScriptErrors(); +}); + +test('admin cannot see other store products or orders', function (): void { + tenantIsolationAdminLogin() + ->click('a[href$="/admin/products"]') + ->wait(1) + ->assertPathIs('/admin/products') + ->assertSee('Products') + ->assertDontSee('Pro Laptop 15') + ->assertDontSee('Wireless Headphones') + ->click('a[href$="/admin/orders"]') + ->wait(1) + ->assertPathIs('/admin/orders') + ->assertSee('#1001') + ->assertDontSee('#5001') + ->assertNoJavaScriptErrors(); +}); + +test('search only returns current store products', function (): void { + visit('/search?q=cotton', tenantIsolationHost()) + ->assertSee('Classic Cotton T-Shirt') + ->assertDontSee('Pro Laptop 15') + ->assertNoJavaScriptErrors(); + + visit('/search?q=laptop', tenantIsolationHost()) + ->assertDontSee('Pro Laptop 15') + ->assertSee('No products found') + ->assertNoJavaScriptErrors(); +}); + +test('customer accounts are scoped to their store', function (): void { + tenantIsolationCustomerLogin() + ->click('nav[aria-label="Account navigation"] a[href$="/account/orders"]') + ->wait(1) + ->assertPathIs('/account/orders') + ->assertSee('#1001') + ->assertSee('#1002') + ->assertSee('#1004') + ->assertDontSee('#5001') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Feature/Admin/AnalyticsDashboardTest.php b/tests/Feature/Admin/AnalyticsDashboardTest.php new file mode 100644 index 00000000..8baf3e3c --- /dev/null +++ b/tests/Feature/Admin/AnalyticsDashboardTest.php @@ -0,0 +1,70 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function adminAnalyticsStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function adminAnalyticsUser(?StoreUserRole $role = null): User +{ + $store = adminAnalyticsStore(); + $user = User::factory()->create([ + 'email_verified_at' => now(), + ]); + + DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => ($role ?? StoreUserRole::Owner)->value, + 'created_at' => now(), + ]); + + return $user; +} + +test('admin analytics route renders metrics for store staff and above', function (): void { + $this->actingAs(adminAnalyticsUser(StoreUserRole::Staff)) + ->get('/admin/analytics') + ->assertSuccessful() + ->assertSee('Analytics') + ->assertSee('Revenue') + ->assertSee('Conversion funnel') + ->assertSee('Top referrers'); +}); + +test('admin analytics rejects support users', function (): void { + $this->actingAs(adminAnalyticsUser(StoreUserRole::Support)) + ->get('/admin/analytics') + ->assertForbidden(); +}); + +test('admin analytics component filters and exports csv data', function (): void { + $store = adminAnalyticsStore(); + app()->instance('current_store', $store); + + Livewire::actingAs(adminAnalyticsUser(StoreUserRole::Admin)) + ->test(AnalyticsIndex::class) + ->assertSee('Sales over time') + ->set('dateRange', 'last_7_days') + ->set('channelFilter', 'storefront') + ->set('deviceFilter', 'mobile') + ->call('exportCsv') + ->assertSet('isExporting', false) + ->assertSet('exportUrl', fn (?string $url): bool => str_starts_with((string) $url, 'data:text/csv')); +}); diff --git a/tests/Feature/Admin/AppsDevelopersTest.php b/tests/Feature/Admin/AppsDevelopersTest.php new file mode 100644 index 00000000..ea683cb4 --- /dev/null +++ b/tests/Feature/Admin/AppsDevelopersTest.php @@ -0,0 +1,92 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function appsDevelopersStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function appsDevelopersUser(?StoreUserRole $role = null): User +{ + $store = appsDevelopersStore(); + $user = User::factory()->create([ + 'email_verified_at' => now(), + ]); + + DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => ($role ?? StoreUserRole::Owner)->value, + 'created_at' => now(), + ]); + + return $user; +} + +test('apps route renders installed apps for owners and admins', function (): void { + $this->actingAs(appsDevelopersUser(StoreUserRole::Admin)) + ->get('/admin/apps') + ->assertSuccessful() + ->assertSee('Apps') + ->assertSee('Inventory Sync'); +}); + +test('developer route rejects staff users', function (): void { + $this->actingAs(appsDevelopersUser(StoreUserRole::Staff)) + ->get('/admin/developers') + ->assertForbidden(); +}); + +test('developers component generates tokens and manages webhooks', function (): void { + $store = appsDevelopersStore(); + app()->instance('current_store', $store); + $user = appsDevelopersUser(StoreUserRole::Owner); + + $initialTokenCount = PersonalAccessToken::query() + ->where('store_id', $store->getKey()) + ->where('tokenable_type', (new User)->getMorphClass()) + ->whereIn('tokenable_id', $store->users()->pluck('users.id')) + ->count(); + + Livewire::actingAs($user) + ->test(DevelopersIndex::class) + ->assertSee('API tokens') + ->set('newTokenName', 'CI Pipeline') + ->call('generateToken') + ->assertHasNoErrors() + ->assertSet('generatedToken', fn (?string $token): bool => str_starts_with((string) $token, 'shop_')) + ->call('openWebhookModal') + ->set('webhookEventType', WebhookEventType::OrderCreated->value) + ->set('webhookUrl', 'https://example.com/webhooks/orders') + ->call('saveWebhook') + ->assertHasNoErrors() + ->assertSee('https://example.com/webhooks/orders'); + + expect(PersonalAccessToken::query() + ->where('store_id', $store->getKey()) + ->where('tokenable_type', (new User)->getMorphClass()) + ->whereIn('tokenable_id', $store->users()->pluck('users.id')) + ->count())->toBe($initialTokenCount + 1) + ->and(WebhookSubscription::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('target_url', 'https://example.com/webhooks/orders') + ->exists())->toBeTrue(); +}); diff --git a/tests/Feature/Admin/ContentManagementTest.php b/tests/Feature/Admin/ContentManagementTest.php new file mode 100644 index 00000000..7c1b72d3 --- /dev/null +++ b/tests/Feature/Admin/ContentManagementTest.php @@ -0,0 +1,285 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function adminContentStore(): Store +{ + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + app()->instance('current_store', $store); + + return $store; +} + +function adminContentUser(): User +{ + return User::query()->where('email', 'admin@acme.test')->firstOrFail(); +} + +function adminContentUserWithRole(Store $store, StoreUserRole $role): User +{ + $user = User::factory()->create(); + $user->stores()->attach($store->getKey(), [ + 'role' => $role->value, + 'created_at' => now(), + ]); + + return $user; +} + +test('content routes render store scoped pages navigation and themes', function (): void { + $store = adminContentStore(); + $user = adminContentUser(); + Page::factory()->create([ + 'store_id' => Store::factory()->create()->getKey(), + 'title' => 'Other Store Page', + ]); + $theme = Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail(); + + $this->get('/admin/pages')->assertRedirect('/admin/login'); + + $this->actingAs($user) + ->withSession(['current_store_id' => $store->getKey()]) + ->get('/admin/pages') + ->assertSuccessful() + ->assertSee('About') + ->assertDontSee('Other Store Page'); + + $this->actingAs($user) + ->withSession(['current_store_id' => $store->getKey()]) + ->get('/admin/pages/create') + ->assertSuccessful() + ->assertSee('Create page'); + + $this->actingAs($user) + ->withSession(['current_store_id' => $store->getKey()]) + ->get('/admin/navigation') + ->assertSuccessful() + ->assertSee('Main Menu'); + + $this->actingAs($user) + ->withSession(['current_store_id' => $store->getKey()]) + ->get('/admin/themes') + ->assertSuccessful() + ->assertSee($theme->name); + + $this->actingAs($user) + ->withSession(['current_store_id' => $store->getKey()]) + ->get('/admin/themes/'.$theme->getKey().'/editor') + ->assertSuccessful() + ->assertSee('Sections'); +}); + +test('admin pages can be created searched edited and deleted', function (): void { + $store = adminContentStore(); + $user = adminContentUser(); + + Livewire::actingAs($user) + ->test(AdminPageForm::class) + ->set('title', 'Sizing Guide') + ->set('handle', 'sizing-guide') + ->set('bodyHtml', '

Measure twice.

') + ->set('status', PageStatus::Published->value) + ->call('save') + ->assertHasNoErrors(); + + $page = Page::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', 'sizing-guide') + ->firstOrFail(); + + expect($page->status)->toBe(PageStatus::Published) + ->and($page->published_at)->not->toBeNull(); + + Livewire::actingAs($user) + ->test(AdminPagesIndex::class) + ->set('search', 'sizing') + ->assertSee('Sizing Guide') + ->assertDontSee('About'); + + Livewire::actingAs($user) + ->test(AdminPageForm::class, ['page' => $page]) + ->set('title', 'Size Guide') + ->set('status', PageStatus::Draft->value) + ->call('save') + ->assertHasNoErrors(); + + expect($page->refresh()->title)->toBe('Size Guide') + ->and($page->status)->toBe(PageStatus::Draft); + + Livewire::actingAs($user) + ->test(AdminPageForm::class, ['page' => $page]) + ->call('deletePage') + ->assertRedirect(route('admin.pages.index', absolute: false)); + + expect(Page::withoutGlobalScopes()->whereKey($page->getKey())->exists())->toBeFalse(); +}); + +test('navigation menu items can be edited and persisted in order', function (): void { + $store = adminContentStore(); + $user = adminContentUser(); + $menu = NavigationMenu::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', 'main-menu') + ->firstOrFail(); + $initialItemCount = $menu->items()->count(); + $component = Livewire::actingAs($user) + ->test(AdminNavigationIndex::class) + ->call('selectMenu', $menu->getKey()); + $shopKey = collect($component->get('menuItems'))->firstWhere('label', 'Shop')['key']; + + $component + ->set('itemLabel', 'Lookbook') + ->set('itemParentKey', $shopKey) + ->set('itemType', NavigationItemType::Link->value) + ->set('itemUrl', '/lookbook') + ->call('saveItem'); + + $lookbookKey = collect($component->get('menuItems'))->firstWhere('label', 'Lookbook')['key']; + + $component + ->call('reorderItem', $lookbookKey, 0, $shopKey) + ->call('saveMenu') + ->assertHasNoErrors(); + + $items = NavigationItem::withoutGlobalScopes() + ->where('menu_id', $menu->getKey()) + ->orderBy('position') + ->get(); + $shop = $items->firstWhere('label', 'Shop'); + $lookbook = $items->firstWhere('label', 'Lookbook'); + + expect($items)->toHaveCount($initialItemCount + 1) + ->and($lookbook?->parent_id)->toBe($shop?->getKey()) + ->and($lookbook?->position)->toBe(0); + + $shopIndex = collect($component->get('menuItems')) + ->search(fn (array $item): bool => $item['label'] === 'Shop'); + + expect($shopIndex)->not->toBeFalse(); + + $component + ->call('removeItem', (int) $shopIndex) + ->call('saveMenu') + ->assertHasNoErrors(); + + expect(NavigationItem::withoutGlobalScopes() + ->where('menu_id', $menu->getKey()) + ->where('label', 'Shop') + ->exists())->toBeFalse() + ->and(NavigationItem::withoutGlobalScopes() + ->where('menu_id', $menu->getKey()) + ->where('label', 'Lookbook') + ->exists())->toBeFalse(); +}); + +test('themes can be duplicated edited and published', function (): void { + $store = adminContentStore(); + $user = adminContentUser(); + $published = Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail(); + + Livewire::actingAs($user) + ->test(AdminThemesIndex::class) + ->call('duplicateTheme', $published->getKey()) + ->assertHasNoErrors(); + + $copy = Theme::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('name', $published->name.' Copy') + ->firstOrFail(); + $publishedFile = $published->files()->withoutGlobalScopes()->firstOrFail(); + $copyFile = $copy->files()->withoutGlobalScopes()->where('path', $publishedFile->path)->firstOrFail(); + + expect($copyFile->storage_key)->not->toBe($publishedFile->storage_key) + ->and(Storage::disk('local')->get($copyFile->storage_key))->toBe(Storage::disk('local')->get($publishedFile->storage_key)); + + Livewire::actingAs($user) + ->test(AdminThemeEditor::class, ['theme' => $copy]) + ->set('settings.home.hero.heading', 'New Hero') + ->call('save') + ->call('publish') + ->assertHasNoErrors(); + + expect(ThemeSettings::withoutGlobalScopes()->where('theme_id', $copy->getKey())->first()?->settings_json['home']['hero']['heading'])->toBe('New Hero') + ->and($copy->refresh()->status)->toBe(ThemeStatus::Published) + ->and($published->refresh()->status)->toBe(ThemeStatus::Draft) + ->and(Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->where('status', ThemeStatus::Published)->count())->toBe(1); +}); + +test('theme editor saves file contents and metadata', function (): void { + $store = adminContentStore(); + $user = adminContentUser(); + $theme = Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail(); + $file = $theme->files()->withoutGlobalScopes()->where('path', 'sections/hero.blade.php')->firstOrFail(); + + Storage::disk('local')->put($file->storage_key, 'Original theme file'); + + Livewire::actingAs($user) + ->test(AdminThemeEditor::class, ['theme' => $theme]) + ->call('selectFile', $file->getKey()) + ->assertSet('selectedFileId', $file->getKey()) + ->assertSet('fileContents', 'Original theme file') + ->set('fileContents', '
Edited hero file
') + ->call('saveFile') + ->assertHasNoErrors() + ->assertSee('Theme file saved'); + + expect(Storage::disk('local')->get($file->storage_key))->toBe('
Edited hero file
') + ->and($file->refresh()->sha256)->toBe(hash('sha256', '
Edited hero file
')) + ->and($file->byte_size)->toBe(strlen('
Edited hero file
')); +}); + +test('content management honors store roles and store scoping', function (): void { + $store = adminContentStore(); + $support = adminContentUserWithRole($store, StoreUserRole::Support); + $staff = adminContentUserWithRole($store, StoreUserRole::Staff); + $otherPage = Page::factory()->create([ + 'store_id' => Store::factory()->create()->getKey(), + ]); + $theme = Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail(); + + Livewire::actingAs($support) + ->test(AdminPageForm::class) + ->assertStatus(403); + + Livewire::actingAs($staff) + ->test(AdminThemesIndex::class) + ->assertStatus(403); + + Livewire::actingAs($staff) + ->test(AdminNavigationIndex::class) + ->assertStatus(403); + + Livewire::actingAs(adminContentUser()) + ->test(AdminPageForm::class, ['page' => $otherPage]) + ->assertStatus(404); + + Livewire::actingAs(adminContentUser()) + ->test(AdminThemeEditor::class, ['theme' => $theme]) + ->assertSuccessful(); +}); diff --git a/tests/Feature/Admin/CustomerManagementTest.php b/tests/Feature/Admin/CustomerManagementTest.php new file mode 100644 index 00000000..08f4d471 --- /dev/null +++ b/tests/Feature/Admin/CustomerManagementTest.php @@ -0,0 +1,164 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function adminCustomerManagementStore(): Store +{ + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + app()->instance('current_store', $store); + + return $store; +} + +function adminCustomerManagementUser(): User +{ + return User::query()->where('email', 'admin@acme.test')->firstOrFail(); +} + +function adminCustomerManagementCustomer(Store $store, string $email, string $name): Customer +{ + return Customer::factory()->create([ + 'store_id' => $store->getKey(), + 'email' => $email, + 'name' => $name, + ]); +} + +function adminCustomerManagementOrder(Store $store, Customer $customer, string $orderNumber, int $totalAmount): Order +{ + return Order::factory()->paid()->create([ + 'store_id' => $store->getKey(), + 'customer_id' => $customer->getKey(), + 'order_number' => $orderNumber, + 'subtotal_amount' => $totalAmount, + 'shipping_amount' => 0, + 'tax_amount' => 0, + 'total_amount' => $totalAmount, + 'email' => $customer->email, + ]); +} + +test('admin customer routes require authentication and render store scoped customers', function (): void { + $store = adminCustomerManagementStore(); + $customer = adminCustomerManagementCustomer($store, 'jane@example.test', 'Jane Example'); + adminCustomerManagementOrder($store, $customer, '#8101', 7000); + CustomerAddress::factory()->default()->create([ + 'customer_id' => $customer->getKey(), + 'label' => 'Home', + ]); + + $otherStore = Store::factory()->create(); + $otherCustomer = adminCustomerManagementCustomer($otherStore, 'other@example.test', 'Other Customer'); + + $this->get('/admin/customers')->assertRedirect('/admin/login'); + + $this->actingAs(adminCustomerManagementUser()) + ->withSession(['current_store_id' => $store->getKey()]) + ->get('/admin/customers') + ->assertSuccessful() + ->assertSee('Jane Example') + ->assertSee('70.00 EUR') + ->assertDontSee('Other Customer'); + + $this->actingAs(adminCustomerManagementUser()) + ->withSession(['current_store_id' => $store->getKey()]) + ->get('/admin/customers/'.$customer->getKey()) + ->assertSuccessful() + ->assertSee('Jane Example') + ->assertSee('#8101') + ->assertSee('Home'); + + $this->actingAs(adminCustomerManagementUser()) + ->withSession(['current_store_id' => $store->getKey()]) + ->get('/admin/customers/'.$otherCustomer->getKey()) + ->assertNotFound(); +}); + +test('admin customer index filters by name and email', function (): void { + $store = adminCustomerManagementStore(); + $user = adminCustomerManagementUser(); + adminCustomerManagementCustomer($store, 'jane@example.test', 'Jane Example'); + adminCustomerManagementCustomer($store, 'bravo@example.test', 'Bravo Example'); + + Livewire::actingAs($user) + ->test(AdminCustomersIndex::class) + ->assertSee('Jane Example') + ->assertSee('Bravo Example') + ->set('search', 'jane') + ->assertSee('Jane Example') + ->assertDontSee('Bravo Example') + ->set('search', 'bravo@example.test') + ->assertSee('Bravo Example') + ->assertDontSee('Jane Example'); +}); + +test('admin customer detail manages addresses', function (): void { + $store = adminCustomerManagementStore(); + $user = adminCustomerManagementUser(); + $customer = adminCustomerManagementCustomer($store, 'jane@example.test', 'Jane Example'); + $home = CustomerAddress::factory()->default()->create([ + 'customer_id' => $customer->getKey(), + 'label' => 'Home', + ]); + + Livewire::actingAs($user) + ->test(AdminCustomerShow::class, ['customer' => $customer]) + ->call('openAddressForm') + ->set('addressLabel', 'Office') + ->set('addressJson.address1', 'Business Street 5') + ->set('addressJson.city', 'Berlin') + ->set('addressJson.postal_code', '10115') + ->set('addressJson.country', 'DE') + ->call('saveAddress') + ->assertHasNoErrors(); + + $office = CustomerAddress::query() + ->where('customer_id', $customer->getKey()) + ->where('label', 'Office') + ->firstOrFail(); + + expect($office->is_default)->toBeFalse(); + + Livewire::actingAs($user) + ->test(AdminCustomerShow::class, ['customer' => $customer]) + ->call('setDefaultAddress', $office->getKey()) + ->assertHasNoErrors() + ->call('openAddressForm', $office->getKey()) + ->set('addressLabel', 'HQ') + ->call('saveAddress') + ->assertHasNoErrors() + ->call('deleteAddress', $home->getKey()) + ->assertHasNoErrors(); + + expect($office->refresh()->is_default)->toBeTrue() + ->and($office->label)->toBe('HQ') + ->and(CustomerAddress::query()->whereKey($home->getKey())->exists())->toBeFalse(); +}); + +test('admin customer detail rejects customers from another store', function (): void { + $store = adminCustomerManagementStore(); + $otherStore = Store::factory()->create(); + $otherCustomer = adminCustomerManagementCustomer($otherStore, 'other@example.test', 'Other Customer'); + + app()->instance('current_store', $store); + + Livewire::actingAs(adminCustomerManagementUser()) + ->test(AdminCustomerShow::class, ['customer' => $otherCustomer]) + ->assertStatus(404); +}); diff --git a/tests/Feature/Admin/DashboardTest.php b/tests/Feature/Admin/DashboardTest.php new file mode 100644 index 00000000..b0c0ea84 --- /dev/null +++ b/tests/Feature/Admin/DashboardTest.php @@ -0,0 +1,144 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function adminDashboardStore(): Store +{ + $store = Store::factory()->create(); + $user = adminDashboardUser(); + + DB::table('store_users')->updateOrInsert( + [ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + ], + [ + 'role' => 'owner', + 'created_at' => now(), + ], + ); + + app()->instance('current_store', $store); + + return $store; +} + +function adminDashboardUser(): User +{ + return User::query()->where('email', 'admin@acme.test')->firstOrFail(); +} + +function adminDashboardOrder(Store $store, string $title, int $quantity, int $unitPrice, array $orderAttributes = []): Order +{ + $product = Product::factory() + ->withDefaultVariant($unitPrice) + ->create([ + 'store_id' => $store->getKey(), + 'title' => $title, + ]); + $variant = ProductVariant::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->firstOrFail(); + $total = $quantity * $unitPrice; + $order = Order::factory()->paid()->create(array_merge([ + 'store_id' => $store->getKey(), + 'subtotal_amount' => $total, + 'shipping_amount' => 0, + 'tax_amount' => 0, + 'total_amount' => $total, + 'placed_at' => now(), + ], $orderAttributes)); + + OrderLine::factory()->create([ + 'order_id' => $order->getKey(), + 'product_id' => $product->getKey(), + 'variant_id' => $variant->getKey(), + 'title_snapshot' => $title, + 'sku_snapshot' => 'DASH-'.str($title)->slug()->upper(), + 'quantity' => $quantity, + 'unit_price_amount' => $unitPrice, + 'total_amount' => $total, + ]); + + return $order; +} + +test('admin dashboard route requires authentication and renders store scoped metrics', function (): void { + $store = adminDashboardStore(); + adminDashboardOrder($store, 'Dashboard Jacket', 2, 2500); + adminDashboardOrder($store, 'Dashboard Cap', 1, 2000); + + $otherStore = Store::factory()->create(); + adminDashboardOrder($otherStore, 'Other Store Product', 1, 9900); + + $this->get('/admin')->assertRedirect('/admin/login'); + + $this->actingAs(adminDashboardUser()) + ->withSession(['current_store_id' => $store->getKey()]) + ->get('/admin') + ->assertSuccessful() + ->assertSee('Dashboard') + ->assertSee('70.00 EUR') + ->assertSee('Dashboard Jacket') + ->assertDontSee('Other Store Product'); +}); + +test('admin dashboard rejects support users', function (): void { + $store = adminDashboardStore(); + $supportUser = User::factory()->create([ + 'email_verified_at' => now(), + ]); + + DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $supportUser->getKey(), + 'role' => StoreUserRole::Support->value, + 'created_at' => now(), + ]); + + $this->actingAs($supportUser) + ->withSession(['current_store_id' => $store->getKey()]) + ->get('/admin') + ->assertForbidden(); +}); + +test('admin dashboard recalculates kpis when date range changes', function (): void { + $store = adminDashboardStore(); + $user = adminDashboardUser(); + + adminDashboardOrder($store, 'Today Product', 1, 6000); + adminDashboardOrder($store, 'Older Product', 1, 4000, [ + 'placed_at' => now()->subDays(15), + ]); + + Livewire::actingAs($user) + ->test(AdminDashboard::class) + ->assertSet('ordersCount', 2) + ->assertSet('totalSales', 10000) + ->set('dateRange', 'today') + ->assertSet('ordersCount', 1) + ->assertSet('totalSales', 6000) + ->set('dateRange', 'custom') + ->set('customStartDate', now()->subDays(20)->toDateString()) + ->set('customEndDate', now()->subDays(10)->toDateString()) + ->assertSet('ordersCount', 1) + ->assertSet('totalSales', 4000); +}); diff --git a/tests/Feature/Admin/DiscountManagementTest.php b/tests/Feature/Admin/DiscountManagementTest.php new file mode 100644 index 00000000..42e0b3e3 --- /dev/null +++ b/tests/Feature/Admin/DiscountManagementTest.php @@ -0,0 +1,306 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function adminDiscountManagementStore(): Store +{ + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + app()->instance('current_store', $store); + + return $store; +} + +function adminDiscountManagementUser(): User +{ + return User::query()->where('email', 'admin@acme.test')->firstOrFail(); +} + +function adminDiscountManagementSupportUser(Store $store): User +{ + $user = User::factory()->create(); + $user->stores()->attach($store->getKey(), [ + 'role' => StoreUserRole::Support->value, + 'created_at' => now(), + ]); + + return $user; +} + +test('livewire persists store middleware for admin action requests', function (): void { + expect(Livewire::getPersistentMiddleware()) + ->toContain(EnsureUserEmailIsVerified::class) + ->toContain(ResolveStore::class) + ->toContain(CheckStoreRole::class); +}); + +test('admin discount routes require authentication and render store scoped discounts', function (): void { + $store = adminDiscountManagementStore(); + $discount = Discount::factory()->create([ + 'store_id' => $store->getKey(), + 'code' => 'SAVE20', + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 20, + 'usage_count' => 3, + 'usage_limit' => 100, + ]); + Discount::factory()->create([ + 'store_id' => Store::factory()->create()->getKey(), + 'code' => 'OTHER20', + ]); + + $this->get('/admin/discounts')->assertRedirect('/admin/login'); + + $this->actingAs(adminDiscountManagementUser()) + ->withSession(['current_store_id' => $store->getKey()]) + ->get('/admin/discounts') + ->assertSuccessful() + ->assertSee('SAVE20') + ->assertSee('20%') + ->assertSee('3 / 100') + ->assertDontSee('OTHER20'); + + $this->actingAs(adminDiscountManagementUser()) + ->withSession(['current_store_id' => $store->getKey()]) + ->get('/admin/discounts/create') + ->assertSuccessful() + ->assertSee('Create discount'); + + $this->actingAs(adminDiscountManagementUser()) + ->withSession(['current_store_id' => $store->getKey()]) + ->get('/admin/discounts/'.$discount->getKey().'/edit') + ->assertSuccessful() + ->assertSee('SAVE20'); +}); + +test('admin discount index filters by code and effective status', function (): void { + $store = adminDiscountManagementStore(); + $user = adminDiscountManagementUser(); + Discount::factory()->create([ + 'store_id' => $store->getKey(), + 'code' => 'ACTIVE10', + ]); + Discount::factory()->create([ + 'store_id' => $store->getKey(), + 'code' => 'SOON10', + 'starts_at' => now()->addWeek(), + ]); + Discount::factory()->create([ + 'store_id' => $store->getKey(), + 'code' => 'DISABLEDSOON', + 'status' => DiscountStatus::Disabled, + 'starts_at' => now()->addWeek(), + ]); + Discount::factory()->create([ + 'store_id' => $store->getKey(), + 'code' => 'ENDED10', + 'starts_at' => now()->subMonth(), + 'ends_at' => now()->subDay(), + ]); + Discount::factory()->create([ + 'store_id' => $store->getKey(), + 'code' => 'DISABLEDENDED', + 'status' => DiscountStatus::Disabled, + 'starts_at' => now()->subMonth(), + 'ends_at' => now()->subDay(), + ]); + + Livewire::actingAs($user) + ->test(AdminDiscountsIndex::class) + ->assertSee('ACTIVE10') + ->assertSee('SOON10') + ->set('search', 'active') + ->assertSee('ACTIVE10') + ->assertDontSee('SOON10') + ->set('search', '') + ->set('statusFilter', 'scheduled') + ->assertSee('SOON10') + ->assertDontSee('ACTIVE10') + ->assertDontSee('DISABLEDSOON') + ->set('statusFilter', 'expired') + ->assertSee('ENDED10') + ->assertDontSee('DISABLEDENDED') + ->set('statusFilter', 'all') + ->set('typeFilter', 'automatic') + ->assertDontSee('ACTIVE10') + ->assertDontSee('SOON10'); +}); + +test('admin discount form creates discounts with eligibility rules', function (): void { + $store = adminDiscountManagementStore(); + $user = adminDiscountManagementUser(); + $product = Product::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail(); + $collection = Collection::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail(); + + Livewire::actingAs($user) + ->test(AdminDiscountForm::class) + ->set('type', 'code') + ->set('code', 'VIP25') + ->set('valueType', 'fixed') + ->set('valueAmount', '5.00') + ->set('minimumPurchaseAmount', '25.00') + ->set('usageLimit', '100') + ->set('onePerCustomer', true) + ->call('addProduct', $product->getKey()) + ->call('addCollection', $collection->getKey()) + ->call('save') + ->assertHasNoErrors(); + + $discount = Discount::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('code', 'VIP25') + ->firstOrFail(); + + expect($discount->value_type)->toBe(DiscountValueType::Fixed) + ->and($discount->value_amount)->toBe(500) + ->and($discount->usage_limit)->toBe(100) + ->and(data_get($discount->rules_json, 'min_purchase_amount'))->toBe(2500) + ->and(data_get($discount->rules_json, 'one_per_customer'))->toBeTrue() + ->and(data_get($discount->rules_json, 'applicable_product_ids'))->toBe([$product->getKey()]) + ->and(data_get($discount->rules_json, 'applicable_collection_ids'))->toBe([$collection->getKey()]); +}); + +test('admin discount form enforces mutation policies and verified users', function (): void { + $store = adminDiscountManagementStore(); + $supportUser = adminDiscountManagementSupportUser($store); + $unverifiedUser = User::factory()->unverified()->create(); + $unverifiedUser->stores()->attach($store->getKey(), [ + 'role' => StoreUserRole::Admin->value, + 'created_at' => now(), + ]); + + $this->actingAs($supportUser) + ->withSession(['current_store_id' => $store->getKey()]) + ->get('/admin/discounts') + ->assertSuccessful(); + + $this->actingAs($supportUser) + ->withSession(['current_store_id' => $store->getKey()]) + ->get('/admin/discounts/create') + ->assertForbidden(); + + $this->actingAs($unverifiedUser) + ->withSession(['current_store_id' => $store->getKey()]) + ->get('/admin/discounts/create') + ->assertRedirect('/email/verify'); + + Livewire::actingAs($supportUser) + ->test(AdminDiscountForm::class) + ->assertStatus(403); +}); + +test('admin discount form validates percentage values and normalized code uniqueness', function (): void { + $store = adminDiscountManagementStore(); + $user = adminDiscountManagementUser(); + Discount::factory()->create([ + 'store_id' => $store->getKey(), + 'code' => 'SAVE20', + ]); + + Livewire::actingAs($user) + ->test(AdminDiscountForm::class) + ->set('code', ' save20 ') + ->set('valueType', 'percent') + ->set('valueAmount', '101') + ->call('save') + ->assertHasErrors(['code', 'valueAmount']); + + Livewire::actingAs($user) + ->test(AdminDiscountForm::class) + ->set('code', 'UNIQUE20') + ->set('valueType', 'percent') + ->set('valueAmount', '10.5') + ->call('save') + ->assertHasErrors(['valueAmount']); +}); + +test('admin discount form creates drafts by default and preserves expired discounts', function (): void { + $store = adminDiscountManagementStore(); + $user = adminDiscountManagementUser(); + + Livewire::actingAs($user) + ->test(AdminDiscountForm::class) + ->set('code', 'DRAFT10') + ->call('save') + ->assertHasNoErrors(); + + $draft = Discount::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('code', 'DRAFT10') + ->firstOrFail(); + + Livewire::actingAs($user) + ->test(AdminDiscountForm::class, ['discount' => $draft]) + ->assertSet('isActive', false); + + $expired = Discount::factory()->create([ + 'store_id' => $store->getKey(), + 'code' => 'EXPIRED10', + 'status' => DiscountStatus::Expired, + 'starts_at' => now()->subMonth(), + 'ends_at' => now()->subDay(), + ]); + + Livewire::actingAs($user) + ->test(AdminDiscountForm::class, ['discount' => $expired]) + ->assertSet('isActive', false) + ->set('isActive', true) + ->set('endsAt', now()->addMonth()->format('Y-m-d\TH:i')) + ->call('save') + ->assertHasNoErrors(); + + expect($draft->status)->toBe(DiscountStatus::Draft) + ->and($expired->refresh()->status)->toBe(DiscountStatus::Expired); +}); + +test('admin discount form edits discounts and rejects another store', function (): void { + $store = adminDiscountManagementStore(); + $user = adminDiscountManagementUser(); + $discount = Discount::factory()->create([ + 'store_id' => $store->getKey(), + 'code' => 'EDITME', + ]); + $otherDiscount = Discount::factory()->create([ + 'store_id' => Store::factory()->create()->getKey(), + 'code' => 'OTHEREDIT', + ]); + + Livewire::actingAs($user) + ->test(AdminDiscountForm::class, ['discount' => $discount]) + ->set('type', 'automatic') + ->set('valueType', 'free_shipping') + ->set('isActive', false) + ->call('save') + ->assertHasNoErrors(); + + expect($discount->refresh()->type)->toBe(DiscountType::Automatic) + ->and($discount->code)->toBeNull() + ->and($discount->value_type)->toBe(DiscountValueType::FreeShipping) + ->and($discount->status)->toBe(DiscountStatus::Disabled); + + Livewire::actingAs($user) + ->test(AdminDiscountForm::class, ['discount' => $otherDiscount]) + ->assertStatus(404); +}); diff --git a/tests/Feature/Admin/OrderManagementTest.php b/tests/Feature/Admin/OrderManagementTest.php new file mode 100644 index 00000000..1317aa9a --- /dev/null +++ b/tests/Feature/Admin/OrderManagementTest.php @@ -0,0 +1,274 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function adminOrderManagementStore(): Store +{ + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + app()->instance('current_store', $store); + + return $store; +} + +function adminOrderManagementUser(): User +{ + return User::query()->where('email', 'admin@acme.test')->firstOrFail(); +} + +function adminOrderManagementUserWithRole(Store $store, StoreUserRole $role): User +{ + $user = User::factory()->create(['email_verified_at' => now()]); + + DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => $role->value, + 'created_at' => now(), + ]); + + return $user; +} + +/** + * @return array{0: Order, 1: OrderLine, 2: ProductVariant} + */ +function adminOrderManagementOrder(Store $store, array $orderAttributes = [], int $quantity = 2, int $unitPrice = 2500): array +{ + $product = Product::factory() + ->withDefaultVariant($unitPrice) + ->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->firstOrFail(); + + InventoryItem::withoutGlobalScopes() + ->where('variant_id', $variant->getKey()) + ->update([ + 'quantity_on_hand' => 10, + 'quantity_reserved' => 0, + ]); + + $total = $quantity * $unitPrice; + $order = Order::factory()->paid()->create(array_merge([ + 'store_id' => $store->getKey(), + 'subtotal_amount' => $total, + 'shipping_amount' => 0, + 'tax_amount' => 0, + 'total_amount' => $total, + 'email' => 'buyer@example.test', + ], $orderAttributes)); + $line = OrderLine::factory()->create([ + 'order_id' => $order->getKey(), + 'product_id' => $product->getKey(), + 'variant_id' => $variant->getKey(), + 'title_snapshot' => 'Admin Test Product', + 'sku_snapshot' => 'ADMIN-TEST-001', + 'quantity' => $quantity, + 'unit_price_amount' => $unitPrice, + 'total_amount' => $total, + ]); + + Payment::factory()->create([ + 'order_id' => $order->getKey(), + 'method' => $order->payment_method, + 'status' => $order->financial_status === FinancialStatus::Pending ? PaymentStatus::Pending : PaymentStatus::Captured, + 'amount' => $total, + 'currency' => $order->currency, + ]); + + return [$order, $line, $variant]; +} + +test('admin order routes require authentication and render scoped orders', function () { + $store = adminOrderManagementStore(); + [$order] = adminOrderManagementOrder($store, [ + 'order_number' => '#7001', + 'email' => 'alpha@example.test', + ]); + + $otherStore = Store::factory()->create(); + adminOrderManagementOrder($otherStore, [ + 'order_number' => '#9001', + 'email' => 'other@example.test', + ]); + + $this->get('/admin/orders')->assertRedirect('/admin/login'); + + $user = adminOrderManagementUser(); + + $this->actingAs($user) + ->get('/admin/orders') + ->assertSuccessful() + ->assertSee('#7001') + ->assertDontSee('#9001'); + + $this->actingAs($user) + ->get('/admin/orders/'.$order->getKey()) + ->assertSuccessful() + ->assertSee('#7001') + ->assertSee('Admin Test Product'); +}); + +test('admin order index filters by search and status dimensions', function () { + $store = adminOrderManagementStore(); + $user = adminOrderManagementUser(); + adminOrderManagementOrder($store, [ + 'order_number' => '#7001', + 'email' => 'alpha@example.test', + ]); + adminOrderManagementOrder($store, [ + 'order_number' => '#7002', + 'email' => 'bravo@example.test', + 'status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + 'payment_method' => PaymentMethod::BankTransfer, + ]); + + Livewire::actingAs($user) + ->test(AdminOrdersIndex::class) + ->assertSee('#7001') + ->assertSee('#7002') + ->set('search', 'alpha') + ->assertSee('#7001') + ->assertDontSee('#7002') + ->set('search', '') + ->set('financialStatusFilter', 'pending') + ->assertSee('#7002') + ->assertDontSee('#7001'); +}); + +test('admin order detail confirms bank transfer payments', function () { + $store = adminOrderManagementStore(); + $user = adminOrderManagementUser(); + [$order, $line, $variant] = adminOrderManagementOrder($store, [ + 'payment_method' => PaymentMethod::BankTransfer, + 'status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + ]); + + InventoryItem::withoutGlobalScopes() + ->where('variant_id', $variant->getKey()) + ->update(['quantity_reserved' => $line->quantity]); + + Livewire::actingAs($user) + ->test(AdminOrderShow::class, ['order' => $order]) + ->call('confirmBankTransferPayment') + ->assertHasNoErrors(); + + $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail(); + + expect($order->refresh()->status)->toBe(OrderStatus::Paid) + ->and($order->financial_status)->toBe(FinancialStatus::Paid) + ->and($order->payments()->first()?->status)->toBe(PaymentStatus::Captured) + ->and($inventory->quantity_on_hand)->toBe(8) + ->and($inventory->quantity_reserved)->toBe(0); +}); + +test('admin order detail processes refunds and fulfillment transitions', function () { + $store = adminOrderManagementStore(); + $user = adminOrderManagementUser(); + [$order, $line] = adminOrderManagementOrder($store, quantity: 2, unitPrice: 2500); + + Livewire::actingAs($user) + ->test(AdminOrderShow::class, ['order' => $order]) + ->set('refundAmount', '10.00') + ->set('refundReason', 'Customer return') + ->call('processRefund') + ->assertHasNoErrors(); + + expect($order->refresh()->financial_status)->toBe(FinancialStatus::PartiallyRefunded) + ->and($order->refunds()->first()?->amount)->toBe(1000); + + Livewire::actingAs($user) + ->test(AdminOrderShow::class, ['order' => $order->refresh()]) + ->set("fulfillmentLineQuantities.{$line->getKey()}", 1) + ->set('trackingCompany', 'DHL') + ->set('trackingNumber', 'DHL123') + ->call('createFulfillment') + ->assertHasNoErrors(); + + $fulfillment = Fulfillment::query()->where('order_id', $order->getKey())->firstOrFail(); + + expect($order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Partial) + ->and($fulfillment->lines()->first()?->quantity)->toBe(1); + + Livewire::actingAs($user) + ->test(AdminOrderShow::class, ['order' => $order->refresh()]) + ->call('markFulfillmentShipped', $fulfillment->getKey()) + ->call('markFulfillmentDelivered', $fulfillment->getKey()) + ->assertHasNoErrors(); + + expect($fulfillment->refresh()->status)->toBe(FulfillmentShipmentStatus::Delivered) + ->and($fulfillment->delivered_at)->not->toBeNull(); +}); + +test('admin order detail enforces refund and fulfillment role policies', function (): void { + $store = adminOrderManagementStore(); + $supportUser = adminOrderManagementUserWithRole($store, StoreUserRole::Support); + $staffUser = adminOrderManagementUserWithRole($store, StoreUserRole::Staff); + [$order, $line] = adminOrderManagementOrder($store, quantity: 2, unitPrice: 2500); + + Livewire::actingAs($supportUser) + ->test(AdminOrderShow::class, ['order' => $order]) + ->set('refundAmount', '5.00') + ->call('processRefund') + ->assertStatus(403); + + Livewire::actingAs($staffUser) + ->test(AdminOrderShow::class, ['order' => $order]) + ->set('refundAmount', '5.00') + ->call('processRefund') + ->assertStatus(403); + + Livewire::actingAs($staffUser) + ->test(AdminOrderShow::class, ['order' => $order]) + ->set("fulfillmentLineQuantities.{$line->getKey()}", 1) + ->call('createFulfillment') + ->assertHasNoErrors(); + + expect(Fulfillment::query()->where('order_id', $order->getKey())->exists())->toBeTrue() + ->and($order->refresh()->refunds()->exists())->toBeFalse(); +}); + +test('admin order detail rejects orders from another store', function () { + $store = adminOrderManagementStore(); + $otherStore = Store::factory()->create(); + $user = adminOrderManagementUser(); + [$otherOrder] = adminOrderManagementOrder($otherStore, [ + 'order_number' => '#9001', + ]); + + app()->instance('current_store', $store); + + Livewire::actingAs($user) + ->test(AdminOrderShow::class, ['order' => $otherOrder]) + ->assertStatus(404); +}); diff --git a/tests/Feature/Admin/ProductMediaManagementTest.php b/tests/Feature/Admin/ProductMediaManagementTest.php new file mode 100644 index 00000000..3863ca31 --- /dev/null +++ b/tests/Feature/Admin/ProductMediaManagementTest.php @@ -0,0 +1,165 @@ +create(); + $user->stores()->attach($store->getKey(), [ + 'role' => StoreUserRole::Admin->value, + ]); + + return $user; +} + +test('admin product form uploads images and queues media processing', function (): void { + Storage::fake('public'); + Queue::fake([ProcessMediaUpload::class]); + + $store = Store::factory()->create(); + $user = productMediaAdminUser($store); + $product = Product::factory()->create([ + 'store_id' => $store->getKey(), + 'title' => 'Media Test Product', + ]); + + app()->instance('current_store', $store); + + Livewire::actingAs($user) + ->test(ProductForm::class, ['product' => $product]) + ->set('newMedia', [ + UploadedFile::fake()->image('front.jpg', 40, 30), + ]) + ->call('uploadMedia') + ->assertHasNoErrors() + ->assertSee('Media uploaded'); + + $media = ProductMedia::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->firstOrFail(); + + expect($media->status)->toBe(MediaStatus::Processing) + ->and($media->position)->toBe(0) + ->and($media->alt_text)->toBe('Media Test Product') + ->and($media->storage_key)->toStartWith("media/originals/{$product->getKey()}/"); + + Storage::disk('public')->assertExists($media->storage_key); + + Queue::assertPushed(ProcessMediaUpload::class, function (ProcessMediaUpload $job) use ($media, $store): bool { + return $job->productMediaId === $media->getKey() + && $job->storeId === $store->getKey(); + }); +}); + +test('admin product form attaches selected media when creating a product', function (): void { + Storage::fake('public'); + Queue::fake([ProcessMediaUpload::class]); + + $store = Store::factory()->create(); + $user = productMediaAdminUser($store); + + app()->instance('current_store', $store); + + Livewire::actingAs($user) + ->test(ProductForm::class) + ->set('title', 'Created Media Product') + ->set('handle', 'created-media-product') + ->set('variants.0.price', '19.99') + ->set('variants.0.quantity', 7) + ->set('newMedia', [ + UploadedFile::fake()->image('created.png', 32, 24), + ]) + ->call('save') + ->assertHasNoErrors() + ->assertSee('Product saved'); + + $product = Product::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', 'created-media-product') + ->firstOrFail(); + $media = ProductMedia::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->firstOrFail(); + + Storage::disk('public')->assertExists($media->storage_key); + Queue::assertPushed(ProcessMediaUpload::class, 1); +}); + +test('admin product form rejects non image media uploads', function (): void { + Storage::fake('public'); + Queue::fake([ProcessMediaUpload::class]); + + $store = Store::factory()->create(); + $user = productMediaAdminUser($store); + $product = Product::factory()->create(['store_id' => $store->getKey()]); + + app()->instance('current_store', $store); + + Livewire::actingAs($user) + ->test(ProductForm::class, ['product' => $product]) + ->set('newMedia', [ + UploadedFile::fake()->create('notes.txt', 1, 'text/plain'), + ]) + ->call('uploadMedia') + ->assertHasErrors(['newMedia.0']); + + expect(ProductMedia::withoutGlobalScopes()->where('product_id', $product->getKey())->count())->toBe(0); + + Queue::assertNothingPushed(); +}); + +test('admin product form updates alt text reorders and deletes media', function (): void { + Storage::fake('public'); + + $store = Store::factory()->create(); + $user = productMediaAdminUser($store); + $product = Product::factory()->create(['store_id' => $store->getKey()]); + + $media = collect([0, 1, 2])->map(function (int $position) use ($product): ProductMedia { + $item = ProductMedia::factory()->create([ + 'product_id' => $product->getKey(), + 'storage_key' => "media/originals/{$product->getKey()}/{$position}.png", + 'position' => $position, + 'status' => MediaStatus::Ready, + ]); + + Storage::disk('public')->put($item->storage_key, 'image-bytes'); + + return $item; + }); + + app()->instance('current_store', $store); + + $component = Livewire::actingAs($user) + ->test(ProductForm::class, ['product' => $product]) + ->set('media.1.altText', 'Updated sleeve detail') + ->call('updateMediaAlt', $media[1]->getKey()) + ->assertHasNoErrors(); + + expect($media[1]->refresh()->alt_text)->toBe('Updated sleeve detail'); + + $component->call('moveMedia', $media[2]->getKey(), 'up'); + + expect($media[0]->refresh()->position)->toBe(0) + ->and($media[2]->refresh()->position)->toBe(1) + ->and($media[1]->refresh()->position)->toBe(2); + + $component->call('removeMedia', $media[2]->getKey()); + + $this->assertModelMissing($media[2]); + Storage::disk('public')->assertMissing($media[2]->storage_key); +}); diff --git a/tests/Feature/Admin/SearchSettingsTest.php b/tests/Feature/Admin/SearchSettingsTest.php new file mode 100644 index 00000000..9aaa2577 --- /dev/null +++ b/tests/Feature/Admin/SearchSettingsTest.php @@ -0,0 +1,80 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function adminSearchSettingsStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function adminSearchSettingsUser(): User +{ + return User::query()->where('email', 'admin@acme.test')->firstOrFail(); +} + +test('admin search settings route renders for store admins', function (): void { + $this->actingAs(adminSearchSettingsUser()) + ->get('/admin/search/settings') + ->assertSuccessful() + ->assertSee('Search settings') + ->assertSee('Synonyms'); +}); + +test('admin search settings saves synonyms and stop words', function (): void { + $store = adminSearchSettingsStore(); + app()->instance('current_store', $store); + + Livewire::actingAs(adminSearchSettingsUser()) + ->test(SearchSettingsComponent::class) + ->set('synonymGroups', ['hoodie, sweatshirt, pullover', 'tee, t-shirt']) + ->set('stopWords', 'the, and, for') + ->call('save') + ->assertSee('Search settings saved'); + + $settings = SearchSettings::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail(); + + expect($settings->synonyms_json)->toBe([ + ['hoodie', 'sweatshirt', 'pullover'], + ['tee', 't-shirt'], + ])->and($settings->stop_words_json)->toBe(['the', 'and', 'for']); +}); + +test('admin search settings can rebuild the search index', function (): void { + $store = adminSearchSettingsStore(); + app()->instance('current_store', $store); + + $product = Product::factory() + ->for($store) + ->withDefaultVariant(2999) + ->create([ + 'title' => 'Reindex Search Jacket', + 'handle' => 'reindex-search-jacket', + ]); + + DB::table('products_fts')->where('product_id', $product->getKey())->delete(); + + expect(app(SearchService::class)->search($store, 'reindex jacket', [], 12)->total())->toBe(0); + + Livewire::actingAs(adminSearchSettingsUser()) + ->test(SearchSettingsComponent::class) + ->call('triggerReindex') + ->assertSee('Search index rebuilt'); + + expect(app(SearchService::class)->search($store, 'reindex jacket', [], 12)->total())->toBe(1); +}); diff --git a/tests/Feature/Admin/SettingsManagementTest.php b/tests/Feature/Admin/SettingsManagementTest.php new file mode 100644 index 00000000..17a9c5cd --- /dev/null +++ b/tests/Feature/Admin/SettingsManagementTest.php @@ -0,0 +1,237 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function adminSettingsStore(): Store +{ + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + app()->instance('current_store', $store); + + return $store; +} + +function adminSettingsUser(): User +{ + return User::query()->where('email', 'admin@acme.test')->firstOrFail(); +} + +function adminSettingsUserWithRole(Store $store, StoreUserRole $role): User +{ + $user = User::factory()->create(); + $user->stores()->attach($store->getKey(), [ + 'role' => $role->value, + 'created_at' => now(), + ]); + + return $user; +} + +test('settings routes render for owners and reject staff', function (): void { + $store = adminSettingsStore(); + $owner = adminSettingsUser(); + $staff = adminSettingsUserWithRole($store, StoreUserRole::Staff); + + foreach (['/admin/settings', '/admin/settings/shipping', '/admin/settings/taxes', '/admin/settings/checkout', '/admin/settings/notifications'] as $path) { + $this->actingAs($owner) + ->withSession(['current_store_id' => $store->getKey()]) + ->get($path) + ->assertSuccessful(); + + $this->actingAs($staff) + ->withSession(['current_store_id' => $store->getKey()]) + ->get($path) + ->assertForbidden(); + } +}); + +test('general settings update store defaults and domains', function (): void { + $store = adminSettingsStore(); + $user = adminSettingsUser(); + + Livewire::actingAs($user) + ->test(AdminSettingsIndex::class) + ->set('storeName', 'Acme Atelier') + ->set('defaultCurrency', 'GBP') + ->set('defaultLocale', 'de') + ->set('timezone', 'Europe/Berlin') + ->set('announcementEnabled', true) + ->set('announcementText', 'Spring edits now live') + ->set('guestCheckoutEnabled', false) + ->call('save') + ->assertHasNoErrors() + ->set('newHostname', 'atelier.test') + ->set('newType', 'storefront') + ->call('addDomain') + ->assertHasNoErrors(); + + $settings = StoreSettings::query()->whereKey($store->getKey())->firstOrFail(); + + expect($store->refresh()->name)->toBe('Acme Atelier') + ->and($store->default_currency)->toBe('GBP') + ->and($store->default_locale)->toBe('de') + ->and($settings->settings_json['announcement']['text'])->toBe('Spring edits now live') + ->and($settings->settings_json['checkout']['guest_checkout_enabled'])->toBeFalse() + ->and(StoreDomain::query()->where('hostname', 'atelier.test')->where('store_id', $store->getKey())->exists())->toBeTrue(); +}); + +test('shipping settings manage zones rates and address tests', function (): void { + $store = adminSettingsStore(); + $user = adminSettingsUser(); + + Livewire::actingAs($user) + ->test(AdminSettingsShipping::class) + ->set('zoneName', 'Nordics') + ->set('zoneCountries', 'SE, NO') + ->call('saveZone') + ->assertHasNoErrors(); + + $zone = ShippingZone::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('name', 'Nordics') + ->firstOrFail(); + + Livewire::actingAs($user) + ->test(AdminSettingsShipping::class) + ->call('addRate', $zone->getKey()) + ->set('rateName', 'Nordic Standard') + ->set('rateType', ShippingRateType::Flat->value) + ->set('rateAmount', '12.50') + ->call('saveRate') + ->assertHasNoErrors() + ->set('testCountry', 'SE') + ->call('testShippingAddress') + ->assertSet('testResult.zone', 'Nordics'); + + $rate = ShippingRate::withoutGlobalScopes() + ->where('zone_id', $zone->getKey()) + ->where('name', 'Nordic Standard') + ->firstOrFail(); + + expect($zone->countries_json)->toBe(['SE', 'NO']) + ->and($rate->type)->toBe(ShippingRateType::Flat) + ->and($rate->config_json['amount'])->toBe(1250); +}); + +test('checkout settings save customer and payment policy values', function (): void { + $store = adminSettingsStore(); + $user = adminSettingsUser(); + + Livewire::actingAs($user) + ->test(AdminSettingsCheckout::class) + ->set('guestCheckoutEnabled', false) + ->set('customerAccountsRequired', true) + ->set('phoneNumberRequired', true) + ->set('billingAddressEnabled', false) + ->set('orderNotesEnabled', false) + ->set('termsRequired', true) + ->set('termsUrl', 'https://shop.test/pages/terms') + ->set('paymentHoldHours', 36) + ->set('abandonedCheckoutDays', 21) + ->set('bankTransferCancelDays', 9) + ->call('save') + ->assertHasNoErrors(); + + $settings = StoreSettings::query()->whereKey($store->getKey())->firstOrFail()->settings_json; + + expect(data_get($settings, 'checkout.guest_checkout_enabled'))->toBeFalse() + ->and(data_get($settings, 'checkout.customer_accounts_required'))->toBeTrue() + ->and(data_get($settings, 'checkout.phone_number_required'))->toBeTrue() + ->and(data_get($settings, 'checkout.billing_address_enabled'))->toBeFalse() + ->and(data_get($settings, 'checkout.order_notes_enabled'))->toBeFalse() + ->and(data_get($settings, 'checkout.terms_required'))->toBeTrue() + ->and(data_get($settings, 'checkout.terms_url'))->toBe('https://shop.test/pages/terms') + ->and(data_get($settings, 'checkout.payment_hold_hours'))->toBe(36) + ->and(data_get($settings, 'checkout.abandoned_checkout_days'))->toBe(21) + ->and(data_get($settings, 'bank_transfer_cancel_days'))->toBe(9); +}); + +test('notification settings save sender and event preferences', function (): void { + $store = adminSettingsStore(); + $user = adminSettingsUser(); + + Livewire::actingAs($user) + ->test(AdminSettingsNotifications::class) + ->set('senderName', 'Acme Ops') + ->set('senderEmail', 'ops@shop.test') + ->set('replyToEmail', 'support@shop.test') + ->set('orderConfirmationEnabled', false) + ->set('shippingConfirmationEnabled', true) + ->set('refundConfirmationEnabled', false) + ->set('adminOrderAlertsEnabled', false) + ->set('lowStockAlertsEnabled', true) + ->set('lowStockThreshold', 3) + ->call('save') + ->assertHasNoErrors(); + + $settings = StoreSettings::query()->whereKey($store->getKey())->firstOrFail()->settings_json; + + expect(data_get($settings, 'notifications.sender_name'))->toBe('Acme Ops') + ->and(data_get($settings, 'notifications.sender_email'))->toBe('ops@shop.test') + ->and(data_get($settings, 'notifications.reply_to_email'))->toBe('support@shop.test') + ->and(data_get($settings, 'notifications.order_confirmation_enabled'))->toBeFalse() + ->and(data_get($settings, 'notifications.shipping_confirmation_enabled'))->toBeTrue() + ->and(data_get($settings, 'notifications.refund_confirmation_enabled'))->toBeFalse() + ->and(data_get($settings, 'notifications.admin_order_alerts_enabled'))->toBeFalse() + ->and(data_get($settings, 'notifications.low_stock_alerts_enabled'))->toBeTrue() + ->and(data_get($settings, 'notifications.low_stock_threshold'))->toBe(3); +}); + +test('tax settings save manual and provider configuration', function (): void { + $store = adminSettingsStore(); + $user = adminSettingsUser(); + + Livewire::actingAs($user) + ->test(AdminSettingsTaxes::class) + ->set('manualRates', [ + ['country' => 'DE', 'name' => 'VAT', 'rate_percentage' => '19.00'], + ['country' => 'FR', 'name' => 'TVA', 'rate_percentage' => '20.00'], + ]) + ->set('pricesIncludeTax', true) + ->call('save') + ->assertHasNoErrors(); + + $manual = TaxSettings::withoutGlobalScopes()->whereKey($store->getKey())->firstOrFail(); + + expect($manual->mode)->toBe(TaxMode::Manual) + ->and($manual->prices_include_tax)->toBeTrue() + ->and($manual->config_json['rates'][1]['country'])->toBe('FR') + ->and($manual->config_json['rates'][1]['rate_bps'])->toBe(2000); + + Livewire::actingAs($user) + ->test(AdminSettingsTaxes::class) + ->set('mode', TaxMode::Provider->value) + ->set('provider', 'stripe_tax') + ->set('providerApiKey', 'sk_test_tax') + ->call('save') + ->assertHasNoErrors(); + + $provider = TaxSettings::withoutGlobalScopes()->whereKey($store->getKey())->firstOrFail(); + + expect($provider->mode)->toBe(TaxMode::Provider) + ->and($provider->provider)->toBe('stripe_tax') + ->and($provider->config_json['provider_api_key'])->toBe('sk_test_tax'); +}); diff --git a/tests/Feature/Analytics/AnalyticsServiceTest.php b/tests/Feature/Analytics/AnalyticsServiceTest.php new file mode 100644 index 00000000..be55c773 --- /dev/null +++ b/tests/Feature/Analytics/AnalyticsServiceTest.php @@ -0,0 +1,85 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function analyticsServiceStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +test('analytics service tracks events and drops duplicate client event ids', function (): void { + $store = analyticsServiceStore(); + $analytics = app(AnalyticsService::class); + + $first = $analytics->track( + $store, + AnalyticsEventType::PageView->value, + ['url' => '/products/classic-cotton-t-shirt'], + 'session-analytics-1', + null, + 'evt-analytics-1', + now(), + ); + + $duplicate = $analytics->track( + $store, + AnalyticsEventType::PageView->value, + ['url' => '/products/classic-cotton-t-shirt'], + 'session-analytics-1', + null, + 'evt-analytics-1', + now(), + ); + + expect($first)->toBeTrue() + ->and($duplicate)->toBeFalse() + ->and(AnalyticsEvent::withoutGlobalScopes()->where('client_event_id', 'evt-analytics-1')->count())->toBe(1); +}); + +test('analytics aggregation writes idempotent daily metrics', function (): void { + $store = analyticsServiceStore(); + $analytics = app(AnalyticsService::class); + $date = now()->subDays(10)->startOfDay(); + + foreach (range(1, 5) as $index) { + $analytics->track($store, AnalyticsEventType::PageView->value, ['url' => '/'], 'agg-session-'.($index % 3), null, "agg-page-{$index}", $date->copy()->addMinutes($index)); + } + + foreach (range(1, 3) as $index) { + $analytics->track($store, AnalyticsEventType::AddToCart->value, ['variant_id' => $index], 'agg-session-'.$index, null, "agg-cart-{$index}", $date->copy()->addHour()->addMinutes($index)); + } + + foreach (range(1, 2) as $index) { + $analytics->track($store, AnalyticsEventType::CheckoutStarted->value, ['cart_id' => $index], 'agg-session-'.$index, null, "agg-checkout-{$index}", $date->copy()->addHours(2)->addMinutes($index)); + $analytics->track($store, AnalyticsEventType::CheckoutCompleted->value, ['total_amount' => 5000 * $index], 'agg-session-'.$index, null, "agg-order-{$index}", $date->copy()->addHours(3)->addMinutes($index)); + } + + expect($analytics->aggregate($date))->toBe(1) + ->and($analytics->aggregate($date))->toBe(1); + + $daily = AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('date', $date->toDateString()) + ->firstOrFail(); + + expect($daily->visits_count)->toBe(3) + ->and($daily->add_to_cart_count)->toBe(3) + ->and($daily->checkout_started_count)->toBe(2) + ->and($daily->checkout_completed_count)->toBe(2) + ->and($daily->orders_count)->toBe(2) + ->and($daily->revenue_amount)->toBe(15000) + ->and($daily->aov_amount)->toBe(7500); +}); diff --git a/tests/Feature/Api/AdminAnalyticsSummaryApiTest.php b/tests/Feature/Api/AdminAnalyticsSummaryApiTest.php new file mode 100644 index 00000000..a50648ba --- /dev/null +++ b/tests/Feature/Api/AdminAnalyticsSummaryApiTest.php @@ -0,0 +1,160 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function adminAnalyticsSummaryApiStore(): Store +{ + $store = Store::factory()->create(); + $user = adminAnalyticsSummaryApiUser(); + + DB::table('store_users')->updateOrInsert( + [ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + ], + [ + 'role' => 'owner', + 'created_at' => now(), + ], + ); + + return $store; +} + +function adminAnalyticsSummaryApiUser(): User +{ + return User::query()->where('email', 'admin@acme.test')->firstOrFail(); +} + +/** + * @param list $abilities + * @return array{token: \App\Models\PersonalAccessToken, plain_text: string} + */ +function adminAnalyticsSummaryApiToken(Store $store, array $abilities): array +{ + return adminApiToken($store, $abilities); +} + +test('admin analytics summary api returns totals daily rows and top products', function (): void { + $store = adminAnalyticsSummaryApiStore(); + $from = now()->subDay()->toDateString(); + $to = now()->toDateString(); + + DB::table('analytics_daily') + ->where('store_id', $store->getKey()) + ->whereBetween('date', [$from, $to]) + ->delete(); + + DB::table('analytics_daily')->insert([ + [ + 'store_id' => $store->getKey(), + 'date' => $from, + 'orders_count' => 2, + 'revenue_amount' => 2000, + 'aov_amount' => 1000, + 'visits_count' => 10, + 'add_to_cart_count' => 4, + 'checkout_started_count' => 3, + 'checkout_completed_count' => 2, + ], + [ + 'store_id' => $store->getKey(), + 'date' => $to, + 'orders_count' => 3, + 'revenue_amount' => 9000, + 'aov_amount' => 3000, + 'visits_count' => 20, + 'add_to_cart_count' => 6, + 'checkout_started_count' => 7, + 'checkout_completed_count' => 3, + ], + ]); + + $product = Product::factory() + ->for($store) + ->withDefaultVariant(2000) + ->create(['title' => 'Analytics API Jacket']); + $order = Order::factory()->paid()->create([ + 'store_id' => $store->getKey(), + 'currency' => $store->default_currency, + 'placed_at' => Carbon::parse($from)->addHours(12), + ]); + OrderLine::factory()->create([ + 'order_id' => $order->getKey(), + 'product_id' => $product->getKey(), + 'title_snapshot' => $product->title, + 'quantity' => 4, + 'total_amount' => 8000, + ]); + + $this->withToken(adminApiBearerToken($store, ['read-analytics'], adminAnalyticsSummaryApiUser())) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/analytics/summary?from={$from}&to={$to}") + ->assertOk() + ->assertJsonPath('data.period.from', $from) + ->assertJsonPath('data.period.to', $to) + ->assertJsonPath('data.summary.orders_count', 5) + ->assertJsonPath('data.summary.revenue_amount', 11000) + ->assertJsonPath('data.summary.aov_amount', 2200) + ->assertJsonPath('data.summary.visits_count', 30) + ->assertJsonPath('data.summary.add_to_cart_count', 10) + ->assertJsonPath('data.summary.checkout_started_count', 10) + ->assertJsonPath('data.summary.conversion_rate', 0.1667) + ->assertJsonPath('data.summary.currency', $store->default_currency) + ->assertJsonPath('data.daily.0.date', $from) + ->assertJsonPath('data.daily.1.date', $to) + ->assertJsonPath('data.top_products.0.product_id', $product->getKey()) + ->assertJsonPath('data.top_products.0.title', 'Analytics API Jacket') + ->assertJsonPath('data.top_products.0.units_sold', 4) + ->assertJsonPath('data.top_products.0.revenue_amount', 8000); +}); + +test('admin analytics summary api enforces token abilities and store scope', function (): void { + $store = adminAnalyticsSummaryApiStore(); + $otherStore = Store::factory()->create(); + $from = now()->subDay()->toDateString(); + $to = now()->toDateString(); + $readToken = adminAnalyticsSummaryApiToken($store, ['read-analytics']); + $wrongAbilityToken = adminAnalyticsSummaryApiToken($store, ['read-settings']); + $otherStoreToken = adminAnalyticsSummaryApiToken($otherStore, ['read-analytics']); + + $this->withToken($readToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/analytics/summary?from={$from}&to={$to}") + ->assertOk(); + + $this->withToken($wrongAbilityToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/analytics/summary?from={$from}&to={$to}") + ->assertForbidden(); + + $this->withToken($otherStoreToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/analytics/summary?from={$from}&to={$to}") + ->assertForbidden(); +}); + +test('admin analytics summary api validates date ranges', function (): void { + $store = adminAnalyticsSummaryApiStore(); + + $this->withToken(adminApiBearerToken($store, ['read-analytics'], adminAnalyticsSummaryApiUser())) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/analytics/summary?from=2026-02-10&to=2026-02-01") + ->assertUnprocessable() + ->assertJsonValidationErrors(['to']); + + $this->withToken(adminApiBearerToken($store, ['read-analytics'], adminAnalyticsSummaryApiUser())) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/analytics/summary?from=2025-01-01&to=2026-02-01") + ->assertUnprocessable() + ->assertJsonValidationErrors(['to']); +}); diff --git a/tests/Feature/Api/AdminCatalogApiTest.php b/tests/Feature/Api/AdminCatalogApiTest.php new file mode 100644 index 00000000..81f33986 --- /dev/null +++ b/tests/Feature/Api/AdminCatalogApiTest.php @@ -0,0 +1,376 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function adminCatalogApiStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function adminCatalogApiUser(): User +{ + return User::query()->where('email', 'admin@acme.test')->firstOrFail(); +} + +/** + * @param list $abilities + * @return array{token: \App\Models\PersonalAccessToken, plain_text: string} + */ +function adminCatalogApiToken(Store $store, array $abilities): array +{ + return adminApiToken($store, $abilities); +} + +test('admin product api lists and shows store scoped products', function (): void { + $store = adminCatalogApiStore(); + $product = Product::factory() + ->withDefaultVariant(3299) + ->create([ + 'store_id' => $store->getKey(), + 'title' => 'Admin API Jacket', + 'handle' => 'admin-api-jacket', + 'vendor' => 'Catalog Test Vendor', + 'tags' => ['outerwear', 'api'], + ]); + $variant = ProductVariant::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->firstOrFail(); + Product::factory() + ->withDefaultVariant() + ->create([ + 'store_id' => Store::factory()->create()->getKey(), + 'title' => 'Other Store Jacket', + ]); + + $this->getJson("/api/admin/v1/stores/{$store->getKey()}/products") + ->assertUnauthorized(); + + $this->actingAs(adminCatalogApiUser()) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/products") + ->assertUnauthorized(); + + $user = adminCatalogApiUser(); + $this->withToken(adminApiBearerToken($store, ['read-products'], $user)) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/products?query={$variant->sku}&sort=title_asc") + ->assertOk() + ->assertJsonPath('data.0.title', 'Admin API Jacket') + ->assertJsonPath('data.0.variants_count', 1) + ->assertJsonPath('data.0.total_inventory', 50) + ->assertJsonMissing(['title' => 'Other Store Jacket']); + + $this->withToken(adminApiBearerToken($store, ['read-products'], $user)) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}") + ->assertOk() + ->assertJsonPath('data.title', 'Admin API Jacket') + ->assertJsonPath('data.variants.0.price_amount', 3299) + ->assertJsonPath('data.variants.0.inventory.quantity_on_hand', 50); + + $otherStore = Store::query()->whereKeyNot($store->getKey())->firstOrFail(); + $otherStore->users()->syncWithoutDetaching([ + $user->getKey() => [ + 'role' => 'owner', + 'created_at' => now(), + ], + ]); + + $this->withToken(adminApiBearerToken($store, ['read-products'], $user)) + ->getJson("/api/admin/v1/stores/{$otherStore->getKey()}/products") + ->assertForbidden(); +}); + +test('admin product api creates updates and archives products', function (): void { + $store = adminCatalogApiStore(); + $collection = ProductCollection::factory()->create([ + 'store_id' => $store->getKey(), + 'title' => 'API Summer', + 'handle' => 'api-summer', + ]); + $user = adminCatalogApiUser(); + + $writeToken = adminApiBearerToken($store, ['write-products'], $user); + + $createResponse = $this->withToken($writeToken) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/products", [ + 'title' => 'API Cotton T-Shirt', + 'handle' => 'api-cotton-t-shirt', + 'description_html' => '

Soft cotton tee.

', + 'vendor' => 'API Apparel', + 'product_type' => 'Shirts', + 'status' => 'active', + 'tags' => ['organic', 'cotton'], + 'options' => [ + ['name' => 'Color', 'position' => 1], + ['name' => 'Size', 'position' => 2], + ], + 'variants' => [ + [ + 'sku' => 'API-TEE-BLU-S', + 'price_amount' => 2500, + 'compare_at_amount' => 3000, + 'is_default' => true, + 'position' => 1, + 'option_values' => [ + ['option_name' => 'Color', 'value' => 'Blue'], + ['option_name' => 'Size', 'value' => 'Small'], + ], + 'inventory' => [ + 'quantity_on_hand' => 12, + 'policy' => 'deny', + ], + ], + [ + 'sku' => 'API-TEE-BLU-M', + 'price_amount' => 2600, + 'is_default' => false, + 'position' => 2, + 'option_values' => [ + ['option_name' => 'Color', 'value' => 'Blue'], + ['option_name' => 'Size', 'value' => 'Medium'], + ], + 'inventory' => [ + 'quantity_on_hand' => 8, + 'policy' => 'continue', + ], + ], + ], + 'collections' => [$collection->getKey()], + ]) + ->assertCreated() + ->assertJsonPath('data.title', 'API Cotton T-Shirt') + ->assertJsonPath('data.status', 'active') + ->assertJsonPath('data.variants.0.inventory.quantity_on_hand', 12) + ->assertJsonPath('data.collections.0.handle', 'api-summer'); + + $product = Product::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', 'api-cotton-t-shirt') + ->firstOrFail(); + $defaultVariant = ProductVariant::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->where('is_default', true) + ->firstOrFail(); + $removedVariant = ProductVariant::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->where('sku', 'API-TEE-BLU-M') + ->firstOrFail(); + + expect($product->status)->toBe(ProductStatus::Active) + ->and($product->options()->count())->toBe(2) + ->and($product->variants()->count())->toBe(2); + + $this->withToken($writeToken) + ->putJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}", [ + 'title' => 'API Cotton T-Shirt Updated', + 'tags' => ['organic', 'bestseller'], + 'variants' => [ + [ + 'id' => $defaultVariant->getKey(), + 'sku' => 'API-TEE-BLU-S-UPDATED', + 'price_amount' => 2700, + 'is_default' => true, + 'position' => 1, + 'option_values' => [ + ['option_name' => 'Color', 'value' => 'Blue'], + ['option_name' => 'Size', 'value' => 'Small'], + ], + 'inventory' => [ + 'quantity_on_hand' => 15, + ], + ], + ], + ]) + ->assertOk() + ->assertJsonPath('data.title', 'API Cotton T-Shirt Updated') + ->assertJsonPath('data.variants.0.sku', 'API-TEE-BLU-S-UPDATED') + ->assertJsonPath('data.variants.0.inventory.quantity_on_hand', 15); + + $this->assertModelMissing($removedVariant); + + $this->withToken($writeToken) + ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}") + ->assertOk() + ->assertJsonPath('data.status', 'archived'); + + expect($product->refresh()->status)->toBe(ProductStatus::Archived); +}); + +test('admin product api presigns media uploads', function (): void { + $store = adminCatalogApiStore(); + $product = Product::factory()->withDefaultVariant()->create([ + 'store_id' => $store->getKey(), + 'title' => 'API Media Product', + ]); + + $this->withToken(adminApiBearerToken($store, ['write-products'], adminCatalogApiUser())) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}/media/presign-upload", [ + 'filename' => 'front.jpg', + 'content_type' => 'image/jpeg', + 'byte_size' => 245000, + ]) + ->assertCreated() + ->assertJsonPath('method', 'PUT') + ->assertJsonPath('headers.Content-Type', 'image/jpeg') + ->assertJsonPath('storage_key', fn (string $storageKey): bool => str_starts_with($storageKey, "media/originals/{$product->getKey()}/")) + ->assertJsonPath('upload_url', fn (string $uploadUrl): bool => $uploadUrl !== ''); + + $media = ProductMedia::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->firstOrFail(); + + expect($media->status)->toBe(MediaStatus::Processing) + ->and($media->mime_type)->toBe('image/jpeg') + ->and($media->byte_size)->toBe(245000) + ->and($media->position)->toBe(0); +}); + +test('admin customer api lists and shows store scoped customers', function (): void { + $store = adminCatalogApiStore(); + $customer = Customer::factory()->create([ + 'store_id' => $store->getKey(), + 'email' => 'customer-api@example.test', + 'name' => 'Customer API', + 'marketing_opt_in' => true, + ]); + CustomerAddress::factory()->default()->create(['customer_id' => $customer->getKey()]); + Order::factory()->paid()->forCustomer($customer)->create([ + 'store_id' => $store->getKey(), + 'order_number' => '#CA-1001', + 'total_amount' => 4400, + ]); + Customer::factory()->create([ + 'store_id' => Store::factory()->create()->getKey(), + 'email' => 'other-customer@example.test', + ]); + + $this->withToken(adminApiBearerToken($store, ['read-customers'], adminCatalogApiUser())) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers?query=customer-api") + ->assertOk() + ->assertJsonPath('data.0.email', 'customer-api@example.test') + ->assertJsonPath('data.0.orders_count', 1) + ->assertJsonPath('data.0.total_spent_amount', 4400) + ->assertJsonMissing(['email' => 'other-customer@example.test']); + + $this->withToken(adminApiBearerToken($store, ['read-customers'], adminCatalogApiUser())) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers/{$customer->getKey()}") + ->assertOk() + ->assertJsonPath('data.email', 'customer-api@example.test') + ->assertJsonPath('data.addresses.0.is_default', true) + ->assertJsonPath('data.orders.0.order_number', '#CA-1001'); +}); + +test('admin catalog api accepts scoped tokens and enforces abilities', function (): void { + $store = adminCatalogApiStore(); + Product::factory()->withDefaultVariant()->create([ + 'store_id' => $store->getKey(), + 'title' => 'Token Visible Product', + ]); + Customer::factory()->create([ + 'store_id' => $store->getKey(), + 'email' => 'token-customer@example.test', + ]); + $productToken = adminCatalogApiToken($store, ['read-products']); + $writeProductToken = adminCatalogApiToken($store, ['write-products']); + $customerToken = adminCatalogApiToken($store, ['read-customers']); + + $this->withToken($productToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/products?query=Token Visible") + ->assertOk() + ->assertJsonPath('data.0.title', 'Token Visible Product'); + + $this->withToken($productToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers") + ->assertForbidden(); + + $this->withToken($productToken['plain_text']) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/products", [ + 'title' => 'Token Created Product', + 'variants' => [ + [ + 'sku' => 'TOKEN-CREATED-001', + 'price_amount' => 1999, + 'is_default' => true, + ], + ], + ]) + ->assertForbidden(); + + $this->withToken($writeProductToken['plain_text']) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/products", [ + 'title' => 'Token Created Product', + 'variants' => [ + [ + 'sku' => 'TOKEN-CREATED-001', + 'price_amount' => 1999, + 'is_default' => true, + ], + ], + ]) + ->assertCreated() + ->assertJsonPath('data.title', 'Token Created Product'); + + $this->withToken($customerToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers?query=token-customer") + ->assertOk() + ->assertJsonPath('data.0.email', 'token-customer@example.test'); + + expect($productToken['token']->refresh()->last_used_at)->not->toBeNull() + ->and($writeProductToken['token']->refresh()->last_used_at)->not->toBeNull() + ->and($customerToken['token']->refresh()->last_used_at)->not->toBeNull(); +}); + +test('admin catalog api rejects resources outside the requested store', function (): void { + $store = adminCatalogApiStore(); + $otherStore = Store::factory()->create(); + $otherProduct = Product::factory()->withDefaultVariant()->create(['store_id' => $otherStore->getKey()]); + $otherCustomer = Customer::factory()->create(['store_id' => $otherStore->getKey()]); + + $this->withToken(adminApiBearerToken($store, ['read-products'], adminCatalogApiUser())) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/products/{$otherProduct->getKey()}") + ->assertNotFound(); + + $this->withToken(adminApiBearerToken($store, ['read-customers'], adminCatalogApiUser())) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers/{$otherCustomer->getKey()}") + ->assertNotFound(); +}); + +test('deferred oauth and app ecosystem routes return not implemented', function (): void { + $store = adminCatalogApiStore(); + + $this->actingAs(adminCatalogApiUser()) + ->getJson('/oauth/authorize?client_id=test&redirect_uri=https://example.test/callback&response_type=code&scope=read-products&state=state') + ->assertStatus(501) + ->assertJsonPath('message', 'OAuth app ecosystem endpoints are deferred for initial implementation.'); + + $this->postJson('/oauth/token', [ + 'grant_type' => 'authorization_code', + 'client_id' => 'test', + 'client_secret' => 'secret', + 'redirect_uri' => 'https://example.test/callback', + 'code' => 'code', + ]) + ->assertStatus(501) + ->assertJsonPath('message', 'OAuth app ecosystem endpoints are deferred for initial implementation.'); + + $this->getJson("/api/apps/v1/stores/{$store->getKey()}/products") + ->assertStatus(501) + ->assertJsonPath('message', 'App API endpoints are deferred for initial implementation.'); +}); diff --git a/tests/Feature/Api/AdminCollectionApiTest.php b/tests/Feature/Api/AdminCollectionApiTest.php new file mode 100644 index 00000000..bb860320 --- /dev/null +++ b/tests/Feature/Api/AdminCollectionApiTest.php @@ -0,0 +1,148 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function adminCollectionApiStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function adminCollectionApiUser(): User +{ + return User::query()->where('email', 'admin@acme.test')->firstOrFail(); +} + +/** + * @param list $abilities + * @return array{token: \App\Models\PersonalAccessToken, plain_text: string} + */ +function adminCollectionApiToken(Store $store, array $abilities): array +{ + return adminApiToken($store, $abilities); +} + +test('admin collection api lists creates updates and deletes collections', function (): void { + $store = adminCollectionApiStore(); + $products = Product::factory() + ->count(3) + ->withDefaultVariant() + ->create(['store_id' => $store->getKey()]); + Collection::factory()->create([ + 'store_id' => Store::factory()->create()->getKey(), + 'title' => 'Other Store Collection', + ]); + $user = adminCollectionApiUser(); + $readToken = adminApiBearerToken($store, ['read-collections'], $user); + $writeToken = adminApiBearerToken($store, ['write-collections'], $user); + + $this->withToken($readToken) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/collections?query=New") + ->assertOk() + ->assertJsonMissing(['title' => 'Other Store Collection']); + + $createResponse = $this->withToken($writeToken) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/collections", [ + 'title' => 'API Winter', + 'description_html' => '

Cold weather goods.

', + 'type' => 'manual', + 'status' => 'active', + 'product_ids' => $products->take(2)->pluck('id')->all(), + ]) + ->assertCreated() + ->assertJsonPath('data.title', 'API Winter') + ->assertJsonPath('data.handle', 'api-winter') + ->assertJsonPath('data.products_count', 2); + + $collectionId = $createResponse->json('data.id'); + $collection = Collection::withoutGlobalScopes()->findOrFail($collectionId); + + expect($collection->products()->pluck('collection_products.position', 'products.id')->all()) + ->toBe([ + $products[0]->getKey() => 0, + $products[1]->getKey() => 1, + ]); + + $this->withToken($writeToken) + ->putJson("/api/admin/v1/stores/{$store->getKey()}/collections/{$collection->getKey()}", [ + 'title' => 'API Winter Edit', + 'status' => 'draft', + 'add_product_ids' => [$products[2]->getKey()], + 'remove_product_ids' => [$products[0]->getKey()], + ]) + ->assertOk() + ->assertJsonPath('data.title', 'API Winter Edit') + ->assertJsonPath('data.status', 'draft') + ->assertJsonPath('data.products_count', 2); + + expect($collection->refresh()->products()->pluck('products.id')->all()) + ->toBe([$products[1]->getKey(), $products[2]->getKey()]); + + $this->withToken($writeToken) + ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/collections/{$collection->getKey()}") + ->assertOk() + ->assertJsonPath('message', 'Collection deleted'); + + expect(Collection::withoutGlobalScopes()->whereKey($collection->getKey())->exists())->toBeFalse(); +}); + +test('admin collection api enforces token abilities and store scope', function (): void { + $store = adminCollectionApiStore(); + $otherStore = Store::factory()->create(); + $collection = Collection::factory()->create([ + 'store_id' => $store->getKey(), + 'title' => 'Token Collection', + ]); + $readToken = adminCollectionApiToken($store, ['read-collections']); + $writeToken = adminCollectionApiToken($store, ['write-collections']); + $otherStoreToken = adminCollectionApiToken($otherStore, ['read-collections']); + + $this->withToken($readToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/collections?query=Token") + ->assertOk() + ->assertJsonPath('data.0.title', 'Token Collection'); + + $this->withToken($readToken['plain_text']) + ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/collections/{$collection->getKey()}") + ->assertForbidden(); + + $this->withToken($otherStoreToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/collections") + ->assertForbidden(); + + $this->withToken($writeToken['plain_text']) + ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/collections/{$collection->getKey()}") + ->assertOk(); +}); + +test('admin collection api validates handles and product store ownership', function (): void { + $store = adminCollectionApiStore(); + $existing = Collection::factory()->create([ + 'store_id' => $store->getKey(), + 'handle' => 'existing-api-collection', + ]); + $otherStoreProduct = Product::factory() + ->withDefaultVariant() + ->create(['store_id' => Store::factory()->create()->getKey()]); + + $this->withToken(adminApiBearerToken($store, ['write-collections'], adminCollectionApiUser())) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/collections", [ + 'title' => 'Invalid API Collection', + 'handle' => $existing->handle, + 'type' => 'manual', + 'product_ids' => [$otherStoreProduct->getKey()], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['handle', 'product_ids.0']); +}); diff --git a/tests/Feature/Api/AdminDiscountApiTest.php b/tests/Feature/Api/AdminDiscountApiTest.php new file mode 100644 index 00000000..6c06b8e6 --- /dev/null +++ b/tests/Feature/Api/AdminDiscountApiTest.php @@ -0,0 +1,150 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function adminDiscountApiStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function adminDiscountApiUser(): User +{ + return User::query()->where('email', 'admin@acme.test')->firstOrFail(); +} + +/** + * @param list $abilities + * @return array{token: \App\Models\PersonalAccessToken, plain_text: string} + */ +function adminDiscountApiToken(Store $store, array $abilities): array +{ + return adminApiToken($store, $abilities); +} + +test('admin discount api lists creates updates and deletes discounts', function (): void { + $store = adminDiscountApiStore(); + $product = Product::factory()->withDefaultVariant()->create(['store_id' => $store->getKey()]); + $collection = Collection::factory()->create(['store_id' => $store->getKey()]); + $user = adminDiscountApiUser(); + $readToken = adminApiBearerToken($store, ['read-discounts'], $user); + $writeToken = adminApiBearerToken($store, ['write-discounts'], $user); + + $this->withToken($readToken) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/discounts?type=code&status=active") + ->assertOk(); + + $createResponse = $this->withToken($writeToken) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/discounts", [ + 'type' => 'code', + 'code' => 'api20', + 'value_type' => 'percent', + 'value_amount' => 20, + 'starts_at' => now()->subHour()->toIso8601String(), + 'ends_at' => now()->addMonth()->toIso8601String(), + 'usage_limit' => 50, + 'rules_json' => [ + 'minimum_purchase_amount' => 5000, + 'applicable_product_ids' => [$product->getKey()], + 'applicable_collection_ids' => [$collection->getKey()], + 'customer_eligibility' => 'all', + 'once_per_customer' => true, + ], + ]) + ->assertCreated() + ->assertJsonPath('data.code', 'API20') + ->assertJsonPath('data.rules_json.min_purchase_amount', 5000) + ->assertJsonPath('data.rules_json.one_per_customer', true); + + $discount = Discount::withoutGlobalScopes()->findOrFail($createResponse->json('data.id')); + + expect($discount->rules_json['applicable_product_ids'])->toBe([$product->getKey()]) + ->and($discount->rules_json['applicable_collection_ids'])->toBe([$collection->getKey()]); + + $this->withToken($writeToken) + ->putJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}", [ + 'value_type' => 'fixed', + 'value_amount' => 750, + 'usage_limit' => 25, + 'status' => 'disabled', + ]) + ->assertOk() + ->assertJsonPath('data.value_type', 'fixed') + ->assertJsonPath('data.value_amount', 750) + ->assertJsonPath('data.status', 'disabled'); + + $this->withToken($writeToken) + ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}") + ->assertOk() + ->assertJsonPath('message', 'Discount deleted'); + + expect(Discount::withoutGlobalScopes()->whereKey($discount->getKey())->exists())->toBeFalse(); +}); + +test('admin discount api enforces token abilities and store scope', function (): void { + $store = adminDiscountApiStore(); + $otherStore = Store::factory()->create(); + $discount = Discount::factory()->create([ + 'store_id' => $store->getKey(), + 'code' => 'TOKEN25', + ]); + $readToken = adminDiscountApiToken($store, ['read-discounts']); + $writeToken = adminDiscountApiToken($store, ['write-discounts']); + $otherStoreToken = adminDiscountApiToken($otherStore, ['read-discounts']); + + $this->withToken($readToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/discounts?type=code") + ->assertOk() + ->assertJsonFragment(['code' => 'TOKEN25']); + + $this->withToken($readToken['plain_text']) + ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}") + ->assertForbidden(); + + $this->withToken($otherStoreToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/discounts") + ->assertForbidden(); + + $this->withToken($writeToken['plain_text']) + ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}") + ->assertOk(); +}); + +test('admin discount api validates code dates and scoped rule resources', function (): void { + $store = adminDiscountApiStore(); + $existing = Discount::factory()->create([ + 'store_id' => $store->getKey(), + 'code' => 'EXISTING', + ]); + $otherStoreProduct = Product::factory() + ->withDefaultVariant() + ->create(['store_id' => Store::factory()->create()->getKey()]); + + $this->withToken(adminApiBearerToken($store, ['write-discounts'], adminDiscountApiUser())) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/discounts", [ + 'type' => 'code', + 'code' => Str::lower($existing->code), + 'value_type' => 'percent', + 'value_amount' => 101, + 'starts_at' => now()->addDay()->toIso8601String(), + 'ends_at' => now()->subDay()->toIso8601String(), + 'rules_json' => [ + 'applicable_product_ids' => [$otherStoreProduct->getKey()], + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['code', 'value_amount', 'ends_at', 'rules_json.applicable_product_ids.0']); +}); diff --git a/tests/Feature/Api/AdminOrderApiTest.php b/tests/Feature/Api/AdminOrderApiTest.php new file mode 100644 index 00000000..fcba83c4 --- /dev/null +++ b/tests/Feature/Api/AdminOrderApiTest.php @@ -0,0 +1,260 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function adminOrderApiStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function adminOrderApiUser(): User +{ + return User::query()->where('email', 'admin@acme.test')->firstOrFail(); +} + +function adminOrderApiUserWithRole(Store $store, StoreUserRole $role): User +{ + $user = User::factory()->create(['email_verified_at' => now()]); + + DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => $role->value, + 'created_at' => now(), + ]); + + return $user; +} + +/** + * @param list $abilities + * @return array{token: \App\Models\PersonalAccessToken, plain_text: string} + */ +function adminOrderApiToken(Store $store, array $abilities): array +{ + return adminApiToken($store, $abilities); +} + +/** + * @return array{0: Order, 1: OrderLine, 2: ProductVariant} + */ +function adminOrderApiOrder(Store $store, array $orderAttributes = [], int $quantity = 2, int $unitPrice = 2500): array +{ + $product = Product::factory() + ->withDefaultVariant($unitPrice) + ->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->firstOrFail(); + + InventoryItem::withoutGlobalScopes() + ->where('variant_id', $variant->getKey()) + ->update([ + 'quantity_on_hand' => 10, + 'quantity_reserved' => 0, + ]); + + $total = $quantity * $unitPrice; + $order = Order::factory()->paid()->create(array_merge([ + 'store_id' => $store->getKey(), + 'subtotal_amount' => $total, + 'shipping_amount' => 0, + 'tax_amount' => 0, + 'total_amount' => $total, + 'email' => 'buyer@example.test', + ], $orderAttributes)); + $line = OrderLine::factory()->create([ + 'order_id' => $order->getKey(), + 'product_id' => $product->getKey(), + 'variant_id' => $variant->getKey(), + 'title_snapshot' => 'Admin API Product', + 'sku_snapshot' => 'ADMIN-API-001', + 'quantity' => $quantity, + 'unit_price_amount' => $unitPrice, + 'total_amount' => $total, + ]); + + Payment::factory()->create([ + 'order_id' => $order->getKey(), + 'status' => $order->financial_status === FinancialStatus::Pending ? PaymentStatus::Pending : PaymentStatus::Captured, + 'amount' => $total, + 'currency' => $order->currency, + ]); + + return [$order, $line, $variant]; +} + +test('admin order api lists and shows store scoped orders', function (): void { + $store = adminOrderApiStore(); + [$order] = adminOrderApiOrder($store, [ + 'order_number' => '#8001', + 'email' => 'alpha@example.test', + ]); + $otherStore = Store::factory()->create(); + adminOrderApiOrder($otherStore, [ + 'order_number' => '#9001', + 'email' => 'other@example.test', + ]); + $readToken = adminApiBearerToken($store, ['read-orders'], adminOrderApiUser()); + + $this->getJson("/api/admin/v1/stores/{$store->getKey()}/orders") + ->assertUnauthorized(); + + $this->withToken($readToken) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/orders?query=alpha") + ->assertOk() + ->assertJsonPath('data.0.order_number', '#8001') + ->assertJsonMissing(['order_number' => '#9001']); + + $this->withToken($readToken) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/orders/{$order->getKey()}") + ->assertOk() + ->assertJsonPath('data.order_number', '#8001') + ->assertJsonPath('data.lines.0.title', 'Admin API Product'); +}); + +test('admin order api creates refunds and fulfillments', function (): void { + $store = adminOrderApiStore(); + [$order, $line] = adminOrderApiOrder($store, quantity: 2, unitPrice: 2500); + $user = adminOrderApiUser(); + $writeToken = adminApiBearerToken($store, ['write-orders'], $user); + + $this->withToken($writeToken) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/orders/{$order->getKey()}/refunds", [ + 'amount' => 1000, + 'reason' => 'Customer return', + ]) + ->assertCreated() + ->assertJsonPath('data.amount', 1000) + ->assertJsonPath('data.status', 'processed'); + + expect($order->refresh()->financial_status)->toBe(FinancialStatus::PartiallyRefunded); + + $this->withToken($writeToken) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/orders/{$order->getKey()}/fulfillments", [ + 'line_items' => [ + ['order_line_id' => $line->getKey(), 'quantity' => 1], + ], + 'tracking_company' => 'DHL', + 'tracking_number' => 'DHL123', + ]) + ->assertCreated() + ->assertJsonPath('data.status', 'pending') + ->assertJsonPath('data.lines.0.quantity', 1); + + $fulfillment = Fulfillment::query()->where('order_id', $order->getKey())->firstOrFail(); + + expect($order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Partial) + ->and($fulfillment->tracking_number)->toBe('DHL123'); +}); + +test('admin order api rejects orders outside the requested store', function (): void { + $store = adminOrderApiStore(); + $otherStore = Store::factory()->create(); + [$otherOrder] = adminOrderApiOrder($otherStore, [ + 'order_number' => '#9001', + ]); + + $this->withToken(adminApiBearerToken($store, ['read-orders'], adminOrderApiUser())) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/orders/{$otherOrder->getKey()}") + ->assertNotFound(); +}); + +test('admin order api accepts scoped bearer tokens', function (): void { + $store = adminOrderApiStore(); + adminOrderApiOrder($store, [ + 'order_number' => '#8101', + 'email' => 'token@example.test', + ]); + $result = adminOrderApiToken($store, ['read-orders']); + + $this->withToken($result['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/orders?query=token") + ->assertOk() + ->assertJsonPath('data.0.order_number', '#8101'); + + expect($result['token']->refresh()->last_used_at)->not->toBeNull(); +}); + +test('admin order api enforces token store scope and abilities', function (): void { + $store = adminOrderApiStore(); + [$order] = adminOrderApiOrder($store); + $readOnly = adminOrderApiToken($store, ['read-orders']); + $writeToken = adminOrderApiToken($store, ['write-orders']); + $otherStore = Store::factory()->create(); + $otherStoreToken = adminOrderApiToken($otherStore, ['read-orders']); + + $this->withToken($readOnly['plain_text']) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/orders/{$order->getKey()}/refunds", [ + 'amount' => 500, + 'reason' => 'Read-only token', + ]) + ->assertForbidden(); + + $this->withToken($otherStoreToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/orders") + ->assertForbidden(); + + $this->withToken($writeToken['plain_text']) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/orders/{$order->getKey()}/refunds", [ + 'amount' => 500, + 'reason' => 'Write token', + ]) + ->assertCreated() + ->assertJsonPath('data.amount', 500); +}); + +test('admin order api applies role policies after token abilities pass', function (): void { + $store = adminOrderApiStore(); + [$order, $line] = adminOrderApiOrder($store); + $staff = adminOrderApiUserWithRole($store, StoreUserRole::Staff); + $staffWriteToken = adminApiBearerToken($store, ['write-orders'], $staff); + + $this->withToken($staffWriteToken) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/orders/{$order->getKey()}/refunds", [ + 'amount' => 500, + 'reason' => 'Staff token', + ]) + ->assertForbidden(); + + $this->withToken($staffWriteToken) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/orders/{$order->getKey()}/fulfillments", [ + 'line_items' => [ + ['order_line_id' => $line->getKey(), 'quantity' => 1], + ], + ]) + ->assertCreated(); +}); + +test('admin order api rejects expired bearer tokens', function (): void { + $store = adminOrderApiStore(); + adminOrderApiOrder($store); + $result = adminOrderApiToken($store, ['read-orders']); + $result['token']->forceFill(['expires_at' => now()->subMinute()])->save(); + + $this->withToken($result['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/orders") + ->assertUnauthorized(); +}); diff --git a/tests/Feature/Api/AdminOrderExportApiTest.php b/tests/Feature/Api/AdminOrderExportApiTest.php new file mode 100644 index 00000000..b7c4d2a9 --- /dev/null +++ b/tests/Feature/Api/AdminOrderExportApiTest.php @@ -0,0 +1,165 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function adminOrderExportApiStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function adminOrderExportApiUser(): User +{ + return User::query()->where('email', 'admin@acme.test')->firstOrFail(); +} + +/** + * @param list $abilities + * @return array{token: \App\Models\PersonalAccessToken, plain_text: string} + */ +function adminOrderExportApiToken(Store $store, array $abilities): array +{ + return adminApiToken($store, $abilities); +} + +function adminOrderExportApiOrder(Store $store, array $attributes = []): Order +{ + return Order::factory()->paid()->create([ + 'store_id' => $store->getKey(), + 'email' => 'export@example.test', + 'subtotal_amount' => 2500, + 'discount_amount' => 100, + 'shipping_amount' => 500, + 'tax_amount' => 0, + 'total_amount' => 2900, + 'currency' => $store->default_currency, + ...$attributes, + ]); +} + +test('admin order export api creates completed csv exports', function (): void { + Storage::fake('local'); + + $store = adminOrderExportApiStore(); + $matching = adminOrderExportApiOrder($store, [ + 'order_number' => '#EX1001', + 'created_at' => now()->subDays(2), + ]); + Fulfillment::factory()->shipped()->create([ + 'order_id' => $matching->getKey(), + 'tracking_number' => 'TRACK-1001', + ]); + adminOrderExportApiOrder($store, [ + 'order_number' => '#EX2001', + 'status' => 'pending', + 'financial_status' => 'pending', + 'created_at' => now()->subDays(2), + ]); + adminOrderExportApiOrder(Store::factory()->create(), [ + 'order_number' => '#EX3001', + 'created_at' => now()->subDays(2), + ]); + $readToken = adminApiBearerToken($store, ['read-orders'], adminOrderExportApiUser()); + + $createResponse = $this->withToken($readToken) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/exports/orders", [ + 'format' => 'csv', + 'filters' => [ + 'status' => 'paid', + 'created_after' => now()->subDays(3)->toIso8601String(), + 'created_before' => now()->subDay()->toIso8601String(), + ], + ]) + ->assertAccepted() + ->assertJsonPath('status', 'completed'); + + $export = DataExport::query()->findOrFail($createResponse->json('export_id')); + + Storage::disk('local')->assertExists($export->storage_key); + + $showResponse = $this->withToken($readToken) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/exports/{$export->getKey()}") + ->assertOk() + ->assertJsonPath('data.status', 'completed') + ->assertJsonPath('data.format', 'csv') + ->assertJsonPath('data.row_count', 1) + ->assertJsonPath('data.download_expires_at', $export->download_expires_at?->toIso8601String()); + + $csv = rawurldecode(Str::after((string) $showResponse->json('data.download_url'), ',')); + + expect($csv)->toContain('order_number,created_at,status') + ->and($csv)->toContain('#EX1001') + ->and($csv)->toContain('TRACK-1001') + ->and($csv)->not->toContain('#EX2001') + ->and($csv)->not->toContain('#EX3001'); +}); + +test('admin order export api enforces token abilities and store scope', function (): void { + Storage::fake('local'); + + $store = adminOrderExportApiStore(); + adminOrderExportApiOrder($store, ['order_number' => '#TOKEN-EXPORT']); + $readToken = adminOrderExportApiToken($store, ['read-orders']); + $wrongAbilityToken = adminOrderExportApiToken($store, ['read-products']); + $otherStoreToken = adminOrderExportApiToken(Store::factory()->create(), ['read-orders']); + + $this->withToken($wrongAbilityToken['plain_text']) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/exports/orders", [ + 'format' => 'csv', + ]) + ->assertForbidden(); + + $createResponse = $this->withToken($readToken['plain_text']) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/exports/orders", [ + 'format' => 'csv', + ]) + ->assertAccepted(); + + $export = DataExport::query()->findOrFail($createResponse->json('export_id')); + + $this->withToken($readToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/exports/{$export->getKey()}") + ->assertOk(); + + $this->withToken($otherStoreToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/exports/{$export->getKey()}") + ->assertForbidden(); + + $otherStoreExport = DataExport::factory()->create([ + 'store_id' => Store::factory()->create()->getKey(), + ]); + + $this->withToken(adminApiBearerToken($store, ['read-orders'], adminOrderExportApiUser())) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/exports/{$otherStoreExport->getKey()}") + ->assertNotFound(); +}); + +test('admin order export api validates format and filters', function (): void { + $store = adminOrderExportApiStore(); + + $this->withToken(adminApiBearerToken($store, ['read-orders'], adminOrderExportApiUser())) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/exports/orders", [ + 'format' => 'xlsx', + 'filters' => [ + 'status' => 'lost', + 'created_after' => now()->toIso8601String(), + 'created_before' => now()->subDay()->toIso8601String(), + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['format', 'filters.status', 'filters.created_before']); +}); diff --git a/tests/Feature/Api/AdminPageApiTest.php b/tests/Feature/Api/AdminPageApiTest.php new file mode 100644 index 00000000..6d5d1bba --- /dev/null +++ b/tests/Feature/Api/AdminPageApiTest.php @@ -0,0 +1,146 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function adminPageApiStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function adminPageApiUser(): User +{ + return User::query()->where('email', 'admin@acme.test')->firstOrFail(); +} + +/** + * @param list $abilities + * @return array{token: \App\Models\PersonalAccessToken, plain_text: string} + */ +function adminPageApiToken(Store $store, array $abilities): array +{ + return adminApiToken($store, $abilities); +} + +test('admin page api lists creates updates and deletes pages', function (): void { + $store = adminPageApiStore(); + $otherStore = Store::factory()->create(); + Page::factory()->published()->create([ + 'store_id' => $store->getKey(), + 'title' => 'API Visible Page', + 'handle' => 'api-visible-page', + ]); + Page::factory()->published()->create([ + 'store_id' => $otherStore->getKey(), + 'title' => 'Other Store Page', + 'handle' => 'other-store-page', + ]); + $user = adminPageApiUser(); + $readToken = adminApiBearerToken($store, ['read-content'], $user); + $writeToken = adminApiBearerToken($store, ['write-content'], $user); + + $this->withToken($readToken) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/pages?status=published&query=API") + ->assertOk() + ->assertJsonFragment(['title' => 'API Visible Page']) + ->assertJsonMissing(['title' => 'Other Store Page']); + + $createResponse = $this->withToken($writeToken) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/pages", [ + 'title' => 'Shipping Policy API', + 'body_html' => '

Shipping Policy

We ship worldwide.

', + 'status' => 'published', + ]) + ->assertCreated() + ->assertJsonPath('data.title', 'Shipping Policy API') + ->assertJsonPath('data.handle', 'shipping-policy-api') + ->assertJsonPath('data.status', 'published'); + + $page = Page::withoutGlobalScopes()->findOrFail($createResponse->json('data.id')); + + expect($page->published_at)->not->toBeNull(); + + $this->withToken($writeToken) + ->putJson("/api/admin/v1/stores/{$store->getKey()}/pages/{$page->getKey()}", [ + 'handle' => 'API Shipping Policy Updated', + 'body_html' => '

Updated policy.

', + 'status' => 'draft', + ]) + ->assertOk() + ->assertJsonPath('data.handle', 'api-shipping-policy-updated') + ->assertJsonPath('data.body_html', '

Updated policy.

') + ->assertJsonPath('data.status', 'draft'); + + $this->withToken($writeToken) + ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/pages/{$page->getKey()}") + ->assertOk() + ->assertJsonPath('message', 'Page deleted'); + + expect(Page::withoutGlobalScopes()->whereKey($page->getKey())->exists())->toBeFalse(); +}); + +test('admin page api enforces token abilities and store scope', function (): void { + $store = adminPageApiStore(); + $otherStore = Store::factory()->create(); + $page = Page::factory()->published()->create([ + 'store_id' => $store->getKey(), + 'title' => 'Token Content Page', + 'handle' => 'token-content-page', + ]); + $readToken = adminPageApiToken($store, ['read-content']); + $writeToken = adminPageApiToken($store, ['write-content']); + $otherStoreToken = adminPageApiToken($otherStore, ['read-content']); + + $this->withToken($readToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/pages?query=Token") + ->assertOk() + ->assertJsonPath('data.0.title', 'Token Content Page'); + + $this->withToken($readToken['plain_text']) + ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/pages/{$page->getKey()}") + ->assertForbidden(); + + $this->withToken($otherStoreToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/pages") + ->assertForbidden(); + + $this->withToken($writeToken['plain_text']) + ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/pages/{$page->getKey()}") + ->assertOk(); +}); + +test('admin page api validates normalized handles and published dates', function (): void { + $store = adminPageApiStore(); + $existing = Page::factory()->create([ + 'store_id' => $store->getKey(), + 'handle' => 'existing-api-page', + ]); + + $this->withToken(adminApiBearerToken($store, ['write-content'], adminPageApiUser())) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/pages", [ + 'title' => 'Invalid API Page', + 'handle' => str_replace('-', ' ', $existing->handle), + 'status' => 'published', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['handle']); + + $this->withToken(adminApiBearerToken($store, ['write-content'], adminPageApiUser())) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/pages", [ + 'title' => 'Invalid API Date', + 'status' => 'published', + 'published_at' => 'not-a-date', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['published_at']); +}); diff --git a/tests/Feature/Api/AdminPlatformApiTest.php b/tests/Feature/Api/AdminPlatformApiTest.php new file mode 100644 index 00000000..54be7d16 --- /dev/null +++ b/tests/Feature/Api/AdminPlatformApiTest.php @@ -0,0 +1,205 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function adminPlatformApiStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function adminPlatformApiUser(): User +{ + return User::query()->where('email', 'admin@acme.test')->firstOrFail(); +} + +/** + * @param list $abilities + * @return array{token: \App\Models\PersonalAccessToken, plain_text: string} + */ +function adminPlatformApiToken(Store $store, array $abilities): array +{ + return adminApiToken($store, $abilities); +} + +test('platform api creates organizations and stores for platform admin bearer tokens', function (): void { + $store = adminPlatformApiStore(); + $user = adminPlatformApiUser(); + $token = adminApiBearerToken($store, ['manage-platform'], $user); + + $organizationResponse = $this->withToken($token) + ->postJson('/api/admin/v1/platform/organizations', [ + 'name' => 'Platform API Org', + 'billing_email' => 'billing@platform-api.test', + ]) + ->assertCreated() + ->assertJsonPath('data.name', 'Platform API Org') + ->assertJsonPath('data.billing_email', 'billing@platform-api.test'); + + $organizationId = $organizationResponse->json('data.id'); + + $this->withToken($token) + ->postJson('/api/admin/v1/platform/stores', [ + 'organization_id' => $organizationId, + 'name' => 'Platform API Store', + 'handle' => 'platform-api-store', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ]) + ->assertCreated() + ->assertJsonPath('data.name', 'Platform API Store') + ->assertJsonPath('data.handle', 'platform-api-store') + ->assertJsonPath('data.status', 'active'); + + $store = Store::query()->where('handle', 'platform-api-store')->firstOrFail(); + + expect(StoreSettings::query()->whereKey($store->getKey())->exists())->toBeTrue() + ->and(DB::table('store_users') + ->where('store_id', $store->getKey()) + ->where('user_id', $user->getKey()) + ->where('role', 'owner') + ->exists())->toBeTrue(); +}); + +test('platform api rejects non platform admin tokens and tokens without manage platform ability', function (): void { + $store = adminPlatformApiStore(); + $ordinaryOwner = User::factory()->create([ + 'email' => 'ordinary-owner@example.test', + 'is_platform_admin' => false, + ]); + $staff = User::factory()->create([ + 'email' => 'platform-staff@example.test', + ]); + DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $ordinaryOwner->getKey(), + 'role' => StoreUserRole::Owner->value, + 'created_at' => now(), + ]); + DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $staff->getKey(), + 'role' => StoreUserRole::Staff->value, + 'created_at' => now(), + ]); + $readToken = adminPlatformApiToken($store, ['read-products']); + $ordinaryOwnerToken = adminApiBearerToken($store, ['manage-platform'], $ordinaryOwner); + $staffToken = adminApiBearerToken($store, ['manage-platform'], $staff); + + $this->withToken($ordinaryOwnerToken) + ->postJson('/api/admin/v1/platform/organizations', [ + 'name' => 'Ordinary Owner Org', + 'billing_email' => 'ordinary-owner@example.test', + ]) + ->assertForbidden(); + + $this->withToken($staffToken) + ->postJson('/api/admin/v1/platform/organizations', [ + 'name' => 'Forbidden Org', + 'billing_email' => 'forbidden@example.test', + ]) + ->assertForbidden(); + + $this->withToken($readToken['plain_text']) + ->postJson('/api/admin/v1/platform/organizations', [ + 'name' => 'Forbidden Token Org', + 'billing_email' => 'forbidden-token@example.test', + ]) + ->assertForbidden(); +}); + +test('admin api routes use the named admin token rate limiter', function (): void { + $storeProductRoute = Route::getRoutes()->getByName('api.admin.v1.products.index'); + $platformRoute = Route::getRoutes()->getByName('api.admin.v1.platform.organizations.store'); + + expect($storeProductRoute)->not->toBeNull() + ->and($storeProductRoute->gatherMiddleware())->toContain('auth:sanctum') + ->and($storeProductRoute->gatherMiddleware())->toContain('throttle:api.admin') + ->and($platformRoute)->not->toBeNull() + ->and($platformRoute->gatherMiddleware())->toContain('auth:sanctum') + ->and($platformRoute->gatherMiddleware())->toContain('throttle:api.admin'); +}); + +test('platform api accepts manage platform bearer tokens', function (): void { + $store = adminPlatformApiStore(); + $token = adminPlatformApiToken($store, ['manage-platform']); + + $this->withToken($token['plain_text']) + ->postJson('/api/admin/v1/platform/organizations', [ + 'name' => 'Token Platform Org', + 'billing_email' => 'token-platform@example.test', + ]) + ->assertCreated() + ->assertJsonPath('data.name', 'Token Platform Org'); + + expect($token['token']->refresh()->last_used_at)->not->toBeNull(); +}); + +test('store me and invite endpoints expose membership and enforce role or ability', function (): void { + $store = adminPlatformApiStore(); + $user = adminPlatformApiUser(); + $staff = User::factory()->create([ + 'email' => 'store-staff@example.test', + ]); + DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $staff->getKey(), + 'role' => 'staff', + 'created_at' => now(), + ]); + $readToken = adminPlatformApiToken($store, ['read-products']); + $manageToken = adminPlatformApiToken($store, ['manage-platform']); + $userToken = adminApiBearerToken($store, ['*'], $user); + $staffToken = adminApiBearerToken($store, ['manage-platform'], $staff); + + $this->withToken($readToken['plain_text']) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/invites", [ + 'email' => 'new-staff@example.test', + 'role' => 'staff', + ]) + ->assertForbidden(); + + $this->withToken($manageToken['plain_text']) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/invites", [ + 'email' => 'new-staff@example.test', + 'role' => 'staff', + ]) + ->assertCreated() + ->assertJsonPath('data.email', 'new-staff@example.test') + ->assertJsonPath('data.role', 'staff'); + + $this->withToken($userToken) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/me") + ->assertOk() + ->assertJsonPath('data.email', 'admin@acme.test') + ->assertJsonPath('data.role', 'owner') + ->assertJsonPath('data.permissions.1', 'read-products'); + + $this->withToken($staffToken) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/invites", [ + 'email' => 'new-staff@example.test', + 'role' => 'staff', + ]) + ->assertForbidden(); + + $this->withToken($manageToken['plain_text']) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/invites", [ + 'email' => 'admin@acme.test', + 'role' => 'admin', + ]) + ->assertStatus(409); +}); diff --git a/tests/Feature/Api/AdminSearchIndexApiTest.php b/tests/Feature/Api/AdminSearchIndexApiTest.php new file mode 100644 index 00000000..0b555eb9 --- /dev/null +++ b/tests/Feature/Api/AdminSearchIndexApiTest.php @@ -0,0 +1,96 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function adminSearchIndexApiStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function adminSearchIndexApiUser(): User +{ + return User::query()->where('email', 'admin@acme.test')->firstOrFail(); +} + +/** + * @param list $abilities + * @return array{token: \App\Models\PersonalAccessToken, plain_text: string} + */ +function adminSearchIndexApiToken(Store $store, array $abilities): array +{ + return adminApiToken($store, $abilities); +} + +test('admin search index api reports status and rebuilds stale documents', function (): void { + $store = adminSearchIndexApiStore(); + $product = Product::factory() + ->for($store) + ->withDefaultVariant(2999) + ->create([ + 'title' => 'API Reindex Linen Jacket', + 'handle' => 'api-reindex-linen-jacket', + ]); + + DB::table('products_fts')->where('product_id', $product->getKey())->delete(); + + expect(app(SearchService::class)->search($store, 'api reindex linen', [], 12)->total())->toBe(0); + + $this->withToken(adminApiBearerToken($store, ['read-settings'], adminSearchIndexApiUser())) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/search/status") + ->assertOk() + ->assertJsonPath('data.index_status', 'stale') + ->assertJsonPath('data.pending_updates', 1); + + $this->withToken(adminApiBearerToken($store, ['write-settings'], adminSearchIndexApiUser())) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/search/reindex") + ->assertAccepted() + ->assertJsonPath('status', 'completed') + ->assertJsonPath('documents_count', Product::withoutGlobalScopes()->where('store_id', $store->getKey())->count()); + + expect(app(SearchService::class)->search($store, 'api reindex linen', [], 12)->total())->toBe(1) + ->and(SearchSettings::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail()->updated_at)->not->toBeNull(); + + $this->withToken(adminApiBearerToken($store, ['read-settings'], adminSearchIndexApiUser())) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/search/status") + ->assertOk() + ->assertJsonPath('data.index_status', 'ready') + ->assertJsonPath('data.pending_updates', 0); +}); + +test('admin search index api enforces token abilities and store scope', function (): void { + $store = adminSearchIndexApiStore(); + $otherStore = Store::factory()->create(); + $readToken = adminSearchIndexApiToken($store, ['read-settings']); + $writeToken = adminSearchIndexApiToken($store, ['write-settings']); + $otherStoreToken = adminSearchIndexApiToken($otherStore, ['read-settings']); + + $this->withToken($readToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/search/status") + ->assertOk(); + + $this->withToken($readToken['plain_text']) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/search/reindex") + ->assertForbidden(); + + $this->withToken($otherStoreToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/search/status") + ->assertForbidden(); + + $this->withToken($writeToken['plain_text']) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/search/reindex") + ->assertAccepted(); +}); diff --git a/tests/Feature/Api/AdminShippingSettingsApiTest.php b/tests/Feature/Api/AdminShippingSettingsApiTest.php new file mode 100644 index 00000000..61eea761 --- /dev/null +++ b/tests/Feature/Api/AdminShippingSettingsApiTest.php @@ -0,0 +1,148 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function adminShippingSettingsApiStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function adminShippingSettingsApiUser(): User +{ + return User::query()->where('email', 'admin@acme.test')->firstOrFail(); +} + +/** + * @param list $abilities + * @return array{token: \App\Models\PersonalAccessToken, plain_text: string} + */ +function adminShippingSettingsApiToken(Store $store, array $abilities): array +{ + return adminApiToken($store, $abilities); +} + +test('admin shipping settings api lists creates updates zones and adds rates', function (): void { + $store = adminShippingSettingsApiStore(); + $user = adminShippingSettingsApiUser(); + $readToken = adminApiBearerToken($store, ['read-settings'], $user); + $writeToken = adminApiBearerToken($store, ['write-settings'], $user); + + $this->withToken($readToken) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones") + ->assertOk() + ->assertJsonPath('data.0.name', 'Domestic') + ->assertJsonPath('data.0.rates.0.config_json.currency', $store->default_currency); + + $createResponse = $this->withToken($writeToken) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones", [ + 'name' => 'Nordics API', + 'countries_json' => ['se', 'no'], + 'regions_json' => ['stockholm'], + ]) + ->assertCreated() + ->assertJsonPath('data.name', 'Nordics API') + ->assertJsonPath('data.countries_json.0', 'SE') + ->assertJsonPath('data.regions_json.0', 'STOCKHOLM'); + + $zone = ShippingZone::withoutGlobalScopes()->findOrFail($createResponse->json('data.id')); + + $this->withToken($writeToken) + ->putJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones/{$zone->getKey()}", [ + 'name' => 'Nordics and Baltics API', + 'countries_json' => ['SE', 'DK'], + 'regions_json' => [], + ]) + ->assertOk() + ->assertJsonPath('data.name', 'Nordics and Baltics API') + ->assertJsonPath('data.countries_json.1', 'DK'); + + $rateResponse = $this->withToken($writeToken) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones/{$zone->getKey()}/rates", [ + 'name' => 'API Express', + 'type' => 'flat', + 'config_json' => [ + 'price_amount' => 1200, + 'currency' => $store->default_currency, + ], + 'is_active' => true, + ]) + ->assertCreated() + ->assertJsonPath('data.name', 'API Express') + ->assertJsonPath('data.type', 'flat') + ->assertJsonPath('data.config_json.price_amount', 1200); + + $rate = ShippingRate::withoutGlobalScopes()->findOrFail($rateResponse->json('data.id')); + + expect($zone->refresh()->countries_json)->toBe(['SE', 'DK']) + ->and($rate->type)->toBe(ShippingRateType::Flat) + ->and($rate->config_json['amount'])->toBe(1200); +}); + +test('admin shipping settings api enforces token abilities and store scope', function (): void { + $store = adminShippingSettingsApiStore(); + $otherStore = Store::factory()->create(); + $zone = ShippingZone::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail(); + $readToken = adminShippingSettingsApiToken($store, ['read-settings']); + $writeToken = adminShippingSettingsApiToken($store, ['write-settings']); + $otherStoreToken = adminShippingSettingsApiToken($otherStore, ['read-settings']); + + $this->withToken($readToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones") + ->assertOk(); + + $this->withToken($readToken['plain_text']) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones", [ + 'name' => 'Read Only Zone', + 'countries_json' => ['FI'], + ]) + ->assertForbidden(); + + $this->withToken($otherStoreToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones") + ->assertForbidden(); + + $this->withToken($writeToken['plain_text']) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones/{$zone->getKey()}/rates", [ + 'name' => 'Token Rate', + 'type' => 'carrier', + 'config_json' => [ + 'price_amount' => 999, + ], + ]) + ->assertCreated(); +}); + +test('admin shipping settings api validates countries overlaps and rate types', function (): void { + $store = adminShippingSettingsApiStore(); + $zone = ShippingZone::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail(); + + $this->withToken(adminApiBearerToken($store, ['write-settings'], adminShippingSettingsApiUser())) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones", [ + 'name' => 'Overlap API', + 'countries_json' => ['DE'], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['countries_json']); + + $this->withToken(adminApiBearerToken($store, ['write-settings'], adminShippingSettingsApiUser())) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones/{$zone->getKey()}/rates", [ + 'name' => 'Invalid Rate', + 'type' => 'rocket', + 'config_json' => [], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['type']); +}); diff --git a/tests/Feature/Api/AdminStoreSettingsApiTest.php b/tests/Feature/Api/AdminStoreSettingsApiTest.php new file mode 100644 index 00000000..c12c30f8 --- /dev/null +++ b/tests/Feature/Api/AdminStoreSettingsApiTest.php @@ -0,0 +1,148 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function adminStoreSettingsApiStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function adminStoreSettingsApiUser(): User +{ + return User::query()->where('email', 'admin@acme.test')->firstOrFail(); +} + +/** + * @param list $abilities + * @return array{token: \App\Models\PersonalAccessToken, plain_text: string} + */ +function adminStoreSettingsApiToken(Store $store, array $abilities): array +{ + return adminApiToken($store, $abilities); +} + +test('admin store settings api shows and updates general settings', function (): void { + $store = adminStoreSettingsApiStore(); + $user = adminStoreSettingsApiUser(); + $readToken = adminApiBearerToken($store, ['read-settings'], $user); + $writeToken = adminApiBearerToken($store, ['write-settings'], $user); + + $this->withToken($readToken) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/settings") + ->assertOk() + ->assertJsonPath('data.name', 'Acme Fashion') + ->assertJsonPath('data.default_currency', 'EUR') + ->assertJsonPath('data.settings_json.announcement.enabled', true) + ->assertJsonPath('data.domains.0.is_primary', true); + + $this->withToken($writeToken) + ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [ + 'name' => 'Acme API Store', + 'default_currency' => 'USD', + 'default_locale' => 'de', + 'timezone' => 'Europe/Paris', + 'settings_json' => [ + 'announcement' => [ + 'enabled' => false, + 'text' => 'API announcement', + ], + 'checkout' => [ + 'terms_required' => true, + 'terms_url' => 'https://example.test/terms', + ], + 'bank_transfer_cancel_days' => 10, + ], + ]) + ->assertOk() + ->assertJsonPath('data.name', 'Acme API Store') + ->assertJsonPath('data.default_currency', 'USD') + ->assertJsonPath('data.default_locale', 'de') + ->assertJsonPath('data.timezone', 'Europe/Paris') + ->assertJsonPath('data.settings_json.announcement.enabled', false) + ->assertJsonPath('data.settings_json.checkout.terms_required', true) + ->assertJsonPath('data.settings_json.notifications.sender_email', 'no-reply@shop.test'); + + $settings = StoreSettings::query()->whereKey($store->getKey())->firstOrFail(); + + expect($store->refresh()->name)->toBe('Acme API Store') + ->and($store->default_currency)->toBe('USD') + ->and($settings->settings_json['announcement']['text'])->toBe('API announcement') + ->and($settings->settings_json['checkout']['terms_url'])->toBe('https://example.test/terms') + ->and($settings->settings_json['notifications']['sender_email'])->toBe('no-reply@shop.test'); +}); + +test('admin store settings api enforces token abilities and store scope', function (): void { + $store = adminStoreSettingsApiStore(); + $otherStore = Store::factory()->create(); + $readToken = adminStoreSettingsApiToken($store, ['read-settings']); + $writeToken = adminStoreSettingsApiToken($store, ['write-settings']); + $otherStoreToken = adminStoreSettingsApiToken($otherStore, ['read-settings']); + + $this->withToken($readToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/settings") + ->assertOk(); + + $this->withToken($readToken['plain_text']) + ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [ + 'name' => 'Read Only API Store', + ]) + ->assertForbidden(); + + $this->withToken($otherStoreToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/settings") + ->assertForbidden(); + + $this->withToken($writeToken['plain_text']) + ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [ + 'settings_json' => [ + 'notifications' => [ + 'low_stock_threshold' => 12, + ], + ], + ]) + ->assertOk() + ->assertJsonPath('data.settings_json.notifications.low_stock_threshold', 12); +}); + +test('admin store settings api validates defaults and settings shape', function (): void { + $store = adminStoreSettingsApiStore(); + + $this->withToken(adminApiBearerToken($store, ['write-settings'], adminStoreSettingsApiUser())) + ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [ + 'default_currency' => 'BTC', + 'default_locale' => 'es', + 'timezone' => 'Mars/Olympus', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['default_currency', 'default_locale', 'timezone']); + + $this->withToken(adminApiBearerToken($store, ['write-settings'], adminStoreSettingsApiUser())) + ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [ + 'settings_json' => ['invalid-list-value'], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['settings_json']); + + $this->withToken(adminApiBearerToken($store, ['write-settings'], adminStoreSettingsApiUser())) + ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [ + 'settings_json' => [ + 'checkout' => [ + 'terms_url' => 'not-a-url', + 'payment_hold_hours' => 0, + ], + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['settings_json.checkout.terms_url', 'settings_json.checkout.payment_hold_hours']); +}); diff --git a/tests/Feature/Api/AdminTaxSettingsApiTest.php b/tests/Feature/Api/AdminTaxSettingsApiTest.php new file mode 100644 index 00000000..4e2301b4 --- /dev/null +++ b/tests/Feature/Api/AdminTaxSettingsApiTest.php @@ -0,0 +1,147 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function adminTaxSettingsApiStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function adminTaxSettingsApiUser(): User +{ + return User::query()->where('email', 'admin@acme.test')->firstOrFail(); +} + +/** + * @param list $abilities + * @return array{token: \App\Models\PersonalAccessToken, plain_text: string} + */ +function adminTaxSettingsApiToken(Store $store, array $abilities): array +{ + return adminApiToken($store, $abilities); +} + +test('admin tax settings api shows and updates manual settings', function (): void { + $store = adminTaxSettingsApiStore(); + $user = adminTaxSettingsApiUser(); + $readToken = adminApiBearerToken($store, ['read-settings'], $user); + $writeToken = adminApiBearerToken($store, ['write-settings'], $user); + + $this->withToken($readToken) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings") + ->assertOk() + ->assertJsonPath('data.store_id', $store->getKey()) + ->assertJsonPath('data.mode', 'manual') + ->assertJsonPath('data.config_json.default_tax_rate', 1900); + + $this->withToken($writeToken) + ->putJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings", [ + 'mode' => 'manual', + 'provider' => 'none', + 'prices_include_tax' => true, + 'config_json' => [ + 'default_tax_rate' => 2100, + 'tax_rates' => [ + [ + 'country_code' => 'de', + 'rate' => 2100, + 'name' => 'VAT', + 'shipping_taxed' => true, + ], + ], + ], + ]) + ->assertOk() + ->assertJsonPath('data.prices_include_tax', true) + ->assertJsonPath('data.config_json.default_tax_rate', 2100) + ->assertJsonPath('data.config_json.tax_rates.0.country_code', 'DE'); + + $settings = TaxSettings::withoutGlobalScopes()->whereKey($store->getKey())->firstOrFail(); + + expect($settings->mode)->toBe(TaxMode::Manual) + ->and($settings->provider)->toBe('none') + ->and($settings->prices_include_tax)->toBeTrue() + ->and($settings->config_json['default_rate_bps'])->toBe(2100) + ->and($settings->config_json['rates'][0]['country'])->toBe('DE'); +}); + +test('admin tax settings api enforces token abilities and store scope', function (): void { + $store = adminTaxSettingsApiStore(); + $otherStore = Store::factory()->create(); + $readToken = adminTaxSettingsApiToken($store, ['read-settings']); + $writeToken = adminTaxSettingsApiToken($store, ['write-settings']); + $otherStoreToken = adminTaxSettingsApiToken($otherStore, ['read-settings']); + + $this->withToken($readToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings") + ->assertOk(); + + $this->withToken($readToken['plain_text']) + ->putJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings", [ + 'mode' => 'manual', + 'provider' => 'none', + 'prices_include_tax' => false, + 'config_json' => [], + ]) + ->assertForbidden(); + + $this->withToken($otherStoreToken['plain_text']) + ->getJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings") + ->assertForbidden(); + + $this->withToken($writeToken['plain_text']) + ->putJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings", [ + 'mode' => 'provider', + 'provider' => 'stripe_tax', + 'prices_include_tax' => false, + 'config_json' => [ + 'stripe_tax_settings_id' => 'txr_api', + ], + ]) + ->assertOk() + ->assertJsonPath('data.mode', 'provider') + ->assertJsonPath('data.provider', 'stripe_tax'); +}); + +test('admin tax settings api validates provider and rate payloads', function (): void { + $store = adminTaxSettingsApiStore(); + + $this->withToken(adminApiBearerToken($store, ['write-settings'], adminTaxSettingsApiUser())) + ->putJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings", [ + 'mode' => 'provider', + 'prices_include_tax' => false, + 'config_json' => [], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['provider']); + + $this->withToken(adminApiBearerToken($store, ['write-settings'], adminTaxSettingsApiUser())) + ->putJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings", [ + 'mode' => 'manual', + 'provider' => 'none', + 'prices_include_tax' => false, + 'config_json' => [ + 'tax_rates' => [ + [ + 'country_code' => 'DEU', + 'rate' => 12000, + 'name' => 'VAT', + ], + ], + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['config_json.tax_rates.0.country_code', 'config_json.tax_rates.0.rate']); +}); diff --git a/tests/Feature/Api/AdminThemeApiTest.php b/tests/Feature/Api/AdminThemeApiTest.php new file mode 100644 index 00000000..93fa9990 --- /dev/null +++ b/tests/Feature/Api/AdminThemeApiTest.php @@ -0,0 +1,212 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function adminThemeApiStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function adminThemeApiUser(): User +{ + return User::query()->where('email', 'admin@acme.test')->firstOrFail(); +} + +/** + * @param list $abilities + * @return array{token: \App\Models\PersonalAccessToken, plain_text: string} + */ +function adminThemeApiToken(Store $store, array $abilities): array +{ + return adminApiToken($store, $abilities); +} + +/** + * @param array $overrides + */ +function adminThemeApiArchiveUpload(array $overrides = []): UploadedFile +{ + $path = tempnam(sys_get_temp_dir(), 'theme-api-'); + $zip = new ZipArchive; + + $zip->open($path, ZipArchive::OVERWRITE); + + foreach (array_merge([ + 'theme.json' => json_encode([ + 'name' => 'API Dawn', + 'version' => '2.0.0', + 'settings_json' => [ + 'home' => [ + 'hero' => [ + 'heading' => 'Archive Hero', + ], + ], + ], + ], JSON_THROW_ON_ERROR), + 'layouts/storefront.blade.php' => '{{ $slot }}', + 'sections/hero.blade.php' => '
API Hero
', + 'sections/featured-products.blade.php' => '
API Products
', + ], $overrides) as $file => $contents) { + if ($contents === null) { + continue; + } + + $zip->addFromString("api-theme/{$file}", $contents); + } + + $zip->close(); + + return new UploadedFile($path, 'theme.zip', 'application/zip', null, true); +} + +function adminThemeApiDraftTheme(Store $store): Theme +{ + $theme = Theme::factory()->create([ + 'store_id' => $store->getKey(), + 'name' => 'API Draft Theme', + ]); + + foreach ([ + 'layouts/storefront.blade.php', + 'sections/hero.blade.php', + 'sections/featured-products.blade.php', + ] as $path) { + ThemeFile::factory()->create([ + 'theme_id' => $theme->getKey(), + 'path' => $path, + ]); + } + + ThemeSettings::factory()->create([ + 'theme_id' => $theme->getKey(), + ]); + + return $theme; +} + +test('admin theme api installs uploaded archives', function (): void { + $store = adminThemeApiStore(); + $user = adminThemeApiUser(); + $writeToken = adminApiBearerToken($store, ['write-themes'], $user); + + $response = $this->withToken($writeToken) + ->post("/api/admin/v1/stores/{$store->getKey()}/themes", [ + 'name' => 'Uploaded API Theme', + 'file' => adminThemeApiArchiveUpload(), + ], ['Accept' => 'application/json']) + ->assertCreated() + ->assertJsonPath('data.name', 'Uploaded API Theme') + ->assertJsonPath('data.version', '2.0.0') + ->assertJsonPath('data.status', 'draft') + ->assertJsonPath('data.files_count', 4) + ->assertJsonPath('data.settings_json.home.hero.heading', 'Archive Hero'); + + $theme = Theme::withoutGlobalScopes()->findOrFail($response->json('data.id')); + $hero = $theme->files()->withoutGlobalScopes()->where('path', 'sections/hero.blade.php')->firstOrFail(); + + expect($theme->status)->toBe(ThemeStatus::Draft) + ->and(Storage::disk('local')->get($hero->storage_key))->toBe('
API Hero
'); +}); + +test('admin theme api updates settings and publishes one active theme', function (): void { + $store = adminThemeApiStore(); + $published = Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->where('status', ThemeStatus::Published)->firstOrFail(); + $draft = adminThemeApiDraftTheme($store); + $writeToken = adminApiBearerToken($store, ['write-themes'], adminThemeApiUser()); + + $this->withToken($writeToken) + ->putJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/settings", [ + 'settings_json' => [ + 'home' => [ + 'hero' => [ + 'heading' => 'API Saved Hero', + ], + ], + ], + ]) + ->assertOk() + ->assertJsonPath('data.settings_json.home.hero.heading', 'API Saved Hero'); + + $this->withToken($writeToken) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish") + ->assertOk() + ->assertJsonPath('data.status', 'published'); + + expect(ThemeSettings::withoutGlobalScopes()->where('theme_id', $draft->getKey())->first()?->settings_json['home']['hero']['heading'])->toBe('API Saved Hero') + ->and($draft->refresh()->status)->toBe(ThemeStatus::Published) + ->and($published->refresh()->status)->toBe(ThemeStatus::Draft) + ->and(Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->where('status', ThemeStatus::Published)->count())->toBe(1); +}); + +test('admin theme api enforces token abilities and store scope', function (): void { + $store = adminThemeApiStore(); + $otherStore = Store::factory()->create(); + $draft = adminThemeApiDraftTheme($store); + $readToken = adminThemeApiToken($store, ['read-settings']); + $writeToken = adminThemeApiToken($store, ['write-themes']); + $otherStoreToken = adminThemeApiToken($otherStore, ['write-themes']); + + $this->withToken($readToken['plain_text']) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish") + ->assertForbidden(); + + $this->withToken($otherStoreToken['plain_text']) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish") + ->assertForbidden(); + + $this->withToken($writeToken['plain_text']) + ->putJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/settings", [ + 'settings_json' => [ + 'announcement' => [ + 'enabled' => false, + ], + ], + ]) + ->assertOk() + ->assertJsonPath('data.settings_json.announcement.enabled', false); +}); + +test('admin theme api validates archives settings and publishable files', function (): void { + $store = adminThemeApiStore(); + $draft = Theme::factory()->create([ + 'store_id' => $store->getKey(), + 'name' => 'Incomplete API Theme', + ]); + + $this->withToken(adminApiBearerToken($store, ['write-themes'], adminThemeApiUser())) + ->post("/api/admin/v1/stores/{$store->getKey()}/themes", [ + 'file' => adminThemeApiArchiveUpload([ + 'sections/hero.blade.php' => null, + ]), + ], ['Accept' => 'application/json']) + ->assertUnprocessable() + ->assertJsonValidationErrors(['file']); + + $this->withToken(adminApiBearerToken($store, ['write-themes'], adminThemeApiUser())) + ->putJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/settings", [ + 'settings_json' => ['invalid-list-value'], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['settings_json']); + + $this->withToken(adminApiBearerToken($store, ['write-themes'], adminThemeApiUser())) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish") + ->assertUnprocessable() + ->assertJsonValidationErrors(['theme']); +}); diff --git a/tests/Feature/Api/StorefrontAnalyticsApiTest.php b/tests/Feature/Api/StorefrontAnalyticsApiTest.php new file mode 100644 index 00000000..86f4e383 --- /dev/null +++ b/tests/Feature/Api/StorefrontAnalyticsApiTest.php @@ -0,0 +1,78 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +test('storefront analytics api accepts batches and deduplicates client events', function (): void { + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + + $payload = [ + 'events' => [ + [ + 'type' => 'page_view', + 'session_id' => 'api-session-1', + 'client_event_id' => 'api-event-1', + 'properties' => ['url' => '/', 'channel' => 'storefront'], + 'occurred_at' => now()->toIso8601String(), + ], + [ + 'type' => 'add_to_cart', + 'session_id' => 'api-session-1', + 'client_event_id' => 'api-event-2', + 'properties' => ['variant_id' => 1, 'quantity' => 2], + 'occurred_at' => now()->toIso8601String(), + ], + ], + ]; + + $this->withHeader('Host', 'shop.test') + ->postJson('/api/storefront/v1/analytics/events', $payload) + ->assertAccepted() + ->assertJsonPath('accepted', 2) + ->assertJsonPath('rejected', 0); + + $this->withHeader('Host', 'shop.test') + ->postJson('/api/storefront/v1/analytics/events', [ + 'events' => [$payload['events'][0]], + ]) + ->assertAccepted() + ->assertJsonPath('accepted', 0) + ->assertJsonPath('rejected', 1); + + expect(AnalyticsEvent::withoutGlobalScopes()->where('store_id', $store->getKey())->whereIn('client_event_id', ['api-event-1', 'api-event-2'])->count())->toBe(2); +}); + +test('storefront analytics api validates event payload boundaries', function (): void { + $this->withHeader('Host', 'shop.test') + ->postJson('/api/storefront/v1/analytics/events', [ + 'events' => [], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('events'); + + $this->withHeader('Host', 'shop.test') + ->postJson('/api/storefront/v1/analytics/events', [ + 'events' => [[ + 'type' => 'unknown', + 'session_id' => 'api-session-2', + 'client_event_id' => 'api-event-invalid', + 'properties' => ['a' => ['b' => ['c' => ['d' => true]]]], + 'occurred_at' => now()->subHours(2)->toIso8601String(), + ]], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors([ + 'events.0.type', + 'events.0.properties', + 'events.0.occurred_at', + ]); +}); diff --git a/tests/Feature/Api/StorefrontCartApiTest.php b/tests/Feature/Api/StorefrontCartApiTest.php new file mode 100644 index 00000000..a448ce32 --- /dev/null +++ b/tests/Feature/Api/StorefrontCartApiTest.php @@ -0,0 +1,156 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function storefrontApiCartStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function storefrontApiCartVariant(Store $store): ProductVariant +{ + $product = Product::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', 'classic-cotton-t-shirt') + ->firstOrFail(); + + $variant = ProductVariant::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->oldest('position') + ->firstOrFail(); + + InventoryItem::withoutGlobalScopes() + ->where('variant_id', $variant->getKey()) + ->update([ + 'quantity_on_hand' => 20, + 'quantity_reserved' => 0, + ]); + + return $variant->refresh(); +} + +test('storefront cart api creates and retrieves carts for the resolved store', function (): void { + $store = storefrontApiCartStore(); + + $response = $this->withHeader('Host', 'shop.test') + ->postJson('/api/storefront/v1/carts', ['currency' => $store->default_currency]); + + $response + ->assertCreated() + ->assertJsonPath('data.store_id', $store->getKey()) + ->assertJsonPath('data.currency', 'EUR') + ->assertJsonPath('data.status', 'active') + ->assertJsonPath('data.cart_version', 1) + ->assertJsonPath('data.line_count', 0) + ->assertJsonPath('data.totals.total', 0); + + $this->withHeader('Host', 'shop.test') + ->getJson("/api/storefront/v1/carts/{$response['data']['id']}") + ->assertOk() + ->assertJsonPath('data.id', $response['data']['id']) + ->assertJsonPath('data.lines', []); +}); + +test('storefront cart api adds updates and removes line items with version increments', function (): void { + $store = storefrontApiCartStore(); + $variant = storefrontApiCartVariant($store); + + $cartId = $this->withHeader('Host', 'shop.test') + ->postJson('/api/storefront/v1/carts') + ->assertCreated()['data']['id']; + + $addResponse = $this->withHeader('Host', 'shop.test') + ->postJson("/api/storefront/v1/carts/{$cartId}/lines", [ + 'variant_id' => $variant->getKey(), + 'quantity' => 2, + ]); + + $addResponse + ->assertCreated() + ->assertJsonPath('data.cart_version', 2) + ->assertJsonPath('data.line_count', 2) + ->assertJsonPath('data.lines.0.quantity', 2) + ->assertJsonPath('data.lines.0.line_total_amount', 4998); + + $lineId = $addResponse['data']['lines'][0]['id']; + + $this->withHeader('Host', 'shop.test') + ->putJson("/api/storefront/v1/carts/{$cartId}/lines/{$lineId}", [ + 'quantity' => 3, + 'cart_version' => 2, + ]) + ->assertOk() + ->assertJsonPath('data.cart_version', 3) + ->assertJsonPath('data.lines.0.quantity', 3) + ->assertJsonPath('data.totals.total', 7497); + + $this->withHeader('Host', 'shop.test') + ->deleteJson("/api/storefront/v1/carts/{$cartId}/lines/{$lineId}", [ + 'cart_version' => 3, + ]) + ->assertOk() + ->assertJsonPath('data.cart_version', 4) + ->assertJsonPath('data.line_count', 0) + ->assertJsonPath('data.lines', []); + + expect(Cart::withoutGlobalScopes()->findOrFail($cartId)->cart_version)->toBe(4); +}); + +test('storefront cart api returns the current cart on version conflicts', function (): void { + $store = storefrontApiCartStore(); + $variant = storefrontApiCartVariant($store); + + $cartId = $this->withHeader('Host', 'shop.test') + ->postJson('/api/storefront/v1/carts') + ->assertCreated()['data']['id']; + + $lineId = $this->withHeader('Host', 'shop.test') + ->postJson("/api/storefront/v1/carts/{$cartId}/lines", [ + 'variant_id' => $variant->getKey(), + 'quantity' => 1, + ]) + ->assertCreated()['data']['lines'][0]['id']; + + $this->withHeader('Host', 'shop.test') + ->putJson("/api/storefront/v1/carts/{$cartId}/lines/{$lineId}", [ + 'quantity' => 2, + 'cart_version' => 1, + ]) + ->assertConflict() + ->assertJsonPath('expected_cart_version', 1) + ->assertJsonPath('current_cart_version', 2) + ->assertJsonPath('cart.cart_version', 2) + ->assertJsonPath('cart.lines.0.quantity', 1); +}); + +test('storefront cart api validates tenant and request boundaries', function (): void { + $store = storefrontApiCartStore(); + + $this->withHeader('Host', 'shop.test') + ->postJson('/api/storefront/v1/carts', ['currency' => 'USD']) + ->assertUnprocessable() + ->assertJsonValidationErrors('currency'); + + $this->withHeader('Host', 'shop.test') + ->getJson('/api/storefront/v1/carts/999999') + ->assertNotFound(); + + $otherStoreCart = Cart::factory()->create(['store_id' => Store::query()->whereKeyNot($store->getKey())->firstOrFail()->getKey()]); + + $this->withHeader('Host', 'shop.test') + ->getJson("/api/storefront/v1/carts/{$otherStoreCart->getKey()}") + ->assertNotFound(); +}); diff --git a/tests/Feature/Api/StorefrontCheckoutApiTest.php b/tests/Feature/Api/StorefrontCheckoutApiTest.php new file mode 100644 index 00000000..6017795b --- /dev/null +++ b/tests/Feature/Api/StorefrontCheckoutApiTest.php @@ -0,0 +1,245 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function storefrontApiCheckoutStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function storefrontApiCheckoutVariant(Store $store): ProductVariant +{ + $product = Product::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', 'classic-cotton-t-shirt') + ->firstOrFail(); + + $variant = ProductVariant::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->oldest('position') + ->firstOrFail(); + + InventoryItem::withoutGlobalScopes() + ->where('variant_id', $variant->getKey()) + ->update([ + 'quantity_on_hand' => 20, + 'quantity_reserved' => 0, + ]); + + return $variant->refresh(); +} + +/** + * @return array + */ +function storefrontApiCheckoutAddress(string $country = 'DE'): array +{ + return [ + 'first_name' => 'Test', + 'last_name' => 'Buyer', + 'address1' => 'Main Street 1', + 'city' => 'Berlin', + 'province_code' => 'BE', + 'country' => $country, + 'country_code' => $country, + 'postal_code' => '10115', + ]; +} + +function storefrontApiCheckoutUrl(int $checkoutId, string $token, string $suffix = ''): string +{ + return "/api/storefront/v1/checkouts/{$checkoutId}{$suffix}?token={$token}"; +} + +test('storefront checkout api progresses through address shipping discount removal and payment selection', function (): void { + $store = storefrontApiCheckoutStore(); + $variant = storefrontApiCheckoutVariant($store); + $api = fn () => $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.21'])->withHeader('Host', 'shop.test'); + + $cartId = $api() + ->postJson('/api/storefront/v1/carts') + ->assertCreated()['data']['id']; + + $api() + ->postJson("/api/storefront/v1/carts/{$cartId}/lines", [ + 'variant_id' => $variant->getKey(), + 'quantity' => 2, + ]) + ->assertCreated(); + + $checkoutResponse = $api() + ->postJson('/api/storefront/v1/checkouts', [ + 'cart_id' => $cartId, + 'email' => 'buyer@example.test', + ]); + + $checkoutResponse + ->assertCreated() + ->assertJsonPath('data.status', 'started') + ->assertJsonPath('data.email', 'buyer@example.test') + ->assertJsonPath('data.totals.subtotal', 4998) + ->assertJsonPath('data.cart.line_count', 2); + + $checkoutId = $checkoutResponse['data']['id']; + $checkoutToken = $checkoutResponse['data']['access_token']; + + $addressResponse = $api() + ->putJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/address'), [ + 'shipping_address' => storefrontApiCheckoutAddress(), + ]); + + $addressResponse + ->assertOk() + ->assertJsonPath('data.status', 'addressed') + ->assertJsonPath('data.shipping_address.country', 'DE') + ->assertJsonCount(3, 'data.available_shipping_rates'); + + $shippingRateId = $addressResponse['data']['available_shipping_rates'][0]['id']; + + $api() + ->putJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/shipping-method'), [ + 'shipping_rate_id' => $shippingRateId, + ]) + ->assertOk() + ->assertJsonPath('data.status', 'shipping_selected') + ->assertJsonPath('data.shipping_method_id', $shippingRateId) + ->assertJsonPath('data.totals.shipping', 499); + + $api() + ->postJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/apply-discount'), ['code' => 'SAVE10']) + ->assertOk() + ->assertJsonPath('data.discount_code', 'SAVE10') + ->assertJsonPath('data.totals.discount', 500); + + $api() + ->deleteJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/discount')) + ->assertOk() + ->assertJsonPath('data.discount_code', null) + ->assertJsonPath('data.totals.discount', 0); + + $api() + ->putJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/payment-method'), [ + 'payment_method' => 'credit_card', + ]) + ->assertOk() + ->assertJsonPath('data.status', 'payment_selected') + ->assertJsonPath('data.payment_method', 'credit_card'); + + $checkout = Checkout::withoutGlobalScopes()->findOrFail($checkoutId); + $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail(); + + expect($checkout->status)->toBe(CheckoutStatus::PaymentSelected) + ->and($inventory->quantity_reserved)->toBe(2); +}); + +test('storefront checkout api rejects invalid addresses shipping methods and discounts', function (): void { + $store = storefrontApiCheckoutStore(); + $variant = storefrontApiCheckoutVariant($store); + $api = fn () => $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.22'])->withHeader('Host', 'shop.test'); + + $cartId = $api() + ->postJson('/api/storefront/v1/carts') + ->assertCreated()['data']['id']; + + $api() + ->postJson("/api/storefront/v1/carts/{$cartId}/lines", [ + 'variant_id' => $variant->getKey(), + 'quantity' => 1, + ]) + ->assertCreated(); + + $checkoutResponse = $api() + ->postJson('/api/storefront/v1/checkouts', [ + 'cart_id' => $cartId, + 'email' => 'buyer@example.test', + ]) + ->assertCreated(); + $checkoutId = $checkoutResponse['data']['id']; + $checkoutToken = $checkoutResponse['data']['access_token']; + + $api() + ->putJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/address'), [ + 'shipping_address' => array_diff_key(storefrontApiCheckoutAddress(), ['first_name' => true]), + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('shipping_address.first_name'); + + $api() + ->putJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/address'), [ + 'shipping_address' => storefrontApiCheckoutAddress(), + ]) + ->assertOk(); + + $otherStoreRate = ShippingRate::withoutGlobalScopes() + ->whereHas('zone', fn ($query) => $query->withoutGlobalScopes()->where('store_id', Store::query()->whereKeyNot($store->getKey())->firstOrFail()->getKey())) + ->firstOrFail(); + + $api() + ->putJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/shipping-method'), [ + 'shipping_rate_id' => $otherStoreRate->getKey(), + ]) + ->assertUnprocessable(); + + $api() + ->postJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken, '/apply-discount'), ['code' => 'NOTREAL']) + ->assertUnprocessable() + ->assertJsonPath('reason', 'discount_not_found'); +}); + +test('storefront checkout api requires the checkout access token', function (): void { + $store = storefrontApiCheckoutStore(); + $variant = storefrontApiCheckoutVariant($store); + $api = fn () => $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.23'])->withHeader('Host', 'shop.test'); + + $cartId = $api() + ->postJson('/api/storefront/v1/carts') + ->assertCreated()['data']['id']; + + $api() + ->postJson("/api/storefront/v1/carts/{$cartId}/lines", [ + 'variant_id' => $variant->getKey(), + 'quantity' => 1, + ]) + ->assertCreated(); + + $checkoutResponse = $api() + ->postJson('/api/storefront/v1/checkouts', [ + 'cart_id' => $cartId, + 'email' => 'buyer@example.test', + ]) + ->assertCreated(); + + $checkoutId = $checkoutResponse['data']['id']; + $checkoutToken = $checkoutResponse['data']['access_token']; + + $api() + ->getJson("/api/storefront/v1/checkouts/{$checkoutId}") + ->assertNotFound(); + + $api() + ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address?token=bad-token", [ + 'shipping_address' => storefrontApiCheckoutAddress(), + ]) + ->assertNotFound(); + + $api() + ->getJson(storefrontApiCheckoutUrl($checkoutId, $checkoutToken)) + ->assertOk() + ->assertJsonPath('data.id', $checkoutId) + ->assertJsonPath('data.email', 'buyer@example.test'); +}); diff --git a/tests/Feature/Api/StorefrontOrderApiTest.php b/tests/Feature/Api/StorefrontOrderApiTest.php new file mode 100644 index 00000000..53242cd3 --- /dev/null +++ b/tests/Feature/Api/StorefrontOrderApiTest.php @@ -0,0 +1,177 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function storefrontOrderApiStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +function storefrontOrderApiVariant(Store $store): ProductVariant +{ + $product = Product::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', 'classic-cotton-t-shirt') + ->firstOrFail(); + + $variant = ProductVariant::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->oldest('position') + ->firstOrFail(); + + InventoryItem::withoutGlobalScopes() + ->where('variant_id', $variant->getKey()) + ->update([ + 'quantity_on_hand' => 20, + 'quantity_reserved' => 0, + ]); + + return $variant->refresh(); +} + +/** + * @return array{0: int, 1: ProductVariant, 2: string} + */ +function storefrontOrderApiCheckout(object $testCase, string $remoteAddress = '10.0.0.41'): array +{ + $store = storefrontOrderApiStore(); + $variant = storefrontOrderApiVariant($store); + $api = fn () => $testCase->withServerVariables(['REMOTE_ADDR' => $remoteAddress])->withHeader('Host', 'shop.test'); + + $cartId = $api() + ->postJson('/api/storefront/v1/carts') + ->assertCreated()['data']['id']; + + $api() + ->postJson("/api/storefront/v1/carts/{$cartId}/lines", [ + 'variant_id' => $variant->getKey(), + 'quantity' => 1, + ]) + ->assertCreated(); + + $checkoutResponse = $api() + ->postJson('/api/storefront/v1/checkouts', [ + 'cart_id' => $cartId, + 'email' => 'buyer@example.test', + ]) + ->assertCreated(); + $checkoutId = $checkoutResponse['data']['id']; + $checkoutToken = $checkoutResponse['data']['access_token']; + + $addressResponse = $api() + ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address?token={$checkoutToken}", [ + 'shipping_address' => [ + 'first_name' => 'Test', + 'last_name' => 'Buyer', + 'address1' => 'Main Street 1', + 'city' => 'Berlin', + 'country' => 'DE', + 'postal_code' => '10115', + ], + ]) + ->assertOk(); + + $api() + ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/shipping-method?token={$checkoutToken}", [ + 'shipping_rate_id' => $addressResponse['data']['available_shipping_rates'][0]['id'], + ]) + ->assertOk(); + + return [$checkoutId, $variant, $checkoutToken]; +} + +test('storefront order api pays a checkout and exposes token-gated order lookup', function (): void { + [$checkoutId, , $checkoutToken] = storefrontOrderApiCheckout($this); + $api = fn () => $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.41'])->withHeader('Host', 'shop.test'); + + $payResponse = $api() + ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay?token={$checkoutToken}", [ + 'payment_method' => 'credit_card', + 'card_number' => '4242 4242 4242 4242', + 'card_holder' => 'Test Buyer', + 'card_expiry' => '12/30', + 'card_cvc' => '123', + ]); + + $payResponse + ->assertOk() + ->assertJsonPath('data.order_number', '#1016') + ->assertJsonPath('data.financial_status', 'paid') + ->assertJsonCount(1, 'data.lines'); + + $token = $payResponse['data']['access_token']; + $orderNumber = $payResponse['data']['order_number']; + + $api() + ->getJson('/api/storefront/v1/orders/'.rawurlencode($orderNumber).'?token='.$token) + ->assertOk() + ->assertJsonPath('data.order_number', $orderNumber) + ->assertJsonPath('data.total_amount', $payResponse['data']['total_amount']); + + $api() + ->getJson('/api/storefront/v1/orders/'.rawurlencode($orderNumber).'?token=bad-token') + ->assertNotFound(); +}); + +test('storefront order api returns the same order when checkout payment is retried', function (): void { + [$checkoutId, $variant, $checkoutToken] = storefrontOrderApiCheckout($this, '10.0.0.43'); + $api = fn () => $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.43'])->withHeader('Host', 'shop.test'); + $payload = [ + 'payment_method' => 'credit_card', + 'card_number' => '4242 4242 4242 4242', + 'card_holder' => 'Test Buyer', + 'card_expiry' => '12/30', + 'card_cvc' => '123', + ]; + + $firstResponse = $api() + ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay?token={$checkoutToken}", $payload) + ->assertOk(); + $secondResponse = $api() + ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay?token={$checkoutToken}", $payload) + ->assertOk(); + + $orderId = $firstResponse->json('data.id'); + + expect($secondResponse->json('data.id'))->toBe($orderId) + ->and(Order::withoutGlobalScopes()->where('checkout_id', $checkoutId)->count())->toBe(1) + ->and(Payment::query()->where('order_id', $orderId)->count())->toBe(1) + ->and(InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail()->quantity_reserved)->toBe(0); +}); + +test('storefront order api returns payment failures and releases reservations', function (): void { + [$checkoutId, $variant, $checkoutToken] = storefrontOrderApiCheckout($this, '10.0.0.42'); + $api = fn () => $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.42'])->withHeader('Host', 'shop.test'); + + $api() + ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay?token={$checkoutToken}", [ + 'payment_method' => 'credit_card', + 'card_number' => '4000 0000 0000 0002', + 'card_holder' => 'Test Buyer', + 'card_expiry' => '12/30', + 'card_cvc' => '123', + ]) + ->assertStatus(402); + + $checkout = Checkout::withoutGlobalScopes()->findOrFail($checkoutId); + $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail(); + + expect($checkout->status)->toBe(CheckoutStatus::ShippingSelected) + ->and($inventory->quantity_reserved)->toBe(0); +}); diff --git a/tests/Feature/Api/StorefrontSearchApiTest.php b/tests/Feature/Api/StorefrontSearchApiTest.php new file mode 100644 index 00000000..2b558e05 --- /dev/null +++ b/tests/Feature/Api/StorefrontSearchApiTest.php @@ -0,0 +1,131 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function storefrontSearchApiStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +test('storefront search api returns store scoped product results with facets', function (): void { + $store = storefrontSearchApiStore(); + $otherStore = Store::query()->whereKeyNot($store->getKey())->firstOrFail(); + + Product::factory() + ->for($store) + ->withDefaultVariant(2199) + ->create([ + 'title' => 'Cloud Cotton Search Tee', + 'handle' => 'cloud-cotton-search-tee', + 'vendor' => 'Search Vendor', + 'product_type' => 'T-Shirts', + 'tags' => ['cotton', 'cloud'], + ]); + + Product::factory() + ->for($otherStore) + ->withDefaultVariant(2199) + ->create([ + 'title' => 'Cloud Cotton Search Tee Other Store', + 'handle' => 'cloud-cotton-search-tee-other-store', + 'vendor' => 'Other Vendor', + 'product_type' => 'T-Shirts', + 'tags' => ['cotton'], + ]); + + $filters = urlencode(json_encode([ + 'vendor' => 'Search Vendor', + 'price_min' => 1500, + 'price_max' => 2500, + 'in_stock' => true, + ], JSON_THROW_ON_ERROR)); + + $this->withHeader('Host', 'shop.test') + ->getJson("/api/storefront/v1/search?q=cloud%20cotton&filters={$filters}&sort=price_asc") + ->assertOk() + ->assertJsonPath('query', 'cloud cotton') + ->assertJsonPath('pagination.total', 1) + ->assertJsonPath('results.0.title', 'Cloud Cotton Search Tee') + ->assertJsonPath('results.0.price_amount', 2199) + ->assertJsonPath('facets.vendors.0.value', 'Search Vendor') + ->assertJsonPath('facets.price_range.min', 2199); + + expect(SearchQuery::withoutGlobalScopes()->where('store_id', $store->getKey())->where('query', 'cloud cotton')->exists())->toBeTrue(); +}); + +test('storefront search api returns empty results for no matches and validates input', function (): void { + $this->withHeader('Host', 'shop.test') + ->getJson('/api/storefront/v1/search?q=xyznonexistent') + ->assertOk() + ->assertJsonPath('pagination.total', 0) + ->assertJsonPath('results', []); + + $this->withHeader('Host', 'shop.test') + ->getJson('/api/storefront/v1/search') + ->assertUnprocessable() + ->assertJsonValidationErrors('q'); + + $this->withHeader('Host', 'shop.test') + ->getJson('/api/storefront/v1/search?q=test&filters=not-json') + ->assertUnprocessable() + ->assertJsonValidationErrors('filters'); +}); + +test('storefront search api paginates results', function (): void { + $store = storefrontSearchApiStore(); + + foreach (range(1, 25) as $index) { + Product::factory() + ->for($store) + ->withDefaultVariant(1200 + $index) + ->create([ + 'title' => "Api Pagination Linen Search {$index}", + 'handle' => "api-pagination-linen-search-{$index}", + ]); + } + + $this->withHeader('Host', 'shop.test') + ->getJson('/api/storefront/v1/search?q=api%20pagination%20linen&per_page=12&page=2') + ->assertOk() + ->assertJsonPath('pagination.total', 25) + ->assertJsonPath('pagination.current_page', 2) + ->assertJsonPath('pagination.last_page', 3) + ->assertJsonCount(12, 'results'); +}); + +test('storefront search suggest api returns product and collection suggestions', function (): void { + $store = storefrontSearchApiStore(); + + Product::factory() + ->for($store) + ->withDefaultVariant(1899) + ->create([ + 'title' => 'Alpaca Suggest Search Jacket', + 'handle' => 'alpaca-suggest-search-jacket', + ]); + + ProductCollection::factory()->for($store)->create([ + 'title' => 'Alpaca Search Collection', + 'handle' => 'alpaca-search-collection', + 'status' => 'active', + ]); + + $response = $this->withHeader('Host', 'shop.test') + ->getJson('/api/storefront/v1/search/suggest?q=alpaca&limit=5') + ->assertOk() + ->assertJsonPath('query', 'alpaca'); + + expect(collect($response['suggestions'])->pluck('type')->all())->toContain('product', 'collection'); +}); diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index fff11fd7..61946360 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -21,7 +21,7 @@ $response ->assertSessionHasNoErrors() - ->assertRedirect(route('dashboard', absolute: false)); + ->assertRedirect(route('admin.dashboard', absolute: false)); $this->assertAuthenticated(); }); @@ -66,4 +66,4 @@ $response->assertRedirect(route('home')); $this->assertGuest(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/CustomerPasswordResetTest.php b/tests/Feature/Auth/CustomerPasswordResetTest.php new file mode 100644 index 00000000..822cf86e --- /dev/null +++ b/tests/Feature/Auth/CustomerPasswordResetTest.php @@ -0,0 +1,266 @@ +withoutVite(); + Cache::flush(); +}); + +test('customer password reset pages render for the resolved storefront store', function (): void { + $store = Store::factory()->create(); + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'customer-reset.test', + ]); + + expect(route('customer.password.request', absolute: false))->toBe('/forgot-password') + ->and(route('customer.password.reset', ['token' => 'test-token'], false))->toBe('/reset-password/test-token'); + + $this->get('http://customer-reset.test/forgot-password') + ->assertSuccessful() + ->assertSee('Reset password') + ->assertSee('Send reset link'); + + $this->get('http://customer-reset.test/reset-password/test-token?email=customer@example.test') + ->assertSuccessful() + ->assertSee('Set a new password') + ->assertSee('Reset password'); + + $this->get('http://customer-reset.test/account/forgot-password') + ->assertSuccessful() + ->assertSee('Reset password'); + + $this->get('http://customer-reset.test/account/reset-password/test-token?email=customer@example.test') + ->assertSuccessful() + ->assertSee('Set a new password'); +}); + +test('customer reset links are sent generically and scoped to the current store', function (): void { + Notification::fake(); + + $store = Store::factory()->create(); + $otherStore = Store::factory()->create(); + + $customer = Customer::factory()->create([ + 'store_id' => $store->getKey(), + 'email' => 'customer@example.test', + ]); + + $otherCustomer = Customer::factory()->create([ + 'store_id' => $otherStore->getKey(), + 'email' => 'customer@example.test', + ]); + + app()->instance('current_store', $store); + + Livewire::test(CustomerForgotPassword::class) + ->set('email', 'CUSTOMER@example.test') + ->call('send') + ->assertHasNoErrors() + ->assertSee('If an account matches that email, a reset link has been sent.'); + + Notification::assertSentTo( + $customer, + CustomerResetPasswordNotification::class, + fn (CustomerResetPasswordNotification $notification): bool => strlen($notification->token) === 64 + && $notification->store->is($store) + && str_contains($notification->toMail($customer)->actionUrl, '/reset-password/'.$notification->token) + && ! str_contains($notification->toMail($customer)->actionUrl, '/account/reset-password/'), + ); + + Notification::assertNotSentTo($otherCustomer, CustomerResetPasswordNotification::class); + + $this->assertDatabaseHas('customer_password_reset_tokens', [ + 'store_id' => $store->getKey(), + 'email' => $customer->email, + ]); + + $this->assertDatabaseMissing('customer_password_reset_tokens', [ + 'store_id' => $otherStore->getKey(), + 'email' => $otherCustomer->email, + ]); +}); + +test('customer password reset root post routes send and reset passwords', function (): void { + Notification::fake(); + + $store = Store::factory()->create(); + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'customer-reset.test', + ]); + + $customer = Customer::factory()->create([ + 'store_id' => $store->getKey(), + 'email' => 'customer@example.test', + 'password' => 'old-password', + ]); + + $this->post('http://customer-reset.test/forgot-password', [ + 'email' => 'CUSTOMER@example.test', + ]) + ->assertSessionHasNoErrors() + ->assertRedirect(); + + $token = null; + + Notification::assertSentTo( + $customer, + CustomerResetPasswordNotification::class, + function (CustomerResetPasswordNotification $notification) use (&$token): bool { + $token = $notification->token; + + return true; + }, + ); + + $this->post('http://customer-reset.test/reset-password', [ + 'token' => $token, + 'email' => $customer->email, + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]) + ->assertSessionHasNoErrors() + ->assertRedirect(); + + expect(Hash::check('new-password', $customer->refresh()->password_hash))->toBeTrue(); + + $this->assertDatabaseMissing('customer_password_reset_tokens', [ + 'store_id' => $store->getKey(), + 'email' => $customer->email, + ]); +}); + +test('unknown customer reset requests keep the generic response', function (): void { + Notification::fake(); + + $store = Store::factory()->create(); + + app()->instance('current_store', $store); + + Livewire::test(CustomerForgotPassword::class) + ->set('email', 'missing@example.test') + ->call('send') + ->assertHasNoErrors() + ->assertSee('If an account matches that email, a reset link has been sent.'); + + Notification::assertNothingSent(); + + expect(DB::table('customer_password_reset_tokens')->count())->toBe(0); +}); + +test('customer password reset updates the password and deletes the token', function (): void { + Notification::fake(); + + $store = Store::factory()->create(); + $customer = Customer::factory()->create([ + 'store_id' => $store->getKey(), + 'email' => 'customer@example.test', + 'password' => 'old-password', + ]); + + app()->instance('current_store', $store); + + app(CustomerPasswordResetService::class)->sendResetLink($store, $customer->email); + + $token = null; + + Notification::assertSentTo( + $customer, + CustomerResetPasswordNotification::class, + function (CustomerResetPasswordNotification $notification) use (&$token): bool { + $token = $notification->token; + + return true; + }, + ); + + Livewire::test(CustomerResetPassword::class, ['token' => $token]) + ->set('email', $customer->email) + ->set('password', 'new-password') + ->set('password_confirmation', 'new-password') + ->call('resetPassword') + ->assertHasNoErrors() + ->assertRedirect(route('account.login', absolute: false)); + + expect(Hash::check('new-password', $customer->refresh()->password_hash))->toBeTrue(); + + $this->assertDatabaseMissing('customer_password_reset_tokens', [ + 'store_id' => $store->getKey(), + 'email' => $customer->email, + ]); + + app()->instance('current_store', $store); + + expect(Auth::guard('customer')->attempt([ + 'email' => $customer->email, + 'password' => 'new-password', + ]))->toBeTrue(); +}); + +test('customer reset tokens cannot be reused across stores or after expiry', function (): void { + $store = Store::factory()->create(); + $otherStore = Store::factory()->create(); + + $customer = Customer::factory()->create([ + 'store_id' => $store->getKey(), + 'email' => 'shared@example.test', + 'password' => 'old-password', + ]); + + $otherCustomer = Customer::factory()->create([ + 'store_id' => $otherStore->getKey(), + 'email' => 'shared@example.test', + 'password' => 'other-password', + ]); + + DB::table('customer_password_reset_tokens')->insert([ + 'store_id' => $store->getKey(), + 'email' => $customer->email, + 'token' => Hash::make('valid-token'), + 'created_at' => now(), + ]); + + app()->instance('current_store', $otherStore); + + Livewire::test(CustomerResetPassword::class, ['token' => 'valid-token']) + ->set('email', $otherCustomer->email) + ->set('password', 'new-password') + ->set('password_confirmation', 'new-password') + ->call('resetPassword') + ->assertHasErrors(['email']); + + expect(Hash::check('other-password', $otherCustomer->refresh()->password_hash))->toBeTrue(); + + DB::table('customer_password_reset_tokens') + ->where('store_id', $store->getKey()) + ->where('email', $customer->email) + ->update(['created_at' => now()->subMinutes(61)]); + + app()->instance('current_store', $store); + + Livewire::test(CustomerResetPassword::class, ['token' => 'valid-token']) + ->set('email', $customer->email) + ->set('password', 'new-password') + ->set('password_confirmation', 'new-password') + ->call('resetPassword') + ->assertHasErrors(['email']); + + expect(Hash::check('old-password', $customer->refresh()->password_hash))->toBeTrue(); +}); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php index 66f58e36..d4db2290 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -31,7 +31,7 @@ Event::assertDispatched(Verified::class); expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); - $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); + $response->assertRedirect(route('admin.dashboard', absolute: false).'?verified=1'); }); test('email is not verified with invalid hash', function () { @@ -62,8 +62,8 @@ ); $this->actingAs($user)->get($verificationUrl) - ->assertRedirect(route('dashboard', absolute: false).'?verified=1'); + ->assertRedirect(route('admin.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/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php index bea78251..415cc72d 100644 --- a/tests/Feature/Auth/PasswordResetTest.php +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -37,6 +37,33 @@ }); }); +test('admin password reset routes render and submit through admin paths', function () { + Notification::fake(); + + $user = User::factory()->create(); + + $this->get(route('admin.password.request')) + ->assertOk() + ->assertSee(route('admin.password.email', absolute: false), false); + + $this->post(route('admin.password.email'), ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { + $this->get(route('admin.password.reset', $notification->token).'?email='.$user->email) + ->assertOk() + ->assertSee(route('admin.password.update', absolute: false), false); + + $this->post(route('admin.password.update'), [ + 'token' => $notification->token, + 'email' => $user->email, + 'password' => 'password', + 'password_confirmation' => 'password', + ])->assertSessionHasNoErrors(); + + return true; + }); +}); + test('password can be reset with valid token', function () { Notification::fake(); @@ -58,4 +85,4 @@ return true; }); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index c22ea5e1..134d2696 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -17,7 +17,7 @@ ]); $response->assertSessionHasNoErrors() - ->assertRedirect(route('dashboard', absolute: false)); + ->assertRedirect(route('admin.dashboard', absolute: false)); $this->assertAuthenticated(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Cart/CartServiceTest.php b/tests/Feature/Cart/CartServiceTest.php new file mode 100644 index 00000000..e0190f32 --- /dev/null +++ b/tests/Feature/Cart/CartServiceTest.php @@ -0,0 +1,125 @@ +create(); + app()->instance('current_store', $store); + + return $store; +} + +function cartServiceVariant(Store $store, int $price = 2500, int $stock = 20, InventoryPolicy $policy = InventoryPolicy::Deny): ProductVariant +{ + $product = Product::factory() + ->withDefaultVariant($price) + ->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->firstOrFail(); + + InventoryItem::withoutGlobalScopes() + ->where('variant_id', $variant->getKey()) + ->update([ + 'quantity_on_hand' => $stock, + 'quantity_reserved' => 0, + 'policy' => $policy, + ]); + + return $variant->refresh(); +} + +test('cart service creates carts and mutates lines with version increments and price snapshots', function () { + $store = cartServiceStore(); + $variant = cartServiceVariant($store, price: 2500, stock: 20); + $service = app(CartService::class); + $cart = $service->create($store); + + expect($cart->store_id)->toBe($store->getKey()) + ->and($cart->currency)->toBe('EUR') + ->and($cart->cart_version)->toBe(1); + + $line = $service->addLine($cart, $variant->getKey(), 2, expectedVersion: 1); + + expect($line->quantity)->toBe(2) + ->and($line->unit_price_amount)->toBe(2500) + ->and($line->line_subtotal_amount)->toBe(5000) + ->and($cart->refresh()->cart_version)->toBe(2); + + $service->addLine($cart, $variant->getKey(), 1, expectedVersion: 2); + + expect(Cart::withoutGlobalScopes()->find($cart->getKey())?->lines()->withoutGlobalScopes()->count())->toBe(1) + ->and($line->refresh()->quantity)->toBe(3) + ->and($cart->refresh()->cart_version)->toBe(3); + + $variant->forceFill(['price_amount' => 9999])->save(); + $service->updateLineQuantity($cart, $line->getKey(), 4, expectedVersion: 3); + + expect($line->refresh()->unit_price_amount)->toBe(2500) + ->and($line->line_subtotal_amount)->toBe(10000) + ->and($cart->refresh()->cart_version)->toBe(4); + + $service->updateLineQuantity($cart, $line->getKey(), 0, expectedVersion: 4); + + expect($cart->refresh()->cart_version)->toBe(5) + ->and($cart->lines()->withoutGlobalScopes()->count())->toBe(0); +}); + +test('cart service rejects stale versions inactive products and insufficient stock', function () { + $store = cartServiceStore(); + $variant = cartServiceVariant($store, stock: 2); + $service = app(CartService::class); + $cart = $service->create($store); + + expect(fn () => $service->addLine($cart, $variant->getKey(), 1, expectedVersion: 99)) + ->toThrow(CartVersionMismatchException::class); + + $draftProduct = Product::factory() + ->draft() + ->withDefaultVariant() + ->create(['store_id' => $store->getKey()]); + $draftVariant = ProductVariant::withoutGlobalScopes() + ->where('product_id', $draftProduct->getKey()) + ->firstOrFail(); + + expect(fn () => $service->addLine($cart, $draftVariant->getKey(), 1)) + ->toThrow(InvalidCartOperationException::class); + + expect(fn () => $service->addLine($cart, $variant->getKey(), 5)) + ->toThrow(InsufficientInventoryException::class); +}); + +test('cart service allows oversell for continue policy and merges guest carts into customer carts', function () { + $store = cartServiceStore(); + $firstVariant = cartServiceVariant($store, stock: 0, policy: InventoryPolicy::Continue); + $secondVariant = cartServiceVariant($store, stock: 10); + $service = app(CartService::class); + $guestCart = $service->create($store); + $customer = Customer::factory()->create(['store_id' => $store->getKey()]); + $customerCart = $service->create($store, $customer); + + $service->addLine($guestCart, $firstVariant->getKey(), 5); + $service->addLine($customerCart, $firstVariant->getKey(), 2); + $service->addLine($customerCart, $secondVariant->getKey(), 3); + + $merged = $service->mergeOnLogin($guestCart, $customerCart); + + expect($merged->lines()->withoutGlobalScopes()->where('variant_id', $firstVariant->getKey())->first()?->quantity)->toBe(5) + ->and($merged->lines()->withoutGlobalScopes()->where('variant_id', $secondVariant->getKey())->first()?->quantity)->toBe(3) + ->and($guestCart->refresh()->status)->toBe(CartStatus::Abandoned); +}); diff --git a/tests/Feature/Catalog/CatalogFoundationTest.php b/tests/Feature/Catalog/CatalogFoundationTest.php new file mode 100644 index 00000000..76d20988 --- /dev/null +++ b/tests/Feature/Catalog/CatalogFoundationTest.php @@ -0,0 +1,115 @@ +seed(DatabaseSeeder::class); + + expect(Schema::hasColumns('products', ['store_id', 'title', 'handle', 'status', 'tags']))->toBeTrue() + ->and(Schema::hasColumns('product_variants', ['product_id', 'sku', 'price_amount', 'requires_shipping']))->toBeTrue() + ->and(Schema::hasColumns('inventory_items', ['store_id', 'variant_id', 'quantity_on_hand', 'quantity_reserved', 'policy']))->toBeTrue() + ->and(Schema::hasColumns('collections', ['store_id', 'title', 'handle', 'type', 'status']))->toBeTrue() + ->and(Store::query()->count())->toBe(2) + ->and(Product::withoutGlobalScopes()->count())->toBe(25) + ->and(ProductVariant::withoutGlobalScopes()->count())->toBe(127) + ->and(InventoryItem::withoutGlobalScopes()->count())->toBe(127) + ->and(ProductMedia::withoutGlobalScopes()->count())->toBe(0) + ->and(Collection::withoutGlobalScopes()->count())->toBe(6) + ->and(Product::withoutGlobalScopes()->where('handle', 'classic-cotton-t-shirt')->first()?->variants()->withoutGlobalScopes()->count())->toBe(12) + ->and(Product::withoutGlobalScopes()->where('handle', 'pro-laptop-15')->first()?->variants()->withoutGlobalScopes()->count())->toBe(3) + ->and(Product::withoutGlobalScopes()->where('handle', 'limited-edition-sneakers')->first()?->variants()->withoutGlobalScopes()->first()?->inventoryItem?->policy?->value)->toBe('deny') + ->and(Product::withoutGlobalScopes()->where('handle', 'backorder-denim-jacket')->first()?->variants()->withoutGlobalScopes()->first()?->inventoryItem?->policy?->value)->toBe('continue'); +}); + +test('catalog models are scoped to the resolved store', function () { + $firstStore = Store::factory()->create(); + $secondStore = Store::factory()->create(); + + Product::factory()->create(['store_id' => $firstStore->getKey(), 'handle' => 'first-store-product']); + Product::factory()->create(['store_id' => $secondStore->getKey(), 'handle' => 'second-store-product']); + + app()->instance('current_store', $firstStore); + + expect(Product::query()->pluck('handle')->all())->toBe(['first-store-product']); +}); + +test('catalog child models are scoped through their product store', function () { + $firstStore = Store::factory()->create(); + $secondStore = Store::factory()->create(); + $firstProduct = Product::factory()->create(['store_id' => $firstStore->getKey()]); + $secondProduct = Product::factory()->create(['store_id' => $secondStore->getKey()]); + + $firstOption = ProductOption::factory()->create(['product_id' => $firstProduct->getKey(), 'position' => 0]); + $secondOption = ProductOption::factory()->create(['product_id' => $secondProduct->getKey(), 'position' => 0]); + ProductOptionValue::factory()->create(['product_option_id' => $firstOption->getKey(), 'position' => 0]); + ProductOptionValue::factory()->create(['product_option_id' => $secondOption->getKey(), 'position' => 0]); + ProductVariant::factory()->create(['product_id' => $firstProduct->getKey(), 'sku' => 'FIRST']); + ProductVariant::factory()->create(['product_id' => $secondProduct->getKey(), 'sku' => 'SECOND']); + ProductMedia::factory()->create(['product_id' => $firstProduct->getKey()]); + ProductMedia::factory()->create(['product_id' => $secondProduct->getKey()]); + + app()->instance('current_store', $firstStore); + + expect(ProductOption::query()->pluck('product_id')->all())->toBe([$firstProduct->getKey()]) + ->and(ProductOptionValue::query()->count())->toBe(1) + ->and(ProductVariant::query()->pluck('sku')->all())->toBe(['FIRST']) + ->and(ProductMedia::query()->pluck('product_id')->all())->toBe([$firstProduct->getKey()]); +}); + +test('variants create inventory for their product store regardless of resolved store scope', function () { + $resolvedStore = Store::factory()->create(); + $productStore = Store::factory()->create(); + $product = Product::factory()->create(['store_id' => $productStore->getKey()]); + + app()->instance('current_store', $resolvedStore); + + $variant = ProductVariant::factory()->create(['product_id' => $product->getKey()]); + $inventory = InventoryItem::withoutGlobalScopes() + ->where('variant_id', $variant->getKey()) + ->first(); + + expect($inventory)->not->toBeNull() + ->and($inventory->store_id)->toBe($productStore->getKey()) + ->and($inventory->policy)->toBe(InventoryPolicy::Deny); +}); + +test('inventory store must match the variant product store', function () { + $variantStore = Store::factory()->create(); + $otherStore = Store::factory()->create(); + $product = Product::factory()->create(['store_id' => $variantStore->getKey()]); + $variant = ProductVariant::factory()->create(['product_id' => $product->getKey()]); + + expect(fn () => InventoryItem::withoutGlobalScopes()->create([ + 'store_id' => $otherStore->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => 1, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ]))->toThrow(InvalidArgumentException::class); +}); + +test('variant sku uniqueness is enforced for direct model saves per store', function () { + $store = Store::factory()->create(); + $product = Product::factory()->create(['store_id' => $store->getKey()]); + $secondProduct = Product::factory()->create(['store_id' => $store->getKey()]); + + ProductVariant::factory()->create(['product_id' => $product->getKey(), 'sku' => 'STORE-SKU']); + + expect(fn () => ProductVariant::factory()->create([ + 'product_id' => $secondProduct->getKey(), + 'sku' => 'STORE-SKU', + ]))->toThrow(RuntimeException::class); +}); diff --git a/tests/Feature/Catalog/CatalogUiTest.php b/tests/Feature/Catalog/CatalogUiTest.php new file mode 100644 index 00000000..63df4350 --- /dev/null +++ b/tests/Feature/Catalog/CatalogUiTest.php @@ -0,0 +1,245 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +test('storefront catalog browsing routes render seeded products', function () { + $this->withHeader('Host', 'shop.test') + ->get('/') + ->assertSuccessful() + ->assertSee('Acme Fashion') + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('24.99 EUR'); + + $this->withHeader('Host', 'shop.test') + ->get('/collections/t-shirts') + ->assertSuccessful() + ->assertSee('T-Shirts') + ->assertSee('Classic Cotton T-Shirt') + ->assertDontSee('Unreleased Winter Jacket'); + + $this->withHeader('Host', 'shop.test') + ->get('/products/classic-cotton-t-shirt') + ->assertSuccessful() + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('Size') + ->assertSee('Color') + ->assertSee('Add to cart'); + + $this->withHeader('Host', 'shop.test') + ->get('/search?q=draft') + ->assertSuccessful() + ->assertDontSee('Unreleased Winter Jacket'); +}); + +test('admin catalog routes require authentication and render for store admins', function () { + $this->get('/admin/products')->assertRedirect('/admin/login'); + + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $user = User::query()->where('email', 'admin@acme.test')->firstOrFail(); + + $this->actingAs($user) + ->withSession(['current_store_id' => $store->getKey()]) + ->get('/admin/products') + ->assertSuccessful() + ->assertSee('Products') + ->assertSee('Add product'); + + $this->actingAs($user) + ->withSession(['current_store_id' => $store->getKey()]) + ->get('/admin/collections') + ->assertSuccessful() + ->assertSee('Collections') + ->assertSee('T-Shirts'); + + $this->actingAs($user) + ->withSession(['current_store_id' => $store->getKey()]) + ->get('/admin/inventory') + ->assertSuccessful() + ->assertSee('Inventory') + ->assertSee('Limited Edition Sneakers'); +}); + +test('admin product form creates edits archives and index filters products', function () { + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $user = User::query()->where('email', 'admin@acme.test')->firstOrFail(); + app()->instance('current_store', $store); + + Livewire::actingAs($user) + ->test(ProductForm::class) + ->set('title', 'Test Product Created by E2E') + ->set('handle', 'test-product-created-by-e2e') + ->set('descriptionHtml', 'This product was created by the E2E test suite.') + ->set('vendor', 'Test Vendor') + ->set('productType', 'T-Shirts') + ->set('variants.0.price', '29.99') + ->set('variants.0.sku', 'E2E-TEST-001') + ->set('variants.0.quantity', 50) + ->call('save') + ->assertSee('Product saved'); + + $product = Product::query()->where('handle', 'test-product-created-by-e2e')->firstOrFail(); + + expect($product->variants()->first()?->price_amount)->toBe(2999) + ->and($product->variants()->first()?->inventoryItem?->quantity_on_hand)->toBe(50); + + Livewire::actingAs($user) + ->test(ProductForm::class, ['product' => $product]) + ->set('title', 'Test Product Updated') + ->call('save') + ->assertSee('Product saved'); + + Livewire::actingAs($user) + ->test(ProductForm::class, ['product' => $product->refresh()]) + ->set('variants.0.sku', 'ACME-CTSH-S-WHT') + ->call('save') + ->assertHasErrors(['variants.0.sku']); + + expect($product->refresh()->variants()->first()?->sku)->toBe('E2E-TEST-001'); + + $draftProduct = app(ProductService::class)->create($store, [ + 'title' => 'Draft Product With Price Later', + 'handle' => 'draft-product-with-price-later', + 'status' => 'draft', + 'variants' => [[ + 'sku' => 'DRAFT-PRICE-LATER-001', + 'price_amount' => 0, + 'currency' => $store->default_currency, + 'quantity_on_hand' => 4, + 'is_default' => true, + 'position' => 0, + ]], + ]); + + Livewire::actingAs($user) + ->test(ProductForm::class, ['product' => $draftProduct]) + ->set('variants.0.price', '17.50') + ->set('status', 'active') + ->call('save') + ->assertSee('Product saved'); + + expect($draftProduct->refresh()->status->value)->toBe('active') + ->and($draftProduct->variants()->first()?->price_amount)->toBe(1750); + + Livewire::actingAs($user) + ->test(ProductForm::class, ['product' => $product->refresh()]) + ->set('status', 'archived') + ->call('save') + ->assertSee('Product saved'); + + expect($product->refresh()->status->value)->toBe('archived'); + + Livewire::actingAs($user) + ->test(ProductIndex::class) + ->set('statusFilter', 'draft') + ->assertSee('Unreleased Winter Jacket') + ->assertDontSee('Classic Cotton T-Shirt') + ->set('statusFilter', 'active') + ->set('search', 'Cotton') + ->assertSee('Classic Cotton T-Shirt'); +}); + +test('admin product form generates and syncs option variant matrix', function () { + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $user = User::query()->where('email', 'admin@acme.test')->firstOrFail(); + app()->instance('current_store', $store); + + $component = Livewire::actingAs($user) + ->test(ProductForm::class) + ->set('title', 'Matrix Product') + ->set('handle', 'matrix-product') + ->set('options', [ + ['name' => 'Size', 'values' => 'S, M'], + ['name' => 'Color', 'values' => 'Black, White'], + ]) + ->call('generateVariants'); + + $generatedVariants = $component->get('variants'); + + expect($generatedVariants)->toHaveCount(4) + ->and($generatedVariants[0]['label'])->toBe('S / Black') + ->and($generatedVariants[3]['label'])->toBe('M / White'); + + foreach (range(0, 3) as $index) { + $component + ->set("variants.{$index}.sku", 'MATRIX-'.$index) + ->set("variants.{$index}.price", '19.99') + ->set("variants.{$index}.quantity", 10 + $index); + } + + $component + ->call('save') + ->assertHasNoErrors() + ->assertSee('Product saved'); + + $product = Product::query()->where('handle', 'matrix-product')->firstOrFail(); + $labels = matrixVariantLabels($product); + + expect($product->variants)->toHaveCount(4) + ->and($labels)->toBe(['M / Black', 'M / White', 'S / Black', 'S / White']); + + $editComponent = Livewire::actingAs($user) + ->test(ProductForm::class, ['product' => $product->refresh()]) + ->set('options.1.values', 'Black, Navy') + ->call('generateVariants'); + + expect(collect($editComponent->get('variants'))->pluck('label')->sort()->values()->all()) + ->toBe(['M / Black', 'M / Navy', 'S / Black', 'S / Navy']); + + $editComponent + ->call('save') + ->assertHasNoErrors() + ->assertSee('Product saved'); + + expect(matrixVariantLabels($product->refresh()))->toBe(['M / Black', 'M / Navy', 'S / Black', 'S / Navy']); +}); + +function matrixVariantLabels(Product $product): array +{ + return $product->variants() + ->with(['optionValues.option']) + ->get() + ->map(fn ($variant): string => $variant->optionValues + ->sortBy(fn ($value): int => $value->option->position) + ->pluck('value') + ->implode(' / ')) + ->sort() + ->values() + ->all(); +} + +test('admin collection form creates and assigns products', function () { + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $user = User::query()->where('email', 'admin@acme.test')->firstOrFail(); + $product = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail(); + app()->instance('current_store', $store); + + Livewire::actingAs($user) + ->test(CollectionForm::class) + ->set('title', 'E2E Test Collection') + ->set('handle', 'e2e-test-collection') + ->set('descriptionHtml', '

Created from tests.

') + ->call('addProduct', $product->getKey()) + ->call('save') + ->assertSee('Collection saved'); + + $collection = Collection::query()->where('handle', 'e2e-test-collection')->firstOrFail(); + + expect($collection->products()->pluck('products.id')->all())->toBe([$product->getKey()]); +}); diff --git a/tests/Feature/Catalog/InventoryServiceTest.php b/tests/Feature/Catalog/InventoryServiceTest.php new file mode 100644 index 00000000..fe9df009 --- /dev/null +++ b/tests/Feature/Catalog/InventoryServiceTest.php @@ -0,0 +1,57 @@ +create(); + app()->instance('current_store', $store); + + $product = Product::factory()->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::factory()->create(['product_id' => $product->getKey()]); + $item = $variant->inventoryItem()->firstOrFail(); + $item->forceFill(['quantity_on_hand' => 10, 'quantity_reserved' => 0, 'policy' => 'deny'])->save(); + + $service = app(InventoryService::class); + + $service->reserve($item, 4); + expect($item->refresh()->quantity_reserved)->toBe(4); + + $service->release($item, 1); + expect($item->refresh()->quantity_reserved)->toBe(3); + + $service->commit($item, 2); + expect($item->refresh()->quantity_on_hand)->toBe(8) + ->and($item->quantity_reserved)->toBe(1); + + $service->restock($item, 5); + expect($item->refresh()->quantity_on_hand)->toBe(13); +}); + +test('deny policy blocks reservations above available stock while continue allows it', function () { + $store = Store::factory()->create(); + app()->instance('current_store', $store); + + $denyProduct = Product::factory()->create(['store_id' => $store->getKey()]); + $denyVariant = ProductVariant::factory()->create(['product_id' => $denyProduct->getKey()]); + $denyItem = $denyVariant->inventoryItem()->firstOrFail(); + $denyItem->forceFill(['quantity_on_hand' => 1, 'quantity_reserved' => 0, 'policy' => 'deny'])->save(); + + expect(fn () => app(InventoryService::class)->reserve($denyItem, 2)) + ->toThrow(InsufficientInventoryException::class); + + $continueProduct = Product::factory()->create(['store_id' => $store->getKey()]); + $continueVariant = ProductVariant::factory()->create(['product_id' => $continueProduct->getKey()]); + $continueItem = $continueVariant->inventoryItem()->firstOrFail(); + $continueItem->forceFill(['quantity_on_hand' => 0, 'quantity_reserved' => 0, 'policy' => 'continue'])->save(); + + app(InventoryService::class)->reserve($continueItem, 2); + + expect($continueItem->refresh()->quantity_reserved)->toBe(2); +}); diff --git a/tests/Feature/Catalog/MediaProcessingTest.php b/tests/Feature/Catalog/MediaProcessingTest.php new file mode 100644 index 00000000..3536cd08 --- /dev/null +++ b/tests/Feature/Catalog/MediaProcessingTest.php @@ -0,0 +1,87 @@ +create(); + $product = Product::factory()->create(['store_id' => $store->getKey()]); + $media = ProductMedia::factory()->processing()->create([ + 'product_id' => $product->getKey(), + 'storage_key' => 'media/originals/test.png', + 'width' => null, + 'height' => null, + 'mime_type' => null, + 'byte_size' => null, + ]); + + Storage::disk('public')->put($media->storage_key, catalogPngImageContents()); + + (new ProcessMediaUpload($media->getKey(), $store->getKey()))->handle(); + + $media->refresh(); + + expect($media->status)->toBe(MediaStatus::Ready) + ->and($media->width)->toBe(20) + ->and($media->height)->toBe(10) + ->and($media->mime_type)->toBe('image/png') + ->and($media->byte_size)->toBeGreaterThan(0); + + foreach (['thumbnail', 'small', 'medium', 'large'] as $size) { + Storage::disk('public')->assertExists("media/{$product->getKey()}/{$media->getKey()}/{$size}.png"); + + if (function_exists('imagewebp')) { + Storage::disk('public')->assertExists("media/{$product->getKey()}/{$media->getKey()}/{$size}.webp"); + } + } + + $media->delete(); + + Storage::disk('public')->assertMissing('media/originals/test.png'); + Storage::disk('public')->assertMissing("media/{$product->getKey()}/{$media->getKey()}/thumbnail.png"); +}); + +test('media processing marks invalid image uploads as failed', function () { + Storage::fake('public'); + + $store = Store::factory()->create(); + $product = Product::factory()->create(['store_id' => $store->getKey()]); + $media = ProductMedia::factory()->processing()->create([ + 'product_id' => $product->getKey(), + 'storage_key' => 'media/originals/not-image.txt', + ]); + + Storage::disk('public')->put($media->storage_key, 'not an image'); + + expect(fn () => (new ProcessMediaUpload($media->getKey(), $store->getKey()))->handle()) + ->toThrow(RuntimeException::class); + + expect($media->refresh()->status)->toBe(MediaStatus::Failed); +}); + +function catalogPngImageContents(): string +{ + $image = imagecreatetruecolor(20, 10); + imagefill($image, 0, 0, imagecolorallocate($image, 20, 80, 140)); + + ob_start(); + imagepng($image); + $contents = ob_get_clean(); + + imagedestroy($image); + + if ($contents === false) { + throw new RuntimeException('Unable to create test image.'); + } + + return $contents; +} diff --git a/tests/Feature/Catalog/ProductServiceTest.php b/tests/Feature/Catalog/ProductServiceTest.php new file mode 100644 index 00000000..dfeb1c48 --- /dev/null +++ b/tests/Feature/Catalog/ProductServiceTest.php @@ -0,0 +1,159 @@ +create(); + + Product::factory()->create([ + 'store_id' => $store->getKey(), + 'title' => 'Classic Cotton T-Shirt', + 'handle' => 'classic-cotton-t-shirt', + ]); + + $handle = app(HandleGenerator::class)->generate('Classic Cotton T-Shirt', 'products', $store->getKey()); + + expect($handle)->toBe('classic-cotton-t-shirt-1'); +}); + +test('product service creates products with a default variant and inventory', function () { + $store = Store::factory()->create(['default_currency' => 'EUR']); + app()->instance('current_store', $store); + + $product = app(ProductService::class)->create($store, [ + 'title' => 'Merino Crew', + 'price_amount' => 4999, + 'tags' => ['new'], + ]); + + expect($product->handle)->toBe('merino-crew') + ->and($product->variants)->toHaveCount(1) + ->and($product->variants->first()->price_amount)->toBe(4999) + ->and($product->variants->first()->inventoryItem)->not->toBeNull(); +}); + +test('product service routes requested active status through lifecycle checks', function () { + $store = Store::factory()->create(['default_currency' => 'EUR']); + app()->instance('current_store', $store); + + expect(fn () => app(ProductService::class)->create($store, [ + 'title' => 'Unpriced Product', + 'status' => ProductStatus::Active, + 'price_amount' => 0, + ]))->toThrow(InvalidProductTransitionException::class); + + $product = app(ProductService::class)->create($store, [ + 'title' => 'Priced Product', + 'status' => ProductStatus::Active, + 'price_amount' => 1000, + ]); + + expect($product->status)->toBe(ProductStatus::Active) + ->and($product->published_at)->not->toBeNull(); +}); + +test('product service syncs variant option selections', function () { + $store = Store::factory()->create(['default_currency' => 'EUR']); + app()->instance('current_store', $store); + + $product = app(ProductService::class)->create($store, [ + 'title' => 'Optioned Product', + 'options' => [ + [ + 'name' => 'Size', + 'position' => 0, + 'values' => [ + ['value' => 'S', 'position' => 0], + ['value' => 'M', 'position' => 1], + ], + ], + [ + 'name' => 'Color', + 'position' => 1, + 'values' => [ + ['value' => 'Black', 'position' => 0], + ['value' => 'White', 'position' => 1], + ], + ], + ], + 'variants' => [ + [ + 'sku' => 'OPT-S-BLK', + 'price_amount' => 1500, + 'status' => VariantStatus::Active, + 'options' => ['Size' => 'S', 'Color' => 'Black'], + ], + ], + ]); + + $variant = $product->variants()->firstOrFail(); + $values = $variant->optionValues() + ->with('option') + ->get() + ->mapWithKeys(fn (ProductOptionValue $value): array => [$value->option->name => $value->value]) + ->all(); + + expect($values)->toBe([ + 'Size' => 'S', + 'Color' => 'Black', + ]); +}); + +test('product status transitions enforce activation preconditions and dispatch events', function () { + Event::fake([ProductStatusChanged::class]); + + $store = Store::factory()->create(); + app()->instance('current_store', $store); + + $product = Product::factory()->draft()->create([ + 'store_id' => $store->getKey(), + 'title' => 'Draft Product', + ]); + + expect(fn () => app(ProductService::class)->transitionStatus($product, ProductStatus::Active)) + ->toThrow(InvalidProductTransitionException::class); + + ProductVariant::factory()->default()->create([ + 'product_id' => $product->getKey(), + 'price_amount' => 1200, + ]); + + app(ProductService::class)->transitionStatus($product->refresh(), ProductStatus::Active); + + expect($product->refresh()->status)->toBe(ProductStatus::Active) + ->and($product->published_at)->not->toBeNull(); + + Event::assertDispatched(ProductStatusChanged::class); +}); + +test('duplicate non-empty skus are rejected within the same store', function () { + $store = Store::factory()->create(); + app()->instance('current_store', $store); + + app(ProductService::class)->create($store, [ + 'title' => 'First Product', + 'variants' => [ + ['sku' => 'DUP-1', 'price_amount' => 1000], + ], + ]); + + expect(fn () => app(ProductService::class)->create($store, [ + 'title' => 'Second Product', + 'variants' => [ + ['sku' => 'DUP-1', 'price_amount' => 1200], + ], + ]))->toThrow(RuntimeException::class); +}); diff --git a/tests/Feature/Catalog/VariantMatrixServiceTest.php b/tests/Feature/Catalog/VariantMatrixServiceTest.php new file mode 100644 index 00000000..259ce6e3 --- /dev/null +++ b/tests/Feature/Catalog/VariantMatrixServiceTest.php @@ -0,0 +1,66 @@ +create(); + app()->instance('current_store', $store); + + $product = Product::factory()->create(['store_id' => $store->getKey()]); + + $size = ProductOption::factory()->create(['product_id' => $product->getKey(), 'name' => 'Size', 'position' => 0]); + $small = ProductOptionValue::factory()->create(['product_option_id' => $size->getKey(), 'value' => 'S', 'position' => 0]); + $medium = ProductOptionValue::factory()->create(['product_option_id' => $size->getKey(), 'value' => 'M', 'position' => 1]); + + $color = ProductOption::factory()->create(['product_id' => $product->getKey(), 'name' => 'Color', 'position' => 1]); + $black = ProductOptionValue::factory()->create(['product_option_id' => $color->getKey(), 'value' => 'Black', 'position' => 0]); + $white = ProductOptionValue::factory()->create(['product_option_id' => $color->getKey(), 'value' => 'White', 'position' => 1]); + + $variant = ProductVariant::factory()->default()->create([ + 'product_id' => $product->getKey(), + 'price_amount' => 2500, + ]); + $variant->optionValues()->sync([$small->getKey(), $black->getKey()]); + + app(VariantMatrixService::class)->rebuildMatrix($product); + + expect($product->variants()->count())->toBe(4) + ->and($product->variants()->whereHas('optionValues', fn ($query) => $query->whereKey($medium->getKey()))->count())->toBe(2) + ->and($product->variants()->whereHas('optionValues', fn ($query) => $query->whereKey($white->getKey()))->count())->toBe(2) + ->and($product->variants()->whereHas('inventoryItem')->count())->toBe(4); +}); + +test('variant matrix rebuild creates a default variant when there are no options', function () { + $store = Store::factory()->create(); + app()->instance('current_store', $store); + + $product = Product::factory()->create(['store_id' => $store->getKey()]); + $product->variants()->delete(); + + app(VariantMatrixService::class)->rebuildMatrix($product); + + expect($product->variants()->count())->toBe(1) + ->and($product->variants()->first()->is_default)->toBeTrue(); +}); + +test('variant matrix rebuild collapses to one default variant after options are removed', function () { + $store = Store::factory()->create(); + app()->instance('current_store', $store); + + $product = Product::factory()->create(['store_id' => $store->getKey()]); + ProductVariant::factory()->count(3)->for($product)->create(); + + app(VariantMatrixService::class)->rebuildMatrix($product); + + expect($product->variants()->count())->toBe(1) + ->and($product->variants()->first()->is_default)->toBeTrue() + ->and($product->variants()->first()->inventoryItem)->not->toBeNull(); +}); diff --git a/tests/Feature/Checkout/CheckoutServiceTest.php b/tests/Feature/Checkout/CheckoutServiceTest.php new file mode 100644 index 00000000..8cd60764 --- /dev/null +++ b/tests/Feature/Checkout/CheckoutServiceTest.php @@ -0,0 +1,220 @@ +create(); + app()->instance('current_store', $store); + + TaxSettings::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + 'mode' => 'manual', + 'provider' => 'none', + 'prices_include_tax' => false, + 'config_json' => ['default_rate_bps' => 0, 'shipping_taxable' => false], + ]); + + return $store; +} + +function checkoutVariant(Store $store, bool $requiresShipping = true): ProductVariant +{ + $product = Product::factory() + ->withDefaultVariant(2500) + ->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->firstOrFail(); + + $variant->forceFill([ + 'requires_shipping' => $requiresShipping, + 'weight_g' => $requiresShipping ? 500 : 0, + ])->save(); + InventoryItem::withoutGlobalScopes() + ->where('variant_id', $variant->getKey()) + ->update([ + 'quantity_on_hand' => 10, + 'quantity_reserved' => 0, + ]); + + return $variant->refresh(); +} + +function checkoutAddress(string $country = 'DE'): array +{ + return [ + 'email' => 'buyer@example.test', + 'shipping_address' => [ + 'first_name' => 'Test', + 'last_name' => 'Buyer', + 'address1' => 'Main Street 1', + 'city' => 'Berlin', + 'country' => $country, + 'postal_code' => '10115', + ], + ]; +} + +test('checkout service transitions through address shipping payment and expiry', function () { + $store = checkoutStore(); + $variant = checkoutVariant($store); + $zone = ShippingZone::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + 'name' => 'Germany', + 'countries_json' => ['DE'], + 'regions_json' => [], + ]); + $rate = ShippingRate::withoutGlobalScopes()->create([ + 'zone_id' => $zone->getKey(), + 'name' => 'Standard', + 'type' => 'flat', + 'config_json' => ['amount' => 499], + 'is_active' => true, + ]); + $cart = app(CartService::class)->create($store); + app(CartService::class)->addLine($cart, $variant->getKey(), 2); + $checkout = app(CheckoutService::class)->createFromCart($cart); + + $checkout = app(CheckoutService::class)->setAddress($checkout, checkoutAddress()); + expect($checkout->status)->toBe(CheckoutStatus::Addressed) + ->and($checkout->email)->toBe('buyer@example.test'); + + $checkout = app(CheckoutService::class)->setShippingMethod($checkout, $rate->getKey()); + expect($checkout->status)->toBe(CheckoutStatus::ShippingSelected) + ->and($checkout->shipping_method_id)->toBe($rate->getKey()); + + $checkout = app(CheckoutService::class)->selectPaymentMethod($checkout, 'credit_card'); + expect($checkout->status)->toBe(CheckoutStatus::PaymentSelected) + ->and($checkout->expires_at)->not->toBeNull() + ->and(InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->first()?->quantity_reserved)->toBe(2); + + app(CheckoutService::class)->selectPaymentMethod($checkout, 'credit_card'); + expect(InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->first()?->quantity_reserved)->toBe(2); + + $expired = app(CheckoutService::class)->expireCheckout($checkout); + expect($expired->status)->toBe(CheckoutStatus::Expired) + ->and(InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->first()?->quantity_reserved)->toBe(0); +}); + +test('checkout service reuses the active checkout for a cart', function () { + $store = checkoutStore(); + $variant = checkoutVariant($store); + $cart = app(CartService::class)->create($store); + app(CartService::class)->addLine($cart, $variant->getKey(), 1); + + $firstCheckout = app(CheckoutService::class)->createFromCart($cart); + $secondCheckout = app(CheckoutService::class)->createFromCart($cart); + + expect($secondCheckout->getKey())->toBe($firstCheckout->getKey()) + ->and(\App\Models\Checkout::withoutGlobalScopes()->where('cart_id', $cart->getKey())->count())->toBe(1); +}); + +test('checkout service rejects unserviceable shipping addresses for physical carts', function () { + $store = checkoutStore(); + $variant = checkoutVariant($store); + ShippingZone::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + 'name' => 'Germany', + 'countries_json' => ['DE'], + 'regions_json' => [], + ]); + $cart = app(CartService::class)->create($store); + app(CartService::class)->addLine($cart, $variant->getKey(), 1); + $checkout = app(CheckoutService::class)->createFromCart($cart); + $checkout = app(CheckoutService::class)->setAddress($checkout, checkoutAddress('FR')); + + expect(fn () => app(CheckoutService::class)->setShippingMethod($checkout, null)) + ->toThrow(UnserviceableShippingAddressException::class); +}); + +test('checkout service skips shipping for digital-only carts', function () { + $store = checkoutStore(); + $variant = checkoutVariant($store, requiresShipping: false); + $cart = app(CartService::class)->create($store); + app(CartService::class)->addLine($cart, $variant->getKey(), 1); + $checkout = app(CheckoutService::class)->createFromCart($cart); + $checkout = app(CheckoutService::class)->setAddress($checkout, checkoutAddress()); + $checkout = app(CheckoutService::class)->setShippingMethod($checkout, null); + + expect($checkout->status)->toBe(CheckoutStatus::ShippingSelected) + ->and($checkout->shipping_method_id)->toBeNull() + ->and($checkout->totals_json['shipping'])->toBe(0); +}); + +test('expire abandoned checkouts job expires stale checkouts and releases reservations', function () { + $store = checkoutStore(); + $variant = checkoutVariant($store); + $zone = ShippingZone::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + 'name' => 'Germany', + 'countries_json' => ['DE'], + 'regions_json' => [], + ]); + $rate = ShippingRate::withoutGlobalScopes()->create([ + 'zone_id' => $zone->getKey(), + 'name' => 'Standard', + 'type' => 'flat', + 'config_json' => ['amount' => 499], + 'is_active' => true, + ]); + $cart = app(CartService::class)->create($store); + app(CartService::class)->addLine($cart, $variant->getKey(), 2); + $checkout = app(CheckoutService::class)->createFromCart($cart); + $checkout = app(CheckoutService::class)->setAddress($checkout, checkoutAddress()); + $checkout = app(CheckoutService::class)->setShippingMethod($checkout, $rate->getKey()); + $checkout = app(CheckoutService::class)->selectPaymentMethod($checkout, 'credit_card'); + $checkout->forceFill(['expires_at' => now()->subMinute()])->save(); + + (new ExpireAbandonedCheckouts)->handle(app(CheckoutService::class)); + + expect($checkout->refresh()->status)->toBe(CheckoutStatus::Expired) + ->and(InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->first()?->quantity_reserved)->toBe(0); +}); + +test('cleanup abandoned carts job abandons old carts and expires related checkouts', function () { + $store = checkoutStore(); + $variant = checkoutVariant($store); + $zone = ShippingZone::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + 'name' => 'Germany', + 'countries_json' => ['DE'], + 'regions_json' => [], + ]); + $rate = ShippingRate::withoutGlobalScopes()->create([ + 'zone_id' => $zone->getKey(), + 'name' => 'Standard', + 'type' => 'flat', + 'config_json' => ['amount' => 499], + 'is_active' => true, + ]); + $cart = app(CartService::class)->create($store); + app(CartService::class)->addLine($cart, $variant->getKey(), 1); + $checkout = app(CheckoutService::class)->createFromCart($cart); + $checkout = app(CheckoutService::class)->setAddress($checkout, checkoutAddress()); + $checkout = app(CheckoutService::class)->setShippingMethod($checkout, $rate->getKey()); + $checkout = app(CheckoutService::class)->selectPaymentMethod($checkout, 'paypal'); + $cart->forceFill(['updated_at' => now()->subDays(15)])->save(); + + (new CleanupAbandonedCarts)->handle(app(CheckoutService::class)); + + expect($cart->refresh()->status)->toBe(CartStatus::Abandoned) + ->and($checkout->refresh()->status)->toBe(CheckoutStatus::Expired) + ->and(InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->first()?->quantity_reserved)->toBe(0); +}); diff --git a/tests/Feature/Checkout/PricingServicesTest.php b/tests/Feature/Checkout/PricingServicesTest.php new file mode 100644 index 00000000..b9b8faea --- /dev/null +++ b/tests/Feature/Checkout/PricingServicesTest.php @@ -0,0 +1,283 @@ +create(); + app()->instance('current_store', $store); + + TaxSettings::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + 'mode' => 'manual', + 'provider' => 'none', + 'prices_include_tax' => false, + 'config_json' => [ + 'name' => 'VAT', + 'default_rate_bps' => 1900, + 'shipping_taxable' => true, + 'rates' => [ + ['country' => 'DE', 'rate_bps' => 1900, 'name' => 'VAT'], + ], + ], + ]); + + return $store; +} + +function pricingVariant(Store $store, int $price = 2500, bool $requiresShipping = true): ProductVariant +{ + $product = Product::factory() + ->withDefaultVariant($price) + ->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->firstOrFail(); + + $variant->forceFill([ + 'requires_shipping' => $requiresShipping, + 'weight_g' => $requiresShipping ? 500 : 0, + ])->save(); + InventoryItem::withoutGlobalScopes() + ->where('variant_id', $variant->getKey()) + ->update(['quantity_on_hand' => 20]); + + return $variant->refresh(); +} + +function pricingCheckout(Store $store, ProductVariant $variant, int $quantity = 2): Checkout +{ + $cart = app(CartService::class)->create($store); + app(CartService::class)->addLine($cart, $variant->getKey(), $quantity); + + return Checkout::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + 'cart_id' => $cart->getKey(), + 'status' => 'shipping_selected', + 'email' => 'buyer@example.test', + 'shipping_address_json' => [ + 'first_name' => 'Test', + 'last_name' => 'Buyer', + 'address1' => 'Main Street 1', + 'city' => 'Berlin', + 'country' => 'DE', + 'postal_code' => '10115', + ], + ]); +} + +test('pricing engine calculates deterministic exclusive totals with shipping tax', function () { + $store = pricingStore(); + $variant = pricingVariant($store); + $zone = ShippingZone::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + 'name' => 'Germany', + 'countries_json' => ['DE'], + 'regions_json' => [], + ]); + $rate = ShippingRate::withoutGlobalScopes()->create([ + 'zone_id' => $zone->getKey(), + 'name' => 'Standard', + 'type' => 'flat', + 'config_json' => ['amount' => 499], + 'is_active' => true, + ]); + $checkout = pricingCheckout($store, $variant); + $checkout->forceFill(['shipping_method_id' => $rate->getKey()])->save(); + + $result = app(PricingEngine::class)->calculate($checkout); + + expect($result->subtotal)->toBe(5000) + ->and($result->shipping)->toBe(499) + ->and($result->taxTotal)->toBe(1045) + ->and($result->total)->toBe(6544) + ->and($checkout->refresh()->totals_json['total'])->toBe(6544); +}); + +test('pricing engine applies percent and free shipping discounts', function () { + $store = pricingStore(); + $variant = pricingVariant($store); + $zone = ShippingZone::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + 'name' => 'Germany', + 'countries_json' => ['DE'], + 'regions_json' => [], + ]); + $rate = ShippingRate::withoutGlobalScopes()->create([ + 'zone_id' => $zone->getKey(), + 'name' => 'Standard', + 'type' => 'flat', + 'config_json' => ['amount' => 499], + 'is_active' => true, + ]); + Discount::factory()->create(['store_id' => $store->getKey(), 'code' => 'SAVE10']); + Discount::factory()->freeShipping()->create(['store_id' => $store->getKey(), 'code' => 'FREESHIP']); + $checkout = pricingCheckout($store, $variant); + $checkout->forceFill([ + 'shipping_method_id' => $rate->getKey(), + 'discount_code' => 'save10', + ])->save(); + + $discounted = app(PricingEngine::class)->calculate($checkout); + + expect($discounted->discount)->toBe(500) + ->and($discounted->shipping)->toBe(499) + ->and($discounted->total)->toBe(5949); + + $checkout->forceFill(['discount_code' => 'FREESHIP'])->save(); + $freeShipping = app(PricingEngine::class)->calculate($checkout); + + expect($freeShipping->discount)->toBe(0) + ->and($freeShipping->shipping)->toBe(0) + ->and($freeShipping->total)->toBe(5950); +}); + +test('pricing engine applies active automatic discounts', function () { + $store = pricingStore(); + $variant = pricingVariant($store); + $checkout = pricingCheckout($store, $variant); + Discount::factory()->create([ + 'store_id' => $store->getKey(), + 'type' => DiscountType::Automatic, + 'code' => null, + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + ]); + + $result = app(PricingEngine::class)->calculate($checkout); + $line = $checkout->cart->lines()->firstOrFail(); + + expect($result->discount)->toBe(500) + ->and($result->total)->toBe(5355) + ->and($line->refresh()->line_discount_amount)->toBe(500) + ->and($line->line_total_amount)->toBe(4500); +}); + +test('pricing engine stacks automatic discounts after explicit code discounts', function () { + $store = pricingStore(); + $variant = pricingVariant($store); + $checkout = pricingCheckout($store, $variant); + Discount::factory()->create([ + 'store_id' => $store->getKey(), + 'code' => 'SAVE10', + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + ]); + Discount::factory()->create([ + 'store_id' => $store->getKey(), + 'type' => DiscountType::Automatic, + 'code' => null, + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + ]); + $checkout->forceFill(['discount_code' => 'SAVE10'])->save(); + + $result = app(PricingEngine::class)->calculate($checkout); + $line = $checkout->cart->lines()->firstOrFail(); + + expect($result->discount)->toBe(950) + ->and($result->total)->toBe(4820) + ->and($line->refresh()->line_discount_amount)->toBe(950) + ->and($line->line_total_amount)->toBe(4050); +}); + +test('discount validation enforces one use per customer from order history', function () { + $store = pricingStore(); + $customer = Customer::factory()->create(['store_id' => $store->getKey()]); + $otherCustomer = Customer::factory()->create(['store_id' => $store->getKey()]); + $variant = pricingVariant($store); + $discount = Discount::factory()->create([ + 'store_id' => $store->getKey(), + 'code' => 'ONCE', + 'rules_json' => [ + 'customer_eligibility' => 'all', + 'one_per_customer' => true, + ], + ]); + $order = Order::factory() + ->forCustomer($customer) + ->create([ + 'store_id' => $store->getKey(), + 'discount_amount' => 500, + ]); + OrderLine::factory()->create([ + 'order_id' => $order->getKey(), + 'discount_allocations_json' => [[ + 'discount_id' => $discount->getKey(), + 'code' => 'ONCE', + 'amount' => 500, + ]], + ]); + + $usedCart = app(CartService::class)->create($store, $customer); + app(CartService::class)->addLine($usedCart, $variant->getKey(), 1); + + try { + app(DiscountService::class)->validate('once', $store, $usedCart); + + $this->fail('Expected one-per-customer discount validation to fail.'); + } catch (InvalidDiscountException $exception) { + expect($exception->reasonCode)->toBe('discount_usage_limit_reached'); + } + + $otherCart = app(CartService::class)->create($store, $otherCustomer); + app(CartService::class)->addLine($otherCart, $variant->getKey(), 1); + + expect(app(DiscountService::class)->validate('ONCE', $store, $otherCart)->is($discount))->toBeTrue(); +}); + +test('shipping and tax calculators handle matching ranges and inclusive extraction', function () { + $store = pricingStore(); + $physicalVariant = pricingVariant($store, requiresShipping: true); + $digitalVariant = pricingVariant($store, requiresShipping: false); + $cart = app(CartService::class)->create($store); + app(CartService::class)->addLine($cart, $physicalVariant->getKey(), 2); + app(CartService::class)->addLine($cart, $digitalVariant->getKey(), 1); + $zone = ShippingZone::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + 'name' => 'Berlin', + 'countries_json' => ['DE'], + 'regions_json' => ['DE-BE'], + ]); + $rate = ShippingRate::withoutGlobalScopes()->create([ + 'zone_id' => $zone->getKey(), + 'name' => 'Weight', + 'type' => 'weight', + 'config_json' => [ + 'ranges' => [ + ['min_g' => 0, 'max_g' => 500, 'amount' => 499], + ['min_g' => 501, 'max_g' => 2000, 'amount' => 899], + ], + ], + 'is_active' => true, + ]); + + $rates = app(ShippingCalculator::class)->getAvailableRates($store, ['country' => 'DE', 'province_code' => 'DE-BE']); + + expect($rates->first()?->getKey())->toBe($rate->getKey()) + ->and(app(ShippingCalculator::class)->calculate($rate, $cart))->toBe(899) + ->and(app(TaxCalculator::class)->extractInclusive(11900, 1900))->toBe(1900) + ->and(app(TaxCalculator::class)->addExclusive(10000, 1900))->toBe(1900); +}); diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php index fcd0258d..36e7729c 100644 --- a/tests/Feature/DashboardTest.php +++ b/tests/Feature/DashboardTest.php @@ -1,6 +1,7 @@ create(); + $this->seed(DatabaseSeeder::class); + + $user = User::query()->where('email', 'admin@acme.test')->firstOrFail(); $this->actingAs($user); - $response = $this->get(route('dashboard')); + $response = $this->get(route('admin.dashboard')); $response->assertOk(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 8b5843f4..cec9fca6 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -1,7 +1,14 @@ get('/'); + $this->seed(DatabaseSeeder::class); + + $response = $this->get('http://shop.test/'); - $response->assertStatus(200); + $response->assertOk(); }); diff --git a/tests/Feature/Foundation/AuditLoggingTest.php b/tests/Feature/Foundation/AuditLoggingTest.php new file mode 100644 index 00000000..370aa150 --- /dev/null +++ b/tests/Feature/Foundation/AuditLoggingTest.php @@ -0,0 +1,103 @@ +withoutVite(); + + auditLoggingConfigureChannel(); + auditLoggingResetFiles(); + + $this->seed(DatabaseSeeder::class); + + Log::forgetChannel('audit'); + auditLoggingResetFiles(); +}); + +function auditLoggingConfigureChannel(): void +{ + config(['logging.channels.audit.path' => storage_path('framework/testing/audit.log')]); + Log::forgetChannel('audit'); +} + +function auditLoggingResetFiles(): void +{ + $directory = storage_path('framework/testing'); + + if (! is_dir($directory)) { + mkdir($directory, 0777, true); + } + + foreach (glob($directory.'/audit*.log') ?: [] as $file) { + unlink($file); + } +} + +function auditLoggingPath(): string +{ + return storage_path('framework/testing/audit-'.now()->format('Y-m-d').'.log'); +} + +function auditLoggingContents(): string +{ + $path = auditLoggingPath(); + + return file_exists($path) ? (string) file_get_contents($path) : ''; +} + +test('audit logger writes structured entries to the audit channel', function (): void { + app(AuditLogger::class)->log( + event: 'test.event', + userId: 1, + storeId: 2, + resourceType: 'product', + resourceId: 3, + changes: ['title' => ['Old title', 'New title']], + extra: ['source' => 'test-suite'], + ); + + expect(auditLoggingContents())->toContain('"event":"test.event"') + ->toContain('"user_id":1') + ->toContain('"store_id":2') + ->toContain('"resource_type":"product"') + ->toContain('"resource_id":3') + ->toContain('"title":["Old title","New title"]') + ->toContain('"source":"test-suite"'); +}); + +test('authentication and resource changes are audit logged', function (): void { + $user = User::query()->where('email', 'admin@acme.test')->firstOrFail(); + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $product = Product::query()->where('store_id', $store->getKey())->firstOrFail(); + + app()->instance('current_store', $store); + $this->actingAs($user); + + event(new AuthLogin('web', $user, false)); + event(new AuthFailed('web', null, ['email' => 'failed-admin@example.test'])); + event(new AuthLogout('web', $user)); + + $product->forceFill([ + 'title' => 'Audit Trail Jacket', + ])->save(); + + expect(auditLoggingContents())->toContain('"event":"auth.login"') + ->toContain('"event":"auth.failed_login"') + ->toContain('"email":"failed-admin@example.test"') + ->toContain('"event":"auth.logout"') + ->toContain('"event":"product.updated"') + ->toContain('"resource_type":"product"') + ->toContain('"title"') + ->toContain('Audit Trail Jacket'); +}); diff --git a/tests/Feature/Foundation/AuthorizationTest.php b/tests/Feature/Foundation/AuthorizationTest.php new file mode 100644 index 00000000..8f632c09 --- /dev/null +++ b/tests/Feature/Foundation/AuthorizationTest.php @@ -0,0 +1,142 @@ +create(); + $user = User::factory()->create(); + + $store->users()->attach($user, ['role' => 'staff', 'created_at' => now()]); + + expect($user->roleForStore($store)?->value)->toBe('staff'); +}); + +test('product policy allows support to view but not mutate products', function () { + $store = Store::factory()->create(); + $support = User::factory()->create(); + $store->users()->attach($support, ['role' => 'support', 'created_at' => now()]); + + app()->instance('current_store', $store); + $product = Product::factory()->for($store)->create(); + $policy = new ProductPolicy; + + expect($policy->viewAny($support))->toBeTrue() + ->and($policy->view($support, $product))->toBeTrue() + ->and($policy->create($support))->toBeFalse() + ->and($policy->update($support, $product))->toBeFalse() + ->and($policy->delete($support, $product))->toBeFalse(); +}); + +test('staff can manage discounts but only owner or admin can delete them', function () { + $store = Store::factory()->create(); + $staff = User::factory()->create(); + $store->users()->attach($staff, ['role' => 'staff', 'created_at' => now()]); + + app()->instance('current_store', $store); + $discount = Discount::factory()->for($store)->create(); + $policy = new DiscountPolicy; + + expect($policy->create($staff))->toBeTrue() + ->and($policy->update($staff, $discount))->toBeTrue() + ->and($policy->delete($staff, $discount))->toBeFalse(); +}); + +test('only owners can delete stores', function () { + $store = Store::factory()->create(); + $owner = User::factory()->create(); + $admin = User::factory()->create(); + + $store->users()->attach($owner, ['role' => 'owner', 'created_at' => now()]); + $store->users()->attach($admin, ['role' => 'admin', 'created_at' => now()]); + + $policy = new StorePolicy; + + expect($policy->delete($owner, $store))->toBeTrue() + ->and($policy->delete($admin, $store))->toBeFalse() + ->and($policy->update($admin, $store))->toBeTrue(); +}); + +test('resource policies type model parameters with concrete resources', function (): void { + $policyMethods = [ + CollectionPolicy::class => [ + 'view' => CatalogCollection::class, + 'update' => CatalogCollection::class, + 'delete' => CatalogCollection::class, + ], + CustomerPolicy::class => [ + 'view' => Customer::class, + 'update' => Customer::class, + ], + DiscountPolicy::class => [ + 'view' => Discount::class, + 'update' => Discount::class, + 'delete' => Discount::class, + ], + FulfillmentPolicy::class => [ + 'update' => Fulfillment::class, + 'cancel' => Fulfillment::class, + ], + OrderPolicy::class => [ + 'view' => Order::class, + 'update' => Order::class, + 'cancel' => Order::class, + 'createFulfillment' => Order::class, + 'createRefund' => Order::class, + ], + PagePolicy::class => [ + 'view' => Page::class, + 'update' => Page::class, + 'delete' => Page::class, + ], + ProductPolicy::class => [ + 'view' => Product::class, + 'update' => Product::class, + 'delete' => Product::class, + 'archive' => Product::class, + 'restore' => Product::class, + ], + RefundPolicy::class => [ + 'view' => Refund::class, + ], + ThemePolicy::class => [ + 'view' => Theme::class, + 'update' => Theme::class, + 'publish' => Theme::class, + 'delete' => Theme::class, + ], + ]; + + foreach ($policyMethods as $policyClass => $methods) { + foreach ($methods as $method => $modelClass) { + $parameter = (new \ReflectionMethod($policyClass, $method))->getParameters()[1] ?? null; + $type = $parameter?->getType(); + + expect($type) + ->toBeInstanceOf(\ReflectionNamedType::class) + ->and($type->getName())->toBe($modelClass); + } + } +}); diff --git a/tests/Feature/Foundation/CustomerAuthTest.php b/tests/Feature/Foundation/CustomerAuthTest.php new file mode 100644 index 00000000..e7c53ef3 --- /dev/null +++ b/tests/Feature/Foundation/CustomerAuthTest.php @@ -0,0 +1,98 @@ +create(); + $secondStore = Store::factory()->create(); + + Customer::factory()->create([ + 'store_id' => $firstStore->getKey(), + 'email' => 'same@example.test', + 'password' => 'first-password', + ]); + + Customer::factory()->create([ + 'store_id' => $secondStore->getKey(), + 'email' => 'same@example.test', + 'password' => 'second-password', + ]); + + app()->instance('current_store', $firstStore); + + expect(Auth::guard('customer')->attempt([ + 'email' => 'same@example.test', + 'password' => 'first-password', + ]))->toBeTrue(); + + Auth::guard('customer')->logout(); + + expect(Auth::guard('customer')->attempt([ + 'email' => 'same@example.test', + 'password' => 'second-password', + ]))->toBeFalse(); + + app()->instance('current_store', $secondStore); + + expect(Auth::guard('customer')->attempt([ + 'email' => 'same@example.test', + 'password' => 'second-password', + ]))->toBeTrue(); +}); + +test('customer login component authenticates within the resolved store', function () { + $store = Store::factory()->create(); + $customer = Customer::factory()->create([ + 'store_id' => $store->getKey(), + 'email' => 'customer@example.test', + 'password' => 'password', + ]); + + app()->instance('current_store', $store); + + Livewire::test(CustomerLogin::class) + ->set('email', $customer->email) + ->set('password', 'password') + ->call('login') + ->assertHasNoErrors() + ->assertRedirect(route('account.dashboard', absolute: false)); + + $this->assertAuthenticatedAs($customer, 'customer'); +}); + +test('customer registration is unique per store', function () { + $store = Store::factory()->create(); + + app()->instance('current_store', $store); + + Livewire::test(CustomerRegister::class) + ->set('name', 'Jane Doe') + ->set('email', 'jane@example.test') + ->set('password', 'password') + ->set('password_confirmation', 'password') + ->set('marketing_opt_in', true) + ->call('register') + ->assertHasNoErrors() + ->assertRedirect(route('account.dashboard', absolute: false)); + + expect(Customer::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('email', 'jane@example.test') + ->exists())->toBeTrue(); + + Livewire::test(CustomerRegister::class) + ->set('name', 'Jane Doe') + ->set('email', 'jane@example.test') + ->set('password', 'password') + ->set('password_confirmation', 'password') + ->call('register') + ->assertHasErrors(['email']); +}); diff --git a/tests/Feature/Foundation/DatabaseConstraintTest.php b/tests/Feature/Foundation/DatabaseConstraintTest.php new file mode 100644 index 00000000..dc9229b6 --- /dev/null +++ b/tests/Feature/Foundation/DatabaseConstraintTest.php @@ -0,0 +1,50 @@ +seed(DatabaseSeeder::class); +}); + +test('sqlite enum columns are created with check constraints', function (): void { + $tableSql = collect(DB::select( + "select name, sql from sqlite_master where type = 'table' and name not like 'sqlite_%'" + ))->mapWithKeys(fn (object $table): array => [$table->name => $table->sql]); + + expect($tableSql['stores'])->toContain('"status" varchar check ("status" in (\'active\', \'suspended\'))') + ->and($tableSql['store_users'])->toContain('"role" varchar check ("role" in (\'owner\', \'admin\', \'staff\', \'support\'))') + ->and($tableSql['products'])->toContain('"status" varchar check ("status" in (\'draft\', \'active\', \'archived\'))') + ->and($tableSql['orders'])->toContain('"financial_status" varchar check ("financial_status" in (\'pending\', \'authorized\', \'paid\', \'partially_refunded\', \'refunded\', \'voided\'))') + ->and($tableSql['tax_settings'])->toContain('"provider" varchar check ("provider" in (\'stripe_tax\', \'none\'))') + ->and($tableSql['data_exports'])->toContain('"status" varchar check ("status" in (\'queued\', \'processing\', \'completed\', \'failed\'))'); +}); + +test('personal access tokens table matches the api token schema', function (): void { + expect(Schema::hasTable('personal_access_tokens'))->toBeTrue() + ->and(Schema::hasColumns('personal_access_tokens', [ + 'id', + 'store_id', + 'tokenable_type', + 'tokenable_id', + 'name', + 'token', + 'abilities', + 'last_used_at', + 'expires_at', + 'created_at', + 'updated_at', + ]))->toBeTrue() + ->and(Schema::hasIndex('personal_access_tokens', ['token'], 'unique'))->toBeTrue() + ->and(Schema::hasIndex('personal_access_tokens', ['tokenable_type', 'tokenable_id']))->toBeTrue() + ->and(Schema::hasIndex('personal_access_tokens', ['store_id', 'tokenable_type', 'tokenable_id']))->toBeTrue(); +}); + +test('users table tracks platform administrators separately from store roles', function (): void { + expect(Schema::hasColumn('users', 'is_platform_admin'))->toBeTrue() + ->and(Schema::hasIndex('users', ['is_platform_admin']))->toBeTrue(); +}); diff --git a/tests/Feature/Foundation/TenancyTest.php b/tests/Feature/Foundation/TenancyTest.php new file mode 100644 index 00000000..aaa7319a --- /dev/null +++ b/tests/Feature/Foundation/TenancyTest.php @@ -0,0 +1,78 @@ +seed(DatabaseSeeder::class); + + expect(Schema::hasColumns('organizations', ['name', 'billing_email']))->toBeTrue() + ->and(Schema::hasColumns('stores', ['organization_id', 'handle', 'status', 'default_currency']))->toBeTrue() + ->and(Schema::hasColumns('store_domains', ['store_id', 'hostname', 'type', 'is_primary']))->toBeTrue() + ->and(Schema::hasColumns('store_users', ['store_id', 'user_id', 'role']))->toBeTrue() + ->and(Schema::hasColumns('store_settings', ['store_id', 'settings_json']))->toBeTrue() + ->and(StoreDomain::query()->where('hostname', 'shop.test')->exists())->toBeTrue() + ->and(User::query()->where('email', 'admin@acme.test')->exists())->toBeTrue(); +}); + +test('storefront requests resolve a store by hostname', function () { + $this->seed(DatabaseSeeder::class); + + $this->get('http://shop.test/') + ->assertOk(); +}); + +test('storefront requests reject unknown or suspended stores', function () { + $this->seed(DatabaseSeeder::class); + + $this->get('http://unknown-shop.test/') + ->assertNotFound(); + + $store = Store::factory()->suspended()->create(); + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'suspended-shop.test', + ]); + + $this->get('http://suspended-shop.test/') + ->assertServiceUnavailable(); +}); + +test('admin requests resolve the selected session store for authorized staff', function () { + $this->seed(DatabaseSeeder::class); + + $user = User::query()->where('email', 'admin@acme.test')->firstOrFail(); + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + + $this->actingAs($user) + ->withSession(['current_store_id' => $store->getKey()]) + ->get('/admin') + ->assertOk(); +}); + +test('admin login component authenticates active staff with a generic failure message', function () { + $this->seed(DatabaseSeeder::class); + + Livewire::test(AdminLogin::class) + ->set('email', 'admin@acme.test') + ->set('password', 'wrong-password') + ->call('login') + ->assertHasErrors(['email']); + + Livewire::test(AdminLogin::class) + ->set('email', 'admin@acme.test') + ->set('password', 'password') + ->call('login') + ->assertHasNoErrors() + ->assertRedirect(route('admin.dashboard', absolute: false)); + + $this->assertAuthenticated(); +}); diff --git a/tests/Feature/Orders/BankTransferOrderTest.php b/tests/Feature/Orders/BankTransferOrderTest.php new file mode 100644 index 00000000..f3b6033b --- /dev/null +++ b/tests/Feature/Orders/BankTransferOrderTest.php @@ -0,0 +1,128 @@ +create(); + app()->instance('current_store', $store); + + StoreSettings::query()->create([ + 'store_id' => $store->getKey(), + 'settings_json' => ['bank_transfer_cancel_days' => 7], + ]); + + $product = Product::factory()->withDefaultVariant(2500)->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::withoutGlobalScopes()->where('product_id', $product->getKey())->firstOrFail(); + $variant->forceFill([ + 'requires_shipping' => ! $digital, + 'weight_g' => $digital ? 0 : $variant->weight_g, + ])->save(); + + InventoryItem::withoutGlobalScopes() + ->where('variant_id', $variant->getKey()) + ->update([ + 'quantity_on_hand' => 10, + 'quantity_reserved' => 2, + ]); + + $order = Order::factory()->bankTransfer()->create([ + 'store_id' => $store->getKey(), + 'payment_method' => PaymentMethod::BankTransfer, + 'status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + 'placed_at' => now()->subDays($ageDays), + 'subtotal_amount' => 5000, + 'shipping_amount' => 0, + 'tax_amount' => 0, + 'total_amount' => 5000, + ]); + OrderLine::factory()->create([ + 'order_id' => $order->getKey(), + 'product_id' => $product->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity' => 2, + 'unit_price_amount' => 2500, + 'total_amount' => 5000, + ]); + Payment::factory()->bankTransfer()->create([ + 'order_id' => $order->getKey(), + 'amount' => 5000, + ]); + + return [$order, $variant]; +} + +test('confirming bank transfer payment captures payment and commits reserved inventory', function () { + [$order, $variant] = bankTransferOrder(); + + Event::fake(); + + $paidOrder = app(OrderService::class)->confirmBankTransferPayment($order); + $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail(); + + expect($paidOrder->status)->toBe(OrderStatus::Paid) + ->and($paidOrder->financial_status)->toBe(FinancialStatus::Paid) + ->and($paidOrder->payments->first()->status)->toBe(PaymentStatus::Captured) + ->and($inventory->quantity_on_hand)->toBe(8) + ->and($inventory->quantity_reserved)->toBe(0); + + Event::assertDispatched(OrderPaid::class); +}); + +test('confirming digital bank transfer payment auto-fulfills the order', function () { + [$order] = bankTransferOrder(digital: true); + + $paidOrder = app(OrderService::class)->confirmBankTransferPayment($order); + + expect($paidOrder->status)->toBe(OrderStatus::Fulfilled) + ->and($paidOrder->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled) + ->and($paidOrder->fulfillments)->toHaveCount(1) + ->and($paidOrder->fulfillments->first()->status)->toBe(FulfillmentShipmentStatus::Delivered); +}); + +test('cancel job voids stale bank transfer orders and releases reservations', function () { + [$oldOrder, $oldVariant] = bankTransferOrder(ageDays: 8); + [$newOrder, $newVariant] = bankTransferOrder(ageDays: 2); + + Event::fake(); + + (new CancelUnpaidBankTransferOrders)->handle(app(OrderService::class)); + + $oldInventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $oldVariant->getKey())->firstOrFail(); + $newInventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $newVariant->getKey())->firstOrFail(); + + expect($oldOrder->refresh()->status)->toBe(OrderStatus::Cancelled) + ->and($oldOrder->financial_status)->toBe(FinancialStatus::Voided) + ->and($oldOrder->payments()->first()?->status)->toBe(PaymentStatus::Failed) + ->and($oldInventory->quantity_on_hand)->toBe(10) + ->and($oldInventory->quantity_reserved)->toBe(0) + ->and($newOrder->refresh()->status)->toBe(OrderStatus::Pending) + ->and($newInventory->quantity_reserved)->toBe(2); + + Event::assertDispatched(OrderCancelled::class); +}); diff --git a/tests/Feature/Orders/FulfillmentServiceTest.php b/tests/Feature/Orders/FulfillmentServiceTest.php new file mode 100644 index 00000000..7a04cb91 --- /dev/null +++ b/tests/Feature/Orders/FulfillmentServiceTest.php @@ -0,0 +1,121 @@ +create(); + app()->instance('current_store', $store); + + $firstProduct = Product::factory()->withDefaultVariant(2500)->create(['store_id' => $store->getKey()]); + $secondProduct = Product::factory()->withDefaultVariant(1500)->create(['store_id' => $store->getKey()]); + $firstVariant = ProductVariant::withoutGlobalScopes()->where('product_id', $firstProduct->getKey())->firstOrFail(); + $secondVariant = ProductVariant::withoutGlobalScopes()->where('product_id', $secondProduct->getKey())->firstOrFail(); + + $order = Order::factory()->paid()->create([ + 'store_id' => $store->getKey(), + 'financial_status' => $financialStatus, + 'status' => $financialStatus === FinancialStatus::Pending ? OrderStatus::Pending : OrderStatus::Paid, + ]); + $firstLine = OrderLine::factory()->create([ + 'order_id' => $order->getKey(), + 'product_id' => $firstProduct->getKey(), + 'variant_id' => $firstVariant->getKey(), + 'quantity' => 2, + 'unit_price_amount' => 2500, + 'total_amount' => 5000, + ]); + $secondLine = OrderLine::factory()->create([ + 'order_id' => $order->getKey(), + 'product_id' => $secondProduct->getKey(), + 'variant_id' => $secondVariant->getKey(), + 'quantity' => 1, + 'unit_price_amount' => 1500, + 'total_amount' => 1500, + ]); + + return [$order, $firstLine, $secondLine]; +} + +test('fulfillment service blocks fulfillment until payment is confirmed', function () { + [$order, $line] = fulfillmentServiceOrder(FinancialStatus::Pending); + + expect(fn () => app(FulfillmentService::class)->create($order, [$line->getKey() => 1])) + ->toThrow(InvalidFulfillmentOperationException::class); +}); + +test('fulfillment service creates partial and complete fulfillments', function () { + [$order, $firstLine, $secondLine] = fulfillmentServiceOrder(); + + Event::fake(); + + $firstFulfillment = app(FulfillmentService::class)->create($order, [ + $firstLine->getKey() => 2, + ], [ + 'tracking_company' => 'DHL', + 'tracking_number' => 'DHL123', + ]); + + expect($firstFulfillment->status)->toBe(FulfillmentShipmentStatus::Pending) + ->and($order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Partial) + ->and($order->status)->toBe(OrderStatus::Paid); + + $secondFulfillment = app(FulfillmentService::class)->create($order, [ + $secondLine->getKey() => 1, + ]); + + expect($secondFulfillment->lines)->toHaveCount(1) + ->and($order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled) + ->and($order->status)->toBe(OrderStatus::Fulfilled); + + Event::assertDispatched(FulfillmentCreated::class, 2); +}); + +test('fulfillment service rejects over-fulfillment and invalid transitions', function () { + [$order, $line] = fulfillmentServiceOrder(); + $fulfillment = app(FulfillmentService::class)->create($order, [$line->getKey() => 1]); + + expect(fn () => app(FulfillmentService::class)->create($order, [$line->getKey() => 2])) + ->toThrow(InvalidFulfillmentOperationException::class); + + expect(fn () => app(FulfillmentService::class)->markDelivered($fulfillment)) + ->toThrow(InvalidFulfillmentOperationException::class); +}); + +test('fulfillment service marks shipments as shipped and delivered', function () { + [$order, $line] = fulfillmentServiceOrder(); + $fulfillment = app(FulfillmentService::class)->create($order, [$line->getKey() => 1]); + + Event::fake(); + + $shipped = app(FulfillmentService::class)->markShipped($fulfillment); + $delivered = app(FulfillmentService::class)->markDelivered($shipped); + + expect($shipped->status)->toBe(FulfillmentShipmentStatus::Shipped) + ->and($shipped->shipped_at)->not->toBeNull() + ->and($delivered->status)->toBe(FulfillmentShipmentStatus::Delivered) + ->and($delivered->delivered_at)->not->toBeNull(); + + Event::assertDispatched(FulfillmentShipped::class); + Event::assertDispatched(FulfillmentDelivered::class); +}); diff --git a/tests/Feature/Orders/OrderServiceTest.php b/tests/Feature/Orders/OrderServiceTest.php new file mode 100644 index 00000000..9b5c6d3a --- /dev/null +++ b/tests/Feature/Orders/OrderServiceTest.php @@ -0,0 +1,321 @@ +create(); + app()->instance('current_store', $store); + + StoreSettings::query()->create([ + 'store_id' => $store->getKey(), + 'settings_json' => [ + 'order_number_prefix' => '#', + 'order_number_start' => $start, + ], + ]); + + TaxSettings::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + 'mode' => 'manual', + 'provider' => 'none', + 'prices_include_tax' => false, + 'config_json' => ['default_rate_bps' => 0, 'shipping_taxable' => false], + ]); + + return $store; +} + +function orderCompletionVariant(Store $store, int $price = 2500, int $stock = 10): ProductVariant +{ + $product = Product::factory() + ->withDefaultVariant($price) + ->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->firstOrFail(); + + InventoryItem::withoutGlobalScopes() + ->where('variant_id', $variant->getKey()) + ->update([ + 'quantity_on_hand' => $stock, + 'quantity_reserved' => 0, + ]); + + return $variant->refresh(); +} + +function orderCompletionShippingRate(Store $store): ShippingRate +{ + $zone = ShippingZone::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('name', 'Germany') + ->first(); + + if (! $zone instanceof ShippingZone) { + $zone = ShippingZone::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + 'name' => 'Germany', + 'countries_json' => ['DE'], + 'regions_json' => [], + ]); + } + + $rate = ShippingRate::withoutGlobalScopes() + ->where('zone_id', $zone->getKey()) + ->where('name', 'Standard') + ->first(); + + if ($rate instanceof ShippingRate) { + return $rate; + } + + return ShippingRate::withoutGlobalScopes()->create([ + 'zone_id' => $zone->getKey(), + 'name' => 'Standard', + 'type' => 'flat', + 'config_json' => ['amount' => 499], + 'is_active' => true, + ]); +} + +/** + * @return array{0: Checkout, 1: ProductVariant, 2: \App\Models\Cart} + */ +function orderCompletionCheckout(Store $store, string $paymentMethod = 'credit_card', ?string $discountCode = null): array +{ + $variant = orderCompletionVariant($store); + $rate = orderCompletionShippingRate($store); + $cart = app(CartService::class)->create($store); + + app(CartService::class)->addLine($cart, $variant->getKey(), 2); + + $checkout = app(CheckoutService::class)->createFromCart($cart); + $checkout = app(CheckoutService::class)->setAddress($checkout, [ + 'email' => 'buyer@example.test', + 'shipping_address' => [ + 'first_name' => 'Test', + 'last_name' => 'Buyer', + 'address1' => 'Main Street 1', + 'city' => 'Berlin', + 'country' => 'DE', + 'postal_code' => '10115', + ], + ]); + $checkout = app(CheckoutService::class)->setShippingMethod($checkout, $rate->getKey()); + + if ($discountCode !== null) { + $checkout->forceFill(['discount_code' => $discountCode])->save(); + app(PricingEngine::class)->calculate($checkout); + } + + $checkout = app(CheckoutService::class)->selectPaymentMethod($checkout, $paymentMethod); + + return [$checkout, $variant, $cart]; +} + +test('checkout completion creates a paid order and commits inventory', function () { + $store = orderCompletionStore(); + $discount = Discount::factory() + ->fixed(500) + ->create([ + 'store_id' => $store->getKey(), + 'code' => 'SAVE500', + ]); + [$checkout, $variant, $cart] = orderCompletionCheckout($store, discountCode: 'SAVE500'); + + Event::fake(); + + $order = app(CheckoutService::class)->completeCheckout($checkout, [ + 'card_number' => '4242 4242 4242 4242', + ]); + + expect($order->order_number)->toBe('#1001') + ->and($order->status)->toBe(OrderStatus::Paid) + ->and($order->financial_status)->toBe(FinancialStatus::Paid) + ->and($order->total_amount)->toBe(4999) + ->and($order->lines)->toHaveCount(1) + ->and($order->lines->first()->title_snapshot)->toContain($variant->product->title) + ->and($order->lines->first()->discount_allocations_json)->toBe([[ + 'discount_id' => $discount->getKey(), + 'code' => 'SAVE500', + 'amount' => 500, + ]]) + ->and($order->payments)->toHaveCount(1) + ->and($order->payments->first()->status)->toBe(PaymentStatus::Captured) + ->and($order->payments->first()->raw_json_encrypted['success'])->toBeTrue() + ->and($cart->refresh()->status)->toBe(CartStatus::Converted) + ->and($checkout->refresh()->status)->toBe(CheckoutStatus::Completed) + ->and($discount->refresh()->usage_count)->toBe(1); + + $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail(); + + expect($inventory->quantity_on_hand)->toBe(8) + ->and($inventory->quantity_reserved)->toBe(0); + + Event::assertDispatched(OrderCreated::class, fn (OrderCreated $event): bool => $event->order->is($order)); + Event::assertDispatched(OrderPaid::class, fn (OrderPaid $event): bool => $event->order->is($order)); +}); + +test('checkout completion attributes automatic discounts on order lines', function () { + $store = orderCompletionStore(); + $automatic = Discount::factory() + ->create([ + 'store_id' => $store->getKey(), + 'type' => DiscountType::Automatic, + 'code' => null, + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + ]); + [$checkout] = orderCompletionCheckout($store); + + $order = app(CheckoutService::class)->completeCheckout($checkout, [ + 'card_number' => '4242 4242 4242 4242', + ]); + + expect($order->discount_amount)->toBe(500) + ->and($order->lines->first()->discount_allocations_json)->toBe([[ + 'discount_id' => $automatic->getKey(), + 'code' => null, + 'amount' => 500, + ]]); +}); + +test('checkout completion keeps explicit and automatic discount allocations separate', function () { + $store = orderCompletionStore(); + $codeDiscount = Discount::factory() + ->create([ + 'store_id' => $store->getKey(), + 'code' => 'SAVE10', + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + ]); + $automatic = Discount::factory() + ->create([ + 'store_id' => $store->getKey(), + 'type' => DiscountType::Automatic, + 'code' => null, + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + ]); + [$checkout] = orderCompletionCheckout($store, discountCode: 'SAVE10'); + + $order = app(CheckoutService::class)->completeCheckout($checkout, [ + 'card_number' => '4242 4242 4242 4242', + ]); + + expect($order->discount_amount)->toBe(950) + ->and($order->lines->first()->discount_allocations_json)->toBe([ + [ + 'discount_id' => $codeDiscount->getKey(), + 'code' => 'SAVE10', + 'amount' => 500, + ], + [ + 'discount_id' => $automatic->getKey(), + 'code' => null, + 'amount' => 450, + ], + ]); +}); + +test('checkout completion is idempotent for the same checkout', function () { + $store = orderCompletionStore(); + [$checkout, $variant] = orderCompletionCheckout($store); + + $order = app(CheckoutService::class)->completeCheckout($checkout, [ + 'card_number' => '4242 4242 4242 4242', + ]); + $sameOrder = app(CheckoutService::class)->completeCheckout($checkout->refresh(), [ + 'card_number' => '4000 0000 0000 0002', + ]); + + expect($sameOrder->is($order))->toBeTrue() + ->and(Order::withoutGlobalScopes()->count())->toBe(1) + ->and(Payment::query()->count())->toBe(1) + ->and(InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->first()?->quantity_on_hand)->toBe(8) + ->and(InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->first()?->quantity_reserved)->toBe(0); +}); + +test('bank transfer completion creates a pending order and keeps inventory reserved', function () { + $store = orderCompletionStore(); + [$checkout, $variant] = orderCompletionCheckout($store, paymentMethod: 'bank_transfer'); + + Event::fake(); + + $order = app(CheckoutService::class)->completeCheckout($checkout); + + expect($order->status)->toBe(OrderStatus::Pending) + ->and($order->financial_status)->toBe(FinancialStatus::Pending) + ->and($order->payments->first()->status)->toBe(PaymentStatus::Pending) + ->and($checkout->refresh()->status)->toBe(CheckoutStatus::Completed); + + $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail(); + + expect($inventory->quantity_on_hand)->toBe(10) + ->and($inventory->quantity_reserved)->toBe(2); + + Event::assertDispatched(OrderCreated::class); + Event::assertNotDispatched(OrderPaid::class); +}); + +test('payment failures release reserved inventory and allow checkout retry', function () { + $store = orderCompletionStore(); + [$checkout, $variant] = orderCompletionCheckout($store); + + expect(fn () => app(CheckoutService::class)->completeCheckout($checkout, [ + 'card_number' => '4000 0000 0000 0002', + ]))->toThrow(PaymentFailedException::class); + + $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail(); + + expect(Order::withoutGlobalScopes()->count())->toBe(0) + ->and($inventory->quantity_on_hand)->toBe(10) + ->and($inventory->quantity_reserved)->toBe(0) + ->and($checkout->refresh()->status)->toBe(CheckoutStatus::ShippingSelected) + ->and($checkout->payment_method)->toBeNull(); +}); + +test('order numbers increment sequentially per store', function () { + $store = orderCompletionStore(start: 5001); + [$firstCheckout] = orderCompletionCheckout($store); + [$secondCheckout] = orderCompletionCheckout($store); + + $firstOrder = app(CheckoutService::class)->completeCheckout($firstCheckout, [ + 'card_number' => '4242 4242 4242 4242', + ]); + $secondOrder = app(CheckoutService::class)->completeCheckout($secondCheckout, [ + 'card_number' => '4242 4242 4242 4242', + ]); + + expect($firstOrder->order_number)->toBe('#5001') + ->and($secondOrder->order_number)->toBe('#5002'); +}); diff --git a/tests/Feature/Orders/RefundServiceTest.php b/tests/Feature/Orders/RefundServiceTest.php new file mode 100644 index 00000000..850bbc0b --- /dev/null +++ b/tests/Feature/Orders/RefundServiceTest.php @@ -0,0 +1,113 @@ +create(); + app()->instance('current_store', $store); + + $product = Product::factory() + ->withDefaultVariant($unitPrice) + ->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->firstOrFail(); + + InventoryItem::withoutGlobalScopes() + ->where('variant_id', $variant->getKey()) + ->update([ + 'quantity_on_hand' => 8, + 'quantity_reserved' => 0, + ]); + + $total = $quantity * $unitPrice; + $order = Order::factory()->paid()->create([ + 'store_id' => $store->getKey(), + 'subtotal_amount' => $total, + 'shipping_amount' => 0, + 'tax_amount' => 0, + 'total_amount' => $total, + ]); + $line = OrderLine::factory()->create([ + 'order_id' => $order->getKey(), + 'product_id' => $product->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity' => $quantity, + 'unit_price_amount' => $unitPrice, + 'total_amount' => $total, + ]); + + Payment::factory()->create([ + 'order_id' => $order->getKey(), + 'status' => PaymentStatus::Captured, + 'amount' => $total, + ]); + + return [$order, $line, $variant]; +} + +test('refund service processes a partial line refund and restocks inventory', function () { + [$order, $line, $variant] = refundServiceOrder(); + + Event::fake(); + + $refund = app(RefundService::class)->process($order, [ + 'lines' => [$line->getKey() => 1], + 'reason' => 'Customer return', + 'restock' => true, + ]); + + expect($refund->amount)->toBe(5000) + ->and($refund->status)->toBe(RefundStatus::Processed) + ->and($refund->provider_refund_id)->toStartWith('mock_refund_') + ->and($order->refresh()->status)->toBe(OrderStatus::Paid) + ->and($order->financial_status)->toBe(FinancialStatus::PartiallyRefunded) + ->and($order->payments()->first()?->status)->toBe(PaymentStatus::Captured) + ->and(InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->first()?->quantity_on_hand)->toBe(9); + + Event::assertDispatched(OrderRefunded::class, fn (OrderRefunded $event): bool => $event->refund->is($refund)); +}); + +test('refund service marks order and payment refunded when the full amount is refunded', function () { + [$order] = refundServiceOrder(); + + $refund = app(RefundService::class)->process($order); + + expect($refund->amount)->toBe(10000) + ->and($order->refresh()->status)->toBe(OrderStatus::Refunded) + ->and($order->financial_status)->toBe(FinancialStatus::Refunded) + ->and($order->payments()->first()?->status)->toBe(PaymentStatus::Refunded); +}); + +test('refund service rejects over-refunds and orders without captured payments', function () { + [$order] = refundServiceOrder(); + + expect(fn () => app(RefundService::class)->process($order, ['amount' => 10001])) + ->toThrow(InvalidRefundOperationException::class); + + $order->payments()->update(['status' => PaymentStatus::Pending]); + + expect(fn () => app(RefundService::class)->process($order, ['amount' => 500])) + ->toThrow(InvalidRefundOperationException::class); +}); diff --git a/tests/Feature/Payments/MockPaymentProviderTest.php b/tests/Feature/Payments/MockPaymentProviderTest.php new file mode 100644 index 00000000..6cfaf1cc --- /dev/null +++ b/tests/Feature/Payments/MockPaymentProviderTest.php @@ -0,0 +1,57 @@ +charge( + new Checkout, + PaymentMethod::CreditCard, + ['card_number' => $number], + ); + + expect($result->success)->toBe($success) + ->and($result->status)->toBe($status) + ->and($result->errorCode)->toBe($errorCode); + + if ($success) { + expect($result->referenceId)->toStartWith('mock_payment_'); + } +})->with([ + 'captured card' => ['4242 4242 4242 4242', true, PaymentStatus::Captured, null], + 'declined card' => ['4000 0000 0000 0002', false, PaymentStatus::Failed, 'card_declined'], + 'insufficient funds' => ['4000 0000 0000 9995', false, PaymentStatus::Failed, 'insufficient_funds'], + 'other card' => ['4111 1111 1111 1111', true, PaymentStatus::Captured, null], +]); + +test('mock provider captures paypal and leaves bank transfer pending', function () { + $provider = app(MockPaymentProvider::class); + + $paypal = $provider->charge(new Checkout, PaymentMethod::Paypal); + $bankTransfer = $provider->charge(new Checkout, PaymentMethod::BankTransfer); + + expect($paypal->success)->toBeTrue() + ->and($paypal->status)->toBe(PaymentStatus::Captured) + ->and($paypal->referenceId)->toStartWith('mock_payment_') + ->and($bankTransfer->success)->toBeTrue() + ->and($bankTransfer->status)->toBe(PaymentStatus::Pending) + ->and($bankTransfer->referenceId)->toStartWith('mock_bank_'); +}); + +test('mock provider processes valid refunds and rejects invalid amounts', function () { + $provider = app(MockPaymentProvider::class); + + $processed = $provider->refund(new Payment, 500); + $failed = $provider->refund(new Payment, 0); + + expect($processed->success)->toBeTrue() + ->and($processed->status)->toBe(RefundStatus::Processed) + ->and($processed->referenceId)->toStartWith('mock_refund_') + ->and($failed->success)->toBeFalse() + ->and($failed->status)->toBe(RefundStatus::Failed) + ->and($failed->errorCode)->toBe('invalid_refund_amount'); +}); diff --git a/tests/Feature/Search/SearchServiceTest.php b/tests/Feature/Search/SearchServiceTest.php new file mode 100644 index 00000000..c10cbe4d --- /dev/null +++ b/tests/Feature/Search/SearchServiceTest.php @@ -0,0 +1,152 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function searchServiceStore(): Store +{ + return Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +} + +test('search service returns matching products scoped to the store and logs the query', function (): void { + $store = searchServiceStore(); + $otherStore = Store::query()->whereKeyNot($store->getKey())->firstOrFail(); + + Product::factory() + ->for($store) + ->withDefaultVariant(1999) + ->create([ + 'title' => 'Blue Viscose Search Shirt', + 'handle' => 'blue-viscose-search-shirt', + 'vendor' => 'Search Vendor', + 'tags' => ['viscose', 'blue'], + ]); + + Product::factory() + ->for($otherStore) + ->withDefaultVariant(1999) + ->create([ + 'title' => 'Blue Viscose Search Shirt Deluxe', + 'handle' => 'blue-viscose-search-shirt-deluxe', + 'vendor' => 'Other Vendor', + 'tags' => ['viscose'], + ]); + + $results = app(SearchService::class)->search($store, 'viscose search', [], 12); + + expect($results->total())->toBe(1) + ->and($results->getCollection()->first()->title)->toBe('Blue Viscose Search Shirt'); + + $queryLog = SearchQuery::withoutGlobalScopes()->where('store_id', $store->getKey())->latest('id')->firstOrFail(); + + expect($queryLog->query)->toBe('viscose search') + ->and($queryLog->results_count)->toBe(1); +}); + +test('search service keeps the FTS index synchronized through product observer events', function (): void { + $store = searchServiceStore(); + + $product = Product::factory() + ->for($store) + ->withDefaultVariant(1599) + ->create([ + 'title' => 'Copper Index Sync Jacket', + 'handle' => 'copper-index-sync-jacket', + ]); + + expect(app(SearchService::class)->search($store, 'copper sync', [], 12)->total())->toBe(1); + + $product->update(['title' => 'Graphite Index Sync Jacket']); + + expect(app(SearchService::class)->search($store, 'copper sync', [], 12)->total())->toBe(0) + ->and(app(SearchService::class)->search($store, 'graphite sync', [], 12)->total())->toBe(1); + + $product->delete(); + + expect(DB::table('products_fts')->where('product_id', $product->getKey())->exists())->toBeFalse(); +}); + +test('search service paginates unique matches', function (): void { + $store = searchServiceStore(); + + foreach (range(1, 25) as $index) { + Product::factory() + ->for($store) + ->withDefaultVariant(1000 + $index) + ->create([ + 'title' => "Pagination Merino Search {$index}", + 'handle' => "pagination-merino-search-{$index}", + ]); + } + + request()->query->set('page', 3); + + $results = app(SearchService::class)->search($store, 'pagination merino', [], 12); + + expect($results->total())->toBe(25) + ->and($results->currentPage())->toBe(3) + ->and($results->getCollection())->toHaveCount(1); +}); + +test('search service expands configured synonyms', function (): void { + $store = searchServiceStore(); + + SearchSettings::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->getKey()], + [ + 'synonyms_json' => [['tee', 'tshirt']], + 'stop_words_json' => [], + ], + ); + + Product::factory() + ->for($store) + ->withDefaultVariant(1999) + ->create([ + 'title' => 'Emerald Tee Synonym Match', + 'handle' => 'emerald-tee-synonym-match', + ]); + + $results = app(SearchService::class)->search($store, 'tshirt synonym', [], 12); + + expect($results->total())->toBe(1) + ->and($results->getCollection()->first()->title)->toBe('Emerald Tee Synonym Match'); +}); + +test('search service removes configured stop words before matching', function (): void { + $store = searchServiceStore(); + + SearchSettings::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->getKey()], + [ + 'synonyms_json' => [], + 'stop_words_json' => ['the', 'for'], + ], + ); + + Product::factory() + ->for($store) + ->withDefaultVariant(1999) + ->create([ + 'title' => 'Stopword Linen Token', + 'handle' => 'stopword-linen-token', + ]); + + $results = app(SearchService::class)->search($store, 'the stopword', [], 12); + + expect($results->total())->toBe(1) + ->and($results->getCollection()->first()->title)->toBe('Stopword Linen Token'); +}); diff --git a/tests/Feature/Security/HtmlSanitizationTest.php b/tests/Feature/Security/HtmlSanitizationTest.php new file mode 100644 index 00000000..3d50128c --- /dev/null +++ b/tests/Feature/Security/HtmlSanitizationTest.php @@ -0,0 +1,316 @@ +withoutVite(); +}); + +function htmlSanitizationAdminUser(Store $store): User +{ + $user = User::factory()->create(); + $user->stores()->attach($store->getKey(), [ + 'role' => StoreUserRole::Admin->value, + ]); + + return $user; +} + +function htmlSanitizationUnsafeHtml(string $heading): string +{ + return implode('', [ + "

{$heading}

", + '

Intro safe copy underlined size

', + '
  • One
', + '
Quoted
', + '
FitRegular
', + 'Tee', + '', + '', + '
Unwrapped
', + 'Plain span', + '

', + 'bad', + 'unsafe link', + ]); +} + +function expectSanitizedRichHtml(string $html, string $heading): void +{ + expect($html) + ->toContain("

{$heading}

") + ->toContain('safe') + ->toContain('copy') + ->toContain('underlined') + ->toContain('size') + ->toContain('
  • One
') + ->toContain('
Quoted
') + ->toContain('Fit') + ->toContain('Regular') + ->toContain('src="/images/tee.jpg"') + ->toContain('alt="Tee"') + ->toContain('Unwrapped') + ->toContain('Plain span') + ->not->toContain('not->toContain('alert(1)') + ->not->toContain('onclick') + ->not->toContain('onerror') + ->not->toContain('style=') + ->not->toContain('target=') + ->not->toContain('cite=') + ->not->toContain('scope=') + ->not->toContain('data-extra') + ->not->toContain('not->toContain('not->toContain('javascript:') + ->not->toContain('

'); +} + +test('sanitize html action applies the security allowlist', function (): void { + $sanitized = app(SanitizeHtml::class)(htmlSanitizationUnsafeHtml('Allowlist Details')); + + expect($sanitized)->toBeString(); + expectSanitizedRichHtml($sanitized, 'Allowlist Details'); +}); + +test('admin product form sanitizes description html on create and update', function (): void { + $store = Store::factory()->create(); + $user = htmlSanitizationAdminUser($store); + + app()->instance('current_store', $store); + + Livewire::actingAs($user) + ->test(ProductForm::class) + ->set('title', 'Sanitized UI Product') + ->set('handle', 'sanitized-ui-product') + ->set('descriptionHtml', htmlSanitizationUnsafeHtml('Product UI Create Details')) + ->set('variants.0.price', '19.99') + ->set('variants.0.quantity', 5) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', 'sanitized-ui-product') + ->firstOrFail(); + + expectSanitizedRichHtml((string) $product->description_html, 'Product UI Create Details'); + + Livewire::actingAs($user) + ->test(ProductForm::class, ['product' => $product]) + ->set('descriptionHtml', htmlSanitizationUnsafeHtml('Product UI Update Details')) + ->call('save') + ->assertHasNoErrors(); + + expectSanitizedRichHtml((string) $product->refresh()->description_html, 'Product UI Update Details'); +}); + +test('admin page form sanitizes body html on create and update', function (): void { + $store = Store::factory()->create(); + $user = htmlSanitizationAdminUser($store); + + app()->instance('current_store', $store); + + Livewire::actingAs($user) + ->test(PageForm::class) + ->set('title', 'Sanitized UI Page') + ->set('handle', 'sanitized-ui-page') + ->set('bodyHtml', htmlSanitizationUnsafeHtml('Page UI Create Details')) + ->set('status', PageStatus::Published->value) + ->call('save') + ->assertHasNoErrors(); + + $page = Page::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', 'sanitized-ui-page') + ->firstOrFail(); + + expectSanitizedRichHtml((string) $page->body_html, 'Page UI Create Details'); + + Livewire::actingAs($user) + ->test(PageForm::class, ['page' => $page]) + ->set('bodyHtml', htmlSanitizationUnsafeHtml('Page UI Update Details')) + ->call('save') + ->assertHasNoErrors(); + + expectSanitizedRichHtml((string) $page->refresh()->body_html, 'Page UI Update Details'); +}); + +test('admin collection form sanitizes description html on create and update', function (): void { + $store = Store::factory()->create(); + $user = htmlSanitizationAdminUser($store); + + app()->instance('current_store', $store); + + Livewire::actingAs($user) + ->test(CollectionForm::class) + ->set('title', 'Sanitized UI Collection') + ->set('handle', 'sanitized-ui-collection') + ->set('descriptionHtml', htmlSanitizationUnsafeHtml('Collection UI Create Details')) + ->call('save') + ->assertHasNoErrors(); + + $collection = Collection::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', 'sanitized-ui-collection') + ->firstOrFail(); + + expectSanitizedRichHtml((string) $collection->description_html, 'Collection UI Create Details'); + + Livewire::actingAs($user) + ->test(CollectionForm::class, ['collection' => $collection]) + ->set('descriptionHtml', htmlSanitizationUnsafeHtml('Collection UI Update Details')) + ->call('save') + ->assertHasNoErrors(); + + expectSanitizedRichHtml((string) $collection->refresh()->description_html, 'Collection UI Update Details'); +}); + +test('admin product api sanitizes description html on create and update', function (): void { + $store = Store::factory()->create(); + $user = htmlSanitizationAdminUser($store); + + $createResponse = $this->withToken(adminApiBearerToken($store, ['write-products'], $user)) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/products", [ + 'title' => 'Sanitized API Product', + 'handle' => 'sanitized-api-product', + 'description_html' => htmlSanitizationUnsafeHtml('Product API Create Details'), + 'status' => 'active', + 'variants' => [ + [ + 'sku' => 'SANITIZED-API-1', + 'price_amount' => 1999, + 'is_default' => true, + ], + ], + ]) + ->assertCreated(); + + expectSanitizedRichHtml($createResponse->json('data.description_html'), 'Product API Create Details'); + + $product = Product::withoutGlobalScopes()->findOrFail($createResponse->json('data.id')); + + expectSanitizedRichHtml((string) $product->description_html, 'Product API Create Details'); + + $updateResponse = $this->withToken(adminApiBearerToken($store, ['write-products'], $user)) + ->putJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}", [ + 'description_html' => htmlSanitizationUnsafeHtml('Product API Update Details'), + ]) + ->assertOk(); + + expectSanitizedRichHtml($updateResponse->json('data.description_html'), 'Product API Update Details'); + expectSanitizedRichHtml((string) $product->refresh()->description_html, 'Product API Update Details'); +}); + +test('admin page api sanitizes body html on create and update', function (): void { + $store = Store::factory()->create(); + $user = htmlSanitizationAdminUser($store); + + $createResponse = $this->withToken(adminApiBearerToken($store, ['write-content'], $user)) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/pages", [ + 'title' => 'Sanitized API Page', + 'body_html' => htmlSanitizationUnsafeHtml('Page API Create Details'), + 'status' => 'published', + ]) + ->assertCreated(); + + expectSanitizedRichHtml($createResponse->json('data.body_html'), 'Page API Create Details'); + + $page = Page::withoutGlobalScopes()->findOrFail($createResponse->json('data.id')); + + expectSanitizedRichHtml((string) $page->body_html, 'Page API Create Details'); + + $updateResponse = $this->withToken(adminApiBearerToken($store, ['write-content'], $user)) + ->putJson("/api/admin/v1/stores/{$store->getKey()}/pages/{$page->getKey()}", [ + 'body_html' => htmlSanitizationUnsafeHtml('Page API Update Details'), + ]) + ->assertOk(); + + expectSanitizedRichHtml($updateResponse->json('data.body_html'), 'Page API Update Details'); + expectSanitizedRichHtml((string) $page->refresh()->body_html, 'Page API Update Details'); +}); + +test('admin collection api sanitizes description html on create and update', function (): void { + $store = Store::factory()->create(); + $user = htmlSanitizationAdminUser($store); + + $createResponse = $this->withToken(adminApiBearerToken($store, ['write-collections'], $user)) + ->postJson("/api/admin/v1/stores/{$store->getKey()}/collections", [ + 'title' => 'Sanitized API Collection', + 'description_html' => htmlSanitizationUnsafeHtml('Collection API Create Details'), + 'type' => 'manual', + 'status' => 'active', + ]) + ->assertCreated(); + + expectSanitizedRichHtml($createResponse->json('data.description_html'), 'Collection API Create Details'); + + $collection = Collection::withoutGlobalScopes()->findOrFail($createResponse->json('data.id')); + + expectSanitizedRichHtml((string) $collection->description_html, 'Collection API Create Details'); + + $updateResponse = $this->withToken(adminApiBearerToken($store, ['write-collections'], $user)) + ->putJson("/api/admin/v1/stores/{$store->getKey()}/collections/{$collection->getKey()}", [ + 'description_html' => htmlSanitizationUnsafeHtml('Collection API Update Details'), + ]) + ->assertOk(); + + expectSanitizedRichHtml($updateResponse->json('data.description_html'), 'Collection API Update Details'); + expectSanitizedRichHtml((string) $collection->refresh()->description_html, 'Collection API Update Details'); +}); + +test('admin Livewire product and collection forms ignore cross store pivot ids', function (): void { + $store = Store::factory()->create(); + $otherStore = Store::factory()->create(); + $user = htmlSanitizationAdminUser($store); + $otherCollection = Collection::factory()->create(['store_id' => $otherStore->getKey()]); + $otherProduct = Product::factory()->withDefaultVariant()->create(['store_id' => $otherStore->getKey()]); + + app()->instance('current_store', $store); + + Livewire::actingAs($user) + ->test(ProductForm::class) + ->set('title', 'Tenant Safe Product') + ->set('handle', 'tenant-safe-product') + ->set('collectionIds', [$otherCollection->getKey()]) + ->set('variants.0.price', '19.99') + ->set('variants.0.quantity', 5) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', 'tenant-safe-product') + ->firstOrFail(); + + expect($product->collections()->withoutGlobalScopes()->count())->toBe(0); + + Livewire::actingAs($user) + ->test(CollectionForm::class) + ->set('title', 'Tenant Safe Collection') + ->set('handle', 'tenant-safe-collection') + ->set('assignedProductIds', [$otherProduct->getKey()]) + ->call('save') + ->assertHasNoErrors(); + + $collection = Collection::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', 'tenant-safe-collection') + ->firstOrFail(); + + expect($collection->products()->withoutGlobalScopes()->count())->toBe(0); +}); diff --git a/tests/Feature/Seeders/SeededOrderDataTest.php b/tests/Feature/Seeders/SeededOrderDataTest.php new file mode 100644 index 00000000..34c89d68 --- /dev/null +++ b/tests/Feature/Seeders/SeededOrderDataTest.php @@ -0,0 +1,105 @@ +seed(DatabaseSeeder::class); +}); + +function seededOrderStore(string $handle): Store +{ + return Store::query()->where('handle', $handle)->firstOrFail(); +} + +function seededOrder(Store $store, string $orderNumber): Order +{ + return Order::withoutGlobalScopes() + ->with(['lines.product', 'payments', 'fulfillments.lines.orderLine.product', 'refunds']) + ->where('store_id', $store->getKey()) + ->where('order_number', $orderNumber) + ->firstOrFail(); +} + +test('database seeder creates deterministic customer and order scenarios', function (): void { + $fashion = seededOrderStore('acme-fashion'); + $electronics = seededOrderStore('acme-electronics'); + + expect(Customer::withoutGlobalScopes()->where('store_id', $fashion->getKey())->count())->toBe(10) + ->and(Customer::withoutGlobalScopes()->where('store_id', $electronics->getKey())->count())->toBe(2) + ->and(Order::withoutGlobalScopes()->where('store_id', $fashion->getKey())->count())->toBe(15) + ->and(Order::withoutGlobalScopes()->where('store_id', $electronics->getKey())->count())->toBe(3); + + $john = Customer::withoutGlobalScopes() + ->where('store_id', $fashion->getKey()) + ->where('email', 'customer@acme.test') + ->firstOrFail(); + $jane = Customer::withoutGlobalScopes() + ->where('store_id', $fashion->getKey()) + ->where('email', 'jane@example.com') + ->firstOrFail(); + + expect($john->addresses()->count())->toBe(2) + ->and($jane->addresses()->count())->toBe(1); + + $awaitingFulfillment = seededOrder($fashion, '#1001'); + + expect($awaitingFulfillment->status)->toBe(OrderStatus::Paid) + ->and($awaitingFulfillment->financial_status)->toBe(FinancialStatus::Paid) + ->and($awaitingFulfillment->fulfillment_status)->toBe(FulfillmentStatus::Unfulfilled) + ->and($awaitingFulfillment->total_amount)->toBe(5497) + ->and($awaitingFulfillment->lines)->toHaveCount(1) + ->and($awaitingFulfillment->lines->first()->title_snapshot)->toContain('Classic Cotton T-Shirt') + ->and($awaitingFulfillment->lines->first()->quantity)->toBe(2) + ->and($awaitingFulfillment->payments->first()->status)->toBe(PaymentStatus::Captured); + + $delivered = seededOrder($fashion, '#1002'); + + expect($delivered->fulfillments)->toHaveCount(1) + ->and($delivered->fulfillments->first()->status)->toBe(FulfillmentShipmentStatus::Delivered) + ->and($delivered->fulfillments->first()->lines)->toHaveCount(2); + + $refunded = seededOrder($fashion, '#1004'); + + expect($refunded->status)->toBe(OrderStatus::Cancelled) + ->and($refunded->financial_status)->toBe(FinancialStatus::Refunded) + ->and($refunded->payments->first()->status)->toBe(PaymentStatus::Refunded) + ->and($refunded->refunds->first()->status)->toBe(RefundStatus::Processed) + ->and($refunded->refunds->first()->amount)->toBe(2998); + + $discounted = seededOrder($fashion, '#1015'); + $allocations = $discounted->lines->pluck('discount_allocations_json')->flatten(1); + + expect($discounted->discount_amount)->toBe(550) + ->and($allocations)->toHaveCount(2) + ->and($allocations->pluck('code')->all())->toBe(['WELCOME10', 'WELCOME10']) + ->and($allocations->sum('amount'))->toBe(550); + + $electronicsOrder = seededOrder($electronics, '#5001'); + + expect($electronicsOrder->total_amount)->toBe(121298) + ->and($electronicsOrder->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled) + ->and($electronicsOrder->lines)->toHaveCount(2); + + $bankTransfer = seededOrder($fashion, '#1005'); + $bankTransferLine = $bankTransfer->lines->firstOrFail(); + $inventory = InventoryItem::withoutGlobalScopes() + ->where('variant_id', $bankTransferLine->variant_id) + ->firstOrFail(); + + expect($bankTransfer->financial_status)->toBe(FinancialStatus::Pending) + ->and($bankTransfer->payments->first()->status)->toBe(PaymentStatus::Pending) + ->and($inventory->quantity_reserved)->toBe($bankTransferLine->quantity); +}); diff --git a/tests/Feature/Storefront/CartCheckoutUiTest.php b/tests/Feature/Storefront/CartCheckoutUiTest.php new file mode 100644 index 00000000..b39b43d8 --- /dev/null +++ b/tests/Feature/Storefront/CartCheckoutUiTest.php @@ -0,0 +1,207 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function storefrontUiStore(): Store +{ + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + app()->instance('current_store', $store); + + return $store; +} + +function storefrontUiVariant(Store $store): ProductVariant +{ + $product = Product::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', 'classic-cotton-t-shirt') + ->firstOrFail(); + + return ProductVariant::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->oldest('position') + ->firstOrFail(); +} + +test('product detail add to cart persists a session cart', function () { + $store = storefrontUiStore(); + $variant = storefrontUiVariant($store); + + Livewire::test(ProductShow::class, ['handle' => 'classic-cotton-t-shirt']) + ->set('quantity', 2) + ->call('addToCart') + ->assertDispatched('cart-updated'); + + $cart = Cart::withoutGlobalScopes()->findOrFail(session('cart_id')); + + expect($cart->store_id)->toBe($store->getKey()) + ->and($cart->lines()->withoutGlobalScopes()->where('variant_id', $variant->getKey())->first()?->quantity)->toBe(2); +}); + +test('cart page updates and removes line quantities', function () { + $store = storefrontUiStore(); + $variant = storefrontUiVariant($store); + $cart = app(CartService::class)->create($store); + session(['cart_id' => $cart->getKey()]); + $line = app(CartService::class)->addLine($cart, $variant->getKey(), 2); + + Livewire::test(CartShow::class) + ->assertSee('Classic Cotton T-Shirt') + ->call('increaseQuantity', $line->getKey()) + ->assertDispatched('cart-updated'); + + expect($line->refresh()->quantity)->toBe(3); + + Livewire::test(CartShow::class) + ->call('removeLine', $line->getKey()) + ->assertDispatched('cart-updated'); + + expect($cart->lines()->withoutGlobalScopes()->count())->toBe(0); +}); + +test('cart page shows a stock message when quantity exceeds deny-policy inventory', function () { + $store = storefrontUiStore(); + $variant = storefrontUiVariant($store); + $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail(); + $inventory->forceFill([ + 'quantity_on_hand' => 2, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ])->save(); + + $cart = app(CartService::class)->create($store); + session(['cart_id' => $cart->getKey()]); + $line = app(CartService::class)->addLine($cart, $variant->getKey(), 2); + + Livewire::test(CartShow::class) + ->call('increaseQuantity', $line->getKey()) + ->assertSee('Only 2 units are available; 3 requested.'); + + expect($line->refresh()->quantity)->toBe(2); +}); + +test('cart page applies discount estimates shipping and carries discount into checkout', function () { + $store = storefrontUiStore(); + $variant = storefrontUiVariant($store); + $cart = app(CartService::class)->create($store); + session(['cart_id' => $cart->getKey()]); + app(CartService::class)->addLine($cart, $variant->getKey(), 2); + + $component = Livewire::test(CartShow::class) + ->set('discountCode', 'SAVE10') + ->call('applyDiscount') + ->assertHasNoErrors() + ->assertSet('appliedDiscountCode', 'SAVE10') + ->set('shippingCountry', 'DE') + ->set('shippingPostalCode', '10115') + ->call('estimateShipping') + ->assertHasNoErrors() + ->assertSee('Standard Shipping'); + + expect(session('cart_discount_code'))->toBe('SAVE10') + ->and($component->instance()->discountAmount())->toBe(500) + ->and($component->instance()->estimatedShippingAmount())->toBe(499) + ->and($component->instance()->estimatedTotal())->toBe(4997); + + $checkout = app(CheckoutService::class)->createFromCart($cart); + + Livewire::test(CheckoutShow::class, ['checkout' => $checkout]) + ->assertSet('discountCode', 'SAVE10'); + + $checkout->refresh(); + + expect($checkout->discount_code)->toBe('SAVE10') + ->and($checkout->totals_json['discount'])->toBe(500); +}); + +test('customer login merges an existing guest cart without creating empty carts', function () { + $store = storefrontUiStore(); + $variant = storefrontUiVariant($store); + $customer = Customer::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('email', 'customer@acme.test') + ->firstOrFail(); + $customerCart = app(CartService::class)->create($store, $customer); + $guestCart = app(CartService::class)->create($store); + session(['cart_id' => $guestCart->getKey()]); + + app(CartService::class)->addLine($customerCart, $variant->getKey(), 1); + app(CartService::class)->addLine($guestCart, $variant->getKey(), 2); + + Livewire::test(CustomerLogin::class) + ->set('email', 'customer@acme.test') + ->set('password', 'password') + ->call('login'); + + expect($guestCart->refresh()->status)->toBe(CartStatus::Abandoned) + ->and($customerCart->refresh()->lines()->withoutGlobalScopes()->where('variant_id', $variant->getKey())->first()?->quantity)->toBe(2) + ->and(Cart::withoutGlobalScopes()->where('store_id', $store->getKey())->count())->toBe(2); +}); + +test('checkout page progresses through address shipping discount and payment selection', function () { + $store = storefrontUiStore(); + $variant = storefrontUiVariant($store); + $cart = app(CartService::class)->create($store); + session(['cart_id' => $cart->getKey()]); + app(CartService::class)->addLine($cart, $variant->getKey(), 2); + $checkout = app(CheckoutService::class)->createFromCart($cart); + + $rate = ShippingRate::withoutGlobalScopes() + ->whereHas('zone', fn ($query) => $query->withoutGlobalScopes()->where('store_id', $store->getKey())) + ->where('name', 'Standard Shipping') + ->firstOrFail(); + + Livewire::test(CheckoutShow::class, ['checkout' => $checkout]) + ->set('email', 'buyer@example.test') + ->set('shippingAddress.first_name', 'Test') + ->set('shippingAddress.last_name', 'Buyer') + ->set('shippingAddress.address1', 'Main Street 1') + ->set('shippingAddress.city', 'Berlin') + ->set('shippingAddress.country', 'DE') + ->set('shippingAddress.postal_code', '10115') + ->call('saveAddress') + ->assertSet('step', 'shipping') + ->set('selectedShippingRateId', $rate->getKey()) + ->call('selectShippingMethod') + ->assertSet('step', 'payment') + ->set('discountCode', 'SAVE10') + ->call('applyDiscount') + ->assertHasNoErrors() + ->set('paymentMethod', 'credit_card') + ->call('selectPaymentMethod') + ->assertSet('step', 'reserved'); + + $checkout = Checkout::withoutGlobalScopes()->where('cart_id', $cart->getKey())->firstOrFail(); + $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail(); + + expect($checkout->status)->toBe(CheckoutStatus::PaymentSelected) + ->and($checkout->discount_code)->toBe('SAVE10') + ->and($checkout->totals_json['discount'])->toBeGreaterThan(0) + ->and($inventory->quantity_reserved)->toBe(2); +}); diff --git a/tests/Feature/Storefront/CustomerAccountTest.php b/tests/Feature/Storefront/CustomerAccountTest.php new file mode 100644 index 00000000..23f09f4d --- /dev/null +++ b/tests/Feature/Storefront/CustomerAccountTest.php @@ -0,0 +1,158 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function customerAccountStore(): Store +{ + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + + app()->instance('current_store', $store); + + return $store; +} + +function customerAccountCustomer(): Customer +{ + $store = customerAccountStore(); + + return Customer::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('email', 'customer@acme.test') + ->firstOrFail(); +} + +test('customer dashboard renders the account overview', function (): void { + $customer = customerAccountCustomer(); + + $this->actingAs($customer, 'customer') + ->get('http://shop.test/account') + ->assertOk() + ->assertSee('My Account') + ->assertSee('John Doe') + ->assertSee('Recent Orders'); +}); + +test('unauthenticated customers are redirected to login', function (): void { + $this->get('http://shop.test/account') + ->assertRedirect('http://shop.test/account/login'); +}); + +test('customer order history and detail are available through account routes', function (): void { + $store = customerAccountStore(); + $customer = customerAccountCustomer(); + $order = Order::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('customer_id', $customer->getKey()) + ->where('order_number', '#1001') + ->firstOrFail(); + + $this->actingAs($customer, 'customer') + ->get('http://shop.test/account/orders') + ->assertOk() + ->assertSee('#1001') + ->assertSee('#1002') + ->assertSee('#1004'); + + $this->actingAs($customer, 'customer') + ->get('http://shop.test/account/orders/'.$order->getKey()) + ->assertOk() + ->assertSee('#1001') + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('Subtotal') + ->assertSee('Total'); +}); + +test('customer can add and update addresses', function (): void { + $customer = customerAccountCustomer(); + + $this->actingAs($customer, 'customer'); + + Livewire::test(AccountAddressesIndex::class) + ->assertSee('Hauptstrasse 1') + ->call('openAddressForm') + ->set('addressLabel', 'Office') + ->set('address.first_name', 'John') + ->set('address.last_name', 'Doe') + ->set('address.address1', 'New Street 42') + ->set('address.city', 'Hamburg') + ->set('address.postal_code', '20095') + ->set('address.country', 'DE') + ->call('saveAddress') + ->assertSee('Address saved') + ->assertSee('New Street 42') + ->assertSee('Hamburg'); + + $address = CustomerAddress::query() + ->where('customer_id', $customer->getKey()) + ->where('label', 'Office') + ->firstOrFail(); + + expect(data_get($address->address_json, 'city'))->toBe('Hamburg'); + + Livewire::test(AccountAddressesIndex::class) + ->call('openAddressForm', $address->getKey()) + ->set('address.city', 'Frankfurt') + ->call('saveAddress') + ->assertSee('Address saved') + ->assertSee('Frankfurt'); + + expect(data_get($address->refresh()->address_json, 'city'))->toBe('Frankfurt'); +}); + +test('customer can set a default address and delete an address', function (): void { + $customer = customerAccountCustomer(); + $homeAddress = CustomerAddress::query() + ->where('customer_id', $customer->getKey()) + ->where('label', 'Home') + ->firstOrFail(); + $officeAddress = CustomerAddress::factory()->create([ + 'customer_id' => $customer->getKey(), + 'label' => 'Office', + 'address_json' => [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'address1' => 'Office Street 10', + 'address2' => null, + 'city' => 'Munich', + 'province_code' => null, + 'country' => 'DE', + 'postal_code' => '80331', + ], + 'is_default' => false, + ]); + + $this->actingAs($customer, 'customer'); + + Livewire::test(AccountAddressesIndex::class) + ->call('setDefaultAddress', $officeAddress->getKey()) + ->assertSee('Default address updated') + ->call('deleteAddress', $officeAddress->getKey()) + ->assertSee('Address deleted'); + + expect(CustomerAddress::query()->whereKey($officeAddress->getKey())->exists())->toBeFalse() + ->and($homeAddress->refresh()->is_default)->toBeTrue(); +}); + +test('customer logout clears the customer guard', function (): void { + $customer = customerAccountCustomer(); + + $this->actingAs($customer, 'customer') + ->post('http://shop.test/account/logout') + ->assertRedirect('http://shop.test/account/login'); + + $this->assertGuest('customer'); +}); diff --git a/tests/Feature/Storefront/OrderViewsTest.php b/tests/Feature/Storefront/OrderViewsTest.php new file mode 100644 index 00000000..50fcc866 --- /dev/null +++ b/tests/Feature/Storefront/OrderViewsTest.php @@ -0,0 +1,205 @@ +withoutVite(); + $this->seed(DatabaseSeeder::class); +}); + +function orderViewsStore(): Store +{ + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + app()->instance('current_store', $store); + + return $store; +} + +function orderViewsVariant(Store $store): ProductVariant +{ + $product = Product::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', 'classic-cotton-t-shirt') + ->firstOrFail(); + + return ProductVariant::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->oldest('position') + ->firstOrFail(); +} + +function orderViewsShippingRate(Store $store): ShippingRate +{ + return ShippingRate::withoutGlobalScopes() + ->whereHas('zone', fn ($query) => $query->withoutGlobalScopes()->where('store_id', $store->getKey())) + ->where('name', 'Standard Shipping') + ->firstOrFail(); +} + +test('checkout page places an order and redirects to confirmation', function () { + $store = orderViewsStore(); + $variant = orderViewsVariant($store); + $cart = app(CartService::class)->create($store); + session(['cart_id' => $cart->getKey()]); + app(CartService::class)->addLine($cart, $variant->getKey(), 2); + $checkout = app(CheckoutService::class)->createFromCart($cart); + + $component = Livewire::test(CheckoutShow::class, ['checkout' => $checkout]) + ->set('email', 'buyer@example.test') + ->set('shippingAddress.first_name', 'Test') + ->set('shippingAddress.last_name', 'Buyer') + ->set('shippingAddress.address1', 'Main Street 1') + ->set('shippingAddress.city', 'Berlin') + ->set('shippingAddress.country', 'DE') + ->set('shippingAddress.postal_code', '10115') + ->call('saveAddress') + ->set('selectedShippingRateId', orderViewsShippingRate($store)->getKey()) + ->call('selectShippingMethod') + ->set('paymentMethod', 'credit_card') + ->call('selectPaymentMethod') + ->set('cardNumber', '4242 4242 4242 4242') + ->call('placeOrder'); + + $order = Order::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('email', 'buyer@example.test') + ->latest('id') + ->firstOrFail(); + + $component->assertRedirect(route('checkout.confirmation', ['checkout' => $checkout->getKey()])); + + expect($order->email)->toBe('buyer@example.test') + ->and($order->lines)->toHaveCount(1) + ->and($cart->refresh()->status)->toBe(CartStatus::Converted) + ->and(session('last_order_id'))->toBe($order->getKey()) + ->and(session('cart_id'))->toBeNull(); +}); + +test('checkout page surfaces payment failures and releases reservations', function () { + $store = orderViewsStore(); + $variant = orderViewsVariant($store); + $ordersCount = Order::withoutGlobalScopes()->count(); + $cart = app(CartService::class)->create($store); + session(['cart_id' => $cart->getKey()]); + app(CartService::class)->addLine($cart, $variant->getKey(), 2); + $checkout = app(CheckoutService::class)->createFromCart($cart); + + Livewire::test(CheckoutShow::class, ['checkout' => $checkout]) + ->set('email', 'buyer@example.test') + ->set('shippingAddress.first_name', 'Test') + ->set('shippingAddress.last_name', 'Buyer') + ->set('shippingAddress.address1', 'Main Street 1') + ->set('shippingAddress.city', 'Berlin') + ->set('shippingAddress.country', 'DE') + ->set('shippingAddress.postal_code', '10115') + ->call('saveAddress') + ->set('selectedShippingRateId', orderViewsShippingRate($store)->getKey()) + ->call('selectShippingMethod') + ->set('paymentMethod', 'credit_card') + ->call('selectPaymentMethod') + ->set('cardNumber', '4000 0000 0000 0002') + ->call('placeOrder') + ->assertHasErrors('cardNumber') + ->assertSet('step', 'payment'); + + $checkout = Checkout::withoutGlobalScopes()->where('cart_id', $cart->getKey())->firstOrFail(); + $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail(); + + expect(Order::withoutGlobalScopes()->count())->toBe($ordersCount) + ->and(Order::withoutGlobalScopes()->where('email', 'buyer@example.test')->exists())->toBeFalse() + ->and($checkout->status)->toBe(CheckoutStatus::ShippingSelected) + ->and($inventory->quantity_reserved)->toBe(0); +}); + +test('order confirmation is visible only for the session order or customer order', function () { + $store = orderViewsStore(); + $customer = Customer::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail(); + $otherCustomer = Customer::factory()->create(['store_id' => $store->getKey()]); + $cart = app(CartService::class)->create($store, $customer); + $checkout = Checkout::factory()->forCustomer($customer)->create([ + 'store_id' => $store->getKey(), + 'cart_id' => $cart->getKey(), + 'status' => CheckoutStatus::Completed, + ]); + $order = Order::factory()->forCustomer($customer)->paid()->create([ + 'store_id' => $store->getKey(), + 'checkout_id' => $checkout->getKey(), + 'customer_id' => $customer->getKey(), + 'order_number' => '#7770', + 'email' => $customer->email, + ]); + + session(['last_order_id' => $order->getKey()]); + + Livewire::test(Confirmation::class, ['checkout' => $checkout]) + ->assertSee($order->order_number); + + session()->forget('last_order_id'); + + Livewire::test(Confirmation::class, ['checkout' => $checkout]) + ->assertStatus(404); + + $this->actingAs($otherCustomer, 'customer'); + + Livewire::test(Confirmation::class, ['checkout' => $checkout]) + ->assertStatus(404); + + $this->actingAs($customer, 'customer'); + + Livewire::test(Confirmation::class, ['checkout' => $checkout]) + ->assertSee($order->order_number); +}); + +test('customer account lists and shows customer orders', function () { + $store = orderViewsStore(); + $customer = Customer::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail(); + $otherCustomer = Customer::factory()->create(['store_id' => $store->getKey()]); + $order = Order::factory()->forCustomer($customer)->paid()->create([ + 'store_id' => $store->getKey(), + 'customer_id' => $customer->getKey(), + 'order_number' => '#7777', + ]); + $otherOrder = Order::factory()->forCustomer($otherCustomer)->paid()->create([ + 'store_id' => $store->getKey(), + 'customer_id' => $otherCustomer->getKey(), + 'order_number' => '#8888', + ]); + OrderLine::factory()->create([ + 'order_id' => $order->getKey(), + 'title_snapshot' => 'Classic Cotton T-Shirt', + ]); + + $this->actingAs($customer, 'customer'); + + Livewire::test(AccountOrdersIndex::class) + ->assertSee('#7777') + ->assertDontSee('#8888'); + + Livewire::test(AccountOrderShow::class, ['order' => $order]) + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('#7777'); + + Livewire::test(AccountOrderShow::class, ['order' => $otherOrder]) + ->assertStatus(404); +}); diff --git a/tests/Feature/Storefront/ThemeDataTest.php b/tests/Feature/Storefront/ThemeDataTest.php new file mode 100644 index 00000000..8894fab1 --- /dev/null +++ b/tests/Feature/Storefront/ThemeDataTest.php @@ -0,0 +1,136 @@ +seed(DatabaseSeeder::class); + + expect(Schema::hasColumns('themes', ['store_id', 'name', 'version', 'status', 'published_at']))->toBeTrue() + ->and(Schema::hasColumns('theme_files', ['theme_id', 'path', 'storage_key', 'sha256', 'byte_size']))->toBeTrue() + ->and(Schema::hasColumns('theme_settings', ['theme_id', 'settings_json', 'updated_at']))->toBeTrue() + ->and(Schema::hasColumns('pages', ['store_id', 'title', 'handle', 'body_html', 'status', 'published_at']))->toBeTrue() + ->and(Schema::hasColumns('navigation_menus', ['store_id', 'handle', 'title']))->toBeTrue() + ->and(Schema::hasColumns('navigation_items', ['menu_id', 'parent_id', 'type', 'label', 'url', 'resource_id', 'position']))->toBeTrue() + ->and(Theme::withoutGlobalScopes()->count())->toBe(2) + ->and(ThemeFile::withoutGlobalScopes()->count())->toBe(6) + ->and(ThemeSettings::withoutGlobalScopes()->count())->toBe(2) + ->and(Page::withoutGlobalScopes()->count())->toBe(6) + ->and(NavigationMenu::withoutGlobalScopes()->count())->toBe(4) + ->and(NavigationItem::withoutGlobalScopes()->count())->toBeGreaterThanOrEqual(10); +}); + +test('theme page and navigation models are scoped to the resolved store', function () { + $firstStore = Store::factory()->create(); + $secondStore = Store::factory()->create(); + + Theme::factory()->published()->create(['store_id' => $firstStore->getKey(), 'name' => 'First Theme']); + Theme::factory()->published()->create(['store_id' => $secondStore->getKey(), 'name' => 'Second Theme']); + Page::factory()->published()->create(['store_id' => $firstStore->getKey(), 'handle' => 'first-page']); + Page::factory()->published()->create(['store_id' => $secondStore->getKey(), 'handle' => 'second-page']); + NavigationMenu::factory()->create(['store_id' => $firstStore->getKey(), 'handle' => 'first-menu']); + NavigationMenu::factory()->create(['store_id' => $secondStore->getKey(), 'handle' => 'second-menu']); + + app()->instance('current_store', $firstStore); + + expect(Theme::query()->pluck('name')->all())->toBe(['First Theme']) + ->and(Page::query()->pluck('handle')->all())->toBe(['first-page']) + ->and(NavigationMenu::query()->pluck('handle')->all())->toBe(['first-menu']); +}); + +test('theme and navigation child models are scoped through their parent store', function () { + $firstStore = Store::factory()->create(); + $secondStore = Store::factory()->create(); + $firstTheme = Theme::factory()->published()->create(['store_id' => $firstStore->getKey()]); + $secondTheme = Theme::factory()->published()->create(['store_id' => $secondStore->getKey()]); + $firstMenu = NavigationMenu::factory()->create(['store_id' => $firstStore->getKey()]); + $secondMenu = NavigationMenu::factory()->create(['store_id' => $secondStore->getKey()]); + + ThemeFile::factory()->create(['theme_id' => $firstTheme->getKey(), 'path' => 'first.blade.php']); + ThemeFile::factory()->create(['theme_id' => $secondTheme->getKey(), 'path' => 'second.blade.php']); + ThemeSettings::factory()->create(['theme_id' => $firstTheme->getKey()]); + ThemeSettings::factory()->create(['theme_id' => $secondTheme->getKey()]); + NavigationItem::factory()->create(['menu_id' => $firstMenu->getKey(), 'label' => 'First']); + NavigationItem::factory()->create(['menu_id' => $secondMenu->getKey(), 'label' => 'Second']); + + app()->instance('current_store', $firstStore); + + expect(ThemeFile::query()->pluck('path')->all())->toBe(['first.blade.php']) + ->and(ThemeSettings::query()->count())->toBe(1) + ->and(NavigationItem::query()->pluck('label')->all())->toBe(['First']); +}); + +test('theme settings service loads cached published settings with defaults', function () { + $this->seed(DatabaseSeeder::class); + + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $settings = app(ThemeSettingsService::class)->forStore($store); + + expect($settings['announcement']['text'])->toBe('Free shipping on orders over 75.00 EUR') + ->and($settings['home']['hero']['heading'])->toBe('Acme Fashion') + ->and(Cache::has("theme_settings:{$store->getKey()}"))->toBeTrue(); +}); + +test('navigation service resolves resource URLs for seeded menus', function () { + $this->seed(DatabaseSeeder::class); + + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $menu = NavigationMenu::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('handle', 'main-menu') + ->firstOrFail(); + + $items = app(NavigationService::class)->buildTree($menu); + $shop = collect($items)->firstWhere('label', 'Shop'); + $shopChildren = collect($shop['children']); + + expect(collect($items)->pluck('label')->all())->toContain('Home', 'Shop', 'About') + ->and($shop['url'])->toBe('/collections') + ->and($shopChildren->pluck('label')->all())->toContain('New Arrivals', 'T-Shirts') + ->and($shopChildren->firstWhere('label', 'New Arrivals')['url'])->toBe('/collections/new-arrivals') + ->and(collect($items)->firstWhere('label', 'About')['url'])->toBe('/pages/about'); +}); + +test('storefront pages render published database content only', function () { + $this->withoutVite(); + $this->seed(DatabaseSeeder::class); + + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + + Page::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + 'title' => 'Private Draft', + 'handle' => 'private-draft', + 'body_html' => '

This should not render.

', + 'status' => PageStatus::Draft, + 'published_at' => null, + ]); + + $this->withHeader('Host', 'shop.test') + ->get('/pages/about') + ->assertSuccessful() + ->assertSee('About') + ->assertSee('Acme Fashion is the demo storefront'); + + $this->withHeader('Host', 'shop.test') + ->get('/pages/private-draft') + ->assertNotFound(); +}); diff --git a/tests/Feature/Webhooks/WebhookDeliveryTest.php b/tests/Feature/Webhooks/WebhookDeliveryTest.php new file mode 100644 index 00000000..8a4e2d5a --- /dev/null +++ b/tests/Feature/Webhooks/WebhookDeliveryTest.php @@ -0,0 +1,301 @@ +create([ + 'store_id' => $store->getKey(), + 'event_type' => $eventType, + 'status' => WebhookSubscriptionStatus::Active, + ]); +} + +function expectQueuedWebhookDelivery(WebhookSubscription $subscription, WebhookEventType $eventType, callable $payloadMatches): void +{ + $delivery = $subscription->deliveries()->sole(); + + expect($delivery)->toBeInstanceOf(WebhookDelivery::class) + ->and($delivery->status)->toBe(WebhookDeliveryStatus::Pending) + ->and($delivery->attempt_count)->toBe(1); + + Queue::assertPushed(DeliverWebhook::class, function (DeliverWebhook $job) use ($delivery, $eventType, $payloadMatches): bool { + return $job->deliveryId === $delivery->getKey() + && $job->eventType === $eventType->value + && data_get($job->payload, 'id') === $delivery->event_id + && data_get($job->payload, 'event_type') === $eventType->value + && $payloadMatches($job->payload); + }); + Queue::assertPushed(DeliverWebhook::class, 1); +} + +function webhookCheckoutStore(): Store +{ + $store = Store::factory()->create(['default_currency' => 'EUR']); + app()->instance('current_store', $store); + + TaxSettings::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + 'mode' => 'manual', + 'provider' => 'none', + 'prices_include_tax' => false, + 'config_json' => ['default_rate_bps' => 0, 'shipping_taxable' => false], + ]); + + return $store; +} + +function webhookCheckoutVariant(Store $store): ProductVariant +{ + $product = Product::factory() + ->withDefaultVariant(2500) + ->create(['store_id' => $store->getKey()]); + + $variant = ProductVariant::withoutGlobalScopes() + ->where('product_id', $product->getKey()) + ->firstOrFail(); + + $variant->forceFill([ + 'requires_shipping' => false, + 'weight_g' => 0, + ])->save(); + + InventoryItem::withoutGlobalScopes() + ->where('variant_id', $variant->getKey()) + ->update([ + 'quantity_on_hand' => 10, + 'quantity_reserved' => 0, + ]); + + return $variant->refresh(); +} + +test('dispatch creates delivery records and queues matching active subscriptions', function (): void { + Queue::fake([DeliverWebhook::class]); + + $store = Store::factory()->create(); + $subscription = WebhookSubscription::factory()->create([ + 'store_id' => $store->getKey(), + 'event_type' => WebhookEventType::OrderCreated, + 'status' => WebhookSubscriptionStatus::Active, + ]); + WebhookSubscription::factory()->create([ + 'store_id' => $store->getKey(), + 'event_type' => WebhookEventType::ProductUpdated, + 'status' => WebhookSubscriptionStatus::Active, + ]); + + app(WebhookService::class)->dispatch($store, WebhookEventType::OrderCreated->value, [ + 'order' => ['id' => 10], + ]); + + Queue::assertPushed(DeliverWebhook::class, function (DeliverWebhook $job) use ($subscription): bool { + return $job->eventType === WebhookEventType::OrderCreated->value + && WebhookDelivery::query()->whereKey($job->deliveryId)->where('subscription_id', $subscription->getKey())->exists(); + }); + Queue::assertPushed(DeliverWebhook::class, 1); + + expect($subscription->deliveries()->count())->toBe(1); +}); + +test('product creation queues product created webhooks', function (): void { + Queue::fake([DeliverWebhook::class]); + + $store = Store::factory()->create(['default_currency' => 'EUR']); + app()->instance('current_store', $store); + $subscription = webhookActiveSubscription($store, WebhookEventType::ProductCreated); + + $product = app(ProductService::class)->create($store, [ + 'title' => 'Webhook Draft Product', + 'price_amount' => 1500, + ]); + + expectQueuedWebhookDelivery( + $subscription, + WebhookEventType::ProductCreated, + fn (array $payload): bool => data_get($payload, 'data.product.id') === $product->getKey() + && data_get($payload, 'data.product.title') === 'Webhook Draft Product', + ); +}); + +test('product updates queue product updated webhooks', function (): void { + Queue::fake([DeliverWebhook::class]); + + $store = Store::factory()->create(['default_currency' => 'EUR']); + app()->instance('current_store', $store); + $product = app(ProductService::class)->create($store, [ + 'title' => 'Original Product', + 'price_amount' => 1500, + ]); + $subscription = webhookActiveSubscription($store, WebhookEventType::ProductUpdated); + + $updated = app(ProductService::class)->update($product, [ + 'title' => 'Updated Product', + ]); + + expectQueuedWebhookDelivery( + $subscription, + WebhookEventType::ProductUpdated, + fn (array $payload): bool => data_get($payload, 'data.product.id') === $updated->getKey() + && data_get($payload, 'data.product.title') === 'Updated Product', + ); +}); + +test('product archive queues product deleted webhooks', function (): void { + Queue::fake([DeliverWebhook::class]); + + $store = Store::factory()->create(['default_currency' => 'EUR']); + app()->instance('current_store', $store); + $product = app(ProductService::class)->create($store, [ + 'title' => 'Archived Product', + 'price_amount' => 1500, + ]); + $subscription = webhookActiveSubscription($store, WebhookEventType::ProductDeleted); + + app(ProductService::class)->transitionStatus($product, \App\Enums\ProductStatus::Archived); + + expectQueuedWebhookDelivery( + $subscription, + WebhookEventType::ProductDeleted, + fn (array $payload): bool => data_get($payload, 'data.product.id') === $product->getKey() + && data_get($payload, 'data.product.status') === \App\Enums\ProductStatus::Archived->value, + ); +}); + +test('checkout completion queues checkout completed webhooks', function (): void { + Queue::fake([DeliverWebhook::class]); + + $store = webhookCheckoutStore(); + $variant = webhookCheckoutVariant($store); + $cart = app(CartService::class)->create($store); + app(CartService::class)->addLine($cart, $variant->getKey(), 2); + + $checkout = app(CheckoutService::class)->createFromCart($cart); + $checkout = app(CheckoutService::class)->setAddress($checkout, [ + 'email' => 'buyer@example.test', + 'shipping_address' => [ + 'first_name' => 'Test', + 'last_name' => 'Buyer', + 'address1' => 'Main Street 1', + 'city' => 'Berlin', + 'country' => 'DE', + 'postal_code' => '10115', + ], + ]); + $checkout = app(CheckoutService::class)->setShippingMethod($checkout, null); + $checkout = app(CheckoutService::class)->selectPaymentMethod($checkout, 'credit_card'); + $subscription = webhookActiveSubscription($store, WebhookEventType::CheckoutCompleted); + + $order = app(CheckoutService::class)->completeCheckout($checkout, [ + 'card_number' => '4242424242424242', + ]); + + expectQueuedWebhookDelivery( + $subscription, + WebhookEventType::CheckoutCompleted, + fn (array $payload): bool => data_get($payload, 'data.checkout.id') === $checkout->getKey() + && data_get($payload, 'data.checkout.email') === 'buyer@example.test' + && data_get($payload, 'data.checkout.total_amount') === 5000 + && data_get($payload, 'data.order.id') === $order->getKey(), + ); +}); + +test('deliver webhook posts signed json payload and records success', function (): void { + Http::preventStrayRequests(); + Http::fake([ + 'https://example.com/webhooks/orders' => Http::response(['ok' => true], 200), + ]); + + $subscription = WebhookSubscription::factory()->create([ + 'target_url' => 'https://example.com/webhooks/orders', + 'signing_secret_encrypted' => 'whsec_test_secret', + ]); + $delivery = WebhookDelivery::factory()->create([ + 'subscription_id' => $subscription->getKey(), + 'status' => WebhookDeliveryStatus::Pending, + ]); + $payload = [ + 'id' => $delivery->event_id, + 'api_version' => '2026-05', + 'event_type' => WebhookEventType::OrderCreated->value, + 'store_id' => $subscription->store_id, + 'data' => ['order' => ['id' => 10]], + ]; + + (new DeliverWebhook($delivery->getKey(), WebhookEventType::OrderCreated->value, $payload)) + ->handle(app(WebhookService::class)); + + Http::assertSent(function ($request) use ($delivery, $subscription): bool { + $timestamp = $request->header('X-Platform-Timestamp')[0] ?? ''; + $signature = $request->header('X-Platform-Signature')[0] ?? ''; + + return $request->url() === 'https://example.com/webhooks/orders' + && $request->header('X-Platform-Event')[0] === WebhookEventType::OrderCreated->value + && $request->header('X-Platform-Delivery-Id')[0] === $delivery->event_id + && app(WebhookService::class)->verify($timestamp.'.'.$request->body(), $signature, $subscription->fresh()->signing_secret_encrypted); + }); + + $delivery->refresh(); + + expect($delivery->status)->toBe(WebhookDeliveryStatus::Success) + ->and($delivery->response_code)->toBe(200) + ->and($delivery->attempt_count)->toBe(1); +}); + +test('failed deliveries pause subscription after five consecutive failures', function (): void { + Http::preventStrayRequests(); + Http::fake([ + 'https://example.com/webhooks/failing' => Http::response('nope', 500), + ]); + + $subscription = WebhookSubscription::factory()->create([ + 'target_url' => 'https://example.com/webhooks/failing', + 'status' => WebhookSubscriptionStatus::Active, + ]); + + WebhookDelivery::factory()->count(4)->create([ + 'subscription_id' => $subscription->getKey(), + 'status' => WebhookDeliveryStatus::Failed, + 'last_attempt_at' => now()->subMinutes(5), + ]); + + $delivery = WebhookDelivery::factory()->create([ + 'subscription_id' => $subscription->getKey(), + 'status' => WebhookDeliveryStatus::Pending, + ]); + + expect(fn () => (new DeliverWebhook($delivery->getKey(), WebhookEventType::OrderCreated->value, [ + 'id' => $delivery->event_id, + 'data' => ['order' => ['id' => 10]], + ]))->handle(app(WebhookService::class)))->toThrow(\RuntimeException::class); + + expect($subscription->refresh()->status)->toBe(WebhookSubscriptionStatus::Paused) + ->and($delivery->refresh()->status)->toBe(WebhookDeliveryStatus::Failed) + ->and($delivery->response_code)->toBe(500); +}); + +test('delivery job uses the required retry schedule', function (): void { + $job = new DeliverWebhook(1, WebhookEventType::OrderCreated->value, []); + + expect($job->tries)->toBe(6) + ->and($job->backoff())->toBe([60, 300, 1800, 7200, 43200]); +}); diff --git a/tests/Feature/Webhooks/WebhookSignatureTest.php b/tests/Feature/Webhooks/WebhookSignatureTest.php new file mode 100644 index 00000000..25bf8283 --- /dev/null +++ b/tests/Feature/Webhooks/WebhookSignatureTest.php @@ -0,0 +1,23 @@ +sign($payload, $secret); + + expect($signature)->toBe(hash_hmac('sha256', $payload, $secret)) + ->and($webhooks->verify($payload, $signature, $secret))->toBeTrue(); +}); + +test('webhook verification rejects tampered payloads and signatures', function (): void { + $webhooks = app(WebhookService::class); + $payload = '1714780800.{"id":"evt_1"}'; + $secret = 'whsec_test_secret'; + $signature = $webhooks->sign($payload, $secret); + + expect($webhooks->verify($payload.'.tampered', $signature, $secret))->toBeFalse() + ->and($webhooks->verify($payload, str_repeat('0', 64), $secret))->toBeFalse(); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 60f04a45..c72c9855 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -12,8 +12,7 @@ */ pest()->extend(Tests\TestCase::class) - // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) - ->in('Feature'); + ->in('Feature', 'Browser'); /* |-------------------------------------------------------------------------- @@ -41,7 +40,31 @@ | */ -function something() +/** + * @param list $abilities + */ +function adminApiBearerToken(\App\Models\Store $store, array $abilities, ?\App\Models\User $user = null): string { - // .. + return adminApiToken($store, $abilities, $user)['plain_text']; +} + +/** + * @param list $abilities + * @return array{token: \App\Models\PersonalAccessToken, plain_text: string} + */ +function adminApiToken(\App\Models\Store $store, array $abilities, ?\App\Models\User $user = null): array +{ + $user ??= $store->users()->wherePivot('role', 'owner')->first() + ?? $store->users()->first(); + + if (! $user instanceof \App\Models\User) { + $user = \App\Models\User::factory()->create(['email_verified_at' => now()]); + $store->users()->attach($user->getKey(), [ + 'role' => \App\Enums\StoreUserRole::Owner->value, + 'created_at' => now(), + ]); + } + + return app(\App\Services\WebhookService::class) + ->createApiToken($store, 'Test API token', $abilities, $user); }