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

Content

+
+``` + +## Verification + +1. Check component renders correctly +2. Test interactive states +3. Verify mobile responsiveness + +## Common Pitfalls + +- Trying to use Pro-only components in the free edition +- Not checking if a Flux component exists before creating custom implementations +- Forgetting to use the `search-docs` tool for component-specific documentation +- Not following existing project patterns for Flux usage diff --git a/.agents/skills/laravel-best-practices/SKILL.md b/.agents/skills/laravel-best-practices/SKILL.md new file mode 100644 index 00000000..d136d755 --- /dev/null +++ b/.agents/skills/laravel-best-practices/SKILL.md @@ -0,0 +1,59 @@ +--- +name: laravel-best-practices +description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns." +license: MIT +metadata: + author: laravel +--- + +# Laravel Best Practices + +Best practices for Laravel, organized as an index of rule files. Each rule file teaches what to do and why. For exact API syntax, verify with `search-docs`. + +## Consistency First + +Before applying any rule, check what the application already does. Laravel offers multiple valid approaches, and the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. + +Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it. Don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides. + +## How to Apply + +1. Check the changed files, nearby code, project configuration, and relevant tests for established patterns. Deviate only for a correctness or security defect, and call the deviation out. +2. Map every affected concern to the rule index below. Read each mapped rule file before editing. Skip unrelated rule files. +3. Make the smallest coherent change. Keep the application's architecture and naming instead of introducing a second pattern for the same job. +4. Verify version-sensitive Laravel APIs for the installed version with `search-docs`, or inspect the installed framework when it is unavailable. +5. Run the narrowest relevant tests first, then the project's formatting and static-analysis checks when the change warrants them. +6. Re-read the diff against every mapped rule before finishing. + +## Rule Index + +Cross-cutting changes often need more than one rule file. + +| Concern | Read | +| --- | --- | +| Query count, eager loading, indexes, large datasets | [`rules/db-performance.md`](rules/db-performance.md) | +| Subqueries, aggregates, complex ordering and query plans | [`rules/advanced-queries.md`](rules/advanced-queries.md) | +| Models, relationships, scopes, casts | [`rules/eloquent.md`](rules/eloquent.md) | +| Authentication, authorization, input safety, secrets, uploads | [`rules/security.md`](rules/security.md) | +| Form Requests and validation rules | [`rules/validation.md`](rules/validation.md) | +| Controllers, route binding, resources, middleware | [`rules/routing.md`](rules/routing.md) | +| Schema changes, columns, foreign keys, indexes | [`rules/migrations.md`](rules/migrations.md) | +| Jobs, retries, uniqueness, batches, Horizon | [`rules/queue-jobs.md`](rules/queue-jobs.md) | +| Cache lifetime, invalidation, locks, memoization | [`rules/caching.md`](rules/caching.md) | +| Outbound requests, retries, timeouts, fakes | [`rules/http-client.md`](rules/http-client.md) | +| Exceptions, reporting, rendering, log context | [`rules/error-handling.md`](rules/error-handling.md) | +| Events and notifications | [`rules/events-notifications.md`](rules/events-notifications.md) | +| Mailables and mail assertions | [`rules/mail.md`](rules/mail.md) | +| Scheduled tasks and overlap protection | [`rules/scheduling.md`](rules/scheduling.md) | +| Collections, lazy iteration, bulk operations | [`rules/collections.md`](rules/collections.md) | +| Blade components, attributes, composers | [`rules/blade-views.md`](rules/blade-views.md) | +| Environment values and application configuration | [`rules/config.md`](rules/config.md) | +| Pest/PHPUnit patterns, factories, fakes | [`rules/testing.md`](rules/testing.md) | +| Naming, helpers, file boundaries, PHP style | [`rules/style.md`](rules/style.md) | +| Actions, services, dependencies, application structure | [`rules/architecture.md`](rules/architecture.md) | + +## Decision Rules + +- Prefer framework features and existing application abstractions over new helpers or dependencies. +- Avoid speculative abstractions. Extract code when it creates a clear domain boundary, removes meaningful duplication, or makes behavior independently testable. +- Keep database access out of Blade views and prevent hidden N+1 queries across controllers, resources, jobs, and serialization. diff --git a/.agents/skills/laravel-best-practices/rules/advanced-queries.md b/.agents/skills/laravel-best-practices/rules/advanced-queries.md new file mode 100644 index 00000000..f12876e4 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/advanced-queries.md @@ -0,0 +1,106 @@ +# Advanced Query Patterns + +## Use `addSelect()` Subqueries for Single Values from Has-Many + +Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries. + +```php +public function scopeWithLastLoginAt($query): void +{ + $query->addSelect([ + 'last_login_at' => Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->withCasts(['last_login_at' => 'datetime']); +} +``` + +## Create Dynamic Relationships via Subquery FK + +Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection. + +```php +public function lastLogin(): BelongsTo +{ + return $this->belongsTo(Login::class); +} + +public function scopeWithLastLogin($query): void +{ + $query->addSelect([ + 'last_login_id' => Login::select('id') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->with('lastLogin'); +} +``` + +## Use Conditional Aggregates Instead of Multiple Count Queries + +Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values. + +```php +$statuses = Feature::toBase() + ->selectRaw("count(case when status = 'Requested' then 1 end) as requested") + ->selectRaw("count(case when status = 'Planned' then 1 end) as planned") + ->selectRaw("count(case when status = 'Completed' then 1 end) as completed") + ->first(); +``` + +## Use `setRelation()` to Prevent Circular N+1 + +When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries. + +```php +$feature->load('comments.user'); +$feature->comments->each->setRelation('feature', $feature); +``` + +## Prefer `whereIn` + Subquery Over `whereHas` + +`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory. + +Incorrect (correlated EXISTS re-executes per row): + +```php +$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term)); +``` + +Correct (index-friendly subquery, no PHP memory overhead): + +```php +$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id')); +``` + +## Sometimes Two Simple Queries Beat One Complex Query + +Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index. + +## Use Compound Indexes Matching `orderBy` Column Order + +When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index. + +```php +// Migration +$table->index(['last_name', 'first_name']); + +// Query — column order must match the index +User::query()->orderBy('last_name')->orderBy('first_name')->paginate(); +``` + +## Use Correlated Subqueries for Has-Many Ordering + +When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading. + +```php +public function scopeOrderByLastLogin($query): void +{ + $query->orderByDesc(Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1) + ); +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/architecture.md b/.agents/skills/laravel-best-practices/rules/architecture.md new file mode 100644 index 00000000..138d5a48 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/architecture.md @@ -0,0 +1,202 @@ +# Architecture Best Practices + +## Single-Purpose Action Classes + +Extract discrete business operations into invokable Action classes. + +```php +class CreateOrderAction +{ + public function __construct(private InventoryService $inventory) {} + + public function handle(array $data): Order + { + $order = Order::create($data); + $this->inventory->reserve($order); + + return $order; + } +} +``` + +## Use Dependency Injection + +Always use constructor injection. Avoid `app()` or `resolve()` inside classes. + +Incorrect: +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request) + { + $service = app(OrderService::class); + + return $service->create($request->validated()); + } +} +``` + +Correct: +```php +class OrderController extends Controller +{ + public function __construct(private OrderService $service) {} + + public function store(StoreOrderRequest $request) + { + return $this->service->create($request->validated()); + } +} +``` + +## Code to Interfaces + +Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability. + +Incorrect (concrete dependency): +```php +class OrderService +{ + public function __construct(private StripeGateway $gateway) {} +} +``` + +Correct (interface dependency): +```php +interface PaymentGateway +{ + public function charge(int $amount, string $customerId): PaymentResult; +} + +class OrderService +{ + public function __construct(private PaymentGateway $gateway) {} +} +``` + +Bind in a service provider: + +```php +$this->app->bind(PaymentGateway::class, StripeGateway::class); +``` + +## Default Sort by Descending + +When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined. + +Incorrect: +```php +$posts = Post::paginate(); +``` + +Correct: +```php +$posts = Post::latest()->paginate(); +``` + +## Use Atomic Locks for Race Conditions + +Prevent race conditions with `Cache::lock()` or `lockForUpdate()`. + +```php +Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) { + $order->process(); +}); + +// Or at query level +$product = Product::where('id', $id)->lockForUpdate()->first(); +``` + +## Use `mb_*` String Functions + +When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters. + +Incorrect: +```php +strlen('José'); // 5 (bytes, not characters) +strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte +``` + +Correct: +```php +mb_strlen('José'); // 4 (characters) +mb_strtolower('MÜNCHEN'); // 'münchen' + +// Prefer Laravel's Str helpers when available +Str::length('José'); // 4 +Str::lower('MÜNCHEN'); // 'münchen' +``` + +## Use `defer()` for Post-Response Work + +For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead. + +Incorrect (job overhead for trivial work): +```php +dispatch(new LogPageView($page)); +``` + +Correct (runs after response, same process): +```php +defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()])); +``` + +Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work. + +## Use `Context` for Request-Scoped Data + +The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually. + +```php +// In middleware +Context::add('tenant_id', $request->header('X-Tenant-ID')); + +// Anywhere later — controllers, jobs, log context +$tenantId = Context::get('tenant_id'); +``` + +Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`. + +## Use `Concurrency::run()` for Parallel Execution + +Run independent operations in parallel using child processes — no async libraries needed. + +```php +use Illuminate\Support\Facades\Concurrency; + +[$users, $orders] = Concurrency::run([ + fn () => User::count(), + fn () => Order::where('status', 'pending')->count(), +]); +``` + +Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially. + +## Convention Over Configuration + +Follow Laravel conventions. Don't override defaults unnecessarily. + +Incorrect: +```php +class Customer extends Model +{ + protected $table = 'Customer'; + protected $primaryKey = 'customer_id'; + + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id'); + } +} +``` + +Correct: +```php +class Customer extends Model +{ + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class); + } +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/blade-views.md b/.agents/skills/laravel-best-practices/rules/blade-views.md new file mode 100644 index 00000000..5f0b3a1e --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/blade-views.md @@ -0,0 +1,36 @@ +# Blade & Views Best Practices + +## Use `$attributes->merge()` in Component Templates + +Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly. + +```blade +
merge(['class' => 'alert alert-'.$type]) }}> + {{ $message }} +
+``` + +## Use `@pushOnce` for Per-Component Scripts + +If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once. + +## Prefer Blade Components Over `@include` + +`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots. + +## Use View Composers for Shared View Data + +If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it. + +## Use Blade Fragments for Partial Re-Renders (htmx/Turbo) + +A single view can return either the full page or just a fragment, keeping routing clean. + +```php +return view('dashboard', compact('users')) + ->fragmentIf($request->hasHeader('HX-Request'), 'user-list'); +``` + +## Use `@aware` for Deeply Nested Component Props + +Avoids re-passing parent props through every level of nested components. diff --git a/.agents/skills/laravel-best-practices/rules/caching.md b/.agents/skills/laravel-best-practices/rules/caching.md new file mode 100644 index 00000000..67408d6e --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/caching.md @@ -0,0 +1,70 @@ +# Caching Best Practices + +## Use `Cache::remember()` Instead of Manual Get/Put + +Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions. + +Incorrect: +```php +$val = Cache::get('stats'); +if (! $val) { + $val = $this->computeStats(); + Cache::put('stats', $val, 60); +} +``` + +Correct: +```php +$val = Cache::remember('stats', 60, fn () => $this->computeStats()); +``` + +## Use `Cache::flexible()` for Stale-While-Revalidate + +On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background. + +Incorrect: `Cache::remember('users', 300, fn () => User::all());` + +Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function. + +## Use `Cache::memo()` to Avoid Redundant Hits Within a Request + +If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory. + +`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5. + +## Use Cache Tags to Invalidate Related Groups + +Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`. + +```php +Cache::tags(['user-1'])->flush(); +``` + +## Use `Cache::add()` for Atomic Conditional Writes + +`add()` only writes if the key does not exist — atomic, no race condition between checking and writing. + +Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }` + +Correct: `Cache::add('lock', true, 10);` + +## Use `once()` for Per-Request Memoization + +`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory. + +```php +public function roles(): Collection +{ + return once(fn () => $this->loadRoles()); +} +``` + +Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching. + +## Configure Failover Cache Stores in Production + +If Redis goes down, the app falls back to a secondary store automatically. + +```php +'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], +``` diff --git a/.agents/skills/laravel-best-practices/rules/collections.md b/.agents/skills/laravel-best-practices/rules/collections.md new file mode 100644 index 00000000..18e8d9e1 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/collections.md @@ -0,0 +1,44 @@ +# Collection Best Practices + +## Use Higher-Order Messages for Simple Operations + +Incorrect: +```php +$users->each(function (User $user) { + $user->markAsVip(); +}); +``` + +Correct: `$users->each->markAsVip();` + +Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc. + +## Choose `cursor()` vs. `lazy()` Correctly + +- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk). +- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading. + +Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored. + +Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work. + +## Use `lazyById()` When Updating Records While Iterating + +`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation. + +## Use `toQuery()` for Bulk Operations on Collections + +Avoids manual `whereIn` construction. + +Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);` + +Correct: `$users->toQuery()->update([...]);` + +## Use `#[CollectedBy]` for Custom Collection Classes + +More declarative than overriding `newCollection()`. + +```php +#[CollectedBy(UserCollection::class)] +class User extends Model {} +``` diff --git a/.agents/skills/laravel-best-practices/rules/config.md b/.agents/skills/laravel-best-practices/rules/config.md new file mode 100644 index 00000000..9bea727b --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/config.md @@ -0,0 +1,73 @@ +# Configuration Best Practices + +## `env()` Only in Config Files + +Direct `env()` calls may return `null` when config is cached. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'key' => env('API_KEY'), + +// Application code +$key = config('services.key'); +``` + +## Use Encrypted Env or External Secrets + +Never store production secrets in plain `.env` files in version control. + +Incorrect: +```bash + +# .env committed to repo or shared in Slack + +STRIPE_SECRET=sk_live_abc123 +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI +``` + +Correct: +```bash +php artisan env:encrypt --env=production --readable +php artisan env:decrypt --env=production +``` + +For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime. + +## Use `App::environment()` for Environment Checks + +Incorrect: +```php +if (env('APP_ENV') === 'production') { +``` + +Correct: +```php +if (app()->isProduction()) { +// or +if (App::environment('production')) { +``` + +## Use Constants and Language Files + +Use class constants instead of hardcoded magic strings for model states, types, and statuses. + +```php +// Incorrect +return $this->type === 'normal'; + +// Correct +return $this->type === self::TYPE_NORMAL; +``` + +If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there. + +```php +// Only when lang files already exist in the project +return back()->with('message', __('app.article_added')); +``` diff --git a/.agents/skills/laravel-best-practices/rules/db-performance.md b/.agents/skills/laravel-best-practices/rules/db-performance.md new file mode 100644 index 00000000..c49ba164 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/db-performance.md @@ -0,0 +1,192 @@ +# Database Performance Best Practices + +## Always Eager Load Relationships + +Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront. + +Incorrect (N+1 — executes 1 + N queries): +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Correct (2 queries total): +```php +$posts = Post::with('author')->get(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Constrain eager loads to select only needed columns (always include the foreign key): + +```php +$users = User::with(['posts' => function ($query) { + $query->select('id', 'user_id', 'title') + ->where('published', true) + ->latest() + ->limit(10); +}])->get(); +``` + +## Prevent Lazy Loading in Development + +Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development. + +```php +public function boot(): void +{ + Model::preventLazyLoading(! app()->isProduction()); +} +``` + +Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded. + +## Select Only Needed Columns + +Avoid `SELECT *` — especially when tables have large text or JSON columns. + +Incorrect: +```php +$posts = Post::with('author')->get(); +``` + +Correct: +```php +$posts = Post::select('id', 'title', 'user_id', 'created_at') + ->with(['author:id,name,avatar']) + ->get(); +``` + +When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match. + +## Chunk Large Datasets + +Never load thousands of records at once. Use chunking for batch processing. + +Incorrect: +```php +$users = User::all(); +foreach ($users as $user) { + $user->notify(new WeeklyDigest); +} +``` + +Correct: +```php +User::where('subscribed', true)->chunk(200, function ($users) { + foreach ($users as $user) { + $user->notify(new WeeklyDigest); + } +}); +``` + +Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change: + +```php +User::where('active', false)->chunkById(200, function ($users) { + $users->each->delete(); +}); +``` + +## Add Database Indexes + +Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->index()->constrained(); + $table->string('status')->index(); + $table->timestamps(); + $table->index(['status', 'created_at']); +}); +``` + +Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`). + +## Use `withCount()` for Counting Relations + +Never load entire collections just to count them. + +Incorrect: +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->comments->count(); +} +``` + +Correct: +```php +$posts = Post::withCount('comments')->get(); +foreach ($posts as $post) { + echo $post->comments_count; +} +``` + +Conditional counting: + +```php +$posts = Post::withCount([ + 'comments', + 'comments as approved_comments_count' => function ($query) { + $query->where('approved', true); + }, +])->get(); +``` + +## Use `cursor()` for Memory-Efficient Iteration + +For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator. + +Incorrect: +```php +$users = User::where('active', true)->get(); +``` + +Correct: +```php +foreach (User::where('active', true)->cursor() as $user) { + ProcessUser::dispatch($user->id); +} +``` + +Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records. + +## No Queries in Blade Templates + +Never execute queries in Blade templates. Pass data from controllers. + +Incorrect: +```blade +@foreach (User::all() as $user) + {{ $user->profile->name }} +@endforeach +``` + +Correct: +```php +// Controller +$users = User::with('profile')->get(); +return view('users.index', compact('users')); +``` + +```blade +@foreach ($users as $user) + {{ $user->profile->name }} +@endforeach +``` diff --git a/.agents/skills/laravel-best-practices/rules/eloquent.md b/.agents/skills/laravel-best-practices/rules/eloquent.md new file mode 100644 index 00000000..413d5da4 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/eloquent.md @@ -0,0 +1,148 @@ +# Eloquent Best Practices + +## Use Correct Relationship Types + +Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints. + +```php +public function comments(): HasMany +{ + return $this->hasMany(Comment::class); +} + +public function author(): BelongsTo +{ + return $this->belongsTo(User::class, 'user_id'); +} +``` + +## Use Local Scopes for Reusable Queries + +Extract reusable query constraints into local scopes to avoid duplication. + +Incorrect: +```php +$active = User::where('verified', true)->whereNotNull('activated_at')->get(); +$articles = Article::whereHas('user', function ($q) { + $q->where('verified', true)->whereNotNull('activated_at'); +})->get(); +``` + +Correct: +```php +public function scopeActive(Builder $query): Builder +{ + return $query->where('verified', true)->whereNotNull('activated_at'); +} + +// Usage +$active = User::active()->get(); +$articles = Article::whereHas('user', fn ($q) => $q->active())->get(); +``` + +## Apply Global Scopes Sparingly + +Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy. + +Incorrect (global scope for a conditional filter): +```php +class PublishedScope implements Scope +{ + public function apply(Builder $builder, Model $model): void + { + $builder->where('published', true); + } +} +// Now admin panels, reports, and background jobs all silently skip drafts +``` + +Correct (local scope you opt into): +```php +public function scopePublished(Builder $query): Builder +{ + return $query->where('published', true); +} + +Post::published()->paginate(); // Explicit +Post::paginate(); // Admin sees all +``` + +## Define Attribute Casts + +Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion. + +```php +protected function casts(): array +{ + return [ + 'is_active' => 'boolean', + 'metadata' => 'array', + 'total' => 'decimal:2', + ]; +} +``` + +## Cast Date Columns Properly + +Always cast date columns. Use Carbon instances in templates instead of formatting strings manually. + +Incorrect: +```blade +{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }} +``` + +Correct: +```php +protected function casts(): array +{ + return [ + 'ordered_at' => 'datetime', + ]; +} +``` + +```blade +{{ $order->ordered_at->toDateString() }} +{{ $order->ordered_at->format('m-d') }} +``` + +## Use `whereBelongsTo()` for Relationship Queries + +Cleaner than manually specifying foreign keys. + +Incorrect: +```php +Post::where('user_id', $user->id)->get(); +``` + +Correct: +```php +Post::whereBelongsTo($user)->get(); +Post::whereBelongsTo($user, 'author')->get(); +``` + +## Avoid Hardcoded Table Names in Queries + +Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string). + +Incorrect: +```php +DB::table('users')->where('active', true)->get(); + +$query->join('companies', 'companies.id', '=', 'users.company_id'); + +DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); +``` + +Correct — reference the model's table: +```php +DB::table((new User)->getTable())->where('active', true)->get(); + +// Even better — use Eloquent or the query builder instead of raw SQL +User::where('active', true)->get(); +Order::where('status', 'pending')->get(); +``` + +Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable. + +**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration. diff --git a/.agents/skills/laravel-best-practices/rules/error-handling.md b/.agents/skills/laravel-best-practices/rules/error-handling.md new file mode 100644 index 00000000..4b148667 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/error-handling.md @@ -0,0 +1,72 @@ +# Error Handling Best Practices + +## Exception Reporting and Rendering + +There are two valid approaches — choose one and apply it consistently across the project. + +**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find: + +```php +class InvalidOrderException extends Exception +{ + public function report(): void { /* custom reporting */ } + + public function render(Request $request): Response + { + return response()->view('errors.invalid-order', status: 422); + } +} +``` + +**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture: + +```php +->withExceptions(function (Exceptions $exceptions) { + $exceptions->report(function (InvalidOrderException $e) { /* ... */ }); + $exceptions->render(function (InvalidOrderException $e, Request $request) { + return response()->view('errors.invalid-order', status: 422); + }); +}) +``` + +Check the existing codebase and follow whichever pattern is already established. + +## Use `ShouldntReport` for Exceptions That Should Never Log + +More discoverable than listing classes in `dontReport()`. + +```php +class PodcastProcessingException extends Exception implements ShouldntReport {} +``` + +## Throttle High-Volume Exceptions + +A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type. + +## Enable `dontReportDuplicates()` + +Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks. + +## Force JSON Error Rendering for API Routes + +Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes. + +```php +$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { + return $request->is('api/*') || $request->expectsJson(); +}); +``` + +## Add Context to Exception Classes + +Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry. + +```php +class InvalidOrderException extends Exception +{ + public function context(): array + { + return ['order_id' => $this->orderId]; + } +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/events-notifications.md b/.agents/skills/laravel-best-practices/rules/events-notifications.md new file mode 100644 index 00000000..82e329e8 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/events-notifications.md @@ -0,0 +1,52 @@ +# Events & Notifications Best Practices + +## Rely on Event Discovery + +Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`. + +## Run `event:cache` in Production Deploy + +Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`. + +## Use `ShouldDispatchAfterCommit` Inside Transactions + +Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet. + +```php +class OrderShipped implements ShouldDispatchAfterCommit {} +``` + +## Always Queue Notifications + +Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response. + +```php +class InvoicePaid extends Notification implements ShouldQueue +{ + use Queueable; +} +``` + +## Use `afterCommit()` on Notifications in Transactions + +Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits. + +```php +$user->notify((new InvoicePaid($invoice))->afterCommit()); +``` + +## Route Notification Channels to Dedicated Queues + +Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues. + +## Use On-Demand Notifications for Non-User Recipients + +Avoid creating dummy models to send notifications to arbitrary addresses. + +```php +Notification::route('mail', 'admin@example.com')->notify(new SystemAlert()); +``` + +## Implement `HasLocalePreference` on Notifiable Models + +Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. diff --git a/.agents/skills/laravel-best-practices/rules/http-client.md b/.agents/skills/laravel-best-practices/rules/http-client.md new file mode 100644 index 00000000..8e2f16e8 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/http-client.md @@ -0,0 +1,160 @@ +# HTTP Client Best Practices + +## Always Set Explicit Timeouts + +The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users'); +``` + +Correct: +```php +$response = Http::timeout(5) + ->connectTimeout(3) + ->get('https://api.example.com/users'); +``` + +For service-specific clients, define timeouts in a macro: + +```php +Http::macro('github', function () { + return Http::baseUrl('https://api.github.com') + ->timeout(10) + ->connectTimeout(3) + ->withToken(config('services.github.token')); +}); + +$response = Http::github()->get('/repos/laravel/framework'); +``` + +## Use Retry with Backoff for External APIs + +External APIs have transient failures. Use `retry()` with increasing delays. + +Incorrect: +```php +$response = Http::post('https://api.stripe.com/v1/charges', $data); + +if ($response->failed()) { + throw new PaymentFailedException('Charge failed'); +} +``` + +Correct: +```php +$response = Http::retry([100, 500, 1000]) + ->timeout(10) + ->post('https://api.stripe.com/v1/charges', $data); +``` + +Only retry on specific errors: + +```php +$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException && $exception->response->serverError()); +})->post('https://api.example.com/data'); +``` + +## Handle Errors Explicitly + +The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users/1'); +$user = $response->json(); // Could be an error body +``` + +Correct: +```php +$response = Http::timeout(5) + ->get('https://api.example.com/users/1') + ->throw(); + +$user = $response->json(); +``` + +For graceful degradation: + +```php +$response = Http::get('https://api.example.com/users/1'); + +if ($response->successful()) { + return $response->json(); +} + +if ($response->notFound()) { + return null; +} + +$response->throw(); +``` + +## Use Request Pooling for Concurrent Requests + +When making multiple independent API calls, use `Http::pool()` instead of sequential calls. + +Incorrect: +```php +$users = Http::get('https://api.example.com/users')->json(); +$posts = Http::get('https://api.example.com/posts')->json(); +$comments = Http::get('https://api.example.com/comments')->json(); +``` + +Correct: +```php +use Illuminate\Http\Client\Pool; + +$responses = Http::pool(fn (Pool $pool) => [ + $pool->as('users')->get('https://api.example.com/users'), + $pool->as('posts')->get('https://api.example.com/posts'), + $pool->as('comments')->get('https://api.example.com/comments'), +]); + +$users = $responses['users']->json(); +$posts = $responses['posts']->json(); +``` + +## Fake HTTP Calls in Tests + +Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`. + +Incorrect: +```php +it('syncs user from API', function () { + $service = new UserSyncService; + $service->sync(1); // Hits the real API +}); +``` + +Correct: +```php +it('syncs user from API', function () { + Http::preventStrayRequests(); + + Http::fake([ + 'api.example.com/users/1' => Http::response([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]), + ]); + + $service = new UserSyncService; + $service->sync(1); + + Http::assertSent(function (Request $request) { + return $request->url() === 'https://api.example.com/users/1'; + }); +}); +``` + +Test failure scenarios too: + +```php +Http::fake([ + 'api.example.com/*' => Http::failedConnection(), +]); +``` diff --git a/.agents/skills/laravel-best-practices/rules/mail.md b/.agents/skills/laravel-best-practices/rules/mail.md new file mode 100644 index 00000000..7c717336 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/mail.md @@ -0,0 +1,27 @@ +# Mail Best Practices + +## Implement `ShouldQueue` on the Mailable Class + +Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it. + +## Use `afterCommit()` on Mailables Inside Transactions + +A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor. + +## Use `assertQueued()` Not `assertSent()` for Queued Mailables + +`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint. + +Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. + +Correct: `Mail::assertQueued(OrderShipped::class);` + +## Use Markdown Mailables for Transactional Emails + +Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag. + +## Separate Content Tests from Sending Tests + +Content tests: instantiate the mailable directly, call `assertSeeInHtml()`. +Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`. +Don't mix them — it conflates concerns and makes tests brittle. diff --git a/.agents/skills/laravel-best-practices/rules/migrations.md b/.agents/skills/laravel-best-practices/rules/migrations.md new file mode 100644 index 00000000..df6f5f33 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/migrations.md @@ -0,0 +1,121 @@ +# Migration Best Practices + +## Generate Migrations with Artisan + +Always use `php artisan make:migration` for consistent naming and timestamps. + +Incorrect (manually created file): +```php +// database/migrations/posts_migration.php ← wrong naming, no timestamp +``` + +Correct (Artisan-generated): +```bash +php artisan make:migration create_posts_table +php artisan make:migration add_slug_to_posts_table +``` + +## Use `constrained()` for Foreign Keys + +Automatic naming and referential integrity. + +```php +$table->foreignId('user_id')->constrained()->cascadeOnDelete(); + +// Non-standard names +$table->foreignId('author_id')->constrained('users'); +``` + +## Never Modify Deployed Migrations + +Once a migration has run in production, treat it as immutable. Create a new migration to change the table. + +Incorrect (editing a deployed migration): +```php +// 2024_01_01_create_posts_table.php — already in production +$table->string('slug')->unique(); // ← added after deployment +``` + +Correct (new migration to alter): +```php +// 2024_03_15_add_slug_to_posts_table.php +Schema::table('posts', function (Blueprint $table) { + $table->string('slug')->unique()->after('title'); +}); +``` + +## Add Indexes in the Migration + +Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->index(); + $table->string('status')->index(); + $table->timestamp('shipped_at')->nullable()->index(); + $table->timestamps(); +}); +``` + +## Mirror Defaults in Model `$attributes` + +When a column has a database default, mirror it in the model so new instances have correct values before saving. + +```php +// Migration +$table->string('status')->default('pending'); + +// Model +protected $attributes = [ + 'status' => 'pending', +]; +``` + +## Write Reversible `down()` Methods by Default + +Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments. + +```php +public function down(): void +{ + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn('slug'); + }); +} +``` + +For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported. + +## Keep Migrations Focused + +One concern per migration. Never mix DDL (schema changes) and DML (data manipulation). + +Incorrect (partial failure creates unrecoverable state): +```php +public function up(): void +{ + Schema::create('settings', function (Blueprint $table) { ... }); + DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +} +``` + +Correct (separate migrations): +```php +// Migration 1: create_settings_table +Schema::create('settings', function (Blueprint $table) { ... }); + +// Migration 2: seed_default_settings +DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +``` diff --git a/.agents/skills/laravel-best-practices/rules/queue-jobs.md b/.agents/skills/laravel-best-practices/rules/queue-jobs.md new file mode 100644 index 00000000..c41915e2 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/queue-jobs.md @@ -0,0 +1,144 @@ +# Queue & Job Best Practices + +## Set `retry_after` Greater Than `timeout` + +If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution. + +Incorrect (`retry_after` ≤ `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 90 ← job retried while still running! +``` + +Correct (`retry_after` > `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 180 ← safely longer than any job timeout +``` + +## Use Exponential Backoff + +Use progressively longer delays between retries to avoid hammering failing services. + +Incorrect (fixed retry interval): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + // Default: retries immediately, overwhelming the API +} +``` + +Correct (exponential backoff): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + public $backoff = [1, 5, 10]; +} +``` + +## Implement `ShouldBeUnique` + +Prevent duplicate job processing. + +```php +class GenerateInvoice implements ShouldQueue, ShouldBeUnique +{ + public function uniqueId(): string + { + return $this->order->id; + } + + public $uniqueFor = 3600; +} +``` + +## Always Implement `failed()` + +Handle errors explicitly — don't rely on silent failure. + +```php +public function failed(?Throwable $exception): void +{ + $this->podcast->update(['status' => 'failed']); + Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]); +} +``` + +## Rate Limit External API Calls in Jobs + +Use `RateLimited` middleware to throttle jobs calling third-party APIs. + +```php +public function middleware(): array +{ + return [new RateLimited('external-api')]; +} +``` + +## Batch Related Jobs + +Use `Bus::batch()` when jobs should succeed or fail together. + +```php +Bus::batch([ + new ImportCsvChunk($chunk1), + new ImportCsvChunk($chunk2), +]) +->then(fn (Batch $batch) => Notification::send($user, new ImportComplete)) +->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed')) +->dispatch(); +``` + +## `retryUntil()` Needs `$tries = 0` + +When using time-based retry limits, set `$tries = 0` to avoid premature failure. + +```php +public $tries = 0; + +public function retryUntil(): \DateTimeInterface +{ + return now()->addHours(4); +} +``` + +## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release + +`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue. + +```php +class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing +{ + // Lock releases when processing begins, not when it finishes +} +``` + +## Use Horizon for Complex Queue Scenarios + +Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities. + +```php +// config/horizon.php +'environments' => [ + 'production' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['high', 'default', 'low'], + 'balance' => 'auto', + 'minProcesses' => 1, + 'maxProcesses' => 10, + 'tries' => 3, + ], + ], +], +``` diff --git a/.agents/skills/laravel-best-practices/rules/routing.md b/.agents/skills/laravel-best-practices/rules/routing.md new file mode 100644 index 00000000..b6e30864 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/routing.md @@ -0,0 +1,99 @@ +# Routing & Controllers Best Practices + +## Use Implicit Route Model Binding + +Let Laravel resolve models automatically from route parameters. + +Incorrect: +```php +public function show(int $id) +{ + $post = Post::findOrFail($id); +} +``` + +Correct: +```php +public function show(Post $post) +{ + return view('posts.show', ['post' => $post]); +} +``` + +## Use Scoped Bindings for Nested Resources + +Enforce parent-child relationships automatically. + +```php +Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { + // $post is automatically scoped to $user +})->scopeBindings(); +``` + +## Use Resource Controllers + +Use `Route::resource()` or `apiResource()` for RESTful endpoints. + +```php +Route::resource('posts', PostController::class); +// In routes/api.php — the /api prefix is applied automatically +Route::apiResource('posts', Api\PostController::class); +``` + +## Keep Controllers Thin + +Aim for under 10 lines per method. Extract business logic to action or service classes. + +Incorrect: +```php +public function store(Request $request) +{ + $validated = $request->validate([...]); + if ($request->hasFile('image')) { + $request->file('image')->move(public_path('images')); + } + $post = Post::create($validated); + $post->tags()->sync($validated['tags']); + event(new PostCreated($post)); + return redirect()->route('posts.show', $post); +} +``` + +Correct: +```php +public function store(StorePostRequest $request, CreatePostAction $create) +{ + $post = $create->execute($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +## Type-Hint Form Requests + +Type-hinting Form Requests triggers automatic validation and authorization before the method executes. + +Incorrect: +```php +public function store(Request $request): RedirectResponse +{ + $validated = $request->validate([ + 'title' => ['required', 'max:255'], + 'body' => ['required'], + ]); + + Post::create($validated); + + return redirect()->route('posts.index'); +} +``` + +Correct: +```php +public function store(StorePostRequest $request): RedirectResponse +{ + Post::create($request->validated()); + + return redirect()->route('posts.index'); +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/scheduling.md b/.agents/skills/laravel-best-practices/rules/scheduling.md new file mode 100644 index 00000000..a9847945 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/scheduling.md @@ -0,0 +1,39 @@ +# Task Scheduling Best Practices + +## Use `withoutOverlapping()` on Variable-Duration Tasks + +Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion. + +## Use `onOneServer()` on Multi-Server Deployments + +Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached). + +## Use `runInBackground()` for Concurrent Long Tasks + +By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes. + +## Use `environments()` to Restrict Tasks + +Prevent accidental execution of production-only tasks (billing, reporting) on staging. + +```php +Schedule::command('billing:charge')->monthly()->environments(['production']); +``` + +## Use `takeUntilTimeout()` for Time-Bounded Processing + +A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time. + +## Use Schedule Groups for Shared Configuration + +Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks. + +```php +Schedule::daily() + ->onOneServer() + ->timezone('America/New_York') + ->group(function () { + Schedule::command('emails:send --force'); + Schedule::command('emails:prune'); + }); +``` diff --git a/.agents/skills/laravel-best-practices/rules/security.md b/.agents/skills/laravel-best-practices/rules/security.md new file mode 100644 index 00000000..2d7200c2 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/security.md @@ -0,0 +1,198 @@ +# Security Best Practices + +## Mass Assignment Protection + +Every model must define `$fillable` (whitelist) or `$guarded` (blacklist). + +Incorrect: +```php +class User extends Model +{ + protected $guarded = []; // All fields are mass assignable +} +``` + +Correct: +```php +class User extends Model +{ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; +} +``` + +Never use `$guarded = []` on models that accept user input. + +## Authorize Every Action + +Use policies or gates in controllers. Never skip authorization. + +Incorrect: +```php +public function update(UpdatePostRequest $request, Post $post) +{ + $post->update($request->validated()); +} +``` + +Correct: +```php +public function update(UpdatePostRequest $request, Post $post) +{ + Gate::authorize('update', $post); + + $post->update($request->validated()); +} +``` + +Or via Form Request: + +```php +public function authorize(): bool +{ + return $this->user()->can('update', $this->route('post')); +} +``` + +## Prevent SQL Injection + +Always use parameter binding. Never interpolate user input into queries. + +Incorrect: +```php +DB::select("SELECT * FROM users WHERE name = '{$request->name}'"); +``` + +Correct: +```php +User::where('name', $request->name)->get(); + +// Raw expressions with bindings +User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get(); +``` + +## Escape Output to Prevent XSS + +Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content. + +Incorrect: +```blade +{!! $user->bio !!} +``` + +Correct: +```blade +{{ $user->bio }} +``` + +## CSRF Protection + +Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied. + +Incorrect: +```blade +
+ +
+``` + +Correct: +```blade +
+ @csrf + +
+``` + +## Rate Limit Auth and API Routes + +Apply `throttle` middleware to authentication and API routes. + +```php +RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by($request->ip()); +}); + +Route::post('/login', LoginController::class)->middleware('throttle:login'); +``` + +## Validate File Uploads + +Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames. + +```php +public function rules(): array +{ + return [ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + ]; +} +``` + +Store with generated filenames: + +```php +$path = $request->file('avatar')->store('avatars', 'public'); +``` + +## Keep Secrets Out of Code + +Never commit `.env`. Access secrets via `config()` only. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'api_key' => env('API_KEY'), + +// In application code +$key = config('services.api_key'); +``` + +## Audit Dependencies + +Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment. + +```bash +composer audit +``` + +## Encrypt Sensitive Database Fields + +Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`. + +Incorrect: +```php +class Integration extends Model +{ + protected function casts(): array + { + return [ + 'api_key' => 'string', + ]; + } +} +``` + +Correct: +```php +class Integration extends Model +{ + protected $hidden = ['api_key', 'api_secret']; + + protected function casts(): array + { + return [ + 'api_key' => 'encrypted', + 'api_secret' => 'encrypted', + ]; + } +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/style.md b/.agents/skills/laravel-best-practices/rules/style.md new file mode 100644 index 00000000..a8afb369 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/style.md @@ -0,0 +1,125 @@ +# Conventions & Style + +## Follow Laravel Naming Conventions + +| What | Convention | Good | Bad | +|------|-----------|------|-----| +| Controller | singular | `ArticleController` | `ArticlesController` | +| Model | singular | `User` | `Users` | +| Table | plural, snake_case | `article_comments` | `articleComments` | +| Pivot table | singular alphabetical | `article_user` | `user_article` | +| Column | snake_case, no model name | `meta_title` | `article_meta_title` | +| Foreign key | singular model + `_id` | `article_id` | `articles_id` | +| Route | plural | `articles/1` | `article/1` | +| Route name | snake_case with dots | `users.show_active` | `users.show-active` | +| Method | camelCase | `getAll` | `get_all` | +| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` | +| Collection | descriptive, plural | `$activeUsers` | `$data` | +| Object | descriptive, singular | `$activeUser` | `$users` | +| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` | +| Config | snake_case | `google_calendar.php` | `googleCalendar.php` | +| Enum | singular | `UserType` | `UserTypes` | + +## Prefer Shorter Readable Syntax + +| Verbose | Shorter | +|---------|---------| +| `Session::get('cart')` | `session('cart')` | +| `$request->session()->get('cart')` | `session('cart')` | +| `$request->input('name')` | `$request->name` | +| `return Redirect::back()` | `return back()` | +| `Carbon::now()` | `now()` | +| `App::make('Class')` | `app('Class')` | +| `->where('column', '=', 1)` | `->where('column', 1)` | +| `->orderBy('created_at', 'desc')` | `->latest()` | +| `->orderBy('created_at', 'asc')` | `->oldest()` | +| `->first()->name` | `->value('name')` | + +## Use Laravel String & Array Helpers + +Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them. + +Strings — use `Str` and fluent `Str::of()` over raw PHP: +```php +// Incorrect +$slug = strtolower(str_replace(' ', '-', $title)); +$short = substr($text, 0, 100) . '...'; +$class = substr(strrchr('App\Models\User', '\\'), 1); + +// Correct +$slug = Str::slug($title); +$short = Str::limit($text, 100); +$class = class_basename('App\Models\User'); +``` + +Fluent strings — chain operations for complex transformations: +```php +// Incorrect +$result = strtolower(trim(str_replace('_', '-', $input))); + +// Correct +$result = Str::of($input)->trim()->replace('_', '-')->lower(); +``` + +Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`. + +Arrays — use `Arr` over raw PHP: +```php +// Incorrect +$name = isset($array['user']['name']) ? $array['user']['name'] : 'default'; + +// Correct +$name = Arr::get($array, 'user.name', 'default'); +``` + +Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`. + +Numbers — use `Number` for display formatting: +```php +Number::format(1000000); // "1,000,000" +Number::currency(1500, 'USD'); // "$1,500.00" +Number::abbreviate(1000000); // "1M" +Number::fileSize(1024 * 1024); // "1 MB" +Number::percentage(75.5); // "75.5%" +``` + +URIs — use `Uri` for URL manipulation: +```php +$uri = Uri::of('https://example.com/search') + ->withQuery(['q' => 'laravel', 'page' => 1]); +``` + +Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining. + +Use `search-docs` for the full list of available methods — these helpers are extensive. + +## No Inline JS/CSS in Blade + +Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes. + +Incorrect: +```blade +let article = `{{ json_encode($article) }}`; +``` + +Correct: +```blade + +``` + +Pass data to JS via data attributes or use a dedicated PHP-to-JS package. + +## No Unnecessary Comments + +Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected. + +Incorrect: +```php +// Check if there are any joins +if (count((array) $builder->getQuery()->joins) > 0) +``` + +Correct: +```php +if ($this->hasJoins()) +``` diff --git a/.agents/skills/laravel-best-practices/rules/testing.md b/.agents/skills/laravel-best-practices/rules/testing.md new file mode 100644 index 00000000..4fbf12f8 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/testing.md @@ -0,0 +1,43 @@ +# Testing Best Practices + +## Use `LazilyRefreshDatabase` Over `RefreshDatabase` + +`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date. + +## Use Model Assertions Over Raw Database Assertions + +Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);` + +Correct: `$this->assertModelExists($user);` + +More expressive, type-safe, and fails with clearer messages. + +## Use Factory States and Sequences + +Named states make tests self-documenting. Sequences eliminate repetitive setup. + +Incorrect: `User::factory()->create(['email_verified_at' => null]);` + +Correct: `User::factory()->unverified()->create();` + +## Use `Exceptions::fake()` to Assert Exception Reporting + +Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally. + +## Call `Event::fake()` After Factory Setup + +Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models. + +Incorrect: `Event::fake(); $user = User::factory()->create();` + +Correct: `$user = User::factory()->create(); Event::fake();` + +## Use `recycle()` to Share Relationship Instances Across Factories + +Without `recycle()`, nested factories create separate instances of the same conceptual entity. + +```php +Ticket::factory() + ->recycle(Airline::factory()->create()) + ->create(); +``` diff --git a/.agents/skills/laravel-best-practices/rules/validation.md b/.agents/skills/laravel-best-practices/rules/validation.md new file mode 100644 index 00000000..5fde1064 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/validation.md @@ -0,0 +1,75 @@ +# Validation & Forms Best Practices + +## Use Form Request Classes + +Extract validation from controllers into dedicated Form Request classes. + +Incorrect: +```php +public function store(Request $request) +{ + $request->validate([ + 'title' => 'required|max:255', + 'body' => 'required', + ]); +} +``` + +Correct: +```php +public function store(StorePostRequest $request) +{ + Post::create($request->validated()); +} +``` + +## Array vs. String Notation for Rules + +Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses. + +```php +// Preferred for new code +'email' => ['required', 'email', Rule::unique('users')], + +// Follow existing convention if the project uses string notation +'email' => 'required|email|unique:users', +``` + +## Always Use `validated()` + +Get only validated data. Never use `$request->all()` for mass operations. + +Incorrect: +```php +Post::create($request->all()); +``` + +Correct: +```php +Post::create($request->validated()); +``` + +## Use `Rule::when()` for Conditional Validation + +```php +'company_name' => [ + Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']), +], +``` + +## Use the `after()` Method for Custom Validation + +Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields. + +```php +public function after(): array +{ + return [ + function (Validator $validator) { + if ($this->quantity > Product::find($this->product_id)?->stock) { + $validator->errors()->add('quantity', 'Not enough stock.'); + } + }, + ]; +} +``` diff --git a/.agents/skills/livewire-development/SKILL.md b/.agents/skills/livewire-development/SKILL.md new file mode 100644 index 00000000..0ae356e5 --- /dev/null +++ b/.agents/skills/livewire-development/SKILL.md @@ -0,0 +1,175 @@ +--- +name: livewire-development +description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire." +license: MIT +metadata: + author: laravel +--- + +# Livewire Development + +## Documentation + +Use `search-docs` for detailed Livewire 4 patterns and documentation. + +## Basic Usage + +### Creating Components + +```bash + +# Single-file component (SFC - default in v4) + +# Creates: resources/views/components/⚡create-post.blade.php + +php artisan make:livewire create-post + +# Page component (SFC - Full Page in v4) + +# Creates: resources/views/pages/⚡create-post.blade.php + +php artisan make:livewire pages::create-post + +# Multi-file component (MFC) + +# Creates: resources/views/components/⚡create-post/create-post.php + +# resources/views/components/⚡create-post/create-post.blade.php + +php artisan make:livewire create-post --mfc + +# Class-based component (v3 style) + +# Creates: app/Livewire/CreatePost.php AND resources/views/livewire/create-post.blade.php + +php artisan make:livewire create-post --class + +# With namespace + +php artisan make:livewire Posts/CreatePost +``` + +### Converting Between Formats + +Use `php artisan livewire:convert create-post` to convert between single-file, multi-file, and class-based formats. + +### Choosing a Component Format + +> **Always follow the project's existing conventions first.** Before creating any component, inspect the project's existing Livewire components to determine the established format (SFC, MFC, or class-based) and directory structure. Check `app/Livewire/`, `resources/views/components/`, and `resources/views/livewire/` for existing components. If the project already uses a consistent format, **use that same format** — even if it differs from the Livewire v4 defaults below. Only fall back to the v4 defaults (SFC in `resources/views/components/`) when no existing convention is established. + +Also check `config/livewire.php` for `make_command.type`, `make_command.emoji`, `component_locations`, and `component_namespaces` overrides, which change the default format and where files are stored. + +### Component Format Reference + +| Format | Flag | Class Path | View Path | +|--------|------|------------|-----------| +| Single-file (SFC) | default | — | `resources/views/components/⚡create-post.blade.php` (PHP + Blade in one file) | +| Full Page SFC | `pages::name` | — | `resources/views/pages/⚡create-post.blade.php` | +| Multi-file (MFC) | `--mfc` | `resources/views/components/⚡create-post/create-post.php` | `resources/views/components/⚡create-post/create-post.blade.php` | +| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` | +| View-based | default (Blade-only) | — | `resources/views/components/⚡create-post.blade.php` (Blade-only with functional state) | + +> **Important:** The ⚡ prefix shown above is the **default** behavior in Livewire v4 — it is **configurable**. Check `config/livewire.php` for the `make_command.emoji` setting. When `true` (default), always include the ⚡ prefix in filenames you create. When `false`, omit the ⚡ prefix from all paths above. + +Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates `resources/views/components/posts/⚡create-post.blade.php` (single-file by default). Use `make:livewire Posts/CreatePost --mfc` for multi-file output at `resources/views/components/posts/⚡create-post/create-post.php` and `resources/views/components/posts/⚡create-post/create-post.blade.php`. + +### Single-File Component Example + + +```php +count++; + } +}; +?> + +
+ +
+``` + +## Livewire 4 Specifics + +### Key Changes From Livewire 3 + +These things changed in Livewire 4, but may not have been updated in this application. Verify this application's setup to ensure you follow existing conventions. + +- Use `Route::livewire()` for full-page components (e.g., `Route::livewire('/posts/create', CreatePost::class)`); config keys renamed: `layout` → `component_layout`, `lazy_placeholder` → `component_placeholder`. +- `wire:model` now ignores child events by default (use `wire:model.deep` for old behavior); `wire:scroll` renamed to `wire:navigate:scroll`. +- Component tags must be properly closed; `wire:transition` now uses View Transitions API (modifiers removed). +- JavaScript: `$wire.$js('name', fn)` → `$wire.$js.name = fn`; `commit`/`request` hooks → `interceptMessage()`/`interceptRequest()`. + +### New Features + +- Component formats: single-file (SFC), multi-file (MFC), view-based components. +- Islands (`@island`) for isolated updates; async actions (`wire:click.async`, `#[Async]`) for parallel execution. +- Deferred/bundled loading: `defer`, `lazy.bundle` for optimized component loading. + +| Feature | Usage | Purpose | +|---------|-------|---------| +| Islands | `@island(name: 'stats')` | Isolated update regions | +| Async | `wire:click.async` or `#[Async]` | Non-blocking actions | +| Deferred | `defer` attribute | Load after page render | +| Bundled | `lazy.bundle` | Load multiple together | + +### New Directives + +- `wire:sort`, `wire:intersect`, `wire:ref`, `.renderless`, `.preserve-scroll` are available for use. +- `data-loading` attribute automatically added to elements triggering network requests. + +| Directive | Purpose | +|-----------|---------| +| `wire:sort` | Drag-and-drop sorting | +| `wire:intersect` | Viewport intersection detection | +| `wire:ref` | Element references for JS | +| `.renderless` | Component without rendering | +| `.preserve-scroll` | Preserve scroll position | + +## Best Practices + +- Always use `wire:key` in loops +- Use `wire:loading` for loading states +- Use `wire:model.live` for instant updates (default is debounced) +- Validate and authorize in actions (treat like HTTP requests) + +## Configuration + +- `smart_wire_keys` defaults to `true`; new configs: `component_locations`, `component_namespaces`, `make_command`, `csp_safe`. + +## Alpine & JavaScript + +- `wire:transition` uses browser View Transitions API; `$errors` and `$intercept` magic properties available. +- Non-blocking `wire:poll` and parallel `wire:model.live` updates improve performance. + +For interceptors and hooks, see [reference/javascript-hooks.md](reference/javascript-hooks.md). + +## Testing + + +```php +Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1); +``` + +## Verification + +1. Browser console: Check for JS errors +2. Network tab: Verify Livewire requests return 200 +3. Ensure `wire:key` on all `@foreach` loops + +## Common Pitfalls + +- Missing `wire:key` in loops → unexpected re-rendering +- Expecting `wire:model` real-time → use `wire:model.live` +- Unclosed component tags → syntax errors in v4 +- Using deprecated config keys or JS hooks +- Including Alpine.js separately (already bundled in Livewire 4) diff --git a/.agents/skills/livewire-development/reference/javascript-hooks.md b/.agents/skills/livewire-development/reference/javascript-hooks.md new file mode 100644 index 00000000..660d66b5 --- /dev/null +++ b/.agents/skills/livewire-development/reference/javascript-hooks.md @@ -0,0 +1,39 @@ +# Livewire 4 JavaScript Integration + +## Interceptor System (v4) + +### Intercept Messages + +```js +Livewire.interceptMessage(({ component, message, onFinish, onSuccess, onError }) => { + onFinish(() => { /* After response, before processing */ }); + onSuccess(({ payload }) => { /* payload.snapshot, payload.effects */ }); + onError(() => { /* Server errors */ }); +}); +``` + +### Intercept Requests + +```js +Livewire.interceptRequest(({ request, onResponse, onSuccess, onError, onFailure }) => { + onResponse(({ response }) => { /* When received */ }); + onSuccess(({ response, responseJson }) => { /* Success */ }); + onError(({ response, responseBody, preventDefault }) => { /* 4xx/5xx */ }); + onFailure(({ error }) => { /* Network failures */ }); +}); +``` + +### Component-Scoped Interceptors + +```blade + +``` + +## Magic Properties + +- `$errors` - Access validation errors from JavaScript +- `$intercept` - Component-scoped interceptors diff --git a/.agents/skills/pest-testing/SKILL.md b/.agents/skills/pest-testing/SKILL.md new file mode 100644 index 00000000..ab271616 --- /dev/null +++ b/.agents/skills/pest-testing/SKILL.md @@ -0,0 +1,166 @@ +--- +name: pest-testing +description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code." +license: MIT +metadata: + author: laravel +--- + +# Pest Testing 4 + +## Documentation + +Use `search-docs` for detailed Pest 4 patterns and documentation. + +## Basic Usage + +### Creating Tests + +All tests must be written using Pest. Use `php artisan make:test --pest {name}`. + +The `{name}` argument should include only the path and test name, but should not include the test suite. +- Incorrect: `php artisan make:test --pest Feature/SomeFeatureTest` will generate `tests/Feature/Feature/SomeFeatureTest.php` +- Correct: `php artisan make:test --pest SomeControllerTest` will generate `tests/Feature/SomeControllerTest.php` +- Incorrect: `php artisan make:test --pest --unit Unit/SomeServiceTest` will generate `tests/Unit/Unit/SomeServiceTest.php` +- Correct: `php artisan make:test --pest --unit SomeServiceTest` will generate `tests/Unit/SomeServiceTest.php` + +### Test Organization + +- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. +- Browser tests: `tests/Browser/` directory. +- Do NOT remove tests without approval - these are core application code. + +### Basic Test Structure + +Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`. + + +```php +it('is true', function () { + expect(true)->toBeTrue(); +}); +``` + +### Running Tests + +- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`. +- Run all tests: `php artisan test --compact`. +- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`. + +## Assertions + +Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: + + +```php +it('returns all', function () { + $this->postJson('/api/docs', [])->assertSuccessful(); +}); +``` + +| Use | Instead of | +|-----|------------| +| `assertSuccessful()` | `assertStatus(200)` | +| `assertNotFound()` | `assertStatus(404)` | +| `assertForbidden()` | `assertStatus(403)` | + +## Mocking + +Import mock function before use: `use function Pest\Laravel\mock;` + +## Datasets + +Use datasets for repetitive tests (validation rules, etc.): + + +```php +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); +``` + +## Pest 4 Features + +| Feature | Purpose | +|---------|---------| +| Browser Testing | Full integration tests in real browsers | +| Smoke Testing | Validate multiple pages quickly | +| Visual Regression | Compare screenshots for visual changes | +| Test Sharding | Parallel CI runs | +| Architecture Testing | Enforce code conventions | + +### Browser Test Example + +Browser tests run in real browsers for full integration testing: + +- Browser tests live in `tests/Browser/`. +- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories. +- Use `RefreshDatabase` for clean state per test. +- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures. +- Test on multiple browsers (Chrome, Firefox, Safari) if requested. +- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested. +- Switch color schemes (light/dark mode) when appropriate. +- Take screenshots or pause tests for debugging. + + +```php +it('may reset the password', function () { + Notification::fake(); + + $this->actingAs(User::factory()->create()); + + $page = visit('/sign-in'); + + $page->assertSee('Sign In') + ->assertNoJavaScriptErrors() + ->click('Forgot Password?') + ->fill('email', 'nuno@laravel.com') + ->click('Send Reset Link') + ->assertSee('We have emailed your password reset link!'); + + Notification::assertSent(ResetPassword::class); +}); +``` + +### Smoke Testing + +Quickly validate multiple pages have no JavaScript errors: + + +```php +$pages = visit(['/', '/about', '/contact']); + +$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs(); +``` + +### Visual Regression Testing + +Capture and compare screenshots to detect visual changes. + +### Test Sharding + +Split tests across parallel processes for faster CI runs. + +### Architecture Testing + +Pest 4 includes architecture testing (from Pest 3): + + +```php +arch('controllers') + ->expect('App\Http\Controllers') + ->toExtendNothing() + ->toHaveSuffix('Controller'); +``` + +## Common Pitfalls + +- Not importing `use function Pest\Laravel\mock;` before using mock +- Using `assertStatus(200)` instead of `assertSuccessful()` +- Forgetting datasets for repetitive validation tests +- Deleting tests without approval +- Forgetting `assertNoJavaScriptErrors()` in browser tests +- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test` diff --git a/.agents/skills/tailwindcss-development/SKILL.md b/.agents/skills/tailwindcss-development/SKILL.md new file mode 100644 index 00000000..c0cb2fbc --- /dev/null +++ b/.agents/skills/tailwindcss-development/SKILL.md @@ -0,0 +1,119 @@ +--- +name: tailwindcss-development +description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS." +license: MIT +metadata: + author: laravel +--- + +# Tailwind CSS Development + +## Documentation + +Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. + +## Basic Usage + +- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. +- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). +- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. + +## Tailwind CSS v4 Specifics + +- Always use Tailwind CSS v4 and avoid deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. + +### CSS-First Configuration + +In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: + + +```css +@theme { + --color-brand: oklch(0.72 0.11 178); +} +``` + +### Import Syntax + +In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: + + +```diff +- @tailwind base; +- @tailwind components; +- @tailwind utilities; ++ @import "tailwindcss"; +``` + +### Replaced Utilities + +Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. + +| Deprecated | Replacement | +|------------|-------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + +## Spacing + +Use `gap` utilities instead of margins for spacing between siblings: + + +```html +
+
Item 1
+
Item 2
+
+``` + +## Dark Mode + +If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: + + +```html +
+ Content adapts to color scheme +
+``` + +## Common Patterns + +### Flexbox Layout + + +```html +
+
Left content
+
Right content
+
+``` + +### Grid Layout + + +```html +
+
Card 1
+
Card 2
+
Card 3
+
+``` + +## Common Pitfalls + +- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.) +- Using `@tailwind` directives instead of `@import "tailwindcss"` +- Trying to use `tailwind.config.js` instead of CSS `@theme` directive +- Using margins for spacing between siblings instead of gap utilities +- Forgetting to add dark mode variants when the project uses dark mode diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 00000000..864e1fbd --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,3 @@ +[mcp_servers.laravel-boost] +command = "php" +args = ["artisan", "boost:mcp"] diff --git a/.env.example b/.env.example index c0660ea1..dd3a94a8 100644 --- a/.env.example +++ b/.env.example @@ -21,13 +21,14 @@ LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug DB_CONNECTION=sqlite +DB_DATABASE=database/database.sqlite # DB_HOST=127.0.0.1 # DB_PORT=3306 # DB_DATABASE=laravel # DB_USERNAME=root # DB_PASSWORD= -SESSION_DRIVER=database +SESSION_DRIVER=file SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ @@ -35,9 +36,9 @@ SESSION_DOMAIN=null BROADCAST_CONNECTION=log FILESYSTEM_DISK=local -QUEUE_CONNECTION=database +QUEUE_CONNECTION=sync -CACHE_STORE=database +CACHE_STORE=file # CACHE_PREFIX= MEMCACHED_HOST=127.0.0.1 diff --git a/.env.testing.example b/.env.testing.example new file mode 100644 index 00000000..2223daeb --- /dev/null +++ b/.env.testing.example @@ -0,0 +1,17 @@ +APP_NAME=Shop +APP_ENV=testing +APP_KEY=base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= +APP_DEBUG=true +APP_URL=http://acme-fashion.test + +DB_CONNECTION=sqlite +DB_DATABASE=/absolute/path/to/shop/database/testing.sqlite + +PAYMENT_PROVIDER=mock +MAIL_MAILER=array +QUEUE_CONNECTION=sync +CACHE_STORE=array +SESSION_DRIVER=array +BCRYPT_ROUNDS=4 + +SANCTUM_STATEFUL_DOMAINS=acme-fashion.test diff --git a/.gitignore b/.gitignore index c7cf1fa6..219c313e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /public/storage /storage/*.key /storage/pail +/tests/Browser/Screenshots /vendor .env .env.backup @@ -21,3 +22,4 @@ yarn-error.log /.nova /.vscode /.zed +/.playwright-mcp diff --git a/AGENTS.md b/AGENTS.md index 296f2af0..ba96363f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,3 +23,220 @@ 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 in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. + +## Conventions + +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts + +- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important. + +## Application Structure & Architecture + +- Stick to existing directory structure; don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling + +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Documentation Files + +- You must only create documentation files if explicitly requested by the user. + +## Replies + +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +=== boost rules === + +# Laravel Boost + +## Tools + +- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads. +- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker. +- Use `database-schema` to inspect table structure before writing migrations or models. +- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user. +- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries. + +## Searching Documentation (IMPORTANT) + +- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically. +- Pass a `packages` array to scope results when you know which packages are relevant. +- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first. +- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`. + +### Search Syntax + +1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit". +2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order. +3. Combine words and phrases for mixed queries: `middleware "rate limit"`. +4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`. + +## Artisan + +- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. +- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`. +- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory. + +## Tinker + +- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code. +- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'` + - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` + +=== php rules === + +# PHP + +- Always use curly braces for control structures, even for single-line bodies. +- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private. +- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool` +- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`. +- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic. +- Use array shape type definitions in PHPDoc blocks. + +=== deployments rules === + +# Deployment + +- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications. + +=== herd rules === + +# Laravel Herd + +- The application is served by Laravel Herd at `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs. Never run commands to serve the site. It is always available. +- Use the `herd` CLI to manage services, PHP versions, and sites (e.g. `herd sites`, `herd services:start `, `herd php:list`). Run `herd list` to discover all available commands. + +=== tests rules === + +# Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. + +=== fortify/core rules === + +# Laravel Fortify + +- Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications. +- IMPORTANT: Always use the `search-docs` tool for detailed Laravel Fortify patterns and documentation. +- IMPORTANT: Activate `developing-with-fortify` skill when working with Fortify authentication features. + +=== laravel/core rules === + +# Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`. +- If you're creating a generic PHP class, use `php artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Model Creation + +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options. + +## APIs & Eloquent Resources + +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +## URL Generation + +- When generating links to other pages, prefer named routes and the `route()` function. + +## Testing + +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +## Vite Error + +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + +=== laravel/v12 rules === + +# Laravel 12 + +- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +## Laravel 12 Structure + +- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`. +- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- The `app/Console/Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. +- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration. + +## Database + +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models + +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + +=== livewire/core rules === + +# Livewire + +- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript. +- You can use Alpine.js for client-side interactions instead of JavaScript frameworks. +- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests. + +=== pint/core rules === + +# Laravel Pint Code Formatter + +- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues. + +=== pest/core rules === + +## Pest + +- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`. +- The `{name}` argument should not include the test suite directory. Use `php artisan make:test --pest SomeFeatureTest` instead of `php artisan make:test --pest Feature/SomeFeatureTest`. +- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`. +- Do NOT delete tests without approval. + + diff --git a/README.md b/README.md new file mode 100644 index 00000000..e8f4bdf8 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +Your mission is to implement an entire shop system based on the specifications im specs/*. You must do in one go without stopping. You might use sub-agents! You must test everything via Pest (unit, and functional tests). You must also additional simulate user behaviour using the Playwright MPC and confirm that all acceptance criterias are met. If you find bugs, you must fix them. The result is a perfect shop system. All requirements are perfectly implemented. All acceptance criterias are met, tested and confirmed by you. + +Continuously keep track of the progress in specs/progress.md Commit your progress after every relevant iteration with a meaningful message. + +When implementation is fully done, then make a full review meeting and showcase all features (customer- and admin-side) to me. In case bugs appear, you must fix them all and restart the review meeting. + +You are not allowed to re-use code from any other branch. You must build the entire shop from scratch. diff --git a/app/Auth/CustomerUserProvider.php b/app/Auth/CustomerUserProvider.php new file mode 100644 index 00000000..819834f0 --- /dev/null +++ b/app/Auth/CustomerUserProvider.php @@ -0,0 +1,20 @@ +bound('current_store')) { + return null; + } + + $credentials['store_id'] = app('current_store')->id; + + return parent::retrieveByCredentials($credentials); + } +} diff --git a/app/Contracts/PaymentProvider.php b/app/Contracts/PaymentProvider.php new file mode 100644 index 00000000..cff5a3ea --- /dev/null +++ b/app/Contracts/PaymentProvider.php @@ -0,0 +1,17 @@ + $details */ + public function charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult; + + public function refund(Payment $payment, int $amount): RefundResult; +} 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 @@ + + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('channel-name'), + ]; + } +} diff --git a/app/Events/CheckoutCompleted.php b/app/Events/CheckoutCompleted.php new file mode 100644 index 00000000..2069aaca --- /dev/null +++ b/app/Events/CheckoutCompleted.php @@ -0,0 +1,30 @@ + + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('channel-name'), + ]; + } +} diff --git a/app/Events/CheckoutExpired.php b/app/Events/CheckoutExpired.php new file mode 100644 index 00000000..a9a7850d --- /dev/null +++ b/app/Events/CheckoutExpired.php @@ -0,0 +1,30 @@ + + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('channel-name'), + ]; + } +} diff --git a/app/Events/CheckoutShippingSelected.php b/app/Events/CheckoutShippingSelected.php new file mode 100644 index 00000000..42b75044 --- /dev/null +++ b/app/Events/CheckoutShippingSelected.php @@ -0,0 +1,30 @@ + + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('channel-name'), + ]; + } +} diff --git a/app/Events/FulfillmentCreated.php b/app/Events/FulfillmentCreated.php new file mode 100644 index 00000000..551a6ae9 --- /dev/null +++ b/app/Events/FulfillmentCreated.php @@ -0,0 +1,30 @@ + + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('channel-name'), + ]; + } +} diff --git a/app/Events/FulfillmentDelivered.php b/app/Events/FulfillmentDelivered.php new file mode 100644 index 00000000..e3ea0e13 --- /dev/null +++ b/app/Events/FulfillmentDelivered.php @@ -0,0 +1,30 @@ + + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('channel-name'), + ]; + } +} diff --git a/app/Events/FulfillmentShipped.php b/app/Events/FulfillmentShipped.php new file mode 100644 index 00000000..d980ab95 --- /dev/null +++ b/app/Events/FulfillmentShipped.php @@ -0,0 +1,30 @@ + + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('channel-name'), + ]; + } +} diff --git a/app/Events/OrderCancelled.php b/app/Events/OrderCancelled.php new file mode 100644 index 00000000..8c140abf --- /dev/null +++ b/app/Events/OrderCancelled.php @@ -0,0 +1,30 @@ + + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('channel-name'), + ]; + } +} diff --git a/app/Events/OrderCreated.php b/app/Events/OrderCreated.php new file mode 100644 index 00000000..649a3ed9 --- /dev/null +++ b/app/Events/OrderCreated.php @@ -0,0 +1,30 @@ + + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('channel-name'), + ]; + } +} diff --git a/app/Events/OrderFulfilled.php b/app/Events/OrderFulfilled.php new file mode 100644 index 00000000..c08c3517 --- /dev/null +++ b/app/Events/OrderFulfilled.php @@ -0,0 +1,30 @@ + + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('channel-name'), + ]; + } +} diff --git a/app/Events/OrderPaid.php b/app/Events/OrderPaid.php new file mode 100644 index 00000000..274c1dda --- /dev/null +++ b/app/Events/OrderPaid.php @@ -0,0 +1,30 @@ + + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('channel-name'), + ]; + } +} diff --git a/app/Events/OrderRefunded.php b/app/Events/OrderRefunded.php new file mode 100644 index 00000000..58bd6569 --- /dev/null +++ b/app/Events/OrderRefunded.php @@ -0,0 +1,30 @@ + + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('channel-name'), + ]; + } +} diff --git a/app/Events/ProductStatusChanged.php b/app/Events/ProductStatusChanged.php new file mode 100644 index 00000000..44259c68 --- /dev/null +++ b/app/Events/ProductStatusChanged.php @@ -0,0 +1,20 @@ + */ + public function context(): array + { + return ['sku' => $this->sku]; + } +} diff --git a/app/Exceptions/FulfillmentGuardException.php b/app/Exceptions/FulfillmentGuardException.php new file mode 100644 index 00000000..fdbe6b90 --- /dev/null +++ b/app/Exceptions/FulfillmentGuardException.php @@ -0,0 +1,13 @@ + */ + public function context(): array + { + return [ + 'inventory_item_id' => $this->inventoryItemId, + 'requested' => $this->requested, + 'available' => $this->available, + ]; + } +} diff --git a/app/Exceptions/InvalidCheckoutTransitionException.php b/app/Exceptions/InvalidCheckoutTransitionException.php new file mode 100644 index 00000000..73980a46 --- /dev/null +++ b/app/Exceptions/InvalidCheckoutTransitionException.php @@ -0,0 +1,7 @@ + */ + public function context(): array + { + return ['product_id' => $this->productId]; + } +} diff --git a/app/Exceptions/InvalidProductTransitionException.php b/app/Exceptions/InvalidProductTransitionException.php new file mode 100644 index 00000000..98d4d179 --- /dev/null +++ b/app/Exceptions/InvalidProductTransitionException.php @@ -0,0 +1,28 @@ +value} to {$to->value}: {$reason}"); + } + + /** @return array */ + public function context(): array + { + return [ + 'product_id' => $this->productId, + 'from' => $this->from->value, + 'to' => $this->to->value, + ]; + } +} diff --git a/app/Exceptions/InvalidVariantMatrixException.php b/app/Exceptions/InvalidVariantMatrixException.php new file mode 100644 index 00000000..d8baa5e1 --- /dev/null +++ b/app/Exceptions/InvalidVariantMatrixException.php @@ -0,0 +1,13 @@ + 'The card has insufficient funds.', + default => 'The payment was declined.', + }); + } +} diff --git a/app/Http/Controllers/Api/Admin/AnalyticsController.php b/app/Http/Controllers/Api/Admin/AnalyticsController.php new file mode 100644 index 00000000..7cb18935 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/AnalyticsController.php @@ -0,0 +1,33 @@ +ensureStore($store); + $start = $request->date('start_date')?->toDateString() ?? now()->subDays(30)->toDateString(); + $end = $request->date('end_date')?->toDateString() ?? now()->toDateString(); + $orders = Order::query()->whereBetween('placed_at', [$start.' 00:00:00', $end.' 23:59:59']); + $count = (clone $orders)->count(); + $revenue = (int) (clone $orders)->sum('total_amount'); + + return response()->json(['data' => ['orders_count' => $count, 'revenue_amount' => $revenue, 'aov_amount' => $count ? (int) round($revenue / $count) : 0, 'daily' => $this->analyticsService->getDailyMetrics($store, $start, $end)]]); + } + + private function ensureStore(Store $store): void + { + abort_unless($store->is(app('current_store')), Response::HTTP_NOT_FOUND); + } +} diff --git a/app/Http/Controllers/Api/Admin/CollectionController.php b/app/Http/Controllers/Api/Admin/CollectionController.php new file mode 100644 index 00000000..31292b14 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/CollectionController.php @@ -0,0 +1,83 @@ +ensureStore($store); + + return CollectionResource::collection(Collection::query()->withCount('products')->latest()->paginate(15)); + } + + public function store(StoreCollectionRequest $request, Store $store): JsonResponse + { + $this->ensureStore($store); + $collection = DB::transaction(function () use ($request, $store): Collection { + $data = $request->validated(); + $productIds = Arr::pull($data, 'product_ids', []); + $data['store_id'] = $store->id; + $data['handle'] = $this->handleGenerator->generate($data['handle'] ?? $data['title'], 'collections', $store->id); + $collection = Collection::query()->create($data); + $collection->products()->sync(collect($productIds)->mapWithKeys(fn (int $id, int $position): array => [$id => ['position' => $position]])); + + return $collection; + }); + + return (new CollectionResource($collection->load('products')))->response()->setStatusCode(Response::HTTP_CREATED); + } + + public function update(UpdateCollectionRequest $request, Store $store, Collection $collection): CollectionResource + { + $this->ensureRelated($store, $collection); + $data = $request->validated(); + $productIds = Arr::pull($data, 'product_ids'); + + if (isset($data['handle'])) { + $data['handle'] = $this->handleGenerator->generate($data['handle'], 'collections', $store->id, $collection->id); + } + + $collection->update($data); + + if (is_array($productIds)) { + $collection->products()->sync(collect($productIds)->mapWithKeys(fn (int $id, int $position): array => [$id => ['position' => $position]])); + } + + return new CollectionResource($collection->refresh()->load('products')); + } + + public function destroy(Store $store, Collection $collection): JsonResponse + { + $this->ensureRelated($store, $collection); + $collection->delete(); + + return response()->json(['deleted' => true]); + } + + private function ensureStore(Store $store): void + { + abort_unless($store->is(app('current_store')), Response::HTTP_NOT_FOUND); + } + + private function ensureRelated(Store $store, Collection $collection): void + { + $this->ensureStore($store); + abort_unless($collection->store_id === $store->id, Response::HTTP_NOT_FOUND); + } +} diff --git a/app/Http/Controllers/Api/Admin/DiscountController.php b/app/Http/Controllers/Api/Admin/DiscountController.php new file mode 100644 index 00000000..d4792b54 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/DiscountController.php @@ -0,0 +1,78 @@ +ensureStore($store); + + return DiscountResource::collection(Discount::query()->latest()->paginate(15)); + } + + public function store(StoreDiscountRequest $request, Store $store): JsonResponse + { + $this->ensureStore($store); + $data = $request->validated(); + $data['store_id'] = $store->id; + $data['code'] = isset($data['code']) ? Str::upper(Str::squish($data['code'])) : null; + $this->ensureUniqueCode($store, $data['code']); + $discount = Discount::query()->create($data); + + return (new DiscountResource($discount))->response()->setStatusCode(Response::HTTP_CREATED); + } + + public function update(UpdateDiscountRequest $request, Store $store, Discount $discount): DiscountResource + { + $this->ensureRelated($store, $discount); + $data = $request->validated(); + + if (array_key_exists('code', $data)) { + $data['code'] = $data['code'] ? Str::upper(Str::squish($data['code'])) : null; + $this->ensureUniqueCode($store, $data['code'], $discount->id); + } + + $discount->update($data); + + return new DiscountResource($discount->refresh()); + } + + public function destroy(Store $store, Discount $discount): JsonResponse + { + $this->ensureRelated($store, $discount); + $discount->delete(); + + return response()->json(['deleted' => true]); + } + + private function ensureUniqueCode(Store $store, ?string $code, ?int $excludeId = null): void + { + if ($code && Discount::query()->whereRaw('LOWER(code) = ?', [Str::lower($code)])->when($excludeId, fn ($query) => $query->whereKeyNot($excludeId))->exists()) { + throw ValidationException::withMessages(['code' => 'The discount code has already been taken.']); + } + } + + private function ensureStore(Store $store): void + { + abort_unless($store->is(app('current_store')), Response::HTTP_NOT_FOUND); + } + + private function ensureRelated(Store $store, Discount $discount): void + { + $this->ensureStore($store); + abort_unless($discount->store_id === $store->id, Response::HTTP_NOT_FOUND); + } +} diff --git a/app/Http/Controllers/Api/Admin/ExportController.php b/app/Http/Controllers/Api/Admin/ExportController.php new file mode 100644 index 00000000..3adaa1a7 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/ExportController.php @@ -0,0 +1,23 @@ +is(app('current_store')), 404); + + return response()->streamDownload(function (): void { + $stream = fopen('php://output', 'w'); + fputcsv($stream, ['order_number', 'placed_at', 'status', 'financial_status', 'fulfillment_status', 'email', 'subtotal_amount', 'discount_amount', 'shipping_amount', 'tax_amount', 'total_amount', 'currency']); + Order::query()->latest('placed_at')->lazy()->each(fn (Order $order) => fputcsv($stream, [$order->order_number, $order->placed_at?->toIso8601String(), $order->status->value, $order->financial_status->value, $order->fulfillment_status->value, $order->email, $order->subtotal_amount, $order->discount_amount, $order->shipping_amount, $order->tax_amount, $order->total_amount, $order->currency])); + fclose($stream); + }, 'orders-'.now()->toDateString().'.csv', ['Content-Type' => 'text/csv']); + } +} diff --git a/app/Http/Controllers/Api/Admin/OrderController.php b/app/Http/Controllers/Api/Admin/OrderController.php new file mode 100644 index 00000000..797716c5 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/OrderController.php @@ -0,0 +1,76 @@ +ensureStore($store); + $orders = Order::query()->when($request->filled('status'), fn (Builder $query): Builder => $query->where('status', $request->string('status')))->when($request->filled('search'), fn (Builder $query): Builder => $query->where(fn (Builder $search): Builder => $search->where('order_number', 'like', '%'.$request->string('search').'%')->orWhere('email', 'like', '%'.$request->string('search').'%')))->latest('placed_at')->paginate(15); + + return OrderResource::collection($orders); + } + + public function show(Store $store, Order $order): OrderResource + { + $this->ensureOrder($store, $order); + + return new OrderResource($order->load(['lines', 'payments', 'refunds', 'fulfillments.lines', 'customer'])); + } + + public function fulfill(CreateFulfillmentRequest $request, Store $store, Order $order): JsonResponse + { + $this->ensureOrder($store, $order); + $data = $request->validated(); + $fulfillment = $this->fulfillmentService->create($order, $data['lines'], $data); + + return response()->json(['data' => $fulfillment], Response::HTTP_CREATED); + } + + public function refund(CreateRefundRequest $request, Store $store, Order $order): JsonResponse + { + $this->ensureOrder($store, $order); + $data = $request->validated(); + $payment = $order->payments()->findOrFail($data['payment_id']); + $refund = $this->refundService->create($order, $payment, $data['amount'], $data['reason'] ?? null, $data['restock'] ?? false); + + return response()->json(['data' => $refund], Response::HTTP_CREATED); + } + + public function confirmPayment(Store $store, Order $order): OrderResource + { + $this->ensureOrder($store, $order); + $this->paymentService->confirmBankTransfer($order); + + return new OrderResource($order->refresh()->load(['lines', 'payments', 'fulfillments.lines'])); + } + + private function ensureStore(Store $store): void + { + abort_unless($store->is(app('current_store')), Response::HTTP_NOT_FOUND); + } + + private function ensureOrder(Store $store, Order $order): void + { + $this->ensureStore($store); + abort_unless($order->store_id === $store->id, Response::HTTP_NOT_FOUND); + } +} diff --git a/app/Http/Controllers/Api/Admin/PageController.php b/app/Http/Controllers/Api/Admin/PageController.php new file mode 100644 index 00000000..334a484f --- /dev/null +++ b/app/Http/Controllers/Api/Admin/PageController.php @@ -0,0 +1,68 @@ +ensureStore($store); + + return response()->json(['data' => Page::query()->latest()->paginate(15)]); + } + + public function store(StorePageRequest $request, Store $store): JsonResponse + { + $this->ensureStore($store); + $data = $request->safe()->only(['title', 'handle', 'body_html', 'status']); + $data['store_id'] = $store->id; + $data['handle'] = $this->handleGenerator->generate($data['handle'] ?? $data['title'], 'pages', $store->id); + $page = Page::query()->create($data); + + return response()->json(['data' => $page], Response::HTTP_CREATED); + } + + public function update(UpdatePageRequest $request, Store $store, Page $page): JsonResponse + { + $this->ensureRelated($store, $page); + $data = $request->safe()->only(['title', 'handle', 'body_html', 'status']); + + if (isset($data['handle'])) { + $data['handle'] = $this->handleGenerator->generate($data['handle'], 'pages', $store->id, $page->id); + } + + $page->update($data); + + return response()->json(['data' => $page->refresh()]); + } + + public function destroy(Store $store, Page $page): JsonResponse + { + $this->ensureRelated($store, $page); + $page->delete(); + + return response()->json(['deleted' => true]); + } + + private function ensureStore(Store $store): void + { + abort_unless($store->is(app('current_store')), Response::HTTP_NOT_FOUND); + } + + private function ensureRelated(Store $store, Page $page): void + { + $this->ensureStore($store); + abort_unless($page->store_id === $store->id, Response::HTTP_NOT_FOUND); + } +} diff --git a/app/Http/Controllers/Api/Admin/PlatformController.php b/app/Http/Controllers/Api/Admin/PlatformController.php new file mode 100644 index 00000000..0eff2443 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/PlatformController.php @@ -0,0 +1,53 @@ +validate(['name' => ['required', 'string', 'max:255'], 'billing_email' => ['required', 'email']]); + + return response()->json(['data' => Organization::query()->create($validated)], Response::HTTP_CREATED); + } + + public function store(Request $request): JsonResponse + { + $validated = $request->validate(['organization_id' => ['required', 'exists:organizations,id'], 'name' => ['required', 'string', 'max:255'], 'handle' => ['required', 'alpha_dash', 'unique:stores,handle'], 'default_currency' => ['sometimes', 'string', 'size:3'], 'default_locale' => ['sometimes', 'string'], 'timezone' => ['sometimes', 'timezone']]); + + return response()->json(['data' => Store::query()->create($validated)], Response::HTTP_CREATED); + } + + public function invite(Request $request, Store $store): JsonResponse + { + abort_unless($store->is(app('current_store')), Response::HTTP_NOT_FOUND); + $validated = $request->validate(['email' => ['required', 'email'], 'name' => ['required', 'string', 'max:255'], 'role' => ['required', 'in:admin,staff,support']]); + $user = DB::transaction(function () use ($validated, $store): User { + $user = User::query()->firstOrCreate(['email' => $validated['email']], ['name' => $validated['name'], 'password_hash' => Hash::make(Str::random(32)), 'status' => 'active']); + $store->users()->syncWithoutDetaching([$user->id => ['role' => StoreUserRole::from($validated['role'])->value, 'created_at' => now()]]); + + return $user; + }); + + return response()->json(['data' => $user], Response::HTTP_CREATED); + } + + public function me(Request $request, Store $store): JsonResponse + { + abort_unless($store->is(app('current_store')), Response::HTTP_NOT_FOUND); + + return response()->json(['data' => ['user' => $request->user(), 'store' => $store, 'role' => $request->user()->roleForStore($store)]]); + } +} diff --git a/app/Http/Controllers/Api/Admin/ProductController.php b/app/Http/Controllers/Api/Admin/ProductController.php new file mode 100644 index 00000000..94b695a2 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/ProductController.php @@ -0,0 +1,69 @@ +ensureStore($store); + $products = Product::query()->with('variants')->when($request->filled('status'), fn (Builder $query): Builder => $query->where('status', $request->string('status')))->when($request->filled('search'), fn (Builder $query): Builder => $query->where('title', 'like', '%'.$request->string('search').'%'))->latest()->paginate(min(50, max(1, $request->integer('per_page', 15)))); + + return ProductResource::collection($products); + } + + public function store(StoreProductRequest $request, Store $store): JsonResponse + { + $this->ensureStore($store); + + return (new ProductResource($this->productService->create($store, $request->validated())))->response()->setStatusCode(Response::HTTP_CREATED); + } + + public function show(Store $store, Product $product): ProductResource + { + $this->ensureRelated($store, $product); + + return new ProductResource($product->load(['options.values', 'variants.inventoryItem', 'collections', 'media'])); + } + + public function update(UpdateProductRequest $request, Store $store, Product $product): ProductResource + { + $this->ensureRelated($store, $product); + + return new ProductResource($this->productService->update($product, $request->validated())); + } + + public function destroy(Store $store, Product $product): JsonResponse + { + $this->ensureRelated($store, $product); + $this->productService->delete($product); + + return response()->json(['deleted' => true]); + } + + private function ensureStore(Store $store): void + { + abort_unless($store->is(app('current_store')), Response::HTTP_NOT_FOUND); + } + + private function ensureRelated(Store $store, Product $product): void + { + $this->ensureStore($store); + abort_unless($product->store_id === $store->id, Response::HTTP_NOT_FOUND); + } +} diff --git a/app/Http/Controllers/Api/Admin/SearchController.php b/app/Http/Controllers/Api/Admin/SearchController.php new file mode 100644 index 00000000..6d58f4fd --- /dev/null +++ b/app/Http/Controllers/Api/Admin/SearchController.php @@ -0,0 +1,37 @@ +ensureStore($store); + DB::table('products_fts')->where('store_id', $store->id)->delete(); + Product::query()->lazyById()->each(fn (Product $product) => $this->searchService->syncProduct($product)); + + return response()->json(['indexed' => Product::query()->count()], Response::HTTP_ACCEPTED); + } + + public function status(Store $store): JsonResponse + { + $this->ensureStore($store); + + return response()->json(['data' => ['products' => Product::query()->count(), 'indexed' => DB::table('products_fts')->where('store_id', $store->id)->count()]]); + } + + private function ensureStore(Store $store): void + { + abort_unless($store->is(app('current_store')), Response::HTTP_NOT_FOUND); + } +} diff --git a/app/Http/Controllers/Api/Admin/ShippingZoneController.php b/app/Http/Controllers/Api/Admin/ShippingZoneController.php new file mode 100644 index 00000000..f5b20c41 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/ShippingZoneController.php @@ -0,0 +1,56 @@ +ensureStore($store); + + return response()->json(['data' => ShippingZone::query()->with('rates')->get()]); + } + + public function store(StoreShippingZoneRequest $request, Store $store): JsonResponse + { + $this->ensureStore($store); + $zone = ShippingZone::query()->create(['store_id' => $store->id, ...$request->validated()]); + + return response()->json(['data' => $zone], Response::HTTP_CREATED); + } + + public function update(StoreShippingZoneRequest $request, Store $store, ShippingZone $zone): JsonResponse + { + $this->ensureRelated($store, $zone); + $zone->update($request->validated()); + + return response()->json(['data' => $zone->refresh()->load('rates')]); + } + + public function storeRate(StoreShippingRateRequest $request, Store $store, ShippingZone $zone): JsonResponse + { + $this->ensureRelated($store, $zone); + $rate = $zone->rates()->create($request->validated()); + + return response()->json(['data' => $rate], Response::HTTP_CREATED); + } + + private function ensureStore(Store $store): void + { + abort_unless($store->is(app('current_store')), Response::HTTP_NOT_FOUND); + } + + private function ensureRelated(Store $store, ShippingZone $zone): void + { + $this->ensureStore($store); + abort_unless($zone->store_id === $store->id, Response::HTTP_NOT_FOUND); + } +} diff --git a/app/Http/Controllers/Api/Admin/TaxSettingsController.php b/app/Http/Controllers/Api/Admin/TaxSettingsController.php new file mode 100644 index 00000000..6d954e60 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/TaxSettingsController.php @@ -0,0 +1,33 @@ +ensureStore($store); + + return response()->json(['data' => TaxSettings::query()->find($store->id)]); + } + + public function update(UpdateTaxSettingsRequest $request, Store $store): JsonResponse + { + $this->ensureStore($store); + $settings = TaxSettings::query()->updateOrCreate(['store_id' => $store->id], $request->validated()); + + return response()->json(['data' => $settings]); + } + + private function ensureStore(Store $store): void + { + abort_unless($store->is(app('current_store')), Response::HTTP_NOT_FOUND); + } +} diff --git a/app/Http/Controllers/Api/Admin/ThemeController.php b/app/Http/Controllers/Api/Admin/ThemeController.php new file mode 100644 index 00000000..65704785 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/ThemeController.php @@ -0,0 +1,55 @@ +ensureStore($store); + $validated = $request->validate(['name' => ['required', 'string', 'max:255'], 'version' => ['nullable', 'string', 'max:50']]); + $theme = Theme::query()->create(['store_id' => $store->id, ...$validated]); + + return response()->json(['data' => $theme], Response::HTTP_CREATED); + } + + public function publish(Store $store, Theme $theme): JsonResponse + { + $this->ensureRelated($store, $theme); + DB::transaction(function () use ($theme): void { + Theme::query()->whereKeyNot($theme->id)->update(['status' => ThemeStatus::Draft, 'published_at' => null]); + $theme->update(['status' => ThemeStatus::Published, 'published_at' => now()]); + }); + + return response()->json(['data' => $theme->refresh()]); + } + + public function updateSettings(Request $request, Store $store, Theme $theme): JsonResponse + { + $this->ensureRelated($store, $theme); + $validated = $request->validate(['settings' => ['required', 'array']]); + $settings = $theme->settings()->updateOrCreate(['theme_id' => $theme->id], ['settings_json' => $validated['settings']]); + + return response()->json(['data' => $settings]); + } + + private function ensureStore(Store $store): void + { + abort_unless($store->is(app('current_store')), Response::HTTP_NOT_FOUND); + } + + private function ensureRelated(Store $store, Theme $theme): void + { + $this->ensureStore($store); + abort_unless($theme->store_id === $store->id, Response::HTTP_NOT_FOUND); + } +} diff --git a/app/Http/Controllers/Api/Storefront/AnalyticsController.php b/app/Http/Controllers/Api/Storefront/AnalyticsController.php new file mode 100644 index 00000000..1004afdf --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/AnalyticsController.php @@ -0,0 +1,34 @@ +validate([ + 'events' => ['required', 'array', 'min:1', 'max:50'], + 'events.*.type' => ['required', 'string'], + 'events.*.properties' => ['sometimes', 'array'], + 'events.*.client_event_id' => ['sometimes', 'nullable', 'string', 'max:255'], + ]); + /** @var Store $store */ + $store = app('current_store'); + + foreach ($validated['events'] as $event) { + $this->analyticsService->track($store, $event['type'], $event['properties'] ?? [], $request->hasSession() ? $request->session()->getId() : null, null, Arr::get($event, 'client_event_id')); + } + + return response()->json(['accepted' => count($validated['events'])], Response::HTTP_ACCEPTED); + } +} diff --git a/app/Http/Controllers/Api/Storefront/CartController.php b/app/Http/Controllers/Api/Storefront/CartController.php new file mode 100644 index 00000000..91d2c8b8 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/CartController.php @@ -0,0 +1,72 @@ +cartService->create($store)))->response()->setStatusCode(Response::HTTP_CREATED); + } + + public function show(Cart $cart): CartResource + { + $this->ensureCurrentStore($cart); + + return new CartResource($cart->load('lines.variant.product')); + } + + public function addLine(Request $request, Cart $cart): CartResource|JsonResponse + { + $this->ensureCurrentStore($cart); + $validated = $request->validate(['variant_id' => ['required', 'integer', 'exists:product_variants,id'], 'quantity' => ['required', 'integer', 'min:1'], 'expected_version' => ['sometimes', 'integer', 'min:1']]); + + return $this->mutate($cart, fn () => $this->cartService->addLine($cart, $validated['variant_id'], $validated['quantity'], $validated['expected_version'] ?? null)); + } + + public function updateLine(Request $request, Cart $cart, int $line): CartResource|JsonResponse + { + $this->ensureCurrentStore($cart); + $validated = $request->validate(['quantity' => ['required', 'integer', 'min:0'], 'expected_version' => ['sometimes', 'integer', 'min:1']]); + + return $this->mutate($cart, fn () => $this->cartService->updateLineQuantity($cart, $line, $validated['quantity'], $validated['expected_version'] ?? null)); + } + + public function destroyLine(Request $request, Cart $cart, int $line): CartResource|JsonResponse + { + $this->ensureCurrentStore($cart); + + return $this->mutate($cart, fn () => $this->cartService->removeLine($cart, $line, $request->integer('expected_version') ?: null)); + } + + private function mutate(Cart $cart, callable $mutation): CartResource|JsonResponse + { + try { + $mutation(); + } catch (CartVersionConflictException $exception) { + return response()->json(['message' => $exception->getMessage(), 'code' => 'cart_version_conflict', 'cart' => new CartResource($cart->refresh()->load('lines'))], Response::HTTP_CONFLICT); + } + + return new CartResource($cart->refresh()->load('lines.variant.product')); + } + + private function ensureCurrentStore(Cart $cart): void + { + abort_unless($cart->store_id === app('current_store')->id, Response::HTTP_NOT_FOUND); + } +} diff --git a/app/Http/Controllers/Api/Storefront/CheckoutController.php b/app/Http/Controllers/Api/Storefront/CheckoutController.php new file mode 100644 index 00000000..21f206ee --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/CheckoutController.php @@ -0,0 +1,93 @@ +validate(['cart_id' => ['required', 'integer', 'exists:carts,id']]); + $cart = Cart::query()->findOrFail($validated['cart_id']); + abort_unless($cart->store_id === app('current_store')->id, Response::HTTP_NOT_FOUND); + + return (new CheckoutResource($this->checkoutService->create($cart)))->response()->setStatusCode(Response::HTTP_CREATED); + } + + public function show(Checkout $checkout): CheckoutResource + { + $this->ensureCurrentStore($checkout); + + return new CheckoutResource($checkout); + } + + public function address(SetCheckoutAddressRequest $request, Checkout $checkout): CheckoutResource + { + $this->ensureCurrentStore($checkout); + + return new CheckoutResource($this->checkoutService->setAddress($checkout, $request->validated())); + } + + public function shipping(SetCheckoutShippingRequest $request, Checkout $checkout): CheckoutResource + { + $this->ensureCurrentStore($checkout); + + return new CheckoutResource($this->checkoutService->setShippingMethod($checkout, $request->validated('shipping_rate_id'))); + } + + public function paymentMethod(Request $request, Checkout $checkout): CheckoutResource + { + $this->ensureCurrentStore($checkout); + $validated = $request->validate(['payment_method' => ['required', 'in:credit_card,paypal,bank_transfer']]); + + return new CheckoutResource($this->checkoutService->selectPaymentMethod($checkout, PaymentMethod::from($validated['payment_method']))); + } + + public function applyDiscount(Request $request, Checkout $checkout): CheckoutResource + { + $this->ensureCurrentStore($checkout); + $validated = $request->validate(['code' => ['required', 'string', 'max:255']]); + $this->discountService->validate($validated['code'], $checkout->store, $checkout->cart); + $checkout->update(['discount_code' => $validated['code']]); + $this->pricingEngine->calculate($checkout->refresh()); + + return new CheckoutResource($checkout->refresh()); + } + + public function removeDiscount(Checkout $checkout): CheckoutResource + { + $this->ensureCurrentStore($checkout); + $checkout->update(['discount_code' => null]); + $this->pricingEngine->calculate($checkout->refresh()); + + return new CheckoutResource($checkout->refresh()); + } + + public function pay(Request $request, Checkout $checkout): OrderResource + { + $this->ensureCurrentStore($checkout); + + return new OrderResource($this->checkoutService->completeCheckout($checkout, $request->all())); + } + + private function ensureCurrentStore(Checkout $checkout): void + { + abort_unless($checkout->store_id === app('current_store')->id, Response::HTTP_NOT_FOUND); + } +} diff --git a/app/Http/Controllers/Api/Storefront/OrderController.php b/app/Http/Controllers/Api/Storefront/OrderController.php new file mode 100644 index 00000000..fd8c57e2 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/OrderController.php @@ -0,0 +1,17 @@ +where('order_number', $orderNumber)->with(['lines', 'payments', 'refunds', 'fulfillments.lines'])->firstOrFail(); + + return new OrderResource($order); + } +} diff --git a/app/Http/Controllers/Api/Storefront/SearchController.php b/app/Http/Controllers/Api/Storefront/SearchController.php new file mode 100644 index 00000000..3eaaf3b9 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/SearchController.php @@ -0,0 +1,33 @@ +validate(['q' => ['required', 'string', 'min:2', 'max:255'], 'per_page' => ['sometimes', 'integer', 'min:1', 'max:50'], 'vendor' => ['sometimes', 'string'], 'collection_id' => ['sometimes', 'integer'], 'sort' => ['sometimes', 'in:relevance,newest,price_asc,price_desc']]); + + return ProductResource::collection($this->searchService->search($store, $validated['q'], $request->except(['q', 'per_page']), $validated['per_page'] ?? 24)); + } + + public function suggest(Request $request): AnonymousResourceCollection + { + /** @var Store $store */ + $store = app('current_store'); + $validated = $request->validate(['q' => ['required', 'string', 'min:2', 'max:255'], 'limit' => ['sometimes', 'integer', 'min:1', 'max:10']]); + + return ProductResource::collection($this->searchService->autocomplete($store, $validated['q'], $validated['limit'] ?? 5)); + } +} diff --git a/app/Http/Middleware/CheckStoreRole.php b/app/Http/Middleware/CheckStoreRole.php new file mode 100644 index 00000000..e8a0b2a2 --- /dev/null +++ b/app/Http/Middleware/CheckStoreRole.php @@ -0,0 +1,52 @@ +bound('current_store'), Response::HTTP_FORBIDDEN); + + /** @var Store $store */ + $store = app('current_store'); + $user = $request->user(); + + abort_unless($user instanceof User, Response::HTTP_FORBIDDEN); + + $storeUser = $user->storeUsers() + ->where('store_id', $store->getKey()) + ->first(); + + abort_if($storeUser === null, Response::HTTP_FORBIDDEN, 'You do not have access to this store.'); + + if ($roles !== []) { + $allowedRoles = array_values(array_filter(array_map( + static fn (string $role): ?StoreUserRole => StoreUserRole::tryFrom($role), + $roles, + ))); + + abort_unless( + in_array($storeUser->role, $allowedRoles, true), + Response::HTTP_FORBIDDEN, + 'Insufficient permissions.', + ); + } + + $request->attributes->set('store_user', $storeUser); + + return $next($request); + } +} diff --git a/app/Http/Middleware/CustomerAuthenticate.php b/app/Http/Middleware/CustomerAuthenticate.php new file mode 100644 index 00000000..c28fa3ce --- /dev/null +++ b/app/Http/Middleware/CustomerAuthenticate.php @@ -0,0 +1,27 @@ +check()) { + return $next($request); + } + + $request->session()->put('url.intended', $request->fullUrl()); + + return redirect()->route('storefront.account.login'); + } +} diff --git a/app/Http/Middleware/ResolveStore.php b/app/Http/Middleware/ResolveStore.php new file mode 100644 index 00000000..294770f9 --- /dev/null +++ b/app/Http/Middleware/ResolveStore.php @@ -0,0 +1,80 @@ +is('admin', 'admin/*', 'api/admin/*') || $request->routeIs('admin.*', 'api.admin.*'); + $store = $isAdminRequest + ? $this->resolveAdminStore($request) + : $this->resolveStorefrontStore($request); + + if ($store->status === StoreStatus::Suspended) { + if (! $isAdminRequest) { + abort(Response::HTTP_SERVICE_UNAVAILABLE, 'This store is currently unavailable.'); + } + + if (! $request->isMethodSafe()) { + abort(Response::HTTP_FORBIDDEN, 'This suspended store cannot be modified.'); + } + } + + app()->instance('current_store', $store); + View::share('currentStore', $store); + + return $next($request); + } + + private function resolveStorefrontStore(Request $request): Store + { + $hostname = mb_strtolower($request->getHost()); + $cacheKey = "store_domain:{$hostname}"; + + $storeId = Cache::remember( + $cacheKey, + now()->addMinutes(5), + fn (): ?int => StoreDomain::query() + ->where('hostname', $hostname) + ->value('store_id'), + ); + + abort_if($storeId === null, Response::HTTP_NOT_FOUND, 'Store not found.'); + + return Store::query()->findOrFail($storeId); + } + + private function resolveAdminStore(Request $request): Store + { + $user = $request->user(); + $routeStore = $request->route('store'); + $storeId = $request->is('api/admin/*') + ? ($routeStore instanceof Store ? $routeStore->id : $routeStore) + : $request->session()->get('current_store_id'); + + abort_unless($user instanceof User && is_numeric($storeId), Response::HTTP_FORBIDDEN); + + $store = Store::query()->findOrFail((int) $storeId); + $hasAccess = $user->storeUsers()->where('store_id', $store->getKey())->exists(); + + abort_unless($hasAccess, Response::HTTP_FORBIDDEN, 'You do not have access to this store.'); + + return $store; + } +} diff --git a/app/Http/Requests/CreateFulfillmentRequest.php b/app/Http/Requests/CreateFulfillmentRequest.php new file mode 100644 index 00000000..35292a7b --- /dev/null +++ b/app/Http/Requests/CreateFulfillmentRequest.php @@ -0,0 +1,32 @@ +|string> + */ + public function rules(): array + { + return [ + 'lines' => ['required', 'array', 'min:1'], + 'lines.*' => ['required', 'integer', 'min:1'], + 'tracking_company' => ['nullable', 'string', 'max:255'], + 'tracking_number' => ['nullable', 'string', 'max:255'], + 'tracking_url' => ['nullable', 'url', 'max:2048'], + ]; + } +} diff --git a/app/Http/Requests/CreateRefundRequest.php b/app/Http/Requests/CreateRefundRequest.php new file mode 100644 index 00000000..11da96db --- /dev/null +++ b/app/Http/Requests/CreateRefundRequest.php @@ -0,0 +1,31 @@ +|string> + */ + public function rules(): array + { + return [ + 'payment_id' => ['required', 'integer', 'exists:payments,id'], + 'amount' => ['required', 'integer', 'min:1'], + 'reason' => ['nullable', 'string', 'max:1000'], + 'restock' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/SetCheckoutAddressRequest.php b/app/Http/Requests/SetCheckoutAddressRequest.php new file mode 100644 index 00000000..49f091c7 --- /dev/null +++ b/app/Http/Requests/SetCheckoutAddressRequest.php @@ -0,0 +1,36 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email'], + 'shipping_address' => ['required', 'array'], + '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'], + 'billing_address' => ['sometimes', 'array'], + ]; + } +} diff --git a/app/Http/Requests/SetCheckoutShippingRequest.php b/app/Http/Requests/SetCheckoutShippingRequest.php new file mode 100644 index 00000000..d529e209 --- /dev/null +++ b/app/Http/Requests/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/StoreCollectionRequest.php b/app/Http/Requests/StoreCollectionRequest.php new file mode 100644 index 00000000..78e702b3 --- /dev/null +++ b/app/Http/Requests/StoreCollectionRequest.php @@ -0,0 +1,33 @@ +|string> + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['sometimes', 'nullable', 'string', 'max:255'], + 'description_html' => ['sometimes', 'nullable', 'string'], + 'type' => ['sometimes', 'in:manual,automated'], + 'status' => ['sometimes', 'in:draft,active,archived'], + 'product_ids' => ['sometimes', 'array'], + ]; + } +} diff --git a/app/Http/Requests/StoreDiscountRequest.php b/app/Http/Requests/StoreDiscountRequest.php new file mode 100644 index 00000000..dc90ed74 --- /dev/null +++ b/app/Http/Requests/StoreDiscountRequest.php @@ -0,0 +1,36 @@ +|string> + */ + public function rules(): array + { + return [ + 'type' => ['required', 'in:code,automatic'], + 'code' => ['nullable', 'required_if:type,code', 'string', 'max:255'], + 'value_type' => ['required', 'in:percent,fixed,free_shipping'], + 'value_amount' => ['required', 'integer', 'min:0'], + 'starts_at' => ['required', 'date'], + 'ends_at' => ['nullable', 'date', 'after:starts_at'], + 'usage_limit' => ['nullable', 'integer', 'min:1'], + 'rules_json' => ['sometimes', 'array'], + 'status' => ['sometimes', 'in:draft,active,expired,disabled'], + ]; + } +} diff --git a/app/Http/Requests/StorePageRequest.php b/app/Http/Requests/StorePageRequest.php new file mode 100644 index 00000000..7ed2bf1e --- /dev/null +++ b/app/Http/Requests/StorePageRequest.php @@ -0,0 +1,33 @@ +|string> + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['sometimes', 'nullable', 'string', 'max:255'], + 'body_html' => ['sometimes', 'nullable', 'string'], + 'status' => ['sometimes', 'in:draft,published,archived'], + 'seo_title' => ['sometimes', 'nullable', 'string', 'max:255'], + 'seo_description' => ['sometimes', 'nullable', 'string', 'max:500'], + ]; + } +} diff --git a/app/Http/Requests/StoreProductRequest.php b/app/Http/Requests/StoreProductRequest.php new file mode 100644 index 00000000..f029bbac --- /dev/null +++ b/app/Http/Requests/StoreProductRequest.php @@ -0,0 +1,37 @@ +|string> + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['sometimes', 'nullable', 'string', 'max:255'], + 'description_html' => ['sometimes', 'nullable', 'string'], + 'status' => ['sometimes', 'in:draft,active,archived'], + 'vendor' => ['sometimes', 'nullable', 'string', 'max:255'], + 'product_type' => ['sometimes', 'nullable', 'string', 'max:255'], + 'tags' => ['sometimes', 'array'], + 'options' => ['sometimes', 'array', 'max:3'], + 'variants' => ['sometimes', 'array'], + 'collection_ids' => ['sometimes', 'array'], + ]; + } +} diff --git a/app/Http/Requests/StoreShippingRateRequest.php b/app/Http/Requests/StoreShippingRateRequest.php new file mode 100644 index 00000000..7954b0be --- /dev/null +++ b/app/Http/Requests/StoreShippingRateRequest.php @@ -0,0 +1,31 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', 'in:flat,weight,price,carrier'], + 'config_json' => ['required', 'array'], + 'is_active' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/StoreShippingZoneRequest.php b/app/Http/Requests/StoreShippingZoneRequest.php new file mode 100644 index 00000000..80658362 --- /dev/null +++ b/app/Http/Requests/StoreShippingZoneRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'countries_json' => ['required', 'array', 'min:1'], + 'regions_json' => ['sometimes', 'array'], + ]; + } +} diff --git a/app/Http/Requests/UpdateCollectionRequest.php b/app/Http/Requests/UpdateCollectionRequest.php new file mode 100644 index 00000000..eaa42bbc --- /dev/null +++ b/app/Http/Requests/UpdateCollectionRequest.php @@ -0,0 +1,33 @@ +|string> + */ + public function rules(): array + { + return [ + 'title' => ['sometimes', 'required', 'string', 'max:255'], + 'handle' => ['sometimes', 'string', 'max:255'], + 'description_html' => ['sometimes', 'nullable', 'string'], + 'type' => ['sometimes', 'in:manual,automated'], + 'status' => ['sometimes', 'in:draft,active,archived'], + 'product_ids' => ['sometimes', 'array'], + ]; + } +} diff --git a/app/Http/Requests/UpdateDiscountRequest.php b/app/Http/Requests/UpdateDiscountRequest.php new file mode 100644 index 00000000..3567055b --- /dev/null +++ b/app/Http/Requests/UpdateDiscountRequest.php @@ -0,0 +1,36 @@ +|string> + */ + public function rules(): array + { + return [ + 'type' => ['sometimes', 'in:code,automatic'], + 'code' => ['sometimes', 'nullable', 'string', 'max:255'], + 'value_type' => ['sometimes', 'in:percent,fixed,free_shipping'], + 'value_amount' => ['sometimes', 'integer', 'min:0'], + 'starts_at' => ['sometimes', 'date'], + 'ends_at' => ['sometimes', 'nullable', 'date'], + 'usage_limit' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'rules_json' => ['sometimes', 'array'], + 'status' => ['sometimes', 'in:draft,active,expired,disabled'], + ]; + } +} diff --git a/app/Http/Requests/UpdatePageRequest.php b/app/Http/Requests/UpdatePageRequest.php new file mode 100644 index 00000000..e1df76b0 --- /dev/null +++ b/app/Http/Requests/UpdatePageRequest.php @@ -0,0 +1,33 @@ +|string> + */ + public function rules(): array + { + return [ + 'title' => ['sometimes', 'required', 'string', 'max:255'], + 'handle' => ['sometimes', 'string', 'max:255'], + 'body_html' => ['sometimes', 'nullable', 'string'], + 'status' => ['sometimes', 'in:draft,published,archived'], + 'seo_title' => ['sometimes', 'nullable', 'string', 'max:255'], + 'seo_description' => ['sometimes', 'nullable', 'string', 'max:500'], + ]; + } +} diff --git a/app/Http/Requests/UpdateProductRequest.php b/app/Http/Requests/UpdateProductRequest.php new file mode 100644 index 00000000..05052f15 --- /dev/null +++ b/app/Http/Requests/UpdateProductRequest.php @@ -0,0 +1,37 @@ +|string> + */ + public function rules(): array + { + return [ + 'title' => ['sometimes', 'required', 'string', 'max:255'], + 'handle' => ['sometimes', 'string', 'max:255'], + 'description_html' => ['sometimes', 'nullable', 'string'], + 'status' => ['sometimes', 'in:draft,active,archived'], + 'vendor' => ['sometimes', 'nullable', 'string', 'max:255'], + 'product_type' => ['sometimes', 'nullable', 'string', 'max:255'], + 'tags' => ['sometimes', 'array'], + 'options' => ['sometimes', 'array', 'max:3'], + 'variants' => ['sometimes', 'array'], + 'collection_ids' => ['sometimes', 'array'], + ]; + } +} diff --git a/app/Http/Requests/UpdateTaxSettingsRequest.php b/app/Http/Requests/UpdateTaxSettingsRequest.php new file mode 100644 index 00000000..25f4926e --- /dev/null +++ b/app/Http/Requests/UpdateTaxSettingsRequest.php @@ -0,0 +1,31 @@ +|string> + */ + public function rules(): array + { + return [ + 'mode' => ['required', 'in:manual,provider'], + 'provider' => ['required', 'in:none,stripe_tax'], + 'prices_include_tax' => ['required', 'boolean'], + 'config_json' => ['required', 'array'], + ]; + } +} diff --git a/app/Http/Resources/CartResource.php b/app/Http/Resources/CartResource.php new file mode 100644 index 00000000..88dc36cd --- /dev/null +++ b/app/Http/Resources/CartResource.php @@ -0,0 +1,38 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'version' => $this->cart_version, + 'status' => $this->status, + 'currency' => $this->currency, + 'lines' => $this->whenLoaded('lines', fn () => $this->lines->map(fn ($line) => [ + 'id' => $line->id, + 'variant_id' => $line->variant_id, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'subtotal_amount' => $line->line_subtotal_amount, + 'discount_amount' => $line->line_discount_amount, + 'total_amount' => $line->line_total_amount, + ])), + 'totals' => [ + 'subtotal' => $this->whenLoaded('lines', fn () => $this->lines->sum('line_subtotal_amount')), + 'discount' => $this->whenLoaded('lines', fn () => $this->lines->sum('line_discount_amount')), + 'total' => $this->whenLoaded('lines', fn () => $this->lines->sum('line_total_amount')), + ], + ]; + } +} diff --git a/app/Http/Resources/CheckoutResource.php b/app/Http/Resources/CheckoutResource.php new file mode 100644 index 00000000..621df1c0 --- /dev/null +++ b/app/Http/Resources/CheckoutResource.php @@ -0,0 +1,31 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'cart_id' => $this->cart_id, + 'status' => $this->status, + '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, + 'expires_at' => $this->expires_at, + ]; + } +} diff --git a/app/Http/Resources/CollectionResource.php b/app/Http/Resources/CollectionResource.php new file mode 100644 index 00000000..d91080ea --- /dev/null +++ b/app/Http/Resources/CollectionResource.php @@ -0,0 +1,28 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'handle' => $this->handle, + 'description_html' => $this->description_html, + 'type' => $this->type, + 'status' => $this->status, + 'products_count' => $this->whenCounted('products'), + 'products' => ProductResource::collection($this->whenLoaded('products')), + ]; + } +} diff --git a/app/Http/Resources/DiscountResource.php b/app/Http/Resources/DiscountResource.php new file mode 100644 index 00000000..d97d9a44 --- /dev/null +++ b/app/Http/Resources/DiscountResource.php @@ -0,0 +1,31 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'type' => $this->type, + 'code' => $this->code, + 'value_type' => $this->value_type, + 'value_amount' => $this->value_amount, + 'starts_at' => $this->starts_at, + 'ends_at' => $this->ends_at, + 'usage_limit' => $this->usage_limit, + 'usage_count' => $this->usage_count, + 'rules' => $this->rules_json, + 'status' => $this->status, + ]; + } +} diff --git a/app/Http/Resources/OrderResource.php b/app/Http/Resources/OrderResource.php new file mode 100644 index 00000000..d2b03037 --- /dev/null +++ b/app/Http/Resources/OrderResource.php @@ -0,0 +1,38 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'order_number' => $this->order_number, + 'status' => $this->status, + 'financial_status' => $this->financial_status, + 'fulfillment_status' => $this->fulfillment_status, + 'payment_method' => $this->payment_method, + '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, + 'email' => $this->email, + 'placed_at' => $this->placed_at, + 'lines' => $this->whenLoaded('lines'), + 'payments' => $this->whenLoaded('payments'), + 'refunds' => $this->whenLoaded('refunds'), + 'fulfillments' => $this->whenLoaded('fulfillments'), + ]; + } +} diff --git a/app/Http/Resources/ProductResource.php b/app/Http/Resources/ProductResource.php new file mode 100644 index 00000000..5e6eb077 --- /dev/null +++ b/app/Http/Resources/ProductResource.php @@ -0,0 +1,38 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'handle' => $this->handle, + 'status' => $this->status, + 'description_html' => $this->description_html, + 'vendor' => $this->vendor, + 'product_type' => $this->product_type, + 'tags' => $this->tags, + 'published_at' => $this->published_at, + 'variants' => $this->whenLoaded('variants', fn () => $this->variants->map(fn ($variant) => [ + 'id' => $variant->id, + 'sku' => $variant->sku, + 'price_amount' => $variant->price_amount, + 'compare_at_amount' => $variant->compare_at_amount, + 'currency' => $variant->currency, + 'requires_shipping' => $variant->requires_shipping, + 'status' => $variant->status, + ])), + ]; + } +} diff --git a/app/Jobs/AggregateAnalytics.php b/app/Jobs/AggregateAnalytics.php new file mode 100644 index 00000000..7932fa42 --- /dev/null +++ b/app/Jobs/AggregateAnalytics.php @@ -0,0 +1,59 @@ +date ?? now('UTC')->subDay()->toDateString(), 'UTC'); + $eventsByStore = AnalyticsEvent::withoutGlobalScope(StoreScope::class) + ->whereBetween('created_at', [$date->startOfDay(), $date->endOfDay()]) + ->get() + ->groupBy('store_id'); + + foreach ($eventsByStore as $storeId => $events) { + $completedCheckouts = $events->where('type', AnalyticsEventType::CheckoutCompleted); + $ordersCount = $completedCheckouts->count(); + $revenueAmount = $completedCheckouts->sum( + fn (AnalyticsEvent $event): int => (int) data_get($event->properties_json, 'total_amount', 0), + ); + + DB::table((new AnalyticsDaily)->getTable())->updateOrInsert( + ['store_id' => (int) $storeId, 'date' => $date->toDateString()], + [ + 'orders_count' => $ordersCount, + 'revenue_amount' => $revenueAmount, + 'aov_amount' => $ordersCount === 0 ? 0 : intdiv($revenueAmount, $ordersCount), + '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, + ], + ); + } + } +} diff --git a/app/Jobs/CancelUnpaidBankTransferOrders.php b/app/Jobs/CancelUnpaidBankTransferOrders.php new file mode 100644 index 00000000..82ab2d38 --- /dev/null +++ b/app/Jobs/CancelUnpaidBankTransferOrders.php @@ -0,0 +1,33 @@ +where('payment_method', PaymentMethod::BankTransfer) + ->where('financial_status', FinancialStatus::Pending) + ->where('placed_at', '<', now()->subDays(7)) + ->lazyById() + ->each(fn (Order $order) => $orderService->cancel($order, 'Bank transfer payment timeout.')); + } +} diff --git a/app/Jobs/CleanupAbandonedCarts.php b/app/Jobs/CleanupAbandonedCarts.php new file mode 100644 index 00000000..8bcddb26 --- /dev/null +++ b/app/Jobs/CleanupAbandonedCarts.php @@ -0,0 +1,29 @@ +where('status', CartStatus::Active) + ->where('updated_at', '<', now()->subDays(14)) + ->update(['status' => CartStatus::Abandoned]); + } +} diff --git a/app/Jobs/DeliverWebhook.php b/app/Jobs/DeliverWebhook.php new file mode 100644 index 00000000..80ccc357 --- /dev/null +++ b/app/Jobs/DeliverWebhook.php @@ -0,0 +1,107 @@ + $payload */ + public function __construct( + public int $deliveryId, + public array $payload, + public int $timestamp, + ) {} + + /** @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->status !== WebhookSubscriptionStatus::Active) { + $delivery->update(['status' => WebhookDeliveryStatus::Failed]); + + return; + } + + $json = json_encode($this->payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + $delivery->update([ + 'attempt_count' => max($delivery->attempt_count, $this->attempts()), + 'last_attempt_at' => now(), + ]); + + try { + $response = Http::connectTimeout(3) + ->timeout(10) + ->withHeaders([ + 'X-Platform-Signature' => $webhooks->sign($json, $subscription->signing_secret_encrypted, $this->timestamp), + 'X-Platform-Event' => $subscription->event_type, + 'X-Platform-Delivery-Id' => $delivery->event_id, + 'X-Platform-Timestamp' => (string) $this->timestamp, + ]) + ->withBody($json, 'application/json') + ->post($subscription->target_url); + } catch (Throwable $exception) { + $this->recordFailure($delivery, null, null, $webhooks); + + throw $exception; + } + + if ($response->failed()) { + $this->recordFailure($delivery, $response, $response->body(), $webhooks); + $response->throw(); + } + + $delivery->update([ + 'status' => WebhookDeliveryStatus::Success, + 'response_code' => $response->status(), + 'response_body_snippet' => Str::limit($response->body(), 1000, ''), + ]); + } + + public function failed(?Throwable $exception): void + { + WebhookDelivery::query()->whereKey($this->deliveryId)->update([ + 'status' => WebhookDeliveryStatus::Failed, + ]); + } + + private function recordFailure( + WebhookDelivery $delivery, + ?Response $response, + ?string $body, + WebhookService $webhooks, + ): void { + $delivery->update([ + 'status' => WebhookDeliveryStatus::Failed, + 'response_code' => $response?->status(), + 'response_body_snippet' => $body === null ? null : Str::limit($body, 1000, ''), + ]); + + $webhooks->recordFailure($delivery->subscription); + } +} diff --git a/app/Jobs/ExpireAbandonedCheckouts.php b/app/Jobs/ExpireAbandonedCheckouts.php new file mode 100644 index 00000000..4c0725e1 --- /dev/null +++ b/app/Jobs/ExpireAbandonedCheckouts.php @@ -0,0 +1,31 @@ +whereNotIn('status', [CheckoutStatus::Completed, CheckoutStatus::Expired]) + ->where(fn ($query) => $query->where('expires_at', '<', now())->orWhere('updated_at', '<', now()->subDay())) + ->lazyById() + ->each(fn (Checkout $checkout) => $checkoutService->expireCheckout($checkout)); + } +} diff --git a/app/Livewire/Admin/AdminComponent.php b/app/Livewire/Admin/AdminComponent.php new file mode 100644 index 00000000..99e2cae7 --- /dev/null +++ b/app/Livewire/Admin/AdminComponent.php @@ -0,0 +1,50 @@ +bound('current_store')) { + return app('current_store'); + } + + /** @var User|null $user */ + $user = auth()->user(); + abort_unless($user instanceof User, 401); + + $store = $user->stores() + ->when(session('current_store_id'), fn ($query) => $query->whereKey(session('current_store_id'))) + ->first() ?? $user->stores()->first(); + + abort_unless($store instanceof Store, 403); + + return $store; + } + + /** @param list $roles */ + protected function authorizeStore(array $roles = [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff, StoreUserRole::Support]): void + { + /** @var User|null $user */ + $user = auth()->user(); + abort_unless($user instanceof User, 401); + abort_unless(in_array($user->roleForStore($this->currentStore()), $roles, true), 403); + } + + public function currency(int $amount, ?string $currency = null): string + { + return Number::currency($amount / 100, in: $currency ?? $this->currentStore()->default_currency); + } + + protected function toast(string $message, string $type = 'success'): void + { + $this->dispatch('toast', type: $type, message: $message); + } +} diff --git a/app/Livewire/Admin/Analytics/Index.php b/app/Livewire/Admin/Analytics/Index.php new file mode 100644 index 00000000..84490a02 --- /dev/null +++ b/app/Livewire/Admin/Analytics/Index.php @@ -0,0 +1,56 @@ +authorizeStore(); + $this->loadAnalytics(); + } + + public function updatedDateRange(): void + { + $this->loadAnalytics(); + } + + public function loadAnalytics(): void + { + $days = $this->dateRange === 'last_7_days' ? 7 : 30; + $rows = AnalyticsDaily::query()->where('store_id', $this->currentStore()->getKey())->where('date', '>=', CarbonImmutable::today()->subDays($days - 1))->orderBy('date')->get(); + $this->revenue = (int) $rows->sum('revenue_amount'); + $this->orders = (int) $rows->sum('orders_count'); + $this->visits = (int) $rows->sum('visits_count'); + $completed = (int) $rows->sum('checkout_completed_count'); + $this->conversionRate = $this->visits > 0 ? round($completed / $this->visits * 100, 2) : 0; + $this->daily = $rows->map(fn (AnalyticsDaily $row): array => ['date' => $row->date->toDateString(), 'revenue' => $row->revenue_amount, 'orders' => $row->orders_count, 'visits' => $row->visits_count])->all(); + } + + public function formattedRevenue(): string + { + return $this->currency($this->revenue); + } + + public function render() + { + return view('livewire.admin.analytics.index'); + } +} diff --git a/app/Livewire/Admin/Apps/Index.php b/app/Livewire/Admin/Apps/Index.php new file mode 100644 index 00000000..3ca80ba1 --- /dev/null +++ b/app/Livewire/Admin/Apps/Index.php @@ -0,0 +1,44 @@ +authorizeStore(); + } + + public function install(int $appId): void + { + $this->authorizeStore([StoreUserRole::Owner, StoreUserRole::Admin]); + App::query()->findOrFail($appId); + AppInstallation::query()->firstOrCreate(['store_id' => $this->currentStore()->getKey(), 'app_id' => $appId], ['scopes_json' => [], 'status' => 'active', 'installed_at' => now()]); + $this->toast('App installed.'); + } + + public function uninstall(int $installationId): void + { + $this->authorizeStore([StoreUserRole::Owner, StoreUserRole::Admin]); + AppInstallation::query()->where('store_id', $this->currentStore()->getKey())->findOrFail($installationId)->update(['status' => 'uninstalled']); + $this->toast('App uninstalled.'); + } + + #[Computed] + public function apps() + { + return App::query()->with(['installations' => fn ($query) => $query->where('store_id', $this->currentStore()->getKey())])->orderBy('name')->get(); + } + + public function render() + { + return view('livewire.admin.apps.index'); + } +} diff --git a/app/Livewire/Admin/Auth/Login.php b/app/Livewire/Admin/Auth/Login.php new file mode 100644 index 00000000..b1ddf910 --- /dev/null +++ b/app/Livewire/Admin/Auth/Login.php @@ -0,0 +1,59 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + 'remember' => ['boolean'], + ]); + $key = Str::transliterate(Str::lower($validated['email']).'|'.request()->ip()); + + if (RateLimiter::tooManyAttempts($key, 5)) { + throw ValidationException::withMessages(['email' => 'Too many sign-in attempts. Please try again shortly.']); + } + + if (! Auth::attempt(['email' => $validated['email'], 'password' => $validated['password'], 'status' => 'active'], $validated['remember'])) { + RateLimiter::hit($key, 60); + throw ValidationException::withMessages(['email' => 'Invalid credentials.']); + } + + /** @var User $user */ + $user = Auth::user(); + $store = $user->stores()->first(); + + if ($store === null) { + Auth::logout(); + throw ValidationException::withMessages(['email' => 'Your account is not assigned to a store.']); + } + + RateLimiter::clear($key); + session()->regenerate(); + session(['current_store_id' => $store->getKey()]); + $user->update(['last_login_at' => now()]); + $this->redirect('/admin', navigate: true); + } + + public function render() + { + return view('livewire.admin.auth.login'); + } +} diff --git a/app/Livewire/Admin/Collections/Form.php b/app/Livewire/Admin/Collections/Form.php new file mode 100644 index 00000000..c12a7037 --- /dev/null +++ b/app/Livewire/Admin/Collections/Form.php @@ -0,0 +1,115 @@ + */ + public array $assignedProductIds = []; + + public function mount(?Collection $collection = null): void + { + if ($collection === null || ! $collection->exists) { + Gate::authorize('create', Collection::class); + + return; + } + Gate::authorize('update', $collection); + $this->collectionId = $collection->getKey(); + $this->title = $collection->title; + $this->handle = $collection->handle; + $this->descriptionHtml = $collection->description_html ?? ''; + $this->status = $collection->status->value; + $this->assignedProductIds = $collection->products()->pluck('products.id')->all(); + } + + public function updatedTitle(): void + { + if ($this->collectionId === null) { + $this->handle = Str::slug($this->title); + } + } + + public function addProduct(int $productId): void + { + Product::query()->where('store_id', $this->currentStore()->getKey())->findOrFail($productId); + if (! in_array($productId, $this->assignedProductIds, true)) { + $this->assignedProductIds[] = $productId; + } + } + + public function removeProduct(int $productId): void + { + $this->assignedProductIds = array_values(array_diff($this->assignedProductIds, [$productId])); + } + + /** @param list $order */ + public function reorderProducts(array $order): void + { + abort_unless(collect($order)->sort()->values()->all() === collect($this->assignedProductIds)->sort()->values()->all(), 422); + $this->assignedProductIds = $order; + } + + public function save(): void + { + $validated = $this->validate([ + 'title' => ['required', 'string', 'max:255'], 'handle' => ['required', 'string', 'max:255'], + 'descriptionHtml' => ['nullable', 'string', 'max:65535'], 'status' => ['required', Rule::enum(CollectionStatus::class)], + 'assignedProductIds' => ['array'], 'assignedProductIds.*' => ['integer'], + ]); + $collection = $this->collectionId === null + ? new Collection(['store_id' => $this->currentStore()->getKey()]) + : Collection::query()->where('store_id', $this->currentStore()->getKey())->findOrFail($this->collectionId); + Gate::authorize($collection->exists ? 'update' : 'create', $collection->exists ? $collection : Collection::class); + $collection->fill(['title' => $validated['title'], 'handle' => Str::slug($validated['handle']), 'description_html' => $validated['descriptionHtml'], 'status' => $validated['status']])->save(); + $validIds = Product::query()->where('store_id', $this->currentStore()->getKey())->whereKey($validated['assignedProductIds'])->pluck('id'); + $collection->products()->sync($validIds->mapWithKeys(fn (int $id, int $position): array => [$id => ['position' => $position]])); + $this->collectionId = $collection->getKey(); + $this->toast('Collection saved.'); + } + + #[Computed] + public function searchResults() + { + if (mb_strlen($this->productSearch) < 2) { + return collect(); + } + + return Product::query()->where('store_id', $this->currentStore()->getKey())->where('title', 'like', '%'.$this->productSearch.'%')->whereKeyNot($this->assignedProductIds)->limit(8)->get(); + } + + #[Computed] + public function assignedProducts() + { + $products = Product::query()->where('store_id', $this->currentStore()->getKey())->whereKey($this->assignedProductIds)->get()->keyBy('id'); + + return collect($this->assignedProductIds)->map(fn (int $id) => $products->get($id))->filter(); + } + + public function render() + { + return view('livewire.admin.collections.form'); + } +} diff --git a/app/Livewire/Admin/Collections/Index.php b/app/Livewire/Admin/Collections/Index.php new file mode 100644 index 00000000..529ade4e --- /dev/null +++ b/app/Livewire/Admin/Collections/Index.php @@ -0,0 +1,53 @@ +resetPage(); + } + + public function deleteCollection(int $id): void + { + $collection = Collection::query()->where('store_id', $this->currentStore()->getKey())->findOrFail($id); + Gate::authorize('delete', $collection); + $collection->products()->detach(); + $collection->delete(); + $this->toast('Collection deleted.'); + } + + #[Computed] + public function collections() + { + return Collection::query()->where('store_id', $this->currentStore()->getKey())->withCount('products') + ->when($this->search, fn (Builder $query) => $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() + { + return view('livewire.admin.collections.index'); + } +} diff --git a/app/Livewire/Admin/Customers/Index.php b/app/Livewire/Admin/Customers/Index.php new file mode 100644 index 00000000..d7881c4f --- /dev/null +++ b/app/Livewire/Admin/Customers/Index.php @@ -0,0 +1,44 @@ +resetPage(); + } + + #[Computed] + public function customers() + { + return Customer::query()->where('store_id', $this->currentStore()->getKey())->withCount('orders')->withSum('orders', 'total_amount') + ->when($this->search, fn (Builder $query) => $query->where(fn (Builder $nested) => $nested->where('name', 'like', '%'.$this->search.'%')->orWhere('email', 'like', '%'.$this->search.'%'))) + ->when($this->marketingFilter !== 'all', fn (Builder $query) => $query->where('marketing_opt_in', $this->marketingFilter === 'subscribed')) + ->latest()->paginate(20); + } + + public function render() + { + return view('livewire.admin.customers.index'); + } +} diff --git a/app/Livewire/Admin/Customers/Show.php b/app/Livewire/Admin/Customers/Show.php new file mode 100644 index 00000000..1f0b777e --- /dev/null +++ b/app/Livewire/Admin/Customers/Show.php @@ -0,0 +1,27 @@ +store_id === $this->currentStore()->getKey(), 404); + $this->customerId = $customer->getKey(); + } + + public function render() + { + $customer = Customer::query()->where('store_id', $this->currentStore()->getKey())->with(['addresses', 'orders' => fn ($query) => $query->latest('placed_at')])->findOrFail($this->customerId); + + return view('livewire.admin.customers.show', ['customer' => $customer]); + } +} diff --git a/app/Livewire/Admin/Dashboard.php b/app/Livewire/Admin/Dashboard.php new file mode 100644 index 00000000..3f8b1477 --- /dev/null +++ b/app/Livewire/Admin/Dashboard.php @@ -0,0 +1,106 @@ + */ + public array $ordersChartData = []; + + /** @var list */ + public array $topProducts = []; + + /** @var array */ + public array $funnelData = []; + + public function mount(): void + { + $this->authorizeStore(); + $this->loadDashboard(); + } + + public function updatedDateRange(): void + { + $this->loadDashboard(); + } + + public function loadDashboard(): void + { + [$start, $end] = $this->dates(); + $store = $this->currentStore(); + $orders = Order::query()->where('store_id', $store->getKey())->whereBetween('placed_at', [$start, $end]); + $this->totalSales = (int) (clone $orders)->sum('total_amount'); + $this->ordersCount = (clone $orders)->count(); + $this->averageOrderValue = $this->ordersCount > 0 ? intdiv($this->totalSales, $this->ordersCount) : 0; + $analytics = AnalyticsDaily::query()->where('store_id', $store->getKey())->whereBetween('date', [$start->toDateString(), $end->toDateString()]); + $this->visitorsCount = (int) (clone $analytics)->sum('visits_count'); + $this->funnelData = [ + 'visits' => $this->visitorsCount, + 'add_to_cart' => (int) (clone $analytics)->sum('add_to_cart_count'), + 'checkout_started' => (int) (clone $analytics)->sum('checkout_started_count'), + 'checkout_completed' => (int) (clone $analytics)->sum('checkout_completed_count'), + ]; + $this->ordersChartData = (clone $orders) + ->selectRaw('date(placed_at) as order_date, count(*) as aggregate') + ->groupBy('order_date')->orderBy('order_date')->get() + ->map(fn ($row): array => ['date' => (string) $row->order_date, 'count' => (int) $row->aggregate])->all(); + $orderLineTable = (new OrderLine)->getTable(); + $orderTable = (new Order)->getTable(); + $this->topProducts = OrderLine::query() + ->join($orderTable, $orderTable.'.id', '=', $orderLineTable.'.order_id') + ->where($orderTable.'.store_id', $store->getKey())->whereBetween($orderTable.'.placed_at', [$start, $end]) + ->selectRaw($orderLineTable.'.title_snapshot as title, sum('.$orderLineTable.'.quantity) as units_sold, sum('.$orderLineTable.'.total_amount) as revenue') + ->groupBy($orderLineTable.'.title_snapshot')->orderByDesc('revenue')->limit(5)->get() + ->map(fn ($row): array => ['title' => (string) $row->title, 'units_sold' => (int) $row->units_sold, 'revenue' => (int) $row->revenue])->all(); + } + + public function formattedTotalSales(): string + { + return $this->currency($this->totalSales); + } + + public function formattedAov(): string + { + return $this->currency($this->averageOrderValue); + } + + /** @return array{CarbonImmutable, CarbonImmutable} */ + private function dates(): array + { + $end = CarbonImmutable::today()->endOfDay(); + $start = match ($this->dateRange) { + 'today' => CarbonImmutable::today()->startOfDay(), + 'last_7_days' => $end->subDays(6)->startOfDay(), + 'custom' => CarbonImmutable::parse($this->customStartDate ?? $end->toDateString())->startOfDay(), + default => $end->subDays(29)->startOfDay(), + }; + $end = $this->dateRange === 'custom' ? CarbonImmutable::parse($this->customEndDate ?? $end->toDateString())->endOfDay() : $end; + + return [$start, $end]; + } + + public function render() + { + return view('livewire.admin.dashboard'); + } +} diff --git a/app/Livewire/Admin/Developers/Index.php b/app/Livewire/Admin/Developers/Index.php new file mode 100644 index 00000000..26612801 --- /dev/null +++ b/app/Livewire/Admin/Developers/Index.php @@ -0,0 +1,71 @@ +authorizeStore([StoreUserRole::Owner, StoreUserRole::Admin]); + } + + public function createToken(): void + { + $validated = $this->validate(['tokenName' => ['required', 'string', 'max:100']]); + $this->newToken = auth()->user()->createToken($validated['tokenName'], ['store:'.$this->currentStore()->getKey()])->plainTextToken; + $this->reset('tokenName'); + $this->toast('API token created. Copy it now.'); + } + + public function revokeToken(int $id): void + { + auth()->user()->tokens()->findOrFail($id)->delete(); + $this->toast('API token revoked.'); + } + + public function createWebhook(): void + { + $validated = $this->validate(['webhookEvent' => ['required', 'string', 'max:100'], 'webhookUrl' => ['required', 'url', 'starts_with:https://']]); + WebhookSubscription::create(['store_id' => $this->currentStore()->getKey(), 'event_type' => $validated['webhookEvent'], 'target_url' => $validated['webhookUrl'], 'signing_secret_encrypted' => Str::random(64), 'status' => 'active']); + $this->reset('webhookUrl'); + $this->toast('Webhook created.'); + } + + public function deleteWebhook(int $id): void + { + WebhookSubscription::query()->where('store_id', $this->currentStore()->getKey())->findOrFail($id)->delete(); + $this->toast('Webhook deleted.'); + } + + #[Computed] + public function tokens() + { + return auth()->user()->tokens()->latest()->get(); + } + + #[Computed] + public function webhooks() + { + return WebhookSubscription::query()->where('store_id', $this->currentStore()->getKey())->latest('id')->get(); + } + + public function render() + { + return view('livewire.admin.developers.index'); + } +} diff --git a/app/Livewire/Admin/Discounts/Form.php b/app/Livewire/Admin/Discounts/Form.php new file mode 100644 index 00000000..4d6c5507 --- /dev/null +++ b/app/Livewire/Admin/Discounts/Form.php @@ -0,0 +1,77 @@ +exists) { + Gate::authorize('create', Discount::class); + + return; + } + abort_unless($discount->store_id === $this->currentStore()->getKey(), 404); + Gate::authorize('update', $discount); + $this->discountId = $discount->getKey(); + $this->code = $discount->code ?? ''; + $this->valueType = $discount->value_type->value; + $this->value = (string) $discount->value_amount; + $this->minimumPurchase = (string) (($discount->rules_json['minimum_purchase_amount'] ?? 0) / 100); + $this->startsAt = $discount->starts_at?->format('Y-m-d\TH:i'); + $this->endsAt = $discount->ends_at?->format('Y-m-d\TH:i'); + $this->usageLimit = $discount->usage_limit; + $this->status = $discount->status->value; + } + + public function save(): void + { + Gate::authorize($this->discountId === null ? 'create' : 'update', $this->discountId === null ? Discount::class : Discount::query()->where('store_id', $this->currentStore()->getKey())->findOrFail($this->discountId)); + $validated = $this->validate([ + 'code' => ['required', 'alpha_dash', 'max:100'], 'valueType' => ['required', Rule::enum(DiscountValueType::class)], + 'value' => ['required_unless:valueType,free_shipping', 'numeric', 'min:0'], 'minimumPurchase' => ['required', 'numeric', 'min:0'], + 'startsAt' => ['nullable', 'date'], 'endsAt' => ['nullable', 'date', 'after_or_equal:startsAt'], + 'usageLimit' => ['nullable', 'integer', 'min:1'], 'status' => ['required', Rule::enum(DiscountStatus::class)], + ]); + $discount = $this->discountId === null ? new Discount(['store_id' => $this->currentStore()->getKey()]) : Discount::query()->where('store_id', $this->currentStore()->getKey())->findOrFail($this->discountId); + $discount->fill([ + 'type' => 'code', 'code' => mb_strtoupper($validated['code']), 'value_type' => $validated['valueType'], + 'value_amount' => $validated['valueType'] === 'fixed' ? (int) round((float) $validated['value'] * 100) : (int) $validated['value'], + 'starts_at' => $validated['startsAt'], 'ends_at' => $validated['endsAt'], 'usage_limit' => $validated['usageLimit'], + 'status' => $validated['status'], 'rules_json' => ['minimum_purchase_amount' => (int) round((float) $validated['minimumPurchase'] * 100)], + ])->save(); + $this->discountId = $discount->getKey(); + $this->toast('Discount saved.'); + } + + public function render() + { + return view('livewire.admin.discounts.form'); + } +} diff --git a/app/Livewire/Admin/Discounts/Index.php b/app/Livewire/Admin/Discounts/Index.php new file mode 100644 index 00000000..8a7096a3 --- /dev/null +++ b/app/Livewire/Admin/Discounts/Index.php @@ -0,0 +1,47 @@ +where('store_id', $this->currentStore()->getKey())->findOrFail($id); + Gate::authorize('update', $discount); + $discount->update(['status' => $discount->status->value === 'active' ? 'disabled' : 'active']); + $this->toast('Discount status updated.'); + } + + #[Computed] + public function discounts() + { + return Discount::query()->where('store_id', $this->currentStore()->getKey()) + ->when($this->search, fn (Builder $query) => $query->where('code', 'like', '%'.$this->search.'%')) + ->when($this->statusFilter !== 'all', fn (Builder $query) => $query->where('status', $this->statusFilter)) + ->latest()->paginate(20); + } + + public function render() + { + return view('livewire.admin.discounts.index'); + } +} diff --git a/app/Livewire/Admin/Inventory/Index.php b/app/Livewire/Admin/Inventory/Index.php new file mode 100644 index 00000000..43c3ae49 --- /dev/null +++ b/app/Livewire/Admin/Inventory/Index.php @@ -0,0 +1,55 @@ +authorizeStore(); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updateQuantity(int $itemId, int $quantity): void + { + $this->authorizeStore([StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + abort_if($quantity < 0, 422); + InventoryItem::query()->where('store_id', $this->currentStore()->getKey())->findOrFail($itemId)->update(['quantity_on_hand' => $quantity]); + $this->toast('Inventory updated.'); + } + + #[Computed] + public function inventoryItems() + { + return InventoryItem::query()->where('store_id', $this->currentStore()->getKey()) + ->with(['variant.product', 'variant.optionValues']) + ->when($this->search, fn (Builder $query) => $query->whereHas('variant', fn (Builder $variant) => $variant->where('sku', 'like', '%'.$this->search.'%')->orWhereHas('product', fn (Builder $product) => $product->where('title', 'like', '%'.$this->search.'%')))) + ->when($this->stockFilter === 'in_stock', fn (Builder $query) => $query->where('quantity_on_hand', '>', 5)) + ->when($this->stockFilter === 'low_stock', fn (Builder $query) => $query->whereBetween('quantity_on_hand', [1, 5])) + ->when($this->stockFilter === 'out_of_stock', fn (Builder $query) => $query->where('quantity_on_hand', '<=', 0)) + ->orderBy('quantity_on_hand')->paginate(20); + } + + public function render() + { + return view('livewire.admin.inventory.index'); + } +} diff --git a/app/Livewire/Admin/Layout/Sidebar.php b/app/Livewire/Admin/Layout/Sidebar.php new file mode 100644 index 00000000..3a724e22 --- /dev/null +++ b/app/Livewire/Admin/Layout/Sidebar.php @@ -0,0 +1,32 @@ +currentRoute = request()->route()?->getName() ?? ''; + } + + public function toggle(): void + { + $this->collapsed = ! $this->collapsed; + } + + public function close(): void + { + $this->collapsed = true; + } + + public function render() + { + return view('livewire.admin.layout.sidebar'); + } +} diff --git a/app/Livewire/Admin/Layout/TopBar.php b/app/Livewire/Admin/Layout/TopBar.php new file mode 100644 index 00000000..d777d2dc --- /dev/null +++ b/app/Livewire/Admin/Layout/TopBar.php @@ -0,0 +1,51 @@ +currentStoreName = $user?->stores() + ->when(session('current_store_id'), fn ($query) => $query->whereKey(session('current_store_id'))) + ->value('name') ?? 'Select store'; + } + + public function switchStore(int $storeId): void + { + /** @var User $user */ + $user = Auth::user(); + $store = $user->stores()->whereKey($storeId)->firstOrFail(); + Session::put('current_store_id', $store->getKey()); + $this->redirect('/admin', navigate: true); + } + + public function logout(): void + { + Auth::logout(); + session()->invalidate(); + session()->regenerateToken(); + $this->redirect('/admin/login', navigate: true); + } + + public function render() + { + /** @var User $user */ + $user = Auth::user(); + + return view('livewire.admin.layout.top-bar', [ + 'stores' => $user->stores()->orderBy('name')->get(), + ]); + } +} diff --git a/app/Livewire/Admin/Navigation/Index.php b/app/Livewire/Admin/Navigation/Index.php new file mode 100644 index 00000000..279b18a5 --- /dev/null +++ b/app/Livewire/Admin/Navigation/Index.php @@ -0,0 +1,77 @@ +menuId = NavigationMenu::query()->where('store_id', $this->currentStore()->getKey())->value('id'); + } + + public function createMenu(): void + { + $this->authorizeWrite(); + $validated = $this->validate(['menuTitle' => ['required', 'string', 'max:255']]); + $menu = NavigationMenu::create(['store_id' => $this->currentStore()->getKey(), 'title' => $validated['menuTitle'], 'handle' => Str::slug($validated['menuTitle'])]); + $this->menuId = $menu->getKey(); + $this->reset('menuTitle'); + $this->toast('Menu created.'); + } + + public function addItem(): void + { + $this->authorizeWrite(); + $validated = $this->validate(['menuId' => ['required', 'integer'], 'itemLabel' => ['required', 'string', 'max:255'], 'itemUrl' => ['required', 'string', 'max:2048']]); + $menu = NavigationMenu::query()->where('store_id', $this->currentStore()->getKey())->findOrFail($validated['menuId']); + $menu->items()->create(['type' => 'link', 'label' => $validated['itemLabel'], 'url' => $validated['itemUrl'], 'position' => $menu->items()->count()]); + $this->reset('itemLabel', 'itemUrl'); + $this->toast('Navigation item added.'); + } + + public function removeItem(int $id): void + { + $this->authorizeWrite(); + $menu = NavigationMenu::query()->where('store_id', $this->currentStore()->getKey())->findOrFail($this->menuId); + $menu->items()->findOrFail($id)->delete(); + $this->toast('Navigation item removed.'); + } + + public function selectMenu(int $id): void + { + NavigationMenu::query()->where('store_id', $this->currentStore()->getKey())->findOrFail($id); + $this->menuId = $id; + } + + #[Computed] + public function menus() + { + return NavigationMenu::query()->where('store_id', $this->currentStore()->getKey())->with('items')->orderBy('title')->get(); + } + + private function authorizeWrite(): void + { + Gate::authorize('manage', NavigationMenu::class); + } + + public function render() + { + return view('livewire.admin.navigation.index'); + } +} diff --git a/app/Livewire/Admin/Orders/Index.php b/app/Livewire/Admin/Orders/Index.php new file mode 100644 index 00000000..9e741e17 --- /dev/null +++ b/app/Livewire/Admin/Orders/Index.php @@ -0,0 +1,51 @@ +resetPage(); + } + + #[Computed] + public function orders() + { + return Order::query()->where('store_id', $this->currentStore()->getKey())->with('customer') + ->when($this->search, fn (Builder $query) => $query->where(fn (Builder $nested) => $nested->where('order_number', 'like', '%'.$this->search.'%')->orWhere('email', 'like', '%'.$this->search.'%'))) + ->when($this->statusFilter !== 'all', function (Builder $query): void { + if (in_array($this->statusFilter, ['paid', 'pending', 'refunded', 'partially_refunded'], true)) { + $query->where('financial_status', $this->statusFilter); + } else { + $query->where('fulfillment_status', $this->statusFilter); + } + })->orderBy('placed_at', $this->sortDirection)->paginate(20); + } + + public function render() + { + return view('livewire.admin.orders.index'); + } +} diff --git a/app/Livewire/Admin/Orders/Show.php b/app/Livewire/Admin/Orders/Show.php new file mode 100644 index 00000000..9f5cc94c --- /dev/null +++ b/app/Livewire/Admin/Orders/Show.php @@ -0,0 +1,101 @@ + */ + public array $fulfillmentQuantities = []; + + public string $refundAmount = ''; + + public string $refundReason = ''; + + public bool $restock = false; + + public function mount(Order $order): void + { + Gate::authorize('view', $order); + abort_unless($order->store_id === $this->currentStore()->getKey(), 404); + $this->orderId = $order->getKey(); + foreach ($order->lines as $line) { + $this->fulfillmentQuantities[$line->id] = $line->quantity; + } + } + + public function confirmPayment(PaymentService $service): void + { + $order = $this->order(); + Gate::authorize('update', $order); + $service->confirmBankTransfer($order); + $this->toast('Payment confirmed.'); + } + + public function createFulfillment(FulfillmentService $service): void + { + Gate::authorize('createFulfillment', $this->order()); + $validated = $this->validate([ + 'trackingCompany' => ['nullable', 'string', 'max:100'], 'trackingNumber' => ['nullable', 'string', 'max:255'], + 'fulfillmentQuantities' => ['required', 'array'], 'fulfillmentQuantities.*' => ['integer', 'min:0'], + ]); + $lines = collect($validated['fulfillmentQuantities'])->filter(fn (int $quantity): bool => $quantity > 0)->all(); + $service->create($this->order(), $lines, ['tracking_company' => $validated['trackingCompany'], 'tracking_number' => $validated['trackingNumber']]); + $this->showFulfillmentModal = false; + $this->toast('Fulfillment created.'); + } + + public function markShipped(int $fulfillmentId, FulfillmentService $service): void + { + $fulfillment = $this->order()->fulfillments()->findOrFail($fulfillmentId); + Gate::authorize('update', $fulfillment); + $service->markAsShipped($fulfillment); + $this->toast('Fulfillment marked as shipped.'); + } + + public function markDelivered(int $fulfillmentId, FulfillmentService $service): void + { + $fulfillment = $this->order()->fulfillments()->findOrFail($fulfillmentId); + Gate::authorize('update', $fulfillment); + $service->markAsDelivered($fulfillment); + $this->toast('Fulfillment marked as delivered.'); + } + + public function processRefund(RefundService $service): void + { + Gate::authorize('createRefund', $this->order()); + $validated = $this->validate(['refundAmount' => ['required', 'numeric', 'min:0.01'], 'refundReason' => ['nullable', 'string', 'max:500'], 'restock' => ['boolean']]); + $order = $this->order(); + $payment = $order->payments()->latest('id')->firstOrFail(); + $service->create($order, $payment, (int) round((float) $validated['refundAmount'] * 100), $validated['refundReason'], $validated['restock']); + $this->showRefundModal = false; + $this->toast('Refund processed.'); + } + + private function order(): Order + { + return Order::query()->where('store_id', $this->currentStore()->getKey())->with(['customer', 'lines', 'payments.refunds', 'refunds', 'fulfillments.lines'])->findOrFail($this->orderId); + } + + public function render() + { + return view('livewire.admin.orders.show', ['order' => $this->order()]); + } +} diff --git a/app/Livewire/Admin/Pages/Form.php b/app/Livewire/Admin/Pages/Form.php new file mode 100644 index 00000000..836878c2 --- /dev/null +++ b/app/Livewire/Admin/Pages/Form.php @@ -0,0 +1,62 @@ +exists) { + Gate::authorize('create', Page::class); + + return; + } + abort_unless($page->store_id === $this->currentStore()->getKey(), 404); + Gate::authorize('update', $page); + $this->pageId = $page->getKey(); + $this->title = $page->title; + $this->handle = $page->handle; + $this->bodyHtml = $page->body_html ?? ''; + $this->status = $page->status->value; + } + + public function updatedTitle(): void + { + if ($this->pageId === null) { + $this->handle = Str::slug($this->title); + } + } + + public function save(): void + { + Gate::authorize($this->pageId === null ? 'create' : 'update', $this->pageId === null ? Page::class : Page::query()->where('store_id', $this->currentStore()->getKey())->findOrFail($this->pageId)); + $validated = $this->validate(['title' => ['required', 'string', 'max:255'], 'handle' => ['required', 'string', 'max:255'], 'bodyHtml' => ['nullable', 'string', 'max:65535'], 'status' => ['required', Rule::enum(PageStatus::class)]]); + $page = $this->pageId === null ? new Page(['store_id' => $this->currentStore()->getKey()]) : Page::query()->where('store_id', $this->currentStore()->getKey())->findOrFail($this->pageId); + $page->fill(['title' => $validated['title'], 'handle' => Str::slug($validated['handle']), 'body_html' => $validated['bodyHtml'], 'status' => $validated['status'], 'published_at' => $validated['status'] === 'published' ? ($page->published_at ?? now()) : null])->save(); + $this->pageId = $page->getKey(); + $this->toast('Page saved.'); + } + + public function render() + { + return view('livewire.admin.pages.form'); + } +} diff --git a/app/Livewire/Admin/Pages/Index.php b/app/Livewire/Admin/Pages/Index.php new file mode 100644 index 00000000..fddabeeb --- /dev/null +++ b/app/Livewire/Admin/Pages/Index.php @@ -0,0 +1,44 @@ +where('store_id', $this->currentStore()->getKey())->findOrFail($id); + Gate::authorize('delete', $page); + $page->delete(); + $this->toast('Page deleted.'); + } + + #[Computed] + public function pages() + { + return Page::query()->where('store_id', $this->currentStore()->getKey())->when($this->search, fn (Builder $query) => $query->where('title', 'like', '%'.$this->search.'%'))->when($this->statusFilter !== 'all', fn (Builder $query) => $query->where('status', $this->statusFilter))->latest('updated_at')->paginate(20); + } + + public function render() + { + return view('livewire.admin.pages.index'); + } +} diff --git a/app/Livewire/Admin/Products/Form.php b/app/Livewire/Admin/Products/Form.php new file mode 100644 index 00000000..6049cfc0 --- /dev/null +++ b/app/Livewire/Admin/Products/Form.php @@ -0,0 +1,145 @@ + */ + public array $collectionIds = []; + + /** @var list> */ + public array $variants = [['sku' => '', 'price' => 0, 'compareAtPrice' => null, 'quantity' => 0, 'requiresShipping' => true]]; + + public function mount(?Product $product = null): void + { + if ($product === null || ! $product->exists) { + Gate::authorize('create', Product::class); + + return; + } + Gate::authorize('update', $product); + $product->load(['variants.inventoryItem', 'collections']); + $this->productId = $product->getKey(); + $this->title = $product->title; + $this->descriptionHtml = $product->description_html ?? ''; + $this->status = $product->status->value; + $this->vendor = $product->vendor ?? ''; + $this->productType = $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->modelKeys(); + $this->variants = $product->variants->map(fn ($variant): array => [ + 'id' => $variant->id, 'sku' => $variant->sku ?? '', 'price' => $variant->price_amount, + 'compareAtPrice' => $variant->compare_at_amount, 'quantity' => $variant->inventoryItem?->quantity_on_hand ?? 0, + 'requiresShipping' => $variant->requires_shipping, + ])->all(); + } + + public function updatedTitle(): void + { + if ($this->productId === null) { + $this->handle = Str::slug($this->title); + } + } + + public function addVariant(): void + { + $this->variants[] = ['sku' => '', 'price' => 0, 'compareAtPrice' => null, 'quantity' => 0, 'requiresShipping' => true]; + } + + public function removeVariant(int $index): void + { + unset($this->variants[$index]); + $this->variants = array_values($this->variants); + } + + public function save(ProductService $service): void + { + $validated = $this->validate([ + 'title' => ['required', 'string', 'max:255'], 'descriptionHtml' => ['nullable', 'string', 'max:65535'], + 'status' => ['required', Rule::enum(ProductStatus::class)], 'vendor' => ['nullable', 'string', 'max:255'], + 'productType' => ['nullable', 'string', 'max:255'], 'tags' => ['nullable', 'string'], 'handle' => ['required', 'string', 'max:255'], + 'publishedAt' => ['nullable', 'date'], 'collectionIds' => ['array'], 'variants' => ['required', 'array', 'min:1'], + 'variants.*.sku' => ['nullable', 'string', 'max:255'], 'variants.*.price' => ['required', 'integer', 'min:0'], + 'variants.*.compareAtPrice' => ['nullable', 'integer', 'min:0'], 'variants.*.quantity' => ['required', 'integer', 'min:0'], + 'variants.*.requiresShipping' => ['boolean'], + ]); + $payload = [ + 'title' => $validated['title'], 'description_html' => $validated['descriptionHtml'], 'status' => $validated['status'], + 'vendor' => $validated['vendor'], 'product_type' => $validated['productType'], + 'tags' => collect(explode(',', $validated['tags']))->map(fn (string $tag): string => trim($tag))->filter()->values()->all(), + 'handle' => $validated['handle'], 'published_at' => $validated['publishedAt'], 'collection_ids' => $validated['collectionIds'], + 'variants' => collect($validated['variants'])->map(fn (array $variant): array => [ + 'id' => $variant['id'] ?? null, 'sku' => $variant['sku'] ?: null, 'price_amount' => $variant['price'], + 'compare_at_amount' => $variant['compareAtPrice'], 'requires_shipping' => $variant['requiresShipping'], + 'inventory' => ['quantity_on_hand' => $variant['quantity']], + ])->all(), + ]; + if ($this->productId === null) { + Gate::authorize('create', Product::class); + $product = $service->create($this->currentStore(), $payload); + $this->productId = $product->getKey(); + } else { + $product = Product::query()->where('store_id', $this->currentStore()->getKey())->findOrFail($this->productId); + Gate::authorize('update', $product); + $service->update($product, $payload); + } + $this->toast('Product saved.'); + } + + public function archive(ProductService $service): void + { + $product = Product::query()->where('store_id', $this->currentStore()->getKey())->findOrFail($this->productId); + Gate::authorize('archive', $product); + $service->transitionStatus($product, ProductStatus::Archived); + $this->status = ProductStatus::Archived->value; + $this->toast('Product archived.'); + } + + #[Computed] + public function availableCollections() + { + return Collection::query()->where('store_id', $this->currentStore()->getKey())->orderBy('title')->get(); + } + + #[Computed] + public function isEditing(): bool + { + return $this->productId !== null; + } + + public function render() + { + return view('livewire.admin.products.form'); + } +} diff --git a/app/Livewire/Admin/Products/Index.php b/app/Livewire/Admin/Products/Index.php new file mode 100644 index 00000000..5d4fd4ba --- /dev/null +++ b/app/Livewire/Admin/Products/Index.php @@ -0,0 +1,96 @@ + */ + public array $selectedIds = []; + + public string $sortField = 'updated_at'; + + public string $sortDirection = 'desc'; + + public function mount(): void + { + Gate::authorize('viewAny', Product::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function sortBy(string $field): void + { + abort_unless(in_array($field, ['title', 'updated_at'], true), 422); + $this->sortDirection = $this->sortField === $field && $this->sortDirection === 'asc' ? 'desc' : 'asc'; + $this->sortField = $field; + } + + public function bulkSetActive(ProductService $service): void + { + $this->selectedProducts()->each(function (Product $product) use ($service): void { + Gate::authorize('update', $product); + $service->transitionStatus($product, ProductStatus::Active); + }); + $this->selectedIds = []; + $this->toast('Products activated.'); + } + + public function bulkArchive(ProductService $service): void + { + $this->selectedProducts()->each(function (Product $product) use ($service): void { + Gate::authorize('archive', $product); + $service->transitionStatus($product, ProductStatus::Archived); + }); + $this->selectedIds = []; + $this->toast('Products archived.'); + } + + #[Computed] + public function products() + { + return Product::query()->where('store_id', $this->currentStore()->getKey()) + ->with(['variants.inventoryItem', 'media' => fn ($query) => $query->limit(1)]) + ->withCount('variants') + ->when($this->search, fn (Builder $query) => $query->where(fn (Builder $nested) => $nested->where('title', 'like', '%'.$this->search.'%')->orWhere('vendor', 'like', '%'.$this->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); + } + + #[Computed] + public function productTypes() + { + return Product::query()->where('store_id', $this->currentStore()->getKey())->whereNotNull('product_type')->distinct()->orderBy('product_type')->pluck('product_type'); + } + + private function selectedProducts() + { + return Product::query()->where('store_id', $this->currentStore()->getKey())->whereKey($this->selectedIds)->get(); + } + + public function render() + { + return view('livewire.admin.products.index'); + } +} diff --git a/app/Livewire/Admin/SearchSettings.php b/app/Livewire/Admin/SearchSettings.php new file mode 100644 index 00000000..4f72add0 --- /dev/null +++ b/app/Livewire/Admin/SearchSettings.php @@ -0,0 +1,40 @@ +currentStore()); + $settings = SearchSettingsModel::query()->firstOrCreate(['store_id' => $this->currentStore()->getKey()]); + $this->synonyms = collect($settings->synonyms_json)->map(fn ($values, $term): string => $term.'='.implode(',', (array) $values))->implode("\n"); + $this->stopWords = implode(', ', $settings->stop_words_json); + } + + public function save(): void + { + Gate::authorize('updateSettings', $this->currentStore()); + $validated = $this->validate(['synonyms' => ['nullable', 'string', 'max:10000'], 'stopWords' => ['nullable', 'string', 'max:5000']]); + $synonyms = collect(preg_split('/\r\n|\r|\n/', $validated['synonyms']) ?: [])->filter()->mapWithKeys(function (string $line): array { + [$term, $values] = array_pad(explode('=', $line, 2), 2, ''); + + return [trim($term) => collect(explode(',', $values))->map(fn (string $value): string => trim($value))->filter()->values()->all()]; + })->all(); + SearchSettingsModel::query()->updateOrCreate(['store_id' => $this->currentStore()->getKey()], ['synonyms_json' => $synonyms, 'stop_words_json' => collect(explode(',', $validated['stopWords']))->map(fn (string $word): string => trim($word))->filter()->values()->all()]); + $this->toast('Search settings saved.'); + } + + public function render() + { + return view('livewire.admin.search-settings'); + } +} diff --git a/app/Livewire/Admin/Settings/Domains.php b/app/Livewire/Admin/Settings/Domains.php new file mode 100644 index 00000000..443a6052 --- /dev/null +++ b/app/Livewire/Admin/Settings/Domains.php @@ -0,0 +1,57 @@ +currentStore()); + } + + public function addDomain(): void + { + Gate::authorize('updateSettings', $this->currentStore()); + $validated = $this->validate(['hostname' => ['required', 'lowercase', 'max:253', 'regex:/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/', 'unique:store_domains,hostname']]); + StoreDomain::create(['store_id' => $this->currentStore()->getKey(), 'hostname' => $validated['hostname'], 'type' => 'storefront', 'is_primary' => false]); + $this->reset('hostname'); + $this->toast('Domain added.'); + } + + public function makePrimary(int $id): void + { + Gate::authorize('updateSettings', $this->currentStore()); + $domain = StoreDomain::query()->where('store_id', $this->currentStore()->getKey())->findOrFail($id); + StoreDomain::query()->where('store_id', $this->currentStore()->getKey())->update(['is_primary' => false]); + $domain->update(['is_primary' => true]); + $this->toast('Primary domain updated.'); + } + + public function removeDomain(int $id): void + { + Gate::authorize('updateSettings', $this->currentStore()); + $domain = StoreDomain::query()->where('store_id', $this->currentStore()->getKey())->findOrFail($id); + abort_if($domain->is_primary, 422); + $domain->delete(); + $this->toast('Domain removed.'); + } + + #[Computed] + public function domains() + { + return StoreDomain::query()->where('store_id', $this->currentStore()->getKey())->orderByDesc('is_primary')->get(); + } + + public function render() + { + return view('livewire.admin.settings.domains'); + } +} diff --git a/app/Livewire/Admin/Settings/General.php b/app/Livewire/Admin/Settings/General.php new file mode 100644 index 00000000..00e9d1d8 --- /dev/null +++ b/app/Livewire/Admin/Settings/General.php @@ -0,0 +1,49 @@ +currentStore(); + Gate::authorize('viewSettings', $store); + $settings = $store->settings?->settings_json ?? []; + $this->name = $store->name; + $this->currency = $store->default_currency; + $this->locale = $store->default_locale; + $this->timezone = $store->timezone; + $this->contactEmail = $settings['contact_email'] ?? ''; + } + + public function save(): void + { + Gate::authorize('updateSettings', $this->currentStore()); + $validated = $this->validate(['name' => ['required', 'string', 'max:255'], 'currency' => ['required', 'string', 'size:3'], 'locale' => ['required', 'string', 'max:10'], 'timezone' => ['required', Rule::in(timezone_identifiers_list())], 'contactEmail' => ['nullable', 'email']]); + $store = $this->currentStore(); + $store->update(['name' => $validated['name'], 'default_currency' => mb_strtoupper($validated['currency']), 'default_locale' => $validated['locale'], 'timezone' => $validated['timezone']]); + $settings = $store->settings()->firstOrCreate(['store_id' => $store->getKey()]); + $settings->update(['settings_json' => [...($settings->settings_json ?? []), 'contact_email' => $validated['contactEmail']]]); + $this->toast('Settings saved.'); + } + + public function render() + { + return view('livewire.admin.settings.general'); + } +} diff --git a/app/Livewire/Admin/Settings/Shipping.php b/app/Livewire/Admin/Settings/Shipping.php new file mode 100644 index 00000000..91f58f79 --- /dev/null +++ b/app/Livewire/Admin/Settings/Shipping.php @@ -0,0 +1,69 @@ +currentStore()); + } + + public function createZone(): void + { + $this->authorizeWrite(); + $validated = $this->validate(['zoneName' => ['required', 'string', 'max:255'], 'countries' => ['required', 'string']]); + ShippingZone::create(['store_id' => $this->currentStore()->getKey(), 'name' => $validated['zoneName'], 'countries_json' => collect(explode(',', $validated['countries']))->map(fn (string $country): string => mb_strtoupper(trim($country)))->filter()->values()->all(), 'regions_json' => []]); + $this->reset('zoneName', 'countries'); + $this->toast('Shipping zone saved.'); + } + + public function addRate(): void + { + $this->authorizeWrite(); + $validated = $this->validate(['activeZoneId' => ['required', 'integer'], 'rateName' => ['required', 'string', 'max:255'], 'rateAmount' => ['required', 'numeric', 'min:0']]); + $zone = ShippingZone::query()->where('store_id', $this->currentStore()->getKey())->findOrFail($validated['activeZoneId']); + $zone->rates()->create(['name' => $validated['rateName'], 'type' => 'flat', 'config_json' => ['amount' => (int) round((float) $validated['rateAmount'] * 100)], 'is_active' => true]); + $this->reset('rateName', 'rateAmount'); + $this->toast('Shipping rate saved.'); + } + + public function deleteZone(int $id): void + { + $this->authorizeWrite(); + ShippingZone::query()->where('store_id', $this->currentStore()->getKey())->findOrFail($id)->delete(); + $this->toast('Shipping zone deleted.'); + } + + #[Computed] + public function zones() + { + return ShippingZone::query()->where('store_id', $this->currentStore()->getKey())->with('rates')->orderBy('name')->get(); + } + + private function authorizeWrite(): void + { + Gate::authorize('updateSettings', $this->currentStore()); + } + + public function render() + { + return view('livewire.admin.settings.shipping'); + } +} diff --git a/app/Livewire/Admin/Settings/Tax.php b/app/Livewire/Admin/Settings/Tax.php new file mode 100644 index 00000000..d3b5fd04 --- /dev/null +++ b/app/Livewire/Admin/Settings/Tax.php @@ -0,0 +1,42 @@ +currentStore()); + $settings = TaxSettings::query()->firstOrCreate(['store_id' => $this->currentStore()->getKey()]); + $this->mode = $settings->mode->value; + $this->provider = $settings->provider; + $this->pricesIncludeTax = $settings->prices_include_tax; + $this->defaultRate = (string) (($settings->config_json['default_rate_bps'] ?? 0) / 100); + } + + public function save(): void + { + Gate::authorize('updateSettings', $this->currentStore()); + $validated = $this->validate(['mode' => ['required', 'in:manual,provider'], 'provider' => ['required', 'string', 'max:50'], 'pricesIncludeTax' => ['boolean'], 'defaultRate' => ['required', 'numeric', 'between:0,100']]); + TaxSettings::query()->updateOrCreate(['store_id' => $this->currentStore()->getKey()], ['mode' => $validated['mode'], 'provider' => $validated['provider'], 'prices_include_tax' => $validated['pricesIncludeTax'], 'config_json' => ['default_rate_bps' => (int) round((float) $validated['defaultRate'] * 100)]]); + $this->toast('Tax settings saved.'); + } + + public function render() + { + return view('livewire.admin.settings.tax'); + } +} diff --git a/app/Livewire/Admin/Themes/Editor.php b/app/Livewire/Admin/Themes/Editor.php new file mode 100644 index 00000000..25016701 --- /dev/null +++ b/app/Livewire/Admin/Themes/Editor.php @@ -0,0 +1,54 @@ +store_id === $this->currentStore()->getKey(), 404); + Gate::authorize('view', $theme); + $this->themeId = $theme->getKey(); + $this->name = $theme->name; + $settings = $theme->settings?->settings_json ?? []; + $this->primaryColor = $settings['primary_color'] ?? $this->primaryColor; + $this->accentColor = $settings['accent_color'] ?? $this->accentColor; + $this->logoUrl = $settings['logo_url'] ?? ''; + $this->headingFont = $settings['heading_font'] ?? 'Inter'; + $this->bodyFont = $settings['body_font'] ?? 'Inter'; + } + + public function save(): void + { + $validated = $this->validate(['name' => ['required', 'string', 'max:255'], 'primaryColor' => ['required', 'regex:/^#[0-9a-fA-F]{6}$/'], 'accentColor' => ['required', 'regex:/^#[0-9a-fA-F]{6}$/'], 'logoUrl' => ['nullable', 'url'], 'headingFont' => ['required', 'in:Inter,Georgia,Arial'], 'bodyFont' => ['required', 'in:Inter,Georgia,Arial']]); + $theme = Theme::query()->where('store_id', $this->currentStore()->getKey())->findOrFail($this->themeId); + Gate::authorize('update', $theme); + $theme->update(['name' => $validated['name']]); + $theme->settings()->updateOrCreate(['theme_id' => $theme->getKey()], ['settings_json' => ['primary_color' => $validated['primaryColor'], 'accent_color' => $validated['accentColor'], 'logo_url' => $validated['logoUrl'], 'heading_font' => $validated['headingFont'], 'body_font' => $validated['bodyFont']]]); + $this->toast('Theme saved.'); + } + + public function render() + { + return view('livewire.admin.themes.editor'); + } +} diff --git a/app/Livewire/Admin/Themes/Index.php b/app/Livewire/Admin/Themes/Index.php new file mode 100644 index 00000000..ecdb4c9a --- /dev/null +++ b/app/Livewire/Admin/Themes/Index.php @@ -0,0 +1,51 @@ +where('store_id', $this->currentStore()->getKey())->findOrFail($id); + Gate::authorize('publish', $theme); + Theme::query()->where('store_id', $this->currentStore()->getKey())->update(['status' => 'draft', 'published_at' => null]); + $theme->update(['status' => 'published', 'published_at' => now()]); + $this->toast('Theme published.'); + } + + public function duplicate(int $id): void + { + $theme = Theme::query()->where('store_id', $this->currentStore()->getKey())->with('settings')->findOrFail($id); + Gate::authorize('create', Theme::class); + $copy = $theme->replicate(['status', 'published_at']); + $copy->name = $theme->name.' copy'; + $copy->status = 'draft'; + $copy->published_at = null; + $copy->save(); + if ($theme->settings) { + $copy->settings()->create(['settings_json' => $theme->settings->settings_json]); + } $this->toast('Theme duplicated.'); + } + + #[Computed] + public function themes() + { + return Theme::query()->where('store_id', $this->currentStore()->getKey())->with('settings')->latest()->get(); + } + + public function render() + { + return view('livewire.admin.themes.index'); + } +} diff --git a/app/Livewire/Storefront/Account/Addresses/Index.php b/app/Livewire/Storefront/Account/Addresses/Index.php new file mode 100644 index 00000000..74e8969b --- /dev/null +++ b/app/Livewire/Storefront/Account/Addresses/Index.php @@ -0,0 +1,78 @@ + */ + public array $address = ['first_name' => '', 'last_name' => '', 'address1' => '', 'address2' => '', 'city' => '', 'province_code' => '', 'country_code' => 'DE', 'zip' => '', 'phone' => '']; + + public function edit(int $addressId): void + { + $address = $this->ownedAddress($addressId); + $this->editingAddressId = $address->id; + $this->label = $address->label ?? 'Home'; + $this->isDefault = $address->is_default; + $this->address = array_merge($this->address, $address->address_json); + } + + public function save(): void + { + $validated = $this->validate([ + 'label' => ['nullable', 'string', 'max:50'], + 'isDefault' => ['boolean'], + 'address.first_name' => ['required', 'string', 'max:255'], + 'address.last_name' => ['required', 'string', 'max:255'], + 'address.address1' => ['required', 'string', 'max:255'], + 'address.city' => ['required', 'string', 'max:255'], + 'address.country_code' => ['required', 'string', 'size:2'], + 'address.zip' => ['required', 'string', 'max:32'], + ]); + $customer = Auth::guard('customer')->user(); + + if ($this->isDefault) { + $customer->addresses()->update(['is_default' => false]); + } + + $model = $this->editingAddressId ? $this->ownedAddress($this->editingAddressId) : new CustomerAddress(['customer_id' => $customer->id]); + $model->fill(['label' => $validated['label'], 'is_default' => $this->isDefault, 'address_json' => $validated['address']])->save(); + $this->resetForm(); + session()->flash('storefront_status', 'Address saved'); + } + + public function delete(int $addressId): void + { + $this->ownedAddress($addressId)->delete(); + } + + public function render(): View + { + return view('livewire.storefront.account.addresses.index', [ + 'addresses' => Auth::guard('customer')->user()->addresses()->orderByDesc('is_default')->get(), + ])->layout('layouts.storefront', ['title' => 'Addresses - '.app('current_store')->name]); + } + + private function ownedAddress(int $addressId): CustomerAddress + { + return Auth::guard('customer')->user()->addresses()->findOrFail($addressId); + } + + private function resetForm(): void + { + $this->editingAddressId = null; + $this->label = 'Home'; + $this->isDefault = false; + $this->address = ['first_name' => '', 'last_name' => '', 'address1' => '', 'address2' => '', 'city' => '', 'province_code' => '', 'country_code' => 'DE', 'zip' => '', 'phone' => '']; + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Login.php b/app/Livewire/Storefront/Account/Auth/Login.php new file mode 100644 index 00000000..0a33479b --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Login.php @@ -0,0 +1,60 @@ +validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]); + $key = 'customer-login:'.request()->ip(); + + if (RateLimiter::tooManyAttempts($key, 5)) { + throw ValidationException::withMessages(['email' => 'Too many attempts. Please try again later.']); + } + + $guestCart = session('cart_id') ? Cart::query()->find(session('cart_id')) : null; + $authenticated = Auth::guard('customer')->attempt([ + 'email' => $this->email, + 'password' => $this->password, + 'store_id' => app('current_store')->id, + ], $this->remember); + + if (! $authenticated) { + RateLimiter::hit($key, 60); + throw ValidationException::withMessages(['email' => 'Invalid credentials']); + } + + session()->regenerate(); + RateLimiter::clear($key); + $customer = Auth::guard('customer')->user(); + + if ($guestCart && $guestCart->customer_id === null) { + $customerCart = $customer->carts()->where('status', CartStatus::Active)->latest()->first() ?? $carts->create(app('current_store'), $customer); + $carts->mergeOnLogin($guestCart->load('lines'), $customerCart); + } + + $this->redirectIntended(route('storefront.account.dashboard'), navigate: true); + } + + public function render(): View + { + return view('livewire.storefront.account.auth.login') + ->layout('layouts.storefront', ['title' => 'Sign in - '.app('current_store')->name]); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Register.php b/app/Livewire/Storefront/Account/Auth/Register.php new file mode 100644 index 00000000..75e15ebd --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Register.php @@ -0,0 +1,41 @@ +register(app('current_store'), [ + 'name' => $this->name, + 'email' => $this->email, + 'password' => $this->password, + 'password_confirmation' => $this->password_confirmation, + 'marketing_opt_in' => $this->marketing_opt_in, + ]); + Auth::guard('customer')->login($customer); + session()->regenerate(); + $this->redirectRoute('storefront.account.dashboard', navigate: true); + } + + public function render(): View + { + return view('livewire.storefront.account.auth.register') + ->layout('layouts.storefront', ['title' => 'Create account - '.app('current_store')->name]); + } +} diff --git a/app/Livewire/Storefront/Account/Dashboard.php b/app/Livewire/Storefront/Account/Dashboard.php new file mode 100644 index 00000000..4a62febd --- /dev/null +++ b/app/Livewire/Storefront/Account/Dashboard.php @@ -0,0 +1,28 @@ +logout(); + session()->invalidate(); + session()->regenerateToken(); + $this->redirectRoute('storefront.account.login', navigate: true); + } + + public function render(): View + { + $customer = Auth::guard('customer')->user(); + + return view('livewire.storefront.account.dashboard', [ + 'customer' => $customer, + 'orders' => $customer->orders()->latest('placed_at')->limit(5)->get(), + ])->layout('layouts.storefront', ['title' => 'Your account - '.app('current_store')->name]); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Index.php b/app/Livewire/Storefront/Account/Orders/Index.php new file mode 100644 index 00000000..ee6a36ea --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Index.php @@ -0,0 +1,20 @@ + Auth::guard('customer')->user()->orders()->latest('placed_at')->paginate(10), + ])->layout('layouts.storefront', ['title' => 'Orders - '.app('current_store')->name]); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Show.php b/app/Livewire/Storefront/Account/Orders/Show.php new file mode 100644 index 00000000..b5b2fa9f --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Show.php @@ -0,0 +1,25 @@ +order = Auth::guard('customer')->user()->orders() + ->whereKey($orderId)->with(['lines', 'payments', 'fulfillments'])->firstOrFail(); + } + + public function render(): View + { + return view('livewire.storefront.account.orders.show') + ->layout('layouts.storefront', ['title' => 'Order '.$this->order->order_number]); + } +} diff --git a/app/Livewire/Storefront/Cart/Show.php b/app/Livewire/Storefront/Cart/Show.php new file mode 100644 index 00000000..09ca2539 --- /dev/null +++ b/app/Livewire/Storefront/Cart/Show.php @@ -0,0 +1,50 @@ +cartId = $carts->getOrCreateForSession(app('current_store'), auth('customer')->user())->id; + } + + public function updateQuantity(int $lineId, int $quantity, CartService $carts): void + { + $carts->updateLineQuantity($this->cart(), $lineId, $quantity); + $this->dispatch('cart-updated'); + } + + public function removeLine(int $lineId, CartService $carts): void + { + $carts->removeLine($this->cart(), $lineId); + session()->flash('storefront_status', 'Item removed'); + $this->dispatch('cart-updated'); + } + + public function checkout(CheckoutService $checkouts): void + { + $checkout = $checkouts->create($this->cart()); + + $this->redirectRoute('storefront.checkout.show', $checkout->id); + } + + public function render(): View + { + return view('livewire.storefront.cart.show', ['cart' => $this->cart()]) + ->layout('layouts.storefront', ['title' => 'Cart - '.app('current_store')->name]); + } + + private function cart(): Cart + { + return Cart::query()->with(['lines.variant.product', 'lines.variant.inventoryItem'])->findOrFail($this->cartId); + } +} diff --git a/app/Livewire/Storefront/CartDrawer.php b/app/Livewire/Storefront/CartDrawer.php new file mode 100644 index 00000000..549ac268 --- /dev/null +++ b/app/Livewire/Storefront/CartDrawer.php @@ -0,0 +1,37 @@ +cartId = session('cart_id'); + $this->open = true; + } + + public function removeLine(int $lineId, CartService $carts): void + { + if ($this->cartId !== null) { + $carts->removeLine(Cart::query()->findOrFail($this->cartId), $lineId); + } + } + + public function render(): View + { + $cart = $this->cartId === null ? null : Cart::query()->with('lines.variant.product')->find($this->cartId); + + return view('livewire.storefront.cart-drawer', ['cart' => $cart]); + } +} diff --git a/app/Livewire/Storefront/Checkout/Confirmation.php b/app/Livewire/Storefront/Checkout/Confirmation.php new file mode 100644 index 00000000..b8d6f14c --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Confirmation.php @@ -0,0 +1,27 @@ +whereKey($checkoutId)->where('status', CheckoutStatus::Completed)->firstOrFail(); + abort_unless($checkout->store_id === app('current_store')->id, 404); + $this->order = $checkout->order()->with(['lines', 'payments'])->firstOrFail(); + } + + public function render(): View + { + return view('livewire.storefront.checkout.confirmation') + ->layout('layouts.storefront', ['title' => 'Order '.$this->order->order_number]); + } +} diff --git a/app/Livewire/Storefront/Checkout/Show.php b/app/Livewire/Storefront/Checkout/Show.php new file mode 100644 index 00000000..8c196d78 --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Show.php @@ -0,0 +1,101 @@ + */ + public array $shipping = ['first_name' => '', 'last_name' => '', 'address1' => '', 'address2' => '', 'city' => '', 'province_code' => '', 'country' => 'DE', 'postal_code' => '', 'phone' => '']; + + public ?int $shippingRateId = null; + + public string $paymentMethod = 'credit_card'; + + public string $cardNumber = '4242 4242 4242 4242'; + + public string $discountCode = ''; + + public function mount(int $checkoutId): void + { + $this->checkout = Checkout::query()->with(['cart.lines.variant.product', 'shippingMethod']) + ->whereKey($checkoutId)->firstOrFail(); + abort_unless($this->checkout->store_id === app('current_store')->id, 404); + $this->email = $this->checkout->email ?? auth('customer')->user()?->email ?? ''; + $this->shipping = array_merge($this->shipping, $this->checkout->shipping_address_json ?? []); + $this->shippingRateId = $this->checkout->shipping_method_id; + $this->paymentMethod = $this->checkout->payment_method?->value ?? PaymentMethod::CreditCard->value; + } + + public function saveAddress(CheckoutService $checkouts): void + { + $this->validate([ + 'email' => ['required', 'email'], + 'shipping.first_name' => ['required', 'string', 'max:255'], + 'shipping.last_name' => ['required', 'string', 'max:255'], + 'shipping.address1' => ['required', 'string', 'max:255'], + 'shipping.city' => ['required', 'string', 'max:255'], + 'shipping.country' => ['required', 'string', 'size:2'], + 'shipping.postal_code' => ['required', 'string', 'max:32'], + ]); + $this->checkout = $checkouts->setAddress($this->checkout, ['email' => $this->email, 'shipping_address' => $this->shipping]); + } + + public function selectShipping(CheckoutService $checkouts): void + { + $this->validate(['shippingRateId' => ['nullable', 'integer']]); + $this->checkout = $checkouts->setShippingMethod($this->checkout, $this->shippingRateId); + } + + public function selectPayment(CheckoutService $checkouts): void + { + $this->validate(['paymentMethod' => ['required', 'in:credit_card,paypal,bank_transfer']]); + $this->checkout = $checkouts->selectPaymentMethod($this->checkout, $this->paymentMethod); + } + + public function applyDiscount(PricingEngine $pricing): void + { + $this->validate(['discountCode' => ['required', 'string', 'max:50']]); + $this->checkout->update(['discount_code' => $this->discountCode]); + $pricing->calculate($this->checkout->refresh()); + session()->flash('storefront_status', 'Discount applied'); + } + + public function pay(CheckoutService $checkouts): void + { + $details = $this->paymentMethod === PaymentMethod::CreditCard->value ? ['card_number' => $this->cardNumber] : []; + + try { + $order = $checkouts->completeCheckout($this->checkout, $details); + } catch (PaymentFailedException $exception) { + $this->addError('cardNumber', $exception->getMessage()); + + return; + } + + $this->redirectRoute('storefront.checkout.confirmation', ['checkoutId' => $this->checkout->id, 'order' => $order->id]); + } + + public function render(ShippingCalculator $shipping): View + { + $rates = in_array($this->checkout->status, [CheckoutStatus::Addressed, CheckoutStatus::ShippingSelected, CheckoutStatus::PaymentSelected], true) + ? $shipping->getAvailableRates(app('current_store'), $this->checkout->shipping_address_json ?? []) + : collect(); + + return view('livewire.storefront.checkout.show', ['rates' => $rates]) + ->layout('layouts.storefront', ['title' => 'Checkout - '.app('current_store')->name]); + } +} diff --git a/app/Livewire/Storefront/Collections/Index.php b/app/Livewire/Storefront/Collections/Index.php new file mode 100644 index 00000000..75784d81 --- /dev/null +++ b/app/Livewire/Storefront/Collections/Index.php @@ -0,0 +1,17 @@ + Collection::query()->where('status', 'active')->withCount('products')->latest()->get(), + ])->layout('layouts.storefront', ['title' => 'Collections - '.app('current_store')->name]); + } +} diff --git a/app/Livewire/Storefront/Collections/Show.php b/app/Livewire/Storefront/Collections/Show.php new file mode 100644 index 00000000..5b05548a --- /dev/null +++ b/app/Livewire/Storefront/Collections/Show.php @@ -0,0 +1,47 @@ +collection = Collection::query()->where('handle', $handle)->where('status', 'active')->firstOrFail(); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function render(): View + { + $products = Product::published() + ->whereHas('collections', fn ($query) => $query->whereKey($this->collection)) + ->with(['variants.inventoryItem', 'media']) + ->when($this->search !== '', fn ($query) => $query->where('title', 'like', '%'.$this->search.'%')); + + match ($this->sort) { + 'price-low' => $products->withMin('variants', 'price_amount')->orderBy('variants_min_price_amount'), + 'price-high' => $products->withMax('variants', 'price_amount')->orderByDesc('variants_max_price_amount'), + default => $products->latest('published_at'), + }; + + return view('livewire.storefront.collections.show', ['products' => $products->paginate(12)]) + ->layout('layouts.storefront', ['title' => $this->collection->title.' - '.app('current_store')->name]); + } +} diff --git a/app/Livewire/Storefront/Home.php b/app/Livewire/Storefront/Home.php new file mode 100644 index 00000000..7b00ed70 --- /dev/null +++ b/app/Livewire/Storefront/Home.php @@ -0,0 +1,19 @@ + Collection::query()->where('status', 'active')->withCount('products')->latest()->limit(3)->get(), + 'products' => Product::published()->with(['variants.inventoryItem', 'media'])->latest('published_at')->limit(8)->get(), + ])->layout('layouts.storefront', ['title' => app('current_store')->name]); + } +} diff --git a/app/Livewire/Storefront/Pages/Show.php b/app/Livewire/Storefront/Pages/Show.php new file mode 100644 index 00000000..af8c0930 --- /dev/null +++ b/app/Livewire/Storefront/Pages/Show.php @@ -0,0 +1,24 @@ +page = Page::query()->where('handle', $handle)->where('status', PageStatus::Published)->firstOrFail(); + } + + public function render(): View + { + return view('livewire.storefront.pages.show') + ->layout('layouts.storefront', ['title' => $this->page->title.' - '.app('current_store')->name]); + } +} diff --git a/app/Livewire/Storefront/Products/Show.php b/app/Livewire/Storefront/Products/Show.php new file mode 100644 index 00000000..a85674fb --- /dev/null +++ b/app/Livewire/Storefront/Products/Show.php @@ -0,0 +1,46 @@ +product = Product::published()->where('handle', $handle) + ->with(['options.values', 'variants.optionValues.option', 'variants.inventoryItem', 'media']) + ->firstOrFail(); + $this->selectedVariantId = $this->product->variants->firstWhere('is_default', true)?->id + ?? $this->product->variants->firstOrFail()->id; + } + + public function addToCart(CartService $carts): void + { + $this->validate(['selectedVariantId' => ['required', 'integer'], 'quantity' => ['required', 'integer', 'min:1']]); + $variant = $this->product->variants->firstWhere('id', $this->selectedVariantId); + abort_unless($variant instanceof ProductVariant, 404); + $customer = auth('customer')->user(); + $cart = $carts->getOrCreateForSession(app('current_store'), $customer); + $carts->addLine($cart, $variant->id, $this->quantity); + session()->flash('storefront_status', 'Added to cart'); + $this->dispatch('cart-updated'); + } + + public function render(): View + { + return view('livewire.storefront.products.show', [ + 'selectedVariant' => $this->product->variants->firstWhere('id', $this->selectedVariantId), + ])->layout('layouts.storefront', ['title' => $this->product->title.' - '.app('current_store')->name]); + } +} diff --git a/app/Livewire/Storefront/Search/Index.php b/app/Livewire/Storefront/Search/Index.php new file mode 100644 index 00000000..1bb3723a --- /dev/null +++ b/app/Livewire/Storefront/Search/Index.php @@ -0,0 +1,38 @@ +query = (string) request()->query('q', ''); + } + + public function updatedQuery(): void + { + $this->resetPage(); + } + + public function render(): View + { + $products = Product::published()->with(['variants.inventoryItem', 'media']) + ->when($this->query !== '', fn ($builder) => $builder->where(function ($query): void { + $query->where('title', 'like', '%'.$this->query.'%')->orWhere('vendor', 'like', '%'.$this->query.'%'); + })) + ->latest('published_at') + ->paginate(12); + + return view('livewire.storefront.search.index', ['products' => $products]) + ->layout('layouts.storefront', ['title' => 'Search - '.app('current_store')->name]); + } +} diff --git a/app/Livewire/Storefront/SearchModal.php b/app/Livewire/Storefront/SearchModal.php new file mode 100644 index 00000000..c2009596 --- /dev/null +++ b/app/Livewire/Storefront/SearchModal.php @@ -0,0 +1,22 @@ +query === '' ? collect() : Product::published() + ->where('title', 'like', '%'.$this->query.'%')->limit(6)->get(); + + return view('livewire.storefront.search-modal', ['suggestions' => $suggestions]); + } +} diff --git a/app/Models/AnalyticsDaily.php b/app/Models/AnalyticsDaily.php new file mode 100644 index 00000000..bad407ae --- /dev/null +++ b/app/Models/AnalyticsDaily.php @@ -0,0 +1,55 @@ + */ + use BelongsToStore, HasFactory; + + public $incrementing = false; + + public $timestamps = false; + + protected $table = 'analytics_daily'; + + protected $fillable = [ + 'store_id', 'date', 'orders_count', 'revenue_amount', 'aov_amount', + 'visits_count', 'add_to_cart_count', 'checkout_started_count', + 'checkout_completed_count', + ]; + + protected $attributes = [ + 'orders_count' => 0, + 'revenue_amount' => 0, + 'aov_amount' => 0, + 'visits_count' => 0, + 'add_to_cart_count' => 0, + 'checkout_started_count' => 0, + 'checkout_completed_count' => 0, + ]; + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + protected function casts(): array + { + return [ + 'date' => 'immutable_date', + '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..d36a76c2 --- /dev/null +++ b/app/Models/AnalyticsEvent.php @@ -0,0 +1,43 @@ + */ + use BelongsToStore, HasFactory; + + public const UPDATED_AT = null; + + protected $fillable = [ + 'store_id', 'type', 'session_id', 'customer_id', 'properties_json', + 'client_event_id', 'occurred_at', + ]; + + protected $attributes = ['properties_json' => '{}']; + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + protected function casts(): array + { + return [ + 'type' => AnalyticsEventType::class, + 'properties_json' => 'array', + 'occurred_at' => 'datetime', + ]; + } +} diff --git a/app/Models/App.php b/app/Models/App.php new file mode 100644 index 00000000..2ad74a0f --- /dev/null +++ b/app/Models/App.php @@ -0,0 +1,35 @@ + */ + use HasFactory; + + public const UPDATED_AT = null; + + protected $fillable = ['name', 'status']; + + protected $attributes = ['status' => AppStatus::Active->value]; + + public function installations(): HasMany + { + return $this->hasMany(AppInstallation::class); + } + + public function oauthClients(): HasMany + { + return $this->hasMany(OauthClient::class); + } + + protected function casts(): array + { + return ['status' => AppStatus::class]; + } +} diff --git a/app/Models/AppInstallation.php b/app/Models/AppInstallation.php new file mode 100644 index 00000000..aaa96ae2 --- /dev/null +++ b/app/Models/AppInstallation.php @@ -0,0 +1,51 @@ + */ + use BelongsToStore, HasFactory; + + public $timestamps = false; + + protected $fillable = ['store_id', 'app_id', 'scopes_json', 'status', 'installed_at']; + + protected $attributes = ['scopes_json' => '[]', 'status' => AppInstallationStatus::Active->value]; + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function app(): BelongsTo + { + return $this->belongsTo(App::class); + } + + public function tokens(): HasMany + { + return $this->hasMany(OauthToken::class, 'installation_id'); + } + + public function webhookSubscriptions(): HasMany + { + return $this->hasMany(WebhookSubscription::class); + } + + 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..a640bb4f --- /dev/null +++ b/app/Models/Cart.php @@ -0,0 +1,45 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = ['store_id', 'customer_id', 'currency', 'cart_version', 'status']; + + protected $attributes = ['currency' => 'USD', 'cart_version' => 1, 'status' => 'active']; + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function lines(): HasMany + { + return $this->hasMany(CartLine::class); + } + + public function checkouts(): HasMany + { + return $this->hasMany(Checkout::class); + } + + protected function casts(): array + { + return ['status' => CartStatus::class]; + } +} diff --git a/app/Models/CartLine.php b/app/Models/CartLine.php new file mode 100644 index 00000000..4a153390 --- /dev/null +++ b/app/Models/CartLine.php @@ -0,0 +1,29 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = ['cart_id', 'variant_id', 'quantity', 'unit_price_amount', 'line_subtotal_amount', 'line_discount_amount', 'line_total_amount']; + + protected $attributes = ['quantity' => 1, 'unit_price_amount' => 0, 'line_subtotal_amount' => 0, 'line_discount_amount' => 0, 'line_total_amount' => 0]; + + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } +} diff --git a/app/Models/Checkout.php b/app/Models/Checkout.php new file mode 100644 index 00000000..1d9e2108 --- /dev/null +++ b/app/Models/Checkout.php @@ -0,0 +1,59 @@ + */ + use BelongsToStore, HasFactory; + + 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']; + + protected $attributes = ['status' => 'started']; + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function shippingMethod(): BelongsTo + { + return $this->belongsTo(ShippingRate::class, 'shipping_method_id'); + } + + public function order(): HasOne + { + return $this->hasOne(Order::class); + } + + protected function casts(): array + { + return [ + 'status' => CheckoutStatus::class, + 'payment_method' => PaymentMethod::class, + 'shipping_address_json' => 'array', + 'billing_address_json' => 'array', + 'tax_provider_snapshot_json' => 'array', + 'totals_json' => 'array', + 'expires_at' => 'datetime', + ]; + } +} diff --git a/app/Models/Collection.php b/app/Models/Collection.php new file mode 100644 index 00000000..c052f266 --- /dev/null +++ b/app/Models/Collection.php @@ -0,0 +1,51 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'description_html', + 'type', + 'status', + ]; + + protected $attributes = [ + 'type' => 'manual', + 'status' => CollectionStatus::Active->value, + ]; + + /** @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 ['status' => CollectionStatus::class]; + } +} diff --git a/app/Models/Concerns/BelongsToStore.php b/app/Models/Concerns/BelongsToStore.php new file mode 100644 index 00000000..a2a022cf --- /dev/null +++ b/app/Models/Concerns/BelongsToStore.php @@ -0,0 +1,24 @@ +getAttribute('store_id') === null && app()->bound('current_store')) { + /** @var Store $store */ + $store = app('current_store'); + + $model->setAttribute('store_id', $store->getKey()); + } + }); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php new file mode 100644 index 00000000..a732bde7 --- /dev/null +++ b/app/Models/Customer.php @@ -0,0 +1,52 @@ + */ + use BelongsToStore, HasFactory, Notifiable; + + protected $fillable = ['store_id', 'email', 'password_hash', 'name', 'marketing_opt_in']; + + protected $hidden = ['password_hash', 'remember_token']; + + protected $attributes = ['marketing_opt_in' => false]; + + public function getAuthPasswordName(): string + { + return 'password_hash'; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function addresses(): HasMany + { + return $this->hasMany(CustomerAddress::class); + } + + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + public function carts(): HasMany + { + return $this->hasMany(Cart::class); + } + + protected function casts(): array + { + return ['password_hash' => 'hashed', 'marketing_opt_in' => 'boolean']; + } +} diff --git a/app/Models/CustomerAddress.php b/app/Models/CustomerAddress.php new file mode 100644 index 00000000..b24a2522 --- /dev/null +++ b/app/Models/CustomerAddress.php @@ -0,0 +1,29 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = ['customer_id', 'label', 'address_json', 'is_default']; + + protected $attributes = ['address_json' => '{}', 'is_default' => false]; + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + protected function casts(): array + { + return ['address_json' => 'array', 'is_default' => 'boolean']; + } +} diff --git a/app/Models/Discount.php b/app/Models/Discount.php new file mode 100644 index 00000000..bcdac72a --- /dev/null +++ b/app/Models/Discount.php @@ -0,0 +1,31 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = ['store_id', 'type', 'code', 'value_type', 'value_amount', 'starts_at', 'ends_at', 'usage_limit', 'usage_count', 'rules_json', 'status']; + + protected $attributes = ['type' => 'code', 'value_amount' => 0, 'usage_count' => 0, 'rules_json' => '{}', 'status' => 'active']; + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + protected function casts(): array + { + return ['type' => DiscountType::class, 'value_type' => DiscountValueType::class, 'status' => DiscountStatus::class, 'rules_json' => 'array', 'starts_at' => 'datetime', 'ends_at' => 'datetime']; + } +} diff --git a/app/Models/Fulfillment.php b/app/Models/Fulfillment.php new file mode 100644 index 00000000..dbf469f5 --- /dev/null +++ b/app/Models/Fulfillment.php @@ -0,0 +1,36 @@ + */ + use HasFactory; + + public const UPDATED_AT = null; + + protected $fillable = ['order_id', 'status', 'tracking_company', 'tracking_number', 'tracking_url', 'shipped_at', 'delivered_at']; + + protected $attributes = ['status' => 'pending']; + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function lines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } + + 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..53781173 --- /dev/null +++ b/app/Models/FulfillmentLine.php @@ -0,0 +1,29 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = ['fulfillment_id', 'order_line_id', 'quantity']; + + protected $attributes = ['quantity' => 1]; + + public function fulfillment(): BelongsTo + { + return $this->belongsTo(Fulfillment::class); + } + + public function orderLine(): BelongsTo + { + return $this->belongsTo(OrderLine::class); + } +} diff --git a/app/Models/InventoryItem.php b/app/Models/InventoryItem.php new file mode 100644 index 00000000..3b215114 --- /dev/null +++ b/app/Models/InventoryItem.php @@ -0,0 +1,60 @@ + */ + use BelongsToStore, HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'store_id', + 'variant_id', + 'quantity_on_hand', + 'quantity_reserved', + 'policy', + ]; + + protected $attributes = [ + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny->value, + ]; + + /** @return BelongsTo */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** @return BelongsTo */ + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + protected function available(): Attribute + { + return Attribute::get(fn (): int => $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..5eff0646 --- /dev/null +++ b/app/Models/NavigationItem.php @@ -0,0 +1,30 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = ['menu_id', 'type', 'label', 'url', 'resource_id', 'position']; + + protected $attributes = ['type' => NavigationItemType::Link->value, 'position' => 0]; + + public function menu(): BelongsTo + { + return $this->belongsTo(NavigationMenu::class, 'menu_id'); + } + + protected function casts(): array + { + return ['type' => NavigationItemType::class, 'position' => 'integer']; + } +} diff --git a/app/Models/NavigationMenu.php b/app/Models/NavigationMenu.php new file mode 100644 index 00000000..d9175d6b --- /dev/null +++ b/app/Models/NavigationMenu.php @@ -0,0 +1,27 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = ['store_id', 'handle', 'title']; + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + 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..77339e8e --- /dev/null +++ b/app/Models/OauthClient.php @@ -0,0 +1,31 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = ['app_id', 'client_id', 'client_secret_encrypted', 'redirect_uris_json']; + + protected $hidden = ['client_secret_encrypted']; + + protected $attributes = ['redirect_uris_json' => '[]']; + + public function app(): BelongsTo + { + return $this->belongsTo(App::class); + } + + 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..ec597abe --- /dev/null +++ b/app/Models/OauthToken.php @@ -0,0 +1,29 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = ['installation_id', 'access_token_hash', 'refresh_token_hash', 'expires_at']; + + protected $hidden = ['access_token_hash', 'refresh_token_hash']; + + public function installation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class, 'installation_id'); + } + + protected function casts(): array + { + return ['expires_at' => 'datetime']; + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 00000000..48ebe4ac --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,71 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = ['store_id', 'customer_id', 'checkout_id', 'order_number', 'payment_method', 'status', 'financial_status', 'fulfillment_status', 'currency', 'subtotal_amount', 'discount_amount', 'shipping_amount', 'tax_amount', 'total_amount', 'email', 'billing_address_json', 'shipping_address_json', 'placed_at']; + + 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]; + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function checkout(): BelongsTo + { + return $this->belongsTo(Checkout::class); + } + + public function lines(): HasMany + { + return $this->hasMany(OrderLine::class); + } + + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } + + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } + + public function fulfillments(): HasMany + { + return $this->hasMany(Fulfillment::class); + } + + protected function casts(): array + { + return [ + 'payment_method' => PaymentMethod::class, + 'status' => OrderStatus::class, + 'financial_status' => FinancialStatus::class, + 'fulfillment_status' => FulfillmentStatus::class, + '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..a0b636ef --- /dev/null +++ b/app/Models/OrderLine.php @@ -0,0 +1,45 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = ['order_id', 'product_id', 'variant_id', 'title_snapshot', 'sku_snapshot', 'quantity', 'unit_price_amount', 'total_amount', 'tax_lines_json', 'discount_allocations_json']; + + protected $attributes = ['quantity' => 1, 'unit_price_amount' => 0, 'total_amount' => 0, 'tax_lines_json' => '[]', 'discount_allocations_json' => '[]']; + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + public function fulfillmentLines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } + + protected function casts(): array + { + return ['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..1fa6f4d0 --- /dev/null +++ b/app/Models/Organization.php @@ -0,0 +1,24 @@ + */ + use HasFactory; + + /** @var list */ + protected $fillable = [ + 'name', + 'billing_email', + ]; + + public function stores(): HasMany + { + return $this->hasMany(Store::class); + } +} diff --git a/app/Models/Page.php b/app/Models/Page.php new file mode 100644 index 00000000..cded7bf6 --- /dev/null +++ b/app/Models/Page.php @@ -0,0 +1,29 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = ['store_id', 'title', 'handle', 'body_html', 'status', 'published_at']; + + protected $attributes = ['status' => PageStatus::Draft->value]; + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + 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..4ba52492 --- /dev/null +++ b/app/Models/Payment.php @@ -0,0 +1,39 @@ + */ + use HasFactory; + + public const UPDATED_AT = null; + + protected $fillable = ['order_id', 'provider', 'method', 'provider_payment_id', 'status', 'amount', 'currency', 'raw_json_encrypted']; + + protected $hidden = ['raw_json_encrypted']; + + protected $attributes = ['provider' => 'mock', 'status' => 'pending', 'amount' => 0, 'currency' => 'USD']; + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } + + protected function casts(): array + { + return ['method' => PaymentMethod::class, 'status' => PaymentStatus::class, 'raw_json_encrypted' => 'encrypted:array']; + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 00000000..dd0328f2 --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,88 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'status', + 'description_html', + 'vendor', + 'product_type', + 'tags', + 'published_at', + ]; + + protected $attributes = [ + 'status' => ProductStatus::Draft->value, + '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 scopeActive(Builder $query): Builder + { + return $query->where('status', ProductStatus::Active); + } + + public function scopePublished(Builder $query): Builder + { + return $query->active()->whereNotNull('published_at'); + } + + /** @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..94efc374 --- /dev/null +++ b/app/Models/ProductMedia.php @@ -0,0 +1,57 @@ + */ + use HasFactory; + + public const UPDATED_AT = null; + + protected $fillable = [ + 'product_id', + 'type', + 'storage_key', + 'alt_text', + 'width', + 'height', + 'mime_type', + 'byte_size', + 'position', + 'status', + ]; + + protected $attributes = [ + 'type' => MediaType::Image->value, + 'position' => 0, + 'status' => MediaStatus::Processing->value, + ]; + + /** @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, + 'created_at' => 'datetime', + ]; + } +} diff --git a/app/Models/ProductOption.php b/app/Models/ProductOption.php new file mode 100644 index 00000000..2e79aac5 --- /dev/null +++ b/app/Models/ProductOption.php @@ -0,0 +1,39 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = ['product_id', 'name', 'position']; + + protected $attributes = ['position' => 0]; + + /** @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..3b4e8fb3 --- /dev/null +++ b/app/Models/ProductOptionValue.php @@ -0,0 +1,39 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = ['product_option_id', 'value', 'position']; + + protected $attributes = ['position' => 0]; + + /** @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..a9369186 --- /dev/null +++ b/app/Models/ProductVariant.php @@ -0,0 +1,112 @@ + */ + use HasFactory; + + protected $fillable = [ + 'product_id', + 'sku', + 'barcode', + 'price_amount', + 'compare_at_amount', + 'currency', + 'weight_g', + 'requires_shipping', + 'is_default', + 'position', + 'status', + ]; + + protected $attributes = [ + 'price_amount' => 0, + 'currency' => 'USD', + 'requires_shipping' => true, + 'is_default' => false, + 'position' => 0, + 'status' => VariantStatus::Active->value, + ]; + + /** @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'); + } + + protected static function booted(): void + { + static::saving(function (ProductVariant $variant): void { + $variant->sku = filled($variant->sku) ? trim((string) $variant->sku) : null; + + if ($variant->sku === null || $variant->product_id === null) { + return; + } + + $storeId = Product::withoutGlobalScopes()->whereKey($variant->product_id)->value('store_id'); + + $duplicateExists = ProductVariant::withoutGlobalScopes() + ->where('sku', $variant->sku) + ->when($variant->exists, fn (Builder $query): Builder => $query->whereKeyNot($variant->getKey())) + ->whereHas('product', fn (Builder $query): Builder => $query->withoutGlobalScopes()->where('store_id', $storeId)) + ->exists(); + + if ($duplicateExists) { + throw new DuplicateSkuException($variant->sku); + } + }); + + static::created(function (ProductVariant $variant): void { + $storeId = Product::withoutGlobalScopes()->whereKey($variant->product_id)->valueOrFail('store_id'); + + InventoryItem::withoutGlobalScopes()->firstOrCreate( + ['variant_id' => $variant->getKey()], + [ + 'store_id' => $storeId, + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ], + ); + }); + } + + /** @return array */ + protected function casts(): array + { + return [ + 'price_amount' => 'integer', + 'compare_at_amount' => 'integer', + 'weight_g' => 'integer', + 'requires_shipping' => 'boolean', + 'is_default' => 'boolean', + 'position' => 'integer', + 'status' => VariantStatus::class, + ]; + } +} diff --git a/app/Models/Refund.php b/app/Models/Refund.php new file mode 100644 index 00000000..c4e11357 --- /dev/null +++ b/app/Models/Refund.php @@ -0,0 +1,35 @@ + */ + use HasFactory; + + public const UPDATED_AT = null; + + protected $fillable = ['order_id', 'payment_id', 'amount', 'reason', 'status', 'provider_refund_id']; + + protected $attributes = ['amount' => 0, 'status' => 'pending']; + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function payment(): BelongsTo + { + return $this->belongsTo(Payment::class); + } + + protected function casts(): array + { + return ['status' => RefundStatus::class]; + } +} diff --git a/app/Models/Scopes/StoreScope.php b/app/Models/Scopes/StoreScope.php new file mode 100644 index 00000000..615003d7 --- /dev/null +++ b/app/Models/Scopes/StoreScope.php @@ -0,0 +1,26 @@ +bound('current_store')) { + return; + } + + /** @var Store $store */ + $store = app('current_store'); + + $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..1dcdc02a --- /dev/null +++ b/app/Models/SearchQuery.php @@ -0,0 +1,30 @@ + */ + use BelongsToStore, HasFactory; + + public const UPDATED_AT = null; + + protected $fillable = ['store_id', 'query', 'filters_json', 'results_count']; + + protected $attributes = ['results_count' => 0]; + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + protected function casts(): array + { + return ['filters_json' => 'array', 'results_count' => 'integer']; + } +} diff --git a/app/Models/SearchSettings.php b/app/Models/SearchSettings.php new file mode 100644 index 00000000..f43d2992 --- /dev/null +++ b/app/Models/SearchSettings.php @@ -0,0 +1,34 @@ + */ + use BelongsToStore, HasFactory; + + public const CREATED_AT = null; + + protected $primaryKey = 'store_id'; + + public $incrementing = false; + + protected $fillable = ['store_id', 'synonyms_json', 'stop_words_json']; + + protected $attributes = ['synonyms_json' => '[]', 'stop_words_json' => '[]']; + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + 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..ca87887c --- /dev/null +++ b/app/Models/ShippingRate.php @@ -0,0 +1,30 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = ['zone_id', 'name', 'type', 'config_json', 'is_active']; + + protected $attributes = ['type' => 'flat', 'config_json' => '{}', 'is_active' => true]; + + public function zone(): BelongsTo + { + return $this->belongsTo(ShippingZone::class, 'zone_id'); + } + + protected function casts(): array + { + return ['type' => ShippingRateType::class, 'config_json' => 'array', 'is_active' => 'boolean']; + } +} diff --git a/app/Models/ShippingZone.php b/app/Models/ShippingZone.php new file mode 100644 index 00000000..62b7e05c --- /dev/null +++ b/app/Models/ShippingZone.php @@ -0,0 +1,36 @@ + */ + use BelongsToStore, HasFactory; + + public $timestamps = false; + + protected $fillable = ['store_id', 'name', 'countries_json', 'regions_json']; + + protected $attributes = ['countries_json' => '[]', 'regions_json' => '[]']; + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function rates(): HasMany + { + return $this->hasMany(ShippingRate::class, 'zone_id'); + } + + 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..53c38b17 --- /dev/null +++ b/app/Models/Store.php @@ -0,0 +1,161 @@ + */ + use HasFactory; + + /** @var list */ + protected $fillable = [ + 'organization_id', + 'name', + 'handle', + 'status', + 'default_currency', + 'default_locale', + 'timezone', + ]; + + /** @var array */ + protected $attributes = [ + 'status' => StoreStatus::Active->value, + 'default_currency' => 'USD', + 'default_locale' => 'en', + 'timezone' => 'UTC', + ]; + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } + + public function domains(): HasMany + { + return $this->hasMany(StoreDomain::class); + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'store_users') + ->using(StoreUser::class) + ->withPivot(['role', 'created_at']); + } + + public function storeUsers(): HasMany + { + return $this->hasMany(StoreUser::class); + } + + public function settings(): HasOne + { + return $this->hasOne(StoreSettings::class); + } + + public function themes(): HasMany + { + return $this->hasMany(Theme::class); + } + + public function products(): HasMany + { + return $this->hasMany(Product::class); + } + + public function collections(): HasMany + { + return $this->hasMany(Collection::class); + } + + public function customers(): HasMany + { + return $this->hasMany(Customer::class); + } + + public function carts(): HasMany + { + return $this->hasMany(Cart::class); + } + + public function checkouts(): HasMany + { + return $this->hasMany(Checkout::class); + } + + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + public function discounts(): HasMany + { + return $this->hasMany(Discount::class); + } + + public function shippingZones(): HasMany + { + return $this->hasMany(ShippingZone::class); + } + + public function taxSettings(): HasOne + { + return $this->hasOne(TaxSettings::class); + } + + public function pages(): HasMany + { + return $this->hasMany(Page::class); + } + + public function navigationMenus(): HasMany + { + return $this->hasMany(NavigationMenu::class); + } + + public function searchSettings(): HasOne + { + return $this->hasOne(SearchSettings::class); + } + + public function searchQueries(): HasMany + { + return $this->hasMany(SearchQuery::class); + } + + public function analyticsEvents(): HasMany + { + return $this->hasMany(AnalyticsEvent::class); + } + + public function analyticsDaily(): HasMany + { + return $this->hasMany(AnalyticsDaily::class); + } + + public function appInstallations(): HasMany + { + return $this->hasMany(AppInstallation::class); + } + + public function webhookSubscriptions(): HasMany + { + return $this->hasMany(WebhookSubscription::class); + } + + /** @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..e6739c08 --- /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', + ]; + + /** @var array */ + protected $attributes = [ + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => false, + 'tls_mode' => 'managed', + ]; + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** @return array */ + protected function casts(): array + { + return [ + 'type' => StoreDomainType::class, + 'is_primary' => 'boolean', + ]; + } +} diff --git a/app/Models/StoreSettings.php b/app/Models/StoreSettings.php new file mode 100644 index 00000000..79b029c9 --- /dev/null +++ b/app/Models/StoreSettings.php @@ -0,0 +1,44 @@ + */ + use HasFactory; + + public const CREATED_AT = null; + + protected $primaryKey = 'store_id'; + + public $incrementing = false; + + /** @var list */ + protected $fillable = [ + 'store_id', + 'settings_json', + ]; + + /** @var array */ + protected $attributes = [ + 'settings_json' => '{}', + ]; + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** @return array */ + protected function casts(): array + { + return [ + 'settings_json' => 'array', + 'updated_at' => 'datetime', + ]; + } +} diff --git a/app/Models/StoreUser.php b/app/Models/StoreUser.php new file mode 100644 index 00000000..16aef4aa --- /dev/null +++ b/app/Models/StoreUser.php @@ -0,0 +1,52 @@ + */ + use HasFactory; + + public $incrementing = false; + + public $timestamps = false; + + protected $table = 'store_users'; + + /** @var list */ + protected $fillable = [ + 'store_id', + 'user_id', + 'role', + 'created_at', + ]; + + /** @var array */ + protected $attributes = [ + 'role' => StoreUserRole::Staff->value, + ]; + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** @return array */ + protected function casts(): array + { + return [ + 'role' => StoreUserRole::class, + 'created_at' => 'datetime', + ]; + } +} diff --git a/app/Models/TaxSettings.php b/app/Models/TaxSettings.php new file mode 100644 index 00000000..f8a9e620 --- /dev/null +++ b/app/Models/TaxSettings.php @@ -0,0 +1,34 @@ + */ + use HasFactory; + + public $incrementing = false; + + public $timestamps = false; + + protected $primaryKey = 'store_id'; + + protected $fillable = ['store_id', 'mode', 'provider', 'prices_include_tax', 'config_json']; + + protected $attributes = ['mode' => 'manual', 'provider' => 'none', 'prices_include_tax' => false, 'config_json' => '{}']; + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + protected function casts(): array + { + return ['mode' => TaxMode::class, 'prices_include_tax' => 'boolean', 'config_json' => 'array']; + } +} diff --git a/app/Models/Theme.php b/app/Models/Theme.php new file mode 100644 index 00000000..7fd7a32a --- /dev/null +++ b/app/Models/Theme.php @@ -0,0 +1,41 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = ['store_id', 'name', 'version', 'status', 'published_at']; + + protected $attributes = ['status' => ThemeStatus::Draft->value]; + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function files(): HasMany + { + return $this->hasMany(ThemeFile::class); + } + + public function settings(): HasOne + { + return $this->hasOne(ThemeSettings::class); + } + + 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..6b352f21 --- /dev/null +++ b/app/Models/ThemeFile.php @@ -0,0 +1,29 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = ['theme_id', 'path', 'storage_key', 'sha256', 'byte_size']; + + protected $attributes = ['byte_size' => 0]; + + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } + + 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..1213e7f1 --- /dev/null +++ b/app/Models/ThemeSettings.php @@ -0,0 +1,33 @@ + */ + use HasFactory; + + public const CREATED_AT = null; + + protected $primaryKey = 'theme_id'; + + public $incrementing = false; + + protected $fillable = ['theme_id', 'settings_json']; + + protected $attributes = ['settings_json' => '{}']; + + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } + + protected function casts(): array + { + return ['settings_json' => 'array', 'updated_at' => 'datetime']; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 214bea4e..e1912ac0 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,16 +3,22 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Enums\StoreUserRole; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; use Laravel\Fortify\TwoFactorAuthenticatable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable, TwoFactorAuthenticatable; + use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; /** * The attributes that are mass assignable. @@ -23,6 +29,14 @@ class User extends Authenticatable 'name', 'email', 'password', + 'password_hash', + 'status', + 'last_login_at', + ]; + + /** @var array */ + protected $attributes = [ + 'status' => 'active', ]; /** @@ -32,6 +46,7 @@ class User extends Authenticatable */ protected $hidden = [ 'password', + 'password_hash', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token', @@ -46,10 +61,51 @@ protected function casts(): array { return [ 'email_verified_at' => 'datetime', - 'password' => 'hashed', + 'last_login_at' => 'datetime', + 'two_factor_confirmed_at' => 'datetime', ]; } + public function stores(): BelongsToMany + { + return $this->belongsToMany(Store::class, 'store_users') + ->using(StoreUser::class) + ->withPivot(['role', 'created_at']); + } + + public function storeUsers(): HasMany + { + return $this->hasMany(StoreUser::class); + } + + public function roleForStore(Store $store): ?StoreUserRole + { + return $this->storeUsers() + ->where('store_id', $store->getKey()) + ->first() + ?->role; + } + + public function getAuthPasswordName(): string + { + return 'password_hash'; + } + + public function getAuthPassword(): string + { + return (string) $this->getAttribute('password_hash'); + } + + protected function password(): Attribute + { + return Attribute::make( + get: fn (): ?string => $this->getAttribute('password_hash'), + set: fn (string $value): array => [ + 'password_hash' => Hash::needsRehash($value) ? Hash::make($value) : $value, + ], + ); + } + /** * Get the user's initials */ diff --git a/app/Models/WebhookDelivery.php b/app/Models/WebhookDelivery.php new file mode 100644 index 00000000..a88fe1ab --- /dev/null +++ b/app/Models/WebhookDelivery.php @@ -0,0 +1,38 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'subscription_id', 'event_id', 'attempt_count', 'status', + 'last_attempt_at', 'response_code', 'response_body_snippet', + ]; + + protected $attributes = ['attempt_count' => 1, 'status' => WebhookDeliveryStatus::Pending->value]; + + public function subscription(): BelongsTo + { + return $this->belongsTo(WebhookSubscription::class, 'subscription_id'); + } + + protected function casts(): array + { + return [ + 'attempt_count' => 'integer', + 'status' => WebhookDeliveryStatus::class, + 'last_attempt_at' => 'datetime', + 'response_code' => 'integer', + ]; + } +} diff --git a/app/Models/WebhookSubscription.php b/app/Models/WebhookSubscription.php new file mode 100644 index 00000000..08946ea1 --- /dev/null +++ b/app/Models/WebhookSubscription.php @@ -0,0 +1,50 @@ + */ + use BelongsToStore, HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'store_id', 'app_installation_id', 'event_type', 'target_url', + 'signing_secret_encrypted', 'status', + ]; + + protected $hidden = ['signing_secret_encrypted']; + + protected $attributes = ['status' => WebhookSubscriptionStatus::Active->value]; + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function appInstallation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class); + } + + public function deliveries(): HasMany + { + return $this->hasMany(WebhookDelivery::class, 'subscription_id'); + } + + protected function casts(): array + { + return [ + 'signing_secret_encrypted' => 'encrypted', + 'status' => WebhookSubscriptionStatus::class, + ]; + } +} diff --git a/app/Observers/ProductObserver.php b/app/Observers/ProductObserver.php new file mode 100644 index 00000000..e193d844 --- /dev/null +++ b/app/Observers/ProductObserver.php @@ -0,0 +1,51 @@ +search->syncProduct($product); + } + + /** + * Handle the Product "updated" event. + */ + public function updated(Product $product): void + { + $this->search->syncProduct($product); + } + + /** + * Handle the Product "deleted" event. + */ + public function deleted(Product $product): void + { + $this->search->removeProduct($product->id); + } + + /** + * Handle the Product "restored" event. + */ + public function restored(Product $product): void + { + $this->search->syncProduct($product); + } + + /** + * Handle the Product "force deleted" event. + */ + public function forceDeleted(Product $product): void + { + $this->search->removeProduct($product->id); + } +} diff --git a/app/Policies/CollectionPolicy.php b/app/Policies/CollectionPolicy.php new file mode 100644 index 00000000..9f00b364 --- /dev/null +++ b/app/Policies/CollectionPolicy.php @@ -0,0 +1,56 @@ +isAnyRole($user, $this->currentStoreId()); + } + + public function view(User $user, Collection $collection): bool + { + return $this->isAnyRole($user, $collection->store_id); + } + + public function create(User $user): bool + { + return $this->isOwnerAdminOrStaff($user, $this->currentStoreId()); + } + + public function update(User $user, Collection $collection): bool + { + return $this->isOwnerAdminOrStaff($user, $collection->store_id); + } + + public function delete(User $user, Collection $collection): bool + { + return $this->isOwnerOrAdmin($user, $collection->store_id); + } + + public function restore(User $user, Collection $collection): bool + { + return $this->delete($user, $collection); + } + + public function forceDelete(User $user, Collection $collection): bool + { + return $this->delete($user, $collection); + } + + private function currentStoreId(): int + { + /** @var Store $store */ + $store = app('current_store'); + + return $store->getKey(); + } +} diff --git a/app/Policies/CustomerPolicy.php b/app/Policies/CustomerPolicy.php new file mode 100644 index 00000000..778b7d89 --- /dev/null +++ b/app/Policies/CustomerPolicy.php @@ -0,0 +1,27 @@ +bound('current_store') && $this->isAnyRole($user, app('current_store')->id); + } + + public function view(User $user, Customer $customer): bool + { + return $this->isAnyRole($user, $customer->store_id); + } + + public function update(User $user, Customer $customer): bool + { + return $this->isOwnerAdminOrStaff($user, $customer->store_id); + } +} diff --git a/app/Policies/DiscountPolicy.php b/app/Policies/DiscountPolicy.php new file mode 100644 index 00000000..48e4c98e --- /dev/null +++ b/app/Policies/DiscountPolicy.php @@ -0,0 +1,37 @@ +bound('current_store') && $this->isAnyRole($user, app('current_store')->id); + } + + public function view(User $user, Discount $discount): bool + { + return $this->isAnyRole($user, $discount->store_id); + } + + public function create(User $user): bool + { + return app()->bound('current_store') && $this->isOwnerAdminOrStaff($user, app('current_store')->id); + } + + public function update(User $user, Discount $discount): bool + { + return $this->isOwnerAdminOrStaff($user, $discount->store_id); + } + + public function delete(User $user, Discount $discount): bool + { + return $this->isOwnerOrAdmin($user, $discount->store_id); + } +} diff --git a/app/Policies/FulfillmentPolicy.php b/app/Policies/FulfillmentPolicy.php new file mode 100644 index 00000000..316d792d --- /dev/null +++ b/app/Policies/FulfillmentPolicy.php @@ -0,0 +1,28 @@ +isOwnerAdminOrStaff($user, $order->store_id); + } + + public function update(User $user, Fulfillment $fulfillment): bool + { + return $this->isOwnerAdminOrStaff($user, $fulfillment->order->store_id); + } + + public function cancel(User $user, Fulfillment $fulfillment): bool + { + return $this->isOwnerAdminOrStaff($user, $fulfillment->order->store_id); + } +} diff --git a/app/Policies/NavigationMenuPolicy.php b/app/Policies/NavigationMenuPolicy.php new file mode 100644 index 00000000..c67a0d93 --- /dev/null +++ b/app/Policies/NavigationMenuPolicy.php @@ -0,0 +1,21 @@ +bound('current_store') && $this->isOwnerAdminOrStaff($user, app('current_store')->id); + } + + public function manage(User $user): bool + { + return app()->bound('current_store') && $this->isOwnerOrAdmin($user, app('current_store')->id); + } +} diff --git a/app/Policies/OrderPolicy.php b/app/Policies/OrderPolicy.php new file mode 100644 index 00000000..84b3943d --- /dev/null +++ b/app/Policies/OrderPolicy.php @@ -0,0 +1,42 @@ +bound('current_store') && $this->isAnyRole($user, app('current_store')->id); + } + + public function view(User $user, Order $order): bool + { + return $this->isAnyRole($user, $order->store_id); + } + + public function update(User $user, Order $order): bool + { + return $this->isOwnerAdminOrStaff($user, $order->store_id); + } + + public function cancel(User $user, Order $order): bool + { + return $this->isOwnerOrAdmin($user, $order->store_id); + } + + public function createFulfillment(User $user, Order $order): bool + { + return $this->isOwnerAdminOrStaff($user, $order->store_id); + } + + public function createRefund(User $user, Order $order): bool + { + return $this->isOwnerOrAdmin($user, $order->store_id); + } +} diff --git a/app/Policies/PagePolicy.php b/app/Policies/PagePolicy.php new file mode 100644 index 00000000..3736b717 --- /dev/null +++ b/app/Policies/PagePolicy.php @@ -0,0 +1,37 @@ +bound('current_store') && $this->isOwnerAdminOrStaff($user, app('current_store')->id); + } + + public function view(User $user, Page $page): bool + { + return $this->isOwnerAdminOrStaff($user, $page->store_id); + } + + public function create(User $user): bool + { + return app()->bound('current_store') && $this->isOwnerAdminOrStaff($user, app('current_store')->id); + } + + public function update(User $user, Page $page): bool + { + return $this->isOwnerAdminOrStaff($user, $page->store_id); + } + + public function delete(User $user, Page $page): bool + { + return $this->isOwnerOrAdmin($user, $page->store_id); + } +} diff --git a/app/Policies/ProductPolicy.php b/app/Policies/ProductPolicy.php new file mode 100644 index 00000000..903aabe3 --- /dev/null +++ b/app/Policies/ProductPolicy.php @@ -0,0 +1,61 @@ +isAnyRole($user, $this->currentStoreId()); + } + + public function view(User $user, Product $product): bool + { + return $this->isAnyRole($user, $product->store_id); + } + + public function create(User $user): bool + { + return $this->isOwnerAdminOrStaff($user, $this->currentStoreId()); + } + + public function update(User $user, Product $product): bool + { + return $this->isOwnerAdminOrStaff($user, $product->store_id); + } + + public function delete(User $user, Product $product): bool + { + return $this->isOwnerOrAdmin($user, $product->store_id); + } + + public function archive(User $user, Product $product): bool + { + return $this->isOwnerOrAdmin($user, $product->store_id); + } + + public function restore(User $user, Product $product): bool + { + return $this->isOwnerOrAdmin($user, $product->store_id); + } + + public function forceDelete(User $user, Product $product): bool + { + return $this->delete($user, $product); + } + + private function currentStoreId(): int + { + /** @var Store $store */ + $store = app('current_store'); + + return $store->getKey(); + } +} diff --git a/app/Policies/RefundPolicy.php b/app/Policies/RefundPolicy.php new file mode 100644 index 00000000..c922d5b2 --- /dev/null +++ b/app/Policies/RefundPolicy.php @@ -0,0 +1,17 @@ +isOwnerOrAdmin($user, $order->store_id); + } +} diff --git a/app/Policies/StorePolicy.php b/app/Policies/StorePolicy.php new file mode 100644 index 00000000..eb79584c --- /dev/null +++ b/app/Policies/StorePolicy.php @@ -0,0 +1,28 @@ +isOwnerOrAdmin($user, $store->id); + } + + public function updateSettings(User $user, Store $store): bool + { + return $this->isOwnerOrAdmin($user, $store->id); + } + + public function delete(User $user, Store $store): bool + { + return $this->hasRole($user, $store->id, [StoreUserRole::Owner]); + } +} diff --git a/app/Policies/ThemePolicy.php b/app/Policies/ThemePolicy.php new file mode 100644 index 00000000..d32e4fd0 --- /dev/null +++ b/app/Policies/ThemePolicy.php @@ -0,0 +1,42 @@ +bound('current_store') && $this->isOwnerOrAdmin($user, app('current_store')->id); + } + + public function view(User $user, Theme $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } + + public function create(User $user): bool + { + return app()->bound('current_store') && $this->isOwnerOrAdmin($user, app('current_store')->id); + } + + public function update(User $user, Theme $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } + + public function delete(User $user, Theme $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } + + public function publish(User $user, Theme $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8a29e6f5..6fd40d6a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,11 +2,24 @@ namespace App\Providers; +use App\Auth\CustomerUserProvider; +use App\Contracts\PaymentProvider; +use App\Enums\StoreUserRole; +use App\Http\Middleware\ResolveStore; +use App\Models\Product; +use App\Observers\ProductObserver; +use App\Services\Payments\MockPaymentProvider; use Carbon\CarbonImmutable; +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\Gate; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; +use Livewire\Livewire; class AppServiceProvider extends ServiceProvider { @@ -15,7 +28,7 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->bind(PaymentProvider::class, MockPaymentProvider::class); } /** @@ -24,6 +37,53 @@ public function register(): void public function boot(): void { $this->configureDefaults(); + $this->configureAuthentication(); + $this->configureLivewire(); + $this->configureRateLimiters(); + $this->configureGates(); + Product::observe(ProductObserver::class); + } + + private function configureAuthentication(): void + { + Auth::provider('store-customers', fn ($app, array $config): CustomerUserProvider => new CustomerUserProvider($app['hash'], $config['model'])); + } + + private function configureLivewire(): void + { + Livewire::addPersistentMiddleware([ + ResolveStore::class, + ]); + } + + private function configureRateLimiters(): void + { + RateLimiter::for('login', fn (Request $request): Limit => Limit::perMinute(5)->by($request->ip())); + RateLimiter::for('api.admin', fn (Request $request): Limit => Limit::perMinute(60)->by((string) ($request->user()?->currentAccessToken()?->id ?? $request->user()?->id ?? $request->ip()))); + RateLimiter::for('api.storefront', fn (Request $request): Limit => Limit::perMinute(120)->by($request->ip())); + RateLimiter::for('checkout', fn (Request $request): Limit => Limit::perMinute(10)->by($request->hasSession() ? $request->session()->getId() : $request->ip())); + RateLimiter::for('search', fn (Request $request): Limit => Limit::perMinute(30)->by($request->ip())); + RateLimiter::for('analytics', fn (Request $request): Limit => Limit::perMinute(60)->by($request->ip())); + RateLimiter::for('webhooks', fn (Request $request): Limit => Limit::perMinute(100)->by($request->ip())); + } + + private function configureGates(): void + { + $gates = [ + 'manage-store-settings' => [StoreUserRole::Owner, StoreUserRole::Admin], + 'manage-staff' => [StoreUserRole::Owner, StoreUserRole::Admin], + 'manage-developers' => [StoreUserRole::Owner, StoreUserRole::Admin], + 'view-analytics' => [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff], + 'manage-shipping' => [StoreUserRole::Owner, StoreUserRole::Admin], + 'manage-taxes' => [StoreUserRole::Owner, StoreUserRole::Admin], + 'manage-search-settings' => [StoreUserRole::Owner, StoreUserRole::Admin], + 'manage-navigation' => [StoreUserRole::Owner, StoreUserRole::Admin], + 'manage-apps' => [StoreUserRole::Owner, StoreUserRole::Admin], + ]; + + foreach ($gates as $name => $roles) { + Gate::define($name, fn ($user): bool => app()->bound('current_store') && in_array($user->roleForStore(app('current_store')), $roles, true)); + } } /** diff --git a/app/Services/AnalyticsService.php b/app/Services/AnalyticsService.php new file mode 100644 index 00000000..b9ad2fa6 --- /dev/null +++ b/app/Services/AnalyticsService.php @@ -0,0 +1,52 @@ + $properties */ + public function track( + Store $store, + string $type, + array $properties = [], + ?string $sessionId = null, + ?int $customerId = null, + ?string $clientEventId = null, + ?CarbonInterface $occurredAt = null, + ): void { + $eventType = AnalyticsEventType::tryFrom($type) + ?? throw new InvalidArgumentException("Unsupported analytics event type [{$type}]."); + + try { + AnalyticsEvent::withoutGlobalScope(StoreScope::class)->create([ + 'store_id' => $store->id, + 'type' => $eventType, + 'session_id' => $sessionId, + 'customer_id' => $customerId, + 'properties_json' => $properties, + 'client_event_id' => $clientEventId, + 'occurred_at' => $occurredAt ?? now(), + ]); + } catch (UniqueConstraintViolationException) { + } + } + + public function getDailyMetrics(Store $store, string $startDate, string $endDate): Collection + { + return AnalyticsDaily::withoutGlobalScope(StoreScope::class) + ->where('store_id', $store->id) + ->whereBetween('date', [$startDate, $endDate]) + ->orderBy('date') + ->get(); + } +} diff --git a/app/Services/CartService.php b/app/Services/CartService.php new file mode 100644 index 00000000..9ffb56f9 --- /dev/null +++ b/app/Services/CartService.php @@ -0,0 +1,153 @@ +create([ + 'store_id' => $store->id, + 'customer_id' => $customer?->id, + 'currency' => $store->default_currency, + ]); + } + + public function addLine(Cart $cart, int $variantId, int $quantity, ?int $expectedVersion = null): CartLine + { + $this->assertExpectedVersion($cart, $expectedVersion); + + return DB::transaction(function () use ($cart, $variantId, $quantity): CartLine { + if ($quantity < 1) { + throw ValidationException::withMessages(['quantity' => 'Quantity must be at least one.']); + } + + $variant = ProductVariant::query()->with(['product', 'inventoryItem'])->findOrFail($variantId); + $productStatus = $variant->product->status instanceof ProductStatus ? $variant->product->status : ProductStatus::from($variant->product->status); + $variantStatus = $variant->status instanceof VariantStatus ? $variant->status : VariantStatus::from($variant->status); + + if ($variant->product->store_id !== $cart->store_id || $productStatus !== ProductStatus::Active || $variantStatus !== VariantStatus::Active) { + throw ValidationException::withMessages(['variant_id' => 'This product variant is not available.']); + } + + $line = $cart->lines()->where('variant_id', $variant->id)->first(); + $newQuantity = ($line?->quantity ?? 0) + $quantity; + $this->ensureAvailable($variant, $newQuantity); + + $line ??= new CartLine(['cart_id' => $cart->id, 'variant_id' => $variant->id]); + $this->setLineAmounts($line, $variant->price_amount, $newQuantity); + $line->save(); + $cart->increment('cart_version'); + + return $line->refresh(); + }); + } + + public function updateLineQuantity(Cart $cart, int $lineId, int $quantity, ?int $expectedVersion = null): ?CartLine + { + $this->assertExpectedVersion($cart, $expectedVersion); + + if ($quantity === 0) { + $this->removeLine($cart, $lineId); + + return null; + } + + return DB::transaction(function () use ($cart, $lineId, $quantity): CartLine { + if ($quantity < 0) { + throw ValidationException::withMessages(['quantity' => 'Quantity cannot be negative.']); + } + + $line = $cart->lines()->with('variant.inventoryItem')->findOrFail($lineId); + $this->ensureAvailable($line->variant, $quantity); + $this->setLineAmounts($line, $line->variant->price_amount, $quantity); + $line->save(); + $cart->increment('cart_version'); + + return $line->refresh(); + }); + } + + public function removeLine(Cart $cart, int $lineId, ?int $expectedVersion = null): void + { + $this->assertExpectedVersion($cart, $expectedVersion); + + DB::transaction(function () use ($cart, $lineId): void { + $cart->lines()->findOrFail($lineId)->delete(); + $cart->increment('cart_version'); + }); + } + + public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart + { + $cart = $customer?->carts()->where('status', CartStatus::Active)->latest('id')->first(); + + if (! $cart && ($cartId = session('cart_id'))) { + $cart = Cart::query()->whereKey($cartId)->where('store_id', $store->id)->where('status', CartStatus::Active)->first(); + } + + $cart ??= $this->create($store, $customer); + session(['cart_id' => $cart->id]); + + return $cart; + } + + public function mergeOnLogin(Cart $guestCart, Cart $customerCart): Cart + { + return DB::transaction(function () use ($guestCart, $customerCart): Cart { + foreach ($guestCart->lines as $guestLine) { + $existingLine = $customerCart->lines()->where('variant_id', $guestLine->variant_id)->first(); + + if ($existingLine) { + $quantity = max($existingLine->quantity, $guestLine->quantity); + $this->setLineAmounts($existingLine, $existingLine->unit_price_amount, $quantity); + $existingLine->save(); + $guestLine->delete(); + } else { + $guestLine->update(['cart_id' => $customerCart->id]); + } + } + + $guestCart->update(['status' => CartStatus::Abandoned]); + $customerCart->increment('cart_version'); + session()->forget('cart_id'); + + return $customerCart->refresh()->load('lines'); + }); + } + + public function assertExpectedVersion(Cart $cart, ?int $expectedVersion): void + { + if ($expectedVersion !== null && $expectedVersion !== $cart->cart_version) { + throw new CartVersionConflictException($cart->cart_version); + } + } + + private function setLineAmounts(CartLine $line, int $unitPrice, int $quantity): void + { + $subtotal = $unitPrice * $quantity; + $line->fill(['quantity' => $quantity, 'unit_price_amount' => $unitPrice, 'line_subtotal_amount' => $subtotal, 'line_discount_amount' => 0, 'line_total_amount' => $subtotal]); + } + + private function ensureAvailable(ProductVariant $variant, int $quantity): void + { + if (! $this->inventoryService->checkAvailability($variant->inventoryItem, $quantity)) { + throw new InsufficientInventoryException($variant->inventoryItem->id, $quantity, $variant->inventoryItem->available); + } + } +} diff --git a/app/Services/CheckoutService.php b/app/Services/CheckoutService.php new file mode 100644 index 00000000..0554454c --- /dev/null +++ b/app/Services/CheckoutService.php @@ -0,0 +1,154 @@ +lines()->exists()) { + throw ValidationException::withMessages(['cart' => 'The cart is empty.']); + } + + return Checkout::query()->create(['store_id' => $cart->store_id, 'cart_id' => $cart->id, 'customer_id' => $cart->customer_id]); + } + + /** @param array $data */ + public function setAddress(Checkout $checkout, array $data): Checkout + { + $this->requireStatus($checkout, CheckoutStatus::Started, CheckoutStatus::Addressed); + + $validated = Validator::make($data, [ + 'email' => ['required', 'email'], + 'shipping_address' => ['required', 'array'], + 'shipping_address.first_name' => ['required', 'string', 'max:255'], + 'shipping_address.last_name' => ['required', 'string', 'max:255'], + 'shipping_address.address1' => ['required', 'string', 'max:255'], + 'shipping_address.city' => ['required', 'string', 'max:255'], + 'shipping_address.country' => ['required', 'string', 'size:2'], + 'shipping_address.postal_code' => ['required', 'string', 'max:32'], + 'billing_address' => ['sometimes', 'array'], + ])->validate(); + + $checkout->update([ + 'email' => $validated['email'], + 'shipping_address_json' => $validated['shipping_address'], + 'billing_address_json' => $validated['billing_address'] ?? $validated['shipping_address'], + 'status' => CheckoutStatus::Addressed, + ]); + $this->pricingEngine->calculate($checkout->refresh()); + CheckoutAddressed::dispatch($checkout); + + return $checkout->refresh(); + } + + public function setShippingMethod(Checkout $checkout, ?int $shippingRateId): Checkout + { + $this->requireStatus($checkout, CheckoutStatus::Addressed, CheckoutStatus::ShippingSelected); + $checkout->loadMissing('cart.lines.variant'); + $requiresShipping = $checkout->cart->lines->contains(fn ($line): bool => $line->variant->requires_shipping); + + if ($requiresShipping) { + $availableRates = $this->shippingCalculator->getAvailableRates($checkout->store, $checkout->shipping_address_json ?? []); + + if (! $shippingRateId || ! $availableRates->contains('id', $shippingRateId)) { + throw ValidationException::withMessages(['shipping_method_id' => 'Select an available shipping method.']); + } + } + + $checkout->update(['shipping_method_id' => $requiresShipping ? $shippingRateId : null, 'status' => CheckoutStatus::ShippingSelected]); + $this->pricingEngine->calculate($checkout->refresh()); + CheckoutShippingSelected::dispatch($checkout); + + return $checkout->refresh(); + } + + public function selectPaymentMethod(Checkout $checkout, PaymentMethod|string $paymentMethod): Checkout + { + $this->requireStatus($checkout, CheckoutStatus::ShippingSelected); + $paymentMethod = is_string($paymentMethod) ? PaymentMethod::from($paymentMethod) : $paymentMethod; + + DB::transaction(function () use ($checkout, $paymentMethod): void { + foreach ($checkout->cart->lines()->with('variant.inventoryItem')->get() as $line) { + $this->inventoryService->reserve($line->variant->inventoryItem, $line->quantity); + } + + $checkout->update(['payment_method' => $paymentMethod, 'status' => CheckoutStatus::PaymentSelected, 'expires_at' => now()->addDay()]); + }); + + return $checkout->refresh(); + } + + /** @param array $paymentDetails */ + public function completeCheckout(Checkout $checkout, array $paymentDetails = []): Order + { + if ($existingOrder = $checkout->order()->first()) { + return $existingOrder; + } + + $this->requireStatus($checkout, CheckoutStatus::PaymentSelected); + + try { + $paymentResult = $this->paymentService->charge($checkout, $paymentDetails); + $order = $this->orderService->createFromCheckout($checkout, $paymentResult); + } catch (PaymentFailedException $exception) { + foreach ($checkout->cart->lines()->with('variant.inventoryItem')->get() as $line) { + $this->inventoryService->release($line->variant->inventoryItem, $line->quantity); + } + + $checkout->update(['status' => CheckoutStatus::ShippingSelected, 'expires_at' => null]); + throw $exception; + } + + $checkout->update(['status' => CheckoutStatus::Completed]); + CheckoutCompleted::dispatch($checkout, $order); + + return $order; + } + + public function expireCheckout(Checkout $checkout): void + { + if (in_array($checkout->status, [CheckoutStatus::Completed, CheckoutStatus::Expired], true)) { + return; + } + + if ($checkout->status === CheckoutStatus::PaymentSelected) { + foreach ($checkout->cart->lines()->with('variant.inventoryItem')->get() as $line) { + $this->inventoryService->release($line->variant->inventoryItem, $line->quantity); + } + } + + $checkout->update(['status' => CheckoutStatus::Expired]); + CheckoutExpired::dispatch($checkout); + } + + private function requireStatus(Checkout $checkout, CheckoutStatus ...$statuses): void + { + if (! in_array($checkout->status, $statuses, true)) { + throw new InvalidCheckoutTransitionException("Checkout cannot transition from {$checkout->status->value}."); + } + } +} diff --git a/app/Services/CustomerService.php b/app/Services/CustomerService.php new file mode 100644 index 00000000..8da77ee4 --- /dev/null +++ b/app/Services/CustomerService.php @@ -0,0 +1,37 @@ + $data */ + public function register(Store $store, array $data): Customer + { + $validator = validator($data, [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', Rule::unique('customers')->where('store_id', $store->id)], + 'password' => ['required', 'string', 'min:8', 'confirmed'], + 'marketing_opt_in' => ['sometimes', 'boolean'], + ]); + + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $validated = $validator->validated(); + + return Customer::query()->create([ + 'store_id' => $store->id, + 'name' => $validated['name'], + 'email' => $validated['email'], + 'password_hash' => Hash::make($validated['password']), + 'marketing_opt_in' => $validated['marketing_opt_in'] ?? false, + ]); + } +} diff --git a/app/Services/DiscountService.php b/app/Services/DiscountService.php new file mode 100644 index 00000000..4df1100b --- /dev/null +++ b/app/Services/DiscountService.php @@ -0,0 +1,107 @@ +where('store_id', $store->id) + ->whereRaw('LOWER(code) = ?', [Str::lower(Str::squish($code))]) + ->first(); + + if (! $discount) { + throw new InvalidDiscountException('discount_not_found'); + } + + if ($discount->status !== DiscountStatus::Active || ($discount->ends_at && $discount->ends_at->isPast())) { + throw new InvalidDiscountException('discount_expired'); + } + + if ($discount->starts_at->isFuture()) { + throw new InvalidDiscountException('discount_not_yet_active'); + } + + if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) { + throw new InvalidDiscountException('discount_usage_limit_reached'); + } + + $subtotal = $cart->lines()->sum('line_subtotal_amount'); + $minimum = (int) ($discount->rules_json['min_purchase_amount'] ?? 0); + + if ($subtotal < $minimum) { + throw new InvalidDiscountException('discount_min_purchase_not_met'); + } + + if ($this->qualifyingLines($discount, $cart->lines()->with('variant.product.collections')->get())->isEmpty()) { + throw new InvalidDiscountException('discount_not_applicable'); + } + + return $discount; + } + + /** @param iterable $lines */ + public function calculate(Discount $discount, int $subtotal, iterable $lines): DiscountResult + { + $qualifyingLines = $this->qualifyingLines($discount, collect($lines)); + + if ($discount->value_type === DiscountValueType::FreeShipping) { + return new DiscountResult(0, [], true); + } + + $qualifyingSubtotal = (int) $qualifyingLines->sum('line_subtotal_amount'); + + if ($qualifyingSubtotal <= 0) { + return new DiscountResult(0, []); + } + + $amount = $discount->value_type === DiscountValueType::Percent + ? (int) round($qualifyingSubtotal * $discount->value_amount / 100) + : min($discount->value_amount, $qualifyingSubtotal, $subtotal); + + $remaining = $amount; + $allocations = []; + + foreach ($qualifyingLines->values() as $index => $line) { + $isLast = $index === $qualifyingLines->count() - 1; + $allocation = $isLast ? $remaining : (int) round($amount * $line->line_subtotal_amount / $qualifyingSubtotal); + $allocation = min($allocation, $remaining, $line->line_subtotal_amount); + $allocations[$line->id] = $allocation; + $remaining -= $allocation; + } + + return new DiscountResult($amount - $remaining, $allocations); + } + + /** @param Collection $lines + * @return Collection + */ + private function qualifyingLines(Discount $discount, Collection $lines): Collection + { + $productIds = collect($discount->rules_json['applicable_product_ids'] ?? [])->filter(); + $collectionIds = collect($discount->rules_json['applicable_collection_ids'] ?? [])->filter(); + + if ($productIds->isEmpty() && $collectionIds->isEmpty()) { + return $lines; + } + + return $lines->filter(function (CartLine $line) use ($productIds, $collectionIds): bool { + $product = $line->variant->product; + + return $productIds->contains($product->id) + || $product->collections->pluck('id')->intersect($collectionIds)->isNotEmpty(); + }); + } +} diff --git a/app/Services/FulfillmentService.php b/app/Services/FulfillmentService.php new file mode 100644 index 00000000..2f516c2f --- /dev/null +++ b/app/Services/FulfillmentService.php @@ -0,0 +1,91 @@ + $lines + * @param array $tracking + */ + public function create(Order $order, array $lines, ?array $tracking = null): Fulfillment + { + if (! in_array($order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true)) { + throw new FulfillmentGuardException; + } + + return DB::transaction(function () use ($order, $lines, $tracking): Fulfillment { + $fulfillment = $order->fulfillments()->create([ + 'tracking_company' => $tracking['tracking_company'] ?? null, + 'tracking_number' => $tracking['tracking_number'] ?? null, + 'tracking_url' => $tracking['tracking_url'] ?? null, + ]); + + foreach ($lines as $orderLineId => $quantity) { + $orderLine = $order->lines()->findOrFail($orderLineId); + $fulfilledQuantity = $orderLine->fulfillmentLines()->sum('quantity'); + + if ($quantity < 1 || $quantity > $orderLine->quantity - $fulfilledQuantity) { + throw ValidationException::withMessages(['lines' => 'A fulfillment quantity exceeds the unfulfilled quantity.']); + } + + $fulfillment->lines()->create(['order_line_id' => $orderLine->id, 'quantity' => $quantity]); + } + + $order->load('lines.fulfillmentLines'); + $allFulfilled = $order->lines->every(fn ($line): bool => $line->fulfillmentLines->sum('quantity') >= $line->quantity); + $order->update([ + 'fulfillment_status' => $allFulfilled ? FulfillmentStatus::Fulfilled : FulfillmentStatus::Partial, + 'status' => $allFulfilled ? OrderStatus::Fulfilled : $order->status, + ]); + + FulfillmentCreated::dispatch($fulfillment); + + if ($allFulfilled) { + OrderFulfilled::dispatch($order); + } + + return $fulfillment->refresh()->load('lines'); + }); + } + + /** @param array $tracking */ + public function markAsShipped(Fulfillment $fulfillment, ?array $tracking = null): void + { + if ($fulfillment->status !== FulfillmentShipmentStatus::Pending) { + throw ValidationException::withMessages(['fulfillment' => 'Only pending fulfillments can be shipped.']); + } + + $fulfillment->update([ + 'status' => FulfillmentShipmentStatus::Shipped, + 'tracking_company' => $tracking['tracking_company'] ?? $fulfillment->tracking_company, + 'tracking_number' => $tracking['tracking_number'] ?? $fulfillment->tracking_number, + 'tracking_url' => $tracking['tracking_url'] ?? $fulfillment->tracking_url, + 'shipped_at' => now(), + ]); + FulfillmentShipped::dispatch($fulfillment); + } + + public function markAsDelivered(Fulfillment $fulfillment): void + { + if ($fulfillment->status !== FulfillmentShipmentStatus::Shipped) { + throw ValidationException::withMessages(['fulfillment' => 'Only shipped fulfillments can be delivered.']); + } + + $fulfillment->update(['status' => FulfillmentShipmentStatus::Delivered, 'delivered_at' => now()]); + FulfillmentDelivered::dispatch($fulfillment); + } +} diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php new file mode 100644 index 00000000..9edf0ca0 --- /dev/null +++ b/app/Services/InventoryService.php @@ -0,0 +1,103 @@ +policy === InventoryPolicy::Continue || $item->available >= $quantity; + } + + public function reserve(InventoryItem $item, int $quantity): void + { + $this->assertPositiveQuantity($quantity); + + DB::transaction(function () use ($item, $quantity): void { + $inventory = $this->lock($item); + + if (! $this->checkAvailability($inventory, $quantity)) { + throw new InsufficientInventoryException($inventory->getKey(), $quantity, $inventory->available); + } + + $inventory->increment('quantity_reserved', $quantity); + $this->syncModel($item, $inventory->fresh()); + }); + } + + public function release(InventoryItem $item, int $quantity): void + { + $this->assertPositiveQuantity($quantity); + + DB::transaction(function () use ($item, $quantity): void { + $inventory = $this->lock($item); + + if ($inventory->quantity_reserved < $quantity) { + throw new InvalidInventoryOperationException('Cannot release more inventory than is reserved.'); + } + + $inventory->decrement('quantity_reserved', $quantity); + $this->syncModel($item, $inventory->fresh()); + }); + } + + public function commit(InventoryItem $item, int $quantity): void + { + $this->assertPositiveQuantity($quantity); + + DB::transaction(function () use ($item, $quantity): void { + $inventory = $this->lock($item); + + if ($inventory->quantity_reserved < $quantity) { + throw new InvalidInventoryOperationException('Cannot commit more inventory than is reserved.'); + } + + $inventory->update([ + 'quantity_on_hand' => $inventory->quantity_on_hand - $quantity, + 'quantity_reserved' => $inventory->quantity_reserved - $quantity, + ]); + + $this->syncModel($item, $inventory); + }); + } + + public function restock(InventoryItem $item, int $quantity): void + { + $this->assertPositiveQuantity($quantity); + + DB::transaction(function () use ($item, $quantity): void { + $inventory = $this->lock($item); + $inventory->increment('quantity_on_hand', $quantity); + $this->syncModel($item, $inventory->fresh()); + }); + } + + private function assertPositiveQuantity(int $quantity): void + { + if ($quantity < 1) { + throw new InvalidInventoryOperationException('Inventory quantities must be greater than zero.'); + } + } + + private function lock(InventoryItem $item): InventoryItem + { + return InventoryItem::withoutGlobalScopes()->whereKey($item->getKey())->lockForUpdate()->firstOrFail(); + } + + private function syncModel(InventoryItem $target, ?InventoryItem $source): void + { + if ($source !== null) { + $target->setRawAttributes($source->getAttributes(), true); + } + } +} diff --git a/app/Services/NavigationService.php b/app/Services/NavigationService.php new file mode 100644 index 00000000..ec8a6ba6 --- /dev/null +++ b/app/Services/NavigationService.php @@ -0,0 +1,65 @@ +}> */ + public function buildTree(NavigationMenu $menu): array + { + return Cache::remember( + "navigation_menu:{$menu->store_id}:{$menu->id}", + now()->addMinutes(5), + fn (): array => $menu->items() + ->get() + ->map(fn (NavigationItem $item): array => [ + 'id' => $item->id, + 'label' => $item->label, + 'type' => $item->type->value, + 'url' => $this->resolveUrl($item), + 'resource_id' => $item->resource_id, + 'position' => $item->position, + 'children' => [], + ])->all(), + ); + } + + public function resolveUrl(NavigationItem $item): string + { + if ($item->type === NavigationItemType::Link) { + return $item->url ?? '#'; + } + + $storeId = $item->menu()->valueOrFail('store_id'); + + return match ($item->type) { + NavigationItemType::Page => $this->resourceUrl(Page::class, $item->resource_id, $storeId, '/pages/'), + NavigationItemType::Collection => $this->resourceUrl(Collection::class, $item->resource_id, $storeId, '/collections/'), + NavigationItemType::Product => $this->resourceUrl(Product::class, $item->resource_id, $storeId, '/products/'), + NavigationItemType::Link => $item->url ?? '#', + }; + } + + /** @param class-string $model */ + private function resourceUrl(string $model, ?int $resourceId, int $storeId, string $prefix): string + { + if ($resourceId === null) { + return '#'; + } + + $handle = $model::withoutGlobalScopes() + ->where('store_id', $storeId) + ->whereKey($resourceId) + ->value('handle'); + + return $handle === null ? '#' : $prefix.$handle; + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php new file mode 100644 index 00000000..77941e74 --- /dev/null +++ b/app/Services/OrderService.php @@ -0,0 +1,142 @@ +where('checkout_id', $checkout->id)->first(); + + if ($existingOrder) { + return $existingOrder; + } + + $checkout->loadMissing('cart.lines.variant.product'); + $captured = $paymentResult->status === PaymentStatus::Captured; + $totals = $checkout->totals_json ?? []; + + $order = Order::query()->create([ + 'store_id' => $checkout->store_id, + 'customer_id' => $checkout->customer_id, + 'checkout_id' => $checkout->id, + 'order_number' => $this->generateOrderNumber($checkout->store), + 'payment_method' => $checkout->payment_method, + 'status' => $captured ? OrderStatus::Paid : OrderStatus::Pending, + 'financial_status' => $captured ? FinancialStatus::Paid : FinancialStatus::Pending, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled, + 'currency' => $checkout->cart->currency, + 'subtotal_amount' => $totals['subtotal'] ?? 0, + 'discount_amount' => $totals['discount'] ?? 0, + 'shipping_amount' => $totals['shipping'] ?? 0, + 'tax_amount' => $totals['tax_total'] ?? 0, + 'total_amount' => $totals['total'] ?? 0, + 'email' => $checkout->email, + 'billing_address_json' => $checkout->billing_address_json, + 'shipping_address_json' => $checkout->shipping_address_json, + 'placed_at' => now(), + ]); + + foreach ($checkout->cart->lines as $cartLine) { + $order->lines()->create([ + 'product_id' => $cartLine->variant->product_id, + 'variant_id' => $cartLine->variant_id, + 'title_snapshot' => $cartLine->variant->product->title, + 'sku_snapshot' => $cartLine->variant->sku, + 'quantity' => $cartLine->quantity, + 'unit_price_amount' => $cartLine->unit_price_amount, + 'total_amount' => $cartLine->line_total_amount, + 'tax_lines_json' => $totals['tax_lines'] ?? [], + 'discount_allocations_json' => $cartLine->line_discount_amount > 0 ? [['amount' => $cartLine->line_discount_amount]] : [], + ]); + + if ($captured) { + $this->inventoryService->commit($cartLine->variant->inventoryItem, $cartLine->quantity); + } + } + + $order->payments()->create([ + 'provider' => 'mock', + 'method' => $checkout->payment_method, + 'provider_payment_id' => $paymentResult->providerPaymentId, + 'status' => $paymentResult->status, + 'amount' => $order->total_amount, + 'currency' => $order->currency, + 'raw_json_encrypted' => $paymentResult->raw, + ]); + + if ($checkout->discount_code) { + Discount::query()->whereRaw('LOWER(code) = ?', [Str::lower($checkout->discount_code)])->increment('usage_count'); + } + + $checkout->cart->update(['status' => CartStatus::Converted]); + + if ($captured && $checkout->cart->lines->every(fn ($line): bool => ! $line->variant->requires_shipping)) { + $this->autoFulfillDigitalOrder($order); + } + + OrderCreated::dispatch($order); + + return $order->refresh()->load(['lines', 'payments', 'fulfillments.lines']); + }); + } + + public function generateOrderNumber(Store $store): string + { + $prefix = $store->settings?->settings_json['order_number_prefix'] ?? '#'; + $latestNumber = Order::query()->where('store_id', $store->id)->latest('id')->value('order_number'); + $nextNumber = $latestNumber ? ((int) preg_replace('/\D/', '', $latestNumber)) + 1 : 1001; + + return $prefix.$nextNumber; + } + + public function cancel(Order $order, string $reason): void + { + if ($order->fulfillment_status !== FulfillmentStatus::Unfulfilled) { + throw ValidationException::withMessages(['order' => 'A fulfilled order cannot be cancelled.']); + } + + DB::transaction(function () use ($order): void { + if ($order->payment_method === PaymentMethod::BankTransfer && $order->financial_status === FinancialStatus::Pending) { + foreach ($order->lines()->with('variant.inventoryItem')->get() as $line) { + $this->inventoryService->release($line->variant->inventoryItem, $line->quantity); + } + } + + $order->payments()->where('status', PaymentStatus::Pending)->update(['status' => PaymentStatus::Failed]); + $order->update(['status' => OrderStatus::Cancelled, 'financial_status' => FinancialStatus::Voided]); + OrderCancelled::dispatch($order); + }); + } + + public function autoFulfillDigitalOrder(Order $order): void + { + $fulfillment = $order->fulfillments()->create(['status' => 'delivered', 'shipped_at' => now(), 'delivered_at' => now()]); + + foreach ($order->lines as $line) { + $fulfillment->lines()->create(['order_line_id' => $line->id, 'quantity' => $line->quantity]); + } + + $order->update(['fulfillment_status' => FulfillmentStatus::Fulfilled, 'status' => OrderStatus::Fulfilled]); + } +} diff --git a/app/Services/PaymentService.php b/app/Services/PaymentService.php new file mode 100644 index 00000000..34169a58 --- /dev/null +++ b/app/Services/PaymentService.php @@ -0,0 +1,59 @@ + $details */ + public function charge(Checkout $checkout, array $details): PaymentResult + { + $result = $this->provider->charge($checkout, $checkout->payment_method, $details); + + if (! $result->success) { + throw new PaymentFailedException($result->errorCode ?? 'card_declined'); + } + + return $result; + } + + public function confirmBankTransfer(Order $order): void + { + if ($order->payment_method !== PaymentMethod::BankTransfer || $order->financial_status !== FinancialStatus::Pending) { + throw ValidationException::withMessages(['order' => 'This order is not awaiting a bank transfer.']); + } + + DB::transaction(function () use ($order): void { + foreach ($order->lines()->with('variant.inventoryItem')->get() as $line) { + $this->inventoryService->commit($line->variant->inventoryItem, $line->quantity); + } + + $order->payments()->where('status', PaymentStatus::Pending)->update(['status' => PaymentStatus::Captured]); + $order->update(['financial_status' => FinancialStatus::Paid, 'status' => OrderStatus::Paid]); + + if ($order->lines->every(fn ($line): bool => ! $line->variant->requires_shipping)) { + $this->orderService->autoFulfillDigitalOrder($order); + } + + OrderPaid::dispatch($order); + }); + } +} diff --git a/app/Services/Payments/MockPaymentProvider.php b/app/Services/Payments/MockPaymentProvider.php new file mode 100644 index 00000000..3f9c0860 --- /dev/null +++ b/app/Services/Payments/MockPaymentProvider.php @@ -0,0 +1,42 @@ +replaceMatches('/\D/', '')->toString(); + + if ($cardNumber === '4000000000000002') { + return new PaymentResult(false, PaymentStatus::Failed, errorCode: 'card_declined'); + } + + if ($cardNumber === '4000000000009995') { + return new PaymentResult(false, PaymentStatus::Failed, errorCode: 'insufficient_funds'); + } + } + + $status = $method === PaymentMethod::BankTransfer ? PaymentStatus::Pending : PaymentStatus::Captured; + $reference = 'mock_'.Str::random(24); + + return new PaymentResult(true, $status, $reference, raw: ['provider' => 'mock', 'reference' => $reference, 'status' => $status->value]); + } + + public function refund(Payment $payment, int $amount): RefundResult + { + $reference = 'mock_refund_'.Str::random(20); + + return new RefundResult(true, $reference, raw: ['provider' => 'mock', 'reference' => $reference, 'amount' => $amount]); + } +} diff --git a/app/Services/PricingEngine.php b/app/Services/PricingEngine.php new file mode 100644 index 00000000..dd7db5b5 --- /dev/null +++ b/app/Services/PricingEngine.php @@ -0,0 +1,56 @@ +loadMissing(['store', 'cart.lines.variant.product.collections', 'shippingMethod']); + $subtotal = (int) $checkout->cart->lines->sum('line_subtotal_amount'); + $discountResult = new DiscountResult(0, []); + + if ($checkout->discount_code) { + $discount = $this->discountService->validate($checkout->discount_code, $checkout->store, $checkout->cart); + $discountResult = $this->discountService->calculate($discount, $subtotal, $checkout->cart->lines); + $this->applyAllocations($checkout, $discount, $discountResult); + } + + $discountedSubtotal = max(0, $subtotal - $discountResult->amount); + $shipping = $checkout->shippingMethod ? ($this->shippingCalculator->calculate($checkout->shippingMethod, $checkout->cart) ?? 0) : 0; + + if ($discountResult->freeShipping) { + $shipping = 0; + } + + $settings = TaxSettings::query()->find($checkout->store_id) ?? new TaxSettings(['store_id' => $checkout->store_id]); + $taxableAmount = $discountedSubtotal + $shipping; + $tax = $this->taxCalculator->calculate($taxableAmount, $settings, $checkout->shipping_address_json ?? []); + $total = $settings->prices_include_tax ? $taxableAmount : $taxableAmount + $tax->taxAmount; + + $result = new PricingResult($subtotal, $discountResult->amount, $shipping, $tax->lines, $tax->taxAmount, $total, $checkout->cart->currency); + $checkout->update(['totals_json' => $result->jsonSerialize()]); + + return $result; + } + + private function applyAllocations(Checkout $checkout, Discount $discount, DiscountResult $result): void + { + foreach ($checkout->cart->lines as $line) { + $amount = $result->allocations[$line->id] ?? 0; + $line->update(['line_discount_amount' => $amount, 'line_total_amount' => $line->line_subtotal_amount - $amount]); + } + } +} diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php new file mode 100644 index 00000000..740662f7 --- /dev/null +++ b/app/Services/ProductService.php @@ -0,0 +1,253 @@ + $data */ + public function create(Store $store, array $data): Product + { + return DB::transaction(function () use ($store, $data): Product { + $attributes = Arr::except($data, ['options', 'variants', 'collection_ids']); + $attributes['store_id'] = $store->getKey(); + $attributes['handle'] = $this->handleGenerator->generate( + (string) ($attributes['handle'] ?? $attributes['title']), + 'products', + $store->getKey(), + ); + + $product = Product::withoutGlobalScopes()->create($attributes); + + if (array_key_exists('options', $data)) { + $this->syncOptions($product, $data['options']); + } + + if (filled($data['variants'] ?? null)) { + $this->syncVariants($product, $data['variants']); + } else { + $this->variantMatrixService->rebuildMatrix($product); + } + + if (array_key_exists('collection_ids', $data)) { + $this->syncCollections($product, $data['collection_ids']); + } + + return $product->load(['options.values', 'variants.inventoryItem', 'collections']); + }); + } + + /** @param array $data */ + public function update(Product $product, array $data): Product + { + return DB::transaction(function () use ($product, $data): Product { + $attributes = Arr::except($data, ['options', 'variants', 'collection_ids']); + + if (array_key_exists('handle', $attributes)) { + $attributes['handle'] = $this->handleGenerator->generate( + (string) $attributes['handle'], + 'products', + $product->store_id, + $product->getKey(), + ); + } + + $product->update($attributes); + + if (array_key_exists('options', $data)) { + $this->syncOptions($product, $data['options']); + $this->variantMatrixService->rebuildMatrix($product); + } + + if (array_key_exists('variants', $data)) { + $this->syncVariants($product, $data['variants']); + } + + if (array_key_exists('collection_ids', $data)) { + $this->syncCollections($product, $data['collection_ids']); + } + + return $product->refresh()->load(['options.values', 'variants.inventoryItem', 'collections']); + }); + } + + public function transitionStatus(Product $product, ProductStatus $newStatus): void + { + $currentStatus = $product->status; + + if ($currentStatus === $newStatus) { + return; + } + + $allowedTransitions = [ + ProductStatus::Draft->value => [ProductStatus::Active, ProductStatus::Archived], + ProductStatus::Active->value => [ProductStatus::Archived, ProductStatus::Draft], + ProductStatus::Archived->value => [ProductStatus::Active, ProductStatus::Draft], + ]; + + if (! in_array($newStatus, $allowedTransitions[$currentStatus->value], true)) { + throw new InvalidProductTransitionException($product->getKey(), $currentStatus, $newStatus, 'the transition is not allowed'); + } + + if ($newStatus === ProductStatus::Active) { + if (trim($product->title) === '' || ! $product->variants()->where('price_amount', '>', 0)->exists()) { + throw new InvalidProductTransitionException($product->getKey(), $currentStatus, $newStatus, 'an active product requires a title and a priced variant'); + } + } + + if ($newStatus === ProductStatus::Draft && $this->hasOrderReferences($product)) { + throw new InvalidProductTransitionException($product->getKey(), $currentStatus, $newStatus, 'products referenced by orders cannot return to draft'); + } + + DB::transaction(function () use ($product, $currentStatus, $newStatus): void { + $product->status = $newStatus; + + if ($newStatus === ProductStatus::Active && $product->published_at === null) { + $product->published_at = now(); + } + + $product->save(); + ProductStatusChanged::dispatch($product, $currentStatus, $newStatus); + }); + } + + public function delete(Product $product): void + { + if ($product->status !== ProductStatus::Draft) { + throw new InvalidProductDeletionException($product->getKey(), 'only draft products may be hard deleted'); + } + + if ($this->hasOrderReferences($product)) { + throw new InvalidProductDeletionException($product->getKey(), 'order history references this product'); + } + + DB::transaction(fn (): bool => $product->delete()); + } + + private function syncOptions(Product $product, mixed $options): void + { + if (! is_array($options) || count($options) > 3) { + throw new InvalidVariantMatrixException($product->getKey(), 'Products may have at most three options.'); + } + + $product->options()->increment('position', 1000); + $keptOptionIds = []; + + foreach (array_values($options) as $optionPosition => $optionData) { + if (! is_array($optionData) || ! filled($optionData['name'] ?? null) || ! filled($optionData['values'] ?? null)) { + throw new InvalidVariantMatrixException($product->getKey(), 'Every option requires a name and at least one value.'); + } + + $optionId = isset($optionData['id']) ? (int) $optionData['id'] : null; + $option = $optionId === null + ? $product->options()->create(['name' => $optionData['name'], 'position' => $optionPosition]) + : $product->options()->whereKey($optionId)->firstOrFail(); + $option->update(['name' => $optionData['name'], 'position' => $optionPosition]); + $keptOptionIds[] = $option->getKey(); + $this->syncOptionValues($option, $optionData['values']); + } + + $product->options()->whereKeyNot($keptOptionIds)->delete(); + } + + private function syncOptionValues(ProductOption $option, mixed $values): void + { + if (! is_array($values) || $values === []) { + throw new InvalidVariantMatrixException($option->product_id, 'Every option requires at least one value.'); + } + + $option->values()->increment('position', 1000); + $keptValueIds = []; + + foreach (array_values($values) as $position => $valueData) { + $valueData = is_array($valueData) ? $valueData : ['value' => $valueData]; + $valueId = isset($valueData['id']) ? (int) $valueData['id'] : null; + $value = $valueId === null + ? $option->values()->create(['value' => $valueData['value'], 'position' => $position]) + : $option->values()->whereKey($valueId)->firstOrFail(); + $value->update(['value' => $valueData['value'], 'position' => $position]); + $keptValueIds[] = $value->getKey(); + } + + $option->values()->whereKeyNot($keptValueIds)->delete(); + } + + private function syncVariants(Product $product, mixed $variants): void + { + if (! is_array($variants)) { + throw new InvalidVariantMatrixException($product->getKey(), 'Variants must be an array.'); + } + + foreach (array_values($variants) as $position => $variantData) { + if (! is_array($variantData)) { + throw new InvalidVariantMatrixException($product->getKey(), 'Every variant must be an array.'); + } + + $inventoryData = Arr::pull($variantData, 'inventory'); + $optionValueIds = Arr::pull($variantData, 'option_value_ids', []); + $variantId = Arr::pull($variantData, 'id'); + $variantData['position'] = $position; + $variantData['currency'] ??= $product->store()->value('default_currency'); + $variant = $variantId === null + ? $product->variants()->create($variantData) + : $product->variants()->whereKey($variantId)->firstOrFail(); + + if ($variantId !== null) { + $variant->update($variantData); + } + + if (is_array($optionValueIds)) { + $variant->optionValues()->sync($optionValueIds); + } + + if (is_array($inventoryData)) { + $variant->inventoryItem()->update($inventoryData); + } + } + } + + private function syncCollections(Product $product, mixed $collectionIds): void + { + if (! is_array($collectionIds)) { + return; + } + + $validIds = Collection::withoutGlobalScopes() + ->where('store_id', $product->store_id) + ->whereKey($collectionIds) + ->pluck('id'); + $pivot = $validIds->mapWithKeys(fn (int $id, int $position): array => [$id => ['position' => $position]])->all(); + $product->collections()->sync($pivot); + } + + private function hasOrderReferences(Product $product): bool + { + if (! Schema::hasTable('order_lines')) { + return false; + } + + return OrderLine::withoutGlobalScopes() + ->whereIn('variant_id', ProductVariant::withoutGlobalScopes()->where('product_id', $product->getKey())->select('id')) + ->exists(); + } +} diff --git a/app/Services/RefundService.php b/app/Services/RefundService.php new file mode 100644 index 00000000..223588f2 --- /dev/null +++ b/app/Services/RefundService.php @@ -0,0 +1,67 @@ +amount - $payment->refunds()->where('status', RefundStatus::Processed)->sum('amount'); + + if ($payment->order_id !== $order->id || $amount < 1 || $amount > $refundable) { + throw ValidationException::withMessages(['amount' => 'The refund amount exceeds the refundable balance.']); + } + + return DB::transaction(function () use ($order, $payment, $amount, $reason, $restock): Refund { + $providerResult = $this->provider->refund($payment, $amount); + $refund = $order->refunds()->create([ + 'payment_id' => $payment->id, + 'amount' => $amount, + 'reason' => $reason, + 'status' => $providerResult->success ? RefundStatus::Processed : RefundStatus::Failed, + 'provider_refund_id' => $providerResult->providerRefundId, + ]); + + if (! $providerResult->success) { + return $refund; + } + + $totalRefunded = $order->refunds()->where('status', RefundStatus::Processed)->sum('amount'); + $isFullRefund = $totalRefunded >= $order->total_amount; + $order->update([ + 'financial_status' => $isFullRefund ? FinancialStatus::Refunded : FinancialStatus::PartiallyRefunded, + 'status' => $isFullRefund ? OrderStatus::Refunded : $order->status, + ]); + + if ($isFullRefund) { + $payment->update(['status' => PaymentStatus::Refunded]); + } + + if ($restock) { + foreach ($order->lines()->with('variant.inventoryItem')->get() as $line) { + if ($line->variant?->inventoryItem) { + $this->inventoryService->restock($line->variant->inventoryItem, $line->quantity); + } + } + } + + OrderRefunded::dispatch($order, $refund); + + return $refund; + }); + } +} diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php new file mode 100644 index 00000000..929b1309 --- /dev/null +++ b/app/Services/SearchService.php @@ -0,0 +1,178 @@ + $filters */ + public function search(Store $store, string $query, array $filters = [], int $perPage = 24): LengthAwarePaginator + { + $perPage = max(1, min(50, $perPage)); + $productIds = $this->matchingProductIds($store, $query); + + if ($productIds->isEmpty()) { + $paginator = new LengthAwarePaginator([], 0, $perPage, LengthAwarePaginator::resolveCurrentPage()); + $this->logQuery($store, $query, $filters, 0); + + return $paginator; + } + + $products = Product::withoutGlobalScope(StoreScope::class) + ->where('store_id', $store->id) + ->where('status', ProductStatus::Active) + ->whereNotNull('published_at') + ->whereIn('id', $productIds) + ->when(filled($filters['vendor'] ?? null), fn (Builder $builder): Builder => $builder->where('vendor', $filters['vendor'])) + ->when(filled($filters['collection_id'] ?? null), fn (Builder $builder): Builder => $builder->whereHas( + 'collections', + fn (Builder $collectionQuery): Builder => $collectionQuery->whereKey((int) $filters['collection_id']), + )) + ->when(is_array($filters['tags'] ?? null), function (Builder $builder) use ($filters): void { + foreach ($filters['tags'] as $tag) { + $builder->whereJsonContains('tags', $tag); + } + }) + ->when(isset($filters['price_min']) || isset($filters['price_max']), function (Builder $builder) use ($filters): void { + $builder->whereHas('variants', function (Builder $variantQuery) use ($filters): void { + $variantQuery + ->when(isset($filters['price_min']), fn (Builder $query): Builder => $query->where('price_amount', '>=', (int) $filters['price_min'])) + ->when(isset($filters['price_max']), fn (Builder $query): Builder => $query->where('price_amount', '<=', (int) $filters['price_max'])); + }); + }) + ->when(($filters['in_stock'] ?? false) === true, fn (Builder $builder): Builder => $builder->whereHas( + 'variants.inventoryItem', + fn (Builder $inventoryQuery): Builder => $inventoryQuery->whereColumn('quantity_on_hand', '>', 'quantity_reserved'), + )); + + $this->applySort($products, $productIds, (string) ($filters['sort'] ?? 'relevance')); + + $paginator = $products->paginate($perPage); + $this->logQuery($store, $query, $filters, $paginator->total()); + + return $paginator; + } + + public function autocomplete(Store $store, string $prefix, int $limit = 5): Collection + { + $limit = max(1, min(10, $limit)); + $productIds = $this->matchingProductIds($store, $prefix, $limit * 2); + + if ($productIds->isEmpty()) { + return collect(); + } + + $products = Product::withoutGlobalScope(StoreScope::class) + ->where('store_id', $store->id) + ->where('status', ProductStatus::Active) + ->whereNotNull('published_at') + ->whereIn('id', $productIds) + ->get() + ->keyBy('id'); + + return $productIds + ->map(fn (int $id): ?Product => $products->get($id)) + ->filter() + ->take($limit) + ->values(); + } + + public function syncProduct(Product $product): void + { + DB::table('products_fts')->where('product_id', $product->id)->delete(); + + DB::table('products_fts')->insert([ + 'store_id' => $product->store_id, + 'product_id' => $product->id, + 'title' => $product->title, + 'description' => strip_tags((string) $product->description_html), + 'vendor' => $product->vendor ?? '', + 'product_type' => $product->product_type ?? '', + 'tags' => implode(' ', $product->tags ?? []), + ]); + } + + public function removeProduct(int $productId): void + { + DB::table('products_fts')->where('product_id', $productId)->delete(); + } + + private function matchingProductIds(Store $store, string $query, ?int $limit = null): Collection + { + $matchExpression = $this->matchExpression($query); + + if ($matchExpression === '') { + return collect(); + } + + return DB::table('products_fts') + ->where('store_id', $store->id) + ->whereRaw('products_fts MATCH ?', [$matchExpression]) + ->orderByRaw('bm25(products_fts)') + ->when($limit !== null, fn ($builder) => $builder->limit($limit)) + ->pluck('product_id') + ->map(static fn (mixed $id): int => (int) $id) + ->unique() + ->values(); + } + + private function matchExpression(string $query): string + { + preg_match_all('/[\pL\pN]+/u', Str::squish($query), $matches); + $tokens = $matches[0] ?? []; + + if ($tokens === []) { + return ''; + } + + $lastIndex = array_key_last($tokens); + + return collect($tokens) + ->map(fn (string $token, int $index): string => '"'.$token.'"'.($index === $lastIndex ? '*' : '')) + ->implode(' AND '); + } + + private function applySort(Builder $builder, Collection $productIds, string $sort): void + { + if ($sort === 'newest') { + $builder->latest('published_at'); + + return; + } + + if (in_array($sort, ['price_asc', 'price_desc'], true)) { + $builder->withMin('variants', 'price_amount') + ->orderBy('variants_min_price_amount', $sort === 'price_asc' ? 'asc' : 'desc'); + + return; + } + + $orderCases = $productIds + ->values() + ->map(fn (int $id, int $position): string => "WHEN {$id} THEN {$position}") + ->implode(' '); + + $builder->orderByRaw("CASE products.id {$orderCases} ELSE 2147483647 END"); + } + + /** @param array $filters */ + private function logQuery(Store $store, string $query, array $filters, int $resultsCount): void + { + SearchQuery::withoutGlobalScope(StoreScope::class)->create([ + 'store_id' => $store->id, + 'query' => Str::limit(Str::squish($query), 255, ''), + 'filters_json' => $filters === [] ? null : $filters, + 'results_count' => $resultsCount, + ]); + } +} diff --git a/app/Services/ShippingCalculator.php b/app/Services/ShippingCalculator.php new file mode 100644 index 00000000..ee827cf1 --- /dev/null +++ b/app/Services/ShippingCalculator.php @@ -0,0 +1,62 @@ + $address + * @return Collection + */ + public function getAvailableRates(Store $store, array $address): Collection + { + $country = $address['country_code'] ?? $address['country'] ?? null; + $region = $address['province_code'] ?? $address['region'] ?? null; + + $zone = ShippingZone::query() + ->where('store_id', $store->id) + ->with('rates') + ->get() + ->map(function (ShippingZone $zone) use ($country, $region): array { + $countryMatches = collect($zone->countries_json)->contains($country); + $regionMatches = $region !== null && collect($zone->regions_json)->contains($region); + + return ['zone' => $zone, 'specificity' => $countryMatches ? ($regionMatches ? 2 : 1) : -1]; + }) + ->filter(fn (array $match): bool => $match['specificity'] >= 0) + ->sortBy([['specificity', 'desc'], [fn (array $match): int => $match['zone']->id, 'asc']]) + ->first()['zone'] ?? null; + + return $zone?->rates->where('is_active', true)->values() ?? collect(); + } + + public function calculate(ShippingRate $rate, Cart $cart): ?int + { + $config = $rate->config_json; + + return match ($rate->type) { + ShippingRateType::Flat => (int) ($config['amount'] ?? 0), + ShippingRateType::Weight => $this->calculateRange((int) $cart->lines->sum(fn ($line): int => $line->variant->requires_shipping ? $line->variant->weight_g * $line->quantity : 0), $config['ranges'] ?? [], 'min_g', 'max_g'), + ShippingRateType::Price => $this->calculateRange((int) $cart->lines->sum('line_total_amount'), $config['ranges'] ?? [], 'min_amount', 'max_amount'), + ShippingRateType::Carrier => (int) ($config['fallback_amount'] ?? 0), + }; + } + + /** @param list> $ranges */ + private function calculateRange(int $value, array $ranges, string $minimumKey, string $maximumKey): ?int + { + foreach ($ranges as $range) { + if ($value >= ($range[$minimumKey] ?? 0) && (! isset($range[$maximumKey]) || $value <= $range[$maximumKey])) { + return (int) $range['amount']; + } + } + + return null; + } +} diff --git a/app/Services/TaxCalculator.php b/app/Services/TaxCalculator.php new file mode 100644 index 00000000..4bf85aea --- /dev/null +++ b/app/Services/TaxCalculator.php @@ -0,0 +1,43 @@ + $address */ + public function calculate(int $amount, TaxSettings $settings, array $address = []): TaxResult + { + $rate = (int) ($settings->config_json['rates'][$address['country_code'] ?? $address['country'] ?? ''] + ?? $settings->config_json['default_rate_bps'] + ?? $settings->config_json['rate_bps'] + ?? 0); + + if ($settings->prices_include_tax) { + $tax = $this->extractInclusive($amount, $rate); + + return new TaxResult($amount - $tax, $tax, $amount, [new TaxLine('Tax', $rate, $tax)]); + } + + $tax = $this->addExclusive($amount, $rate); + + return new TaxResult($amount, $tax, $amount + $tax, [new TaxLine('Tax', $rate, $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 + { + return (int) round($netAmount * $rateBasisPoints / 10000); + } +} diff --git a/app/Services/VariantMatrixService.php b/app/Services/VariantMatrixService.php new file mode 100644 index 00000000..b3b8afb8 --- /dev/null +++ b/app/Services/VariantMatrixService.php @@ -0,0 +1,151 @@ +options()->with('values')->get(); + + if ($options->count() > 3) { + throw new InvalidVariantMatrixException($product->getKey(), 'Products may have at most three options.'); + } + + if ($options->contains(fn ($option): bool => $option->values->isEmpty())) { + throw new InvalidVariantMatrixException($product->getKey(), 'Every option must contain at least one value.'); + } + + $variants = $product->variants()->with('optionValues')->get(); + + if ($options->isEmpty()) { + $this->ensureDefaultVariant($product, $variants); + + return; + } + + $template = $variants->first(); + $existingByCombination = $variants->keyBy( + fn (ProductVariant $variant): string => $this->combinationKey($variant->optionValues->modelKeys()), + ); + $desiredKeys = []; + + foreach ($this->cartesianProduct($options->map->values->all()) as $position => $combination) { + $valueIds = collect($combination)->map(fn ($value): int => $value->getKey())->all(); + $key = $this->combinationKey($valueIds); + $desiredKeys[] = $key; + $variant = $existingByCombination->get($key); + + if ($variant === null) { + $variant = $product->variants()->create([ + 'price_amount' => $template?->price_amount ?? 0, + 'compare_at_amount' => $template?->compare_at_amount, + 'currency' => $template?->currency ?? $product->store()->value('default_currency'), + 'weight_g' => $template?->weight_g, + 'requires_shipping' => $template?->requires_shipping ?? true, + 'is_default' => $position === 0, + 'position' => $position, + 'status' => VariantStatus::Active, + ]); + $variant->optionValues()->attach($valueIds); + } else { + $variant->update([ + 'position' => $position, + 'is_default' => $position === 0, + ]); + } + } + + foreach ($variants as $variant) { + if (in_array($this->combinationKey($variant->optionValues->modelKeys()), $desiredKeys, true)) { + continue; + } + + $this->removeOrArchive($variant); + } + }); + } + + /** @param Collection $variants */ + private function ensureDefaultVariant(Product $product, Collection $variants): void + { + $default = $variants->firstWhere('is_default', true) ?? $variants->first(); + + if ($default === null) { + $product->variants()->create([ + 'price_amount' => 0, + 'currency' => $product->store()->value('default_currency'), + 'requires_shipping' => true, + 'is_default' => true, + 'position' => 0, + 'status' => VariantStatus::Active, + ]); + + return; + } + + $default->update(['is_default' => true, 'position' => 0]); + + foreach ($variants->where('id', '!=', $default->getKey()) as $variant) { + $this->removeOrArchive($variant); + } + } + + private function removeOrArchive(ProductVariant $variant): void + { + if ($this->hasOrderReferences($variant)) { + $variant->update(['status' => VariantStatus::Archived, 'is_default' => false]); + + return; + } + + $variant->delete(); + } + + private function hasOrderReferences(ProductVariant $variant): bool + { + return Schema::hasTable('order_lines') + && OrderLine::withoutGlobalScopes()->where('variant_id', $variant->getKey())->exists(); + } + + /** + * @param array> $valueGroups + * @return list> + */ + private function cartesianProduct(array $valueGroups): array + { + $combinations = [[]]; + + foreach ($valueGroups as $values) { + $next = []; + + foreach ($combinations as $combination) { + foreach ($values as $value) { + $next[] = [...$combination, $value]; + } + } + + $combinations = $next; + } + + return $combinations; + } + + /** @param array $valueIds */ + private function combinationKey(array $valueIds): string + { + sort($valueIds, SORT_NUMERIC); + + return implode(':', $valueIds); + } +} diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php new file mode 100644 index 00000000..bf3ba96d --- /dev/null +++ b/app/Services/WebhookService.php @@ -0,0 +1,73 @@ + $payload */ + public function dispatch(Store $store, string $eventType, array $payload): void + { + WebhookSubscription::withoutGlobalScope(StoreScope::class) + ->where('store_id', $store->id) + ->where('event_type', $eventType) + ->where('status', WebhookSubscriptionStatus::Active) + ->each(function (WebhookSubscription $subscription) use ($eventType, $payload): void { + $eventId = (string) Str::uuid(); + $timestamp = now()->getTimestamp(); + $envelope = [ + 'id' => $eventId, + 'type' => $eventType, + 'created_at' => now()->toIso8601String(), + 'data' => $payload, + ]; + $delivery = $subscription->deliveries()->create([ + 'event_id' => $eventId, + 'attempt_count' => 1, + 'status' => WebhookDeliveryStatus::Pending, + ]); + + DeliverWebhook::dispatch($delivery->id, $envelope, $timestamp); + }); + } + + public function sign(string $payload, string $secret, ?int $timestamp = null): string + { + return hash_hmac('sha256', ($timestamp ?? 0).'.'.$payload, $secret); + } + + public function verify(string $payload, string $signature, string $secret, ?int $timestamp = null): bool + { + return hash_equals($this->sign($payload, $secret, $timestamp), $signature); + } + + public function recordFailure(WebhookSubscription $subscription): void + { + $statuses = $subscription->deliveries() + ->latest('id') + ->limit(5) + ->pluck('status'); + + if ($statuses->count() !== 5 || $statuses->contains( + fn (WebhookDeliveryStatus|string $status): bool => $status !== WebhookDeliveryStatus::Failed + && $status !== WebhookDeliveryStatus::Failed->value, + )) { + return; + } + + $subscription->update(['status' => WebhookSubscriptionStatus::Paused]); + + Log::warning('Webhook subscription paused after consecutive failures.', [ + 'subscription_id' => $subscription->id, + 'store_id' => $subscription->store_id, + ]); + } +} diff --git a/app/Support/HandleGenerator.php b/app/Support/HandleGenerator.php new file mode 100644 index 00000000..44f6cb98 --- /dev/null +++ b/app/Support/HandleGenerator.php @@ -0,0 +1,41 @@ + */ + private const ALLOWED_TABLES = ['products', 'collections', 'pages']; + + public function generate(string $title, string $table, int $storeId, ?int $excludeId = null): string + { + if (! in_array($table, self::ALLOWED_TABLES, true)) { + throw new InvalidArgumentException("Handles cannot be generated for the [{$table}] table."); + } + + $baseHandle = Str::slug($title); + $baseHandle = $baseHandle !== '' ? $baseHandle : 'item'; + $handle = $baseHandle; + $suffix = 1; + + while ($this->exists($table, $storeId, $handle, $excludeId)) { + $handle = $baseHandle.'-'.$suffix; + $suffix++; + } + + return $handle; + } + + private function exists(string $table, int $storeId, string $handle, ?int $excludeId): bool + { + return DB::table($table) + ->where('store_id', $storeId) + ->where('handle', $handle) + ->when($excludeId !== null, fn ($query) => $query->where('id', '!=', $excludeId)) + ->exists(); + } +} diff --git a/app/Traits/ChecksStoreRole.php b/app/Traits/ChecksStoreRole.php new file mode 100644 index 00000000..8b8e4b73 --- /dev/null +++ b/app/Traits/ChecksStoreRole.php @@ -0,0 +1,47 @@ +storeUsers() + ->where('store_id', $storeId) + ->first() + ?->role; + } + + /** @param list $roles */ + protected function hasRole(User $user, int $storeId, array $roles): bool + { + $role = $this->getStoreRole($user, $storeId); + + return $role !== null && in_array($role, $roles, true); + } + + protected function isOwnerOrAdmin(User $user, int $storeId): bool + { + return $this->hasRole($user, $storeId, [ + StoreUserRole::Owner, + StoreUserRole::Admin, + ]); + } + + protected function isOwnerAdminOrStaff(User $user, int $storeId): bool + { + return $this->hasRole($user, $storeId, [ + StoreUserRole::Owner, + StoreUserRole::Admin, + StoreUserRole::Staff, + ]); + } + + protected function isAnyRole(User $user, int $storeId): bool + { + return $this->getStoreRole($user, $storeId) !== null; + } +} diff --git a/app/ValueObjects/DiscountResult.php b/app/ValueObjects/DiscountResult.php new file mode 100644 index 00000000..1f6ae3d3 --- /dev/null +++ b/app/ValueObjects/DiscountResult.php @@ -0,0 +1,9 @@ + $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..2bbec14a --- /dev/null +++ b/app/ValueObjects/PaymentResult.php @@ -0,0 +1,11 @@ + $raw */ + public function __construct(public bool $success, public PaymentStatus $status, public ?string $providerPaymentId = null, public ?string $errorCode = null, public array $raw = []) {} +} diff --git a/app/ValueObjects/PricingResult.php b/app/ValueObjects/PricingResult.php new file mode 100644 index 00000000..6885a75e --- /dev/null +++ b/app/ValueObjects/PricingResult.php @@ -0,0 +1,17 @@ + $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: list, tax_total: int, total: int, currency: string} */ + public function jsonSerialize(): array + { + return ['subtotal' => $this->subtotal, 'discount' => $this->discount, 'shipping' => $this->shipping, 'tax_lines' => $this->taxLines, 'tax_total' => $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..5f1a6f93 --- /dev/null +++ b/app/ValueObjects/RefundResult.php @@ -0,0 +1,9 @@ + $raw */ + public function __construct(public bool $success, public ?string $providerRefundId = null, public ?string $errorCode = null, public array $raw = []) {} +} diff --git a/app/ValueObjects/TaxLine.php b/app/ValueObjects/TaxLine.php new file mode 100644 index 00000000..9d46fae7 --- /dev/null +++ b/app/ValueObjects/TaxLine.php @@ -0,0 +1,16 @@ + $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..a0848121 --- /dev/null +++ b/app/ValueObjects/TaxResult.php @@ -0,0 +1,9 @@ + $lines */ + public function __construct(public int $netAmount, public int $taxAmount, public int $grossAmount, public array $lines = []) {} +} diff --git a/boost.json b/boost.json new file mode 100644 index 00000000..e2d9b11c --- /dev/null +++ b/boost.json @@ -0,0 +1,18 @@ +{ + "agents": [ + "codex" + ], + "cloud": false, + "guidelines": true, + "mcp": true, + "nightwatch": false, + "sail": false, + "skills": [ + "developing-with-fortify", + "laravel-best-practices", + "fluxui-development", + "livewire-development", + "pest-testing", + "tailwindcss-development" + ] +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c1832766..ae5139bc 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,17 +1,48 @@ withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->redirectGuestsTo(fn (Request $request): string => match (true) { + $request->is('admin', 'admin/*') => route('admin.login'), + $request->is('account', 'account/*') => route('storefront.account.login'), + default => route('login'), + }); + $middleware->redirectUsersTo(fn (Request $request): string => $request->is('admin', 'admin/*') + ? route('admin.dashboard') + : route('dashboard')); + + $middleware->alias([ + 'role.check' => CheckStoreRole::class, + 'store.resolve' => ResolveStore::class, + 'customer.auth' => CustomerAuthenticate::class, + 'abilities' => CheckAbilities::class, + 'ability' => CheckForAnyAbility::class, + ]); + + $middleware->group('storefront', [ + ResolveStore::class, + ]); + + $middleware->group('admin', [ + ResolveStore::class, + CheckStoreRole::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/composer.json b/composer.json index 1f848aaf..72e094db 100644 --- a/composer.json +++ b/composer.json @@ -12,19 +12,21 @@ "php": "^8.2", "laravel/fortify": "^1.30", "laravel/framework": "^12.0", + "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1", "livewire/flux": "^2.9.0", "livewire/livewire": "^4.0" }, "require-dev": { "fakerphp/faker": "^1.23", - "laravel/boost": "^1.0", + "laravel/boost": "^2.4", "laravel/pail": "^1.2.2", "laravel/pint": "^1.24", "laravel/sail": "^1.41", "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.6", "pestphp/pest": "^4.3", + "pestphp/pest-plugin-browser": "^4.3", "pestphp/pest-plugin-laravel": "^4.0" }, "autoload": { diff --git a/composer.lock b/composer.lock index e4255dbd..6ed41567 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e4aa7ad38dac6834e5ff6bf65b1cdf23", + "content-hash": "842bb7ef40dbf5886b406e01f112bcc6", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1501,6 +1501,69 @@ }, "time": "2026-02-06T12:17:10+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-04-30T11:46:25+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.9", @@ -6429,56 +6492,37 @@ ], "packages-dev": [ { - "name": "brianium/paratest", - "version": "v7.17.0", + "name": "amphp/amp", + "version": "v3.1.2", "source": { "type": "git", - "url": "https://github.com/paratestphp/paratest.git", - "reference": "53cb90a6aa3ef3840458781600628ade058a18b9" + "url": "https://github.com/amphp/amp.git", + "reference": "2f3ebed5a4f663968a0590dbb7654a8b32cb63cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/53cb90a6aa3ef3840458781600628ade058a18b9", - "reference": "53cb90a6aa3ef3840458781600628ade058a18b9", + "url": "https://api.github.com/repos/amphp/amp/zipball/2f3ebed5a4f663968a0590dbb7654a8b32cb63cb", + "reference": "2f3ebed5a4f663968a0590dbb7654a8b32cb63cb", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-pcre": "*", - "ext-reflection": "*", - "ext-simplexml": "*", - "fidry/cpu-core-counter": "^1.3.0", - "jean85/pretty-package-versions": "^2.1.1", - "php": "~8.3.0 || ~8.4.0 || ~8.5.0", - "phpunit/php-code-coverage": "^12.5.2", - "phpunit/php-file-iterator": "^6", - "phpunit/php-timer": "^8", - "phpunit/phpunit": "^12.5.8", - "sebastian/environment": "^8.0.3", - "symfony/console": "^7.3.4 || ^8.0.0", - "symfony/process": "^7.3.4 || ^8.0.0" + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" }, "require-dev": { - "doctrine/coding-standard": "^14.0.0", - "ext-pcntl": "*", - "ext-pcov": "*", - "ext-posix": "*", - "phpstan/phpstan": "^2.1.38", - "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.12", - "phpstan/phpstan-strict-rules": "^2.0.8", - "symfony/filesystem": "^7.3.2 || ^8.0.0" + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" }, - "bin": [ - "bin/paratest", - "bin/paratest_for_phpstorm" - ], "type": "library", "autoload": { + "files": [ + "src/functions.php", + "src/Future/functions.php", + "src/Internal/functions.php" + ], "psr-4": { - "ParaTest\\": [ - "src/" - ] + "Amp\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -6487,128 +6531,153 @@ ], "authors": [ { - "name": "Brian Scaturro", - "email": "scaturrob@gmail.com", - "role": "Developer" + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" }, { - "name": "Filippo Tessarotto", - "email": "zoeslam@gmail.com", - "role": "Developer" + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" } ], - "description": "Parallel testing for PHP", - "homepage": "https://github.com/paratestphp/paratest", + "description": "A non-blocking concurrency framework for PHP applications.", + "homepage": "https://amphp.org/amp", "keywords": [ - "concurrent", - "parallel", - "phpunit", - "testing" + "async", + "asynchronous", + "awaitable", + "concurrency", + "event", + "event-loop", + "future", + "non-blocking", + "promise" ], "support": { - "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.17.0" + "issues": "https://github.com/amphp/amp/issues", + "source": "https://github.com/amphp/amp/tree/v3.1.2" }, "funding": [ { - "url": "https://github.com/sponsors/Slamdunk", + "url": "https://github.com/amphp", "type": "github" - }, - { - "url": "https://paypal.me/filippotessarotto", - "type": "paypal" } ], - "time": "2026-02-05T09:14:44+00:00" + "time": "2026-06-21T13:59:44+00:00" }, { - "name": "doctrine/deprecations", - "version": "1.1.6", + "name": "amphp/byte-stream", + "version": "v2.1.2", "source": { "type": "git", - "url": "https://github.com/doctrine/deprecations.git", - "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + "url": "https://github.com/amphp/byte-stream.git", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", - "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "phpunit/phpunit": "<=7.5 || >=14" + "amphp/amp": "^3", + "amphp/parser": "^1.1", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2.3" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^14", - "phpstan/phpstan": "1.4.10 || 2.1.30", - "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", - "psr/log": "^1 || ^2 || ^3" - }, - "suggest": { - "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.22.1" }, "type": "library", "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], "psr-4": { - "Doctrine\\Deprecations\\": "src" + "Amp\\ByteStream\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", - "homepage": "https://www.doctrine-project.org/", + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A stream abstraction to make working with non-blocking I/O simple.", + "homepage": "https://amphp.org/byte-stream", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "non-blocking", + "stream" + ], "support": { - "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + "issues": "https://github.com/amphp/byte-stream/issues", + "source": "https://github.com/amphp/byte-stream/tree/v2.1.2" }, - "time": "2026-02-07T07:09:04+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-03-16T17:10:27+00:00" }, { - "name": "fakerphp/faker", - "version": "v1.24.1", + "name": "amphp/cache", + "version": "v2.0.1", "source": { "type": "git", - "url": "https://github.com/FakerPHP/Faker.git", - "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + "url": "https://github.com/amphp/cache.git", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", - "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0", - "psr/container": "^1.0 || ^2.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" - }, - "conflict": { - "fzaninotto/faker": "*" + "amphp/amp": "^3", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.4.1", - "doctrine/persistence": "^1.3 || ^2.0", - "ext-intl": "*", - "phpunit/phpunit": "^9.5.26", - "symfony/phpunit-bridge": "^5.4.16" - }, - "suggest": { - "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", - "ext-curl": "Required by Faker\\Provider\\Image to download images.", - "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", - "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", - "ext-mbstring": "Required for multibyte Unicode string functionality." + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" }, "type": "library", "autoload": { "psr-4": { - "Faker\\": "src/Faker/" + "Amp\\Cache\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -6617,53 +6686,71 @@ ], "authors": [ { - "name": "François Zaninotto" + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" } ], - "description": "Faker is a PHP library that generates fake data for you.", - "keywords": [ - "data", - "faker", - "fixtures" - ], + "description": "A fiber-aware cache API based on Amp and Revolt.", + "homepage": "https://amphp.org/cache", "support": { - "issues": "https://github.com/FakerPHP/Faker/issues", - "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + "issues": "https://github.com/amphp/cache/issues", + "source": "https://github.com/amphp/cache/tree/v2.0.1" }, - "time": "2024-11-21T13:46:39+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:38:06+00:00" }, { - "name": "fidry/cpu-core-counter", - "version": "1.3.0", + "name": "amphp/dns", + "version": "v2.4.0", "source": { "type": "git", - "url": "https://github.com/theofidry/cpu-core-counter.git", - "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + "url": "https://github.com/amphp/dns.git", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", - "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/process": "^2", + "daverandom/libdns": "^2.0.2", + "ext-filter": "*", + "ext-json": "*", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" }, "require-dev": { - "fidry/makefile": "^0.2.0", - "fidry/php-cs-fixer-config": "^1.1.2", - "phpstan/extension-installer": "^1.2.0", - "phpstan/phpstan": "^2.0", - "phpstan/phpstan-deprecation-rules": "^2.0.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^8.5.31 || ^9.5.26", - "webmozarts/strict-phpunit": "^7.5" + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" }, "type": "library", "autoload": { + "files": [ + "src/functions.php" + ], "psr-4": { - "Fidry\\CpuCoreCounter\\": "src/" + "Amp\\Dns\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -6672,58 +6759,1308 @@ ], "authors": [ { - "name": "Théo FIDRY", - "email": "theo.fidry@gmail.com" + "name": "Chris Wright", + "email": "addr@daverandom.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" } ], - "description": "Tiny utility to get the number of CPU cores.", + "description": "Async DNS resolution for Amp.", + "homepage": "https://github.com/amphp/dns", "keywords": [ - "CPU", - "core" + "amp", + "amphp", + "async", + "client", + "dns", + "resolve" ], "support": { - "issues": "https://github.com/theofidry/cpu-core-counter/issues", - "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + "issues": "https://github.com/amphp/dns/issues", + "source": "https://github.com/amphp/dns/tree/v2.4.0" }, "funding": [ { - "url": "https://github.com/theofidry", + "url": "https://github.com/amphp", "type": "github" } ], - "time": "2025-08-14T07:29:31+00:00" + "time": "2025-01-19T15:43:40+00:00" }, { - "name": "filp/whoops", - "version": "2.18.4", + "name": "amphp/hpack", + "version": "v3.2.2", "source": { "type": "git", - "url": "https://github.com/filp/whoops.git", - "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + "url": "https://github.com/amphp/hpack.git", + "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", - "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "url": "https://api.github.com/repos/amphp/hpack/zipball/291da27078e7e149a9bad4d08ff05bf7d81c89f4", + "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0", - "psr/log": "^1.0.1 || ^2.0 || ^3.0" + "php": ">=7.1" }, "require-dev": { - "mockery/mockery": "^1.0", - "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", - "symfony/var-dumper": "^4.0 || ^5.0" - }, - "suggest": { - "symfony/var-dumper": "Pretty print complex values better with var-dumper available", - "whoops/soap": "Formats errors as SOAP responses" + "amphp/php-cs-fixer-config": "^2", + "http2jp/hpack-test-case": "^1", + "nikic/php-fuzzer": "^0.0.11", + "phpunit/phpunit": "^7 | ^8 | ^9" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.7-dev" + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Amp\\Http\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "HTTP/2 HPack implementation.", + "homepage": "https://github.com/amphp/hpack", + "keywords": [ + "headers", + "hpack", + "http-2" + ], + "support": { + "issues": "https://github.com/amphp/hpack/issues", + "source": "https://github.com/amphp/hpack/tree/v3.2.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-03T19:28:59+00:00" + }, + { + "name": "amphp/http", + "version": "v2.1.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/http.git", + "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http/zipball/3680d80bd38b5d6f3c2cef2214ca6dd6cef26588", + "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588", + "shasum": "" + }, + "require": { + "amphp/hpack": "^3", + "amphp/parser": "^1.1", + "league/uri-components": "^2.4.2 | ^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "league/uri": "^6.8 | ^7.1", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.26.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/constants.php" + ], + "psr-4": { + "Amp\\Http\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Basic HTTP primitives which can be shared by servers and clients.", + "support": { + "issues": "https://github.com/amphp/http/issues", + "source": "https://github.com/amphp/http/tree/v2.1.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-11-23T14:57:26+00:00" + }, + { + "name": "amphp/http-client", + "version": "v5.3.6", + "source": { + "type": "git", + "url": "https://github.com/amphp/http-client.git", + "reference": "ca155026acafa74a612d776a97202d53077fee86" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http-client/zipball/ca155026acafa74a612d776a97202d53077fee86", + "reference": "ca155026acafa74a612d776a97202d53077fee86", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/hpack": "^3", + "amphp/http": "^2", + "amphp/pipeline": "^1", + "amphp/socket": "^2", + "amphp/sync": "^2", + "league/uri": "^7", + "league/uri-components": "^7", + "league/uri-interfaces": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2", + "revolt/event-loop": "^1" + }, + "conflict": { + "amphp/file": "<3 | >=5" + }, + "require-dev": { + "amphp/file": "^3 | ^4", + "amphp/http-server": "^3", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "ext-json": "*", + "kelunik/link-header-rfc5988": "^1", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "suggest": { + "amphp/file": "Required for file request bodies and HTTP archive logging", + "ext-json": "Required for logging HTTP archives", + "ext-zlib": "Allows using compression for response bodies." + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\Http\\Client\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "An advanced async HTTP client library for PHP, enabling efficient, non-blocking, and concurrent requests and responses.", + "homepage": "https://amphp.org/http-client", + "keywords": [ + "async", + "client", + "concurrent", + "http", + "non-blocking", + "rest" + ], + "support": { + "issues": "https://github.com/amphp/http-client/issues", + "source": "https://github.com/amphp/http-client/tree/v5.3.6" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-15T23:29:38+00:00" + }, + { + "name": "amphp/http-server", + "version": "v3.4.6", + "source": { + "type": "git", + "url": "https://github.com/amphp/http-server.git", + "reference": "8a971bf92cf8cf2bc511f37a75b39126d5305315" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http-server/zipball/8a971bf92cf8cf2bc511f37a75b39126d5305315", + "reference": "8a971bf92cf8cf2bc511f37a75b39126d5305315", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/hpack": "^3", + "amphp/http": "^2", + "amphp/pipeline": "^1", + "amphp/socket": "^2.1", + "amphp/sync": "^2.2", + "league/uri": "^7.1", + "league/uri-interfaces": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2", + "psr/log": "^1 | ^2 | ^3", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/http-client": "^5", + "amphp/log": "^2", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "league/uri-components": "^7.1", + "monolog/monolog": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "suggest": { + "ext-zlib": "Allows GZip compression of response bodies" + }, + "type": "library", + "autoload": { + "files": [ + "src/Driver/functions.php", + "src/Middleware/functions.php", + "src/functions.php" + ], + "psr-4": { + "Amp\\Http\\Server\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "A non-blocking HTTP application server for PHP based on Amp.", + "homepage": "https://github.com/amphp/http-server", + "keywords": [ + "amp", + "amphp", + "async", + "http", + "non-blocking", + "server" + ], + "support": { + "issues": "https://github.com/amphp/http-server/issues", + "source": "https://github.com/amphp/http-server/tree/v3.4.6" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-06-27T10:31:48+00:00" + }, + { + "name": "amphp/parser", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/parser.git", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Parser\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A generator parser to make streaming parsers simple.", + "homepage": "https://github.com/amphp/parser", + "keywords": [ + "async", + "non-blocking", + "parser", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/parser/issues", + "source": "https://github.com/amphp/parser/tree/v1.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:16:53+00:00" + }, + { + "name": "amphp/pipeline", + "version": "v1.2.5", + "source": { + "type": "git", + "url": "https://github.com/amphp/pipeline.git", + "reference": "92f121dde31cd1d89d5d0f9eba64ac40271b236e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/92f121dde31cd1d89d5d0f9eba64ac40271b236e", + "reference": "92f121dde31cd1d89d5d0f9eba64ac40271b236e", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Pipeline\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Asynchronous iterators and operators.", + "homepage": "https://amphp.org/pipeline", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "iterator", + "non-blocking" + ], + "support": { + "issues": "https://github.com/amphp/pipeline/issues", + "source": "https://github.com/amphp/pipeline/tree/v1.2.5" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-06-27T14:17:20+00:00" + }, + { + "name": "amphp/process", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/process.git", + "reference": "583959df17d00304ad7b0b32285373f985935643" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/process/zipball/583959df17d00304ad7b0b32285373f985935643", + "reference": "583959df17d00304ad7b0b32285373f985935643", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Process\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A fiber-aware process manager based on Amp and Revolt.", + "homepage": "https://amphp.org/process", + "support": { + "issues": "https://github.com/amphp/process/issues", + "source": "https://github.com/amphp/process/tree/v2.1.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-31T15:11:55+00:00" + }, + { + "name": "amphp/serialization", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/serialization.git", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/serialization/zipball/fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "ext-json": "*", + "ext-zlib": "*", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Serialization\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Serialization tools for IPC and data storage in PHP.", + "homepage": "https://github.com/amphp/serialization", + "keywords": [ + "async", + "asynchronous", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/amphp/serialization/issues", + "source": "https://github.com/amphp/serialization/tree/v1.1.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-05T15:59:53+00:00" + }, + { + "name": "amphp/socket", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/socket.git", + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/socket/zipball/dadb63c5d3179fd83803e29dfeac27350e619314", + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/dns": "^2", + "ext-openssl": "*", + "kelunik/certificate": "^1.1", + "league/uri": "^7", + "league/uri-interfaces": "^7", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/process": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php", + "src/SocketAddress/functions.php" + ], + "psr-4": { + "Amp\\Socket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.", + "homepage": "https://github.com/amphp/socket", + "keywords": [ + "amp", + "async", + "encryption", + "non-blocking", + "sockets", + "tcp", + "tls" + ], + "support": { + "issues": "https://github.com/amphp/socket/issues", + "source": "https://github.com/amphp/socket/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-19T15:09:56+00:00" + }, + { + "name": "amphp/sync", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/sync.git", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Sync\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" + } + ], + "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.", + "homepage": "https://github.com/amphp/sync", + "keywords": [ + "async", + "asynchronous", + "mutex", + "semaphore", + "synchronization" + ], + "support": { + "issues": "https://github.com/amphp/sync/issues", + "source": "https://github.com/amphp/sync/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-08-03T19:31:26+00:00" + }, + { + "name": "amphp/websocket", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/websocket.git", + "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/websocket/zipball/963904b6a883c4b62d9222d1d9749814fac96a3b", + "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/parser": "^1", + "amphp/pipeline": "^1", + "amphp/socket": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.18" + }, + "suggest": { + "ext-zlib": "Required for compression" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Websocket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + } + ], + "description": "Shared code for websocket servers and clients.", + "homepage": "https://github.com/amphp/websocket", + "keywords": [ + "amp", + "amphp", + "async", + "http", + "non-blocking", + "websocket" + ], + "support": { + "issues": "https://github.com/amphp/websocket/issues", + "source": "https://github.com/amphp/websocket/tree/v2.0.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-10-28T21:28:45+00:00" + }, + { + "name": "amphp/websocket-client", + "version": "v2.0.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/websocket-client.git", + "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/websocket-client/zipball/dc033fdce0af56295a23f63ac4f579b34d470d6c", + "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2.1", + "amphp/http": "^2.1", + "amphp/http-client": "^5", + "amphp/socket": "^2.2", + "amphp/websocket": "^2", + "league/uri": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1|^2", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/http-server": "^3", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/websocket-server": "^3|^4", + "phpunit/phpunit": "^9", + "psalm/phar": "~5.26.1", + "psr/log": "^1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Websocket\\Client\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Async WebSocket client for PHP based on Amp.", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "http", + "non-blocking", + "websocket" + ], + "support": { + "issues": "https://github.com/amphp/websocket-client/issues", + "source": "https://github.com/amphp/websocket-client/tree/v2.0.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-24T17:25:34+00:00" + }, + { + "name": "brianium/paratest", + "version": "v7.17.0", + "source": { + "type": "git", + "url": "https://github.com/paratestphp/paratest.git", + "reference": "53cb90a6aa3ef3840458781600628ade058a18b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/53cb90a6aa3ef3840458781600628ade058a18b9", + "reference": "53cb90a6aa3ef3840458781600628ade058a18b9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-pcre": "*", + "ext-reflection": "*", + "ext-simplexml": "*", + "fidry/cpu-core-counter": "^1.3.0", + "jean85/pretty-package-versions": "^2.1.1", + "php": "~8.3.0 || ~8.4.0 || ~8.5.0", + "phpunit/php-code-coverage": "^12.5.2", + "phpunit/php-file-iterator": "^6", + "phpunit/php-timer": "^8", + "phpunit/phpunit": "^12.5.8", + "sebastian/environment": "^8.0.3", + "symfony/console": "^7.3.4 || ^8.0.0", + "symfony/process": "^7.3.4 || ^8.0.0" + }, + "require-dev": { + "doctrine/coding-standard": "^14.0.0", + "ext-pcntl": "*", + "ext-pcov": "*", + "ext-posix": "*", + "phpstan/phpstan": "^2.1.38", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpstan/phpstan-phpunit": "^2.0.12", + "phpstan/phpstan-strict-rules": "^2.0.8", + "symfony/filesystem": "^7.3.2 || ^8.0.0" + }, + "bin": [ + "bin/paratest", + "bin/paratest_for_phpstorm" + ], + "type": "library", + "autoload": { + "psr-4": { + "ParaTest\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Scaturro", + "email": "scaturrob@gmail.com", + "role": "Developer" + }, + { + "name": "Filippo Tessarotto", + "email": "zoeslam@gmail.com", + "role": "Developer" + } + ], + "description": "Parallel testing for PHP", + "homepage": "https://github.com/paratestphp/paratest", + "keywords": [ + "concurrent", + "parallel", + "phpunit", + "testing" + ], + "support": { + "issues": "https://github.com/paratestphp/paratest/issues", + "source": "https://github.com/paratestphp/paratest/tree/v7.17.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/Slamdunk", + "type": "github" + }, + { + "url": "https://paypal.me/filippotessarotto", + "type": "paypal" + } + ], + "time": "2026-02-05T09:14:44+00:00" + }, + { + "name": "daverandom/libdns", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/DaveRandom/LibDNS.git", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "Required for IDN support" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "LibDNS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "DNS protocol implementation written in pure PHP", + "keywords": [ + "dns" + ], + "support": { + "issues": "https://github.com/DaveRandom/LibDNS/issues", + "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" + }, + "time": "2024-04-12T12:12:48+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" } }, "autoload": { @@ -6875,37 +8212,96 @@ }, "time": "2025-03-19T14:43:43+00:00" }, + { + "name": "kelunik/certificate", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/kelunik/certificate.git", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">=7.0" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^6 | 7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Kelunik\\Certificate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Access certificate details and transform between different formats.", + "keywords": [ + "DER", + "certificate", + "certificates", + "openssl", + "pem", + "x509" + ], + "support": { + "issues": "https://github.com/kelunik/certificate/issues", + "source": "https://github.com/kelunik/certificate/tree/v1.1.3" + }, + "time": "2023-02-03T21:26:53+00:00" + }, { "name": "laravel/boost", - "version": "v1.0.18", + "version": "v2.4.12", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab" + "reference": "d43bdf901fee8d216145cb062c9847c892844908" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", + "url": "https://api.github.com/repos/laravel/boost/zipball/d43bdf901fee8d216145cb062c9847c892844908", + "reference": "d43bdf901fee8d216145cb062c9847c892844908", "shasum": "" }, "require": { "guzzlehttp/guzzle": "^7.9", - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "laravel/mcp": "^0.1.0", - "laravel/prompts": "^0.1.9|^0.3", - "laravel/roster": "^0.2", - "php": "^8.1|^8.2" + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "laravel/mcp": "^0.7.1|^0.8.0", + "laravel/prompts": "^0.3.10", + "laravel/roster": "^0.5.0", + "php": "^8.2" }, "require-dev": { - "laravel/pint": "^1.14|^1.23", - "mockery/mockery": "^1.6", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.0", - "phpstan/phpstan": "^2.0" + "laravel/pint": "^1.27.0", + "mockery/mockery": "^1.6.12", + "orchestra/testbench": "^9.15.0|^10.6|^11.0", + "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.1" }, "type": "library", "extra": { @@ -6927,7 +8323,7 @@ "license": [ "MIT" ], - "description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.", + "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.", "homepage": "https://github.com/laravel/boost", "keywords": [ "ai", @@ -6938,41 +8334,48 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2025-08-16T09:10:03+00:00" + "time": "2026-07-08T09:53:34+00:00" }, { "name": "laravel/mcp", - "version": "v0.1.1", + "version": "v0.8.2", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713" + "reference": "0c32bf369c6432cab21458f9f4479da33a49ba37" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713", + "url": "https://api.github.com/repos/laravel/mcp/zipball/0c32bf369c6432cab21458f9f4479da33a49ba37", + "reference": "0c32bf369c6432cab21458f9f4479da33a49ba37", "shasum": "" }, "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/http": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "illuminate/validation": "^10.0|^11.0|^12.0", - "php": "^8.1|^8.2" + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2", + "symfony/process": "^7.4.5|^8.0.5" }, "require-dev": { - "laravel/pint": "^1.14", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "phpstan/phpstan": "^2.0" + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" }, "type": "library", "extra": { "laravel": { "aliases": { - "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + "Mcp": "Laravel\\Mcp\\Facades\\Mcp" }, "providers": [ "Laravel\\Mcp\\Server\\McpServiceProvider" @@ -6982,8 +8385,6 @@ "autoload": { "psr-4": { "Laravel\\Mcp\\": "src/", - "Workbench\\App\\": "workbench/app/", - "Laravel\\Mcp\\Tests\\": "tests/", "Laravel\\Mcp\\Server\\": "src/Server/" } }, @@ -6991,10 +8392,15 @@ "license": [ "MIT" ], - "description": "The easiest way to add MCP servers to your Laravel app.", + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", "homepage": "https://github.com/laravel/mcp", "keywords": [ - "dev", "laravel", "mcp" ], @@ -7002,7 +8408,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2025-08-16T09:50:43+00:00" + "time": "2026-06-25T14:00:45+00:00" }, { "name": "laravel/pail", @@ -7153,30 +8559,31 @@ }, { "name": "laravel/roster", - "version": "v0.2.2", + "version": "v0.5.1", "source": { "type": "git", "url": "https://github.com/laravel/roster.git", - "reference": "67a39bce557a6cb7e7205a2a9d6c464f0e72956f" + "reference": "5089de7615f72f78e831590ff9d0435fed0102bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/roster/zipball/67a39bce557a6cb7e7205a2a9d6c464f0e72956f", - "reference": "67a39bce557a6cb7e7205a2a9d6c464f0e72956f", + "url": "https://api.github.com/repos/laravel/roster/zipball/5089de7615f72f78e831590ff9d0435fed0102bb", + "reference": "5089de7615f72f78e831590ff9d0435fed0102bb", "shasum": "" }, "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "php": "^8.1|^8.2" + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/routing": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/yaml": "^7.2|^8.0" }, "require-dev": { "laravel/pint": "^1.14", "mockery/mockery": "^1.6", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.0", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.1", "phpstan/phpstan": "^2.0" }, "type": "library", @@ -7209,7 +8616,7 @@ "issues": "https://github.com/laravel/roster/issues", "source": "https://github.com/laravel/roster" }, - "time": "2025-07-24T12:31:13+00:00" + "time": "2026-03-05T07:58:43+00:00" }, { "name": "laravel/sail", @@ -7274,6 +8681,90 @@ }, "time": "2026-02-06T12:16:02+00:00" }, + { + "name": "league/uri-components", + "version": "7.8.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-components.git", + "reference": "8b5ffcebcc0842b76eb80964795bd56a8333b2ba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/8b5ffcebcc0842b76eb80964795bd56a8333b2ba", + "reference": "8b5ffcebcc0842b76eb80964795bd56a8333b2ba", + "shasum": "" + }, + "require": { + "league/uri": "^7.8", + "php": "^8.1" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-mbstring": "to use the sorting algorithm of URLSearchParams", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI components manipulation library", + "homepage": "http://uri.thephpleague.com", + "keywords": [ + "authority", + "components", + "fragment", + "host", + "middleware", + "modifier", + "path", + "port", + "query", + "rfc3986", + "scheme", + "uri", + "url", + "userinfo" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-components/tree/7.8.0" + }, + "funding": [ + { + "url": "https://github.com/nyamsprod", + "type": "github" + } + ], + "time": "2026-01-14T17:24:56+00:00" + }, { "name": "mockery/mockery", "version": "1.6.12", @@ -7772,6 +9263,89 @@ ], "time": "2025-08-20T13:10:51+00:00" }, + { + "name": "pestphp/pest-plugin-browser", + "version": "v4.3.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-browser.git", + "reference": "48bc408033281974952a6b296592cef3b920a2db" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/48bc408033281974952a6b296592cef3b920a2db", + "reference": "48bc408033281974952a6b296592cef3b920a2db", + "shasum": "" + }, + "require": { + "amphp/amp": "^3.1.1", + "amphp/http-server": "^3.4.4", + "amphp/websocket-client": "^2.0.2", + "ext-sockets": "*", + "pestphp/pest": "^4.3.2", + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3", + "symfony/process": "^7.4.5|^8.0.5" + }, + "require-dev": { + "ext-pcntl": "*", + "ext-posix": "*", + "livewire/livewire": "^3.7.10", + "nunomaduro/collision": "^8.9.0", + "orchestra/testbench": "^10.9.0", + "pestphp/pest-dev-tools": "^4.1.0", + "pestphp/pest-plugin-laravel": "^4.0", + "pestphp/pest-plugin-type-coverage": "^4.0.3" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Browser\\Plugin" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Browser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Pest plugin to test browser interactions", + "keywords": [ + "browser", + "framework", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.3.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-02-17T14:54:40+00:00" + }, { "name": "pestphp/pest-plugin-laravel", "version": "v4.0.0", @@ -8769,6 +10343,78 @@ ], "time": "2026-01-27T06:12:29+00:00" }, + { + "name": "revolt/event-loop", + "version": "v1.0.9", + "source": { + "type": "git", + "url": "https://github.com/revoltphp/event-loop.git", + "reference": "44061cf513e53c6200372fc935ac42271566295d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/44061cf513e53c6200372fc935ac42271566295d", + "reference": "44061cf513e53c6200372fc935ac42271566295d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-json": "*", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Revolt\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "ceesjank@gmail.com" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Rock-solid event loop for concurrent PHP applications.", + "keywords": [ + "async", + "asynchronous", + "concurrency", + "event", + "event-loop", + "non-blocking", + "scheduler" + ], + "support": { + "issues": "https://github.com/revoltphp/event-loop/issues", + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.9" + }, + "time": "2026-05-16T17:55:38+00:00" + }, { "name": "sebastian/cli-parser", "version": "4.2.0", @@ -9974,5 +11620,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/auth.php b/config/auth.php index 7d1eb0de..493a97af 100644 --- a/config/auth.php +++ b/config/auth.php @@ -40,6 +40,10 @@ 'driver' => 'session', 'provider' => 'users', ], + 'customer' => [ + 'driver' => 'session', + 'provider' => 'customers', + ], ], /* @@ -65,6 +69,11 @@ 'model' => env('AUTH_MODEL', App\Models\User::class), ], + 'customers' => [ + 'driver' => 'store-customers', + 'model' => App\Models\Customer::class, + ], + // 'users' => [ // 'driver' => 'database', // 'table' => 'users', @@ -97,6 +106,12 @@ '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..ac1d24f7 100644 --- a/config/database.php +++ b/config/database.php @@ -37,10 +37,13 @@ '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, - 'transaction_mode' => 'DEFERRED', + 'busy_timeout' => (int) env('DB_BUSY_TIMEOUT', 5000), + 'journal_mode' => env('DB_JOURNAL_MODE', 'wal'), + 'synchronous' => env('DB_SYNCHRONOUS', 'normal'), + 'transaction_mode' => env('DB_TRANSACTION_MODE', 'DEFERRED'), + 'pragmas' => [ + 'cache_size' => (int) env('DB_CACHE_SIZE', -20000), + ], ], 'mysql' => [ diff --git a/config/logging.php b/config/logging.php index 9e998a49..5f150182 100644 --- a/config/logging.php +++ b/config/logging.php @@ -1,5 +1,6 @@ true, ], + 'audit' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/audit.log'), + 'level' => 'info', + 'days' => 90, + 'formatter' => JsonFormatter::class, + ], + '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/sanctum.php b/config/sanctum.php new file mode 100644 index 00000000..8ba51b55 --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,87 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', 'shop_'), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, + ], + +]; diff --git a/config/session.php b/config/session.php index 5b541b75..e6197a0f 100644 --- a/config/session.php +++ b/config/session.php @@ -18,7 +18,7 @@ | */ - 'driver' => env('SESSION_DRIVER', 'database'), + 'driver' => env('SESSION_DRIVER', 'file'), /* |-------------------------------------------------------------------------- diff --git a/database/factories/AnalyticsDailyFactory.php b/database/factories/AnalyticsDailyFactory.php new file mode 100644 index 00000000..75f23a89 --- /dev/null +++ b/database/factories/AnalyticsDailyFactory.php @@ -0,0 +1,32 @@ + + */ +class AnalyticsDailyFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'date' => fake()->date(), + 'orders_count' => 0, + 'revenue_amount' => 0, + 'aov_amount' => 0, + 'visits_count' => 0, + 'add_to_cart_count' => 0, + 'checkout_started_count' => 0, + 'checkout_completed_count' => 0, + ]; + } +} diff --git a/database/factories/AnalyticsEventFactory.php b/database/factories/AnalyticsEventFactory.php new file mode 100644 index 00000000..e9f49f5f --- /dev/null +++ b/database/factories/AnalyticsEventFactory.php @@ -0,0 +1,45 @@ + + */ +class AnalyticsEventFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'type' => fake()->randomElement(AnalyticsEventType::cases()), + 'session_id' => fake()->uuid(), + 'customer_id' => null, + 'properties_json' => ['url' => '/'.fake()->slug(), 'referrer' => fake()->boolean(40) ? fake()->url() : null], + 'client_event_id' => fake()->uuid(), + 'occurred_at' => fake()->dateTimeBetween('-7 days'), + 'created_at' => fake()->dateTimeBetween('-7 days'), + ]; + } + + public function pageView(): static + { + return $this->state(fn (): array => ['type' => AnalyticsEventType::PageView]); + } + + public function addToCart(): static + { + return $this->state(fn (): array => [ + 'type' => AnalyticsEventType::AddToCart, + 'properties_json' => ['variant_id' => fake()->randomNumber(), 'quantity' => 1], + ]); + } +} diff --git a/database/factories/AppFactory.php b/database/factories/AppFactory.php new file mode 100644 index 00000000..bc28ea65 --- /dev/null +++ b/database/factories/AppFactory.php @@ -0,0 +1,25 @@ + + */ +class AppFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->unique()->company().' App', + 'status' => AppStatus::Active, + ]; + } +} diff --git a/database/factories/AppInstallationFactory.php b/database/factories/AppInstallationFactory.php new file mode 100644 index 00000000..3934f84b --- /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'], + 'status' => AppInstallationStatus::Active, + 'installed_at' => now(), + ]; + } +} diff --git a/database/factories/CartFactory.php b/database/factories/CartFactory.php new file mode 100644 index 00000000..4bd63766 --- /dev/null +++ b/database/factories/CartFactory.php @@ -0,0 +1,27 @@ + + */ +class CartFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'currency' => 'EUR', + 'cart_version' => 1, + 'status' => 'active', + ]; + } +} diff --git a/database/factories/CartLineFactory.php b/database/factories/CartLineFactory.php new file mode 100644 index 00000000..2dbd2a3a --- /dev/null +++ b/database/factories/CartLineFactory.php @@ -0,0 +1,31 @@ + + */ +class CartLineFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'cart_id' => Cart::factory(), + 'variant_id' => ProductVariant::factory(), + 'quantity' => 1, + 'unit_price_amount' => 2500, + 'line_subtotal_amount' => 2500, + 'line_discount_amount' => 0, + 'line_total_amount' => 2500, + ]; + } +} diff --git a/database/factories/CheckoutFactory.php b/database/factories/CheckoutFactory.php new file mode 100644 index 00000000..c8c4be0b --- /dev/null +++ b/database/factories/CheckoutFactory.php @@ -0,0 +1,27 @@ + + */ +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(), + 'status' => 'started', + ]; + } +} diff --git a/database/factories/CollectionFactory.php b/database/factories/CollectionFactory.php new file mode 100644 index 00000000..730f63d7 --- /dev/null +++ b/database/factories/CollectionFactory.php @@ -0,0 +1,43 @@ + */ +class CollectionFactory extends Factory +{ + /** @return array */ + public function definition(): array + { + $title = fake()->unique()->words(2, true); + + return [ + 'store_id' => Store::factory(), + 'title' => Str::title($title), + 'handle' => Str::slug($title), + 'description_html' => '

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

', + 'type' => 'manual', + 'status' => CollectionStatus::Active, + ]; + } + + public function draft(): static + { + return $this->state(fn (): array => ['status' => CollectionStatus::Draft]); + } + + public function archived(): static + { + return $this->state(fn (): array => ['status' => CollectionStatus::Archived]); + } + + public function automated(): static + { + return $this->state(fn (): array => ['type' => 'automated']); + } +} diff --git a/database/factories/CustomerAddressFactory.php b/database/factories/CustomerAddressFactory.php new file mode 100644 index 00000000..ca7167de --- /dev/null +++ b/database/factories/CustomerAddressFactory.php @@ -0,0 +1,27 @@ + + */ +class CustomerAddressFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'customer_id' => Customer::factory(), + 'label' => 'Home', + 'address_json' => ['first_name' => fake()->firstName(), 'last_name' => fake()->lastName(), 'address1' => fake()->streetAddress(), 'city' => fake()->city(), 'country' => 'DE', 'country_code' => 'DE', 'postal_code' => fake()->postcode()], + 'is_default' => true, + ]; + } +} diff --git a/database/factories/CustomerFactory.php b/database/factories/CustomerFactory.php new file mode 100644 index 00000000..d29ab1fc --- /dev/null +++ b/database/factories/CustomerFactory.php @@ -0,0 +1,29 @@ + + */ +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_hash' => Hash::make('password'), + 'name' => fake()->name(), + 'marketing_opt_in' => fake()->boolean(), + ]; + } +} diff --git a/database/factories/DiscountFactory.php b/database/factories/DiscountFactory.php new file mode 100644 index 00000000..3b38aa6a --- /dev/null +++ b/database/factories/DiscountFactory.php @@ -0,0 +1,33 @@ + + */ +class DiscountFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'type' => 'code', + 'code' => fake()->unique()->bothify('SAVE##??'), + 'value_type' => 'percent', + 'value_amount' => 10, + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addMonth(), + 'usage_count' => 0, + 'rules_json' => [], + 'status' => 'active', + ]; + } +} diff --git a/database/factories/FulfillmentFactory.php b/database/factories/FulfillmentFactory.php new file mode 100644 index 00000000..17462006 --- /dev/null +++ b/database/factories/FulfillmentFactory.php @@ -0,0 +1,25 @@ + + */ +class FulfillmentFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'status' => 'pending', + ]; + } +} diff --git a/database/factories/FulfillmentLineFactory.php b/database/factories/FulfillmentLineFactory.php new file mode 100644 index 00000000..ac74060b --- /dev/null +++ b/database/factories/FulfillmentLineFactory.php @@ -0,0 +1,27 @@ + + */ +class FulfillmentLineFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'fulfillment_id' => Fulfillment::factory(), + 'order_line_id' => OrderLine::factory(), + 'quantity' => 1, + ]; + } +} diff --git a/database/factories/InventoryItemFactory.php b/database/factories/InventoryItemFactory.php new file mode 100644 index 00000000..8c36f33a --- /dev/null +++ b/database/factories/InventoryItemFactory.php @@ -0,0 +1,45 @@ + */ +class InventoryItemFactory extends Factory +{ + /** @return array */ + public function definition(): array + { + $store = Store::factory()->create(); + $product = Product::factory()->for($store)->create(); + $variant = ProductVariant::withoutEvents(fn (): ProductVariant => ProductVariant::factory()->for($product)->create()); + + return [ + 'store_id' => $store, + 'variant_id' => $variant, + 'quantity_on_hand' => fake()->numberBetween(0, 100), + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ]; + } + + public function outOfStock(): static + { + return $this->state(fn (): array => ['quantity_on_hand' => 0]); + } + + public function continuePolicy(): static + { + return $this->state(fn (): array => ['policy' => InventoryPolicy::Continue]); + } + + public function lowStock(): static + { + return $this->state(fn (): array => ['quantity_on_hand' => fake()->numberBetween(1, 3)]); + } +} diff --git a/database/factories/NavigationItemFactory.php b/database/factories/NavigationItemFactory.php new file mode 100644 index 00000000..5b426461 --- /dev/null +++ b/database/factories/NavigationItemFactory.php @@ -0,0 +1,45 @@ + + */ +class NavigationItemFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'menu_id' => NavigationMenu::factory(), + 'type' => NavigationItemType::Link, + 'label' => fake()->words(2, true), + 'url' => '/', + 'resource_id' => null, + 'position' => 0, + ]; + } + + public function page(int $pageId): static + { + return $this->state(fn (): array => ['type' => NavigationItemType::Page, 'url' => null, 'resource_id' => $pageId]); + } + + public function collection(int $collectionId): static + { + return $this->state(fn (): array => ['type' => NavigationItemType::Collection, 'url' => null, 'resource_id' => $collectionId]); + } + + public function product(int $productId): static + { + return $this->state(fn (): 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..f941091d --- /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 = fake()->words(2, true); + + return [ + 'store_id' => Store::factory(), + 'handle' => Str::slug($title).'-'.fake()->unique()->numberBetween(1, 99999), + 'title' => Str::title($title), + ]; + } +} diff --git a/database/factories/OauthClientFactory.php b/database/factories/OauthClientFactory.php new file mode 100644 index 00000000..016f7825 --- /dev/null +++ b/database/factories/OauthClientFactory.php @@ -0,0 +1,28 @@ + + */ +class OauthClientFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'app_id' => App::factory(), + 'client_id' => (string) Str::uuid(), + 'client_secret_encrypted' => Str::random(40), + 'redirect_uris_json' => ['https://example.test/oauth/callback'], + ]; + } +} diff --git a/database/factories/OauthTokenFactory.php b/database/factories/OauthTokenFactory.php new file mode 100644 index 00000000..74b329c0 --- /dev/null +++ b/database/factories/OauthTokenFactory.php @@ -0,0 +1,28 @@ + + */ +class OauthTokenFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'installation_id' => AppInstallation::factory(), + 'access_token_hash' => hash('sha256', Str::random(40)), + 'refresh_token_hash' => hash('sha256', Str::random(40)), + 'expires_at' => now()->addHour(), + ]; + } +} diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php new file mode 100644 index 00000000..0d315322 --- /dev/null +++ b/database/factories/OrderFactory.php @@ -0,0 +1,37 @@ + + */ +class OrderFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'order_number' => '#'.fake()->unique()->numberBetween(1001, 99999), + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'fulfillment_status' => 'unfulfilled', + 'currency' => 'EUR', + 'subtotal_amount' => 5000, + 'discount_amount' => 0, + 'shipping_amount' => 499, + 'tax_amount' => 878, + 'total_amount' => 5499, + 'email' => fake()->safeEmail(), + 'placed_at' => now(), + ]; + } +} diff --git a/database/factories/OrderLineFactory.php b/database/factories/OrderLineFactory.php new file mode 100644 index 00000000..b9f6b5a3 --- /dev/null +++ b/database/factories/OrderLineFactory.php @@ -0,0 +1,31 @@ + + */ +class OrderLineFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'title_snapshot' => fake()->words(3, true), + 'sku_snapshot' => fake()->unique()->bothify('SKU-####'), + 'quantity' => 1, + 'unit_price_amount' => 2500, + 'total_amount' => 2500, + '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..1636b639 --- /dev/null +++ b/database/factories/PageFactory.php @@ -0,0 +1,43 @@ + + */ +class PageFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $title = fake()->words(3, true); + + return [ + 'store_id' => Store::factory(), + 'title' => Str::title($title), + 'handle' => Str::slug($title).'-'.fake()->unique()->numberBetween(1, 99999), + 'body_html' => '

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

'.fake()->paragraphs(3, true).'

', + 'status' => PageStatus::Published, + 'published_at' => now(), + ]; + } + + public function draft(): static + { + return $this->state(fn (): array => ['status' => PageStatus::Draft, 'published_at' => null]); + } + + public function archived(): static + { + return $this->state(fn (): array => ['status' => PageStatus::Archived]); + } +} diff --git a/database/factories/PaymentFactory.php b/database/factories/PaymentFactory.php new file mode 100644 index 00000000..4c3ef5c2 --- /dev/null +++ b/database/factories/PaymentFactory.php @@ -0,0 +1,31 @@ + + */ +class PaymentFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'provider' => 'mock', + 'method' => 'credit_card', + 'provider_payment_id' => 'mock_'.fake()->uuid(), + 'status' => 'captured', + 'amount' => 5499, + 'currency' => 'EUR', + 'raw_json_encrypted' => ['provider' => 'mock'], + ]; + } +} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php new file mode 100644 index 00000000..7b7ea9fd --- /dev/null +++ b/database/factories/ProductFactory.php @@ -0,0 +1,64 @@ + */ +class ProductFactory extends Factory +{ + /** @return array */ + public function definition(): array + { + $title = fake()->unique()->words(3, true); + + return [ + 'store_id' => Store::factory(), + 'title' => Str::title($title), + 'handle' => Str::slug($title), + 'status' => ProductStatus::Active, + 'description_html' => '

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

'.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 => [ + 'status' => ProductStatus::Draft, + 'published_at' => null, + ]); + } + + public function archived(): static + { + return $this->state(fn (): array => ['status' => ProductStatus::Archived]); + } + + public function withVariants(int $count = 1): static + { + return $this->afterCreating(function (Product $product) use ($count): void { + ProductVariant::factory()->count($count)->for($product)->sequence( + ...array_map(fn (int $position): array => ['position' => $position], range(0, $count - 1)), + )->create(); + }); + } + + public function withDefaultVariant(int $priceAmount = 1000): static + { + return $this->afterCreating(function (Product $product) use ($priceAmount): void { + ProductVariant::factory()->default()->for($product)->create([ + 'price_amount' => $priceAmount, + 'currency' => $product->store()->value('default_currency'), + ]); + }); + } +} diff --git a/database/factories/ProductMediaFactory.php b/database/factories/ProductMediaFactory.php new file mode 100644 index 00000000..76918e73 --- /dev/null +++ b/database/factories/ProductMediaFactory.php @@ -0,0 +1,30 @@ + */ +class ProductMediaFactory extends Factory +{ + /** @return array */ + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'type' => MediaType::Image, + 'storage_key' => 'media/originals/'.fake()->uuid().'.jpg', + 'alt_text' => fake()->sentence(4), + 'width' => 1200, + 'height' => 1200, + 'mime_type' => 'image/jpeg', + 'byte_size' => fake()->numberBetween(10000, 5000000), + 'position' => 0, + 'status' => MediaStatus::Processing, + ]; + } +} diff --git a/database/factories/ProductOptionFactory.php b/database/factories/ProductOptionFactory.php new file mode 100644 index 00000000..74cd54a5 --- /dev/null +++ b/database/factories/ProductOptionFactory.php @@ -0,0 +1,21 @@ + */ +class ProductOptionFactory extends Factory +{ + /** @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..446999e9 --- /dev/null +++ b/database/factories/ProductOptionValueFactory.php @@ -0,0 +1,21 @@ + */ +class ProductOptionValueFactory extends Factory +{ + /** @return array */ + public function definition(): array + { + return [ + 'product_option_id' => ProductOption::factory(), + 'value' => fake()->randomElement(['Small', 'Medium', 'Large', 'Black', 'White']), + 'position' => 0, + ]; + } +} diff --git a/database/factories/ProductVariantFactory.php b/database/factories/ProductVariantFactory.php new file mode 100644 index 00000000..00af5a9c --- /dev/null +++ b/database/factories/ProductVariantFactory.php @@ -0,0 +1,53 @@ + */ +class ProductVariantFactory extends Factory +{ + /** @return array */ + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'sku' => fake()->unique()->bothify('SKU-####-???'), + '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 => [ + 'compare_at_amount' => fake()->numberBetween(20000, 39999), + 'price_amount' => fake()->numberBetween(9999, 19999), + ]); + } + + public function digital(): static + { + return $this->state(fn (): array => ['requires_shipping' => false, 'weight_g' => 0]); + } + + public function default(): static + { + return $this->state(fn (): array => ['is_default' => true]); + } + + public function archived(): static + { + return $this->state(fn (): array => ['status' => VariantStatus::Archived]); + } +} diff --git a/database/factories/RefundFactory.php b/database/factories/RefundFactory.php new file mode 100644 index 00000000..66c0100e --- /dev/null +++ b/database/factories/RefundFactory.php @@ -0,0 +1,30 @@ + + */ +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' => 1000, + 'reason' => 'Customer requested', + 'status' => 'processed', + 'provider_refund_id' => 'mock_refund_'.fake()->uuid(), + ]; + } +} diff --git a/database/factories/SearchQueryFactory.php b/database/factories/SearchQueryFactory.php new file mode 100644 index 00000000..9106dd04 --- /dev/null +++ b/database/factories/SearchQueryFactory.php @@ -0,0 +1,27 @@ + + */ +class SearchQueryFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'query' => fake()->words(2, true), + 'filters_json' => null, + 'results_count' => fake()->numberBetween(0, 100), + ]; + } +} diff --git a/database/factories/SearchSettingsFactory.php b/database/factories/SearchSettingsFactory.php new file mode 100644 index 00000000..7c34d82b --- /dev/null +++ b/database/factories/SearchSettingsFactory.php @@ -0,0 +1,26 @@ + + */ +class SearchSettingsFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'synonyms_json' => [], + 'stop_words_json' => [], + ]; + } +} diff --git a/database/factories/ShippingRateFactory.php b/database/factories/ShippingRateFactory.php new file mode 100644 index 00000000..2b92e078 --- /dev/null +++ b/database/factories/ShippingRateFactory.php @@ -0,0 +1,28 @@ + + */ +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' => 'flat', + 'config_json' => ['amount' => 499], + 'is_active' => true, + ]; + } +} diff --git a/database/factories/ShippingZoneFactory.php b/database/factories/ShippingZoneFactory.php new file mode 100644 index 00000000..02d0fcd1 --- /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' => 'Domestic', + 'countries_json' => ['DE'], + 'regions_json' => [], + ]; + } +} diff --git a/database/factories/StoreDomainFactory.php b/database/factories/StoreDomainFactory.php new file mode 100644 index 00000000..a20affc8 --- /dev/null +++ b/database/factories/StoreDomainFactory.php @@ -0,0 +1,36 @@ + + */ +class StoreDomainFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'hostname' => fake()->unique()->domainName(), + 'type' => StoreDomainType::Storefront, + 'is_primary' => false, + 'tls_mode' => 'managed', + ]; + } + + public function primary(): static + { + return $this->state(fn (): array => [ + 'is_primary' => true, + ]); + } +} diff --git a/database/factories/StoreFactory.php b/database/factories/StoreFactory.php new file mode 100644 index 00000000..ab48a5f4 --- /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()->unique()->company(); + + return [ + 'organization_id' => Organization::factory(), + 'name' => $name, + 'handle' => Str::slug($name).'-'.fake()->unique()->numberBetween(100, 99999), + 'status' => StoreStatus::Active, + 'default_currency' => 'USD', + 'default_locale' => 'en', + 'timezone' => 'UTC', + ]; + } + + public function suspended(): static + { + return $this->state(fn (): array => [ + 'status' => StoreStatus::Suspended, + ]); + } +} diff --git a/database/factories/StoreSettingsFactory.php b/database/factories/StoreSettingsFactory.php new file mode 100644 index 00000000..0d44b386 --- /dev/null +++ b/database/factories/StoreSettingsFactory.php @@ -0,0 +1,25 @@ + + */ +class StoreSettingsFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'settings_json' => [], + ]; + } +} diff --git a/database/factories/StoreUserFactory.php b/database/factories/StoreUserFactory.php new file mode 100644 index 00000000..b6bec787 --- /dev/null +++ b/database/factories/StoreUserFactory.php @@ -0,0 +1,36 @@ + + */ +class StoreUserFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'user_id' => User::factory(), + 'role' => StoreUserRole::Staff, + 'created_at' => now(), + ]; + } + + public function owner(): static + { + return $this->state(fn (): array => [ + 'role' => StoreUserRole::Owner, + ]); + } +} diff --git a/database/factories/TaxSettingsFactory.php b/database/factories/TaxSettingsFactory.php new file mode 100644 index 00000000..26c757ec --- /dev/null +++ b/database/factories/TaxSettingsFactory.php @@ -0,0 +1,28 @@ + + */ +class TaxSettingsFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'mode' => 'manual', + 'provider' => 'none', + 'prices_include_tax' => true, + 'config_json' => ['default_rate_bps' => 1900], + ]; + } +} diff --git a/database/factories/ThemeFactory.php b/database/factories/ThemeFactory.php new file mode 100644 index 00000000..45c383a8 --- /dev/null +++ b/database/factories/ThemeFactory.php @@ -0,0 +1,34 @@ + + */ +class ThemeFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'name' => 'Default Theme', + 'version' => '1.0.0', + 'status' => ThemeStatus::Published, + 'published_at' => now(), + ]; + } + + public function draft(): static + { + return $this->state(fn (): array => ['status' => ThemeStatus::Draft, 'published_at' => null]); + } +} diff --git a/database/factories/ThemeFileFactory.php b/database/factories/ThemeFileFactory.php new file mode 100644 index 00000000..d0bdd628 --- /dev/null +++ b/database/factories/ThemeFileFactory.php @@ -0,0 +1,29 @@ + + */ +class ThemeFileFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'theme_id' => Theme::factory(), + 'path' => 'templates/'.fake()->unique()->slug().'.blade.php', + 'storage_key' => 'themes/'.Str::uuid().'.blade.php', + 'sha256' => hash('sha256', fake()->text()), + 'byte_size' => fake()->numberBetween(100, 100000), + ]; + } +} diff --git a/database/factories/ThemeSettingsFactory.php b/database/factories/ThemeSettingsFactory.php new file mode 100644 index 00000000..47915a22 --- /dev/null +++ b/database/factories/ThemeSettingsFactory.php @@ -0,0 +1,25 @@ + + */ +class ThemeSettingsFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'theme_id' => Theme::factory(), + 'settings_json' => ['colors' => ['primary' => '#18181b']], + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 80da5ac7..94b6460c 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -27,7 +27,9 @@ public function definition(): array 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), - 'password' => static::$password ??= Hash::make('password'), + 'password_hash' => static::$password ??= Hash::make('password'), + 'status' => 'active', + 'last_login_at' => null, 'remember_token' => Str::random(10), 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, diff --git a/database/factories/WebhookDeliveryFactory.php b/database/factories/WebhookDeliveryFactory.php new file mode 100644 index 00000000..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..158d659a --- /dev/null +++ b/database/factories/WebhookSubscriptionFactory.php @@ -0,0 +1,31 @@ + + */ +class WebhookSubscriptionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'app_installation_id' => null, + 'event_type' => 'order.created', + 'target_url' => 'https://example.test/webhooks/'.Str::random(8), + 'signing_secret_encrypted' => Str::random(32), + '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..dece5ad0 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -14,11 +14,16 @@ public function up(): void Schema::create('users', function (Blueprint $table) { $table->id(); $table->string('name'); - $table->string('email')->unique(); + $table->string('email'); $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); + $table->string('password_hash'); + $table->enum('status', ['active', 'disabled'])->default('active'); + $table->timestamp('last_login_at')->nullable(); $table->rememberToken(); $table->timestamps(); + + $table->unique('email', 'idx_users_email'); + $table->index('status', 'idx_users_status'); }); Schema::create('password_reset_tokens', function (Blueprint $table) { diff --git a/database/migrations/2025_01_01_000000_create_organizations_table.php b/database/migrations/2025_01_01_000000_create_organizations_table.php new file mode 100644 index 00000000..64b81fac --- /dev/null +++ b/database/migrations/2025_01_01_000000_create_organizations_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name'); + $table->string('billing_email'); + $table->timestamps(); + + $table->index('billing_email', 'idx_organizations_billing_email'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('organizations'); + } +}; diff --git a/database/migrations/2025_01_01_000001_create_stores_table.php b/database/migrations/2025_01_01_000001_create_stores_table.php new file mode 100644 index 00000000..5fe0f7f0 --- /dev/null +++ b/database/migrations/2025_01_01_000001_create_stores_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('organization_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('handle'); + $table->enum('status', ['active', 'suspended'])->default('active'); + $table->string('default_currency', 3)->default('USD'); + $table->string('default_locale')->default('en'); + $table->string('timezone')->default('UTC'); + $table->timestamps(); + + $table->unique('handle', 'idx_stores_handle'); + $table->index('organization_id', 'idx_stores_organization_id'); + $table->index('status', 'idx_stores_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('stores'); + } +}; diff --git a/database/migrations/2025_01_01_000002_create_store_domains_table.php b/database/migrations/2025_01_01_000002_create_store_domains_table.php new file mode 100644 index 00000000..71c7623e --- /dev/null +++ b/database/migrations/2025_01_01_000002_create_store_domains_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('hostname'); + $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->unique('hostname', 'idx_store_domains_hostname'); + $table->index('store_id', 'idx_store_domains_store_id'); + $table->index(['store_id', 'is_primary'], 'idx_store_domains_store_primary'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('store_domains'); + } +}; diff --git a/database/migrations/2025_01_01_000003_create_store_users_table.php b/database/migrations/2025_01_01_000003_create_store_users_table.php new file mode 100644 index 00000000..6e71a448 --- /dev/null +++ b/database/migrations/2025_01_01_000003_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', 'idx_store_users_user_id'); + $table->index(['store_id', 'role'], 'idx_store_users_role'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('store_users'); + } +}; diff --git a/database/migrations/2025_01_01_000004_create_store_settings_table.php b/database/migrations/2025_01_01_000004_create_store_settings_table.php new file mode 100644 index 00000000..d9d55117 --- /dev/null +++ b/database/migrations/2025_01_01_000004_create_store_settings_table.php @@ -0,0 +1,28 @@ +foreignId('store_id')->primary()->constrained()->cascadeOnDelete(); + $table->longText('settings_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('store_settings'); + } +}; diff --git a/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php b/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php index 187d974d..a008f488 100644 --- a/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php +++ b/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php @@ -12,7 +12,7 @@ public function up(): void { Schema::table('users', function (Blueprint $table) { - $table->text('two_factor_secret')->after('password')->nullable(); + $table->text('two_factor_secret')->after('password_hash')->nullable(); $table->text('two_factor_recovery_codes')->after('two_factor_secret')->nullable(); $table->timestamp('two_factor_confirmed_at')->after('two_factor_recovery_codes')->nullable(); }); diff --git a/database/migrations/2026_07_11_101220_create_personal_access_tokens_table.php b/database/migrations/2026_07_11_101220_create_personal_access_tokens_table.php new file mode 100644 index 00000000..40ff706e --- /dev/null +++ b/database/migrations/2026_07_11_101220_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/migrations/2026_07_11_101324_create_products_table.php b/database/migrations/2026_07_11_101324_create_products_table.php new file mode 100644 index 00000000..c7ae442a --- /dev/null +++ b/database/migrations/2026_07_11_101324_create_products_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->text('title'); + $table->text('handle'); + $table->enum('status', ['draft', 'active', 'archived'])->default('draft'); + $table->text('description_html')->nullable(); + $table->text('vendor')->nullable(); + $table->text('product_type')->nullable(); + $table->text('tags')->default('[]'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'handle'], 'idx_products_store_handle'); + $table->index('store_id', 'idx_products_store_id'); + $table->index(['store_id', 'status'], 'idx_products_store_status'); + $table->index(['store_id', 'published_at'], 'idx_products_published_at'); + $table->index(['store_id', 'vendor'], 'idx_products_vendor'); + $table->index(['store_id', 'product_type'], 'idx_products_product_type'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('products'); + } +}; diff --git a/database/migrations/2026_07_11_101326_create_product_options_table.php b/database/migrations/2026_07_11_101326_create_product_options_table.php new file mode 100644 index 00000000..7ddecac7 --- /dev/null +++ b/database/migrations/2026_07_11_101326_create_product_options_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->text('name'); + $table->integer('position')->default(0); + + $table->index('product_id', 'idx_product_options_product_id'); + $table->unique(['product_id', 'position'], 'idx_product_options_product_position'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_options'); + } +}; diff --git a/database/migrations/2026_07_11_101327_create_product_option_values_table.php b/database/migrations/2026_07_11_101327_create_product_option_values_table.php new file mode 100644 index 00000000..a67a71f6 --- /dev/null +++ b/database/migrations/2026_07_11_101327_create_product_option_values_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('product_option_id')->constrained()->cascadeOnDelete(); + $table->text('value'); + $table->integer('position')->default(0); + + $table->index('product_option_id', 'idx_product_option_values_option_id'); + $table->unique(['product_option_id', 'position'], 'idx_product_option_values_option_position'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_option_values'); + } +}; diff --git a/database/migrations/2026_07_11_101328_create_product_variants_table.php b/database/migrations/2026_07_11_101328_create_product_variants_table.php new file mode 100644 index 00000000..fb5493d9 --- /dev/null +++ b/database/migrations/2026_07_11_101328_create_product_variants_table.php @@ -0,0 +1,44 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->text('sku')->nullable(); + $table->text('barcode')->nullable(); + $table->integer('price_amount')->default(0); + $table->integer('compare_at_amount')->nullable(); + $table->text('currency')->default('USD'); + $table->integer('weight_g')->nullable(); + $table->boolean('requires_shipping')->default(true); + $table->boolean('is_default')->default(false); + $table->integer('position')->default(0); + $table->enum('status', ['active', 'archived'])->default('active'); + $table->timestamps(); + + $table->index('product_id', 'idx_product_variants_product_id'); + $table->index('sku', 'idx_product_variants_sku'); + $table->index('barcode', 'idx_product_variants_barcode'); + $table->index(['product_id', 'position'], 'idx_product_variants_product_position'); + $table->index(['product_id', 'is_default'], 'idx_product_variants_product_default'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_variants'); + } +}; diff --git a/database/migrations/2026_07_11_101329_create_variant_option_values_table.php b/database/migrations/2026_07_11_101329_create_variant_option_values_table.php new file mode 100644 index 00000000..9381e442 --- /dev/null +++ b/database/migrations/2026_07_11_101329_create_variant_option_values_table.php @@ -0,0 +1,30 @@ +foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->foreignId('product_option_value_id')->constrained()->cascadeOnDelete(); + + $table->primary(['variant_id', 'product_option_value_id']); + $table->index('product_option_value_id', 'idx_variant_option_values_value_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('variant_option_values'); + } +}; diff --git a/database/migrations/2026_07_11_101330_create_inventory_items_table.php b/database/migrations/2026_07_11_101330_create_inventory_items_table.php new file mode 100644 index 00000000..48558d6b --- /dev/null +++ b/database/migrations/2026_07_11_101330_create_inventory_items_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('variant_id')->unique('idx_inventory_items_variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->integer('quantity_on_hand')->default(0); + $table->integer('quantity_reserved')->default(0); + $table->enum('policy', ['deny', 'continue'])->default('deny'); + + $table->index('store_id', 'idx_inventory_items_store_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('inventory_items'); + } +}; diff --git a/database/migrations/2026_07_11_101332_create_collections_table.php b/database/migrations/2026_07_11_101332_create_collections_table.php new file mode 100644 index 00000000..9dc5f3ba --- /dev/null +++ b/database/migrations/2026_07_11_101332_create_collections_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->text('title'); + $table->text('handle'); + $table->text('description_html')->nullable(); + $table->enum('type', ['manual', 'automated'])->default('manual'); + $table->enum('status', ['draft', 'active', 'archived'])->default('active'); + $table->timestamps(); + + $table->unique(['store_id', 'handle'], 'idx_collections_store_handle'); + $table->index('store_id', 'idx_collections_store_id'); + $table->index(['store_id', 'status'], 'idx_collections_store_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('collections'); + } +}; diff --git a/database/migrations/2026_07_11_101333_create_collection_products_table.php b/database/migrations/2026_07_11_101333_create_collection_products_table.php new file mode 100644 index 00000000..93592eb4 --- /dev/null +++ b/database/migrations/2026_07_11_101333_create_collection_products_table.php @@ -0,0 +1,32 @@ +foreignId('collection_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->integer('position')->default(0); + + $table->primary(['collection_id', 'product_id']); + $table->index('product_id', 'idx_collection_products_product_id'); + $table->index(['collection_id', 'position'], 'idx_collection_products_position'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('collection_products'); + } +}; diff --git a/database/migrations/2026_07_11_101334_create_product_media_table.php b/database/migrations/2026_07_11_101334_create_product_media_table.php new file mode 100644 index 00000000..6752c63e --- /dev/null +++ b/database/migrations/2026_07_11_101334_create_product_media_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->enum('type', ['image', 'video'])->default('image'); + $table->text('storage_key'); + $table->text('alt_text')->nullable(); + $table->integer('width')->nullable(); + $table->integer('height')->nullable(); + $table->text('mime_type')->nullable(); + $table->integer('byte_size')->nullable(); + $table->integer('position')->default(0); + $table->enum('status', ['processing', 'ready', 'failed'])->default('processing'); + $table->timestamp('created_at')->nullable(); + + $table->index('product_id', 'idx_product_media_product_id'); + $table->index(['product_id', 'position'], 'idx_product_media_product_position'); + $table->index('status', 'idx_product_media_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_media'); + } +}; diff --git a/database/migrations/2026_07_11_101400_create_customers_table.php b/database/migrations/2026_07_11_101400_create_customers_table.php new file mode 100644 index 00000000..47c0ddfc --- /dev/null +++ b/database/migrations/2026_07_11_101400_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_07_11_101401_create_customer_addresses_table.php b/database/migrations/2026_07_11_101401_create_customer_addresses_table.php new file mode 100644 index 00000000..45957a9b --- /dev/null +++ b/database/migrations/2026_07_11_101401_create_customer_addresses_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('customer_id')->constrained()->cascadeOnDelete(); + $table->string('label')->nullable(); + $table->json('address_json')->default('{}'); + $table->boolean('is_default')->default(false); + + $table->index(['customer_id', 'is_default']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('customer_addresses'); + } +}; diff --git a/database/migrations/2026_07_11_101402_create_shipping_zones_table.php b/database/migrations/2026_07_11_101402_create_shipping_zones_table.php new file mode 100644 index 00000000..957080cd --- /dev/null +++ b/database/migrations/2026_07_11_101402_create_shipping_zones_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->json('countries_json')->default('[]'); + $table->json('regions_json')->default('[]'); + + $table->index('store_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shipping_zones'); + } +}; diff --git a/database/migrations/2026_07_11_101403_create_shipping_rates_table.php b/database/migrations/2026_07_11_101403_create_shipping_rates_table.php new file mode 100644 index 00000000..c1903694 --- /dev/null +++ b/database/migrations/2026_07_11_101403_create_shipping_rates_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('zone_id')->constrained('shipping_zones')->cascadeOnDelete(); + $table->string('name'); + $table->enum('type', ['flat', 'weight', 'price', 'carrier'])->default('flat'); + $table->json('config_json')->default('{}'); + $table->boolean('is_active')->default(true); + + $table->index(['zone_id', 'is_active']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shipping_rates'); + } +}; diff --git a/database/migrations/2026_07_11_101404_create_tax_settings_table.php b/database/migrations/2026_07_11_101404_create_tax_settings_table.php new file mode 100644 index 00000000..f918c713 --- /dev/null +++ b/database/migrations/2026_07_11_101404_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->json('config_json')->default('{}'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tax_settings'); + } +}; diff --git a/database/migrations/2026_07_11_101405_create_discounts_table.php b/database/migrations/2026_07_11_101405_create_discounts_table.php new file mode 100644 index 00000000..d7bd093b --- /dev/null +++ b/database/migrations/2026_07_11_101405_create_discounts_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->enum('type', ['code', 'automatic'])->default('code'); + $table->string('code')->nullable(); + $table->enum('value_type', ['fixed', 'percent', 'free_shipping']); + $table->unsignedInteger('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->json('rules_json')->default('{}'); + $table->enum('status', ['draft', 'active', 'expired', 'disabled'])->default('active'); + $table->timestamps(); + + $table->unique(['store_id', 'code']); + $table->index(['store_id', 'status']); + $table->index(['store_id', 'type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('discounts'); + } +}; diff --git a/database/migrations/2026_07_11_101406_create_carts_table.php b/database/migrations/2026_07_11_101406_create_carts_table.php new file mode 100644 index 00000000..0af0e280 --- /dev/null +++ b/database/migrations/2026_07_11_101406_create_carts_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->string('currency', 3)->default('USD'); + $table->unsignedInteger('cart_version')->default(1); + $table->enum('status', ['active', 'converted', 'abandoned'])->default('active'); + $table->timestamps(); + + $table->index(['store_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('carts'); + } +}; diff --git a/database/migrations/2026_07_11_101407_create_cart_lines_table.php b/database/migrations/2026_07_11_101407_create_cart_lines_table.php new file mode 100644 index 00000000..ca707639 --- /dev/null +++ b/database/migrations/2026_07_11_101407_create_cart_lines_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('cart_id')->constrained()->cascadeOnDelete(); + $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->unsignedInteger('quantity')->default(1); + $table->unsignedInteger('unit_price_amount')->default(0); + $table->unsignedInteger('line_subtotal_amount')->default(0); + $table->unsignedInteger('line_discount_amount')->default(0); + $table->unsignedInteger('line_total_amount')->default(0); + + $table->unique(['cart_id', 'variant_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cart_lines'); + } +}; diff --git a/database/migrations/2026_07_11_101408_create_checkouts_table.php b/database/migrations/2026_07_11_101408_create_checkouts_table.php new file mode 100644 index 00000000..f2dfa367 --- /dev/null +++ b/database/migrations/2026_07_11_101408_create_checkouts_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('cart_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->enum('status', ['started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired'])->default('started'); + $table->enum('payment_method', ['credit_card', 'paypal', 'bank_transfer'])->nullable(); + $table->string('email')->nullable(); + $table->json('shipping_address_json')->nullable(); + $table->json('billing_address_json')->nullable(); + $table->foreignId('shipping_method_id')->nullable()->constrained('shipping_rates')->nullOnDelete(); + $table->string('discount_code')->nullable(); + $table->json('tax_provider_snapshot_json')->nullable(); + $table->json('totals_json')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + + $table->index(['store_id', 'status']); + $table->index('expires_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('checkouts'); + } +}; diff --git a/database/migrations/2026_07_11_101409_create_orders_table.php b/database/migrations/2026_07_11_101409_create_orders_table.php new file mode 100644 index 00000000..376cc206 --- /dev/null +++ b/database/migrations/2026_07_11_101409_create_orders_table.php @@ -0,0 +1,51 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('checkout_id')->nullable()->unique()->constrained()->nullOnDelete(); + $table->string('order_number'); + $table->enum('payment_method', ['credit_card', 'paypal', 'bank_transfer']); + $table->enum('status', ['pending', 'paid', 'fulfilled', 'cancelled', 'refunded'])->default('pending'); + $table->enum('financial_status', ['pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided'])->default('pending'); + $table->enum('fulfillment_status', ['unfulfilled', 'partial', 'fulfilled'])->default('unfulfilled'); + $table->string('currency', 3)->default('USD'); + $table->unsignedInteger('subtotal_amount')->default(0); + $table->unsignedInteger('discount_amount')->default(0); + $table->unsignedInteger('shipping_amount')->default(0); + $table->unsignedInteger('tax_amount')->default(0); + $table->unsignedInteger('total_amount')->default(0); + $table->string('email')->nullable(); + $table->json('billing_address_json')->nullable(); + $table->json('shipping_address_json')->nullable(); + $table->timestamp('placed_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'order_number']); + $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_07_11_101410_create_order_lines_table.php b/database/migrations/2026_07_11_101410_create_order_lines_table.php new file mode 100644 index 00000000..b70ccf2e --- /dev/null +++ b/database/migrations/2026_07_11_101410_create_order_lines_table.php @@ -0,0 +1,39 @@ +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->unsignedInteger('unit_price_amount')->default(0); + $table->unsignedInteger('total_amount')->default(0); + $table->json('tax_lines_json')->default('[]'); + $table->json('discount_allocations_json')->default('[]'); + + $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_07_11_101411_create_payments_table.php b/database/migrations/2026_07_11_101411_create_payments_table.php new file mode 100644 index 00000000..9804c32f --- /dev/null +++ b/database/migrations/2026_07_11_101411_create_payments_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->enum('provider', ['mock'])->default('mock'); + $table->enum('method', ['credit_card', 'paypal', 'bank_transfer']); + $table->string('provider_payment_id')->nullable(); + $table->enum('status', ['pending', 'captured', 'failed', 'refunded'])->default('pending'); + $table->unsignedInteger('amount')->default(0); + $table->string('currency', 3)->default('USD'); + $table->text('raw_json_encrypted')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index(['provider', 'provider_payment_id']); + $table->index('method'); + $table->index('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('payments'); + } +}; diff --git a/database/migrations/2026_07_11_101412_create_fulfillments_table.php b/database/migrations/2026_07_11_101412_create_fulfillments_table.php new file mode 100644 index 00000000..d26abf31 --- /dev/null +++ b/database/migrations/2026_07_11_101412_create_fulfillments_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->enum('status', ['pending', 'shipped', 'delivered'])->default('pending'); + $table->string('tracking_company')->nullable(); + $table->string('tracking_number')->nullable(); + $table->string('tracking_url')->nullable(); + $table->timestamp('shipped_at')->nullable(); + $table->timestamp('delivered_at')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('status'); + $table->index(['tracking_company', 'tracking_number']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('fulfillments'); + } +}; diff --git a/database/migrations/2026_07_11_101413_create_refunds_table.php b/database/migrations/2026_07_11_101413_create_refunds_table.php new file mode 100644 index 00000000..73efd998 --- /dev/null +++ b/database/migrations/2026_07_11_101413_create_refunds_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('payment_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('amount')->default(0); + $table->string('reason')->nullable(); + $table->enum('status', ['pending', 'processed', 'failed'])->default('pending'); + $table->string('provider_refund_id')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('refunds'); + } +}; diff --git a/database/migrations/2026_07_11_101414_create_fulfillment_lines_table.php b/database/migrations/2026_07_11_101414_create_fulfillment_lines_table.php new file mode 100644 index 00000000..371f72c6 --- /dev/null +++ b/database/migrations/2026_07_11_101414_create_fulfillment_lines_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('fulfillment_id')->constrained()->cascadeOnDelete(); + $table->foreignId('order_line_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('quantity')->default(1); + + $table->unique(['fulfillment_id', 'order_line_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('fulfillment_lines'); + } +}; diff --git a/database/migrations/2026_07_11_120000_create_apps_table.php b/database/migrations/2026_07_11_120000_create_apps_table.php new file mode 100644 index 00000000..0605f8c2 --- /dev/null +++ b/database/migrations/2026_07_11_120000_create_apps_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name'); + $table->enum('status', ['active', 'disabled'])->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_07_11_120001_create_themes_table.php b/database/migrations/2026_07_11_120001_create_themes_table.php new file mode 100644 index 00000000..09157280 --- /dev/null +++ b/database/migrations/2026_07_11_120001_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', 'idx_themes_store_id'); + $table->index(['store_id', 'status'], 'idx_themes_store_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('themes'); + } +}; diff --git a/database/migrations/2026_07_11_120002_create_theme_files_table.php b/database/migrations/2026_07_11_120002_create_theme_files_table.php new file mode 100644 index 00000000..1164b53a --- /dev/null +++ b/database/migrations/2026_07_11_120002_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', 64); + $table->unsignedBigInteger('byte_size')->default(0); + + $table->unique(['theme_id', 'path'], 'idx_theme_files_theme_path'); + $table->index('theme_id', 'idx_theme_files_theme_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('theme_files'); + } +}; diff --git a/database/migrations/2026_07_11_120003_create_theme_settings_table.php b/database/migrations/2026_07_11_120003_create_theme_settings_table.php new file mode 100644 index 00000000..74169302 --- /dev/null +++ b/database/migrations/2026_07_11_120003_create_theme_settings_table.php @@ -0,0 +1,28 @@ +foreignId('theme_id')->primary()->constrained()->cascadeOnDelete(); + $table->longText('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_07_11_120004_create_pages_table.php b/database/migrations/2026_07_11_120004_create_pages_table.php new file mode 100644 index 00000000..4986f306 --- /dev/null +++ b/database/migrations/2026_07_11_120004_create_pages_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->longText('body_html')->nullable(); + $table->enum('status', ['draft', 'published', 'archived'])->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'handle'], 'idx_pages_store_handle'); + $table->index('store_id', 'idx_pages_store_id'); + $table->index(['store_id', 'status'], 'idx_pages_store_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('pages'); + } +}; diff --git a/database/migrations/2026_07_11_120005_create_navigation_menus_table.php b/database/migrations/2026_07_11_120005_create_navigation_menus_table.php new file mode 100644 index 00000000..5ce45ab4 --- /dev/null +++ b/database/migrations/2026_07_11_120005_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'], 'idx_navigation_menus_store_handle'); + $table->index('store_id', 'idx_navigation_menus_store_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('navigation_menus'); + } +}; diff --git a/database/migrations/2026_07_11_120006_create_navigation_items_table.php b/database/migrations/2026_07_11_120006_create_navigation_items_table.php new file mode 100644 index 00000000..b995292c --- /dev/null +++ b/database/migrations/2026_07_11_120006_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', 'idx_navigation_items_menu_id'); + $table->index(['menu_id', 'position'], 'idx_navigation_items_menu_position'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('navigation_items'); + } +}; diff --git a/database/migrations/2026_07_11_120007_create_search_settings_table.php b/database/migrations/2026_07_11_120007_create_search_settings_table.php new file mode 100644 index 00000000..98b0f38a --- /dev/null +++ b/database/migrations/2026_07_11_120007_create_search_settings_table.php @@ -0,0 +1,29 @@ +foreignId('store_id')->primary()->constrained()->cascadeOnDelete(); + $table->longText('synonyms_json')->default('[]'); + $table->longText('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_07_11_120008_create_search_queries_table.php b/database/migrations/2026_07_11_120008_create_search_queries_table.php new file mode 100644 index 00000000..f35310bd --- /dev/null +++ b/database/migrations/2026_07_11_120008_create_search_queries_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('query'); + $table->longText('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_07_11_120009_create_products_fts_table.php b/database/migrations/2026_07_11_120009_create_products_fts_table.php new file mode 100644 index 00000000..33ddc2e5 --- /dev/null +++ b/database/migrations/2026_07_11_120009_create_products_fts_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('type'); + $table->string('session_id')->nullable(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->longText('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_07_11_120011_create_analytics_daily_table.php b/database/migrations/2026_07_11_120011_create_analytics_daily_table.php new file mode 100644 index 00000000..b6ccefd5 --- /dev/null +++ b/database/migrations/2026_07_11_120011_create_analytics_daily_table.php @@ -0,0 +1,37 @@ +foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->date('date'); + $table->unsignedInteger('orders_count')->default(0); + $table->unsignedBigInteger('revenue_amount')->default(0); + $table->unsignedBigInteger('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_07_11_120012_create_app_installations_table.php b/database/migrations/2026_07_11_120012_create_app_installations_table.php new file mode 100644 index 00000000..52c3232c --- /dev/null +++ b/database/migrations/2026_07_11_120012_create_app_installations_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('app_id')->constrained()->cascadeOnDelete(); + $table->longText('scopes_json')->default('[]'); + $table->enum('status', ['active', 'suspended', 'uninstalled'])->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_07_11_120013_create_oauth_clients_table.php b/database/migrations/2026_07_11_120013_create_oauth_clients_table.php new file mode 100644 index 00000000..edb307a3 --- /dev/null +++ b/database/migrations/2026_07_11_120013_create_oauth_clients_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('app_id')->constrained()->cascadeOnDelete(); + $table->string('client_id'); + $table->text('client_secret_encrypted'); + $table->longText('redirect_uris_json')->default('[]'); + + $table->unique('client_id', 'idx_oauth_clients_client_id'); + $table->index('app_id', 'idx_oauth_clients_app_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_clients'); + } +}; diff --git a/database/migrations/2026_07_11_120014_create_oauth_tokens_table.php b/database/migrations/2026_07_11_120014_create_oauth_tokens_table.php new file mode 100644 index 00000000..d84781a8 --- /dev/null +++ b/database/migrations/2026_07_11_120014_create_oauth_tokens_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('installation_id')->constrained('app_installations')->cascadeOnDelete(); + $table->string('access_token_hash'); + $table->string('refresh_token_hash')->nullable(); + $table->timestamp('expires_at'); + + $table->index('installation_id', 'idx_oauth_tokens_installation_id'); + $table->unique('access_token_hash', 'idx_oauth_tokens_access_hash'); + $table->index('expires_at', 'idx_oauth_tokens_expires_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_tokens'); + } +}; diff --git a/database/migrations/2026_07_11_120015_create_webhook_subscriptions_table.php b/database/migrations/2026_07_11_120015_create_webhook_subscriptions_table.php new file mode 100644 index 00000000..306cfacc --- /dev/null +++ b/database/migrations/2026_07_11_120015_create_webhook_subscriptions_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('app_installation_id')->nullable()->constrained()->cascadeOnDelete(); + $table->string('event_type'); + $table->string('target_url'); + $table->text('signing_secret_encrypted'); + $table->enum('status', ['active', 'paused', 'disabled'])->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_07_11_120016_create_webhook_deliveries_table.php b/database/migrations/2026_07_11_120016_create_webhook_deliveries_table.php new file mode 100644 index 00000000..c3881f7f --- /dev/null +++ b/database/migrations/2026_07_11_120016_create_webhook_deliveries_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('subscription_id')->constrained('webhook_subscriptions')->cascadeOnDelete(); + $table->string('event_id'); + $table->unsignedInteger('attempt_count')->default(1); + $table->enum('status', ['pending', 'success', 'failed'])->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/seeders/AnalyticsSeeder.php b/database/seeders/AnalyticsSeeder.php new file mode 100644 index 00000000..25134ab8 --- /dev/null +++ b/database/seeders/AnalyticsSeeder.php @@ -0,0 +1,94 @@ +where('handle', 'acme-fashion')->sole(); + $this->seedDaily($store); + $this->seedEvents($store); + }); + } + + private function seedDaily(Store $store): void + { + $table = (new AnalyticsDaily)->getTable(); + for ($daysAgo = 30; $daysAgo >= 0; $daysAgo--) { + $growth = 1 + (30 - $daysAgo) * 0.03; + $visits = $daysAgo === 0 ? 96 : (int) round((60 + (($daysAgo * 17) % 41)) * $growth); + $addToCart = (int) round($visits * (18 + ($daysAgo % 8)) / 100); + $checkoutStarted = (int) round($addToCart * (40 + ($daysAgo % 16)) / 100); + $orders = $daysAgo === 0 ? 3 : max(2, (int) round($checkoutStarted * (35 + ($daysAgo % 21)) / 100)); + $aov = 4000 + (($daysAgo * 347) % 5001); + + DB::table($table)->updateOrInsert( + ['store_id' => $store->id, 'date' => now()->subDays($daysAgo)->toDateString()], + [ + 'orders_count' => $orders, + 'revenue_amount' => $orders * $aov, + 'aov_amount' => $aov, + 'visits_count' => $visits, + 'add_to_cart_count' => $addToCart, + 'checkout_started_count' => $checkoutStarted, + 'checkout_completed_count' => $orders, + ], + ); + } + } + + private function seedEvents(Store $store): void + { + $customers = Customer::withoutGlobalScopes()->where('store_id', $store->id)->orderBy('id')->get(); + $products = Product::withoutGlobalScopes()->where('store_id', $store->id)->where('status', 'active')->with('variants')->get(); + $orders = Order::withoutGlobalScopes()->where('store_id', $store->id)->orderBy('id')->get(); + $types = [ + ...array_fill(0, 88, 'page_view'), ...array_fill(0, 55, 'product_view'), + ...array_fill(0, 33, 'add_to_cart'), ...array_fill(0, 22, 'checkout_started'), + ...array_fill(0, 11, 'checkout_completed'), ...array_fill(0, 11, 'search'), + ]; + $table = (new AnalyticsEvent)->getTable(); + + foreach ($types as $index => $type) { + $product = $products[$index % $products->count()]; + $variant = $product->variants[$index % $product->variants->count()]; + $order = $orders[$index % $orders->count()]; + $occurredAt = now()->subDays($index % 7)->subMinutes(($index * 19) % 1440); + DB::table($table)->updateOrInsert( + ['store_id' => $store->id, 'client_event_id' => 'seed-event-'.str_pad((string) ($index + 1), 3, '0', STR_PAD_LEFT)], + [ + 'type' => $type, + 'session_id' => 'seed-session-'.str_pad((string) (($index % 35) + 1), 2, '0', STR_PAD_LEFT), + 'customer_id' => $index % 10 < 3 ? $customers[$index % $customers->count()]->id : null, + 'properties_json' => json_encode($this->properties($type, $product, $variant, $order), JSON_THROW_ON_ERROR), + 'occurred_at' => $occurredAt, + 'created_at' => $occurredAt, + ], + ); + } + } + + /** @return array */ + private function properties(string $type, Product $product, mixed $variant, Order $order): array + { + return match ($type) { + 'page_view' => ['url' => '/', 'referrer' => 'https://www.google.com'], + 'product_view' => ['product_id' => $product->id, 'product_title' => $product->title, 'url' => '/products/'.$product->handle], + 'add_to_cart' => ['product_id' => $product->id, 'variant_id' => $variant->id, 'quantity' => 1, 'price_amount' => $variant->price_amount], + 'checkout_started' => ['cart_id' => 'seed-cart-'.$order->id, 'item_count' => 1, 'cart_total' => $order->subtotal_amount], + 'checkout_completed' => ['order_id' => $order->id, 'order_number' => $order->order_number, 'total_amount' => $order->total_amount], + 'search' => ['query' => ['cotton t-shirt', 'jeans', 'gift card'][$order->id % 3], 'results_count' => 5], + }; + } +} diff --git a/database/seeders/CollectionSeeder.php b/database/seeders/CollectionSeeder.php new file mode 100644 index 00000000..12ad0dd5 --- /dev/null +++ b/database/seeders/CollectionSeeder.php @@ -0,0 +1,41 @@ + [ + ['New Arrivals', 'new-arrivals', '

Discover the latest additions to our store.

'], + ['T-Shirts', 't-shirts', '

Premium cotton tees for every occasion.

'], + ['Pants & Jeans', 'pants-jeans', '

Find the perfect fit from our denim and trouser range.

'], + ['Sale', 'sale', '

Great deals on selected items.

'], + ], + 'acme-electronics' => [ + ['Featured', 'featured', null], + ['Accessories', 'accessories', null], + ], + ]; + foreach ($data as $handle => $collections) { + $store = Store::query()->where('handle', $handle)->sole(); + foreach ($collections as [$title, $collectionHandle, $description]) { + Collection::query()->updateOrCreate( + ['store_id' => $store->id, 'handle' => $collectionHandle], + ['title' => $title, 'description_html' => $description, 'type' => 'manual', 'status' => 'active'], + ); + } + } + }); + } +} diff --git a/database/seeders/CustomerSeeder.php b/database/seeders/CustomerSeeder.php new file mode 100644 index 00000000..e65b4b11 --- /dev/null +++ b/database/seeders/CustomerSeeder.php @@ -0,0 +1,80 @@ +passwordHash = Hash::make('password'); + $fashion = Store::query()->where('handle', 'acme-fashion')->sole(); + $electronics = Store::query()->where('handle', 'acme-electronics')->sole(); + $fashionCustomers = [ + ['customer@acme.test', 'John Doe', true], ['jane@example.com', 'Jane Smith', false], + ['michael@example.com', 'Michael Brown', true], ['sarah@example.com', 'Sarah Wilson', false], + ['david@example.com', 'David Lee', true], ['emma@example.com', 'Emma Garcia', false], + ['james@example.com', 'James Taylor', false], ['lisa@example.com', 'Lisa Anderson', true], + ['robert@example.com', 'Robert Martinez', false], ['anna@example.com', 'Anna Thomas', true], + ]; + foreach ($fashionCustomers as $index => [$email, $name, $marketingOptIn]) { + $customer = $this->customer($fashion->id, $email, $name, $marketingOptIn); + if ($email === 'customer@acme.test') { + $this->address($customer, 'Home', true, $this->addressData('John', 'Doe', 'Hauptstrasse 1', 'Berlin', '10115', '+49 30 12345678')); + $this->address($customer, 'Work', false, $this->addressData('John', 'Doe', 'Friedrichstrasse 100', 'Berlin', '10117', '+49 30 87654321', 'Acme Corp', '3rd Floor')); + } elseif ($email === 'jane@example.com') { + $this->address($customer, 'Home', true, $this->addressData('Jane', 'Smith', 'Schillerstrasse 45', 'Munich', '80336', '', '', '', 'Bavaria', 'BY')); + } else { + [$firstName, $lastName] = explode(' ', $name, 2); + $this->address($customer, 'Home', true, $this->addressData($firstName, $lastName, 'Musterstrasse '.($index + 10), 'Berlin', '101'.str_pad((string) $index, 2, '0', STR_PAD_LEFT))); + } + } + + foreach ([['techfan@example.com', 'Tech Fan'], ['gadgetlover@example.com', 'Gadget Lover']] as $index => [$email, $name]) { + $customer = $this->customer($electronics->id, $email, $name, false); + [$firstName, $lastName] = explode(' ', $name, 2); + $this->address($customer, 'Home', true, $this->addressData($firstName, $lastName, 'Technikstrasse '.($index + 1), 'Berlin', '1024'.($index + 3))); + } + }); + } + + private function customer(int $storeId, string $email, string $name, bool $marketingOptIn): Customer + { + return Customer::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $storeId, 'email' => $email], + ['name' => $name, 'password_hash' => $this->passwordHash, 'marketing_opt_in' => $marketingOptIn], + ); + } + + /** @param array $address */ + private function address(Customer $customer, string $label, bool $isDefault, array $address): void + { + CustomerAddress::query()->updateOrCreate( + ['customer_id' => $customer->id, 'label' => $label], + ['address_json' => $address, 'is_default' => $isDefault], + ); + } + + /** @return array */ + private function addressData(string $firstName, string $lastName, string $address1, string $city, string $zip, string $phone = '', string $company = '', string $address2 = '', string $province = '', string $provinceCode = ''): array + { + return [ + 'first_name' => $firstName, 'last_name' => $lastName, 'company' => $company, + 'address1' => $address1, 'address2' => $address2, 'city' => $city, + 'province' => $province, 'province_code' => $provinceCode, 'country' => 'Germany', + 'country_code' => 'DE', 'zip' => $zip, 'phone' => $phone, + ]; + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef2..a2b153f9 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,22 +2,31 @@ namespace Database\Seeders; -use App\Models\User; -// use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder { - /** - * Seed the application's database. - */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', + $this->call([ + OrganizationSeeder::class, + StoreSeeder::class, + StoreDomainSeeder::class, + UserSeeder::class, + StoreUserSeeder::class, + StoreSettingsSeeder::class, + TaxSettingsSeeder::class, + ShippingSeeder::class, + CollectionSeeder::class, + ProductSeeder::class, + DiscountSeeder::class, + CustomerSeeder::class, + OrderSeeder::class, + ThemeSeeder::class, + PageSeeder::class, + NavigationSeeder::class, + AnalyticsSeeder::class, + SearchSettingsSeeder::class, ]); } } diff --git a/database/seeders/DiscountSeeder.php b/database/seeders/DiscountSeeder.php new file mode 100644 index 00000000..8586fdd7 --- /dev/null +++ b/database/seeders/DiscountSeeder.php @@ -0,0 +1,43 @@ +where('handle', 'acme-fashion')->sole(); + foreach ([ + ['WELCOME10', 'percent', 10, '2025-01-01', '2027-12-31', null, 3, ['min_purchase_amount' => 2000], 'active'], + ['FLAT5', 'fixed', 500, '2025-01-01', '2027-12-31', null, 0, [], 'active'], + ['FREESHIP', 'free_shipping', 0, '2025-01-01', '2027-12-31', null, 1, [], 'active'], + ['EXPIRED20', 'percent', 20, '2024-01-01', '2024-12-31', null, 0, [], 'expired'], + ['MAXED', 'percent', 10, '2025-01-01', '2027-12-31', 5, 5, [], 'active'], + ] as [$code, $valueType, $valueAmount, $startsAt, $endsAt, $usageLimit, $usageCount, $rules, $status]) { + Discount::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->id, 'code' => $code], + [ + 'type' => 'code', + 'value_type' => $valueType, + 'value_amount' => $valueAmount, + 'starts_at' => $startsAt, + 'ends_at' => $endsAt, + 'usage_limit' => $usageLimit, + 'usage_count' => $usageCount, + 'rules_json' => $rules, + 'status' => $status, + ], + ); + } + }); + } +} diff --git a/database/seeders/NavigationSeeder.php b/database/seeders/NavigationSeeder.php new file mode 100644 index 00000000..b86286de --- /dev/null +++ b/database/seeders/NavigationSeeder.php @@ -0,0 +1,50 @@ +where('handle', 'acme-fashion')->sole(); + $electronics = Store::query()->where('handle', 'acme-electronics')->sole(); + $this->menu($fashion, 'main-menu', 'Main Menu', [ + ['Home', 'link', '/', null], + ...collect(['new-arrivals' => 'New Arrivals', 't-shirts' => 'T-Shirts', 'pants-jeans' => 'Pants & Jeans', 'sale' => 'Sale']) + ->map(fn (string $label, string $handle): array => [$label, 'collection', null, Collection::withoutGlobalScopes()->where('store_id', $fashion->id)->where('handle', $handle)->valueOrFail('id')])->values()->all(), + ]); + $this->menu($fashion, 'footer-menu', 'Footer Menu', collect([ + 'about' => 'About Us', 'faq' => 'FAQ', 'shipping-returns' => 'Shipping & Returns', 'privacy-policy' => 'Privacy Policy', 'terms' => 'Terms of Service', + ])->map(fn (string $label, string $handle): array => [$label, 'page', null, Page::withoutGlobalScopes()->where('store_id', $fashion->id)->where('handle', $handle)->valueOrFail('id')])->values()->all()); + $this->menu($electronics, 'main-menu', 'Main Menu', [ + ['Home', 'link', '/', null], + ...collect(['featured' => 'Featured', 'accessories' => 'Accessories']) + ->map(fn (string $label, string $handle): array => [$label, 'collection', null, Collection::withoutGlobalScopes()->where('store_id', $electronics->id)->where('handle', $handle)->valueOrFail('id')])->values()->all(), + ]); + }); + } + + /** @param list $items */ + private function menu(Store $store, string $handle, string $title, array $items): void + { + $menu = NavigationMenu::withoutGlobalScopes()->updateOrCreate(['store_id' => $store->id, 'handle' => $handle], ['title' => $title]); + foreach ($items as $position => [$label, $type, $url, $resourceId]) { + NavigationItem::query()->updateOrCreate( + ['menu_id' => $menu->id, 'position' => $position], + ['label' => $label, 'type' => $type, 'url' => $url, 'resource_id' => $resourceId], + ); + } + } +} diff --git a/database/seeders/OrderSeeder.php b/database/seeders/OrderSeeder.php new file mode 100644 index 00000000..15ebd41d --- /dev/null +++ b/database/seeders/OrderSeeder.php @@ -0,0 +1,172 @@ +where('handle', 'acme-fashion')->sole(); + foreach ($this->fashionOrders() as $definition) { + $this->seedOrder($fashion, $definition); + } + + $electronics = Store::query()->where('handle', 'acme-electronics')->sole(); + foreach ($this->electronicsOrders() as $definition) { + $this->seedOrder($electronics, $definition); + } + }); + } + + /** @param array $definition */ + private function seedOrder(Store $store, array $definition): void + { + $customer = Customer::withoutGlobalScopes()->where('store_id', $store->id)->where('email', $definition['email'])->sole(); + $address = CustomerAddress::query()->where('customer_id', $customer->id)->where('is_default', true)->valueOrFail('address_json'); + $order = Order::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->id, 'order_number' => $definition['number']], + [ + 'customer_id' => $customer->id, + 'payment_method' => $definition['method'], + 'status' => $definition['status'], + 'financial_status' => $definition['financial'], + 'fulfillment_status' => $definition['fulfillment'], + 'currency' => 'EUR', + 'subtotal_amount' => $definition['subtotal'], + 'discount_amount' => $definition['discount'] ?? 0, + 'shipping_amount' => $definition['shipping'], + 'tax_amount' => $definition['tax'], + 'total_amount' => $definition['total'], + 'email' => $customer->email, + 'billing_address_json' => $address, + 'shipping_address_json' => $address, + 'placed_at' => $definition['placed_at'], + ], + ); + + $lines = []; + foreach ($definition['lines'] as $lineDefinition) { + $product = Product::withoutGlobalScopes()->where('store_id', $store->id)->where('handle', $lineDefinition[0])->sole(); + $variant = $this->variant($product, $lineDefinition[1]); + $allocations = []; + if (isset($lineDefinition[4])) { + $discount = Discount::withoutGlobalScopes()->where('store_id', $store->id)->where('code', 'WELCOME10')->sole(); + $allocations[] = ['discount_id' => $discount->id, 'amount' => $lineDefinition[4]]; + } + $line = OrderLine::query()->updateOrCreate( + ['order_id' => $order->id, 'product_id' => $product->id, 'variant_id' => $variant->id], + [ + 'title_snapshot' => $product->title, + 'sku_snapshot' => $variant->sku, + 'quantity' => $lineDefinition[2], + 'unit_price_amount' => $lineDefinition[3], + 'total_amount' => $lineDefinition[2] * $lineDefinition[3], + 'tax_lines_json' => [['title' => 'VAT', 'rate_bps' => 1900]], + 'discount_allocations_json' => $allocations, + ], + ); + + if ($definition['method'] === 'bank_transfer' && $definition['financial'] === 'pending') { + $variant->inventoryItem()->update(['quantity_reserved' => $lineDefinition[2]]); + } + + $lines[] = $line; + } + + $payment = Payment::query()->updateOrCreate( + ['order_id' => $order->id, 'provider_payment_id' => 'mock_test_order'.ltrim($definition['number'], '#')], + ['provider' => 'mock', 'method' => $definition['method'], 'status' => $definition['payment_status'], 'amount' => $definition['total'], 'currency' => 'EUR', 'raw_json_encrypted' => ['seeded' => true]], + ); + + if (isset($definition['refund'])) { + Refund::query()->updateOrCreate( + ['order_id' => $order->id, 'provider_refund_id' => 'mock_re_test_order'.ltrim($definition['number'], '#')], + ['payment_id' => $payment->id, 'amount' => $definition['refund'][0], 'reason' => $definition['refund'][1], 'status' => 'processed'], + ); + } + + if (isset($definition['shipment'])) { + [$shipmentStatus, $company, $tracking, $shippedAt, $deliveredAt, $fulfilledLineIndexes] = $definition['shipment']; + $fulfillment = Fulfillment::query()->updateOrCreate( + ['order_id' => $order->id, 'tracking_number' => $tracking], + ['status' => $shipmentStatus, 'tracking_company' => $company, 'tracking_url' => $tracking ? 'https://tracking.example/'.$tracking : null, 'shipped_at' => $shippedAt, 'delivered_at' => $deliveredAt], + ); + foreach ($fulfilledLineIndexes as $lineIndex) { + FulfillmentLine::query()->updateOrCreate( + ['fulfillment_id' => $fulfillment->id, 'order_line_id' => $lines[$lineIndex]->id], + ['quantity' => $lines[$lineIndex]->quantity], + ); + } + } + } + + /** @param list $values */ + private function variant(Product $product, array $values): ProductVariant + { + if ($values === []) { + return $product->variants()->where('is_default', true)->sole(); + } + + return $product->variants() + ->whereHas('optionValues', fn (Builder $query): Builder => $query->whereIn('value', $values), '=', count($values)) + ->sole(); + } + + /** @return list> */ + private function fashionOrders(): array + { + return [ + $this->order('#1001', 'customer@acme.test', 'credit_card', 'paid', 'paid', 'unfulfilled', 4998, 499, 798, 5497, now()->subDays(2), [['classic-cotton-t-shirt', ['S', 'White'], 2, 2499]]), + $this->order('#1002', 'customer@acme.test', 'credit_card', 'fulfilled', 'paid', 'fulfilled', 8498, 499, 1357, 8997, now()->subDays(10), [['organic-hoodie', ['M'], 1, 5999], ['classic-cotton-t-shirt', ['L', 'Black'], 1, 2499]], ['shipment' => ['delivered', 'DHL', 'DHL1234567890', now()->subDays(8), now()->subDays(6), [0, 1]]]), + $this->order('#1003', 'jane@example.com', 'credit_card', 'paid', 'paid', 'partial', 11498, 499, 1836, 11997, now()->subDays(5), [['premium-slim-fit-jeans', ['32', 'Blue'], 1, 7999], ['leather-belt', ['L/XL', 'Brown'], 1, 3499]], ['shipment' => ['shipped', 'DHL', 'DHL9876543210', now()->subDays(3), null, [0]]]), + $this->order('#1004', 'customer@acme.test', 'credit_card', 'cancelled', 'refunded', 'unfulfilled', 2499, 499, 399, 2998, now()->subDays(15), [['classic-cotton-t-shirt', ['M', 'Navy'], 1, 2499]], ['payment_status' => 'refunded', 'refund' => [2998, 'Customer requested cancellation']]), + $this->order('#1005', 'jane@example.com', 'bank_transfer', 'pending', 'pending', 'unfulfilled', 3499, 499, 559, 3998, now()->subHours(2), [['leather-belt', ['S/M', 'Black'], 1, 3499]], ['payment_status' => 'pending']), + $this->order('#1006', 'michael@example.com', 'credit_card', 'paid', 'paid', 'unfulfilled', 11999, 499, 1916, 12498, now()->subDay(), [['running-sneakers', ['EU 42', 'Black'], 1, 11999]]), + $this->order('#1007', 'sarah@example.com', 'paypal', 'fulfilled', 'paid', 'fulfilled', 9997, 499, 1596, 10496, now()->subDays(20), [['v-neck-linen-tee', ['M', 'Beige'], 2, 3499], ['wool-scarf', ['Grey'], 1, 2999]], ['shipment' => ['delivered', 'DHL', 'DHL1112223334', now()->subDays(18), now()->subDays(16), [0, 1]]]), + $this->order('#1008', 'david@example.com', 'credit_card', 'paid', 'partially_refunded', 'fulfilled', 8498, 499, 1357, 8997, now()->subDays(12), [['cargo-pants', ['32', 'Khaki'], 1, 5499], ['graphic-print-tee', ['L'], 1, 2999]], ['refund' => [2999, 'Item returned'], 'shipment' => ['delivered', 'UPS', 'UPS5556667778', now()->subDays(10), now()->subDays(8), [0, 1]]]), + $this->order('#1009', 'emma@example.com', 'credit_card', 'paid', 'paid', 'unfulfilled', 4498, 499, 718, 4997, now()->subDays(3), [['canvas-tote-bag', ['Natural'], 1, 1999], ['bucket-hat', ['S/M', 'Black'], 1, 2499]]), + $this->order('#1010', 'customer@acme.test', 'paypal', 'paid', 'paid', 'unfulfilled', 49999, 499, 7983, 50498, now()->subDay(), [['cashmere-overcoat', ['M', 'Camel'], 1, 49999]]), + $this->order('#1011', 'james@example.com', 'credit_card', 'paid', 'paid', 'fulfilled', 2799, 499, 447, 3298, now()->subDays(25), [['striped-polo-shirt', ['XL'], 1, 2799]], ['shipment' => ['delivered', 'FedEx', 'FX9998887776', now()->subDays(23), now()->subDays(21), [0]]]), + $this->order('#1012', 'lisa@example.com', 'credit_card', 'paid', 'paid', 'unfulfilled', 7998, 499, 1277, 8497, now()->subDays(4), [['chino-shorts', ['34', 'Navy'], 2, 3999]]), + $this->order('#1013', 'robert@example.com', 'credit_card', 'paid', 'paid', 'unfulfilled', 7998, 499, 1277, 8497, now()->subDay(), [['wide-leg-trousers', ['M'], 1, 4999], ['wool-scarf', ['Burgundy'], 1, 2999]]), + $this->order('#1014', 'anna@example.com', 'credit_card', 'paid', 'paid', 'fulfilled', 5000, 0, 798, 5000, now()->subDays(14), [['gift-card', ['50 EUR'], 1, 5000]], ['shipment' => ['delivered', null, null, now()->subDays(14), now()->subDays(14), [0]]]), + $this->order('#1015', 'customer@acme.test', 'bank_transfer', 'paid', 'paid', 'unfulfilled', 5498, 499, 790, 5447, now(), [['classic-cotton-t-shirt', ['M', 'White'], 1, 2499, 250], ['graphic-print-tee', ['M'], 1, 2999, 300]], ['discount' => 550]), + ]; + } + + /** @return list> */ + private function electronicsOrders(): array + { + return [ + $this->order('#5001', 'techfan@example.com', 'credit_card', 'fulfilled', 'paid', 'fulfilled', 121298, 0, 19368, 121298, now()->subDays(6), [['pro-laptop-15', ['512GB'], 1, 119999], ['usb-c-cable-2m', [], 1, 1299]], ['shipment' => ['delivered', 'DHL', 'DHL5001000001', now()->subDays(5), now()->subDays(3), [0, 1]]]), + $this->order('#5002', 'gadgetlover@example.com', 'credit_card', 'paid', 'paid', 'unfulfilled', 14999, 0, 2395, 14999, now()->subDay(), [['wireless-headphones', ['Black'], 1, 14999]]), + $this->order('#5003', 'techfan@example.com', 'bank_transfer', 'pending', 'pending', 'unfulfilled', 4999, 0, 798, 4999, now()->subHours(3), [['monitor-stand', [], 1, 4999]], ['payment_status' => 'pending']), + ]; + } + + /** @param list> $lines + * @param array $extra + * @return array + */ + private function order(string $number, string $email, string $method, string $status, string $financial, string $fulfillment, int $subtotal, int $shipping, int $tax, int $total, mixed $placedAt, array $lines, array $extra = []): array + { + return [...compact('number', 'email', 'method', 'status', 'financial', 'fulfillment', 'subtotal', 'shipping', 'tax', 'total', 'lines'), 'placed_at' => $placedAt, 'payment_status' => 'captured', 'discount' => 0, ...$extra]; + } +} diff --git a/database/seeders/OrganizationSeeder.php b/database/seeders/OrganizationSeeder.php new file mode 100644 index 00000000..e8ba20ef --- /dev/null +++ b/database/seeders/OrganizationSeeder.php @@ -0,0 +1,21 @@ + Organization::query()->updateOrCreate( + ['billing_email' => 'billing@acme.test'], + ['name' => 'Acme Corp'], + )); + } +} diff --git a/database/seeders/PageSeeder.php b/database/seeders/PageSeeder.php new file mode 100644 index 00000000..40685f8c --- /dev/null +++ b/database/seeders/PageSeeder.php @@ -0,0 +1,34 @@ +where('handle', 'acme-fashion')->sole(); + $pages = [ + ['About Us', 'about', '

Our Story

Acme Fashion creates modern essentials designed in Berlin.

Our Values

We believe in ethical sourcing, sustainability, and fair labor.

Our Team

Our Berlin-based designers create thoughtful, long-lasting pieces.

'], + ['FAQ', 'faq', '

Frequently Asked Questions

How long does shipping take?

Germany standard shipping takes 2-4 days, express 1-2 days, and EU shipping 5-7 days.

What is your return policy?

Returns are accepted within 30 days for unworn items in original packaging.

Do you ship internationally?

We ship across the EU and to the US, UK, Canada, and Australia.

How can I track my order?

We email a tracking number after shipment.

'], + ['Shipping & Returns', 'shipping-returns', '

Shipping & Returns

Shipping rates

  • Germany Standard: 4.99 EUR
  • Germany Express: 9.99 EUR
  • EU: 8.99 EUR
  • International: 14.99 EUR

Returns

Return unworn products within 30 days. Customers pay return shipping unless an item is defective.

'], + ['Privacy Policy', 'privacy-policy', '

Privacy Policy

Information We Collect

We collect information needed to process orders.

How We Use Your Information

We use your information to provide and improve our services.

Cookies

Cookies keep the storefront secure and functional.

Contact

Contact privacy@acme-fashion.test.

'], + ['Terms of Service', 'terms', '

Terms of Service

Orders and Payments

Orders are paid in EUR and prices include tax.

Product Descriptions

Screen settings may cause slight color variance.

Limitation of Liability

Liability is limited as permitted by law.

Governing Law

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

'], + ]; + foreach ($pages as [$title, $handle, $body]) { + Page::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->id, 'handle' => $handle], + ['title' => $title, 'body_html' => $body, 'status' => 'published', 'published_at' => now()->subMonths(3)], + ); + } + }); + } +} diff --git a/database/seeders/ProductSeeder.php b/database/seeders/ProductSeeder.php new file mode 100644 index 00000000..82e4eef2 --- /dev/null +++ b/database/seeders/ProductSeeder.php @@ -0,0 +1,207 @@ +where('handle', 'acme-fashion')->sole(); + $electronics = Store::query()->where('handle', 'acme-electronics')->sole(); + + foreach ($this->fashionProducts() as $definition) { + $this->seedProduct($fashion, $definition); + } + + foreach ($this->electronicsProducts() as $definition) { + $this->seedProduct($electronics, $definition); + } + }); + } + + /** @param array $definition */ + private function seedProduct(Store $store, array $definition): void + { + $product = Product::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->id, 'handle' => $definition['handle']], + [ + 'title' => $definition['title'], + 'status' => $definition['status'] ?? 'active', + 'description_html' => '

'.$definition['description'].'

', + 'vendor' => $definition['vendor'], + 'product_type' => $definition['type'], + 'tags' => $definition['tags'], + 'published_at' => ($definition['status'] ?? 'active') === 'draft' ? null : ($definition['published_at'] ?? now()), + ], + ); + + $optionValueIds = []; + foreach ($definition['options'] as $optionPosition => $optionDefinition) { + $option = ProductOption::query()->updateOrCreate( + ['product_id' => $product->id, 'position' => $optionPosition], + ['name' => $optionDefinition[0]], + ); + foreach ($optionDefinition[1] as $valuePosition => $value) { + $optionValue = ProductOptionValue::query()->updateOrCreate( + ['product_option_id' => $option->id, 'position' => $valuePosition], + ['value' => $value], + ); + $optionValueIds[$optionPosition][$valuePosition] = $optionValue->id; + } + } + + $combinations = $this->combinations(array_map(fn (array $option): array => $option[1], $definition['options'])); + if ($combinations === []) { + $combinations = [[]]; + } + + foreach ($combinations as $position => $combination) { + $price = is_array($definition['price']) ? $definition['price'][$position] : $definition['price']; + $sku = $definition['skus'][$position] ?? $this->sku($definition['handle'], $combination, $position); + $variant = ProductVariant::query()->updateOrCreate( + ['product_id' => $product->id, 'position' => $position], + [ + 'sku' => $sku, + 'barcode' => null, + 'price_amount' => $price, + 'compare_at_amount' => $definition['compare_at'] ?? null, + 'currency' => 'EUR', + 'weight_g' => $definition['weight'], + 'requires_shipping' => $definition['shipping'] ?? true, + 'is_default' => $position === 0, + 'status' => 'active', + ], + ); + + $variant->optionValues()->sync(collect($combination)->keys()->map( + fn (int $optionPosition): int => $optionValueIds[$optionPosition][array_search($combination[$optionPosition], $definition['options'][$optionPosition][1], true)], + )->all()); + + InventoryItem::withoutGlobalScopes()->updateOrCreate( + ['variant_id' => $variant->id], + ['store_id' => $store->id, 'quantity_on_hand' => $definition['inventory'], 'quantity_reserved' => 0, 'policy' => $definition['policy'] ?? 'deny'], + ); + } + + $collections = Collection::withoutGlobalScopes() + ->where('store_id', $store->id) + ->whereIn('handle', $definition['collections']) + ->get(); + foreach ($collections as $collection) { + $assignments = $this->collectionAssignments($store, $collection->handle); + $position = array_search($product->handle, $assignments, true); + if ($position !== false) { + $collection->products()->syncWithoutDetaching([$product->id => ['position' => $position]]); + } + } + } + + /** @param list}> $options + * @return list> + */ + private function combinations(array $options): array + { + $combinations = [[]]; + foreach ($options as $option) { + $next = []; + foreach ($combinations as $combination) { + foreach ($option as $value) { + $next[] = [...$combination, $value]; + } + } + $combinations = $next; + } + + return $options === [] ? [] : $combinations; + } + + /** @param list $combination */ + private function sku(string $handle, array $combination, int $position): string + { + $suffix = $combination === [] ? 'DEFAULT' : collect($combination)->map( + fn (string $value): string => Str::upper(Str::of($value)->replaceMatches('/[^A-Za-z0-9]/', '')->substr(0, 5)->toString()), + )->implode('-'); + + return 'ACME-'.Str::upper(Str::of($handle)->replace('-', '')->substr(0, 8)->toString()).'-'.$suffix.'-'.($position + 1); + } + + /** @return list */ + private function collectionAssignments(Store $store, string $handle): array + { + $fashion = [ + 'new-arrivals' => ['classic-cotton-t-shirt', 'premium-slim-fit-jeans', 'organic-hoodie', 'running-sneakers', 'chino-shorts', 'bucket-hat', 'cashmere-overcoat'], + 't-shirts' => ['classic-cotton-t-shirt', 'graphic-print-tee', 'v-neck-linen-tee', 'striped-polo-shirt'], + 'pants-jeans' => ['premium-slim-fit-jeans', 'cargo-pants', 'chino-shorts', 'wide-leg-trousers'], + 'sale' => ['premium-slim-fit-jeans', 'striped-polo-shirt', 'wide-leg-trousers'], + ]; + $electronics = [ + 'featured' => ['pro-laptop-15', 'wireless-headphones', 'mechanical-keyboard'], + 'accessories' => ['usb-c-cable-2m', 'monitor-stand'], + ]; + + return ($store->handle === 'acme-fashion' ? $fashion : $electronics)[$handle] ?? []; + } + + /** @return list> */ + private function fashionProducts(): array + { + return [ + $this->product('Classic Cotton T-Shirt', 'classic-cotton-t-shirt', 'Acme Basics', 'T-Shirts', ['new', 'popular'], 'A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.', [['Size', ['S', 'M', 'L', 'XL']], ['Color', ['White', 'Black', 'Navy']]], 2499, 200, 15, ['new-arrivals', 't-shirts']), + $this->product('Premium Slim Fit Jeans', 'premium-slim-fit-jeans', 'Acme Denim', 'Pants', ['new', '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']]], 7999, 800, 8, ['new-arrivals', 'pants-jeans', 'sale'], ['compare_at' => 9999]), + $this->product('Organic Hoodie', 'organic-hoodie', 'Acme Basics', 'Hoodies', ['new', 'trending'], 'Made from 100% organic cotton. Warm, soft, and sustainably produced.', [['Size', ['S', 'M', 'L', 'XL']]], 5999, 500, 20, ['new-arrivals']), + $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']]], 3499, 150, 25), + $this->product('Running Sneakers', 'running-sneakers', 'Acme Sport', 'Shoes', ['trending'], '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']]], 11999, 600, 5, ['new-arrivals']), + $this->product('Graphic Print Tee', 'graphic-print-tee', 'Acme Basics', 'T-Shirts', ['new'], 'Bold graphic print on soft cotton. Express yourself with this statement piece.', [['Size', ['S', 'M', 'L', 'XL']]], 2999, 210, 18, ['t-shirts']), + $this->product('V-Neck Linen Tee', 'v-neck-linen-tee', 'Acme Basics', 'T-Shirts', ['popular'], 'Lightweight linen blend v-neck. Perfect for warm summer days.', [['Size', ['S', 'M', 'L']], ['Color', ['Beige', 'Olive', 'Sky Blue']]], 3499, 180, 12, ['t-shirts']), + $this->product('Striped Polo Shirt', 'striped-polo-shirt', 'Acme Basics', 'T-Shirts', ['sale'], 'Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.', [['Size', ['S', 'M', 'L', 'XL']]], 2799, 250, 10, ['t-shirts', 'sale'], ['compare_at' => 3999]), + $this->product('Cargo Pants', 'cargo-pants', 'Acme Workwear', 'Pants', ['popular'], 'Utility cargo pants with multiple pockets. Durable cotton twill construction.', [['Size', ['30', '32', '34', '36']], ['Color', ['Khaki', 'Olive', 'Black']]], 5499, 700, 14, ['pants-jeans']), + $this->product('Chino Shorts', 'chino-shorts', 'Acme Basics', 'Pants', ['new', 'trending'], 'Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.', [['Size', ['30', '32', '34', '36']], ['Color', ['Navy', 'Sand']]], 3999, 350, 16, ['pants-jeans', 'new-arrivals']), + $this->product('Wide Leg Trousers', 'wide-leg-trousers', 'Acme Denim', 'Pants', ['sale'], 'Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.', [['Size', ['S', 'M', 'L']]], 4999, 550, 7, ['pants-jeans', 'sale'], ['compare_at' => 6999]), + $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']]], 2999, 120, 30), + $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']]], 1999, 300, 40), + $this->product('Bucket Hat', 'bucket-hat', 'Acme Accessories', 'Accessories', ['new', 'trending'], 'Lightweight bucket hat for sun protection. Packable design, washed cotton twill.', [['Size', ['S/M', 'L/XL']], ['Color', ['Beige', 'Black', 'Olive']]], 2499, 80, 22, ['new-arrivals']), + $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']]], 14999, 900, 0, [], ['status' => 'draft']), + $this->product('Discontinued Raincoat', 'discontinued-raincoat', 'Acme Outerwear', 'Jackets', [], 'Lightweight waterproof raincoat. This product has been discontinued.', [['Size', ['M', 'L']]], 8999, 400, 3, [], ['status' => 'archived', 'published_at' => 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']]], 15999, 650, 0), + $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']]], 9999, 750, 0, [], ['policy' => 'continue']), + $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']]], [2500, 5000, 10000], 0, 9999, [], ['shipping' => false, 'skus' => ['ACME-GIFT-25', 'ACME-GIFT-50', 'ACME-GIFT-100']]), + $this->product('Cashmere Overcoat', 'cashmere-overcoat', 'Acme Premium', 'Jackets', ['limited', 'new'], 'Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.', [['Size', ['S', 'M', 'L']], ['Color', ['Camel', 'Charcoal']]], 49999, 1200, 3, ['new-arrivals']), + ]; + } + + /** @return list> */ + private function electronicsProducts(): array + { + return [ + $this->product('Pro Laptop 15', 'pro-laptop-15', 'TechCorp', 'Laptops', ['featured'], 'A professional laptop built for demanding workloads.', [['Storage', ['256GB', '512GB', '1TB']]], [99999, 119999, 149999], 1800, 10, ['featured']), + $this->product('Wireless Headphones', 'wireless-headphones', 'AudioMax', 'Audio', ['featured'], 'Premium wireless headphones with active noise cancellation.', [['Color', ['Black', 'Silver']]], 14999, 250, 25, ['featured']), + $this->product('USB-C Cable 2m', 'usb-c-cable-2m', 'CablePro', 'Cables', ['accessory'], 'Durable two metre USB-C charging and data cable.', [], 1299, 50, 200, ['accessories']), + $this->product('Mechanical Keyboard', 'mechanical-keyboard', 'KeyTech', 'Peripherals', ['featured'], 'Full-size mechanical keyboard for work and play.', [['Switch Type', ['Red', 'Blue', 'Brown']]], 12999, 1100, 15, ['featured']), + $this->product('Monitor Stand', 'monitor-stand', 'DeskGear', 'Accessories', ['accessory'], 'Ergonomic monitor stand with storage space.', [], 4999, 2500, 30, ['accessories']), + ]; + } + + /** @param list $tags + * @param list}> $options + * @param int|list $price + * @param list $collections + * @param array $extra + * @return array + */ + private function product(string $title, string $handle, string $vendor, string $type, array $tags, string $description, array $options, int|array $price, int $weight, int $inventory, array $collections = [], array $extra = []): array + { + return [...compact('title', 'handle', 'vendor', 'type', 'tags', 'description', 'options', 'price', 'weight', 'inventory', 'collections'), ...$extra]; + } +} diff --git a/database/seeders/SearchSettingsSeeder.php b/database/seeders/SearchSettingsSeeder.php new file mode 100644 index 00000000..d57daee1 --- /dev/null +++ b/database/seeders/SearchSettingsSeeder.php @@ -0,0 +1,37 @@ + [ + [['tee', 't-shirt', 'tshirt'], ['pants', 'trousers', 'jeans'], ['sneakers', 'trainers', 'shoes'], ['hoodie', 'sweatshirt']], + ['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'is'], + ], + 'acme-electronics' => [ + [['laptop', 'notebook', 'computer'], ['headphones', 'earphones', 'earbuds'], ['cable', 'cord', 'wire']], + ['the', 'a', 'an', 'and', 'or'], + ], + ]; + foreach ($data as $handle => [$synonyms, $stopWords]) { + $store = Store::query()->where('handle', $handle)->sole(); + SearchSettings::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->id], + ['synonyms_json' => $synonyms, 'stop_words_json' => $stopWords], + ); + } + }); + } +} diff --git a/database/seeders/ShippingSeeder.php b/database/seeders/ShippingSeeder.php new file mode 100644 index 00000000..9a052ae6 --- /dev/null +++ b/database/seeders/ShippingSeeder.php @@ -0,0 +1,46 @@ + [ + ['Domestic', ['DE'], [['Standard Shipping', 499], ['Express Shipping', 999]]], + ['EU', ['AT', 'FR', 'IT', 'ES', 'NL', 'BE', 'PL'], [['EU Standard', 899]]], + ['Rest of World', ['US', 'GB', 'CA', 'AU'], [['International', 1499]]], + ], + 'acme-electronics' => [ + ['Germany', ['DE'], [['Standard', 0]]], + ], + ]; + foreach ($data as $handle => $zones) { + $store = Store::query()->where('handle', $handle)->sole(); + foreach ($zones as [$name, $countries, $rates]) { + $zone = ShippingZone::query()->updateOrCreate( + ['store_id' => $store->id, 'name' => $name], + ['countries_json' => $countries, 'regions_json' => []], + ); + foreach ($rates as [$rateName, $amount]) { + ShippingRate::query()->updateOrCreate( + ['zone_id' => $zone->id, 'name' => $rateName], + ['type' => 'flat', 'config_json' => ['amount' => $amount], 'is_active' => true], + ); + } + } + } + }); + } +} diff --git a/database/seeders/StoreDomainSeeder.php b/database/seeders/StoreDomainSeeder.php new file mode 100644 index 00000000..a3aa2c15 --- /dev/null +++ b/database/seeders/StoreDomainSeeder.php @@ -0,0 +1,33 @@ +whereIn('handle', ['acme-fashion', 'acme-electronics'])->get()->keyBy('handle'); + $domains = [ + ['store' => 'acme-fashion', 'hostname' => 'acme-fashion.test', 'type' => 'storefront', 'is_primary' => true], + ['store' => 'acme-fashion', 'hostname' => 'admin.acme-fashion.test', 'type' => 'admin', 'is_primary' => false], + ['store' => 'acme-electronics', 'hostname' => 'acme-electronics.test', 'type' => 'storefront', 'is_primary' => true], + ]; + + foreach ($domains as $domain) { + StoreDomain::query()->updateOrCreate( + ['hostname' => $domain['hostname']], + ['store_id' => $stores[$domain['store']]->id, 'type' => $domain['type'], 'is_primary' => $domain['is_primary'], 'tls_mode' => 'managed'], + ); + } + }); + } +} diff --git a/database/seeders/StoreSeeder.php b/database/seeders/StoreSeeder.php new file mode 100644 index 00000000..3d564288 --- /dev/null +++ b/database/seeders/StoreSeeder.php @@ -0,0 +1,31 @@ +where('billing_email', 'billing@acme.test')->sole(); + + foreach ([ + ['name' => 'Acme Fashion', 'handle' => 'acme-fashion'], + ['name' => 'Acme Electronics', 'handle' => 'acme-electronics'], + ] as $store) { + Store::query()->updateOrCreate( + ['handle' => $store['handle']], + [...$store, 'organization_id' => $organization->id, '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..e23e80bb --- /dev/null +++ b/database/seeders/StoreSettingsSeeder.php @@ -0,0 +1,27 @@ + ['store_name' => 'Acme Fashion', 'contact_email' => 'hello@acme-fashion.test', 'order_number_prefix' => '#', 'order_number_start' => 1001], + 'acme-electronics' => ['store_name' => 'Acme Electronics', 'contact_email' => 'hello@acme-electronics.test', 'order_number_prefix' => '#', 'order_number_start' => 5001], + ] as $handle => $settings) { + $store = Store::query()->where('handle', $handle)->sole(); + StoreSettings::query()->updateOrCreate(['store_id' => $store->id], ['settings_json' => $settings]); + } + }); + } +} diff --git a/database/seeders/StoreUserSeeder.php b/database/seeders/StoreUserSeeder.php new file mode 100644 index 00000000..9acc3048 --- /dev/null +++ b/database/seeders/StoreUserSeeder.php @@ -0,0 +1,35 @@ +get()->keyBy('handle'); + $users = User::query()->get()->keyBy('email'); + foreach ([ + ['admin@acme.test', 'acme-fashion', 'owner'], + ['staff@acme.test', 'acme-fashion', 'staff'], + ['support@acme.test', 'acme-fashion', 'support'], + ['manager@acme.test', 'acme-fashion', 'admin'], + ['admin2@acme.test', 'acme-electronics', 'owner'], + ] as [$email, $handle, $role]) { + StoreUser::query()->updateOrCreate( + ['store_id' => $stores[$handle]->id, 'user_id' => $users[$email]->id], + ['role' => $role, 'created_at' => now()], + ); + } + }); + } +} diff --git a/database/seeders/TaxSettingsSeeder.php b/database/seeders/TaxSettingsSeeder.php new file mode 100644 index 00000000..52c5f36c --- /dev/null +++ b/database/seeders/TaxSettingsSeeder.php @@ -0,0 +1,25 @@ +whereIn('handle', ['acme-fashion', 'acme-electronics'])->get() as $store) { + TaxSettings::query()->updateOrCreate(['store_id' => $store->id], [ + 'mode' => 'manual', 'provider' => 'none', 'prices_include_tax' => true, 'config_json' => ['default_rate_bps' => 1900], + ]); + } + }); + } +} diff --git a/database/seeders/ThemeSeeder.php b/database/seeders/ThemeSeeder.php new file mode 100644 index 00000000..1a033fa3 --- /dev/null +++ b/database/seeders/ThemeSeeder.php @@ -0,0 +1,46 @@ + [ + 'primary_color' => '#1a1a2e', 'secondary_color' => '#e94560', 'font_family' => 'Inter, sans-serif', + 'hero_heading' => 'Welcome to Acme Fashion', 'hero_subheading' => 'Discover our curated collection of modern essentials', + 'hero_cta_text' => 'Shop New Arrivals', 'hero_cta_link' => '/collections/new-arrivals', + 'featured_collection_handles' => ['new-arrivals', 't-shirts', 'sale'], + 'footer_text' => '2025 Acme Fashion. All rights reserved.', 'show_announcement_bar' => true, + 'announcement_text' => 'Free shipping on orders over 50 EUR - Use code FREESHIP', + 'products_per_page' => 12, 'show_vendor' => true, 'show_quantity_selector' => true, + ], + 'acme-electronics' => [ + 'primary_color' => '#0f172a', 'secondary_color' => '#3b82f6', 'font_family' => 'Inter, sans-serif', + 'hero_heading' => 'Acme Electronics', 'hero_subheading' => 'Premium tech for professionals', + 'hero_cta_text' => 'Shop Featured', 'hero_cta_link' => '/collections/featured', + 'featured_collection_handles' => ['featured'], 'footer_text' => '2025 Acme Electronics. All rights reserved.', + ], + ]; + foreach ($data as $handle => $settings) { + $store = Store::query()->where('handle', $handle)->sole(); + $theme = Theme::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->id, 'name' => 'Default Theme'], + ['version' => '1.0.0', 'status' => 'published', 'published_at' => now()], + ); + ThemeSettings::query()->updateOrCreate(['theme_id' => $theme->id], ['settings_json' => $settings]); + } + }); + } +} diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php new file mode 100644 index 00000000..bb0c8d3d --- /dev/null +++ b/database/seeders/UserSeeder.php @@ -0,0 +1,33 @@ + 'admin@acme.test', 'name' => 'Admin User', 'last_login_at' => now()], + ['email' => 'staff@acme.test', 'name' => 'Staff User', 'last_login_at' => now()->subDays(2)], + ['email' => 'support@acme.test', 'name' => 'Support User', 'last_login_at' => now()->subDay()], + ['email' => 'manager@acme.test', 'name' => 'Store Manager', 'last_login_at' => now()->subDay()], + ['email' => 'admin2@acme.test', 'name' => 'Admin Two', 'last_login_at' => now()->subDay()], + ] as $user) { + User::query()->updateOrCreate( + ['email' => $user['email']], + [...$user, 'password_hash' => $passwordHash, 'status' => 'active', 'email_verified_at' => now()], + ); + } + }); + } +} diff --git a/package-lock.json b/package-lock.json index b558d2d8..01e491b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,9 @@ "tailwindcss": "^4.0.7", "vite": "^7.0.4" }, + "devDependencies": { + "playwright": "^1.61.1" + }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.9.5", "@tailwindcss/oxide-linux-x64-gnu": "^4.0.1", @@ -2056,6 +2059,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/package.json b/package.json index 688bea86..991beff4 100644 --- a/package.json +++ b/package.json @@ -19,5 +19,8 @@ "@rollup/rollup-linux-x64-gnu": "4.9.5", "@tailwindcss/oxide-linux-x64-gnu": "^4.0.1", "lightningcss-linux-x64-gnu": "^1.29.1" + }, + "devDependencies": { + "playwright": "^1.61.1" } } diff --git a/phpunit.xml b/phpunit.xml index d7032415..1a64158f 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -11,6 +11,9 @@ tests/Feature + + tests/Browser + @@ -19,6 +22,7 @@ + @@ -26,7 +30,9 @@ + + diff --git a/resources/views/components/admin/page.blade.php b/resources/views/components/admin/page.blade.php new file mode 100644 index 00000000..22b4d9d6 --- /dev/null +++ b/resources/views/components/admin/page.blade.php @@ -0,0 +1,9 @@ +@props(['title', 'subtitle' => null]) +
+ Home{{ $title }} +
+
{{ $title }}@if ($subtitle){{ $subtitle }}@endif
+ @isset($actions)
{{ $actions }}
@endisset +
+ {{ $slot }} +
diff --git a/resources/views/components/admin/panel.blade.php b/resources/views/components/admin/panel.blade.php new file mode 100644 index 00000000..e82d470a --- /dev/null +++ b/resources/views/components/admin/panel.blade.php @@ -0,0 +1,5 @@ +@props(['heading' => null, 'description' => null]) +
class(['rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900']) }}> + @if ($heading)
{{ $heading }}@if ($description){{ $description }}@endif
@endif + {{ $slot }} +
diff --git a/resources/views/components/storefront/badge.blade.php b/resources/views/components/storefront/badge.blade.php new file mode 100644 index 00000000..5cd32d11 --- /dev/null +++ b/resources/views/components/storefront/badge.blade.php @@ -0,0 +1,2 @@ +@props(['variant' => 'default']) +class(['inline-flex rounded-full px-2.5 py-1 text-xs font-medium', 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-200' => $variant === 'sale', 'bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200' => $variant !== 'sale']) }}>{{ $slot }} diff --git a/resources/views/components/storefront/breadcrumbs.blade.php b/resources/views/components/storefront/breadcrumbs.blade.php new file mode 100644 index 00000000..b29f1fd7 --- /dev/null +++ b/resources/views/components/storefront/breadcrumbs.blade.php @@ -0,0 +1,2 @@ +@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..730495d4 --- /dev/null +++ b/resources/views/components/storefront/price.blade.php @@ -0,0 +1,8 @@ +@props(['amount', 'currency' => 'USD', 'compareAtAmount' => null]) +class(['inline-flex items-baseline gap-2']) }}> + {{ \Illuminate\Support\Number::currency($amount / 100, in: $currency) }} + @if ($compareAtAmount !== null && $compareAtAmount > $amount) + {{ \Illuminate\Support\Number::currency($compareAtAmount / 100, in: $currency) }} + Sale price + @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..0f84f791 --- /dev/null +++ b/resources/views/components/storefront/product-card.blade.php @@ -0,0 +1,15 @@ +@props(['product']) +@php($variant = $product->variants->firstWhere('is_default', true) ?? $product->variants->first()) + diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php new file mode 100644 index 00000000..83a29793 --- /dev/null +++ b/resources/views/layouts/admin.blade.php @@ -0,0 +1,18 @@ + + + @include('partials.head') + +
+ + +
+
+
{{ $slot }}
+
+
+ +
+
+ @fluxScripts + + diff --git a/resources/views/layouts/storefront.blade.php b/resources/views/layouts/storefront.blade.php new file mode 100644 index 00000000..e7131cdb --- /dev/null +++ b/resources/views/layouts/storefront.blade.php @@ -0,0 +1,59 @@ + + + + @include('partials.head') + + + + + @php($organizationSchema = ['@context' => 'https://schema.org', '@type' => 'Organization', 'name' => $currentStore->name, 'url' => route('storefront.home')]) + + + + Skip to main content + +
Free shipping available with code FREESHIP
+
+
+ {{ $currentStore->name }} + +
+ + +
+ Menu + +
+
+
+
+ + @if (session('storefront_status')) +
{{ session('storefront_status') }}
+ @endif + +
{{ $slot }}
+ +
+
+

{{ $currentStore->name }}

Thoughtfully selected products for everyday life.

+ +

Secure mock checkout. Prices shown in {{ $currentStore->default_currency }}.

+
+
+ @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..009fb1d6 --- /dev/null +++ b/resources/views/livewire/admin/analytics/index.blade.php @@ -0,0 +1 @@ +Last 7 daysLast 30 days
Revenue{{ $this->formattedRevenue() }}Orders{{ number_format($orders) }}Visits{{ number_format($visits) }}Conversion{{ $conversionRate }}%
@forelse($daily as $row)@empty@endforelse
DateVisitsOrdersRevenue
{{ $row['date'] }}{{ $row['visits'] }}{{ $row['orders'] }}{{ $this->currency($row['revenue']) }}
No analytics data for this period.
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..a9bcf2e2 --- /dev/null +++ b/resources/views/livewire/admin/apps/index.blade.php @@ -0,0 +1 @@ +
@forelse($this->apps as $app)@php($installation = $app->installations->first())
{{ $app->name }}Store integration
@if($installation && $installation->status->value === 'active')
InstalledUninstall
@elseInstall@endif
@emptyNo apps availableThe app catalog is currently empty.@endforelse
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..fdfe1327 --- /dev/null +++ b/resources/views/livewire/admin/auth/login.blade.php @@ -0,0 +1,11 @@ +
+
+
Sign inAccess your store administration.
+
+ Email + Password + + Sign inSigning 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..2f7ad42b --- /dev/null +++ b/resources/views/livewire/admin/collections/form.blade.php @@ -0,0 +1,11 @@ + + Save collection +
+
+
+
@if($this->searchResults->isNotEmpty())
@foreach($this->searchResults as $product)
{{ $product->title }}Add
@endforeach
@endif
@forelse($this->assignedProducts as $product)
{{ $product->title }}
@emptyNo products assigned.@endforelse
+
+ ActiveDraftArchived +
DiscardSave
+
+
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..068657a9 --- /dev/null +++ b/resources/views/livewire/admin/collections/index.blade.php @@ -0,0 +1,5 @@ + + Add collection +
All statusesActiveDraftArchived
+
@forelse($this->collections as $collection)@empty@endforelse
TitleProductsStatusUpdatedActions
{{ $collection->title }}{{ $collection->products_count }}{{ str($collection->status->value)->headline() }}{{ $collection->updated_at->diffForHumans() }}
Create your first collectionCollections make products easier to discover.
{{ $this->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..788c9276 --- /dev/null +++ b/resources/views/livewire/admin/customers/index.blade.php @@ -0,0 +1,4 @@ + +
All customersSubscribedNot subscribed
+
@forelse($this->customers as $customer)@empty@endforelse
CustomerMarketingOrdersTotal spentJoined
{{ $customer->name }}
{{ $customer->email }}
{{ $customer->marketing_opt_in ? 'Subscribed' : 'Not subscribed' }}{{ $customer->orders_count }}{{ \Illuminate\Support\Number::currency(($customer->orders_sum_total_amount ?? 0) / 100, in: $this->currentStore()->default_currency) }}{{ $customer->created_at->format('M j, Y') }}
No customers found.
{{ $this->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..4ae90b25 --- /dev/null +++ b/resources/views/livewire/admin/customers/show.blade.php @@ -0,0 +1,3 @@ + +
@forelse($customer->orders as $order)@empty@endforelse
OrderDateStatusTotal
{{ $order->order_number }}{{ $order->placed_at?->format('M j, Y') }}{{ str($order->financial_status->value)->headline() }}{{ \Illuminate\Support\Number::currency($order->total_amount / 100, in: $order->currency) }}
No orders yet.
Email
{{ $customer->email }}
Marketing
{{ $customer->marketing_opt_in ? 'Subscribed' : 'Not subscribed' }}
@forelse($customer->addresses as $address)
{{ collect($address->address_json)->filter()->implode("\n") }}
@emptyNo saved addresses.@endforelse
+
diff --git a/resources/views/livewire/admin/dashboard.blade.php b/resources/views/livewire/admin/dashboard.blade.php new file mode 100644 index 00000000..ad401533 --- /dev/null +++ b/resources/views/livewire/admin/dashboard.blade.php @@ -0,0 +1,7 @@ + + TodayLast 7 daysLast 30 daysCustom range + @if($dateRange === 'custom')
@endif +
@foreach ([['Total sales', $this->formattedTotalSales(), 'banknotes'], ['Orders', number_format($ordersCount), 'shopping-bag'], ['Average order value', $this->formattedAov(), 'calculator'], ['Visitors', number_format($visitorsCount), 'users']] as [$label, $value, $icon])
{{ $label }}{{ $value }}
@endforeach
+
@forelse($ordersChartData as $point)
@emptyNo order data for this period.@endforelse
@foreach($funnelData as $label => $value)
{{ str($label)->headline() }}{{ number_format($value) }}
@endforeach
+
@forelse($topProducts as $product)@empty@endforelse
ProductUnits soldRevenue
{{ $product['title'] }}{{ $product['units_sold'] }}{{ $this->currency($product['revenue']) }}
No sales data for this period.
+
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..6730df8d --- /dev/null +++ b/resources/views/livewire/admin/developers/index.blade.php @@ -0,0 +1 @@ +
Create token
@if($newToken){{ $newToken }}@endif
@forelse($this->tokens as $token)
{{ $token->name }}
Created {{ $token->created_at->diffForHumans() }}
Revoke
@emptyNo API tokens.@endforelse
Order createdOrder paidFulfillment createdRefund createdAdd webhook
@forelse($this->webhooks as $webhook)
{{ $webhook->event_type }}
{{ $webhook->target_url }}
@emptyNo webhooks.@endforelse
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..0f18092e --- /dev/null +++ b/resources/views/livewire/admin/discounts/form.blade.php @@ -0,0 +1,4 @@ + + Save discount +
PercentageFixed amountFree shipping@if($valueType !== 'free_shipping')@endif
ActiveDraftDisabled
DiscardSave
+
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..b1aa8096 --- /dev/null +++ b/resources/views/livewire/admin/discounts/index.blade.php @@ -0,0 +1,5 @@ + + Add discount +
All statusesActiveDraftExpiredDisabled
+
@forelse($this->discounts as $discount)@empty@endforelse
CodeValueStatusUsesEndsActions
{{ $discount->code }}@if($discount->value_type->value === 'percent'){{ $discount->value_amount }}%@elseif($discount->value_type->value === 'fixed'){{ \Illuminate\Support\Number::currency($discount->value_amount / 100, in: $this->currentStore()->default_currency) }}@else Free shipping @endif{{ str($discount->status->value)->headline() }}{{ $discount->usage_count }} / {{ $discount->usage_limit ?: '∞' }}{{ $discount->ends_at?->format('M j, Y') ?: 'Never' }}{{ $discount->status->value === 'active' ? 'Disable' : 'Activate' }}
No discounts found.
{{ $this->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..9d062a93 --- /dev/null +++ b/resources/views/livewire/admin/inventory/index.blade.php @@ -0,0 +1,4 @@ + +
All stockIn stockLow stockOut of stock
+
@forelse($this->inventoryItems as $item)@empty@endforelse
ProductVariantSKUOn handReservedPolicy
{{ $item->variant->product->title }}{{ $item->variant->optionValues->pluck('value')->implode(' / ') ?: 'Default' }}{{ $item->variant->sku ?: '—' }}{{ $item->quantity_reserved }}{{ $item->policy->value }}
No inventory items found.
{{ $this->inventoryItems->links() }}
+
diff --git a/resources/views/livewire/admin/layout/sidebar.blade.php b/resources/views/livewire/admin/layout/sidebar.blade.php new file mode 100644 index 00000000..dba29619 --- /dev/null +++ b/resources/views/livewire/admin/layout/sidebar.blade.php @@ -0,0 +1,21 @@ + diff --git a/resources/views/livewire/admin/layout/top-bar.blade.php b/resources/views/livewire/admin/layout/top-bar.blade.php new file mode 100644 index 00000000..41fcbd60 --- /dev/null +++ b/resources/views/livewire/admin/layout/top-bar.blade.php @@ -0,0 +1,4 @@ +
+
{{ $currentStoreName }}@foreach ($stores as $store){{ $store->name }}@endforeach
+
@if($unreadNotificationCount){{ $unreadNotificationCount }}@endif
SettingsLog out
+
diff --git a/resources/views/livewire/admin/navigation/index.blade.php b/resources/views/livewire/admin/navigation/index.blade.php new file mode 100644 index 00000000..cdce56e9 --- /dev/null +++ b/resources/views/livewire/admin/navigation/index.blade.php @@ -0,0 +1 @@ +
Create
@foreach($this->menus as $menu)@endforeach
@if($menuId)
Add
@php($activeMenu = $this->menus->firstWhere('id', $menuId))
@forelse($activeMenu?->items ?? [] as $item)
{{ $item->label }}
{{ $item->url }}
@emptyThis menu has no links.@endforelse
@elseCreate or select a menu.@endif
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..d07e4d78 --- /dev/null +++ b/resources/views/livewire/admin/orders/index.blade.php @@ -0,0 +1,4 @@ + +
All ordersPaidPendingUnfulfilledFulfilledRefunded
+
@forelse($this->orders as $order)@empty@endforelse
OrderDateCustomerTotalPaymentFulfillment
{{ $order->order_number }}{{ $order->placed_at?->format('M j, Y H:i') }}
{{ $order->customer?->name ?: 'Guest' }}
{{ $order->email }}
{{ \Illuminate\Support\Number::currency($order->total_amount / 100, in: $order->currency) }}{{ str($order->financial_status->value)->headline() }}{{ str($order->fulfillment_status->value)->headline() }}
No orders found.
{{ $this->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..2ecacc4e --- /dev/null +++ b/resources/views/livewire/admin/orders/show.blade.php @@ -0,0 +1,14 @@ + + @if($order->payment_method?->value === 'bank_transfer' && $order->financial_status->value === 'pending')Confirm payment@endifRefund + @if($order->financial_status->value === 'pending')This order must be paid before fulfillment can be created.@endif +
+
+
@foreach($order->lines as $line)
{{ $line->title_snapshot }}
SKU {{ $line->sku_snapshot ?: '—' }} · Qty {{ $line->quantity }}
{{ \Illuminate\Support\Number::currency($line->total_amount / 100, in: $order->currency) }}
@endforeach
@foreach(['Subtotal' => $order->subtotal_amount, 'Discount' => -$order->discount_amount, 'Shipping' => $order->shipping_amount, 'Tax' => $order->tax_amount] as $label => $amount)
{{ $label }}{{ \Illuminate\Support\Number::currency($amount / 100, in: $order->currency) }}
@endforeach
Total{{ \Illuminate\Support\Number::currency($order->total_amount / 100, in: $order->currency) }}
+
@forelse($order->fulfillments as $fulfillment)
{{ str($fulfillment->status->value)->headline() }}
{{ $fulfillment->tracking_company }} {{ $fulfillment->tracking_number }}
@if($fulfillment->status->value === 'pending')Mark as shipped@elseif($fulfillment->status->value === 'shipped')Mark as delivered@endif
@emptyNo fulfillments yet.@endforelse@if($order->financial_status->value !== 'pending')Create fulfillment@endif
+
  1. Order created
    {{ $order->placed_at?->format('M j, Y H:i') }}
  2. @foreach($order->refunds as $refund)
  3. Refund {{ str($refund->status->value)->headline() }}
    {{ \Illuminate\Support\Number::currency($refund->amount / 100, in: $order->currency) }}
  4. @endforeach
+
+
Payment{{ str($order->financial_status->value)->headline() }}
Fulfillment{{ str($order->fulfillment_status->value)->headline() }}
{{ $order->customer?->name ?: 'Guest customer' }}
{{ $order->email }}
{{ collect($order->shipping_address_json)->filter()->implode("\n") ?: 'No shipping address' }}
+
+
Create fulfillment@foreach($order->lines as $line)@endforeach
CancelFulfill items
+
Process refund
CancelProcess refund
+
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..1a6c278a --- /dev/null +++ b/resources/views/livewire/admin/pages/form.blade.php @@ -0,0 +1 @@ +Save page
DraftPublishedArchived
DiscardSave
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..c41eda40 --- /dev/null +++ b/resources/views/livewire/admin/pages/index.blade.php @@ -0,0 +1 @@ +Add page
All statusesPublishedDraftArchived
@forelse($this->pages as $page)
{{ $page->title }}
/pages/{{ $page->handle }} · {{ $page->updated_at->diffForHumans() }}
{{ str($page->status->value)->headline() }}
@empty
No pages found.
@endforelse
{{ $this->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..5aeb39ba --- /dev/null +++ b/resources/views/livewire/admin/products/form.blade.php @@ -0,0 +1,15 @@ + + @if($this->isEditing)Archive@endifSave product +
+
+
TitleDescriptionURL handle
+
@foreach($variants as $index => $variant)
@endforeachAdd variant
+
+
+
DraftActiveArchived
+
+
@forelse($this->availableCollections as $collection)@emptyNo collections yet.@endforelse
+
+
DiscardSaveSaving…
+
+
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..b28e150c --- /dev/null +++ b/resources/views/livewire/admin/products/index.blade.php @@ -0,0 +1,6 @@ + + Add product +
All statusesActiveDraftArchivedAll types@foreach($this->productTypes as $type){{ $type }}@endforeach
+ @if($selectedIds)
{{ count($selectedIds) }} products selectedSet activeArchive
@endif +
@forelse($this->products as $product)@empty@endforelse
SelectStatusInventoryTypeVendor
{{ $product->title }}
{{ $product->variants_count }} variants
{{ str($product->status->value)->headline() }}{{ $product->variants->sum(fn($variant) => $variant->inventoryItem?->quantity_on_hand ?? 0) }}{{ $product->product_type ?: '—' }}{{ $product->vendor ?: '—' }}{{ $product->updated_at->diffForHumans() }}
No products foundAdjust your filters or create your first product.
{{ $this->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..6128bfe6 --- /dev/null +++ b/resources/views/livewire/admin/search-settings.blade.php @@ -0,0 +1 @@ +Save search settings
diff --git a/resources/views/livewire/admin/settings/domains.blade.php b/resources/views/livewire/admin/settings/domains.blade.php new file mode 100644 index 00000000..65dfa668 --- /dev/null +++ b/resources/views/livewire/admin/settings/domains.blade.php @@ -0,0 +1,4 @@ + +
Add domain
+
@forelse($this->domains as $domain)
{{ $domain->hostname }} @if($domain->is_primary)Primary@endif
TLS: {{ $domain->tls_mode }}
@unless($domain->is_primary)Make primaryRemove@endunless
@emptyNo domains connected.@endforelse
+
diff --git a/resources/views/livewire/admin/settings/general.blade.php b/resources/views/livewire/admin/settings/general.blade.php new file mode 100644 index 00000000..c513fed4 --- /dev/null +++ b/resources/views/livewire/admin/settings/general.blade.php @@ -0,0 +1,4 @@ + + Save settings +
@foreach(['UTC','Europe/Berlin','Europe/London','America/New_York','America/Los_Angeles','Asia/Tokyo'] as $zone){{ $zone }}@endforeach
Save
+
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..e4210089 --- /dev/null +++ b/resources/views/livewire/admin/settings/shipping.blade.php @@ -0,0 +1,3 @@ + +
Save zone
@forelse($this->zones as $zone)
@foreach($zone->rates as $rate)
{{ $rate->name }}{{ \Illuminate\Support\Number::currency(($rate->config_json['amount'] ?? 0) / 100, in: $this->currentStore()->default_currency) }}
@endforeachAdd rateDelete zone@if($activeZoneId === $zone->id)
Save rate
@endif
@emptyNo shipping zones yet.@endforelse
+
diff --git a/resources/views/livewire/admin/settings/tax.blade.php b/resources/views/livewire/admin/settings/tax.blade.php new file mode 100644 index 00000000..4e72f1ce --- /dev/null +++ b/resources/views/livewire/admin/settings/tax.blade.php @@ -0,0 +1,4 @@ + + Save taxes +
ManualExternal providerSave tax settings
+
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..92918f1a --- /dev/null +++ b/resources/views/livewire/admin/themes/editor.blade.php @@ -0,0 +1,4 @@ + + Save theme +
InterGeorgiaArialInterGeorgiaArialSave
{{ $name }}Catalog · About · Cart

A storefront made for your brand

Preview typography, colors, and calls to action before publishing.

+
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..7418383b --- /dev/null +++ b/resources/views/livewire/admin/themes/index.blade.php @@ -0,0 +1,3 @@ + +
@forelse($this->themes as $theme)
{{ $theme->name }}Version {{ $theme->version }}
{{ str($theme->status->value)->headline() }}
CustomizeDuplicate@if($theme->status->value !== 'published')Publish@endif
@emptyNo themesInstall a theme to begin customizing your storefront.@endforelse
+
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..5c2749f9 --- /dev/null +++ b/resources/views/livewire/storefront/account/addresses/index.blade.php @@ -0,0 +1 @@ +

Addresses

@forelse ($addresses as $saved)

{{ $saved->label ?: 'Address' }} @if ($saved->is_default)Default@endif

EditDelete
{{ $saved->address_json['first_name'] ?? '' }} {{ $saved->address_json['last_name'] ?? '' }}
{{ $saved->address_json['address1'] ?? '' }}
{{ $saved->address_json['zip'] ?? '' }} {{ $saved->address_json['city'] ?? '' }}
@empty

No saved addresses.

@endforelse
{{ $editingAddressId ? 'Edit address' : 'Add address' }}
Save address
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..bca9c289 --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/login.blade.php @@ -0,0 +1 @@ +
Sign inAccess your orders and saved addresses.
Sign in

New here? 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..a45bf842 --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/register.blade.php @@ -0,0 +1 @@ +
Create account
Create account

Already registered? Sign in

diff --git a/resources/views/livewire/storefront/account/dashboard.blade.php b/resources/views/livewire/storefront/account/dashboard.blade.php new file mode 100644 index 00000000..4f5b2deb --- /dev/null +++ b/resources/views/livewire/storefront/account/dashboard.blade.php @@ -0,0 +1 @@ +

Welcome, {{ $customer->name }}

{{ $customer->email }}

Sign out

Recent orders

@forelse ($orders as $order){{ $order->order_number }}@empty

No orders yet.

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

Your orders

@forelse ($orders as $order)@empty@endforelse
OrderDateStatusTotal
{{ $order->order_number }}{{ $order->placed_at?->format('M j, Y') }}{{ $order->status->value }}
No orders yet.
{{ $orders->links() }}
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..1145b95f --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/show.blade.php @@ -0,0 +1 @@ +

Order {{ $order->order_number }}

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

{{ $order->status->value }}

Items

@foreach ($order->lines as $line)
{{ $line->quantity }} × {{ $line->title_snapshot }}
@endforeach
Total
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..ba25e715 --- /dev/null +++ b/resources/views/livewire/storefront/cart-drawer.blade.php @@ -0,0 +1 @@ +
Cart @if ($cart?->lines->count()) ({{ $cart->lines->sum('quantity') }}) @endif
Shopping cart@forelse ($cart?->lines ?? [] as $line)

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

Qty {{ $line->quantity }}

Remove
@empty

Your cart is empty

@endforelseView cart
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..7cc8de28 --- /dev/null +++ b/resources/views/livewire/storefront/cart/show.blade.php @@ -0,0 +1 @@ +

Your cart

@if ($cart->lines->isEmpty())

Your cart is empty

Continue shopping
@else
@foreach ($cart->lines as $line)

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

{{ $line->quantity }}+Remove
@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..7ea85272 --- /dev/null +++ b/resources/views/livewire/storefront/checkout/confirmation.blade.php @@ -0,0 +1 @@ +

Thank you for your order!

Order {{ $order->order_number }} has been placed.

@if ($order->payment_method->value === 'bank_transfer')

Mock Bank AG · IBAN DE89 3704 0044 0532 0130 00 · BIC COBADEFFXXX

Use {{ $order->order_number }} as the payment reference.

@endif

Order details

@foreach ($order->lines as $line)
{{ $line->quantity }} × {{ $line->title_snapshot }}
@endforeach
Total
Continue shopping
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..30a694fd --- /dev/null +++ b/resources/views/livewire/storefront/checkout/show.blade.php @@ -0,0 +1 @@ +

Checkout

1. Contact and shipping address
Continue to shipping
@if ($checkout->status->value !== 'started')2. Shipping method
Choose a shipping method@forelse ($rates as $rate)@empty

No shipping required or no rates available.

@endforelse
Continue to payment
@endif@if (in_array($checkout->status->value, ['shipping_selected', 'payment_selected']))3. Payment
Payment method@foreach (['credit_card' => 'Credit card', 'paypal' => 'PayPal', 'bank_transfer' => 'Bank transfer'] as $value => $label)@endforeach
@if ($paymentMethod === 'credit_card')@endif@if ($checkout->status->value === 'shipping_selected')Review payment@elsePay now@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..aad0581f --- /dev/null +++ b/resources/views/livewire/storefront/collections/index.blade.php @@ -0,0 +1 @@ +

Collections

Browse every collection from {{ $currentStore->name }}.

@forelse ($collections as $collection)

{{ $collection->title }}

{{ $collection->products_count }} products

@empty

No collections available.

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

{{ $collection->title }}

{{ strip_tags($collection->description_html ?? '') }}

NewestPrice: low to highPrice: high to low

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

@forelse ($products as $product)@empty

No products match your filters.

@endforelse
{{ $products->links() }}
diff --git a/resources/views/livewire/storefront/home.blade.php b/resources/views/livewire/storefront/home.blade.php new file mode 100644 index 00000000..29ed0aab --- /dev/null +++ b/resources/views/livewire/storefront/home.blade.php @@ -0,0 +1,5 @@ +
+
New season

Find your next favorite.

Explore curated essentials from {{ $currentStore->name }}.

Shop collections
+

Explore

Featured collections

View all
@forelse ($collections as $collection)

{{ $collection->title }}

{{ $collection->products_count }} products

@empty

No collections yet.

@endforelse
+

Featured products

@foreach ($products 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..7ff3add0 --- /dev/null +++ b/resources/views/livewire/storefront/pages/show.blade.php @@ -0,0 +1 @@ +

{{ $page->title }}

{!! $page->body_html !!}
diff --git a/resources/views/livewire/storefront/products/show.blade.php b/resources/views/livewire/storefront/products/show.blade.php new file mode 100644 index 00000000..8ea3365c --- /dev/null +++ b/resources/views/livewire/storefront/products/show.blade.php @@ -0,0 +1 @@ +
@if ($product->vendor)

{{ $product->vendor }}

@endif

{{ $product->title }}

@if ($selectedVariant)
Choose a variant
@foreach ($product->variants->where('status.value', 'active') as $variant)@endforeach

@if ($selectedVariant->inventoryItem->available > 0) In stock @elseif ($selectedVariant->inventoryItem->policy->value === 'continue') Available on backorder @else Out of stock @endif

Add to cart@endif@if ($product->description_html)
{!! $product->description_html !!}
@endif
diff --git a/resources/views/livewire/storefront/search-modal.blade.php b/resources/views/livewire/storefront/search-modal.blade.php new file mode 100644 index 00000000..485319de --- /dev/null +++ b/resources/views/livewire/storefront/search-modal.blade.php @@ -0,0 +1 @@ +
Search
Search products@if ($query !== '')View all results@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..8cdd800a --- /dev/null +++ b/resources/views/livewire/storefront/search/index.blade.php @@ -0,0 +1 @@ +

Search

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

@forelse ($products as $product)@empty

No products found.

@endforelse
{{ $products->links() }}
diff --git a/resources/views/partials/head.blade.php b/resources/views/partials/head.blade.php index dce80588..9f5d9ec7 100644 --- a/resources/views/partials/head.blade.php +++ b/resources/views/partials/head.blade.php @@ -3,7 +3,6 @@ {{ $title ?? config('app.name') }} - diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 00000000..35046716 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,101 @@ +name('api.storefront.')->middleware(['store.resolve', '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', [CartController::class, 'addLine'])->name('carts.lines.store'); + Route::put('carts/{cart}/lines/{line}', [CartController::class, 'updateLine'])->name('carts.lines.update'); + Route::delete('carts/{cart}/lines/{line}', [CartController::class, 'destroyLine'])->name('carts.lines.destroy'); + + 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, 'shipping'])->name('checkouts.shipping'); + Route::put('checkouts/{checkout}/payment-method', [CheckoutController::class, 'paymentMethod'])->name('checkouts.payment_method'); + Route::post('checkouts/{checkout}/apply-discount', [CheckoutController::class, 'applyDiscount'])->name('checkouts.discount.store'); + Route::delete('checkouts/{checkout}/discount', [CheckoutController::class, 'removeDiscount'])->name('checkouts.discount.destroy'); + Route::post('checkouts/{checkout}/pay', [CheckoutController::class, 'pay'])->name('checkouts.pay'); + }); + + Route::get('orders/{orderNumber}', [OrderController::class, 'show'])->name('orders.show'); + Route::get('search', [SearchController::class, 'index'])->middleware('throttle:search')->name('search'); + Route::get('search/suggest', [SearchController::class, 'suggest'])->middleware('throttle:search')->name('search.suggest'); + Route::post('analytics/events', [AnalyticsController::class, 'store'])->middleware('throttle:analytics')->name('analytics.store'); +}); + +Route::prefix('admin/v1')->name('api.admin.')->middleware(['auth:sanctum', 'throttle:api.admin'])->group(function (): void { + Route::post('platform/organizations', [PlatformController::class, 'organization'])->middleware('abilities:manage-platform')->name('platform.organizations.store'); + Route::post('platform/stores', [PlatformController::class, 'store'])->middleware('abilities:manage-platform')->name('platform.stores.store'); + + Route::prefix('stores/{store}')->middleware('store.resolve')->scopeBindings()->group(function (): void { + Route::post('invites', [PlatformController::class, 'invite'])->middleware('abilities:write-settings')->name('stores.invites.store'); + Route::get('me', [PlatformController::class, 'me'])->name('stores.me'); + + Route::get('products', [AdminProductController::class, 'index'])->middleware('abilities:read-products')->name('products.index'); + Route::post('products', [AdminProductController::class, 'store'])->middleware('abilities:write-products')->name('products.store'); + Route::get('products/{product}', [AdminProductController::class, 'show'])->middleware('abilities:read-products')->name('products.show'); + Route::put('products/{product}', [AdminProductController::class, 'update'])->middleware('abilities:write-products')->name('products.update'); + Route::delete('products/{product}', [AdminProductController::class, 'destroy'])->middleware('abilities:write-products')->name('products.destroy'); + Route::post('products/{product}/media/presign-upload', fn () => response()->json(['message' => 'Direct uploads use the local public disk.'], 501))->middleware('abilities:write-products')->name('products.media.presign'); + + Route::get('collections', [AdminCollectionController::class, 'index'])->middleware('abilities:read-collections')->name('collections.index'); + Route::post('collections', [AdminCollectionController::class, 'store'])->middleware('abilities:write-collections')->name('collections.store'); + Route::put('collections/{collection}', [AdminCollectionController::class, 'update'])->middleware('abilities:write-collections')->name('collections.update'); + Route::delete('collections/{collection}', [AdminCollectionController::class, 'destroy'])->middleware('abilities:write-collections')->name('collections.destroy'); + + Route::get('orders', [AdminOrderController::class, 'index'])->middleware('abilities:read-orders')->name('orders.index'); + Route::get('orders/{order}', [AdminOrderController::class, 'show'])->middleware('abilities:read-orders')->name('orders.show'); + Route::post('orders/{order}/fulfillments', [AdminOrderController::class, 'fulfill'])->middleware('abilities:write-orders')->name('orders.fulfillments.store'); + Route::post('orders/{order}/refunds', [AdminOrderController::class, 'refund'])->middleware('abilities:write-orders')->name('orders.refunds.store'); + Route::post('orders/{order}/confirm-payment', [AdminOrderController::class, 'confirmPayment'])->middleware('abilities:write-orders')->name('orders.confirm_payment'); + + Route::get('discounts', [AdminDiscountController::class, 'index'])->middleware('abilities:read-discounts')->name('discounts.index'); + Route::post('discounts', [AdminDiscountController::class, 'store'])->middleware('abilities:write-discounts')->name('discounts.store'); + Route::put('discounts/{discount}', [AdminDiscountController::class, 'update'])->middleware('abilities:write-discounts')->name('discounts.update'); + Route::delete('discounts/{discount}', [AdminDiscountController::class, 'destroy'])->middleware('abilities:write-discounts')->name('discounts.destroy'); + + Route::get('shipping/zones', [ShippingZoneController::class, 'index'])->middleware('abilities:read-settings')->name('shipping.index'); + Route::post('shipping/zones', [ShippingZoneController::class, 'store'])->middleware('abilities:write-settings')->name('shipping.store'); + Route::put('shipping/zones/{zone}', [ShippingZoneController::class, 'update'])->middleware('abilities:write-settings')->name('shipping.update'); + Route::post('shipping/zones/{zone}/rates', [ShippingZoneController::class, 'storeRate'])->middleware('abilities:write-settings')->name('shipping.rates.store'); + Route::get('tax/settings', [TaxSettingsController::class, 'show'])->middleware('abilities:read-settings')->name('tax.show'); + Route::put('tax/settings', [TaxSettingsController::class, 'update'])->middleware('abilities:write-settings')->name('tax.update'); + + Route::post('themes', [ThemeController::class, 'store'])->middleware('abilities:write-themes')->name('themes.store'); + Route::post('themes/{theme}/publish', [ThemeController::class, 'publish'])->middleware('abilities:write-themes')->name('themes.publish'); + Route::put('themes/{theme}/settings', [ThemeController::class, 'updateSettings'])->middleware('abilities:write-themes')->name('themes.settings.update'); + Route::get('pages', [AdminPageController::class, 'index'])->middleware('abilities:read-content')->name('pages.index'); + Route::post('pages', [AdminPageController::class, 'store'])->middleware('abilities:write-content')->name('pages.store'); + Route::put('pages/{page}', [AdminPageController::class, 'update'])->middleware('abilities:write-content')->name('pages.update'); + Route::delete('pages/{page}', [AdminPageController::class, 'destroy'])->middleware('abilities:write-content')->name('pages.destroy'); + + Route::post('search/reindex', [AdminSearchController::class, 'reindex'])->middleware('abilities:write-settings')->name('search.reindex'); + Route::get('search/status', [AdminSearchController::class, 'status'])->middleware('abilities:read-settings')->name('search.status'); + Route::get('analytics/summary', [AdminAnalyticsController::class, 'summary'])->middleware('abilities:read-analytics')->name('analytics.summary'); + Route::post('exports/orders', [ExportController::class, 'orders'])->middleware('abilities:read-orders')->name('exports.orders'); + }); +}); + +Route::prefix('app/v1')->middleware('auth:sanctum')->group(function (): void { + Route::any('{path}', fn () => response()->json(['message' => 'OAuth app APIs are not implemented in this self-contained edition.'], 501))->where('path', '.*'); +}); diff --git a/routes/console.php b/routes/console.php index 3c9adf1a..1473b439 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,30 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::job(new AggregateAnalytics) + ->dailyAt('01:00') + ->timezone('UTC') + ->withoutOverlapping(); + +Schedule::job(new ExpireAbandonedCheckouts) + ->everyFifteenMinutes() + ->withoutOverlapping(); + +Schedule::job(new CleanupAbandonedCarts) + ->daily() + ->withoutOverlapping(); + +Schedule::job(new CancelUnpaidBankTransferOrders) + ->daily() + ->withoutOverlapping(); diff --git a/routes/web.php b/routes/web.php index f755f111..638e392d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,10 +1,126 @@ name('home'); +Route::redirect('/home', '/')->name('home'); + +Route::middleware('guest')->group(function (): void { + Route::get('/admin/login', AdminLogin::class)->name('admin.login'); +}); + +Route::prefix('admin')->name('admin.')->middleware(['auth', 'verified', 'store.resolve', 'role.check'])->group(function (): void { + Route::get('/', AdminDashboard::class)->name('dashboard'); + Route::get('/products', AdminProducts::class)->name('products.index'); + Route::get('/products/create', AdminProductForm::class)->name('products.create'); + Route::get('/products/{product}/edit', AdminProductForm::class)->name('products.edit'); + Route::get('/inventory', AdminInventory::class)->name('inventory.index'); + Route::get('/collections', AdminCollections::class)->name('collections.index'); + Route::get('/collections/create', AdminCollectionForm::class)->name('collections.create'); + Route::get('/collections/{collection}/edit', AdminCollectionForm::class)->name('collections.edit'); + Route::get('/orders', AdminOrders::class)->name('orders.index'); + Route::get('/orders/{order}', AdminOrder::class)->name('orders.show'); + Route::get('/customers', AdminCustomers::class)->name('customers.index'); + Route::get('/customers/{customer}', AdminCustomer::class)->name('customers.show'); + Route::get('/discounts', AdminDiscounts::class)->name('discounts.index'); + Route::get('/discounts/create', AdminDiscountForm::class)->name('discounts.create'); + Route::get('/discounts/{discount}/edit', AdminDiscountForm::class)->name('discounts.edit'); + Route::get('/settings', AdminSettings::class)->name('settings.general'); + Route::get('/settings/domains', AdminDomains::class)->name('settings.domains'); + Route::get('/settings/shipping', AdminShipping::class)->name('settings.shipping'); + Route::get('/settings/taxes', AdminTax::class)->name('settings.taxes'); + Route::get('/themes', AdminThemes::class)->name('themes.index'); + Route::get('/themes/{theme}/editor', AdminThemeEditor::class)->name('themes.editor'); + Route::get('/pages', AdminPages::class)->name('pages.index'); + Route::get('/pages/create', AdminPageForm::class)->name('pages.create'); + Route::get('/pages/{page}/edit', AdminPageForm::class)->name('pages.edit'); + Route::get('/navigation', AdminNavigation::class)->name('navigation.index'); + Route::get('/apps', AdminApps::class)->name('apps.index'); + Route::get('/developers', AdminDevelopers::class)->name('developers.index'); + Route::get('/analytics', AdminAnalytics::class)->name('analytics.index'); + Route::get('/settings/search', AdminSearchSettings::class)->name('search.settings'); + + Route::post('/logout', function (Request $request) { + Auth::guard('web')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('admin.login'); + })->name('logout'); +}); + +Route::middleware('storefront')->group(function (): void { + Route::get('/', Home::class)->name('storefront.home'); + Route::get('/collections', Collections::class)->name('storefront.collections.index'); + Route::get('/collections/{handle}', Collection::class)->name('storefront.collections.show'); + Route::get('/products/{handle}', Product::class)->name('storefront.products.show'); + Route::get('/cart', Cart::class)->name('storefront.cart.show'); + Route::get('/search', Search::class)->name('storefront.search'); + Route::get('/pages/{handle}', Page::class)->name('storefront.pages.show'); + Route::get('/checkout/{checkoutId}', Checkout::class)->name('storefront.checkout.show'); + Route::get('/checkout/{checkoutId}/confirmation', Confirmation::class)->name('storefront.checkout.confirmation'); + + Route::middleware('guest:customer')->group(function (): void { + Route::get('/account/login', CustomerLogin::class)->name('storefront.account.login'); + Route::get('/account/register', CustomerRegister::class)->name('storefront.account.register'); + }); + + Route::middleware('customer.auth')->group(function (): void { + Route::get('/account', AccountDashboard::class)->name('storefront.account.dashboard'); + Route::get('/account/orders', AccountOrders::class)->name('storefront.account.orders.index'); + Route::get('/account/orders/{orderId}', AccountOrder::class)->name('storefront.account.orders.show'); + Route::get('/account/addresses', AccountAddresses::class)->name('storefront.account.addresses.index'); + Route::post('/account/logout', function (Request $request) { + Auth::guard('customer')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('storefront.account.login'); + })->name('storefront.account.logout'); + }); +}); Route::view('dashboard', 'dashboard') ->middleware(['auth', 'verified']) diff --git a/specs/progress.md b/specs/progress.md new file mode 100644 index 00000000..2e9389b4 --- /dev/null +++ b/specs/progress.md @@ -0,0 +1,62 @@ +# Shop Implementation Progress + +## Status + +- Overall: Complete +- Current iteration: Final acceptance review completed +- Started: 2026-07-11 +- Completed: 2026-07-11 + +## Plan + +- [x] Audit specifications, application foundation, and acceptance criteria +- [x] Implement database schema, domain models, factories, and core services +- [x] Implement authentication, tenancy, authorization, and security controls +- [x] Implement storefront catalog, cart, checkout, payments, and customer account +- [x] Implement admin dashboard, catalog, orders, customers, settings, content, analytics, apps, and developer views +- [x] Implement complete deterministic, idempotent two-tenant demo data +- [x] Add comprehensive Pest unit, feature, API, Livewire, and browser coverage +- [x] Build frontend assets and run the complete automated suite +- [x] Execute Playwright acceptance flows on desktop and mobile and fix defects +- [x] Conduct the final customer/admin review and record acceptance results + +## Activity Log + +- 2026-07-11: Audited the full specification set and decomposed the roadmap and acceptance criteria. +- 2026-07-11: Implemented the tenant-aware schema, model graph, factories, catalog, inventory, cart, pricing, checkout, mock payments, orders, refunds, fulfillments, content, search, analytics, apps, and webhooks. +- 2026-07-11: Added Fortify/session and customer-guard authentication, Sanctum token abilities, policies, tenant resolution, rate limits, API resources, scheduled jobs, and versioned storefront/admin APIs. +- 2026-07-11: Implemented the responsive storefront, checkout, customer account, and complete Flux/Livewire admin console. +- 2026-07-11: Added 18 transactional seeders for two stores, 25 products, 127 variants, 12 customers, 18 orders, content, settings, and analytics. Repeated seeding is idempotent. +- 2026-07-11: Added a process-isolated, file-backed Pest Browser harness and consolidated Playwright scenarios covering every major customer/admin acceptance area. +- 2026-07-11: Real-browser review found and fixed tenant middleware persistence, Livewire redirect typing, inline card-decline handling, pending bank-transfer inventory reservations, customer order URLs, favicon errors, mobile overflow, and admin auth redirects. +- 2026-07-11: Restarted affected customer/admin journeys after every defect and completed the final review without JavaScript errors. + +## Verification + +- Laravel/Pest: 188 tests, 682 assertions passed. +- Playwright-backed Pest Browser: 33 tests passed, including storefront, cart, checkout, customer account, inventory, tenant isolation, responsive/accessibility, and admin workflows. +- Manual Playwright MCP review: successful card, declined card, bank transfer, customer order detail, admin bank-transfer confirmation, tenant switching, and 375 px mobile journeys passed with no JavaScript errors. +- Fresh `migrate:fresh --seed`: passed; verified 2 stores, 25 products, 127 variants, 12 customers, and 18 orders. +- Seeder double-run integrity: passed with 47 assertions. +- Laravel Pint: passed. +- Blade compilation: passed. +- Vite production build: passed. +- Route inspection: 108 application routes registered. +- Scheduler inspection: analytics aggregation, checkout expiry, cart cleanup, and unpaid bank-transfer cancellation registered. +- Final Git worktree: clean. + +## Specification Clarifications + +- The detailed product grids define 117 fashion variants rather than the contradictory 107 summary; all detailed combinations are preserved, producing 127 variants across both stores. +- The inclusive range from today minus 30 days through today contains 31 dates; all 31 analytics rows are seeded. + +## Commits + +- `d796ee51` — build core multitenant commerce platform +- `ccde5e7d` — build complete storefront and customer experience +- `40865fbe` — build complete admin operations console +- `79ef0bd1` — seed complete multi-tenant demo shop +- `0e9788af` — fix browser-discovered commerce regressions +- `aa5a3a2c` — test complete browser acceptance journeys +- `86efc999` — fix isolated browser database bootstrap +- `3f78cc01` — fix authenticated admin login redirect diff --git a/specs/testplan.md b/specs/testplan.md new file mode 100644 index 00000000..31180ab0 --- /dev/null +++ b/specs/testplan.md @@ -0,0 +1,35 @@ +# Shop Release Test Plan + +## Automated Gates + +| Area | Command | Required result | +| --- | --- | --- | +| PHP style | `vendor/bin/pint --dirty --format agent` | No remaining formatting changes | +| Application suite | `php artisan test --compact` | All unit and feature tests pass | +| Browser suite | `php artisan test --compact tests/Browser` | All Playwright-backed Pest Browser tests pass | +| Fresh install | `php artisan migrate:fresh --seed --no-interaction` | All migrations and deterministic seeders complete | +| Blade | `php artisan view:cache` | All templates compile | +| Frontend | `npm run build` | Vite production build succeeds | +| Routes | `php artisan route:list --except-vendor` | Storefront, customer, admin, and v1 API routes are registered | +| Scheduler | `php artisan schedule:list` | Checkout expiry, cart cleanup, bank-transfer cancellation, and analytics rollup jobs are registered | + +## Functional Coverage + +- Tenant hostname resolution, store scoping, suspended stores, role policies, customer guard isolation, Sanctum abilities, validation, throttling, and webhook signatures. +- Product variants, inventory reservation/commit/release, collections, media, search, themes, pages, navigation, analytics, apps, and webhooks. +- Cart mutation, discounts, tax, shipping, checkout transitions, successful and declined mock cards, PayPal, bank transfer, order creation, refunds, and fulfillments. +- Admin dashboard and catalog, inventory, order, customer, discount, content, settings, analytics, apps, and developer workflows. +- Deterministic two-tenant seed data, double-run idempotency, pending bank-transfer reservations, and expected demo credentials. + +## Browser Acceptance Journeys + +Run against the Herd-linked `acme-fashion.test` and `acme-electronics.test` hosts at desktop and 375 × 812 mobile viewports. + +1. Storefront home, collection, product, search, CMS page, sold-out/backorder states, cart quantity/removal, and discount presentation. +2. Checkout address validation, domestic shipping, successful `4242 4242 4242 4242` card, declined `4000 0000 0000 0002` card, and bank-transfer confirmation instructions. +3. Customer sign-in, dashboard, order history/detail, addresses, logout, and guest access control. +4. Admin sign-in/access control, dashboard metrics, product search/filters, order detail, bank-transfer confirmation, fulfillment/refund controls, and settings/content pages. +5. Cross-host tenant isolation: electronics content must render without fashion catalog or customer state. +6. Accessibility and quality: landmarks/headings, labels, keyboard focus, skip link, mobile menu, no horizontal overflow, and no browser JavaScript errors. + +Any defect restarts the affected journey after a focused regression test and fix. The complete automated suite is rerun after all browser journeys pass. diff --git a/tests/Browser/.gitkeep b/tests/Browser/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/Browser/Admin/AuthenticationTest.php b/tests/Browser/Admin/AuthenticationTest.php new file mode 100644 index 00000000..5caa4125 --- /dev/null +++ b/tests/Browser/Admin/AuthenticationTest.php @@ -0,0 +1,39 @@ +assertPathIs('/admin/login') + ->assertSee('Access your store administration.') + ->assertNoJavaScriptErrors(); +}); + +it('rejects invalid admin credentials', function (): void { + visit('/admin/login') + ->fill('email', 'admin@acme.test') + ->fill('password', 'wrong-password') + ->click('form button[type="submit"]') + ->waitForText('Invalid credentials.') + ->assertPathIs('/admin/login') + ->assertNoJavaScriptErrors(); +}); + +it('signs an owner into the selected store dashboard', function (): void { + loginBrowserAdmin() + ->assertPathIs('/admin') + ->assertSee('Total sales') + ->assertSee('Orders') + ->assertSee('Top products') + ->assertNoJavaScriptErrors(); +}); + +it('redirects an authenticated owner away from the admin login', function (): void { + loginBrowserAdmin() + ->navigate('/admin/login') + ->assertPathIs('/admin') + ->assertSee('Total sales') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Admin/ManagementTest.php b/tests/Browser/Admin/ManagementTest.php new file mode 100644 index 00000000..ec07ba27 --- /dev/null +++ b/tests/Browser/Admin/ManagementTest.php @@ -0,0 +1,62 @@ +navigate('/admin/products') + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('Unreleased Winter Jacket') + ->fill('input[placeholder="Search products…"]', 'Winter Jacket') + ->waitForText('Unreleased Winter Jacket') + ->assertDontSee('Classic Cotton T-Shirt') + ->assertNoJavaScriptErrors(); +}); + +it('creates a draft product with inventory', function (): void { + loginBrowserAdmin() + ->navigate('/admin/products/create') + ->fill('title', 'Browser Test Cap') + ->fill('descriptionHtml', 'Created through the acceptance suite.') + ->fill('[name="variants.0.sku"]', 'BROWSER-CAP-1') + ->fill('[name="variants.0.price"]', '2999') + ->fill('[name="variants.0.quantity"]', '12') + ->click('form button[type="submit"]') + ->waitForText('Product saved.') + ->navigate('/admin/products') + ->assertSee('Browser Test Cap') + ->assertNoJavaScriptErrors(); +}); + +it('shows order, customer, collection, discount, and page management', function (): void { + $page = loginBrowserAdmin(); + + $page->navigate('/admin/orders')->assertSee('#1001')->assertSee('customer@acme.test'); + $page->navigate('/admin/customers')->assertSee('John Doe')->assertSee('customer@acme.test'); + $page->navigate('/admin/collections')->assertSee('T-Shirts')->assertSee('New Arrivals'); + $page->navigate('/admin/discounts')->assertSee('WELCOME10')->assertSee('FREESHIP'); + $page->navigate('/admin/pages')->assertSee('About')->assertSee('Shipping & Returns'); + $page->assertNoJavaScriptErrors(); +}); + +it('renders analytics and operational settings', function (): void { + $page = loginBrowserAdmin(); + + $page->navigate('/admin/analytics')->assertSee('Analytics')->assertSee('Revenue'); + $page->navigate('/admin/settings')->assertSee('Store Settings')->assertSee('Acme Fashion'); + $page->navigate('/admin/settings/domains')->assertSee('acme-fashion.test'); + $page->navigate('/admin/settings/shipping')->assertSee('Domestic')->assertSee('Standard Shipping'); + $page->navigate('/admin/settings/taxes')->assertSee('Tax Settings'); + $page->assertNoJavaScriptErrors(); +}); + +it('renders the admin dashboard at tablet size', function (): void { + loginBrowserAdmin() + ->resize(768, 1024) + ->assertSee('Dashboard') + ->assertSee('Total sales') + ->assertPresent('nav[aria-label="Admin navigation"]') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/SmokeTest.php b/tests/Browser/SmokeTest.php new file mode 100644 index 00000000..39b5cf46 --- /dev/null +++ b/tests/Browser/SmokeTest.php @@ -0,0 +1,29 @@ +assertNoJavaScriptErrors(); + + $pages[0]->assertSee('Acme Fashion'); + $pages[1]->assertSee('T-Shirts'); + $pages[2]->assertSee('Classic Cotton T-Shirt')->assertSee('24.99'); + $pages[3]->assertSee('Your cart'); + $pages[4]->assertSee('Search'); + $pages[5]->assertSee('About'); + $pages[6]->assertSee('Sign in'); + $pages[7]->assertSee('Access your store administration.'); +}); diff --git a/tests/Browser/Storefront/BrowsingTest.php b/tests/Browser/Storefront/BrowsingTest.php new file mode 100644 index 00000000..b2caba2c --- /dev/null +++ b/tests/Browser/Storefront/BrowsingTest.php @@ -0,0 +1,60 @@ +assertTitleContains('Acme Fashion') + ->assertSee('Find your next favorite.') + ->assertSee('Featured collections') + ->assertSee('Featured products') + ->assertNoJavaScriptErrors(); +}); + +it('browses a collection and filters its products', function (): void { + visit('/collections/t-shirts') + ->assertSee('T-Shirts') + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('Graphic Print Tee') + ->fill('input[placeholder="Search this collection"]', 'Graphic') + ->waitForText('Graphic Print Tee') + ->assertDontSee('Classic Cotton T-Shirt') + ->assertNoJavaScriptErrors(); +}); + +it('renders product pricing, variants, inventory, and accessible media', function (): void { + visit('/products/classic-cotton-t-shirt') + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('24.99') + ->assertSee('Choose a variant') + ->assertSee('In stock') + ->assertPresent('[role="img"][aria-label="Classic Cotton T-Shirt"]') + ->assertPresent('input[aria-label^="Select variant"]') + ->assertNoJavaScriptErrors(); +}); + +it('searches the published catalog and handles empty results', function (): void { + $page = visit('/search'); + + $page->fill('input[placeholder^="Try cotton"]', 'hoodie') + ->waitForText('Organic Hoodie') + ->assertDontSee('Unreleased Winter Jacket') + ->fill('input[placeholder^="Try cotton"]', 'no-such-product-xyz') + ->waitForText('No products found.') + ->assertNoJavaScriptErrors(); +}); + +it('does not expose draft or archived product detail pages', function (string $handle): void { + visit('/products/'.$handle) + ->assertSee('404') + ->assertDontSee('Add to cart'); +})->with(['unreleased-winter-jacket', 'discontinued-raincoat']); + +it('renders published content pages', function (): void { + visit('/pages/about') + ->assertSee('About') + ->assertSee('Acme Fashion') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Storefront/CartTest.php b/tests/Browser/Storefront/CartTest.php new file mode 100644 index 00000000..be6bab6b --- /dev/null +++ b/tests/Browser/Storefront/CartTest.php @@ -0,0 +1,49 @@ +press('Add to cart') + ->waitForText('Shopping cart') + ->navigate('/cart') + ->waitForText('Classic Cotton T-Shirt'); +} + +it('starts with an empty cart', function (): void { + visit('/cart') + ->assertSee('Your cart is empty') + ->assertSeeLink('Continue shopping') + ->assertNoJavaScriptErrors(); +}); + +it('adds a product and displays the correct line total', function (): void { + addClassicShirtToBrowserCart() + ->assertSee('Classic Cotton T-Shirt') + ->assertSee('24.99') + ->assertSee('Subtotal') + ->assertSee('Checkout') + ->assertNoJavaScriptErrors(); +}); + +it('updates quantity and recalculates totals', function (): void { + addClassicShirtToBrowserCart() + ->click('[aria-label="Increase quantity"]') + ->waitForText('49.98') + ->assertSee('49.98') + ->click('dialog[open] [aria-label="Close modal"]') + ->click('[aria-label="Decrease quantity"]') + ->waitForText('24.99') + ->assertNoJavaScriptErrors(); +}); + +it('removes a line from the cart', function (): void { + addClassicShirtToBrowserCart() + ->press('Remove') + ->waitForText('Your cart is empty') + ->assertDontSee('Classic Cotton T-Shirt') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Storefront/CheckoutTest.php b/tests/Browser/Storefront/CheckoutTest.php new file mode 100644 index 00000000..7281af65 --- /dev/null +++ b/tests/Browser/Storefront/CheckoutTest.php @@ -0,0 +1,68 @@ +press('Add to cart') + ->waitForText('Shopping cart') + ->navigate('/cart') + ->waitForText('Classic Cotton T-Shirt') + ->press('Checkout') + ->waitForText('1. Contact and shipping address'); +} + +function fillBrowserShippingAddress(mixed $page): mixed +{ + return $page + ->fill('email', 'buyer@example.com') + ->fill('[name="shipping.first_name"]', 'Taylor') + ->fill('[name="shipping.last_name"]', 'Buyer') + ->fill('[name="shipping.address1"]', 'Alexanderplatz 1') + ->fill('[name="shipping.city"]', 'Berlin') + ->fill('[name="shipping.postal_code"]', '10178') + ->fill('[name="shipping.country"]', 'DE') + ->press('Continue to shipping') + ->waitForText('Standard Shipping'); +} + +function chooseBrowserShippingAndPayment(mixed $page, string $method): mixed +{ + return $page + ->click('Standard Shipping') + ->press('Continue to payment') + ->waitForText('3. Payment') + ->click($method) + ->press('Review payment') + ->waitForText('Pay now'); +} + +it('completes a credit-card checkout and shows confirmation', function (): void { + $page = fillBrowserShippingAddress(startBrowserCheckout()); + + chooseBrowserShippingAndPayment($page, 'Credit card') + ->fill('cardNumber', '4242 4242 4242 4242') + ->press('Pay now') + ->waitForText('Thank you for your order!') + ->assertPathContains('/confirmation') + ->assertSee('Order #') + ->assertNoJavaScriptErrors(); +}); + +it('applies a discount and completes a bank-transfer checkout', function (): void { + $page = fillBrowserShippingAddress(startBrowserCheckout()); + + $page->fill('discountCode', 'WELCOME10') + ->press('Apply discount') + ->wait(0.3); + + chooseBrowserShippingAndPayment($page, 'Bank transfer') + ->press('Pay now') + ->waitForText('Thank you for your order!') + ->assertSee('Bank transfer') + ->assertPathContains('/confirmation') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Storefront/CustomerAccountTest.php b/tests/Browser/Storefront/CustomerAccountTest.php new file mode 100644 index 00000000..90ce5712 --- /dev/null +++ b/tests/Browser/Storefront/CustomerAccountTest.php @@ -0,0 +1,62 @@ +fill('email', 'customer@acme.test') + ->fill('password', 'password') + ->click('form button[type="submit"]') + ->waitForText('Welcome, John Doe'); +} + +it('rejects invalid customer credentials', function (): void { + visit('/account/login') + ->fill('email', 'customer@acme.test') + ->fill('password', 'incorrect') + ->click('form button[type="submit"]') + ->waitForText('Invalid credentials') + ->assertPathIs('/account/login') + ->assertNoJavaScriptErrors(); +}); + +it('signs in and displays the customer dashboard', function (): void { + loginBrowserCustomer() + ->assertPathIs('/account') + ->assertSeeLink('Orders') + ->assertSeeLink('Addresses') + ->assertNoJavaScriptErrors(); +}); + +it('shows only the signed-in customer order history', function (): void { + loginBrowserCustomer() + ->click('Orders') + ->waitForText('Your orders') + ->assertSee('#1001') + ->assertSee('#1002') + ->assertSee('#1004') + ->assertDontSee('#1003') + ->assertNoJavaScriptErrors(); +}); + +it('creates a saved address and signs out', function (): void { + loginBrowserCustomer() + ->click('Addresses') + ->fill('label', 'Office') + ->fill('[name="address.first_name"]', 'John') + ->fill('[name="address.last_name"]', 'Doe') + ->fill('[name="address.address1"]', 'Teststrasse 42') + ->fill('[name="address.city"]', 'Berlin') + ->fill('[name="address.zip"]', '10115') + ->fill('[name="address.country_code"]', 'DE') + ->click('form button[type="submit"]') + ->waitForText('Office') + ->navigate('/account') + ->press('Sign out') + ->waitForText('Sign in') + ->assertPathIs('/account/login') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Storefront/InventoryTest.php b/tests/Browser/Storefront/InventoryTest.php new file mode 100644 index 00000000..6662e068 --- /dev/null +++ b/tests/Browser/Storefront/InventoryTest.php @@ -0,0 +1,23 @@ +assertSee('Out of stock') + ->assertDisabled('button[wire\\:click="addToCart"]') + ->assertNoJavaScriptErrors(); +}); + +it('allows adding a continue-policy backorder variant', function (): void { + visit('/products/backorder-denim-jacket') + ->assertSee('Available on backorder') + ->assertEnabled('button[wire\\:click="addToCart"]') + ->press('Add to cart') + ->waitForText('Shopping cart') + ->navigate('/cart') + ->assertSee('Backorder Denim Jacket') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Storefront/QualityTest.php b/tests/Browser/Storefront/QualityTest.php new file mode 100644 index 00000000..13956891 --- /dev/null +++ b/tests/Browser/Storefront/QualityTest.php @@ -0,0 +1,46 @@ +assertSee('No products found.') + ->assertDontSee('Pro Laptop 15') + ->navigate('/products/pro-laptop-15') + ->assertSee('404'); +}); + +it('renders storefront essentials at a mobile viewport', function (): void { + visit('/') + ->resize(390, 844) + ->assertVisible('body > header') + ->assertSee('Find your next favorite.') + ->assertSee('Featured products') + ->assertScript('document.documentElement.scrollWidth <= document.documentElement.clientWidth') + ->assertNoJavaScriptErrors(); +}); + +it('supports a mobile product-to-cart interaction', function (): void { + visit('/products/classic-cotton-t-shirt') + ->resize(390, 844) + ->assertVisible('h1:first-of-type') + ->press('Add to cart') + ->waitForText('Shopping cart') + ->navigate('/cart') + ->assertSee('Classic Cotton T-Shirt') + ->assertNoJavaScriptErrors(); +}); + +it('provides landmark, heading, and labeled control semantics', function (): void { + visit('/products/classic-cotton-t-shirt') + ->assertPresent('body > header') + ->assertPresent('body > main') + ->assertPresent('body > footer') + ->assertCount('h1:first-of-type', 1) + ->assertPresent('input[aria-label^="Select variant"]') + ->assertPresent('input[type="number"]') + ->assertPresent('[aria-live="polite"]') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/BrowserTestCase.php b/tests/BrowserTestCase.php new file mode 100644 index 00000000..61b5ce98 --- /dev/null +++ b/tests/BrowserTestCase.php @@ -0,0 +1,40 @@ +set('database.connections.sqlite.database', $databasePath); + $database = $app->make('db'); + $database->purge('sqlite'); + + if (! $database->connection('sqlite')->getSchemaBuilder()->hasTable('migrations')) { + $app->make(Kernel::class)->call('migrate:fresh', [ + '--database' => 'sqlite', + '--force' => true, + '--no-interaction' => true, + ]); + } + + RefreshDatabaseState::$migrated = true; + + return $app; + } +} diff --git a/tests/Feature/Admin/AdminComponentsTest.php b/tests/Feature/Admin/AdminComponentsTest.php new file mode 100644 index 00000000..2d93ed09 --- /dev/null +++ b/tests/Feature/Admin/AdminComponentsTest.php @@ -0,0 +1,251 @@ +forgetInstance('current_store'); +}); + +function createAdminContext(StoreUserRole $role = StoreUserRole::Owner): array +{ + $store = Store::factory()->for(Organization::factory())->create(['default_currency' => 'EUR']); + $user = User::factory()->create(); + StoreUser::query()->create(['store_id' => $store->getKey(), 'user_id' => $user->getKey(), 'role' => $role, 'created_at' => now()]); + app()->instance('current_store', $store); + session(['current_store_id' => $store->getKey()]); + + return [$user, $store]; +} + +test('an active store administrator can sign in', function () { + [$user] = createAdminContext(); + auth()->logout(); + + Livewire::test(Login::class) + ->set('email', $user->email) + ->set('password', 'password') + ->call('login') + ->assertHasNoErrors() + ->assertRedirect('/admin'); + + $this->assertAuthenticatedAs($user); +}); + +test('the product list is scoped to the active store', function () { + [$user, $store] = createAdminContext(); + $visibleProduct = Product::factory()->for($store)->create(['title' => 'Visible Product']); + $otherProduct = Product::factory()->create(['title' => 'Hidden Product']); + + Livewire::actingAs($user) + ->test(ProductIndex::class) + ->assertSee($visibleProduct->title) + ->assertDontSee($otherProduct->title); +}); + +test('an administrator can create a product with inventory', function () { + [$user, $store] = createAdminContext(); + + Livewire::actingAs($user) + ->test(ProductForm::class) + ->set('title', 'Admin Created Shirt') + ->set('handle', 'admin-created-shirt') + ->set('status', 'active') + ->set('variants', [[ + 'sku' => 'ADMIN-SHIRT-M', + 'price' => 2499, + 'compareAtPrice' => null, + 'quantity' => 12, + 'requiresShipping' => true, + ]]) + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('toast'); + + $product = Product::query()->where('store_id', $store->getKey())->where('handle', 'admin-created-shirt')->firstOrFail(); + expect($product->variants)->toHaveCount(1) + ->and($product->variants->first()->inventoryItem->quantity_on_hand)->toBe(12); +}); + +test('an administrator can create a collection with assigned products', function () { + [$user, $store] = createAdminContext(); + $product = Product::factory()->for($store)->create(); + + Livewire::actingAs($user) + ->test(CollectionForm::class) + ->set('title', 'Summer Favorites') + ->set('handle', 'summer-favorites') + ->set('assignedProductIds', [$product->getKey()]) + ->call('save') + ->assertHasNoErrors(); + + $collection = Collection::query()->where('store_id', $store->getKey())->where('handle', 'summer-favorites')->firstOrFail(); + expect($collection->products()->pluck('products.id')->all())->toBe([$product->getKey()]); +}); + +test('staff can update inventory quantities', function () { + [$user, $store] = createAdminContext(StoreUserRole::Staff); + $product = Product::factory()->for($store)->create(); + $variant = ProductVariant::factory()->for($product)->create(); + $inventory = $variant->inventoryItem; + + Livewire::actingAs($user) + ->test(InventoryIndex::class) + ->call('updateQuantity', $inventory->getKey(), 27) + ->assertDispatched('toast'); + + expect($inventory->refresh()->quantity_on_hand)->toBe(27); +}); + +test('an owner can update general store settings', function () { + [$user, $store] = createAdminContext(); + + Livewire::actingAs($user) + ->test(General::class) + ->set('name', 'Updated Store') + ->set('contactEmail', 'hello@example.com') + ->set('currency', 'EUR') + ->set('locale', 'de') + ->set('timezone', 'Europe/Berlin') + ->call('save') + ->assertHasNoErrors(); + + expect($store->refresh()->name)->toBe('Updated Store') + ->and($store->settings->settings_json['contact_email'])->toBe('hello@example.com'); +}); + +test('an administrator can publish a content page', function () { + [$user, $store] = createAdminContext(); + + Livewire::actingAs($user) + ->test(PageForm::class) + ->set('title', 'About our materials') + ->set('handle', 'about-materials') + ->set('bodyHtml', '

Responsibly sourced.

') + ->set('status', 'published') + ->call('save') + ->assertHasNoErrors(); + + $page = Page::query()->where('store_id', $store->getKey())->where('handle', 'about-materials')->firstOrFail(); + expect($page->published_at)->not->toBeNull(); +}); + +test('an administrator can confirm a bank transfer payment', function () { + [$user, $store] = createAdminContext(); + $product = Product::factory()->for($store)->create(); + $variant = ProductVariant::factory()->for($product)->create(['requires_shipping' => true]); + $variant->inventoryItem->update(['quantity_on_hand' => 10, 'quantity_reserved' => 1]); + $order = Order::factory()->for($store)->create(['payment_method' => 'bank_transfer', 'status' => 'pending', 'financial_status' => 'pending']); + OrderLine::factory()->for($order)->create(['product_id' => $product->getKey(), 'variant_id' => $variant->getKey(), 'quantity' => 1]); + Payment::factory()->for($order)->create(['method' => 'bank_transfer', 'status' => 'pending']); + + Livewire::actingAs($user) + ->test(OrderShow::class, ['order' => $order]) + ->call('confirmPayment') + ->assertHasNoErrors() + ->assertDispatched('toast'); + + expect($order->refresh()->financial_status->value)->toBe('paid') + ->and($variant->inventoryItem->refresh()->quantity_on_hand)->toBe(9) + ->and($variant->inventoryItem->quantity_reserved)->toBe(0); +}); + +test('staff can fulfill and ship a paid order', function () { + [$user, $store] = createAdminContext(StoreUserRole::Staff); + $product = Product::factory()->for($store)->create(); + $variant = ProductVariant::factory()->for($product)->create(['requires_shipping' => true]); + $order = Order::factory()->for($store)->create(['financial_status' => 'paid', 'fulfillment_status' => 'unfulfilled']); + $line = OrderLine::factory()->for($order)->create(['product_id' => $product->getKey(), 'variant_id' => $variant->getKey(), 'quantity' => 1]); + + $component = Livewire::actingAs($user) + ->test(OrderShow::class, ['order' => $order]) + ->set('trackingCompany', 'DHL') + ->set('trackingNumber', 'DHL123') + ->set('fulfillmentQuantities', [$line->getKey() => 1]) + ->call('createFulfillment') + ->assertHasNoErrors(); + + $fulfillment = $order->fulfillments()->firstOrFail(); + $component->call('markShipped', $fulfillment->getKey())->assertHasNoErrors(); + + expect($fulfillment->refresh()->status->value)->toBe('shipped'); +}); + +test('an owner can issue a partial refund', function () { + [$user, $store] = createAdminContext(); + $order = Order::factory()->for($store)->create(['financial_status' => 'paid', 'total_amount' => 5499]); + Payment::factory()->for($order)->create(['amount' => 5499, 'status' => 'captured']); + + Livewire::actingAs($user) + ->test(OrderShow::class, ['order' => $order]) + ->set('refundAmount', '10.00') + ->set('refundReason', 'Customer request') + ->call('processRefund') + ->assertHasNoErrors() + ->assertDispatched('toast'); + + expect($order->refresh()->financial_status->value)->toBe('partially_refunded') + ->and($order->refunds()->value('amount'))->toBe(1000); +}); + +test('admin index and settings screens render', function (string $component, string $heading) { + [$user] = createAdminContext(); + + Livewire::actingAs($user) + ->test($component) + ->assertSee($heading); +})->with([ + 'dashboard' => [Dashboard::class, 'Dashboard'], + 'collections' => [CollectionIndex::class, 'Collections'], + 'inventory' => [InventoryIndex::class, 'Inventory'], + 'orders' => [OrderIndex::class, 'Orders'], + 'customers' => [CustomerIndex::class, 'Customers'], + 'discounts' => [DiscountIndex::class, 'Discounts'], + 'domains' => [Domains::class, 'Domains'], + 'shipping' => [Shipping::class, 'Shipping'], + 'tax' => [Tax::class, 'Tax Settings'], + 'themes' => [ThemeIndex::class, 'Themes'], + 'pages' => [PageIndex::class, 'Pages'], + 'navigation' => [NavigationIndex::class, 'Navigation'], + 'analytics' => [AnalyticsIndex::class, 'Analytics'], + 'search' => [SearchSettings::class, 'Search Settings'], + 'apps' => [AppsIndex::class, 'Apps'], + 'developers' => [DevelopersIndex::class, 'Developers'], +]); diff --git a/tests/Feature/Analytics/AggregationTest.php b/tests/Feature/Analytics/AggregationTest.php new file mode 100644 index 00000000..c6d77333 --- /dev/null +++ b/tests/Feature/Analytics/AggregationTest.php @@ -0,0 +1,38 @@ +create(); + $date = '2026-07-10'; + AnalyticsEvent::factory()->for($store)->pageView()->create(['session_id' => 'one', 'created_at' => "{$date} 10:00:00"]); + AnalyticsEvent::factory()->for($store)->pageView()->create(['session_id' => 'one', 'created_at' => "{$date} 10:01:00"]); + AnalyticsEvent::factory()->for($store)->pageView()->create(['session_id' => 'two', 'created_at' => "{$date} 10:02:00"]); + AnalyticsEvent::factory()->for($store)->addToCart()->create(['created_at' => "{$date} 10:03:00"]); + AnalyticsEvent::factory()->for($store)->create(['type' => 'checkout_started', 'created_at' => "{$date} 10:04:00"]); + AnalyticsEvent::factory()->for($store)->count(2)->create([ + 'type' => 'checkout_completed', + 'properties_json' => ['total_amount' => 2500], + 'created_at' => "{$date} 10:05:00", + ]); + + (new AggregateAnalytics($date))->handle(); + + $daily = AnalyticsDaily::withoutGlobalScopes()->sole(); + + expect($daily->orders_count)->toBe(2) + ->and($daily->revenue_amount)->toBe(5000) + ->and($daily->aov_amount)->toBe(2500) + ->and($daily->visits_count)->toBe(2) + ->and($daily->add_to_cart_count)->toBe(1) + ->and($daily->checkout_started_count)->toBe(1) + ->and($daily->checkout_completed_count)->toBe(2) + ->and(app(AnalyticsService::class)->getDailyMetrics($store, $date, $date))->toHaveCount(1); +}); diff --git a/tests/Feature/Analytics/EventIngestionTest.php b/tests/Feature/Analytics/EventIngestionTest.php new file mode 100644 index 00000000..13f81b1a --- /dev/null +++ b/tests/Feature/Analytics/EventIngestionTest.php @@ -0,0 +1,30 @@ +create(); + $analytics = app(AnalyticsService::class); + + $analytics->track( + $store, + 'add_to_cart', + ['variant_id' => 42, 'quantity' => 2], + 'session-1', + null, + 'client-event-1', + ); + $analytics->track($store, 'add_to_cart', [], 'session-1', null, 'client-event-1'); + + $event = AnalyticsEvent::withoutGlobalScopes()->sole(); + + expect($event->type)->toBe(AnalyticsEventType::AddToCart) + ->and($event->properties_json)->toBe(['variant_id' => 42, 'quantity' => 2]) + ->and($event->store_id)->toBe($store->id); +}); diff --git a/tests/Feature/Api/AdminOrderApiTest.php b/tests/Feature/Api/AdminOrderApiTest.php new file mode 100644 index 00000000..6120407c --- /dev/null +++ b/tests/Feature/Api/AdminOrderApiTest.php @@ -0,0 +1,42 @@ +forgetInstance('current_store'); + $this->store = Store::factory()->create(); + $this->user = User::factory()->create(); + StoreUser::query()->create(['store_id' => $this->store->id, 'user_id' => $this->user->id, 'role' => StoreUserRole::Owner, 'created_at' => now()]); + app()->instance('current_store', $this->store); + $this->order = Order::factory()->for($this->store)->create(['financial_status' => FinancialStatus::Paid]); + $this->line = OrderLine::factory()->for($this->order)->create(['quantity' => 2]); + app()->forgetInstance('current_store'); +}); + +it('lists order details and creates a fulfillment', function () { + Sanctum::actingAs($this->user, ['read-orders', 'write-orders']); + + $this->getJson("/api/admin/v1/stores/{$this->store->id}/orders") + ->assertSuccessful()->assertJsonCount(1, 'data'); + $this->getJson("/api/admin/v1/stores/{$this->store->id}/orders/{$this->order->id}") + ->assertSuccessful()->assertJsonPath('data.order_number', $this->order->order_number); + $this->postJson("/api/admin/v1/stores/{$this->store->id}/orders/{$this->order->id}/fulfillments", ['lines' => [$this->line->id => 2]]) + ->assertCreated()->assertJsonPath('data.lines.0.quantity', 2); +}); + +it('enforces write order abilities', function () { + Sanctum::actingAs($this->user, ['read-orders']); + + $this->postJson("/api/admin/v1/stores/{$this->store->id}/orders/{$this->order->id}/fulfillments", ['lines' => [$this->line->id => 1]]) + ->assertForbidden(); +}); diff --git a/tests/Feature/Api/AdminProductApiTest.php b/tests/Feature/Api/AdminProductApiTest.php new file mode 100644 index 00000000..2de7c7b4 --- /dev/null +++ b/tests/Feature/Api/AdminProductApiTest.php @@ -0,0 +1,39 @@ +forgetInstance('current_store'); + $this->store = Store::factory()->create(); + $this->user = User::factory()->create(); + StoreUser::query()->create(['store_id' => $this->store->id, 'user_id' => $this->user->id, 'role' => StoreUserRole::Owner, 'created_at' => now()]); +}); + +it('lists and creates products with the correct abilities', function () { + Sanctum::actingAs($this->user, ['read-products', 'write-products']); + app()->instance('current_store', $this->store); + Product::factory()->count(2)->for($this->store)->create(); + app()->forgetInstance('current_store'); + + $this->getJson("/api/admin/v1/stores/{$this->store->id}/products") + ->assertSuccessful()->assertJsonCount(2, 'data'); + + $this->postJson("/api/admin/v1/stores/{$this->store->id}/products", ['title' => 'API Product']) + ->assertCreated()->assertJsonPath('data.title', 'API Product'); +}); + +it('enforces write product abilities and authentication', function () { + Sanctum::actingAs($this->user, ['read-products']); + $this->postJson("/api/admin/v1/stores/{$this->store->id}/products", ['title' => 'Forbidden'])->assertForbidden(); + + auth()->forgetGuards(); + $this->getJson("/api/admin/v1/stores/{$this->store->id}/products")->assertUnauthorized(); +}); diff --git a/tests/Feature/Api/StorefrontCartApiTest.php b/tests/Feature/Api/StorefrontCartApiTest.php new file mode 100644 index 00000000..c6b95ab8 --- /dev/null +++ b/tests/Feature/Api/StorefrontCartApiTest.php @@ -0,0 +1,50 @@ +forgetInstance('current_store'); + $this->store = Store::factory()->create(); + StoreDomain::factory()->for($this->store)->create(['hostname' => 'acme-fashion.test']); + $product = Product::factory()->for($this->store)->create(); + $this->variant = ProductVariant::factory()->for($product)->create(['price_amount' => 2500]); + $this->variant->inventoryItem->update(['quantity_on_hand' => 10]); + $this->withServerVariables(['HTTP_HOST' => 'acme-fashion.test']); +}); + +it('creates retrieves and mutates a storefront cart', function () { + $create = $this->postJson('/api/storefront/v1/carts')->assertCreated()->assertJsonPath('data.version', 1); + $cartId = $create->json('data.id'); + + $this->getJson("/api/storefront/v1/carts/{$cartId}")->assertSuccessful()->assertJsonPath('data.lines', []); + + $this->postJson("/api/storefront/v1/carts/{$cartId}/lines", ['variant_id' => $this->variant->id, 'quantity' => 2, 'expected_version' => 1]) + ->assertSuccessful() + ->assertJsonPath('data.version', 2) + ->assertJsonPath('data.lines.0.total_amount', 5000); +}); + +it('returns a conflict with current cart state for stale versions', function () { + $cartId = $this->postJson('/api/storefront/v1/carts')->json('data.id'); + $this->postJson("/api/storefront/v1/carts/{$cartId}/lines", ['variant_id' => $this->variant->id, 'quantity' => 1, 'expected_version' => 1])->assertSuccessful(); + + $this->putJson("/api/storefront/v1/carts/{$cartId}/lines/1", ['quantity' => 2, 'expected_version' => 1]) + ->assertConflict() + ->assertJsonPath('code', 'cart_version_conflict') + ->assertJsonPath('cart.version', 2); +}); + +it('rejects cross-store cart access', function () { + $otherStore = Store::factory()->create(); + app()->instance('current_store', $otherStore); + $otherCart = \App\Models\Cart::factory()->for($otherStore)->create(); + app()->forgetInstance('current_store'); + + $this->getJson("/api/storefront/v1/carts/{$otherCart->id}")->assertNotFound(); +}); diff --git a/tests/Feature/Api/StorefrontCheckoutApiTest.php b/tests/Feature/Api/StorefrontCheckoutApiTest.php new file mode 100644 index 00000000..92be5946 --- /dev/null +++ b/tests/Feature/Api/StorefrontCheckoutApiTest.php @@ -0,0 +1,51 @@ +forgetInstance('current_store'); + $this->store = Store::factory()->create(['default_currency' => 'EUR']); + StoreDomain::factory()->for($this->store)->create(['hostname' => 'acme-fashion.test']); + app()->instance('current_store', $this->store); + $product = Product::factory()->for($this->store)->create(); + $this->variant = ProductVariant::factory()->for($product)->create(['price_amount' => 2500]); + $this->variant->inventoryItem->update(['quantity_on_hand' => 10]); + $zone = ShippingZone::factory()->for($this->store)->create(['countries_json' => ['DE']]); + $this->rate = ShippingRate::factory()->for($zone, 'zone')->create(['config_json' => ['amount' => 499]]); + TaxSettings::factory()->for($this->store)->create(['prices_include_tax' => false, 'config_json' => ['default_rate_bps' => 1900]]); + app()->forgetInstance('current_store'); + $this->withServerVariables(['HTTP_HOST' => 'acme-fashion.test']); +}); + +it('completes the storefront checkout API flow', function () { + $cartId = $this->postJson('/api/storefront/v1/carts')->json('data.id'); + $this->postJson("/api/storefront/v1/carts/{$cartId}/lines", ['variant_id' => $this->variant->id, 'quantity' => 2])->assertSuccessful(); + $checkoutId = $this->postJson('/api/storefront/v1/checkouts', ['cart_id' => $cartId])->assertCreated()->json('data.id'); + + $this->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", ['email' => 'buyer@example.com', 'shipping_address' => ['first_name' => 'Ada', 'last_name' => 'Lovelace', 'address1' => 'Main Street 1', 'city' => 'Berlin', 'country' => 'DE', 'country_code' => 'DE', 'postal_code' => '10115']]) + ->assertSuccessful()->assertJsonPath('data.status', 'addressed'); + $this->putJson("/api/storefront/v1/checkouts/{$checkoutId}/shipping-method", ['shipping_rate_id' => $this->rate->id]) + ->assertSuccessful()->assertJsonPath('data.status', 'shipping_selected'); + $this->putJson("/api/storefront/v1/checkouts/{$checkoutId}/payment-method", ['payment_method' => 'credit_card']) + ->assertSuccessful()->assertJsonPath('data.status', 'payment_selected'); + $this->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", ['card_number' => '4242424242424242']) + ->assertSuccessful()->assertJsonPath('data.financial_status', 'paid'); +}); + +it('returns validation errors for incomplete addresses and declined cards', function () { + $cartId = $this->postJson('/api/storefront/v1/carts')->json('data.id'); + $this->postJson("/api/storefront/v1/carts/{$cartId}/lines", ['variant_id' => $this->variant->id, 'quantity' => 1]); + $checkoutId = $this->postJson('/api/storefront/v1/checkouts', ['cart_id' => $cartId])->json('data.id'); + + $this->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", ['email' => 'buyer@example.com', 'shipping_address' => []]) + ->assertUnprocessable()->assertJsonValidationErrors(['shipping_address.first_name', 'shipping_address.city']); +}); diff --git a/tests/Feature/Apps/AppModelsTest.php b/tests/Feature/Apps/AppModelsTest.php new file mode 100644 index 00000000..50cafdcd --- /dev/null +++ b/tests/Feature/Apps/AppModelsTest.php @@ -0,0 +1,29 @@ +create(); + $app = App::factory()->create(); + $installation = AppInstallation::factory()->for($store)->for($app)->create([ + 'scopes_json' => ['read-products', 'write-products'], + ]); + $client = OauthClient::factory()->for($app)->create(['client_secret_encrypted' => 'plain-secret']); + $token = OauthToken::factory()->for($installation, 'installation')->create(); + + expect($installation->status)->toBe(AppInstallationStatus::Active) + ->and($installation->scopes_json)->toBe(['read-products', 'write-products']) + ->and($installation->tokens->sole()->is($token))->toBeTrue() + ->and($client->client_secret_encrypted)->toBe('plain-secret') + ->and(DB::table('oauth_clients')->whereKey($client->id)->value('client_secret_encrypted'))->not->toBe('plain-secret'); + +}); diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index fff11fd7..01b55d02 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -64,6 +64,6 @@ $response = $this->actingAs($user)->post(route('logout')); - $response->assertRedirect(route('home')); + $response->assertRedirect('/'); $this->assertGuest(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/AuthorizationPoliciesTest.php b/tests/Feature/Auth/AuthorizationPoliciesTest.php new file mode 100644 index 00000000..a429286a --- /dev/null +++ b/tests/Feature/Auth/AuthorizationPoliciesTest.php @@ -0,0 +1,55 @@ +create(); + StoreUser::query()->create(['store_id' => $store->id, 'user_id' => $user->id, 'role' => $role, 'created_at' => now()]); + + return $user; +} + +beforeEach(function () { + $this->store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->order = Order::factory()->for($this->store)->create(); + $this->customer = Customer::factory()->for($this->store)->create(); +}); + +it('gives support read only access', function () { + $support = policyUser($this->store, StoreUserRole::Support); + + expect(Gate::forUser($support)->allows('view', $this->order))->toBeTrue() + ->and(Gate::forUser($support)->allows('update', $this->order))->toBeFalse() + ->and(Gate::forUser($support)->allows('view', $this->customer))->toBeTrue() + ->and(Gate::forUser($support)->allows('update', $this->customer))->toBeFalse(); +}); + +it('allows staff operations but blocks refunds and settings', function () { + $staff = policyUser($this->store, StoreUserRole::Staff); + + expect(Gate::forUser($staff)->allows('update', $this->order))->toBeTrue() + ->and(Gate::forUser($staff)->allows('createFulfillment', $this->order))->toBeTrue() + ->and(Gate::forUser($staff)->allows('createRefund', $this->order))->toBeFalse() + ->and(Gate::forUser($staff)->allows('manage-store-settings'))->toBeFalse(); +}); + +it('reserves destructive store operations for owners', function () { + $owner = policyUser($this->store, StoreUserRole::Owner); + $admin = policyUser($this->store, StoreUserRole::Admin); + + expect(Gate::forUser($owner)->allows('delete', $this->store))->toBeTrue() + ->and(Gate::forUser($admin)->allows('delete', $this->store))->toBeFalse() + ->and(Gate::forUser($admin)->allows('createRefund', $this->order))->toBeTrue() + ->and(Gate::forUser($admin)->allows('manage-store-settings'))->toBeTrue(); +}); diff --git a/tests/Feature/Cart/CartServiceTest.php b/tests/Feature/Cart/CartServiceTest.php new file mode 100644 index 00000000..e08104b8 --- /dev/null +++ b/tests/Feature/Cart/CartServiceTest.php @@ -0,0 +1,63 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->product = Product::factory()->for($this->store)->create(); + $this->variant = ProductVariant::factory()->for($this->product)->create(['price_amount' => 2500]); + $this->variant->inventoryItem->update(['quantity_on_hand' => 20]); + $this->cartService = app(CartService::class); + $this->cart = $this->cartService->create($this->store); +}); + +it('creates and mutates a versioned cart with integer amounts', function () { + expect($this->cart->currency)->toBe($this->store->default_currency) + ->and($this->cart->cart_version)->toBe(1); + + $line = $this->cartService->addLine($this->cart->refresh(), $this->variant->id, 2, 1); + expect($line->quantity)->toBe(2) + ->and($line->line_subtotal_amount)->toBe(5000) + ->and($this->cart->refresh()->cart_version)->toBe(2); + + $line = $this->cartService->updateLineQuantity($this->cart->refresh(), $line->id, 3, 2); + expect($line->line_total_amount)->toBe(7500) + ->and($this->cart->refresh()->cart_version)->toBe(3); + + $this->cartService->removeLine($this->cart->refresh(), $line->id, 3); + expect($this->cart->lines()->count())->toBe(0) + ->and($this->cart->refresh()->cart_version)->toBe(4); +}); + +it('increments an existing line and rejects stale mutations', function () { + $line = $this->cartService->addLine($this->cart, $this->variant->id, 1); + $sameLine = $this->cartService->addLine($this->cart->refresh(), $this->variant->id, 2); + + expect($sameLine->id)->toBe($line->id) + ->and($sameLine->quantity)->toBe(3) + ->and(fn () => $this->cartService->updateLineQuantity($this->cart->refresh(), $line->id, 4, 1)) + ->toThrow(CartVersionConflictException::class); +}); + +it('merges guest carts using the higher duplicate quantity', function () { + $guest = $this->cart; + $this->cartService->addLine($guest, $this->variant->id, 2); + $customer = Cart::factory()->for($this->store)->create(); + $this->cartService->addLine($customer, $this->variant->id, 5); + + $merged = $this->cartService->mergeOnLogin($guest->refresh(), $customer->refresh()); + + expect($merged->lines)->toHaveCount(1) + ->and($merged->lines->first()->quantity)->toBe(5) + ->and($guest->refresh()->status)->toBe(CartStatus::Abandoned); +}); diff --git a/tests/Feature/Checkout/CheckoutFlowTest.php b/tests/Feature/Checkout/CheckoutFlowTest.php new file mode 100644 index 00000000..e75d17a3 --- /dev/null +++ b/tests/Feature/Checkout/CheckoutFlowTest.php @@ -0,0 +1,77 @@ +store = Store::factory()->create(['default_currency' => 'EUR']); + app()->instance('current_store', $this->store); + $product = Product::factory()->for($this->store)->create(); + $this->variant = ProductVariant::factory()->for($product)->create(['price_amount' => 2500, 'requires_shipping' => true]); + $this->variant->inventoryItem->update(['quantity_on_hand' => 10]); + $zone = ShippingZone::factory()->for($this->store)->create(['countries_json' => ['DE']]); + $this->rate = ShippingRate::factory()->for($zone, 'zone')->create(['config_json' => ['amount' => 499]]); + TaxSettings::factory()->for($this->store)->create(['prices_include_tax' => false, 'config_json' => ['default_rate_bps' => 1900]]); + $this->cart = app(CartService::class)->create($this->store); + app(CartService::class)->addLine($this->cart, $this->variant->id, 2); + $this->service = app(CheckoutService::class); +}); + +function checkoutAddressData(): array +{ + return ['email' => 'buyer@example.com', 'shipping_address' => ['first_name' => 'Ada', 'last_name' => 'Lovelace', 'address1' => 'Main Street 1', 'city' => 'Berlin', 'country' => 'DE', 'country_code' => 'DE', 'postal_code' => '10115']]; +} + +it('completes an idempotent paid checkout and commits inventory', function () { + $checkout = $this->service->create($this->cart); + $this->service->setAddress($checkout, checkoutAddressData()); + $this->service->setShippingMethod($checkout->refresh(), $this->rate->id); + $this->service->selectPaymentMethod($checkout->refresh(), PaymentMethod::CreditCard); + $order = $this->service->completeCheckout($checkout->refresh(), ['card_number' => '4242424242424242']); + $sameOrder = $this->service->completeCheckout($checkout->refresh(), ['card_number' => '4242424242424242']); + + expect($order->id)->toBe($sameOrder->id) + ->and($order->financial_status)->toBe(FinancialStatus::Paid) + ->and($checkout->refresh()->status)->toBe(CheckoutStatus::Completed) + ->and($this->cart->refresh()->status)->toBe(CartStatus::Converted) + ->and($this->variant->inventoryItem->refresh()->quantity_on_hand)->toBe(8) + ->and($this->variant->inventoryItem->quantity_reserved)->toBe(0); +}); + +it('releases reserved stock when a card is declined', function () { + $checkout = $this->service->create($this->cart); + $this->service->setAddress($checkout, checkoutAddressData()); + $this->service->setShippingMethod($checkout->refresh(), $this->rate->id); + $this->service->selectPaymentMethod($checkout->refresh(), PaymentMethod::CreditCard); + + expect(fn () => $this->service->completeCheckout($checkout->refresh(), ['card_number' => '4000000000000002'])) + ->toThrow(PaymentFailedException::class); + expect($checkout->refresh()->status)->toBe(CheckoutStatus::ShippingSelected) + ->and($this->variant->inventoryItem->refresh()->quantity_reserved)->toBe(0); +}); + +it('creates pending bank transfer orders while retaining reservations', function () { + $checkout = $this->service->create($this->cart); + $this->service->setAddress($checkout, checkoutAddressData()); + $this->service->setShippingMethod($checkout->refresh(), $this->rate->id); + $this->service->selectPaymentMethod($checkout->refresh(), PaymentMethod::BankTransfer); + $order = $this->service->completeCheckout($checkout->refresh()); + + expect($order->financial_status)->toBe(FinancialStatus::Pending) + ->and($this->variant->inventoryItem->refresh()->quantity_on_hand)->toBe(10) + ->and($this->variant->inventoryItem->quantity_reserved)->toBe(2); +}); diff --git a/tests/Feature/Content/NavigationServiceTest.php b/tests/Feature/Content/NavigationServiceTest.php new file mode 100644 index 00000000..111578b4 --- /dev/null +++ b/tests/Feature/Content/NavigationServiceTest.php @@ -0,0 +1,36 @@ +create(); + $menu = NavigationMenu::factory()->for($store)->create(); + $page = Page::factory()->for($store)->create(['handle' => 'about-us']); + NavigationItem::factory()->for($menu, 'menu')->page($page->id)->create([ + 'label' => 'About', + 'position' => 2, + ]); + NavigationItem::factory()->for($menu, 'menu')->create([ + 'label' => 'Home', + 'url' => '/', + 'position' => 1, + ]); + + $tree = app(NavigationService::class)->buildTree($menu); + + expect($tree)->toHaveCount(2) + ->and($tree[0]['label'])->toBe('Home') + ->and($tree[0]['url'])->toBe('/') + ->and($tree[1]['label'])->toBe('About') + ->and($tree[1]['url'])->toBe('/pages/about-us') + ->and($tree[1]['children'])->toBe([]); +}); diff --git a/tests/Feature/Content/ThemeContentTest.php b/tests/Feature/Content/ThemeContentTest.php new file mode 100644 index 00000000..9c4e0fe9 --- /dev/null +++ b/tests/Feature/Content/ThemeContentTest.php @@ -0,0 +1,29 @@ +create(); + $theme = Theme::factory()->for($store)->create(); + $file = ThemeFile::factory()->for($theme)->create(); + $settings = ThemeSettings::factory()->for($theme)->create([ + 'settings_json' => ['announcement' => ['enabled' => true]], + ]); + $page = Page::factory()->for($store)->draft()->create(); + + expect($theme->status)->toBe(ThemeStatus::Published) + ->and($theme->files->sole()->is($file))->toBeTrue() + ->and($theme->settings->is($settings))->toBeTrue() + ->and($settings->settings_json)->toBe(['announcement' => ['enabled' => true]]) + ->and($page->status)->toBe(PageStatus::Draft) + ->and($page->store->is($store))->toBeTrue(); +}); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 8b5843f4..cd52f7cb 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -1,7 +1,15 @@ get('/'); + $store = Store::factory()->create(); + StoreDomain::factory()->for($store)->create(['hostname' => 'acme-fashion.test']); + $response = $this->withServerVariables(['HTTP_HOST' => 'acme-fashion.test'])->get('/'); - $response->assertStatus(200); + $response->assertSuccessful(); }); diff --git a/tests/Feature/Orders/FulfillmentTest.php b/tests/Feature/Orders/FulfillmentTest.php new file mode 100644 index 00000000..a89a3a01 --- /dev/null +++ b/tests/Feature/Orders/FulfillmentTest.php @@ -0,0 +1,50 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->order = Order::factory()->for($this->store)->create(['financial_status' => FinancialStatus::Paid]); + $this->firstLine = OrderLine::factory()->for($this->order)->create(['quantity' => 2]); + $this->secondLine = OrderLine::factory()->for($this->order)->create(['quantity' => 1]); + $this->service = app(FulfillmentService::class); +}); + +it('creates partial and complete fulfillments without over fulfilling', function () { + $first = $this->service->create($this->order, [$this->firstLine->id => 2]); + expect($first->lines)->toHaveCount(1) + ->and($this->order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Partial); + + $this->service->create($this->order->refresh(), [$this->secondLine->id => 1]); + expect($this->order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled); +}); + +it('blocks fulfillment before payment', function () { + $this->order->update(['financial_status' => FinancialStatus::Pending]); + + expect(fn () => $this->service->create($this->order->refresh(), [$this->firstLine->id => 1])) + ->toThrow(FulfillmentGuardException::class); +}); + +it('marks fulfillments shipped and delivered', function () { + $fulfillment = $this->service->create($this->order, [$this->firstLine->id => 2]); + $this->service->markAsShipped($fulfillment, ['tracking_company' => 'DHL', 'tracking_number' => '123456']); + + expect($fulfillment->refresh()->status)->toBe(FulfillmentShipmentStatus::Shipped) + ->and($fulfillment->shipped_at)->not->toBeNull(); + + $this->service->markAsDelivered($fulfillment); + expect($fulfillment->refresh()->status)->toBe(FulfillmentShipmentStatus::Delivered) + ->and($fulfillment->delivered_at)->not->toBeNull(); +}); diff --git a/tests/Feature/Orders/RefundTest.php b/tests/Feature/Orders/RefundTest.php new file mode 100644 index 00000000..3bc2b99e --- /dev/null +++ b/tests/Feature/Orders/RefundTest.php @@ -0,0 +1,42 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $product = Product::factory()->for($this->store)->create(); + $this->variant = ProductVariant::factory()->for($product)->create(); + $this->variant->inventoryItem->update(['quantity_on_hand' => 8]); + $this->order = Order::factory()->for($this->store)->create(['total_amount' => 5000, 'financial_status' => FinancialStatus::Paid]); + OrderLine::factory()->for($this->order)->create(['product_id' => $product->id, 'variant_id' => $this->variant->id, 'quantity' => 2, 'total_amount' => 5000]); + $this->payment = Payment::factory()->for($this->order)->create(['amount' => 5000]); + $this->service = app(RefundService::class); +}); + +it('processes partial and full refunds', function () { + $partial = $this->service->create($this->order, $this->payment, 2000, 'Partial'); + expect($partial->status->value)->toBe('processed') + ->and($this->order->refresh()->financial_status)->toBe(FinancialStatus::PartiallyRefunded); + + $this->service->create($this->order, $this->payment, 3000, 'Remaining'); + expect($this->order->refresh()->financial_status)->toBe(FinancialStatus::Refunded); +}); + +it('rejects over refunds and can restock', function () { + expect(fn () => $this->service->create($this->order, $this->payment, 5001))->toThrow(ValidationException::class); + + $this->service->create($this->order, $this->payment, 5000, restock: true); + expect($this->variant->inventoryItem->refresh()->quantity_on_hand)->toBe(10); +}); diff --git a/tests/Feature/Payments/MockPaymentProviderTest.php b/tests/Feature/Payments/MockPaymentProviderTest.php new file mode 100644 index 00000000..dc1274f1 --- /dev/null +++ b/tests/Feature/Payments/MockPaymentProviderTest.php @@ -0,0 +1,46 @@ +create(); + app()->instance('current_store', $store); + $cart = Cart::factory()->for($store)->create(); + $this->checkout = Checkout::factory()->for($store)->for($cart)->create(); + $this->provider = new MockPaymentProvider; +}); + +it('captures the successful magic card', function () { + $result = $this->provider->charge($this->checkout, PaymentMethod::CreditCard, ['card_number' => '4242 4242 4242 4242']); + + expect($result->success)->toBeTrue() + ->and($result->status)->toBe(PaymentStatus::Captured) + ->and($result->providerPaymentId)->toStartWith('mock_'); +}); + +it('returns stable decline outcomes for magic cards', function (string $card, string $errorCode) { + $result = $this->provider->charge($this->checkout, PaymentMethod::CreditCard, ['card_number' => $card]); + + expect($result->success)->toBeFalse() + ->and($result->status)->toBe(PaymentStatus::Failed) + ->and($result->errorCode)->toBe($errorCode); +})->with([ + ['4000000000000002', 'card_declined'], + ['4000000000009995', 'insufficient_funds'], +]); + +it('captures paypal and defers bank transfers', function () { + $paypal = $this->provider->charge($this->checkout, PaymentMethod::Paypal, []); + $bank = $this->provider->charge($this->checkout, PaymentMethod::BankTransfer, []); + + expect($paypal->status)->toBe(PaymentStatus::Captured) + ->and($bank->status)->toBe(PaymentStatus::Pending); +}); diff --git a/tests/Feature/Products/CollectionTest.php b/tests/Feature/Products/CollectionTest.php new file mode 100644 index 00000000..6ced5d20 --- /dev/null +++ b/tests/Feature/Products/CollectionTest.php @@ -0,0 +1,58 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); +}); + +it('creates a collection with a store scoped unique handle', function () { + $generator = app(HandleGenerator::class); + $collection = Collection::factory()->for($this->store)->create([ + 'title' => 'Summer Sale', + 'handle' => $generator->generate('Summer Sale', 'collections', $this->store->id), + ]); + + expect($collection->handle)->toBe('summer-sale'); + $this->assertModelExists($collection); +}); + +it('adds removes and orders products through the collection pivot', function () { + $collection = Collection::factory()->for($this->store)->create(); + $products = Product::factory()->for($this->store)->count(3)->create(); + $collection->products()->sync([ + $products[0]->id => ['position' => 2], + $products[1]->id => ['position' => 0], + $products[2]->id => ['position' => 1], + ]); + + expect($collection->products()->pluck('products.id')->all()) + ->toBe([$products[1]->id, $products[2]->id, $products[0]->id]); + + $collection->products()->detach($products[2]); + + expect($collection->products()->count())->toBe(2); +}); + +it('reports product counts without loading product collections', function () { + $collection = Collection::factory()->for($this->store)->create(); + $collection->products()->attach(Product::factory()->for($this->store)->count(3)->create()); + + expect(Collection::withCount('products')->findOrFail($collection->id)->products_count)->toBe(3); +}); + +it('scopes collections to the current store', function () { + Collection::factory()->for($this->store)->count(2)->create(); + $otherStore = Store::factory()->create(); + Collection::factory()->for($otherStore)->count(4)->create(); + + expect(Collection::count())->toBe(2) + ->and(Collection::withoutGlobalScopes()->count())->toBe(6); +}); diff --git a/tests/Feature/Products/InventoryTest.php b/tests/Feature/Products/InventoryTest.php new file mode 100644 index 00000000..74d74fc7 --- /dev/null +++ b/tests/Feature/Products/InventoryTest.php @@ -0,0 +1,64 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $product = Product::factory()->for($this->store)->create(); + $this->variant = ProductVariant::factory()->for($product)->create(); + $this->inventory = $this->variant->inventoryItem; + $this->inventoryService = app(InventoryService::class); +}); + +it('automatically creates inventory for every variant', function () { + expect($this->inventory)->not->toBeNull() + ->and($this->inventory->store_id)->toBe($this->store->id) + ->and($this->inventory->quantity_on_hand)->toBe(0) + ->and($this->inventory->quantity_reserved)->toBe(0) + ->and($this->inventory->policy)->toBe(InventoryPolicy::Deny); +}); + +it('reserves releases commits and restocks inventory', function () { + $this->inventory->update(['quantity_on_hand' => 10]); + + $this->inventoryService->reserve($this->inventory, 3); + expect($this->inventory->quantity_reserved)->toBe(3)->and($this->inventory->available)->toBe(7); + + $this->inventoryService->release($this->inventory, 1); + expect($this->inventory->quantity_reserved)->toBe(2); + + $this->inventoryService->commit($this->inventory, 2); + expect($this->inventory->quantity_on_hand)->toBe(8)->and($this->inventory->quantity_reserved)->toBe(0); + + $this->inventoryService->restock($this->inventory, 4); + expect($this->inventory->quantity_on_hand)->toBe(12); +}); + +it('rejects over reservation under deny policy', function () { + $this->inventory->update(['quantity_on_hand' => 5, 'quantity_reserved' => 3]); + + expect(fn () => $this->inventoryService->reserve($this->inventory, 3)) + ->toThrow(InsufficientInventoryException::class); +}); + +it('allows over reservation under continue policy', function () { + $this->inventory->update([ + 'quantity_on_hand' => 2, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Continue, + ]); + + $this->inventoryService->reserve($this->inventory, 5); + + expect($this->inventory->quantity_reserved)->toBe(5) + ->and($this->inventory->available)->toBe(-3); +}); diff --git a/tests/Feature/Products/ProductCrudTest.php b/tests/Feature/Products/ProductCrudTest.php new file mode 100644 index 00000000..b149e5ad --- /dev/null +++ b/tests/Feature/Products/ProductCrudTest.php @@ -0,0 +1,74 @@ +store = Store::factory()->create(['default_currency' => 'EUR']); + app()->instance('current_store', $this->store); + $this->products = app(ProductService::class); +}); + +it('creates a draft product with a default variant and inventory', function () { + $product = $this->products->create($this->store, [ + 'title' => 'Summer T-Shirt', + 'status' => ProductStatus::Draft, + 'tags' => ['summer'], + ]); + + expect($product->handle)->toBe('summer-t-shirt') + ->and($product->status)->toBe(ProductStatus::Draft) + ->and($product->variants)->toHaveCount(1) + ->and($product->variants->first()->is_default)->toBeTrue() + ->and($product->variants->first()->inventoryItem)->not->toBeNull() + ->and($product->variants->first()->inventoryItem->quantity_on_hand)->toBe(0); +}); + +it('generates unique handles per store', function () { + $first = $this->products->create($this->store, ['title' => 'T-Shirt']); + $second = $this->products->create($this->store, ['title' => 'T-Shirt']); + + expect($first->handle)->toBe('t-shirt') + ->and($second->handle)->toBe('t-shirt-1'); +}); + +it('activates a draft product only when it has a priced variant', function () { + $product = $this->products->create($this->store, [ + 'title' => 'Priced Product', + 'status' => ProductStatus::Draft, + 'variants' => [['price_amount' => 2500, 'is_default' => true]], + ]); + + $this->products->transitionStatus($product, ProductStatus::Active); + + expect($product->refresh()->status)->toBe(ProductStatus::Active) + ->and($product->published_at)->not->toBeNull(); +}); + +it('rejects activation without a priced variant', function () { + $product = $this->products->create($this->store, [ + 'title' => 'Free Product', + 'status' => ProductStatus::Draft, + ]); + + expect(fn () => $this->products->transitionStatus($product, ProductStatus::Active)) + ->toThrow(InvalidProductTransitionException::class) + ->and($product->refresh()->status)->toBe(ProductStatus::Draft); +}); + +it('hard deletes only unreferenced draft products', function () { + $draft = $this->products->create($this->store, ['title' => 'Draft', 'status' => ProductStatus::Draft]); + $active = Product::factory()->for($this->store)->create(['status' => ProductStatus::Active]); + + $this->products->delete($draft); + + expect($draft->exists)->toBeFalse() + ->and(fn () => $this->products->delete($active))->toThrow(InvalidProductDeletionException::class); +}); diff --git a/tests/Feature/Products/VariantTest.php b/tests/Feature/Products/VariantTest.php new file mode 100644 index 00000000..3e201a08 --- /dev/null +++ b/tests/Feature/Products/VariantTest.php @@ -0,0 +1,79 @@ +store = Store::factory()->create(['default_currency' => 'EUR']); + app()->instance('current_store', $this->store); + $this->product = Product::factory()->for($this->store)->create(); + $this->matrix = app(VariantMatrixService::class); +}); + +it('creates the cartesian product of option values', function () { + $size = ProductOption::factory()->for($this->product)->create(['name' => 'Size', 'position' => 0]); + ProductOptionValue::factory()->for($size, 'option')->createMany([ + ['value' => 'S', 'position' => 0], + ['value' => 'M', 'position' => 1], + ['value' => 'L', 'position' => 2], + ]); + $color = ProductOption::factory()->for($this->product)->create(['name' => 'Color', 'position' => 1]); + ProductOptionValue::factory()->for($color, 'option')->createMany([ + ['value' => 'Red', 'position' => 0], + ['value' => 'Blue', 'position' => 1], + ]); + + $this->matrix->rebuildMatrix($this->product); + + expect($this->product->variants()->count())->toBe(6) + ->and($this->product->variants()->where('is_default', true)->count())->toBe(1) + ->and($this->product->variants()->withCount('optionValues')->get()->pluck('option_values_count')->unique()->all()) + ->toBe([2]); +}); + +it('preserves matching variants and removes unreferenced orphaned variants', function () { + $size = ProductOption::factory()->for($this->product)->create(['name' => 'Size', 'position' => 0]); + $small = ProductOptionValue::factory()->for($size, 'option')->create(['value' => 'S', 'position' => 0]); + $medium = ProductOptionValue::factory()->for($size, 'option')->create(['value' => 'M', 'position' => 1]); + $this->matrix->rebuildMatrix($this->product); + $smallVariant = ProductVariant::whereHas('optionValues', fn ($query) => $query->whereKey($small))->firstOrFail(); + $smallVariant->update(['price_amount' => 4321]); + $mediumVariantId = ProductVariant::whereHas('optionValues', fn ($query) => $query->whereKey($medium))->valueOrFail('id'); + + $medium->delete(); + ProductOptionValue::factory()->for($size, 'option')->create(['value' => 'L', 'position' => 1]); + $this->matrix->rebuildMatrix($this->product); + + expect($smallVariant->refresh()->price_amount)->toBe(4321) + ->and(ProductVariant::find($mediumVariantId))->toBeNull() + ->and($this->product->variants()->count())->toBe(2); +}); + +it('auto creates one default variant for products without options', function () { + $this->matrix->rebuildMatrix($this->product); + + expect($this->product->variants()->count())->toBe(1) + ->and($this->product->variants()->first()->is_default)->toBeTrue(); +}); + +it('enforces sku uniqueness within a store but allows null and cross-store skus', function () { + ProductVariant::factory()->for($this->product)->create(['sku' => 'TSH-001']); + + expect(fn () => ProductVariant::factory()->for($this->product)->create(['sku' => 'TSH-001'])) + ->toThrow(DuplicateSkuException::class); + + ProductVariant::factory()->for($this->product)->count(2)->sequence(['sku' => null], ['sku' => null])->create(); + $otherStore = Store::factory()->create(); + $otherProduct = Product::factory()->for($otherStore)->create(); + $crossStore = ProductVariant::factory()->for($otherProduct)->create(['sku' => 'TSH-001']); + + expect($crossStore->exists)->toBeTrue(); +}); diff --git a/tests/Feature/SanctumTokenTest.php b/tests/Feature/SanctumTokenTest.php new file mode 100644 index 00000000..4c37f810 --- /dev/null +++ b/tests/Feature/SanctumTokenTest.php @@ -0,0 +1,17 @@ +create(); + + $newAccessToken = $user->createToken('test-client', ['orders:read']); + $storedAccessToken = $user->tokens()->sole(); + + expect($newAccessToken->plainTextToken) + ->toStartWith($storedAccessToken->getKey().'|') + ->and($storedAccessToken->can('orders:read'))->toBeTrue() + ->and($storedAccessToken->can('orders:write'))->toBeFalse(); +}); diff --git a/tests/Feature/Search/AutocompleteTest.php b/tests/Feature/Search/AutocompleteTest.php new file mode 100644 index 00000000..ff44026e --- /dev/null +++ b/tests/Feature/Search/AutocompleteTest.php @@ -0,0 +1,22 @@ +create(); + Product::factory()->count(3)->for($store)->sequence( + ['title' => 'Running Shirt'], + ['title' => 'Running Shoes'], + ['title' => 'Running Shorts'], + )->create(); + + $results = app(SearchService::class)->autocomplete($store, 'running sh"***', 2); + + expect($results)->toHaveCount(2) + ->and($results->every(fn (Product $product): bool => str_starts_with($product->title, 'Running Sh')))->toBeTrue(); +}); diff --git a/tests/Feature/Search/SearchTest.php b/tests/Feature/Search/SearchTest.php new file mode 100644 index 00000000..543ad183 --- /dev/null +++ b/tests/Feature/Search/SearchTest.php @@ -0,0 +1,43 @@ +create(); + $secondStore = Store::factory()->create(); + $matchingProduct = Product::factory()->for($firstStore)->create([ + 'title' => 'Organic Cotton Shirt', + 'vendor' => 'Acme Apparel', + 'tags' => ['organic', 'cotton'], + ]); + Product::factory()->for($secondStore)->create(['title' => 'Organic Cotton Shirt']); + Product::factory()->for($firstStore)->draft()->create(['title' => 'Organic Cotton Draft']); + + $results = app(SearchService::class)->search($firstStore, 'organic cott', ['vendor' => 'Acme Apparel'], 10); + + expect($results->total())->toBe(1) + ->and($results->items()[0]->is($matchingProduct))->toBeTrue() + ->and(SearchQuery::withoutGlobalScopes()->sole()->results_count)->toBe(1); +}); + +test('product observer keeps the fts index synchronized on update and delete', function () { + $store = Store::factory()->create(); + $product = Product::factory()->for($store)->create(['title' => 'Initial Search Title']); + + expect(DB::table('products_fts')->where('product_id', $product->id)->count())->toBe(1); + + $product->update(['title' => 'Updated Search Title']); + + expect(app(SearchService::class)->search($store, 'updated')->total())->toBe(1); + + $product->delete(); + + expect(DB::table('products_fts')->where('product_id', $product->id)->count())->toBe(0); +}); diff --git a/tests/Feature/SeederIntegrityTest.php b/tests/Feature/SeederIntegrityTest.php new file mode 100644 index 00000000..c0feb63c --- /dev/null +++ b/tests/Feature/SeederIntegrityTest.php @@ -0,0 +1,96 @@ +seed(DatabaseSeeder::class); + + $expectedCounts = [ + 'organizations' => 1, + 'stores' => 2, + 'store_domains' => 3, + 'users' => 5, + 'store_users' => 5, + 'store_settings' => 2, + 'tax_settings' => 2, + 'shipping_zones' => 4, + 'shipping_rates' => 5, + 'collections' => 6, + 'products' => 25, + 'product_variants' => 127, + 'inventory_items' => 127, + 'product_media' => 0, + 'discounts' => 5, + 'customers' => 12, + 'customer_addresses' => 13, + 'orders' => 18, + 'order_lines' => 26, + 'payments' => 18, + 'fulfillments' => 7, + 'fulfillment_lines' => 11, + 'refunds' => 2, + 'themes' => 2, + 'theme_settings' => 2, + 'pages' => 5, + 'navigation_menus' => 3, + 'navigation_items' => 13, + 'analytics_daily' => 31, + 'analytics_events' => 220, + 'search_settings' => 2, + ]; + + foreach ($expectedCounts as $table => $count) { + $this->assertDatabaseCount($table, $count); + } + + $fashion = Store::query()->where('handle', 'acme-fashion')->sole(); + $electronics = Store::query()->where('handle', 'acme-electronics')->sole(); + expect(Product::withoutGlobalScopes()->where('store_id', $fashion->id)->count())->toBe(20) + ->and(Product::withoutGlobalScopes()->where('store_id', $electronics->id)->count())->toBe(5) + ->and(Product::withoutGlobalScopes()->where('store_id', $fashion->id)->whereHas('variants')->withCount('variants')->get()->sum('variants_count'))->toBe(117) + ->and(Product::withoutGlobalScopes()->where('store_id', $electronics->id)->withCount('variants')->get()->sum('variants_count'))->toBe(10); + + $admin = User::query()->where('email', 'admin@acme.test')->sole(); + $customer = Customer::withoutGlobalScopes()->where('store_id', $fashion->id)->where('email', 'customer@acme.test')->sole(); + expect(Hash::check('password', $admin->password_hash))->toBeTrue() + ->and(Hash::check('password', $customer->password_hash))->toBeTrue() + ->and($customer->addresses()->count())->toBe(2); + + $soldOut = Product::withoutGlobalScopes()->where('store_id', $fashion->id)->where('handle', 'limited-edition-sneakers')->sole(); + $backorder = Product::withoutGlobalScopes()->where('store_id', $fashion->id)->where('handle', 'backorder-denim-jacket')->sole(); + $giftCard = Product::withoutGlobalScopes()->where('store_id', $fashion->id)->where('handle', 'gift-card')->sole(); + expect($soldOut->variants()->whereHas('inventoryItem', fn ($query) => $query->where('quantity_on_hand', 0)->where('policy', 'deny'))->count())->toBe(3) + ->and($backorder->variants()->whereHas('inventoryItem', fn ($query) => $query->where('quantity_on_hand', 0)->where('policy', 'continue'))->count())->toBe(4) + ->and($giftCard->variants()->where('requires_shipping', false)->count())->toBe(3); + + expect(Discount::withoutGlobalScopes()->where('store_id', $fashion->id)->where('code', 'MAXED')->value('usage_count'))->toBe(5) + ->and(Discount::withoutGlobalScopes()->where('store_id', $fashion->id)->where('code', 'EXPIRED20')->value('status')->value)->toBe('expired') + ->and(Order::withoutGlobalScopes()->where('store_id', $fashion->id)->where('order_number', '#1015')->value('discount_amount'))->toBe(550) + ->and(Order::withoutGlobalScopes()->where('store_id', $electronics->id)->pluck('order_number')->all())->toBe(['#5001', '#5002', '#5003']); + + $pendingBankTransfer = Order::withoutGlobalScopes() + ->with('lines.variant.inventoryItem') + ->where('store_id', $fashion->id) + ->where('order_number', '#1005') + ->sole(); + expect($pendingBankTransfer->lines->every( + fn ($line): bool => $line->variant->inventoryItem->quantity_reserved >= $line->quantity, + ))->toBeTrue(); + + $countsBeforeSecondRun = collect($expectedCounts)->mapWithKeys(fn (int $count, string $table): array => [$table => DB::table($table)->count()]); + $this->seed(DatabaseSeeder::class); + $countsAfterSecondRun = collect($expectedCounts)->mapWithKeys(fn (int $count, string $table): array => [$table => DB::table($table)->count()]); + + expect($countsAfterSecondRun->all())->toBe($countsBeforeSecondRun->all()); +}); diff --git a/tests/Feature/Storefront/BrowsingTest.php b/tests/Feature/Storefront/BrowsingTest.php new file mode 100644 index 00000000..ab13057e --- /dev/null +++ b/tests/Feature/Storefront/BrowsingTest.php @@ -0,0 +1,43 @@ +store = Store::factory()->create(['name' => 'Acme Fashion', 'default_currency' => 'EUR']); + StoreDomain::factory()->for($this->store)->create(['hostname' => 'acme-fashion.test']); + app()->instance('current_store', $this->store); +}); + +it('renders the storefront home collections search and content routes', function () { + Collection::factory()->for($this->store)->create(['title' => 'New Arrivals', 'handle' => 'new-arrivals']); + Page::factory()->for($this->store)->create(['title' => 'About Us', 'handle' => 'about', 'status' => PageStatus::Published, 'published_at' => now()]); + + $this->withHeader('Host', 'acme-fashion.test')->get('/')->assertSuccessful()->assertSee('Acme Fashion'); + $this->withHeader('Host', 'acme-fashion.test')->get('/collections')->assertSuccessful()->assertSee('New Arrivals'); + $this->withHeader('Host', 'acme-fashion.test')->get('/search')->assertSuccessful()->assertSee('Search products'); + $this->withHeader('Host', 'acme-fashion.test')->get('/pages/about')->assertSuccessful()->assertSee('About Us'); +}); + +it('renders active products and hides draft products', function () { + $collection = Collection::factory()->for($this->store)->create(['handle' => 'featured']); + $active = Product::factory()->for($this->store)->create(['title' => 'Classic Tee', 'handle' => 'classic-tee', 'status' => ProductStatus::Active, 'published_at' => now()]); + ProductVariant::factory()->for($active)->default()->create(['price_amount' => 2499]); + $draft = Product::factory()->for($this->store)->draft()->create(['title' => 'Secret Jacket']); + $collection->products()->attach([$active->id, $draft->id]); + + Livewire::test(CollectionShow::class, ['handle' => 'featured'])->assertSee('Classic Tee')->assertDontSee('Secret Jacket'); + Livewire::test(ProductShow::class, ['handle' => 'classic-tee'])->assertSee('Classic Tee')->assertSee('Add to cart'); +}); diff --git a/tests/Feature/Storefront/CartUiTest.php b/tests/Feature/Storefront/CartUiTest.php new file mode 100644 index 00000000..9a908e60 --- /dev/null +++ b/tests/Feature/Storefront/CartUiTest.php @@ -0,0 +1,42 @@ +store = Store::factory()->create(['default_currency' => 'EUR']); + app()->instance('current_store', $this->store); + $this->product = Product::factory()->for($this->store)->create(['title' => 'Classic Tee', 'handle' => 'classic-tee', 'status' => ProductStatus::Active, 'published_at' => now()]); + $this->variant = ProductVariant::factory()->for($this->product)->default()->create(['price_amount' => 2499]); + $this->variant->inventoryItem->update(['quantity_on_hand' => 10]); +}); + +it('adds a selected variant to the session cart', function () { + Livewire::test(ProductShow::class, ['handle' => 'classic-tee']) + ->set('quantity', 2) + ->call('addToCart') + ->assertDispatched('cart-updated'); + + $cartId = session('cart_id'); + expect($cartId)->not->toBeNull(); + $this->assertDatabaseHas('cart_lines', ['cart_id' => $cartId, 'variant_id' => $this->variant->id, 'quantity' => 2]); +}); + +it('updates and removes cart lines through the cart page', function () { + Livewire::test(ProductShow::class, ['handle' => 'classic-tee'])->call('addToCart'); + $cartLine = \App\Models\Cart::find(session('cart_id'))->lines()->firstOrFail(); + + Livewire::test(CartShow::class) + ->call('updateQuantity', $cartLine->id, 3) + ->assertSee('Classic Tee') + ->call('removeLine', $cartLine->id) + ->assertSee('Your cart is empty'); +}); diff --git a/tests/Feature/Storefront/CheckoutUiTest.php b/tests/Feature/Storefront/CheckoutUiTest.php new file mode 100644 index 00000000..c3e8882f --- /dev/null +++ b/tests/Feature/Storefront/CheckoutUiTest.php @@ -0,0 +1,52 @@ +store = Store::factory()->create(['default_currency' => 'EUR']); + app()->instance('current_store', $this->store); + $product = Product::factory()->for($this->store)->create(); + $variant = ProductVariant::factory()->digital()->for($product)->create(['price_amount' => 2500]); + $variant->inventoryItem->update(['quantity_on_hand' => 10]); + $cart = Cart::factory()->for($this->store)->create(); + $cart->lines()->create(['variant_id' => $variant->id, 'quantity' => 1, 'unit_price_amount' => 2500, 'line_subtotal_amount' => 2500, 'line_total_amount' => 2500]); + $this->checkout = Checkout::factory()->for($this->store)->for($cart)->create(); +}); + +it('renders checkout and validates required address fields', function () { + Livewire::test(CheckoutShow::class, ['checkoutId' => $this->checkout->id]) + ->assertSee('Contact and shipping address') + ->call('saveAddress') + ->assertHasErrors(['email', 'shipping.first_name', 'shipping.last_name', 'shipping.address1', 'shipping.city', 'shipping.postal_code']); +}); + +it('progresses a digital checkout through address and shipping', function () { + Livewire::test(CheckoutShow::class, ['checkoutId' => $this->checkout->id]) + ->set('email', 'buyer@example.com')->set('shipping.first_name', 'Buyer')->set('shipping.last_name', 'Person') + ->set('shipping.address1', 'Main Street 1')->set('shipping.city', 'Berlin')->set('shipping.country', 'DE')->set('shipping.postal_code', '10115') + ->call('saveAddress')->assertSee('Shipping method') + ->call('selectShipping')->assertSee('Payment'); +}); + +it('shows declined card failures inline without leaving checkout', function () { + Livewire::test(CheckoutShow::class, ['checkoutId' => $this->checkout->id]) + ->set('email', 'buyer@example.com')->set('shipping.first_name', 'Buyer')->set('shipping.last_name', 'Person') + ->set('shipping.address1', 'Main Street 1')->set('shipping.city', 'Berlin')->set('shipping.country', 'DE')->set('shipping.postal_code', '10115') + ->call('saveAddress') + ->call('selectShipping') + ->call('selectPayment') + ->set('cardNumber', '4000 0000 0000 0002') + ->call('pay') + ->assertHasErrors(['cardNumber']) + ->assertSee('The payment was declined.') + ->assertNoRedirect(); +}); diff --git a/tests/Feature/Storefront/CustomerAccountUiTest.php b/tests/Feature/Storefront/CustomerAccountUiTest.php new file mode 100644 index 00000000..24fe3490 --- /dev/null +++ b/tests/Feature/Storefront/CustomerAccountUiTest.php @@ -0,0 +1,72 @@ +store = Store::factory()->create(); + StoreDomain::factory()->for($this->store)->create(['hostname' => 'acme-fashion.test']); + app()->instance('current_store', $this->store); +}); + +it('redirects guests from account pages to the customer login', function () { + $this->withHeader('Host', 'acme-fashion.test')->get('/account') + ->assertRedirect('/account/login'); +}); + +it('registers and signs in a store scoped customer', function () { + Livewire::test(Register::class) + ->set('name', 'Jane Doe')->set('email', 'jane@example.com') + ->set('password', 'password')->set('password_confirmation', 'password') + ->call('register')->assertRedirectToRoute('storefront.account.dashboard'); + + $this->assertAuthenticated('customer'); + $this->assertDatabaseHas('customers', ['store_id' => $this->store->id, 'email' => 'jane@example.com']); +}); + +it('rejects invalid credentials and accepts valid credentials', function () { + Customer::factory()->for($this->store)->create(['email' => 'customer@example.com', 'password_hash' => Hash::make('password')]); + + Livewire::test(Login::class)->set('email', 'customer@example.com')->set('password', 'wrong-password')->call('authenticate')->assertHasErrors('email'); + Livewire::test(Login::class)->set('email', 'customer@example.com')->set('password', 'password')->call('authenticate')->assertRedirectToRoute('storefront.account.dashboard'); + $this->assertAuthenticated('customer'); +}); + +it('creates and updates only the signed in customers addresses', function () { + $customer = Customer::factory()->for($this->store)->create(); + $this->actingAs($customer, 'customer'); + + Livewire::test(Addresses::class) + ->set('label', 'Home')->set('isDefault', true) + ->set('address.first_name', 'Jane')->set('address.last_name', 'Doe') + ->set('address.address1', 'Main Street 1')->set('address.city', 'Berlin') + ->set('address.country_code', 'DE')->set('address.zip', '10115') + ->call('save')->assertSee('Main Street 1'); + + $this->assertDatabaseHas('customer_addresses', ['customer_id' => $customer->id, 'label' => 'Home', 'is_default' => true]); +}); + +it('links customer orders by database id while displaying the public order number', function () { + $customer = Customer::factory()->for($this->store)->create(); + $order = Order::factory()->for($this->store)->for($customer)->create(['order_number' => '#1001']); + $this->actingAs($customer, 'customer'); + + $this->withHeader('Host', 'acme-fashion.test')->get('/account') + ->assertOk() + ->assertSee('/account/orders/'.$order->id, false) + ->assertSee('#1001'); + + $this->withHeader('Host', 'acme-fashion.test')->get('/account/orders/'.$order->id) + ->assertOk() + ->assertSee('Order #1001'); +}); diff --git a/tests/Feature/Tenancy/FoundationModelsTest.php b/tests/Feature/Tenancy/FoundationModelsTest.php new file mode 100644 index 00000000..70d223a3 --- /dev/null +++ b/tests/Feature/Tenancy/FoundationModelsTest.php @@ -0,0 +1,77 @@ +toBeTrue() + ->and(Schema::hasColumns('stores', [ + 'id', 'organization_id', 'name', 'handle', 'status', 'default_currency', + 'default_locale', 'timezone', 'created_at', 'updated_at', + ]))->toBeTrue() + ->and(Schema::hasColumns('store_domains', [ + 'id', 'store_id', 'hostname', 'type', 'is_primary', 'tls_mode', 'created_at', + ]))->toBeTrue() + ->and(Schema::hasColumns('users', [ + 'id', 'email', 'password_hash', 'name', 'status', 'email_verified_at', + 'last_login_at', 'two_factor_secret', 'two_factor_recovery_codes', + 'two_factor_confirmed_at', 'remember_token', 'created_at', 'updated_at', + ]))->toBeTrue() + ->and(Schema::hasColumns('store_users', ['store_id', 'user_id', 'role', 'created_at']))->toBeTrue() + ->and(Schema::hasColumns('store_settings', ['store_id', 'settings_json', 'updated_at']))->toBeTrue() + ->and(Schema::hasColumn('users', 'password'))->toBeFalse(); +}); + +test('foundation models expose typed relationships and casts', function () { + $organization = Organization::factory()->create(); + $store = Store::factory()->for($organization)->create(); + $domain = StoreDomain::factory()->for($store)->primary()->create(); + $settings = StoreSettings::factory()->for($store)->create([ + 'settings_json' => ['checkout' => ['guest_enabled' => true]], + ]); + $user = User::factory()->create(); + $storeUser = StoreUser::factory()->owner()->create([ + 'store_id' => $store->id, + 'user_id' => $user->id, + ]); + + expect($organization->stores->sole()->is($store))->toBeTrue() + ->and($store->organization->is($organization))->toBeTrue() + ->and($store->status)->toBe(StoreStatus::Active) + ->and($store->domains->sole()->is($domain))->toBeTrue() + ->and($domain->type)->toBe(StoreDomainType::Storefront) + ->and($domain->is_primary)->toBeTrue() + ->and($store->settings->is($settings))->toBeTrue() + ->and($settings->settings_json)->toBe(['checkout' => ['guest_enabled' => true]]) + ->and($storeUser->role)->toBe(StoreUserRole::Owner) + ->and($user->roleForStore($store))->toBe(StoreUserRole::Owner) + ->and($user->stores->sole()->is($store))->toBeTrue(); +}); + +test('laravel authentication uses the password hash column', function () { + $user = User::factory()->create([ + 'email' => 'owner@example.com', + 'password' => 'secret-password', + ]); + + expect($user->getAuthPasswordName())->toBe('password_hash') + ->and(Hash::check('secret-password', $user->password_hash))->toBeTrue() + ->and($user->password)->toBe($user->password_hash) + ->and(Auth::validate([ + 'email' => 'owner@example.com', + 'password' => 'secret-password', + ]))->toBeTrue(); +}); diff --git a/tests/Feature/Tenancy/StoreIsolationTest.php b/tests/Feature/Tenancy/StoreIsolationTest.php new file mode 100644 index 00000000..9834839b --- /dev/null +++ b/tests/Feature/Tenancy/StoreIsolationTest.php @@ -0,0 +1,63 @@ +forgetInstance('current_store'); + + Schema::create('tenant_records', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->timestamps(); + }); +}); + +afterEach(function () { + app()->forgetInstance('current_store'); +}); + +test('tenant models are automatically assigned to the current store', function () { + $store = Store::factory()->create(); + app()->instance('current_store', $store); + + $record = TenantRecordForTest::query()->create(['name' => 'Scoped record']); + + expect($record->store_id)->toBe($store->id); +}); + +test('tenant model queries only return records for the current store', function () { + [$firstStore, $secondStore] = Store::factory()->count(2)->create(); + + TenantRecordForTest::withoutGlobalScope(StoreScope::class)->create([ + 'store_id' => $firstStore->id, + 'name' => 'First store record', + ]); + TenantRecordForTest::withoutGlobalScope(StoreScope::class)->create([ + 'store_id' => $secondStore->id, + 'name' => 'Second store record', + ]); + + app()->instance('current_store', $firstStore); + + expect(TenantRecordForTest::query()->pluck('name')->all()) + ->toBe(['First store record']) + ->and(TenantRecordForTest::withoutGlobalScope(StoreScope::class)->count())->toBe(2); +}); diff --git a/tests/Feature/Tenancy/StoreRoleTest.php b/tests/Feature/Tenancy/StoreRoleTest.php new file mode 100644 index 00000000..4d624346 --- /dev/null +++ b/tests/Feature/Tenancy/StoreRoleTest.php @@ -0,0 +1,93 @@ +getStoreRole($user, $storeId); + } + + public function ownerOrAdmin(User $user, int $storeId): bool + { + return $this->isOwnerOrAdmin($user, $storeId); + } + + public function ownerAdminOrStaff(User $user, int $storeId): bool + { + return $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function anyRole(User $user, int $storeId): bool + { + return $this->isAnyRole($user, $storeId); + } +} + +beforeEach(function () { + Route::middleware(['web', 'auth', 'store.resolve', 'role.check:owner,admin']) + ->get('/admin/_test/authorized', function (Request $request) { + return response()->json([ + 'role' => $request->attributes->get('store_user')->role->value, + ]); + })->name('admin.test.authorized'); +}); + +test('allowed store roles may continue and receive the pivot record', function () { + $store = Store::factory()->create(); + $user = User::factory()->create(); + StoreUser::factory()->create([ + 'store_id' => $store->id, + 'user_id' => $user->id, + 'role' => StoreUserRole::Admin, + ]); + + $this->actingAs($user) + ->withSession(['current_store_id' => $store->id]) + ->get('/admin/_test/authorized') + ->assertSuccessful() + ->assertJson(['role' => 'admin']); +}); + +test('roles outside the allowed set are forbidden', function () { + $store = Store::factory()->create(); + $user = User::factory()->create(); + StoreUser::factory()->create([ + 'store_id' => $store->id, + 'user_id' => $user->id, + 'role' => StoreUserRole::Staff, + ]); + + $this->actingAs($user) + ->withSession(['current_store_id' => $store->id]) + ->get('/admin/_test/authorized') + ->assertForbidden(); +}); + +test('role checking helpers implement the permission group shorthands', function () { + $store = Store::factory()->create(); + $user = User::factory()->create(); + StoreUser::factory()->create([ + 'store_id' => $store->id, + 'user_id' => $user->id, + 'role' => StoreUserRole::Staff, + ]); + $checker = new StoreRoleCheckerForTest; + + expect($checker->role($user, $store->id))->toBe(StoreUserRole::Staff) + ->and($checker->ownerOrAdmin($user, $store->id))->toBeFalse() + ->and($checker->ownerAdminOrStaff($user, $store->id))->toBeTrue() + ->and($checker->anyRole($user, $store->id))->toBeTrue(); +}); diff --git a/tests/Feature/Tenancy/TenantResolutionTest.php b/tests/Feature/Tenancy/TenantResolutionTest.php new file mode 100644 index 00000000..6b3ea9c7 --- /dev/null +++ b/tests/Feature/Tenancy/TenantResolutionTest.php @@ -0,0 +1,96 @@ +get('/_test/storefront', function () { + return response()->json([ + 'store_id' => app('current_store')->id, + 'shared_store_id' => View::shared('currentStore')->id, + ]); + }); + + Route::middleware(['web', 'auth', 'store.resolve']) + ->match(['GET', 'POST'], '/admin/_test/store', function (Request $request) { + return response()->json([ + 'store_id' => app('current_store')->id, + 'method' => $request->method(), + ]); + })->name('admin.test.store'); +}); + +test('storefront requests resolve and share the store by hostname', function () { + $store = Store::factory()->create(); + StoreDomain::factory()->for($store)->create(['hostname' => 'tenant.test']); + + $this->get('http://tenant.test/_test/storefront') + ->assertSuccessful() + ->assertJson([ + 'store_id' => $store->id, + 'shared_store_id' => $store->id, + ]); + + expect(Cache::get('store_domain:tenant.test'))->toBe($store->id); +}); + +test('unknown and suspended storefronts are rejected', function () { + $this->get('http://unknown.test/_test/storefront')->assertNotFound(); + + $store = Store::factory()->suspended()->create(); + StoreDomain::factory()->for($store)->create(['hostname' => 'suspended.test']); + + $this->get('http://suspended.test/_test/storefront') + ->assertServiceUnavailable(); +}); + +test('admin requests resolve the selected store from the session', function () { + $store = Store::factory()->create(); + $user = User::factory()->create(); + StoreUser::factory()->create([ + 'store_id' => $store->id, + 'user_id' => $user->id, + ]); + + $this->actingAs($user) + ->withSession(['current_store_id' => $store->id]) + ->get('/admin/_test/store') + ->assertSuccessful() + ->assertJson(['store_id' => $store->id]); +}); + +test('admin requests cannot select a store the user cannot access', function () { + $store = Store::factory()->create(); + $user = User::factory()->create(); + + $this->actingAs($user) + ->withSession(['current_store_id' => $store->id]) + ->get('/admin/_test/store') + ->assertForbidden(); +}); + +test('suspended stores allow admin reads but reject mutations', function () { + $store = Store::factory()->suspended()->create(); + $user = User::factory()->create(); + StoreUser::factory()->create([ + 'store_id' => $store->id, + 'user_id' => $user->id, + ]); + + $this->actingAs($user)->withSession(['current_store_id' => $store->id]); + + $this->get('/admin/_test/store')->assertSuccessful(); + $this->post('/admin/_test/store')->assertForbidden(); +}); diff --git a/tests/Feature/Webhooks/WebhookDeliveryTest.php b/tests/Feature/Webhooks/WebhookDeliveryTest.php new file mode 100644 index 00000000..9da368e0 --- /dev/null +++ b/tests/Feature/Webhooks/WebhookDeliveryTest.php @@ -0,0 +1,81 @@ +create(); + WebhookSubscription::factory()->for($store)->create(['event_type' => 'order.created']); + WebhookSubscription::factory()->for($store)->create(['event_type' => 'order.refunded']); + + app(WebhookService::class)->dispatch($store, 'order.created', ['order_id' => 42]); + + expect(WebhookDelivery::query()->count())->toBe(1); + Queue::assertPushed(DeliverWebhook::class, function (DeliverWebhook $job): bool { + return $job->payload['type'] === 'order.created' + && $job->payload['data'] === ['order_id' => 42]; + }); +}); + +test('delivery job posts signed json headers and records a successful response', function () { + Http::preventStrayRequests(); + Http::fake(['https://hooks.example.test/*' => Http::response('accepted', 202)]); + $subscription = WebhookSubscription::factory()->create([ + 'target_url' => 'https://hooks.example.test/orders', + 'event_type' => 'order.created', + 'signing_secret_encrypted' => 'secret', + ]); + $delivery = WebhookDelivery::factory()->for($subscription, 'subscription')->create([ + 'event_id' => 'delivery-uuid', + ]); + $payload = ['id' => 'delivery-uuid', 'type' => 'order.created', 'data' => ['order_id' => 42]]; + $timestamp = 1783764000; + + (new DeliverWebhook($delivery->id, $payload, $timestamp))->handle(app(WebhookService::class)); + + $delivery->refresh(); + expect($delivery->status)->toBe(WebhookDeliveryStatus::Success) + ->and($delivery->response_code)->toBe(202) + ->and($delivery->response_body_snippet)->toBe('accepted'); + + Http::assertSent(function (Request $request) use ($timestamp): bool { + return $request->url() === 'https://hooks.example.test/orders' + && $request->hasHeader('X-Platform-Event', 'order.created') + && $request->hasHeader('X-Platform-Delivery-Id', 'delivery-uuid') + && $request->hasHeader('X-Platform-Timestamp', (string) $timestamp) + && $request->hasHeader('X-Platform-Signature'); + }); +}); + +test('five consecutive failed deliveries pause the subscription', function () { + Http::preventStrayRequests(); + Http::fake(['https://hooks.example.test/*' => Http::response('down', 500)]); + $subscription = WebhookSubscription::factory()->create([ + 'target_url' => 'https://hooks.example.test/orders', + ]); + WebhookDelivery::factory()->count(4)->for($subscription, 'subscription')->create([ + 'status' => WebhookDeliveryStatus::Failed, + ]); + $delivery = WebhookDelivery::factory()->for($subscription, 'subscription')->create(); + $job = new DeliverWebhook($delivery->id, ['type' => 'order.created'], 1783764000); + + expect(fn () => $job->handle(app(WebhookService::class))) + ->toThrow(RequestException::class); + + expect($subscription->refresh()->status)->toBe(WebhookSubscriptionStatus::Paused) + ->and($delivery->refresh()->status)->toBe(WebhookDeliveryStatus::Failed) + ->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..89fc4596 --- /dev/null +++ b/tests/Feature/Webhooks/WebhookSignatureTest.php @@ -0,0 +1,15 @@ +sign($payload, 'signing-secret', $timestamp); + + expect($signature)->toBe(hash_hmac('sha256', $timestamp.'.'.$payload, 'signing-secret')) + ->and($webhooks->verify($payload, $signature, 'signing-secret', $timestamp))->toBeTrue() + ->and($webhooks->verify('{"order_id":43}', $signature, 'signing-secret', $timestamp))->toBeFalse() + ->and($webhooks->verify($payload, $signature, 'wrong-secret', $timestamp))->toBeFalse(); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 60f04a45..5d71119d 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -12,9 +12,14 @@ */ pest()->extend(Tests\TestCase::class) - // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) ->in('Feature'); +pest()->extend(Tests\BrowserTestCase::class) + ->use(Illuminate\Foundation\Testing\DatabaseTruncation::class) + ->in('Browser'); + +pest()->browser()->withHost('127.0.0.1'); + /* |-------------------------------------------------------------------------- | Expectations @@ -45,3 +50,27 @@ function something() { // .. } + +function seedBrowserShop(Tests\BrowserTestCase $test): void +{ + $test->seed(Database\Seeders\DatabaseSeeder::class); + + App\Models\StoreDomain::query()->create([ + 'store_id' => App\Models\Store::query()->where('handle', 'acme-fashion')->valueOrFail('id'), + 'hostname' => '127.0.0.1', + 'type' => App\Enums\StoreDomainType::Storefront, + 'is_primary' => false, + 'tls_mode' => 'managed', + ]); + + Illuminate\Support\Facades\Cache::forget('store_domain:127.0.0.1'); +} + +function loginBrowserAdmin(): mixed +{ + return visit('/admin/login') + ->fill('email', 'admin@acme.test') + ->fill('password', 'password') + ->click('form button[type="submit"]') + ->waitForText('Dashboard'); +} diff --git a/tests/Unit/CartVersionTest.php b/tests/Unit/CartVersionTest.php new file mode 100644 index 00000000..9989d964 --- /dev/null +++ b/tests/Unit/CartVersionTest.php @@ -0,0 +1,14 @@ + 3]); + $service = new CartService(new InventoryService); + + expect(fn () => $service->assertExpectedVersion($cart, 2))->toThrow(CartVersionConflictException::class) + ->and(fn () => $service->assertExpectedVersion($cart, 3))->not->toThrow(CartVersionConflictException::class); +}); diff --git a/tests/Unit/DiscountCalculatorTest.php b/tests/Unit/DiscountCalculatorTest.php new file mode 100644 index 00000000..3f25eaed --- /dev/null +++ b/tests/Unit/DiscountCalculatorTest.php @@ -0,0 +1,33 @@ + 7500]))->setAttribute('id', 1), + (new CartLine(['line_subtotal_amount' => 2500]))->setAttribute('id', 2), + ]); + $service = new DiscountService; + + $percent = $service->calculate(new Discount(['value_type' => 'percent', 'value_amount' => 10, 'rules_json' => []]), 10000, $lines); + $fixed = $service->calculate(new Discount(['value_type' => 'fixed', 'value_amount' => 12000, 'rules_json' => []]), 10000, $lines); + $shipping = $service->calculate(new Discount(['value_type' => 'free_shipping', 'rules_json' => []]), 10000, $lines); + + expect($percent->amount)->toBe(1000) + ->and($percent->allocations)->toBe([1 => 750, 2 => 250]) + ->and($fixed->amount)->toBe(10000) + ->and($shipping->freeShipping)->toBeTrue(); +}); + +it('assigns rounding remainder to the final line', function () { + $lines = collect([ + (new CartLine(['line_subtotal_amount' => 333]))->setAttribute('id', 1), + (new CartLine(['line_subtotal_amount' => 333]))->setAttribute('id', 2), + (new CartLine(['line_subtotal_amount' => 334]))->setAttribute('id', 3), + ]); + $result = (new DiscountService)->calculate(new Discount(['value_type' => 'percent', 'value_amount' => 10, 'rules_json' => []]), 1000, $lines); + + expect(array_sum($result->allocations))->toBe(100); +}); diff --git a/tests/Unit/HandleGeneratorTest.php b/tests/Unit/HandleGeneratorTest.php new file mode 100644 index 00000000..8232fa60 --- /dev/null +++ b/tests/Unit/HandleGeneratorTest.php @@ -0,0 +1,43 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->generator = app(HandleGenerator::class); +}); + +it('generates a slug from a title', function () { + expect($this->generator->generate('My Amazing Product', 'products', $this->store->id)) + ->toBe('my-amazing-product'); +}); + +it('increments a suffix for collisions in the same store', function () { + Product::factory()->for($this->store)->create(['handle' => 't-shirt']); + Product::factory()->for($this->store)->create(['handle' => 't-shirt-1']); + + expect($this->generator->generate('T-Shirt', 'products', $this->store->id)) + ->toBe('t-shirt-2'); +}); + +it('normalizes special characters into a valid handle', function () { + $handle = $this->generator->generate("Loewe's Fall/Winter 2026", 'products', $this->store->id); + + expect($handle)->toMatch('/^[a-z0-9]+(?:-[a-z0-9]+)*$/'); +}); + +it('excludes the current record and scopes collisions to a store', function () { + $product = Product::factory()->for($this->store)->create(['handle' => 't-shirt']); + $otherStore = Store::factory()->create(); + + expect($this->generator->generate('T-Shirt', 'products', $this->store->id, $product->id)) + ->toBe('t-shirt') + ->and($this->generator->generate('T-Shirt', 'products', $otherStore->id)) + ->toBe('t-shirt'); +}); diff --git a/tests/Unit/PricingEngineTest.php b/tests/Unit/PricingEngineTest.php new file mode 100644 index 00000000..5c26c136 --- /dev/null +++ b/tests/Unit/PricingEngineTest.php @@ -0,0 +1,18 @@ +jsonSerialize())->toBe([ + 'subtotal' => 10000, + 'discount' => 1000, + 'shipping' => 499, + 'tax_lines' => $result->taxLines, + 'tax_total' => 1805, + 'total' => 11304, + 'currency' => 'EUR', + ]); +}); diff --git a/tests/Unit/ShippingCalculatorTest.php b/tests/Unit/ShippingCalculatorTest.php new file mode 100644 index 00000000..ed7733ba --- /dev/null +++ b/tests/Unit/ShippingCalculatorTest.php @@ -0,0 +1,34 @@ + $weight, 'requires_shipping' => true]); + $line = new CartLine(['quantity' => 1, 'line_total_amount' => $subtotal]); + $line->setRelation('variant', $variant); + $cart = new Cart; + $cart->setRelation('lines', new Collection([$line])); + + return $cart; +} + +it('calculates flat weight and price shipping rates', function () { + $calculator = new ShippingCalculator; + $cart = unitCartWithLine(); + + expect($calculator->calculate(new ShippingRate(['type' => 'flat', 'config_json' => ['amount' => 499]]), $cart))->toBe(499) + ->and($calculator->calculate(new ShippingRate(['type' => 'weight', 'config_json' => ['ranges' => [['min_g' => 501, 'max_g' => 2000, 'amount' => 899]]]]), $cart))->toBe(899) + ->and($calculator->calculate(new ShippingRate(['type' => 'price', 'config_json' => ['ranges' => [['min_amount' => 5000, 'amount' => 0]]]]), $cart))->toBe(0); +}); + +it('returns null when no configured range matches', function () { + $rate = new ShippingRate(['type' => 'weight', 'config_json' => ['ranges' => [['min_g' => 0, 'max_g' => 100, 'amount' => 499]]]]); + + expect((new ShippingCalculator)->calculate($rate, unitCartWithLine()))->toBeNull(); +}); diff --git a/tests/Unit/TaxCalculatorTest.php b/tests/Unit/TaxCalculatorTest.php new file mode 100644 index 00000000..6759063b --- /dev/null +++ b/tests/Unit/TaxCalculatorTest.php @@ -0,0 +1,27 @@ + false, 'config_json' => ['default_rate_bps' => 1900]]); + $result = (new TaxCalculator)->calculate(10000, $settings); + + expect($result->taxAmount)->toBe(1900) + ->and($result->grossAmount)->toBe(11900); +}); + +it('extracts inclusive tax deterministically', function () { + $calculator = new TaxCalculator; + + expect($calculator->extractInclusive(11900, 1900))->toBe(1900) + ->and($calculator->extractInclusive(119, 1900))->toBe(19) + ->and($calculator->addExclusive(8999, 700))->toBe(630); +}); + +it('returns zero tax for zero rates and amounts', function () { + $calculator = new TaxCalculator; + + expect($calculator->addExclusive(10000, 0))->toBe(0) + ->and($calculator->extractInclusive(0, 1900))->toBe(0); +});