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

Content

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

Content

+
+``` + +## Verification + +1. Check component renders correctly +2. Test interactive states +3. Verify mobile responsiveness + +## Common Pitfalls + +- Trying to use Pro-only components in the free edition +- Not checking if a Flux component exists before creating custom implementations +- Forgetting to use the `search-docs` tool for component-specific documentation +- Not following existing project patterns for Flux usage \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/SKILL.md b/.github/skills/laravel-best-practices/SKILL.md new file mode 100644 index 00000000..aca32c9c --- /dev/null +++ b/.github/skills/laravel-best-practices/SKILL.md @@ -0,0 +1,190 @@ +--- +name: laravel-best-practices +description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns." +license: MIT +metadata: + author: laravel +--- + +# Laravel Best Practices + +Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`. + +## Consistency First + +Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. + +Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides. + +## Quick Reference + +### 1. Database Performance → `rules/db-performance.md` + +- Eager load with `with()` to prevent N+1 queries +- Enable `Model::preventLazyLoading()` in development +- Select only needed columns, avoid `SELECT *` +- `chunk()` / `chunkById()` for large datasets +- Index columns used in `WHERE`, `ORDER BY`, `JOIN` +- `withCount()` instead of loading relations to count +- `cursor()` for memory-efficient read-only iteration +- Never query in Blade templates + +### 2. Advanced Query Patterns → `rules/advanced-queries.md` + +- `addSelect()` subqueries over eager-loading entire has-many for a single value +- Dynamic relationships via subquery FK + `belongsTo` +- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries +- `setRelation()` to prevent circular N+1 queries +- `whereIn` + `pluck()` over `whereHas` for better index usage +- Two simple queries can beat one complex query +- Compound indexes matching `orderBy` column order +- Correlated subqueries in `orderBy` for has-many sorting (avoid joins) + +### 3. Security → `rules/security.md` + +- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates +- No raw SQL with user input — use Eloquent or query builder +- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes +- Validate MIME type, extension, and size for file uploads +- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields + +### 4. Caching → `rules/caching.md` + +- `Cache::remember()` over manual get/put +- `Cache::flexible()` for stale-while-revalidate on high-traffic data +- `Cache::memo()` to avoid redundant cache hits within a request +- Cache tags to invalidate related groups +- `Cache::add()` for atomic conditional writes +- `once()` to memoize per-request or per-object lifetime +- `Cache::lock()` / `lockForUpdate()` for race conditions +- Failover cache stores in production + +### 5. Eloquent Patterns → `rules/eloquent.md` + +- Correct relationship types with return type hints +- Local scopes for reusable query constraints +- Global scopes sparingly — document their existence +- Attribute casts in the `casts()` method +- Cast date columns, use Carbon instances in templates +- `whereBelongsTo($model)` for cleaner queries +- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries + +### 6. Validation & Forms → `rules/validation.md` + +- Form Request classes, not inline validation +- Array notation `['required', 'email']` for new code; follow existing convention +- `$request->validated()` only — never `$request->all()` +- `Rule::when()` for conditional validation +- `after()` instead of `withValidator()` + +### 7. Configuration → `rules/config.md` + +- `env()` only inside config files +- `App::environment()` or `app()->isProduction()` +- Config, lang files, and constants over hardcoded text + +### 8. Testing Patterns → `rules/testing.md` + +- `LazilyRefreshDatabase` over `RefreshDatabase` for speed +- `assertModelExists()` over raw `assertDatabaseHas()` +- Factory states and sequences over manual overrides +- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before +- `recycle()` to share relationship instances across factories + +### 9. Queue & Job Patterns → `rules/queue-jobs.md` + +- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]` +- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release +- Always implement `failed()`; with `retryUntil()`, set `$tries = 0` +- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs +- Horizon for complex multi-queue scenarios + +### 10. Routing & Controllers → `rules/routing.md` + +- Implicit route model binding +- Scoped bindings for nested resources +- `Route::resource()` or `apiResource()` +- Methods under 10 lines — extract to actions/services +- Type-hint Form Requests for auto-validation + +### 11. HTTP Client → `rules/http-client.md` + +- Explicit `timeout` and `connectTimeout` on every request +- `retry()` with exponential backoff for external APIs +- Check response status or use `throw()` +- `Http::pool()` for concurrent independent requests +- `Http::fake()` and `preventStrayRequests()` in tests + +### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md` + +- Event discovery over manual registration; `event:cache` in production +- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions +- Queue notifications and mailables with `ShouldQueue` +- On-demand notifications for non-user recipients +- `HasLocalePreference` on notifiable models +- `assertQueued()` not `assertSent()` for queued mailables +- Markdown mailables for transactional emails + +### 13. Error Handling → `rules/error-handling.md` + +- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern +- `ShouldntReport` for exceptions that should never log +- Throttle high-volume exceptions to protect log sinks +- `dontReportDuplicates()` for multi-catch scenarios +- Force JSON rendering for API routes +- Structured context via `context()` on exception classes + +### 14. Task Scheduling → `rules/scheduling.md` + +- `withoutOverlapping()` on variable-duration tasks +- `onOneServer()` on multi-server deployments +- `runInBackground()` for concurrent long tasks +- `environments()` to restrict to appropriate environments +- `takeUntilTimeout()` for time-bounded processing +- Schedule groups for shared configuration + +### 15. Architecture → `rules/architecture.md` + +- Single-purpose Action classes; dependency injection over `app()` helper +- Prefer official Laravel packages and follow conventions, don't override defaults +- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety +- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution + +### 16. Migrations → `rules/migrations.md` + +- Generate migrations with `php artisan make:migration` +- `constrained()` for foreign keys +- Never modify migrations that have run in production +- Add indexes in the migration, not as an afterthought +- Mirror column defaults in model `$attributes` +- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes +- One concern per migration — never mix DDL and DML + +### 17. Collections → `rules/collections.md` + +- Higher-order messages for simple collection operations +- `cursor()` vs. `lazy()` — choose based on relationship needs +- `lazyById()` when updating records while iterating +- `toQuery()` for bulk operations on collections + +### 18. Blade & Views → `rules/blade-views.md` + +- `$attributes->merge()` in component templates +- Blade components over `@include`; `@pushOnce` for per-component scripts +- View Composers for shared view data +- `@aware` for deeply nested component props + +### 19. Conventions & Style → `rules/style.md` + +- Follow Laravel naming conventions for all entities +- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions +- No JS/CSS in Blade, no HTML in PHP classes +- Code should be readable; comments only for config files + +## How to Apply + +Always use a sub-agent to read rule files and explore this skill's content. + +1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10) +2. Check sibling files for existing patterns — follow those first per Consistency First +3. Verify API syntax with `search-docs` for the installed Laravel version \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/advanced-queries.md b/.github/skills/laravel-best-practices/rules/advanced-queries.md new file mode 100644 index 00000000..920714a1 --- /dev/null +++ b/.github/skills/laravel-best-practices/rules/advanced-queries.md @@ -0,0 +1,106 @@ +# Advanced Query Patterns + +## Use `addSelect()` Subqueries for Single Values from Has-Many + +Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries. + +```php +public function scopeWithLastLoginAt($query): void +{ + $query->addSelect([ + 'last_login_at' => Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->withCasts(['last_login_at' => 'datetime']); +} +``` + +## Create Dynamic Relationships via Subquery FK + +Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection. + +```php +public function lastLogin(): BelongsTo +{ + return $this->belongsTo(Login::class); +} + +public function scopeWithLastLogin($query): void +{ + $query->addSelect([ + 'last_login_id' => Login::select('id') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->with('lastLogin'); +} +``` + +## Use Conditional Aggregates Instead of Multiple Count Queries + +Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values. + +```php +$statuses = Feature::toBase() + ->selectRaw("count(case when status = 'Requested' then 1 end) as requested") + ->selectRaw("count(case when status = 'Planned' then 1 end) as planned") + ->selectRaw("count(case when status = 'Completed' then 1 end) as completed") + ->first(); +``` + +## Use `setRelation()` to Prevent Circular N+1 + +When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries. + +```php +$feature->load('comments.user'); +$feature->comments->each->setRelation('feature', $feature); +``` + +## Prefer `whereIn` + Subquery Over `whereHas` + +`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory. + +Incorrect (correlated EXISTS re-executes per row): + +```php +$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term)); +``` + +Correct (index-friendly subquery, no PHP memory overhead): + +```php +$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id')); +``` + +## Sometimes Two Simple Queries Beat One Complex Query + +Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index. + +## Use Compound Indexes Matching `orderBy` Column Order + +When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index. + +```php +// Migration +$table->index(['last_name', 'first_name']); + +// Query — column order must match the index +User::query()->orderBy('last_name')->orderBy('first_name')->paginate(); +``` + +## Use Correlated Subqueries for Has-Many Ordering + +When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading. + +```php +public function scopeOrderByLastLogin($query): void +{ + $query->orderByDesc(Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1) + ); +} +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/architecture.md b/.github/skills/laravel-best-practices/rules/architecture.md new file mode 100644 index 00000000..6112a635 --- /dev/null +++ b/.github/skills/laravel-best-practices/rules/architecture.md @@ -0,0 +1,202 @@ +# Architecture Best Practices + +## Single-Purpose Action Classes + +Extract discrete business operations into invokable Action classes. + +```php +class CreateOrderAction +{ + public function __construct(private InventoryService $inventory) {} + + public function execute(array $data): Order + { + $order = Order::create($data); + $this->inventory->reserve($order); + + return $order; + } +} +``` + +## Use Dependency Injection + +Always use constructor injection. Avoid `app()` or `resolve()` inside classes. + +Incorrect: +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request) + { + $service = app(OrderService::class); + + return $service->create($request->validated()); + } +} +``` + +Correct: +```php +class OrderController extends Controller +{ + public function __construct(private OrderService $service) {} + + public function store(StoreOrderRequest $request) + { + return $this->service->create($request->validated()); + } +} +``` + +## Code to Interfaces + +Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability. + +Incorrect (concrete dependency): +```php +class OrderService +{ + public function __construct(private StripeGateway $gateway) {} +} +``` + +Correct (interface dependency): +```php +interface PaymentGateway +{ + public function charge(int $amount, string $customerId): PaymentResult; +} + +class OrderService +{ + public function __construct(private PaymentGateway $gateway) {} +} +``` + +Bind in a service provider: + +```php +$this->app->bind(PaymentGateway::class, StripeGateway::class); +``` + +## Default Sort by Descending + +When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined. + +Incorrect: +```php +$posts = Post::paginate(); +``` + +Correct: +```php +$posts = Post::latest()->paginate(); +``` + +## Use Atomic Locks for Race Conditions + +Prevent race conditions with `Cache::lock()` or `lockForUpdate()`. + +```php +Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) { + $order->process(); +}); + +// Or at query level +$product = Product::where('id', $id)->lockForUpdate()->first(); +``` + +## Use `mb_*` String Functions + +When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters. + +Incorrect: +```php +strlen('José'); // 5 (bytes, not characters) +strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte +``` + +Correct: +```php +mb_strlen('José'); // 4 (characters) +mb_strtolower('MÜNCHEN'); // 'münchen' + +// Prefer Laravel's Str helpers when available +Str::length('José'); // 4 +Str::lower('MÜNCHEN'); // 'münchen' +``` + +## Use `defer()` for Post-Response Work + +For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead. + +Incorrect (job overhead for trivial work): +```php +dispatch(new LogPageView($page)); +``` + +Correct (runs after response, same process): +```php +defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()])); +``` + +Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work. + +## Use `Context` for Request-Scoped Data + +The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually. + +```php +// In middleware +Context::add('tenant_id', $request->header('X-Tenant-ID')); + +// Anywhere later — controllers, jobs, log context +$tenantId = Context::get('tenant_id'); +``` + +Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`. + +## Use `Concurrency::run()` for Parallel Execution + +Run independent operations in parallel using child processes — no async libraries needed. + +```php +use Illuminate\Support\Facades\Concurrency; + +[$users, $orders] = Concurrency::run([ + fn () => User::count(), + fn () => Order::where('status', 'pending')->count(), +]); +``` + +Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially. + +## Convention Over Configuration + +Follow Laravel conventions. Don't override defaults unnecessarily. + +Incorrect: +```php +class Customer extends Model +{ + protected $table = 'Customer'; + protected $primaryKey = 'customer_id'; + + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id'); + } +} +``` + +Correct: +```php +class Customer extends Model +{ + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class); + } +} +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/blade-views.md b/.github/skills/laravel-best-practices/rules/blade-views.md new file mode 100644 index 00000000..c6f8aaf1 --- /dev/null +++ b/.github/skills/laravel-best-practices/rules/blade-views.md @@ -0,0 +1,36 @@ +# Blade & Views Best Practices + +## Use `$attributes->merge()` in Component Templates + +Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly. + +```blade +
merge(['class' => 'alert alert-'.$type]) }}> + {{ $message }} +
+``` + +## Use `@pushOnce` for Per-Component Scripts + +If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once. + +## Prefer Blade Components Over `@include` + +`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots. + +## Use View Composers for Shared View Data + +If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it. + +## Use Blade Fragments for Partial Re-Renders (htmx/Turbo) + +A single view can return either the full page or just a fragment, keeping routing clean. + +```php +return view('dashboard', compact('users')) + ->fragmentIf($request->hasHeader('HX-Request'), 'user-list'); +``` + +## Use `@aware` for Deeply Nested Component Props + +Avoids re-passing parent props through every level of nested components. \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/caching.md b/.github/skills/laravel-best-practices/rules/caching.md new file mode 100644 index 00000000..e65146dc --- /dev/null +++ b/.github/skills/laravel-best-practices/rules/caching.md @@ -0,0 +1,70 @@ +# Caching Best Practices + +## Use `Cache::remember()` Instead of Manual Get/Put + +Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions. + +Incorrect: +```php +$val = Cache::get('stats'); +if (! $val) { + $val = $this->computeStats(); + Cache::put('stats', $val, 60); +} +``` + +Correct: +```php +$val = Cache::remember('stats', 60, fn () => $this->computeStats()); +``` + +## Use `Cache::flexible()` for Stale-While-Revalidate + +On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background. + +Incorrect: `Cache::remember('users', 300, fn () => User::all());` + +Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function. + +## Use `Cache::memo()` to Avoid Redundant Hits Within a Request + +If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory. + +`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5. + +## Use Cache Tags to Invalidate Related Groups + +Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`. + +```php +Cache::tags(['user-1'])->flush(); +``` + +## Use `Cache::add()` for Atomic Conditional Writes + +`add()` only writes if the key does not exist — atomic, no race condition between checking and writing. + +Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }` + +Correct: `Cache::add('lock', true, 10);` + +## Use `once()` for Per-Request Memoization + +`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory. + +```php +public function roles(): Collection +{ + return once(fn () => $this->loadRoles()); +} +``` + +Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching. + +## Configure Failover Cache Stores in Production + +If Redis goes down, the app falls back to a secondary store automatically. + +```php +'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/collections.md b/.github/skills/laravel-best-practices/rules/collections.md new file mode 100644 index 00000000..14f683d3 --- /dev/null +++ b/.github/skills/laravel-best-practices/rules/collections.md @@ -0,0 +1,44 @@ +# Collection Best Practices + +## Use Higher-Order Messages for Simple Operations + +Incorrect: +```php +$users->each(function (User $user) { + $user->markAsVip(); +}); +``` + +Correct: `$users->each->markAsVip();` + +Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc. + +## Choose `cursor()` vs. `lazy()` Correctly + +- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk). +- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading. + +Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored. + +Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work. + +## Use `lazyById()` When Updating Records While Iterating + +`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation. + +## Use `toQuery()` for Bulk Operations on Collections + +Avoids manual `whereIn` construction. + +Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);` + +Correct: `$users->toQuery()->update([...]);` + +## Use `#[CollectedBy]` for Custom Collection Classes + +More declarative than overriding `newCollection()`. + +```php +#[CollectedBy(UserCollection::class)] +class User extends Model {} +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/config.md b/.github/skills/laravel-best-practices/rules/config.md new file mode 100644 index 00000000..193155d6 --- /dev/null +++ b/.github/skills/laravel-best-practices/rules/config.md @@ -0,0 +1,73 @@ +# Configuration Best Practices + +## `env()` Only in Config Files + +Direct `env()` calls may return `null` when config is cached. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'key' => env('API_KEY'), + +// Application code +$key = config('services.key'); +``` + +## Use Encrypted Env or External Secrets + +Never store production secrets in plain `.env` files in version control. + +Incorrect: +```bash + +# .env committed to repo or shared in Slack + +STRIPE_SECRET=sk_live_abc123 +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI +``` + +Correct: +```bash +php artisan env:encrypt --env=production --readable +php artisan env:decrypt --env=production +``` + +For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime. + +## Use `App::environment()` for Environment Checks + +Incorrect: +```php +if (env('APP_ENV') === 'production') { +``` + +Correct: +```php +if (app()->isProduction()) { +// or +if (App::environment('production')) { +``` + +## Use Constants and Language Files + +Use class constants instead of hardcoded magic strings for model states, types, and statuses. + +```php +// Incorrect +return $this->type === 'normal'; + +// Correct +return $this->type === self::TYPE_NORMAL; +``` + +If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there. + +```php +// Only when lang files already exist in the project +return back()->with('message', __('app.article_added')); +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/db-performance.md b/.github/skills/laravel-best-practices/rules/db-performance.md new file mode 100644 index 00000000..8fb71937 --- /dev/null +++ b/.github/skills/laravel-best-practices/rules/db-performance.md @@ -0,0 +1,192 @@ +# Database Performance Best Practices + +## Always Eager Load Relationships + +Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront. + +Incorrect (N+1 — executes 1 + N queries): +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Correct (2 queries total): +```php +$posts = Post::with('author')->get(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Constrain eager loads to select only needed columns (always include the foreign key): + +```php +$users = User::with(['posts' => function ($query) { + $query->select('id', 'user_id', 'title') + ->where('published', true) + ->latest() + ->limit(10); +}])->get(); +``` + +## Prevent Lazy Loading in Development + +Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development. + +```php +public function boot(): void +{ + Model::preventLazyLoading(! app()->isProduction()); +} +``` + +Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded. + +## Select Only Needed Columns + +Avoid `SELECT *` — especially when tables have large text or JSON columns. + +Incorrect: +```php +$posts = Post::with('author')->get(); +``` + +Correct: +```php +$posts = Post::select('id', 'title', 'user_id', 'created_at') + ->with(['author:id,name,avatar']) + ->get(); +``` + +When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match. + +## Chunk Large Datasets + +Never load thousands of records at once. Use chunking for batch processing. + +Incorrect: +```php +$users = User::all(); +foreach ($users as $user) { + $user->notify(new WeeklyDigest); +} +``` + +Correct: +```php +User::where('subscribed', true)->chunk(200, function ($users) { + foreach ($users as $user) { + $user->notify(new WeeklyDigest); + } +}); +``` + +Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change: + +```php +User::where('active', false)->chunkById(200, function ($users) { + $users->each->delete(); +}); +``` + +## Add Database Indexes + +Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->index()->constrained(); + $table->string('status')->index(); + $table->timestamps(); + $table->index(['status', 'created_at']); +}); +``` + +Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`). + +## Use `withCount()` for Counting Relations + +Never load entire collections just to count them. + +Incorrect: +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->comments->count(); +} +``` + +Correct: +```php +$posts = Post::withCount('comments')->get(); +foreach ($posts as $post) { + echo $post->comments_count; +} +``` + +Conditional counting: + +```php +$posts = Post::withCount([ + 'comments', + 'comments as approved_comments_count' => function ($query) { + $query->where('approved', true); + }, +])->get(); +``` + +## Use `cursor()` for Memory-Efficient Iteration + +For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator. + +Incorrect: +```php +$users = User::where('active', true)->get(); +``` + +Correct: +```php +foreach (User::where('active', true)->cursor() as $user) { + ProcessUser::dispatch($user->id); +} +``` + +Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records. + +## No Queries in Blade Templates + +Never execute queries in Blade templates. Pass data from controllers. + +Incorrect: +```blade +@foreach (User::all() as $user) + {{ $user->profile->name }} +@endforeach +``` + +Correct: +```php +// Controller +$users = User::with('profile')->get(); +return view('users.index', compact('users')); +``` + +```blade +@foreach ($users as $user) + {{ $user->profile->name }} +@endforeach +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/eloquent.md b/.github/skills/laravel-best-practices/rules/eloquent.md new file mode 100644 index 00000000..09cd66a0 --- /dev/null +++ b/.github/skills/laravel-best-practices/rules/eloquent.md @@ -0,0 +1,148 @@ +# Eloquent Best Practices + +## Use Correct Relationship Types + +Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints. + +```php +public function comments(): HasMany +{ + return $this->hasMany(Comment::class); +} + +public function author(): BelongsTo +{ + return $this->belongsTo(User::class, 'user_id'); +} +``` + +## Use Local Scopes for Reusable Queries + +Extract reusable query constraints into local scopes to avoid duplication. + +Incorrect: +```php +$active = User::where('verified', true)->whereNotNull('activated_at')->get(); +$articles = Article::whereHas('user', function ($q) { + $q->where('verified', true)->whereNotNull('activated_at'); +})->get(); +``` + +Correct: +```php +public function scopeActive(Builder $query): Builder +{ + return $query->where('verified', true)->whereNotNull('activated_at'); +} + +// Usage +$active = User::active()->get(); +$articles = Article::whereHas('user', fn ($q) => $q->active())->get(); +``` + +## Apply Global Scopes Sparingly + +Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy. + +Incorrect (global scope for a conditional filter): +```php +class PublishedScope implements Scope +{ + public function apply(Builder $builder, Model $model): void + { + $builder->where('published', true); + } +} +// Now admin panels, reports, and background jobs all silently skip drafts +``` + +Correct (local scope you opt into): +```php +public function scopePublished(Builder $query): Builder +{ + return $query->where('published', true); +} + +Post::published()->paginate(); // Explicit +Post::paginate(); // Admin sees all +``` + +## Define Attribute Casts + +Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion. + +```php +protected function casts(): array +{ + return [ + 'is_active' => 'boolean', + 'metadata' => 'array', + 'total' => 'decimal:2', + ]; +} +``` + +## Cast Date Columns Properly + +Always cast date columns. Use Carbon instances in templates instead of formatting strings manually. + +Incorrect: +```blade +{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }} +``` + +Correct: +```php +protected function casts(): array +{ + return [ + 'ordered_at' => 'datetime', + ]; +} +``` + +```blade +{{ $order->ordered_at->toDateString() }} +{{ $order->ordered_at->format('m-d') }} +``` + +## Use `whereBelongsTo()` for Relationship Queries + +Cleaner than manually specifying foreign keys. + +Incorrect: +```php +Post::where('user_id', $user->id)->get(); +``` + +Correct: +```php +Post::whereBelongsTo($user)->get(); +Post::whereBelongsTo($user, 'author')->get(); +``` + +## Avoid Hardcoded Table Names in Queries + +Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string). + +Incorrect: +```php +DB::table('users')->where('active', true)->get(); + +$query->join('companies', 'companies.id', '=', 'users.company_id'); + +DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); +``` + +Correct — reference the model's table: +```php +DB::table((new User)->getTable())->where('active', true)->get(); + +// Even better — use Eloquent or the query builder instead of raw SQL +User::where('active', true)->get(); +Order::where('status', 'pending')->get(); +``` + +Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable. + +**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration. \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/error-handling.md b/.github/skills/laravel-best-practices/rules/error-handling.md new file mode 100644 index 00000000..bb8e7a38 --- /dev/null +++ b/.github/skills/laravel-best-practices/rules/error-handling.md @@ -0,0 +1,72 @@ +# Error Handling Best Practices + +## Exception Reporting and Rendering + +There are two valid approaches — choose one and apply it consistently across the project. + +**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find: + +```php +class InvalidOrderException extends Exception +{ + public function report(): void { /* custom reporting */ } + + public function render(Request $request): Response + { + return response()->view('errors.invalid-order', status: 422); + } +} +``` + +**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture: + +```php +->withExceptions(function (Exceptions $exceptions) { + $exceptions->report(function (InvalidOrderException $e) { /* ... */ }); + $exceptions->render(function (InvalidOrderException $e, Request $request) { + return response()->view('errors.invalid-order', status: 422); + }); +}) +``` + +Check the existing codebase and follow whichever pattern is already established. + +## Use `ShouldntReport` for Exceptions That Should Never Log + +More discoverable than listing classes in `dontReport()`. + +```php +class PodcastProcessingException extends Exception implements ShouldntReport {} +``` + +## Throttle High-Volume Exceptions + +A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type. + +## Enable `dontReportDuplicates()` + +Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks. + +## Force JSON Error Rendering for API Routes + +Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes. + +```php +$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { + return $request->is('api/*') || $request->expectsJson(); +}); +``` + +## Add Context to Exception Classes + +Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry. + +```php +class InvalidOrderException extends Exception +{ + public function context(): array + { + return ['order_id' => $this->orderId]; + } +} +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/events-notifications.md b/.github/skills/laravel-best-practices/rules/events-notifications.md new file mode 100644 index 00000000..47fcf324 --- /dev/null +++ b/.github/skills/laravel-best-practices/rules/events-notifications.md @@ -0,0 +1,52 @@ +# Events & Notifications Best Practices + +## Rely on Event Discovery + +Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`. + +## Run `event:cache` in Production Deploy + +Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`. + +## Use `ShouldDispatchAfterCommit` Inside Transactions + +Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet. + +```php +class OrderShipped implements ShouldDispatchAfterCommit {} +``` + +## Always Queue Notifications + +Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response. + +```php +class InvoicePaid extends Notification implements ShouldQueue +{ + use Queueable; +} +``` + +## Use `afterCommit()` on Notifications in Transactions + +Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits. + +```php +$user->notify((new InvoicePaid($invoice))->afterCommit()); +``` + +## Route Notification Channels to Dedicated Queues + +Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues. + +## Use On-Demand Notifications for Non-User Recipients + +Avoid creating dummy models to send notifications to arbitrary addresses. + +```php +Notification::route('mail', 'admin@example.com')->notify(new SystemAlert()); +``` + +## Implement `HasLocalePreference` on Notifiable Models + +Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/http-client.md b/.github/skills/laravel-best-practices/rules/http-client.md new file mode 100644 index 00000000..fd37ddb9 --- /dev/null +++ b/.github/skills/laravel-best-practices/rules/http-client.md @@ -0,0 +1,160 @@ +# HTTP Client Best Practices + +## Always Set Explicit Timeouts + +The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users'); +``` + +Correct: +```php +$response = Http::timeout(5) + ->connectTimeout(3) + ->get('https://api.example.com/users'); +``` + +For service-specific clients, define timeouts in a macro: + +```php +Http::macro('github', function () { + return Http::baseUrl('https://api.github.com') + ->timeout(10) + ->connectTimeout(3) + ->withToken(config('services.github.token')); +}); + +$response = Http::github()->get('/repos/laravel/framework'); +``` + +## Use Retry with Backoff for External APIs + +External APIs have transient failures. Use `retry()` with increasing delays. + +Incorrect: +```php +$response = Http::post('https://api.stripe.com/v1/charges', $data); + +if ($response->failed()) { + throw new PaymentFailedException('Charge failed'); +} +``` + +Correct: +```php +$response = Http::retry([100, 500, 1000]) + ->timeout(10) + ->post('https://api.stripe.com/v1/charges', $data); +``` + +Only retry on specific errors: + +```php +$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException && $exception->response->serverError()); +})->post('https://api.example.com/data'); +``` + +## Handle Errors Explicitly + +The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users/1'); +$user = $response->json(); // Could be an error body +``` + +Correct: +```php +$response = Http::timeout(5) + ->get('https://api.example.com/users/1') + ->throw(); + +$user = $response->json(); +``` + +For graceful degradation: + +```php +$response = Http::get('https://api.example.com/users/1'); + +if ($response->successful()) { + return $response->json(); +} + +if ($response->notFound()) { + return null; +} + +$response->throw(); +``` + +## Use Request Pooling for Concurrent Requests + +When making multiple independent API calls, use `Http::pool()` instead of sequential calls. + +Incorrect: +```php +$users = Http::get('https://api.example.com/users')->json(); +$posts = Http::get('https://api.example.com/posts')->json(); +$comments = Http::get('https://api.example.com/comments')->json(); +``` + +Correct: +```php +use Illuminate\Http\Client\Pool; + +$responses = Http::pool(fn (Pool $pool) => [ + $pool->as('users')->get('https://api.example.com/users'), + $pool->as('posts')->get('https://api.example.com/posts'), + $pool->as('comments')->get('https://api.example.com/comments'), +]); + +$users = $responses['users']->json(); +$posts = $responses['posts']->json(); +``` + +## Fake HTTP Calls in Tests + +Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`. + +Incorrect: +```php +it('syncs user from API', function () { + $service = new UserSyncService; + $service->sync(1); // Hits the real API +}); +``` + +Correct: +```php +it('syncs user from API', function () { + Http::preventStrayRequests(); + + Http::fake([ + 'api.example.com/users/1' => Http::response([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]), + ]); + + $service = new UserSyncService; + $service->sync(1); + + Http::assertSent(function (Request $request) { + return $request->url() === 'https://api.example.com/users/1'; + }); +}); +``` + +Test failure scenarios too: + +```php +Http::fake([ + 'api.example.com/*' => Http::failedConnection(), +]); +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/mail.md b/.github/skills/laravel-best-practices/rules/mail.md new file mode 100644 index 00000000..2435d9cc --- /dev/null +++ b/.github/skills/laravel-best-practices/rules/mail.md @@ -0,0 +1,27 @@ +# Mail Best Practices + +## Implement `ShouldQueue` on the Mailable Class + +Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it. + +## Use `afterCommit()` on Mailables Inside Transactions + +A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor. + +## Use `assertQueued()` Not `assertSent()` for Queued Mailables + +`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint. + +Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. + +Correct: `Mail::assertQueued(OrderShipped::class);` + +## Use Markdown Mailables for Transactional Emails + +Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag. + +## Separate Content Tests from Sending Tests + +Content tests: instantiate the mailable directly, call `assertSeeInHtml()`. +Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`. +Don't mix them — it conflates concerns and makes tests brittle. \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/migrations.md b/.github/skills/laravel-best-practices/rules/migrations.md new file mode 100644 index 00000000..de25aa39 --- /dev/null +++ b/.github/skills/laravel-best-practices/rules/migrations.md @@ -0,0 +1,121 @@ +# Migration Best Practices + +## Generate Migrations with Artisan + +Always use `php artisan make:migration` for consistent naming and timestamps. + +Incorrect (manually created file): +```php +// database/migrations/posts_migration.php ← wrong naming, no timestamp +``` + +Correct (Artisan-generated): +```bash +php artisan make:migration create_posts_table +php artisan make:migration add_slug_to_posts_table +``` + +## Use `constrained()` for Foreign Keys + +Automatic naming and referential integrity. + +```php +$table->foreignId('user_id')->constrained()->cascadeOnDelete(); + +// Non-standard names +$table->foreignId('author_id')->constrained('users'); +``` + +## Never Modify Deployed Migrations + +Once a migration has run in production, treat it as immutable. Create a new migration to change the table. + +Incorrect (editing a deployed migration): +```php +// 2024_01_01_create_posts_table.php — already in production +$table->string('slug')->unique(); // ← added after deployment +``` + +Correct (new migration to alter): +```php +// 2024_03_15_add_slug_to_posts_table.php +Schema::table('posts', function (Blueprint $table) { + $table->string('slug')->unique()->after('title'); +}); +``` + +## Add Indexes in the Migration + +Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->index(); + $table->string('status')->index(); + $table->timestamp('shipped_at')->nullable()->index(); + $table->timestamps(); +}); +``` + +## Mirror Defaults in Model `$attributes` + +When a column has a database default, mirror it in the model so new instances have correct values before saving. + +```php +// Migration +$table->string('status')->default('pending'); + +// Model +protected $attributes = [ + 'status' => 'pending', +]; +``` + +## Write Reversible `down()` Methods by Default + +Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments. + +```php +public function down(): void +{ + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn('slug'); + }); +} +``` + +For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported. + +## Keep Migrations Focused + +One concern per migration. Never mix DDL (schema changes) and DML (data manipulation). + +Incorrect (partial failure creates unrecoverable state): +```php +public function up(): void +{ + Schema::create('settings', function (Blueprint $table) { ... }); + DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +} +``` + +Correct (separate migrations): +```php +// Migration 1: create_settings_table +Schema::create('settings', function (Blueprint $table) { ... }); + +// Migration 2: seed_default_settings +DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/queue-jobs.md b/.github/skills/laravel-best-practices/rules/queue-jobs.md new file mode 100644 index 00000000..f7aa548b --- /dev/null +++ b/.github/skills/laravel-best-practices/rules/queue-jobs.md @@ -0,0 +1,144 @@ +# Queue & Job Best Practices + +## Set `retry_after` Greater Than `timeout` + +If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution. + +Incorrect (`retry_after` ≤ `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 90 ← job retried while still running! +``` + +Correct (`retry_after` > `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 180 ← safely longer than any job timeout +``` + +## Use Exponential Backoff + +Use progressively longer delays between retries to avoid hammering failing services. + +Incorrect (fixed retry interval): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + // Default: retries immediately, overwhelming the API +} +``` + +Correct (exponential backoff): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + public $backoff = [1, 5, 10]; +} +``` + +## Implement `ShouldBeUnique` + +Prevent duplicate job processing. + +```php +class GenerateInvoice implements ShouldQueue, ShouldBeUnique +{ + public function uniqueId(): string + { + return $this->order->id; + } + + public $uniqueFor = 3600; +} +``` + +## Always Implement `failed()` + +Handle errors explicitly — don't rely on silent failure. + +```php +public function failed(?Throwable $exception): void +{ + $this->podcast->update(['status' => 'failed']); + Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]); +} +``` + +## Rate Limit External API Calls in Jobs + +Use `RateLimited` middleware to throttle jobs calling third-party APIs. + +```php +public function middleware(): array +{ + return [new RateLimited('external-api')]; +} +``` + +## Batch Related Jobs + +Use `Bus::batch()` when jobs should succeed or fail together. + +```php +Bus::batch([ + new ImportCsvChunk($chunk1), + new ImportCsvChunk($chunk2), +]) +->then(fn (Batch $batch) => Notification::send($user, new ImportComplete)) +->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed')) +->dispatch(); +``` + +## `retryUntil()` Needs `$tries = 0` + +When using time-based retry limits, set `$tries = 0` to avoid premature failure. + +```php +public $tries = 0; + +public function retryUntil(): \DateTimeInterface +{ + return now()->addHours(4); +} +``` + +## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release + +`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue. + +```php +class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing +{ + // Lock releases when processing begins, not when it finishes +} +``` + +## Use Horizon for Complex Queue Scenarios + +Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities. + +```php +// config/horizon.php +'environments' => [ + 'production' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['high', 'default', 'low'], + 'balance' => 'auto', + 'minProcesses' => 1, + 'maxProcesses' => 10, + 'tries' => 3, + ], + ], +], +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/routing.md b/.github/skills/laravel-best-practices/rules/routing.md new file mode 100644 index 00000000..977d136e --- /dev/null +++ b/.github/skills/laravel-best-practices/rules/routing.md @@ -0,0 +1,99 @@ +# Routing & Controllers Best Practices + +## Use Implicit Route Model Binding + +Let Laravel resolve models automatically from route parameters. + +Incorrect: +```php +public function show(int $id) +{ + $post = Post::findOrFail($id); +} +``` + +Correct: +```php +public function show(Post $post) +{ + return view('posts.show', ['post' => $post]); +} +``` + +## Use Scoped Bindings for Nested Resources + +Enforce parent-child relationships automatically. + +```php +Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { + // $post is automatically scoped to $user +})->scopeBindings(); +``` + +## Use Resource Controllers + +Use `Route::resource()` or `apiResource()` for RESTful endpoints. + +```php +Route::resource('posts', PostController::class); +// In routes/api.php — the /api prefix is applied automatically +Route::apiResource('posts', Api\PostController::class); +``` + +## Keep Controllers Thin + +Aim for under 10 lines per method. Extract business logic to action or service classes. + +Incorrect: +```php +public function store(Request $request) +{ + $validated = $request->validate([...]); + if ($request->hasFile('image')) { + $request->file('image')->move(public_path('images')); + } + $post = Post::create($validated); + $post->tags()->sync($validated['tags']); + event(new PostCreated($post)); + return redirect()->route('posts.show', $post); +} +``` + +Correct: +```php +public function store(StorePostRequest $request, CreatePostAction $create) +{ + $post = $create->execute($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +## Type-Hint Form Requests + +Type-hinting Form Requests triggers automatic validation and authorization before the method executes. + +Incorrect: +```php +public function store(Request $request): RedirectResponse +{ + $validated = $request->validate([ + 'title' => ['required', 'max:255'], + 'body' => ['required'], + ]); + + Post::create($validated); + + return redirect()->route('posts.index'); +} +``` + +Correct: +```php +public function store(StorePostRequest $request): RedirectResponse +{ + Post::create($request->validated()); + + return redirect()->route('posts.index'); +} +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/scheduling.md b/.github/skills/laravel-best-practices/rules/scheduling.md new file mode 100644 index 00000000..dfaefa26 --- /dev/null +++ b/.github/skills/laravel-best-practices/rules/scheduling.md @@ -0,0 +1,39 @@ +# Task Scheduling Best Practices + +## Use `withoutOverlapping()` on Variable-Duration Tasks + +Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion. + +## Use `onOneServer()` on Multi-Server Deployments + +Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached). + +## Use `runInBackground()` for Concurrent Long Tasks + +By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes. + +## Use `environments()` to Restrict Tasks + +Prevent accidental execution of production-only tasks (billing, reporting) on staging. + +```php +Schedule::command('billing:charge')->monthly()->environments(['production']); +``` + +## Use `takeUntilTimeout()` for Time-Bounded Processing + +A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time. + +## Use Schedule Groups for Shared Configuration + +Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks. + +```php +Schedule::daily() + ->onOneServer() + ->timezone('America/New_York') + ->group(function () { + Schedule::command('emails:send --force'); + Schedule::command('emails:prune'); + }); +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/security.md b/.github/skills/laravel-best-practices/rules/security.md new file mode 100644 index 00000000..909ff91a --- /dev/null +++ b/.github/skills/laravel-best-practices/rules/security.md @@ -0,0 +1,198 @@ +# Security Best Practices + +## Mass Assignment Protection + +Every model must define `$fillable` (whitelist) or `$guarded` (blacklist). + +Incorrect: +```php +class User extends Model +{ + protected $guarded = []; // All fields are mass assignable +} +``` + +Correct: +```php +class User extends Model +{ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; +} +``` + +Never use `$guarded = []` on models that accept user input. + +## Authorize Every Action + +Use policies or gates in controllers. Never skip authorization. + +Incorrect: +```php +public function update(UpdatePostRequest $request, Post $post) +{ + $post->update($request->validated()); +} +``` + +Correct: +```php +public function update(UpdatePostRequest $request, Post $post) +{ + Gate::authorize('update', $post); + + $post->update($request->validated()); +} +``` + +Or via Form Request: + +```php +public function authorize(): bool +{ + return $this->user()->can('update', $this->route('post')); +} +``` + +## Prevent SQL Injection + +Always use parameter binding. Never interpolate user input into queries. + +Incorrect: +```php +DB::select("SELECT * FROM users WHERE name = '{$request->name}'"); +``` + +Correct: +```php +User::where('name', $request->name)->get(); + +// Raw expressions with bindings +User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get(); +``` + +## Escape Output to Prevent XSS + +Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content. + +Incorrect: +```blade +{!! $user->bio !!} +``` + +Correct: +```blade +{{ $user->bio }} +``` + +## CSRF Protection + +Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied. + +Incorrect: +```blade +
+ +
+``` + +Correct: +```blade +
+ @csrf + +
+``` + +## Rate Limit Auth and API Routes + +Apply `throttle` middleware to authentication and API routes. + +```php +RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by($request->ip()); +}); + +Route::post('/login', LoginController::class)->middleware('throttle:login'); +``` + +## Validate File Uploads + +Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames. + +```php +public function rules(): array +{ + return [ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + ]; +} +``` + +Store with generated filenames: + +```php +$path = $request->file('avatar')->store('avatars', 'public'); +``` + +## Keep Secrets Out of Code + +Never commit `.env`. Access secrets via `config()` only. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'api_key' => env('API_KEY'), + +// In application code +$key = config('services.api_key'); +``` + +## Audit Dependencies + +Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment. + +```bash +composer audit +``` + +## Encrypt Sensitive Database Fields + +Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`. + +Incorrect: +```php +class Integration extends Model +{ + protected function casts(): array + { + return [ + 'api_key' => 'string', + ]; + } +} +``` + +Correct: +```php +class Integration extends Model +{ + protected $hidden = ['api_key', 'api_secret']; + + protected function casts(): array + { + return [ + 'api_key' => 'encrypted', + 'api_secret' => 'encrypted', + ]; + } +} +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/style.md b/.github/skills/laravel-best-practices/rules/style.md new file mode 100644 index 00000000..67af9891 --- /dev/null +++ b/.github/skills/laravel-best-practices/rules/style.md @@ -0,0 +1,125 @@ +# Conventions & Style + +## Follow Laravel Naming Conventions + +| What | Convention | Good | Bad | +|------|-----------|------|-----| +| Controller | singular | `ArticleController` | `ArticlesController` | +| Model | singular | `User` | `Users` | +| Table | plural, snake_case | `article_comments` | `articleComments` | +| Pivot table | singular alphabetical | `article_user` | `user_article` | +| Column | snake_case, no model name | `meta_title` | `article_meta_title` | +| Foreign key | singular model + `_id` | `article_id` | `articles_id` | +| Route | plural | `articles/1` | `article/1` | +| Route name | snake_case with dots | `users.show_active` | `users.show-active` | +| Method | camelCase | `getAll` | `get_all` | +| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` | +| Collection | descriptive, plural | `$activeUsers` | `$data` | +| Object | descriptive, singular | `$activeUser` | `$users` | +| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` | +| Config | snake_case | `google_calendar.php` | `googleCalendar.php` | +| Enum | singular | `UserType` | `UserTypes` | + +## Prefer Shorter Readable Syntax + +| Verbose | Shorter | +|---------|---------| +| `Session::get('cart')` | `session('cart')` | +| `$request->session()->get('cart')` | `session('cart')` | +| `$request->input('name')` | `$request->name` | +| `return Redirect::back()` | `return back()` | +| `Carbon::now()` | `now()` | +| `App::make('Class')` | `app('Class')` | +| `->where('column', '=', 1)` | `->where('column', 1)` | +| `->orderBy('created_at', 'desc')` | `->latest()` | +| `->orderBy('created_at', 'asc')` | `->oldest()` | +| `->first()->name` | `->value('name')` | + +## Use Laravel String & Array Helpers + +Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them. + +Strings — use `Str` and fluent `Str::of()` over raw PHP: +```php +// Incorrect +$slug = strtolower(str_replace(' ', '-', $title)); +$short = substr($text, 0, 100) . '...'; +$class = substr(strrchr('App\Models\User', '\'), 1); + +// Correct +$slug = Str::slug($title); +$short = Str::limit($text, 100); +$class = class_basename('App\Models\User'); +``` + +Fluent strings — chain operations for complex transformations: +```php +// Incorrect +$result = strtolower(trim(str_replace('_', '-', $input))); + +// Correct +$result = Str::of($input)->trim()->replace('_', '-')->lower(); +``` + +Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`. + +Arrays — use `Arr` over raw PHP: +```php +// Incorrect +$name = isset($array['user']['name']) ? $array['user']['name'] : 'default'; + +// Correct +$name = Arr::get($array, 'user.name', 'default'); +``` + +Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`. + +Numbers — use `Number` for display formatting: +```php +Number::format(1000000); // "1,000,000" +Number::currency(1500, 'USD'); // "$1,500.00" +Number::abbreviate(1000000); // "1M" +Number::fileSize(1024 * 1024); // "1 MB" +Number::percentage(75.5); // "75.5%" +``` + +URIs — use `Uri` for URL manipulation: +```php +$uri = Uri::of('https://example.com/search') + ->withQuery(['q' => 'laravel', 'page' => 1]); +``` + +Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining. + +Use `search-docs` for the full list of available methods — these helpers are extensive. + +## No Inline JS/CSS in Blade + +Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes. + +Incorrect: +```blade +let article = `{{ json_encode($article) }}`; +``` + +Correct: +```blade + +``` + +Pass data to JS via data attributes or use a dedicated PHP-to-JS package. + +## No Unnecessary Comments + +Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected. + +Incorrect: +```php +// Check if there are any joins +if (count((array) $builder->getQuery()->joins) > 0) +``` + +Correct: +```php +if ($this->hasJoins()) +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/testing.md b/.github/skills/laravel-best-practices/rules/testing.md new file mode 100644 index 00000000..287b083b --- /dev/null +++ b/.github/skills/laravel-best-practices/rules/testing.md @@ -0,0 +1,43 @@ +# Testing Best Practices + +## Use `LazilyRefreshDatabase` Over `RefreshDatabase` + +`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date. + +## Use Model Assertions Over Raw Database Assertions + +Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);` + +Correct: `$this->assertModelExists($user);` + +More expressive, type-safe, and fails with clearer messages. + +## Use Factory States and Sequences + +Named states make tests self-documenting. Sequences eliminate repetitive setup. + +Incorrect: `User::factory()->create(['email_verified_at' => null]);` + +Correct: `User::factory()->unverified()->create();` + +## Use `Exceptions::fake()` to Assert Exception Reporting + +Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally. + +## Call `Event::fake()` After Factory Setup + +Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models. + +Incorrect: `Event::fake(); $user = User::factory()->create();` + +Correct: `$user = User::factory()->create(); Event::fake();` + +## Use `recycle()` to Share Relationship Instances Across Factories + +Without `recycle()`, nested factories create separate instances of the same conceptual entity. + +```php +Ticket::factory() + ->recycle(Airline::factory()->create()) + ->create(); +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/validation.md b/.github/skills/laravel-best-practices/rules/validation.md new file mode 100644 index 00000000..a20202ff --- /dev/null +++ b/.github/skills/laravel-best-practices/rules/validation.md @@ -0,0 +1,75 @@ +# Validation & Forms Best Practices + +## Use Form Request Classes + +Extract validation from controllers into dedicated Form Request classes. + +Incorrect: +```php +public function store(Request $request) +{ + $request->validate([ + 'title' => 'required|max:255', + 'body' => 'required', + ]); +} +``` + +Correct: +```php +public function store(StorePostRequest $request) +{ + Post::create($request->validated()); +} +``` + +## Array vs. String Notation for Rules + +Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses. + +```php +// Preferred for new code +'email' => ['required', 'email', Rule::unique('users')], + +// Follow existing convention if the project uses string notation +'email' => 'required|email|unique:users', +``` + +## Always Use `validated()` + +Get only validated data. Never use `$request->all()` for mass operations. + +Incorrect: +```php +Post::create($request->all()); +``` + +Correct: +```php +Post::create($request->validated()); +``` + +## Use `Rule::when()` for Conditional Validation + +```php +'company_name' => [ + Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']), +], +``` + +## Use the `after()` Method for Custom Validation + +Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields. + +```php +public function after(): array +{ + return [ + function (Validator $validator) { + if ($this->quantity > Product::find($this->product_id)?->stock) { + $validator->errors()->add('quantity', 'Not enough stock.'); + } + }, + ]; +} +``` \ No newline at end of file diff --git a/.github/skills/livewire-development/SKILL.md b/.github/skills/livewire-development/SKILL.md new file mode 100644 index 00000000..c009dae6 --- /dev/null +++ b/.github/skills/livewire-development/SKILL.md @@ -0,0 +1,156 @@ +--- +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 (default in v4) + +php artisan make:livewire create-post + +# Multi-file component + +php artisan make:livewire create-post --mfc + +# Class-based component (v3 style) + +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 + +Before creating a component, check `config/livewire.php` for directory overrides, which change where files are stored. Then, look at existing files in those directories (defaulting to `app/Livewire/` and `resources/views/livewire/`) to match the established convention. + +### Component Format Reference + +| Format | Flag | Class Path | View Path | +|--------|------|------------|-----------| +| Single-file (SFC) | default | — | `resources/views/livewire/create-post.blade.php` (PHP + Blade in one file) | +| Multi-file (MFC) | `--mfc` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` | +| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` | +| View-based | ⚡ prefix | — | `resources/views/livewire/create-post.blade.php` (Blade-only with functional state) | + +Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates files at `app/Livewire/Posts/CreatePost.php` and `resources/views/livewire/posts/create-post.blade.php`. + +### Single-File Component Example + + +```php +count++; + } +} +?> + +
+ +
+``` + +## Livewire 4 Specifics + +### Key Changes From Livewire 3 + +These things changed in Livewire 4, but may not have been updated in this application. Verify this application's setup to ensure you follow existing conventions. + +- Use `Route::livewire()` for full-page components (e.g., `Route::livewire('/posts/create', CreatePost::class)`); config keys renamed: `layout` → `component_layout`, `lazy_placeholder` → `component_placeholder`. +- `wire:model` now ignores child events by default (use `wire:model.deep` for old behavior); `wire:scroll` renamed to `wire:navigate:scroll`. +- Component tags must be properly closed; `wire:transition` now uses View Transitions API (modifiers removed). +- JavaScript: `$wire.$js('name', fn)` → `$wire.$js.name = fn`; `commit`/`request` hooks → `interceptMessage()`/`interceptRequest()`. + +### New Features + +- Component formats: single-file (SFC), multi-file (MFC), view-based components. +- Islands (`@island`) for isolated updates; async actions (`wire:click.async`, `#[Async]`) for parallel execution. +- Deferred/bundled loading: `defer`, `lazy.bundle` for optimized component loading. + +| Feature | Usage | Purpose | +|---------|-------|---------| +| Islands | `@island(name: 'stats')` | Isolated update regions | +| Async | `wire:click.async` or `#[Async]` | Non-blocking actions | +| Deferred | `defer` attribute | Load after page render | +| Bundled | `lazy.bundle` | Load multiple together | + +### New Directives + +- `wire:sort`, `wire:intersect`, `wire:ref`, `.renderless`, `.preserve-scroll` are available for use. +- `data-loading` attribute automatically added to elements triggering network requests. + +| Directive | Purpose | +|-----------|---------| +| `wire:sort` | Drag-and-drop sorting | +| `wire:intersect` | Viewport intersection detection | +| `wire:ref` | Element references for JS | +| `.renderless` | Component without rendering | +| `.preserve-scroll` | Preserve scroll position | + +## Best Practices + +- Always use `wire:key` in loops +- Use `wire:loading` for loading states +- Use `wire:model.live` for instant updates (default is debounced) +- Validate and authorize in actions (treat like HTTP requests) + +## Configuration + +- `smart_wire_keys` defaults to `true`; new configs: `component_locations`, `component_namespaces`, `make_command`, `csp_safe`. + +## Alpine & JavaScript + +- `wire:transition` uses browser View Transitions API; `$errors` and `$intercept` magic properties available. +- Non-blocking `wire:poll` and parallel `wire:model.live` updates improve performance. + +For interceptors and hooks, see [reference/javascript-hooks.md](reference/javascript-hooks.md). + +## Testing + + +```php +Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1); +``` + +## Verification + +1. Browser console: Check for JS errors +2. Network tab: Verify Livewire requests return 200 +3. Ensure `wire:key` on all `@foreach` loops + +## Common Pitfalls + +- Missing `wire:key` in loops → unexpected re-rendering +- Expecting `wire:model` real-time → use `wire:model.live` +- Unclosed component tags → syntax errors in v4 +- Using deprecated config keys or JS hooks +- Including Alpine.js separately (already bundled in Livewire 4) \ No newline at end of file diff --git a/.github/skills/livewire-development/reference/javascript-hooks.md b/.github/skills/livewire-development/reference/javascript-hooks.md new file mode 100644 index 00000000..d6a44170 --- /dev/null +++ b/.github/skills/livewire-development/reference/javascript-hooks.md @@ -0,0 +1,39 @@ +# Livewire 4 JavaScript Integration + +## Interceptor System (v4) + +### Intercept Messages + +```js +Livewire.interceptMessage(({ component, message, onFinish, onSuccess, onError }) => { + onFinish(() => { /* After response, before processing */ }); + onSuccess(({ payload }) => { /* payload.snapshot, payload.effects */ }); + onError(() => { /* Server errors */ }); +}); +``` + +### Intercept Requests + +```js +Livewire.interceptRequest(({ request, onResponse, onSuccess, onError, onFailure }) => { + onResponse(({ response }) => { /* When received */ }); + onSuccess(({ response, responseJson }) => { /* Success */ }); + onError(({ response, responseBody, preventDefault }) => { /* 4xx/5xx */ }); + onFailure(({ error }) => { /* Network failures */ }); +}); +``` + +### Component-Scoped Interceptors + +```blade + +``` + +## Magic Properties + +- `$errors` - Access validation errors from JavaScript +- `$intercept` - Component-scoped interceptors \ No newline at end of file diff --git a/.github/skills/pest-testing/SKILL.md b/.github/skills/pest-testing/SKILL.md new file mode 100644 index 00000000..323d4723 --- /dev/null +++ b/.github/skills/pest-testing/SKILL.md @@ -0,0 +1,159 @@ +--- +name: pest-testing +description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code." +license: MIT +metadata: + author: laravel +--- + +# Pest Testing 4 + +## Documentation + +Use `search-docs` for detailed Pest 4 patterns and documentation. + +## Basic Usage + +### Creating Tests + +All tests must be written using Pest. Use `php artisan make:test --pest {name}`. + +### Test Organization + +- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. +- Browser tests: `tests/Browser/` directory. +- Do NOT remove tests without approval - these are core application code. + +### Basic Test Structure + +Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`. + + +```php +it('is true', function () { + expect(true)->toBeTrue(); +}); +``` + +### Running Tests + +- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`. +- Run all tests: `php artisan test --compact`. +- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`. + +## Assertions + +Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: + + +```php +it('returns all', function () { + $this->postJson('/api/docs', [])->assertSuccessful(); +}); +``` + +| Use | Instead of | +|-----|------------| +| `assertSuccessful()` | `assertStatus(200)` | +| `assertNotFound()` | `assertStatus(404)` | +| `assertForbidden()` | `assertStatus(403)` | + +## Mocking + +Import mock function before use: `use function Pest\Laravel\mock;` + +## Datasets + +Use datasets for repetitive tests (validation rules, etc.): + + +```php +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); +``` + +## Pest 4 Features + +| Feature | Purpose | +|---------|---------| +| Browser Testing | Full integration tests in real browsers | +| Smoke Testing | Validate multiple pages quickly | +| Visual Regression | Compare screenshots for visual changes | +| Test Sharding | Parallel CI runs | +| Architecture Testing | Enforce code conventions | + +### Browser Test Example + +Browser tests run in real browsers for full integration testing: + +- Browser tests live in `tests/Browser/`. +- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories. +- Use `RefreshDatabase` for clean state per test. +- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures. +- Test on multiple browsers (Chrome, Firefox, Safari) if requested. +- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested. +- Switch color schemes (light/dark mode) when appropriate. +- Take screenshots or pause tests for debugging. + + +```php +it('may reset the password', function () { + Notification::fake(); + + $this->actingAs(User::factory()->create()); + + $page = visit('/sign-in'); + + $page->assertSee('Sign In') + ->assertNoJavaScriptErrors() + ->click('Forgot Password?') + ->fill('email', 'nuno@laravel.com') + ->click('Send Reset Link') + ->assertSee('We have emailed your password reset link!'); + + Notification::assertSent(ResetPassword::class); +}); +``` + +### Smoke Testing + +Quickly validate multiple pages have no JavaScript errors: + + +```php +$pages = visit(['/', '/about', '/contact']); + +$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs(); +``` + +### Visual Regression Testing + +Capture and compare screenshots to detect visual changes. + +### Test Sharding + +Split tests across parallel processes for faster CI runs. + +### Architecture Testing + +Pest 4 includes architecture testing (from Pest 3): + + +```php +arch('controllers') + ->expect('App\Http\Controllers') + ->toExtendNothing() + ->toHaveSuffix('Controller'); +``` + +## Common Pitfalls + +- Not importing `use function Pest\Laravel\mock;` before using mock +- Using `assertStatus(200)` instead of `assertSuccessful()` +- Forgetting datasets for repetitive validation tests +- Deleting tests without approval +- Forgetting `assertNoJavaScriptErrors()` in browser tests \ No newline at end of file diff --git a/.github/skills/tailwindcss-development/SKILL.md b/.github/skills/tailwindcss-development/SKILL.md new file mode 100644 index 00000000..7c8e295e --- /dev/null +++ b/.github/skills/tailwindcss-development/SKILL.md @@ -0,0 +1,119 @@ +--- +name: tailwindcss-development +description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS." +license: MIT +metadata: + author: laravel +--- + +# Tailwind CSS Development + +## Documentation + +Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. + +## Basic Usage + +- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. +- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). +- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. + +## Tailwind CSS v4 Specifics + +- Always use Tailwind CSS v4 and avoid deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. + +### CSS-First Configuration + +In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: + + +```css +@theme { + --color-brand: oklch(0.72 0.11 178); +} +``` + +### Import Syntax + +In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: + + +```diff +- @tailwind base; +- @tailwind components; +- @tailwind utilities; ++ @import "tailwindcss"; +``` + +### Replaced Utilities + +Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. + +| Deprecated | Replacement | +|------------|-------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + +## Spacing + +Use `gap` utilities instead of margins for spacing between siblings: + + +```html +
+
Item 1
+
Item 2
+
+``` + +## Dark Mode + +If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: + + +```html +
+ Content adapts to color scheme +
+``` + +## Common Patterns + +### Flexbox Layout + + +```html +
+
Left content
+
Right content
+
+``` + +### Grid Layout + + +```html +
+
Card 1
+
Card 2
+
Card 3
+
+``` + +## Common Pitfalls + +- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.) +- Using `@tailwind` directives instead of `@import "tailwindcss"` +- Trying to use `tailwind.config.js` instead of CSS `@theme` directive +- Using margins for spacing between siblings instead of gap utilities +- Forgetting to add dark mode variants when the project uses dark mode \ No newline at end of file diff --git a/.gitignore b/.gitignore index c7cf1fa6..4f849e1b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ yarn-error.log /.nova /.vscode /.zed + +# Playwright MCP session artifacts +.playwright-mcp/ diff --git a/AGENTS.md b/AGENTS.md index 296f2af0..bcc30226 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,3 +23,225 @@ The complete specification is in `specs/`. Start with `specs/09-IMPLEMENTATION-R - `specs/07-SEEDERS-AND-TEST-DATA.md` - Seeders and test data - `specs/08-PLAYWRIGHT-E2E-PLAN.md` - E2E browser tests - `specs/09-IMPLEMENTATION-ROADMAP.md` - Implementation roadmap + +=== + + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications. + +## Foundational Context + +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.4 +- laravel/fortify (FORTIFY) - v1 +- laravel/framework (LARAVEL) - v12 +- laravel/prompts (PROMPTS) - v0 +- livewire/flux (FLUXUI_FREE) - v2 +- livewire/livewire (LIVEWIRE) - v4 +- laravel/boost (BOOST) - v2 +- laravel/mcp (MCP) - v0 +- laravel/pail (PAIL) - v1 +- laravel/pint (PINT) - v1 +- laravel/sail (SAIL) - v1 +- pestphp/pest (PEST) - v4 +- phpunit/phpunit (PHPUNIT) - v12 +- tailwindcss (TAILWINDCSS) - v4 + +## Skills Activation + +This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. + +- `developing-with-fortify` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications. +- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns. +- `fluxui-development` — Use this skill for Flux UI development in Livewire applications only. Trigger when working with components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling. +- `livewire-development` — Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire. +- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code. +- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS. + +## Conventions + +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts + +- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important. + +## Application Structure & Architecture + +- Stick to existing directory structure; don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling + +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Documentation Files + +- You must only create documentation files if explicitly requested by the user. + +## Replies + +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +=== boost rules === + +# Laravel Boost + +## Tools + +- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads. +- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker. +- Use `database-schema` to inspect table structure before writing migrations or models. +- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user. +- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries. + +## Searching Documentation (IMPORTANT) + +- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically. +- Pass a `packages` array to scope results when you know which packages are relevant. +- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first. +- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`. + +### Search Syntax + +1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit". +2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order. +3. Combine words and phrases for mixed queries: `middleware "rate limit"`. +4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`. + +## Artisan + +- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. +- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`. +- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory. +- To check environment variables, read the `.env` file directly. + +## Tinker + +- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code. +- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'` + - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` + +=== php rules === + +# PHP + +- Always use curly braces for control structures, even for single-line bodies. +- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private. +- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool` +- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`. +- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic. +- Use array shape type definitions in PHPDoc blocks. + +=== 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`. + +## Deployment + +- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications. + +=== laravel/v12 rules === + +# Laravel 12 + +- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +## Laravel 12 Structure + +- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`. +- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- The `app/Console/Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. +- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration. + +## Database + +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models + +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + +=== livewire/core rules === + +# Livewire + +- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript. +- You can use Alpine.js for client-side interactions instead of JavaScript frameworks. +- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests. + +=== pint/core rules === + +# Laravel Pint Code Formatter + +- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues. + +=== pest/core rules === + +## Pest + +- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`. +- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`. +- Do NOT delete tests without approval. + + diff --git a/README.md b/README.md new file mode 100644 index 00000000..dc4fb8b2 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +Your mission is to implement an entire shop system based on the specifications im specs/*. Distribute work to sub-agents. Keep the context of the main agent clean. You have to build in one go without stopping. You hace to use team mode! You have to test everything via Pest (unit, and functional tests). You have to also additional simulate user behaviour using the Playwright MPC and confirm that all acceptance criterias are met. If you find bugs, you have to 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 with Playwright in Chrome and showcase all features (customer- and admin-side) to me. In case bugs appear, you have to fix them all and restart the review meeting. Shop is running at http://shop.test/. + +Don't re-use any existing implementation in another branch. Build it from scratch. diff --git a/app/Auth/CustomerUserProvider.php b/app/Auth/CustomerUserProvider.php new file mode 100644 index 00000000..eb9df977 --- /dev/null +++ b/app/Auth/CustomerUserProvider.php @@ -0,0 +1,48 @@ +scopedCredentials($credentials); + + return parent::retrieveByCredentials($credentials); + } + + public function retrieveById($identifier): ?Authenticatable + { + $query = $this->createModel()->newQuery(); + + if ($storeId = $this->currentStoreId()) { + $query->where('store_id', $storeId); + } + + return $query->find($identifier); + } + + protected function scopedCredentials(array $credentials): array + { + if ($storeId = $this->currentStoreId()) { + $credentials['store_id'] = $storeId; + } + + return $credentials; + } + + protected function currentStoreId(): ?int + { + if (! app()->bound('current_store')) { + return null; + } + + $store = app('current_store'); + + return $store instanceof Store ? $store->id : null; + } +} diff --git a/app/Contracts/PaymentProvider.php b/app/Contracts/PaymentProvider.php new file mode 100644 index 00000000..359a9d54 --- /dev/null +++ b/app/Contracts/PaymentProvider.php @@ -0,0 +1,19 @@ + $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 @@ +validate([ + 'currency' => 'nullable|string|size:3', + ]); + + $store = app('current_store'); + $cart = $this->service->create($store); + if (! empty($data['currency'])) { + $cart->currency = strtoupper($data['currency']); + $cart->save(); + } + + return CartResource::fromCart($cart, $this->pricing)->response()->setStatusCode(201); + } + + public function show(int $cartId): CartResource + { + $cart = $this->findCart($cartId); + + return CartResource::fromCart($cart, $this->pricing); + } + + public function addLine(Request $request, int $cartId): CartResource + { + $data = $request->validate([ + 'variant_id' => 'required|integer', + 'quantity' => 'required|integer|min:1|max:9999', + ]); + + $cart = $this->findCart($cartId); + + try { + $this->service->addLine($cart, (int) $data['variant_id'], (int) $data['quantity']); + } catch (InsufficientInventoryException $e) { + throw ValidationException::withMessages(['variant_id' => $e->getMessage()]); + } + + return CartResource::fromCart($cart->fresh(), $this->pricing); + } + + public function updateLine(Request $request, int $cartId, int $lineId): CartResource + { + $data = $request->validate([ + 'quantity' => 'required|integer|min:1|max:9999', + 'cart_version' => 'required|integer', + ]); + + $cart = $this->findCart($cartId); + + try { + $this->service->updateLineQuantity($cart, $lineId, (int) $data['quantity'], (int) $data['cart_version']); + } catch (InsufficientInventoryException $e) { + throw ValidationException::withMessages(['quantity' => $e->getMessage()]); + } + + return CartResource::fromCart($cart->fresh(), $this->pricing); + } + + public function removeLine(Request $request, int $cartId, int $lineId): CartResource + { + $data = $request->validate([ + 'cart_version' => 'required|integer', + ]); + + $cart = $this->findCart($cartId); + + $this->service->removeLine($cart, $lineId, (int) $data['cart_version']); + + return CartResource::fromCart($cart->fresh(), $this->pricing); + } + + protected function findCart(int $cartId): Cart + { + $store = app('current_store'); + + $cart = Cart::query() + ->where('store_id', $store->id) + ->where('status', CartStatus::Active) + ->with('lines.variant.product') + ->find($cartId); + + if (! $cart) { + throw new NotFoundHttpException('Cart not found'); + } + + return $cart; + } +} diff --git a/app/Http/Middleware/ResolveStore.php b/app/Http/Middleware/ResolveStore.php new file mode 100644 index 00000000..62bd45dd --- /dev/null +++ b/app/Http/Middleware/ResolveStore.php @@ -0,0 +1,84 @@ + $this->resolveAdmin($request), + default => $this->resolveStorefront($request), + }; + + if (! $store) { + if ($context === 'admin') { + abort(404); + } + throw new NotFoundHttpException('Store not found for hostname '.$request->getHost()); + } + + if ($context === 'storefront' && $store->isSuspended()) { + abort(503, 'Store temporarily unavailable'); + } + + app()->instance('current_store', $store); + $request->attributes->set('current_store', $store); + + return $next($request); + } + + protected function resolveStorefront(Request $request): ?Store + { + $hostname = strtolower($request->getHost()); + + $storeId = Cache::remember("store_domain:{$hostname}", now()->addMinutes(5), function () use ($hostname): ?int { + $domain = StoreDomain::query()->where('hostname', $hostname)->first(); + + return $domain?->store_id; + }); + + if (! $storeId) { + return null; + } + + return Store::query()->find($storeId); + } + + protected function resolveAdmin(Request $request): ?Store + { + $user = $request->user(); + $storeId = $request->session()->get('current_store_id'); + + if (! $storeId && $user) { + $storeId = $user->stores()->value('stores.id'); + if ($storeId) { + $request->session()->put('current_store_id', $storeId); + } + } + + if (! $storeId) { + return null; + } + + $store = Store::query()->find($storeId); + + if (! $store) { + return null; + } + + if ($user && ! $user->stores()->whereKey($store->id)->exists()) { + abort(403, 'You are not a member of this store'); + } + + return $store; + } +} diff --git a/app/Http/Resources/CartResource.php b/app/Http/Resources/CartResource.php new file mode 100644 index 00000000..f0e5b0d5 --- /dev/null +++ b/app/Http/Resources/CartResource.php @@ -0,0 +1,66 @@ +pricing = $pricing; + + return $resource; + } + + public function toArray(Request $request): array + { + /** @var Cart $cart */ + $cart = $this->resource; + $cart->loadMissing('lines.variant.product.media'); + + $store = $cart->store()->first(); + $result = $this->pricing + ? $this->pricing->calculateForCart($cart, $store) + : null; + + return [ + 'id' => $cart->id, + 'store_id' => $cart->store_id, + 'customer_id' => $cart->customer_id, + 'currency' => $cart->currency, + 'cart_version' => $cart->cart_version, + 'status' => $cart->status->value, + 'lines' => $cart->lines->map(fn ($line): array => [ + 'id' => $line->id, + 'variant_id' => $line->variant_id, + 'product_title' => $line->variant?->product?->title, + 'sku' => $line->variant?->sku, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'line_subtotal_amount' => $line->line_subtotal_amount, + 'line_discount_amount' => $line->line_discount_amount, + 'line_total_amount' => $line->line_total_amount, + 'requires_shipping' => (bool) ($line->variant?->requires_shipping ?? false), + ])->all(), + 'totals' => [ + 'subtotal' => (int) $cart->lines->sum('line_subtotal_amount'), + 'discount' => $result?->discount ?? 0, + 'total' => $result?->total ?? (int) $cart->lines->sum('line_total_amount'), + 'currency' => $cart->currency, + 'line_count' => $cart->lines->count(), + 'item_count' => (int) $cart->lines->sum('quantity'), + ], + 'created_at' => $cart->created_at?->toIso8601String(), + 'updated_at' => $cart->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Jobs/AggregateAnalytics.php b/app/Jobs/AggregateAnalytics.php new file mode 100644 index 00000000..1a171996 --- /dev/null +++ b/app/Jobs/AggregateAnalytics.php @@ -0,0 +1,28 @@ +date ?? Carbon::yesterday()->toDateString(); + + Store::query()->each(function (Store $store) use ($analytics, $date): void { + $analytics->aggregate($store, $date); + }); + } +} diff --git a/app/Jobs/CancelUnpaidBankTransferOrders.php b/app/Jobs/CancelUnpaidBankTransferOrders.php new file mode 100644 index 00000000..e5fda644 --- /dev/null +++ b/app/Jobs/CancelUnpaidBankTransferOrders.php @@ -0,0 +1,33 @@ +subDays(config('shop.bank_transfer_cancel_days', 7)); + + Order::query() + ->withoutGlobalScopes() + ->where('payment_method', PaymentMethod::BankTransfer) + ->where('financial_status', FinancialStatus::Pending) + ->where('placed_at', '<=', $cutoff) + ->cursor() + ->each(function (Order $order) use ($service): void { + $service->cancel($order, 'bank_transfer_timeout'); + }); + } +} diff --git a/app/Jobs/CleanupAbandonedCarts.php b/app/Jobs/CleanupAbandonedCarts.php new file mode 100644 index 00000000..1e15e023 --- /dev/null +++ b/app/Jobs/CleanupAbandonedCarts.php @@ -0,0 +1,27 @@ +subDays(14); + + Cart::query() + ->withoutGlobalScopes() + ->where('status', CartStatus::Active) + ->where('updated_at', '<=', $threshold) + ->update(['status' => CartStatus::Abandoned->value]); + } +} diff --git a/app/Jobs/DeliverWebhook.php b/app/Jobs/DeliverWebhook.php new file mode 100644 index 00000000..c642e628 --- /dev/null +++ b/app/Jobs/DeliverWebhook.php @@ -0,0 +1,121 @@ + + */ + public function backoff(): array + { + return [60, 300, 1800, 7200, 43200]; + } + + public function handle(WebhookService $service): void + { + $subscription = WebhookSubscription::query()->find($this->subscriptionId); + + if (! $subscription || $subscription->status !== WebhookSubscriptionStatus::Active) { + return; + } + + $delivery = WebhookDelivery::query()->firstOrCreate( + ['subscription_id' => $subscription->id, 'event_id' => $this->eventId], + ['attempt_count' => 0, 'status' => WebhookDeliveryStatus::Pending] + ); + + $delivery->attempt_count++; + $delivery->last_attempt_at = now(); + + $body = json_encode($this->payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $secret = $service->decryptSecret($subscription); + $signature = $service->sign($body, $secret); + + try { + $response = Http::timeout(10) + ->withHeaders([ + 'Content-Type' => 'application/json', + 'X-Platform-Signature' => $signature, + 'X-Platform-Event' => $this->eventType, + 'X-Platform-Delivery-Id' => $this->eventId, + 'X-Platform-Timestamp' => (string) now()->timestamp, + ]) + ->send('POST', $subscription->target_url, [ + 'body' => $body, + ]); + + $delivery->response_code = $response->status(); + $delivery->response_body_snippet = mb_substr((string) $response->body(), 0, 500); + + if ($response->successful()) { + $delivery->status = WebhookDeliveryStatus::Success; + $delivery->save(); + if ($subscription->consecutive_failures > 0) { + $subscription->consecutive_failures = 0; + $subscription->save(); + } + + return; + } + + $this->recordFailure($subscription, $delivery); + throw new \RuntimeException("Webhook delivery failed with status {$response->status()}"); + } catch (\RuntimeException $e) { + throw $e; + } catch (Throwable $e) { + $delivery->response_body_snippet = mb_substr($e->getMessage(), 0, 500); + $this->recordFailure($subscription, $delivery); + throw $e; + } + } + + public function failed(Throwable $e): void + { + $delivery = WebhookDelivery::query() + ->where('subscription_id', $this->subscriptionId) + ->where('event_id', $this->eventId) + ->first(); + + if ($delivery) { + $delivery->status = WebhookDeliveryStatus::Failed; + $delivery->save(); + } + } + + protected function recordFailure(WebhookSubscription $subscription, WebhookDelivery $delivery): void + { + $delivery->status = WebhookDeliveryStatus::Pending; + $delivery->save(); + + $subscription->consecutive_failures = $subscription->consecutive_failures + 1; + if ($subscription->consecutive_failures >= 5) { + $subscription->status = WebhookSubscriptionStatus::Paused; + } + $subscription->save(); + } +} diff --git a/app/Jobs/ExpireAbandonedCheckouts.php b/app/Jobs/ExpireAbandonedCheckouts.php new file mode 100644 index 00000000..1ba7c5b4 --- /dev/null +++ b/app/Jobs/ExpireAbandonedCheckouts.php @@ -0,0 +1,32 @@ +withoutGlobalScopes() + ->whereNotIn('status', [CheckoutStatus::Completed, CheckoutStatus::Expired]) + ->where(function ($query): void { + $query->where('expires_at', '<=', now()) + ->orWhere('updated_at', '<=', now()->subHours(24)); + }) + ->cursor() + ->each(function (Checkout $checkout) use ($service): void { + $service->expireCheckout($checkout); + }); + } +} diff --git a/app/Jobs/ProcessMediaUpload.php b/app/Jobs/ProcessMediaUpload.php new file mode 100644 index 00000000..847d0576 --- /dev/null +++ b/app/Jobs/ProcessMediaUpload.php @@ -0,0 +1,37 @@ +exists($this->media->storage_key)) { + $this->media->update(['status' => MediaStatus::Failed]); + + return; + } + + $this->media->update(['status' => MediaStatus::Ready]); + } catch (Throwable $e) { + $this->media->update(['status' => MediaStatus::Failed]); + + throw $e; + } + } +} diff --git a/app/Listeners/DispatchWebhooks.php b/app/Listeners/DispatchWebhooks.php new file mode 100644 index 00000000..8cdef151 --- /dev/null +++ b/app/Listeners/DispatchWebhooks.php @@ -0,0 +1,71 @@ +dispatch('order.created', $event->order); + } + + public function onOrderPaid(OrderPaid $event): void + { + $this->dispatch('order.paid', $event->order); + } + + public function onOrderFulfilled(OrderFulfilled $event): void + { + $this->dispatch('order.fulfilled', $event->order); + } + + public function onOrderCancelled(OrderCancelled $event): void + { + $this->dispatch('order.cancelled', $event->order); + } + + public function onOrderRefunded(OrderRefunded $event): void + { + $this->dispatch('order.refunded', $event->order); + } + + /** + * @return array + */ + public function subscribe(): array + { + return [ + OrderCreated::class => 'onOrderCreated', + OrderPaid::class => 'onOrderPaid', + OrderFulfilled::class => 'onOrderFulfilled', + OrderCancelled::class => 'onOrderCancelled', + OrderRefunded::class => 'onOrderRefunded', + ]; + } + + protected function dispatch(string $eventType, Order $order): void + { + $this->webhooks->dispatchEvent($order->store_id, $eventType, [ + 'type' => $eventType, + 'occurred_at' => now()->toIso8601String(), + 'data' => [ + 'order_id' => $order->id, + 'order_number' => $order->order_number, + 'total_amount' => $order->total_amount, + 'currency' => $order->currency, + 'status' => $order->status->value, + 'financial_status' => $order->financial_status->value, + ], + ]); + } +} diff --git a/app/Livewire/Actions/Logout.php b/app/Livewire/Actions/Logout.php deleted file mode 100644 index 45993bb8..00000000 --- a/app/Livewire/Actions/Logout.php +++ /dev/null @@ -1,22 +0,0 @@ -logout(); - - Session::invalidate(); - Session::regenerateToken(); - - return redirect('/'); - } -} diff --git a/app/Livewire/Admin/Analytics/Index.php b/app/Livewire/Admin/Analytics/Index.php new file mode 100644 index 00000000..306d682d --- /dev/null +++ b/app/Livewire/Admin/Analytics/Index.php @@ -0,0 +1,48 @@ +endDate = now()->format('Y-m-d'); + $this->startDate = now()->subDays(29)->format('Y-m-d'); + } + + public function render() + { + $store = app('current_store'); + + $metrics = collect(); + + if (class_exists(\App\Services\AnalyticsService::class) && Schema::hasTable('analytics_daily')) { + $service = app(\App\Services\AnalyticsService::class); + $metrics = $service->getDailyMetrics($store, $this->startDate, $this->endDate); + } + + $totals = [ + 'revenue_amount' => (int) $metrics->sum('revenue_amount'), + 'orders_count' => (int) $metrics->sum('orders_count'), + 'visits_count' => (int) $metrics->sum('visits_count'), + 'aov_amount' => 0, + ]; + if ($totals['orders_count'] > 0) { + $totals['aov_amount'] = intdiv($totals['revenue_amount'], $totals['orders_count']); + } + + return view('livewire.admin.analytics.index', [ + 'metrics' => $metrics, + 'totals' => $totals, + ]); + } +} diff --git a/app/Livewire/Admin/Apps/Index.php b/app/Livewire/Admin/Apps/Index.php new file mode 100644 index 00000000..2dd009b3 --- /dev/null +++ b/app/Livewire/Admin/Apps/Index.php @@ -0,0 +1,28 @@ +where('store_id', $store->id) + ->with('app') + ->get(); + } + + return view('livewire.admin.apps.index', [ + 'installations' => $installations, + ]); + } +} diff --git a/app/Livewire/Admin/Apps/Show.php b/app/Livewire/Admin/Apps/Show.php new file mode 100644 index 00000000..10d63d4e --- /dev/null +++ b/app/Livewire/Admin/Apps/Show.php @@ -0,0 +1,24 @@ +loadMissing('app', 'subscriptions'); + $this->installation = $installation; + } + + public function render() + { + return view('livewire.admin.apps.show'); + } +} diff --git a/app/Livewire/Admin/Auth/Login.php b/app/Livewire/Admin/Auth/Login.php new file mode 100644 index 00000000..5c6858aa --- /dev/null +++ b/app/Livewire/Admin/Auth/Login.php @@ -0,0 +1,70 @@ +validate(); + + $key = 'login:'.request()->ip(); + + if (RateLimiter::tooManyAttempts($key, 5)) { + throw ValidationException::withMessages([ + 'email' => __('Too many attempts. Please try again in a minute.'), + ]); + } + + $user = \App\Models\User::query()->where('email', $this->email)->first(); + + if (! $user || $user->status !== UserStatus::Active || ! Auth::guard('web')->attempt([ + 'email' => $this->email, + 'password' => $this->password, + ], $this->remember)) { + RateLimiter::hit($key, 60); + + throw ValidationException::withMessages([ + 'email' => __('Invalid credentials.'), + ]); + } + + RateLimiter::clear($key); + + if (request()->hasSession()) { + request()->session()->regenerate(); + } + + $user = Auth::guard('web')->user(); + $user->forceFill(['last_login_at' => now()])->save(); + + $firstStoreId = $user->stores()->value('stores.id'); + if ($firstStoreId && request()->hasSession()) { + request()->session()->put('current_store_id', $firstStoreId); + } + + return redirect()->intended(route('admin.dashboard')); + } + + 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..c24e2540 --- /dev/null +++ b/app/Livewire/Admin/Collections/Form.php @@ -0,0 +1,136 @@ + */ + public array $product_ids = []; + + public string $productSearch = ''; + + public function mount(?Collection $collection = null): void + { + if ($collection && $collection->exists) { + $collection->loadMissing('products'); + $this->collection = $collection; + $this->title = (string) $collection->title; + $this->handle = (string) $collection->handle; + $this->description_html = (string) $collection->description_html; + $this->type = $collection->type?->value ?? 'manual'; + $this->status = $collection->status?->value ?? 'active'; + $this->product_ids = $collection->products->pluck('id')->toArray(); + } + } + + public function save(): void + { + $data = $this->validate([ + 'title' => 'required|string|max:255', + 'description_html' => 'nullable|string', + 'type' => 'required|in:manual,automated', + 'status' => 'required|in:draft,active,archived', + ]); + + $store = app('current_store'); + $handle = $this->handle !== '' + ? $this->handle + : HandleGenerator::generate($this->title, 'collections', $store->id, $this->collection?->id); + + if ($this->collection && $this->collection->exists) { + $this->collection->update([ + ...$data, + 'handle' => $handle, + ]); + $collection = $this->collection; + } else { + $collection = Collection::create([ + 'store_id' => $store->id, + ...$data, + 'handle' => $handle, + ]); + $this->collection = $collection; + } + + $sync = []; + foreach ($this->product_ids as $idx => $pid) { + $sync[$pid] = ['position' => $idx]; + } + $collection->products()->sync($sync); + + session()->flash('success', 'Collection saved.'); + + $this->redirect(route('admin.collections.edit', $collection), navigate: true); + } + + public function addProduct(int $id): void + { + if (! in_array($id, $this->product_ids, true)) { + $this->product_ids[] = $id; + } + } + + public function removeProduct(int $id): void + { + $this->product_ids = array_values(array_filter($this->product_ids, fn ($pid) => (int) $pid !== $id)); + } + + public function moveUp(int $id): void + { + $idx = array_search($id, $this->product_ids, true); + if ($idx !== false && $idx > 0) { + [$this->product_ids[$idx - 1], $this->product_ids[$idx]] = [$this->product_ids[$idx], $this->product_ids[$idx - 1]]; + } + } + + public function moveDown(int $id): void + { + $idx = array_search($id, $this->product_ids, true); + if ($idx !== false && $idx < count($this->product_ids) - 1) { + [$this->product_ids[$idx], $this->product_ids[$idx + 1]] = [$this->product_ids[$idx + 1], $this->product_ids[$idx]]; + } + } + + public function render() + { + $searchResults = $this->productSearch === '' + ? collect() + : Product::query() + ->where('title', 'like', '%'.$this->productSearch.'%') + ->whereNotIn('id', $this->product_ids) + ->limit(10) + ->get(['id', 'title']); + + $pickedProducts = empty($this->product_ids) + ? collect() + : Product::query()->whereIn('id', $this->product_ids)->get(['id', 'title'])->keyBy('id'); + + return view('livewire.admin.collections.form', [ + 'searchResults' => $searchResults, + 'pickedProducts' => $pickedProducts, + 'statuses' => CollectionStatus::cases(), + 'types' => CollectionType::cases(), + ]); + } +} diff --git a/app/Livewire/Admin/Collections/Index.php b/app/Livewire/Admin/Collections/Index.php new file mode 100644 index 00000000..acbd6f0d --- /dev/null +++ b/app/Livewire/Admin/Collections/Index.php @@ -0,0 +1,27 @@ +when($this->search !== '', fn ($q) => $q->where('title', 'like', '%'.$this->search.'%')) + ->orderByDesc('id'); + + return view('livewire.admin.collections.index', [ + 'collections' => $query->paginate(20), + ]); + } +} diff --git a/app/Livewire/Admin/Customers/Index.php b/app/Livewire/Admin/Customers/Index.php new file mode 100644 index 00000000..0381e19b --- /dev/null +++ b/app/Livewire/Admin/Customers/Index.php @@ -0,0 +1,33 @@ +when($this->search !== '', function ($q): void { + $q->where(function ($inner): void { + $inner->where('email', 'like', '%'.$this->search.'%') + ->orWhere('first_name', 'like', '%'.$this->search.'%') + ->orWhere('last_name', 'like', '%'.$this->search.'%'); + }); + }) + ->orderByDesc('id'); + + return view('livewire.admin.customers.index', [ + 'customers' => $query->paginate(20), + ]); + } +} diff --git a/app/Livewire/Admin/Customers/Show.php b/app/Livewire/Admin/Customers/Show.php new file mode 100644 index 00000000..5ba98801 --- /dev/null +++ b/app/Livewire/Admin/Customers/Show.php @@ -0,0 +1,33 @@ +loadMissing('addresses'); + $this->customer = $customer; + } + + public function render() + { + $orders = Order::query() + ->where('customer_id', $this->customer->id) + ->orderByDesc('placed_at') + ->limit(50) + ->get(); + + return view('livewire.admin.customers.show', [ + 'orders' => $orders, + ]); + } +} diff --git a/app/Livewire/Admin/Dashboard.php b/app/Livewire/Admin/Dashboard.php new file mode 100644 index 00000000..96a696f1 --- /dev/null +++ b/app/Livewire/Admin/Dashboard.php @@ -0,0 +1,81 @@ +endDate = now()->format('Y-m-d'); + $this->startDate = now()->subDays(29)->format('Y-m-d'); + } + + public function render() + { + $store = app('current_store'); + + $start = Carbon::parse($this->startDate)->startOfDay(); + $end = Carbon::parse($this->endDate)->endOfDay(); + + $ordersQuery = Order::query() + ->where('store_id', $store->id) + ->whereBetween('placed_at', [$start, $end]) + ->whereIn('financial_status', [ + FinancialStatus::Paid->value, + FinancialStatus::PartiallyRefunded->value, + FinancialStatus::Refunded->value, + ]); + + $orderCount = (clone $ordersQuery)->count(); + $salesAmount = (int) (clone $ordersQuery)->sum('total_amount'); + $aovAmount = $orderCount > 0 ? intdiv($salesAmount, $orderCount) : 0; + + $conversionRate = $this->computeConversionRate($store->id, $start, $end, $orderCount); + + $recentOrders = Order::query() + ->where('store_id', $store->id) + ->orderByDesc('placed_at') + ->limit(10) + ->get(['id', 'order_number', 'email', 'total_amount', 'currency', 'financial_status', 'placed_at']); + + return view('livewire.admin.dashboard', [ + 'store' => $store, + 'metrics' => [ + 'sales_amount' => $salesAmount, + 'order_count' => $orderCount, + 'aov_amount' => $aovAmount, + 'conversion_rate' => $conversionRate, + ], + 'recentOrders' => $recentOrders, + ]); + } + + protected function computeConversionRate(int $storeId, Carbon $start, Carbon $end, int $orderCount): float + { + if (! \Illuminate\Support\Facades\Schema::hasTable('analytics_daily')) { + return 0.0; + } + + $visits = (int) \Illuminate\Support\Facades\DB::table('analytics_daily') + ->where('store_id', $storeId) + ->whereBetween('date', [$start->format('Y-m-d'), $end->format('Y-m-d')]) + ->sum('visits_count'); + + if ($visits <= 0) { + return 0.0; + } + + return round($orderCount / $visits, 4); + } +} diff --git a/app/Livewire/Admin/Developers/Index.php b/app/Livewire/Admin/Developers/Index.php new file mode 100644 index 00000000..793b5aaf --- /dev/null +++ b/app/Livewire/Admin/Developers/Index.php @@ -0,0 +1,56 @@ +validate([ + 'tokenName' => 'required|string|max:100', + 'abilities' => 'nullable|string|max:255', + ]); + + $user = auth()->user(); + $abilitiesArr = array_values(array_filter(array_map('trim', explode(',', $this->abilities)))) ?: ['*']; + $token = $user->createToken($this->tokenName, $abilitiesArr); + $this->newToken = $token->plainTextToken; + $this->tokenName = ''; + } + + public function revokeToken(int $id): void + { + $user = auth()->user(); + $user->tokens()->whereKey($id)->delete(); + } + + public function render() + { + $user = auth()->user(); + $tokens = $user->tokens()->orderByDesc('id')->get(); + + $subscriptions = collect(); + if (class_exists(\App\Models\WebhookSubscription::class) && Schema::hasTable('webhook_subscriptions')) { + $store = app('current_store'); + $subscriptions = \App\Models\WebhookSubscription::query() + ->where('store_id', $store->id) + ->get(); + } + + return view('livewire.admin.developers.index', [ + 'tokens' => $tokens, + 'subscriptions' => $subscriptions, + ]); + } +} diff --git a/app/Livewire/Admin/Discounts/Form.php b/app/Livewire/Admin/Discounts/Form.php new file mode 100644 index 00000000..cb250c61 --- /dev/null +++ b/app/Livewire/Admin/Discounts/Form.php @@ -0,0 +1,103 @@ +exists) { + $this->discount = $discount; + $this->type = $discount->type?->value ?? 'code'; + $this->code = (string) $discount->code; + $this->value_type = $discount->value_type?->value ?? 'percent'; + $this->value_amount = (int) $discount->value_amount; + $this->starts_at = $discount->starts_at?->format('Y-m-d\TH:i') ?? ''; + $this->ends_at = $discount->ends_at?->format('Y-m-d\TH:i') ?? ''; + $this->usage_limit = $discount->usage_limit; + $this->minimum_purchase_amount = (int) ($discount->rules_json['minimum_purchase_amount'] ?? 0); + $this->status = $discount->status?->value ?? 'active'; + } else { + $this->starts_at = now()->format('Y-m-d\TH:i'); + } + } + + public function save(): void + { + $data = $this->validate([ + 'type' => 'required|in:code,automatic', + 'code' => 'nullable|string|max:100', + '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', + 'minimum_purchase_amount' => 'nullable|integer|min:0', + 'status' => 'required|in:draft,active,expired,disabled', + ]); + + $store = app('current_store'); + + $payload = [ + 'store_id' => $store->id, + 'type' => $data['type'], + 'code' => $data['code'] ?: null, + 'value_type' => $data['value_type'], + 'value_amount' => (int) $data['value_amount'], + 'starts_at' => $data['starts_at'], + 'ends_at' => $data['ends_at'] ?: null, + 'usage_limit' => $data['usage_limit'] ?? null, + 'rules_json' => ['minimum_purchase_amount' => (int) $data['minimum_purchase_amount']], + 'status' => $data['status'], + ]; + + if ($this->discount && $this->discount->exists) { + $this->discount->update($payload); + $discount = $this->discount; + } else { + $discount = Discount::create($payload); + $this->discount = $discount; + } + + session()->flash('success', 'Discount saved.'); + + $this->redirect(route('admin.discounts.edit', $discount), navigate: true); + } + + public function render() + { + return view('livewire.admin.discounts.form', [ + 'statuses' => DiscountStatus::cases(), + 'types' => DiscountType::cases(), + 'valueTypes' => DiscountValueType::cases(), + ]); + } +} diff --git a/app/Livewire/Admin/Discounts/Index.php b/app/Livewire/Admin/Discounts/Index.php new file mode 100644 index 00000000..e29926e3 --- /dev/null +++ b/app/Livewire/Admin/Discounts/Index.php @@ -0,0 +1,32 @@ +whereKey($id)->delete(); + } + + public function render() + { + $query = Discount::query() + ->when($this->search !== '', fn ($q) => $q->where('code', 'like', '%'.$this->search.'%')) + ->orderByDesc('id'); + + return view('livewire.admin.discounts.index', [ + 'discounts' => $query->paginate(20), + ]); + } +} diff --git a/app/Livewire/Admin/Navigation/Index.php b/app/Livewire/Admin/Navigation/Index.php new file mode 100644 index 00000000..1fd6834c --- /dev/null +++ b/app/Livewire/Admin/Navigation/Index.php @@ -0,0 +1,91 @@ +> */ + public array $newItem = []; + + public function addMenu(): void + { + $this->validate([ + 'newMenuHandle' => 'required|string|max:100', + 'newMenuTitle' => 'required|string|max:255', + ]); + + $store = app('current_store'); + NavigationMenu::create([ + 'store_id' => $store->id, + 'handle' => $this->newMenuHandle, + 'title' => $this->newMenuTitle, + ]); + + $this->newMenuHandle = ''; + $this->newMenuTitle = ''; + } + + public function removeMenu(int $id): void + { + NavigationMenu::query()->whereKey($id)->delete(); + } + + public function addItem(int $menuId): void + { + $entry = $this->newItem[$menuId] ?? []; + $label = trim((string) ($entry['label'] ?? '')); + $url = trim((string) ($entry['url'] ?? '')); + + if ($label === '') { + return; + } + + $position = (int) NavigationItem::query()->where('menu_id', $menuId)->max('position') + 1; + + NavigationItem::create([ + 'menu_id' => $menuId, + 'type' => 'link', + 'label' => $label, + 'url' => $url, + 'position' => $position, + ]); + + $this->newItem[$menuId] = ['label' => '', 'url' => '']; + } + + public function removeItem(int $itemId): void + { + NavigationItem::query()->whereKey($itemId)->delete(); + } + + public function moveItem(int $itemId, int $delta): void + { + $item = NavigationItem::query()->find($itemId); + if (! $item) { + return; + } + + $newPos = max(0, $item->position + $delta); + $item->update(['position' => $newPos]); + } + + public function render() + { + $store = app('current_store'); + $menus = NavigationMenu::query()->where('store_id', $store->id)->with('items')->get(); + + return view('livewire.admin.navigation.index', [ + 'menus' => $menus, + ]); + } +} diff --git a/app/Livewire/Admin/Orders/Index.php b/app/Livewire/Admin/Orders/Index.php new file mode 100644 index 00000000..f3100022 --- /dev/null +++ b/app/Livewire/Admin/Orders/Index.php @@ -0,0 +1,55 @@ +resetPage(); + } + } + + public function render() + { + $query = Order::query() + ->when($this->search !== '', function ($q): void { + $q->where(function ($inner): void { + $inner->where('order_number', 'like', '%'.$this->search.'%') + ->orWhere('email', 'like', '%'.$this->search.'%'); + }); + }) + ->when($this->status !== '', fn ($q) => $q->where('status', $this->status)) + ->when($this->startDate !== '', fn ($q) => $q->whereDate('placed_at', '>=', $this->startDate)) + ->when($this->endDate !== '', fn ($q) => $q->whereDate('placed_at', '<=', $this->endDate)) + ->orderByDesc('placed_at'); + + return view('livewire.admin.orders.index', [ + 'orders' => $query->paginate(20), + 'statuses' => OrderStatus::cases(), + ]); + } +} diff --git a/app/Livewire/Admin/Orders/Show.php b/app/Livewire/Admin/Orders/Show.php new file mode 100644 index 00000000..20b13420 --- /dev/null +++ b/app/Livewire/Admin/Orders/Show.php @@ -0,0 +1,135 @@ + */ + public array $fulfillLines = []; + + public string $trackingCompany = ''; + + public string $trackingNumber = ''; + + public string $trackingUrl = ''; + + public int $refundAmount = 0; + + public string $refundReason = ''; + + public bool $refundRestock = false; + + public function mount(Order $order): void + { + $order->load(['lines.variant.product', 'payments', 'refunds', 'fulfillments.lines', 'customer']); + $this->order = $order; + $this->refundAmount = max(0, $order->total_amount - $order->totalRefunded()); + } + + public function openFulfillment(): void + { + $this->showFulfillment = true; + $this->fulfillLines = []; + foreach ($this->order->lines as $line) { + $remaining = $line->quantity - $line->fulfilledQuantity(); + $this->fulfillLines[$line->id] = max(0, $remaining); + } + } + + public function closeFulfillment(): void + { + $this->showFulfillment = false; + } + + public function fulfill(FulfillmentService $service): void + { + $lines = []; + foreach ($this->fulfillLines as $lineId => $qty) { + if ((int) $qty > 0) { + $lines[] = ['order_line_id' => (int) $lineId, 'quantity' => (int) $qty]; + } + } + + try { + $service->create($this->order, $lines, [ + 'tracking_company' => $this->trackingCompany ?: null, + 'tracking_number' => $this->trackingNumber ?: null, + 'tracking_url' => $this->trackingUrl ?: null, + ]); + } catch (ValidationException $e) { + throw $e; + } + + $this->order = $this->order->fresh(['lines.variant', 'fulfillments.lines', 'payments', 'refunds', 'customer']); + $this->showFulfillment = false; + session()->flash('success', 'Fulfillment created.'); + } + + public function openRefund(): void + { + $this->showRefund = true; + } + + public function closeRefund(): void + { + $this->showRefund = false; + } + + public function refund(RefundService $service): void + { + $payment = $this->order->payments()->latest('id')->first(); + if (! $payment) { + session()->flash('error', 'No payment to refund.'); + + return; + } + + $service->create($this->order, $payment, (int) $this->refundAmount, $this->refundReason ?: null, $this->refundRestock); + + $this->order = $this->order->fresh(['lines.variant', 'fulfillments.lines', 'payments', 'refunds', 'customer']); + $this->refundAmount = max(0, $this->order->total_amount - $this->order->totalRefunded()); + $this->showRefund = false; + session()->flash('success', 'Refund processed.'); + } + + public function confirmBankTransfer(OrderService $service): void + { + $service->confirmBankTransferPayment($this->order); + $this->order = $this->order->fresh(['lines.variant', 'fulfillments.lines', 'payments', 'refunds', 'customer']); + session()->flash('success', 'Payment confirmed.'); + } + + public function cancelOrder(OrderService $service): void + { + $service->cancel($this->order, 'Cancelled by admin'); + $this->order = $this->order->fresh(['lines.variant', 'fulfillments.lines', 'payments', 'refunds', 'customer']); + session()->flash('success', 'Order cancelled.'); + } + + public function render() + { + $canConfirmBankTransfer = $this->order->payment_method === PaymentMethod::BankTransfer + && $this->order->financial_status === FinancialStatus::Pending; + + return view('livewire.admin.orders.show', [ + 'canConfirmBankTransfer' => $canConfirmBankTransfer, + ]); + } +} diff --git a/app/Livewire/Admin/Pages/Form.php b/app/Livewire/Admin/Pages/Form.php new file mode 100644 index 00000000..a221ea8c --- /dev/null +++ b/app/Livewire/Admin/Pages/Form.php @@ -0,0 +1,75 @@ +exists) { + $this->page = $page; + $this->title = (string) $page->title; + $this->handle = (string) $page->handle; + $this->body_html = (string) $page->body_html; + $this->status = $page->status?->value ?? 'draft'; + } + } + + public function save(): void + { + $data = $this->validate([ + 'title' => 'required|string|max:255', + 'handle' => 'nullable|string|max:255', + 'body_html' => 'nullable|string', + 'status' => 'required|in:draft,published,archived', + ]); + + $store = app('current_store'); + $handle = $data['handle'] !== '' ? $data['handle'] : HandleGenerator::generate($data['title'], 'pages', $store->id, $this->page?->id); + + $payload = [ + 'store_id' => $store->id, + 'title' => $data['title'], + 'handle' => $handle, + 'body_html' => $data['body_html'] ?? null, + 'status' => $data['status'], + 'published_at' => $data['status'] === 'published' ? now() : null, + ]; + + if ($this->page && $this->page->exists) { + $this->page->update($payload); + $page = $this->page; + } else { + $page = Page::create($payload); + $this->page = $page; + } + + session()->flash('success', 'Page saved.'); + + $this->redirect(route('admin.pages.edit', $page), navigate: true); + } + + public function render() + { + return view('livewire.admin.pages.form', [ + 'statuses' => PageStatus::cases(), + ]); + } +} diff --git a/app/Livewire/Admin/Pages/Index.php b/app/Livewire/Admin/Pages/Index.php new file mode 100644 index 00000000..7f097f8f --- /dev/null +++ b/app/Livewire/Admin/Pages/Index.php @@ -0,0 +1,26 @@ +whereKey($id)->delete(); + } + + public function render() + { + $pages = Page::query()->orderByDesc('id')->paginate(20); + + return view('livewire.admin.pages.index', compact('pages')); + } +} diff --git a/app/Livewire/Admin/Products/Form.php b/app/Livewire/Admin/Products/Form.php new file mode 100644 index 00000000..0a01cfad --- /dev/null +++ b/app/Livewire/Admin/Products/Form.php @@ -0,0 +1,137 @@ +> */ + public array $variants = []; + + public function mount(?Product $product = null): void + { + if ($product && $product->exists) { + $product->loadMissing('variants', 'media'); + $this->product = $product; + $this->title = (string) $product->title; + $this->status = $product->status?->value ?? 'draft'; + $this->vendor = (string) $product->vendor; + $this->product_type = (string) $product->product_type; + $this->description_html = (string) $product->description_html; + $this->tags_input = implode(', ', (array) $product->tags); + $this->variants = $product->variants->map(fn ($v) => [ + 'id' => $v->id, + 'sku' => $v->sku, + 'price_amount' => $v->price_amount, + ])->toArray(); + } + } + + public function save(ProductService $service): void + { + $data = $this->validate([ + 'title' => 'required|string|max:255', + 'status' => 'required|in:draft,active,archived', + 'vendor' => 'nullable|string|max:255', + 'product_type' => 'nullable|string|max:255', + 'description_html' => 'nullable|string', + 'tags_input' => 'nullable|string', + ]); + + $tags = array_values(array_filter(array_map('trim', explode(',', (string) $this->tags_input)))); + + $payload = [ + 'title' => $data['title'], + 'status' => ProductStatus::from($data['status']), + 'vendor' => $data['vendor'] ?? null, + 'product_type' => $data['product_type'] ?? null, + 'description_html' => $data['description_html'] ?? null, + 'tags' => $tags, + 'price_amount' => $this->price_amount, + ]; + + if ($this->product && $this->product->exists) { + $current = $this->product->status; + $newStatus = $payload['status']; + unset($payload['status'], $payload['price_amount']); + $service->update($this->product, $payload); + if ($current !== $newStatus) { + $service->transitionStatus($this->product->fresh(), $newStatus); + } + $product = $this->product->fresh(); + } else { + $store = app('current_store'); + $product = $service->create($store, $payload); + $this->product = $product; + } + + foreach ($this->variants as $entry) { + if (! empty($entry['id'])) { + ProductVariant::query()->whereKey($entry['id'])->update([ + 'sku' => $entry['sku'] ?? null, + 'price_amount' => (int) ($entry['price_amount'] ?? 0), + ]); + } + } + + if ($this->image) { + $path = $this->image->store('product-media', 'public'); + ProductMedia::create([ + 'product_id' => $product->id, + 'type' => MediaType::Image, + 'storage_key' => $path, + 'alt_text' => $product->title, + 'status' => MediaStatus::Ready, + 'mime_type' => $this->image->getMimeType(), + 'byte_size' => $this->image->getSize(), + 'position' => $product->media()->count(), + 'created_at' => now(), + ]); + $this->image = null; + } + + session()->flash('success', 'Product saved.'); + + $this->redirect(route('admin.products.edit', $product), navigate: true); + } + + public function render() + { + return view('livewire.admin.products.form', [ + 'statuses' => ProductStatus::cases(), + ]); + } +} diff --git a/app/Livewire/Admin/Products/Index.php b/app/Livewire/Admin/Products/Index.php new file mode 100644 index 00000000..c07bb5d9 --- /dev/null +++ b/app/Livewire/Admin/Products/Index.php @@ -0,0 +1,69 @@ + */ + public array $selected = []; + + public function updatingSearch(): void + { + $this->resetPage(); + } + + public function updatingStatus(): void + { + $this->resetPage(); + } + + public function bulkArchive(): void + { + Product::query() + ->whereIn('id', $this->selected) + ->update(['status' => ProductStatus::Archived->value]); + $this->selected = []; + } + + public function bulkDelete(ProductService $service): void + { + foreach (Product::query()->whereIn('id', $this->selected)->get() as $product) { + try { + $service->delete($product); + } catch (\Throwable $e) { + // skip products that cannot be deleted + } + } + $this->selected = []; + } + + public function render() + { + $query = Product::query() + ->when($this->search !== '', fn ($q) => $q->where('title', 'like', '%'.$this->search.'%')) + ->when($this->status !== '', fn ($q) => $q->where('status', $this->status)) + ->orderByDesc('id'); + + return view('livewire.admin.products.index', [ + 'products' => $query->paginate(20), + 'statuses' => ProductStatus::cases(), + ]); + } +} diff --git a/app/Livewire/Admin/Search/Settings.php b/app/Livewire/Admin/Search/Settings.php new file mode 100644 index 00000000..b3624d23 --- /dev/null +++ b/app/Livewire/Admin/Search/Settings.php @@ -0,0 +1,75 @@ +where('store_id', $store->id)->first(); + if ($row) { + $this->synonyms = implode(', ', json_decode($row->synonyms_json ?? '[]', true) ?: []); + $this->stopWords = implode(', ', json_decode($row->stop_words_json ?? '[]', true) ?: []); + } + } + + public function save(): void + { + if (! Schema::hasTable('search_settings')) { + session()->flash('error', 'Search settings table unavailable.'); + + return; + } + + $store = app('current_store'); + $synonyms = array_values(array_filter(array_map('trim', explode(',', $this->synonyms)))); + $stopWords = array_values(array_filter(array_map('trim', explode(',', $this->stopWords)))); + + DB::table('search_settings')->updateOrInsert( + ['store_id' => $store->id], + [ + 'synonyms_json' => json_encode($synonyms), + 'stop_words_json' => json_encode($stopWords), + 'updated_at' => now(), + ], + ); + + session()->flash('success', 'Search settings saved.'); + } + + public function reindex(SearchService $service): void + { + $count = 0; + foreach (Product::query()->cursor() as $product) { + $service->syncProduct($product); + $count++; + } + + $this->reindexed = $count; + session()->flash('success', "Reindexed {$count} products."); + } + + public function render() + { + return view('livewire.admin.search.settings'); + } +} diff --git a/app/Livewire/Admin/Settings/Index.php b/app/Livewire/Admin/Settings/Index.php new file mode 100644 index 00000000..9765ba04 --- /dev/null +++ b/app/Livewire/Admin/Settings/Index.php @@ -0,0 +1,83 @@ +name = (string) $store->name; + $this->default_currency = (string) $store->default_currency; + $this->default_locale = (string) $store->default_locale; + $this->timezone = (string) $store->timezone; + } + + public function saveGeneral(): void + { + $data = $this->validate([ + 'name' => 'required|string|max:255', + 'default_currency' => 'required|string|size:3', + 'default_locale' => 'required|string|max:10', + 'timezone' => 'required|string|max:50', + ]); + + $store = app('current_store'); + Store::query()->whereKey($store->id)->update($data); + + session()->flash('success', 'General settings saved.'); + } + + public function addDomain(): void + { + $this->validate([ + 'newDomain' => 'required|string|max:255|unique:store_domains,hostname', + ]); + + $store = app('current_store'); + StoreDomain::create([ + 'store_id' => $store->id, + 'hostname' => strtolower($this->newDomain), + 'type' => 'storefront', + 'is_primary' => false, + 'created_at' => now(), + ]); + $this->newDomain = ''; + } + + public function removeDomain(int $id): void + { + StoreDomain::query()->whereKey($id)->delete(); + } + + public function render() + { + $store = app('current_store'); + $domains = StoreDomain::query()->where('store_id', $store->id)->get(); + + return view('livewire.admin.settings.index', [ + 'store' => $store, + 'domains' => $domains, + ]); + } +} diff --git a/app/Livewire/Admin/Settings/Shipping.php b/app/Livewire/Admin/Settings/Shipping.php new file mode 100644 index 00000000..1201fa7b --- /dev/null +++ b/app/Livewire/Admin/Settings/Shipping.php @@ -0,0 +1,81 @@ +> */ + public array $newRate = []; + + public function addZone(): void + { + $this->validate([ + 'newZoneName' => 'required|string|max:255', + ]); + + $store = app('current_store'); + ShippingZone::create([ + 'store_id' => $store->id, + 'name' => $this->newZoneName, + 'countries_json' => array_values(array_filter(array_map('trim', explode(',', $this->newZoneCountries)))), + 'regions_json' => [], + ]); + + $this->newZoneName = ''; + $this->newZoneCountries = ''; + } + + public function removeZone(int $id): void + { + ShippingZone::query()->whereKey($id)->delete(); + } + + public function addRate(int $zoneId): void + { + $entry = $this->newRate[$zoneId] ?? []; + $name = trim((string) ($entry['name'] ?? '')); + $type = $entry['type'] ?? 'flat'; + $amount = (int) ($entry['amount'] ?? 0); + + if ($name === '') { + return; + } + + ShippingRate::create([ + 'zone_id' => $zoneId, + 'name' => $name, + 'type' => $type, + 'config_json' => ['amount' => $amount], + 'is_active' => true, + ]); + + $this->newRate[$zoneId] = ['name' => '', 'type' => 'flat', 'amount' => 0]; + } + + public function removeRate(int $rateId): void + { + ShippingRate::query()->whereKey($rateId)->delete(); + } + + public function render() + { + $store = app('current_store'); + $zones = ShippingZone::query()->where('store_id', $store->id)->with('rates')->get(); + + return view('livewire.admin.settings.shipping', [ + 'zones' => $zones, + 'rateTypes' => ShippingRateType::cases(), + ]); + } +} diff --git a/app/Livewire/Admin/Settings/Taxes.php b/app/Livewire/Admin/Settings/Taxes.php new file mode 100644 index 00000000..501df98b --- /dev/null +++ b/app/Livewire/Admin/Settings/Taxes.php @@ -0,0 +1,58 @@ +where('store_id', $store->id)->first(); + if ($settings) { + $this->mode = $settings->mode?->value ?? 'manual'; + $this->prices_include_tax = (bool) $settings->prices_include_tax; + $this->default_rate_bp = (int) ($settings->config_json['default_rate_bp'] ?? 0); + } + } + + public function save(): void + { + $data = $this->validate([ + 'mode' => 'required|in:manual,provider', + 'prices_include_tax' => 'boolean', + 'default_rate_bp' => 'integer|min:0|max:10000', + ]); + + $store = app('current_store'); + TaxSettings::query()->updateOrInsert( + ['store_id' => $store->id], + [ + 'mode' => $data['mode'], + 'provider' => 'none', + 'prices_include_tax' => (bool) $data['prices_include_tax'], + 'config_json' => json_encode(['default_rate_bp' => (int) $data['default_rate_bp']]), + ], + ); + + session()->flash('success', 'Tax settings saved.'); + } + + public function render() + { + return view('livewire.admin.settings.taxes', [ + 'modes' => TaxMode::cases(), + ]); + } +} diff --git a/app/Livewire/Admin/Themes/Editor.php b/app/Livewire/Admin/Themes/Editor.php new file mode 100644 index 00000000..76d8d0e1 --- /dev/null +++ b/app/Livewire/Admin/Themes/Editor.php @@ -0,0 +1,63 @@ + */ + public array $settings = []; + + public function mount(Theme $theme): void + { + $this->theme = $theme; + $existing = $theme->settings?->settings_json ?? []; + $this->settings = array_merge([ + 'hero_heading' => '', + 'hero_subheading' => '', + 'featured_collection_handles' => '', + 'featured_product_handles' => '', + 'primary_color' => '#0f172a', + 'accent_color' => '#0ea5e9', + 'dark_mode' => 'auto', + ], $existing); + if (is_array($this->settings['featured_collection_handles'] ?? null)) { + $this->settings['featured_collection_handles'] = implode(',', $this->settings['featured_collection_handles']); + } + if (is_array($this->settings['featured_product_handles'] ?? null)) { + $this->settings['featured_product_handles'] = implode(',', $this->settings['featured_product_handles']); + } + } + + public function save(): void + { + $persist = $this->settings; + $persist['featured_collection_handles'] = array_values(array_filter(array_map('trim', explode(',', (string) $persist['featured_collection_handles'])))); + $persist['featured_product_handles'] = array_values(array_filter(array_map('trim', explode(',', (string) $persist['featured_product_handles'])))); + + ThemeSettings::query()->updateOrInsert( + ['theme_id' => $this->theme->id], + [ + 'settings_json' => json_encode($persist), + 'updated_at' => now(), + ], + ); + + app(ThemeSettingsService::class)->forget($this->theme->store); + + session()->flash('success', '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..bcd68d88 --- /dev/null +++ b/app/Livewire/Admin/Themes/Index.php @@ -0,0 +1,47 @@ +where('store_id', $store->id)->update(['status' => ThemeStatus::Draft->value]); + Theme::query()->whereKey($id)->update([ + 'status' => ThemeStatus::Published->value, + 'published_at' => now(), + ]); + } + + public function duplicate(int $id): void + { + $theme = Theme::query()->findOrFail($id); + $copy = $theme->replicate(['published_at']); + $copy->status = ThemeStatus::Draft; + $copy->name = $theme->name.' (copy)'; + $copy->published_at = null; + $copy->save(); + } + + public function delete(int $id): void + { + Theme::query()->whereKey($id)->where('status', '!=', ThemeStatus::Published->value)->delete(); + } + + public function render() + { + $store = app('current_store'); + $themes = Theme::query()->where('store_id', $store->id)->orderByDesc('id')->get(); + + return view('livewire.admin.themes.index', [ + 'themes' => $themes, + ]); + } +} diff --git a/app/Livewire/Settings/Appearance.php b/app/Livewire/Settings/Appearance.php deleted file mode 100644 index 7e87193e..00000000 --- a/app/Livewire/Settings/Appearance.php +++ /dev/null @@ -1,10 +0,0 @@ -validate([ - 'password' => $this->currentPasswordRules(), - ]); - - tap(Auth::user(), $logout(...))->delete(); - - $this->redirect('/', navigate: true); - } -} diff --git a/app/Livewire/Settings/Password.php b/app/Livewire/Settings/Password.php deleted file mode 100644 index 613abebe..00000000 --- a/app/Livewire/Settings/Password.php +++ /dev/null @@ -1,44 +0,0 @@ -validate([ - 'current_password' => $this->currentPasswordRules(), - 'password' => $this->passwordRules(), - ]); - } catch (ValidationException $e) { - $this->reset('current_password', 'password', 'password_confirmation'); - - throw $e; - } - - Auth::user()->update([ - 'password' => $validated['password'], - ]); - - $this->reset('current_password', 'password', 'password_confirmation'); - - $this->dispatch('password-updated'); - } -} diff --git a/app/Livewire/Settings/Profile.php b/app/Livewire/Settings/Profile.php deleted file mode 100644 index bfecd6cf..00000000 --- a/app/Livewire/Settings/Profile.php +++ /dev/null @@ -1,79 +0,0 @@ -name = Auth::user()->name; - $this->email = Auth::user()->email; - } - - /** - * Update the profile information for the currently authenticated user. - */ - public function updateProfileInformation(): void - { - $user = Auth::user(); - - $validated = $this->validate($this->profileRules($user->id)); - - $user->fill($validated); - - if ($user->isDirty('email')) { - $user->email_verified_at = null; - } - - $user->save(); - - $this->dispatch('profile-updated', name: $user->name); - } - - /** - * Send an email verification notification to the current user. - */ - public function resendVerificationNotification(): void - { - $user = Auth::user(); - - if ($user->hasVerifiedEmail()) { - $this->redirectIntended(default: route('dashboard', absolute: false)); - - return; - } - - $user->sendEmailVerificationNotification(); - - Session::flash('status', 'verification-link-sent'); - } - - #[Computed] - public function hasUnverifiedEmail(): bool - { - return Auth::user() instanceof MustVerifyEmail && ! Auth::user()->hasVerifiedEmail(); - } - - #[Computed] - public function showDeleteUser(): bool - { - return ! Auth::user() instanceof MustVerifyEmail - || (Auth::user() instanceof MustVerifyEmail && Auth::user()->hasVerifiedEmail()); - } -} diff --git a/app/Livewire/Settings/TwoFactor.php b/app/Livewire/Settings/TwoFactor.php deleted file mode 100644 index a1641b56..00000000 --- a/app/Livewire/Settings/TwoFactor.php +++ /dev/null @@ -1,182 +0,0 @@ -user()->two_factor_confirmed_at)) { - $disableTwoFactorAuthentication(auth()->user()); - } - - $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication(); - $this->requiresConfirmation = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'); - } - - /** - * Enable two-factor authentication for the user. - */ - public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthentication): void - { - $enableTwoFactorAuthentication(auth()->user()); - - if (! $this->requiresConfirmation) { - $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication(); - } - - $this->loadSetupData(); - - $this->showModal = true; - } - - /** - * Load the two-factor authentication setup data for the user. - */ - private function loadSetupData(): void - { - $user = auth()->user(); - - try { - $this->qrCodeSvg = $user?->twoFactorQrCodeSvg(); - $this->manualSetupKey = decrypt($user->two_factor_secret); - } catch (Exception) { - $this->addError('setupData', 'Failed to fetch setup data.'); - - $this->reset('qrCodeSvg', 'manualSetupKey'); - } - } - - /** - * Show the two-factor verification step if necessary. - */ - public function showVerificationIfNecessary(): void - { - if ($this->requiresConfirmation) { - $this->showVerificationStep = true; - - $this->resetErrorBag(); - - return; - } - - $this->closeModal(); - } - - /** - * Confirm two-factor authentication for the user. - */ - public function confirmTwoFactor(ConfirmTwoFactorAuthentication $confirmTwoFactorAuthentication): void - { - $this->validate(); - - $confirmTwoFactorAuthentication(auth()->user(), $this->code); - - $this->closeModal(); - - $this->twoFactorEnabled = true; - } - - /** - * Reset two-factor verification state. - */ - public function resetVerification(): void - { - $this->reset('code', 'showVerificationStep'); - - $this->resetErrorBag(); - } - - /** - * Disable two-factor authentication for the user. - */ - public function disable(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void - { - $disableTwoFactorAuthentication(auth()->user()); - - $this->twoFactorEnabled = false; - } - - /** - * Close the two-factor authentication modal. - */ - public function closeModal(): void - { - $this->reset( - 'code', - 'manualSetupKey', - 'qrCodeSvg', - 'showModal', - 'showVerificationStep', - ); - - $this->resetErrorBag(); - - if (! $this->requiresConfirmation) { - $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication(); - } - } - - /** - * Get the current modal configuration state. - */ - public function getModalConfigProperty(): array - { - if ($this->twoFactorEnabled) { - return [ - 'title' => __('Two-Factor Authentication Enabled'), - 'description' => __('Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.'), - 'buttonText' => __('Close'), - ]; - } - - if ($this->showVerificationStep) { - return [ - 'title' => __('Verify Authentication Code'), - 'description' => __('Enter the 6-digit code from your authenticator app.'), - 'buttonText' => __('Continue'), - ]; - } - - return [ - 'title' => __('Enable Two-Factor Authentication'), - 'description' => __('To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app.'), - 'buttonText' => __('Continue'), - ]; - } -} diff --git a/app/Livewire/Settings/TwoFactor/RecoveryCodes.php b/app/Livewire/Settings/TwoFactor/RecoveryCodes.php deleted file mode 100644 index 7352d80f..00000000 --- a/app/Livewire/Settings/TwoFactor/RecoveryCodes.php +++ /dev/null @@ -1,50 +0,0 @@ -loadRecoveryCodes(); - } - - /** - * Generate new recovery codes for the user. - */ - public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generateNewRecoveryCodes): void - { - $generateNewRecoveryCodes(auth()->user()); - - $this->loadRecoveryCodes(); - } - - /** - * Load the recovery codes for the user. - */ - private function loadRecoveryCodes(): void - { - $user = auth()->user(); - - if ($user->hasEnabledTwoFactorAuthentication() && $user->two_factor_recovery_codes) { - try { - $this->recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true); - } catch (Exception) { - $this->addError('recoveryCodes', 'Failed to load recovery codes'); - - $this->recoveryCodes = []; - } - } - } -} diff --git a/app/Livewire/Storefront/Account/Addresses/Index.php b/app/Livewire/Storefront/Account/Addresses/Index.php new file mode 100644 index 00000000..fdcc0191 --- /dev/null +++ b/app/Livewire/Storefront/Account/Addresses/Index.php @@ -0,0 +1,209 @@ + ['nullable', 'string', 'max:50'], + 'first_name' => ['required', 'string', 'max:120'], + 'last_name' => ['required', 'string', 'max:120'], + 'company' => ['nullable', 'string', 'max:120'], + 'address1' => ['required', 'string', 'max:255'], + 'address2' => ['nullable', 'string', 'max:255'], + 'city' => ['required', 'string', 'max:120'], + 'postal_code' => ['required', 'string', 'max:30'], + 'region' => ['nullable', 'string', 'max:120'], + 'country_code' => ['required', 'string', 'size:2'], + 'phone' => ['nullable', 'string', 'max:40'], + 'is_default' => ['boolean'], + ]; + } + + public function openCreate(): void + { + $this->resetForm(); + $this->showForm = true; + } + + public function openEdit(int $id): void + { + $address = $this->customerAddresses()->findOrFail($id); + $this->editingId = $address->id; + $this->label = (string) ($address->label ?? ''); + $this->is_default = (bool) $address->is_default; + + $json = $address->address_json ?? []; + foreach (['first_name', 'last_name', 'company', 'address1', 'address2', 'city', 'postal_code', 'region', 'country_code', 'phone'] as $key) { + $this->{$key} = (string) ($json[$key] ?? ''); + } + + $this->showForm = true; + } + + public function cancel(): void + { + $this->resetForm(); + $this->showForm = false; + } + + public function save(): void + { + $data = $this->validate(); + $customer = auth('customer')->user(); + + if (! $customer) { + return; + } + + $payload = [ + 'customer_id' => $customer->id, + 'label' => $data['label'] ?: null, + 'address_json' => collect($data) + ->only(['first_name', 'last_name', 'company', 'address1', 'address2', 'city', 'postal_code', 'region', 'country_code', 'phone']) + ->filter(fn ($v): bool => $v !== '' && $v !== null) + ->all(), + 'is_default' => $data['is_default'] ?? false, + ]; + + DB::transaction(function () use ($customer, $payload): void { + if ($payload['is_default']) { + CustomerAddress::query() + ->where('customer_id', $customer->id) + ->update(['is_default' => false]); + } + + if ($this->editingId) { + CustomerAddress::query() + ->where('customer_id', $customer->id) + ->where('id', $this->editingId) + ->update($payload); + } else { + $address = CustomerAddress::query()->create($payload); + + if ($customer->addresses()->count() === 1) { + $address->update(['is_default' => true]); + } + } + }); + + $this->resetForm(); + $this->showForm = false; + } + + public function setDefault(int $id): void + { + $customer = auth('customer')->user(); + + if (! $customer) { + return; + } + + DB::transaction(function () use ($customer, $id): void { + CustomerAddress::query() + ->where('customer_id', $customer->id) + ->update(['is_default' => false]); + + CustomerAddress::query() + ->where('customer_id', $customer->id) + ->where('id', $id) + ->update(['is_default' => true]); + }); + } + + public function delete(int $id): void + { + $customer = auth('customer')->user(); + + if (! $customer) { + return; + } + + $address = $this->customerAddresses()->find($id); + + if (! $address) { + return; + } + + $wasDefault = (bool) $address->is_default; + $address->delete(); + + if ($wasDefault) { + $next = $this->customerAddresses()->orderBy('id')->first(); + $next?->update(['is_default' => true]); + } + } + + public function render() + { + $addresses = $this->customerAddresses() + ->orderByDesc('is_default') + ->orderBy('id') + ->get(); + + return view('livewire.storefront.account.addresses.index', [ + 'addresses' => $addresses, + ]); + } + + protected function customerAddresses() + { + $customer = auth('customer')->user(); + + return CustomerAddress::query()->where('customer_id', $customer?->id ?? 0); + } + + protected function resetForm(): void + { + $this->editingId = null; + $this->label = ''; + $this->first_name = ''; + $this->last_name = ''; + $this->company = ''; + $this->address1 = ''; + $this->address2 = ''; + $this->city = ''; + $this->postal_code = ''; + $this->region = ''; + $this->country_code = ''; + $this->phone = ''; + $this->is_default = false; + $this->resetErrorBag(); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Login.php b/app/Livewire/Storefront/Account/Auth/Login.php new file mode 100644 index 00000000..473b75e0 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Login.php @@ -0,0 +1,91 @@ +validate(); + + $key = 'customer-login:'.request()->ip(); + + if (RateLimiter::tooManyAttempts($key, 5)) { + throw ValidationException::withMessages([ + 'email' => __('Too many attempts. Please try again in a minute.'), + ]); + } + + if (! Auth::guard('customer')->attempt([ + 'email' => $this->email, + 'password' => $this->password, + ], $this->remember)) { + RateLimiter::hit($key, 60); + + throw ValidationException::withMessages([ + 'email' => __('Invalid credentials.'), + ]); + } + + RateLimiter::clear($key); + if (request()->hasSession()) { + request()->session()->regenerate(); + } + + $this->mergeGuestCart(); + + return redirect()->intended(route('account.dashboard')); + } + + protected function mergeGuestCart(): void + { + if (! app()->bound('current_store')) { + return; + } + + $customer = Auth::guard('customer')->user(); + + if (! $customer) { + return; + } + + $store = app('current_store'); + $cartService = app(CartService::class); + $session = session(); + $guestCartId = $session->get(CartService::SESSION_KEY); + + $guest = $guestCartId + ? Cart::query()->where('store_id', $store->id)->find($guestCartId) + : null; + + $customerCart = $cartService->getOrCreateForSession($store, $customer); + + if ($guest && $guest->id !== $customerCart->id) { + $cartService->mergeOnLogin($guest, $customerCart); + $session->put(CartService::SESSION_KEY, $customerCart->id); + } + } + + public function render() + { + return view('livewire.storefront.account.auth.login'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Register.php b/app/Livewire/Storefront/Account/Auth/Register.php new file mode 100644 index 00000000..9309056c --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Register.php @@ -0,0 +1,89 @@ +resetErrorBag(); + + $validated = $this->validate([ + 'first_name' => ['required', 'string', 'max:120'], + 'last_name' => ['required', 'string', 'max:120'], + 'email' => [ + 'required', + 'email', + Rule::unique('customers', 'email')->where('store_id', $store->id), + ], + 'password' => ['required', 'string', 'min:8', 'confirmed'], + ]); + + $customer = Customer::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'first_name' => $validated['first_name'], + 'last_name' => $validated['last_name'], + 'email' => $validated['email'], + 'password' => $validated['password'], + 'state' => 'active', + 'email_verified_at' => now(), + ]); + + Auth::guard('customer')->login($customer); + if (request()->hasSession()) { + request()->session()->regenerate(); + } + + $this->mergeGuestCart($customer); + + return redirect()->route('account.dashboard'); + } + + protected function mergeGuestCart(Customer $customer): void + { + if (! app()->bound('current_store')) { + return; + } + + $store = app('current_store'); + $cartService = app(CartService::class); + $session = session(); + $guestCartId = $session->get(CartService::SESSION_KEY); + + $guest = $guestCartId + ? Cart::query()->where('store_id', $store->id)->find($guestCartId) + : null; + + $customerCart = $cartService->getOrCreateForSession($store, $customer); + + if ($guest && $guest->id !== $customerCart->id) { + $cartService->mergeOnLogin($guest, $customerCart); + $session->put(CartService::SESSION_KEY, $customerCart->id); + } + } + + public function render() + { + return view('livewire.storefront.account.auth.register'); + } +} diff --git a/app/Livewire/Storefront/Account/Dashboard.php b/app/Livewire/Storefront/Account/Dashboard.php new file mode 100644 index 00000000..19f33db4 --- /dev/null +++ b/app/Livewire/Storefront/Account/Dashboard.php @@ -0,0 +1,29 @@ +user(); + + $recentOrders = $customer + ? Order::query() + ->where('customer_id', $customer->id) + ->orderByDesc('placed_at') + ->limit(5) + ->get() + : new Collection; + + return view('livewire.storefront.account.dashboard', [ + 'recentOrders' => $recentOrders, + ]); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Index.php b/app/Livewire/Storefront/Account/Orders/Index.php new file mode 100644 index 00000000..4a752240 --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Index.php @@ -0,0 +1,31 @@ +user(); + + $orders = $customer + ? Order::query() + ->where('customer_id', $customer->id) + ->orderByDesc('placed_at') + ->paginate(15) + : new LengthAwarePaginator([], 0, 15, 1); + + return view('livewire.storefront.account.orders.index', [ + 'orders' => $orders, + ]); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Show.php b/app/Livewire/Storefront/Account/Orders/Show.php new file mode 100644 index 00000000..8e0796da --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Show.php @@ -0,0 +1,38 @@ +number = $number; + } + + public function render() + { + $customer = auth('customer')->user(); + + $order = Order::query() + ->where('order_number', $this->number) + ->when($customer, fn ($q) => $q->where('customer_id', $customer->id)) + ->with(['lines', 'fulfillments', 'payments']) + ->first(); + + if (! $order || ! $customer) { + throw new NotFoundHttpException('Order not found'); + } + + return view('livewire.storefront.account.orders.show', [ + 'order' => $order, + ]); + } +} diff --git a/app/Livewire/Storefront/Cart/Show.php b/app/Livewire/Storefront/Cart/Show.php new file mode 100644 index 00000000..c5ca7810 --- /dev/null +++ b/app/Livewire/Storefront/Cart/Show.php @@ -0,0 +1,169 @@ +loadCart(); + $this->appliedDiscountCode = $cart?->getAttribute('applied_discount_code'); + } + + public function increment(int $lineId): void + { + $this->adjust($lineId, +1); + } + + public function decrement(int $lineId): void + { + $this->adjust($lineId, -1); + } + + public function remove(int $lineId): void + { + $cart = $this->loadCart(); + if (! $cart) { + return; + } + + app(CartService::class)->removeLine($cart, $lineId); + $this->dispatch('cart:updated'); + } + + public function applyDiscount(): void + { + $cart = $this->loadCart(); + $this->discountError = null; + + if (! $cart || $this->discountCode === '') { + return; + } + + try { + app(DiscountService::class)->validate($this->discountCode, app('current_store'), $cart); + $this->appliedDiscountCode = strtoupper($this->discountCode); + $this->discountCode = ''; + } catch (InvalidDiscountException $e) { + $this->discountError = $e->getMessage(); + $this->appliedDiscountCode = null; + } + } + + public function removeDiscount(): void + { + $this->appliedDiscountCode = null; + } + + public function checkout() + { + $cart = $this->loadCart(); + + if (! $cart || $cart->lines->isEmpty()) { + return; + } + + $checkout = app(CheckoutService::class)->startFromCart($cart); + if ($this->appliedDiscountCode) { + $checkout->discount_code = $this->appliedDiscountCode; + $checkout->save(); + } + + session()->put('checkout_id', $checkout->id); + + return redirect()->route('storefront.checkout.show'); + } + + public function render() + { + $cart = $this->loadCart(); + $pricing = null; + $lines = []; + + if ($cart && $cart->lines->isNotEmpty()) { + $pricing = app(PricingEngine::class)->calculateForCart( + $cart, + app('current_store'), + discountCode: $this->appliedDiscountCode, + ); + $cart->refresh(); + $cart->load('lines.variant.product.media'); + + $lines = $cart->lines->map(fn ($line): array => [ + 'id' => $line->id, + 'title' => $line->variant?->product?->title ?? 'Item', + 'sku' => $line->variant?->sku, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'line_total_amount' => $line->line_total_amount, + ])->all(); + } + + return view('livewire.storefront.cart.show', [ + 'lines' => $lines, + 'totals' => $pricing?->toArray() ?? [ + 'subtotal' => 0, + 'discount' => 0, + 'total' => 0, + 'currency' => $cart?->currency ?? 'USD', + ], + 'currency' => $cart?->currency ?? 'USD', + 'isEmpty' => ! $cart || $cart->lines->isEmpty(), + ]); + } + + protected function adjust(int $lineId, int $delta): void + { + $cart = $this->loadCart(); + if (! $cart) { + return; + } + + $line = $cart->lines->firstWhere('id', $lineId); + if (! $line) { + return; + } + + $new = $line->quantity + $delta; + if ($new <= 0) { + app(CartService::class)->removeLine($cart, $lineId); + + return; + } + + try { + app(CartService::class)->updateLineQuantity($cart, $lineId, $new); + } catch (InsufficientInventoryException $e) { + $this->dispatch('cart:error', message: $e->getMessage()); + } + + $this->dispatch('cart:updated'); + } + + protected function loadCart(): ?Cart + { + $cartId = session(CartService::SESSION_KEY); + if (! $cartId) { + return null; + } + + return Cart::query()->with('lines.variant.product.media')->find($cartId); + } +} diff --git a/app/Livewire/Storefront/CartDrawer.php b/app/Livewire/Storefront/CartDrawer.php new file mode 100644 index 00000000..d4b271f5 --- /dev/null +++ b/app/Livewire/Storefront/CartDrawer.php @@ -0,0 +1,163 @@ + 0, + 'discount' => 0, + 'total' => 0, + 'item_count' => 0, + ]; + + public string $currency = 'USD'; + + public function mount(): void + { + $this->refreshCart(); + } + + #[On('cart:add-line')] + public function addLine(int $variantId, int $quantity = 1): void + { + $service = app(CartService::class); + $store = app('current_store'); + $cart = $service->getOrCreateForSession($store); + + try { + $service->addLine($cart, $variantId, $quantity); + } catch (InsufficientInventoryException $e) { + $this->dispatch('cart:error', message: $e->getMessage()); + + return; + } + + $this->open = true; + $this->refreshCart(); + $this->dispatch('cart:updated'); + } + + public function incrementLine(int $lineId): void + { + $this->adjustLine($lineId, +1); + } + + public function decrementLine(int $lineId): void + { + $this->adjustLine($lineId, -1); + } + + public function removeLine(int $lineId): void + { + $cart = $this->loadCart(); + if (! $cart) { + return; + } + + app(CartService::class)->removeLine($cart, $lineId); + $this->refreshCart(); + $this->dispatch('cart:updated'); + } + + public function close(): void + { + $this->open = false; + } + + public function openDrawer(): void + { + $this->refreshCart(); + $this->open = true; + } + + public function render() + { + return view('livewire.storefront.cart-drawer'); + } + + protected function adjustLine(int $lineId, int $delta): void + { + $cart = $this->loadCart(); + if (! $cart) { + return; + } + + $line = $cart->lines->firstWhere('id', $lineId); + if (! $line) { + return; + } + + $new = $line->quantity + $delta; + if ($new <= 0) { + app(CartService::class)->removeLine($cart, $lineId); + } else { + try { + app(CartService::class)->updateLineQuantity($cart, $lineId, $new); + } catch (InsufficientInventoryException $e) { + $this->dispatch('cart:error', message: $e->getMessage()); + } + } + + $this->refreshCart(); + $this->dispatch('cart:updated'); + } + + protected function loadCart(): ?Cart + { + $cartId = session(CartService::SESSION_KEY); + if (! $cartId) { + return null; + } + + return Cart::query()->with('lines.variant.product.media')->find($cartId); + } + + protected function refreshCart(): void + { + $cart = $this->loadCart(); + + if (! $cart) { + $this->cartId = null; + $this->linesData = []; + $this->totals = ['subtotal' => 0, 'discount' => 0, 'total' => 0, 'item_count' => 0]; + + return; + } + + $this->cartId = $cart->id; + $this->currency = $cart->currency; + + $pricing = app(PricingEngine::class)->calculateForCart($cart, app('current_store')); + + $this->linesData = $cart->lines->map(fn ($line): array => [ + 'id' => $line->id, + 'title' => $line->variant?->product?->title ?? 'Item', + 'sku' => $line->variant?->sku, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'line_total_amount' => $line->line_total_amount, + 'image_url' => $line->variant?->product?->media->first()?->storage_key, + ])->all(); + + $this->totals = [ + 'subtotal' => $pricing->subtotal, + 'discount' => $pricing->discount, + 'total' => $pricing->subtotal - $pricing->discount, + 'item_count' => (int) $cart->lines->sum('quantity'), + ]; + } +} diff --git a/app/Livewire/Storefront/Checkout/Confirmation.php b/app/Livewire/Storefront/Checkout/Confirmation.php new file mode 100644 index 00000000..e8092ebc --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Confirmation.php @@ -0,0 +1,31 @@ +number = $number; + + $this->order = Order::query() + ->where('store_id', app('current_store')->id) + ->where('order_number', $number) + ->with(['lines', 'payments']) + ->first(); + } + + public function render() + { + return view('livewire.storefront.checkout.confirmation'); + } +} diff --git a/app/Livewire/Storefront/Checkout/Show.php b/app/Livewire/Storefront/Checkout/Show.php new file mode 100644 index 00000000..e9707ec5 --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Show.php @@ -0,0 +1,166 @@ + '', + 'last_name' => '', + 'address1' => '', + 'address2' => '', + 'city' => '', + 'province_code' => '', + 'zip' => '', + 'country_code' => 'US', + 'phone' => '', + ]; + + public ?int $shippingMethodId = null; + + public string $paymentMethod = 'credit_card'; + + public array $errorMessages = []; + + public function mount(): void + { + $checkout = $this->ensureCheckout(); + $this->checkoutId = $checkout->id; + $this->email = (string) ($checkout->email ?? ''); + if ($checkout->shipping_address_json) { + $this->address = array_merge($this->address, $checkout->shipping_address_json); + } + $this->shippingMethodId = $checkout->shipping_method_id; + if ($checkout->payment_method) { + $this->paymentMethod = $checkout->payment_method; + } + } + + public function submitAddress(): void + { + $this->errorMessages = []; + + try { + app(CheckoutService::class)->setAddress($this->checkout(), [ + 'email' => $this->email, + 'shipping_address' => $this->address, + ]); + } catch (\Illuminate\Validation\ValidationException $e) { + $this->errorMessages = collect($e->errors())->flatten()->all(); + } + } + + public function selectShipping(int $rateId): void + { + $this->shippingMethodId = $rateId; + + try { + app(CheckoutService::class)->setShippingMethod($this->checkout(), $rateId); + } catch (\Illuminate\Validation\ValidationException $e) { + $this->errorMessages = collect($e->errors())->flatten()->all(); + } + } + + public array $card = [ + 'card_number' => '', + 'cardholder_name' => '', + 'expiry' => '', + 'cvc' => '', + ]; + + public function placeOrder() + { + $checkout = $this->checkout(); + + try { + app(CheckoutService::class)->selectPaymentMethod($checkout, $this->paymentMethod); + } catch (\Illuminate\Validation\ValidationException $e) { + $this->errorMessages = collect($e->errors())->flatten()->all(); + + return null; + } + + try { + $order = app(OrderService::class)->createFromCheckout($checkout->fresh(), $this->card); + } catch (PaymentFailedException $e) { + $this->errorMessages = ['Payment declined: '.$e->errorCode]; + + return null; + } + + session()->forget(['checkout_id', CartService::SESSION_KEY]); + + return redirect()->route('storefront.checkout.confirmation', ['number' => $order->order_number]); + } + + public function render() + { + $checkout = $this->checkout(); + $pricing = app(PricingEngine::class)->calculate($checkout); + + $availableRates = []; + if ($checkout->status !== CheckoutStatus::Started) { + $cart = $checkout->cart()->with('lines.variant')->first(); + $availableRates = app(ShippingCalculator::class) + ->getAvailableRates(app('current_store'), $checkout->shipping_address_json ?? [], $cart) + ->map(fn ($rate) => [ + 'id' => $rate->id, + 'name' => $rate->name, + 'amount' => app(ShippingCalculator::class)->calculate($rate, $cart) ?? 0, + ]) + ->values() + ->all(); + } + + return view('livewire.storefront.checkout.show', [ + 'checkout' => $checkout, + 'pricing' => $pricing, + 'availableRates' => $availableRates, + ]); + } + + protected function checkout(): Checkout + { + $id = $this->checkoutId ?? session('checkout_id'); + $checkout = Checkout::query()->with(['cart.lines.variant.product'])->find($id); + if (! $checkout) { + throw new NotFoundHttpException('Checkout not found'); + } + + return $checkout; + } + + protected function ensureCheckout(): Checkout + { + $id = session('checkout_id'); + if ($id) { + $checkout = Checkout::query()->find($id); + if ($checkout && $checkout->status !== CheckoutStatus::Completed) { + return $checkout; + } + } + + $cart = app(CartService::class)->getOrCreateForSession(app('current_store')); + $checkout = app(CheckoutService::class)->startFromCart($cart); + session()->put('checkout_id', $checkout->id); + + return $checkout; + } +} diff --git a/app/Livewire/Storefront/Collections/Index.php b/app/Livewire/Storefront/Collections/Index.php new file mode 100644 index 00000000..d8d6a574 --- /dev/null +++ b/app/Livewire/Storefront/Collections/Index.php @@ -0,0 +1,30 @@ +bound('current_store')) { + $collections = DB::table('collections') + ->where('store_id', app('current_store')->id) + ->where('status', 'active') + ->orderBy('title') + ->get(['id', 'title', 'handle']) + ->all(); + } + + return view('livewire.storefront.collections.index', [ + 'collections' => $collections, + ]); + } +} diff --git a/app/Livewire/Storefront/Collections/Show.php b/app/Livewire/Storefront/Collections/Show.php new file mode 100644 index 00000000..b92b61b3 --- /dev/null +++ b/app/Livewire/Storefront/Collections/Show.php @@ -0,0 +1,94 @@ +handle = $handle; + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedSort(): void + { + $this->resetPage(); + } + + public function render() + { + if (! Schema::hasTable('collections') || ! app()->bound('current_store')) { + throw new NotFoundHttpException('Collections not available'); + } + + $storeId = app('current_store')->id; + + $collection = DB::table('collections') + ->where('store_id', $storeId) + ->where('handle', $this->handle) + ->where('status', 'active') + ->first(['id', 'title', 'handle', 'description_html']); + + if (! $collection) { + throw new NotFoundHttpException('Collection not found'); + } + + $products = $this->loadProducts($collection->id); + + return view('livewire.storefront.collections.show', [ + 'collection' => $collection, + 'products' => $products, + ]); + } + + protected function loadProducts(int $collectionId): LengthAwarePaginator + { + if (! Schema::hasTable('products')) { + return new LengthAwarePaginator([], 0, 12, 1); + } + + $query = DB::table('products') + ->join('collection_products', 'collection_products.product_id', '=', 'products.id') + ->where('collection_products.collection_id', $collectionId) + ->where('products.status', 'active'); + + if ($this->search !== '') { + $query->where('products.title', 'like', '%'.$this->search.'%'); + } + + match ($this->sort) { + 'title-asc' => $query->orderBy('products.title'), + 'title-desc' => $query->orderByDesc('products.title'), + 'newest' => $query->orderByDesc('products.created_at'), + default => $query->orderBy('collection_products.position'), + }; + + return $query + ->select('products.id', 'products.title', 'products.handle') + ->paginate(12); + } +} diff --git a/app/Livewire/Storefront/Home.php b/app/Livewire/Storefront/Home.php new file mode 100644 index 00000000..5681d5b4 --- /dev/null +++ b/app/Livewire/Storefront/Home.php @@ -0,0 +1,80 @@ +forStore(); + + $featuredCollections = $this->loadFeaturedCollections((array) ($settings['featured_collection_handles'] ?? [])); + $featuredProducts = $this->loadFeaturedProducts((array) ($settings['featured_product_handles'] ?? [])); + + return view('livewire.storefront.home', [ + 'themeSettings' => $settings, + 'featuredCollections' => $featuredCollections, + 'featuredProducts' => $featuredProducts, + ]); + } + + /** + * @param array $handles + * @return array> + */ + protected function loadFeaturedCollections(array $handles): array + { + if (! Schema::hasTable('collections') || ! app()->bound('current_store')) { + return []; + } + + $storeId = app('current_store')->id; + + $query = \DB::table('collections') + ->where('store_id', $storeId) + ->where('status', 'active'); + + if (! empty($handles)) { + $query->whereIn('handle', $handles); + } + + return $query->limit(6)->get(['id', 'title', 'handle'])->map(fn ($row): array => [ + 'id' => $row->id, + 'title' => $row->title, + 'handle' => $row->handle, + ])->all(); + } + + /** + * @param array $handles + * @return array> + */ + protected function loadFeaturedProducts(array $handles): array + { + if (! Schema::hasTable('products') || ! app()->bound('current_store')) { + return []; + } + + $storeId = app('current_store')->id; + + $query = \DB::table('products') + ->where('store_id', $storeId) + ->where('status', 'active'); + + if (! empty($handles)) { + $query->whereIn('handle', $handles); + } + + return $query->limit(8)->get(['id', 'title', 'handle'])->map(fn ($row): array => [ + 'id' => $row->id, + 'title' => $row->title, + 'handle' => $row->handle, + ])->all(); + } +} diff --git a/app/Livewire/Storefront/Pages/Show.php b/app/Livewire/Storefront/Pages/Show.php new file mode 100644 index 00000000..b78eaef0 --- /dev/null +++ b/app/Livewire/Storefront/Pages/Show.php @@ -0,0 +1,36 @@ +handle = $handle; + } + + public function render() + { + $page = Page::query() + ->where('handle', $this->handle) + ->where('status', PageStatus::Published->value) + ->first(); + + if (! $page) { + throw new NotFoundHttpException('Page not found'); + } + + return view('livewire.storefront.pages.show', [ + 'page' => $page, + ]); + } +} diff --git a/app/Livewire/Storefront/Products/Show.php b/app/Livewire/Storefront/Products/Show.php new file mode 100644 index 00000000..37a2a0a3 --- /dev/null +++ b/app/Livewire/Storefront/Products/Show.php @@ -0,0 +1,79 @@ +handle = $handle; + } + + public function addToCart(): void + { + if ($this->selectedVariantId === null || $this->quantity < 1) { + return; + } + + $this->dispatch('cart:add-line', variantId: $this->selectedVariantId, quantity: $this->quantity); + } + + public function render() + { + if (! Schema::hasTable('products') || ! app()->bound('current_store')) { + throw new NotFoundHttpException('Products not available'); + } + + $storeId = app('current_store')->id; + + $product = DB::table('products') + ->where('store_id', $storeId) + ->where('handle', $this->handle) + ->where('status', 'active') + ->first(['id', 'title', 'handle', 'description_html', 'tags']); + + if (! $product) { + throw new NotFoundHttpException('Product not found'); + } + + $variants = Schema::hasTable('product_variants') + ? DB::table('product_variants') + ->where('product_id', $product->id) + ->orderBy('position') + ->get(['id', 'sku', 'price_amount', 'compare_at_amount', 'currency', 'is_default', 'status']) + ->all() + : []; + + $media = Schema::hasTable('product_media') + ? DB::table('product_media') + ->where('product_id', $product->id) + ->orderBy('position') + ->get(['id', 'storage_key', 'alt_text']) + ->all() + : []; + + if ($this->selectedVariantId === null && ! empty($variants)) { + $default = collect($variants)->firstWhere('is_default', 1) ?? $variants[0]; + $this->selectedVariantId = (int) $default->id; + } + + return view('livewire.storefront.products.show', [ + 'product' => $product, + 'variants' => $variants, + 'media' => $media, + ]); + } +} diff --git a/app/Livewire/Storefront/Search/Index.php b/app/Livewire/Storefront/Search/Index.php new file mode 100644 index 00000000..dfc910eb --- /dev/null +++ b/app/Livewire/Storefront/Search/Index.php @@ -0,0 +1,47 @@ +resetPage(); + } + + public function render() + { + return view('livewire.storefront.search.index', [ + 'results' => $this->searchProducts(), + ]); + } + + protected function searchProducts(): LengthAwarePaginator + { + if (! app()->bound('current_store') || trim($this->query) === '') { + return new Paginator([], 0, 12, 1); + } + + return app(SearchService::class)->search( + app('current_store'), + $this->query, + [], + 12, + $this->getPage() + ); + } +} diff --git a/app/Livewire/Storefront/Search/Modal.php b/app/Livewire/Storefront/Search/Modal.php new file mode 100644 index 00000000..efb75c67 --- /dev/null +++ b/app/Livewire/Storefront/Search/Modal.php @@ -0,0 +1,57 @@ + + */ + public array $results = []; + + public function toggle(): void + { + $this->open = ! $this->open; + + if (! $this->open) { + $this->query = ''; + $this->results = []; + } + } + + public function updatedQuery(): void + { + $this->results = $this->searchProducts(); + } + + public function render() + { + return view('livewire.storefront.search.modal'); + } + + /** + * @return array + */ + protected function searchProducts(): array + { + if (! app()->bound('current_store') || trim($this->query) === '') { + return []; + } + + return app(SearchService::class) + ->autocomplete(app('current_store'), $this->query, 8) + ->map(fn ($p): array => [ + 'id' => (int) $p->id, + 'title' => $p->title, + 'handle' => $p->handle, + ]) + ->all(); + } +} diff --git a/app/Models/AnalyticsDaily.php b/app/Models/AnalyticsDaily.php new file mode 100644 index 00000000..90b8a27b --- /dev/null +++ b/app/Models/AnalyticsDaily.php @@ -0,0 +1,46 @@ + */ + use BelongsToStore, HasFactory; + + protected $table = 'analytics_daily'; + + public $timestamps = false; + + public $incrementing = false; + + protected $primaryKey = null; + + protected $fillable = [ + 'store_id', + 'date', + 'orders_count', + 'revenue_amount', + 'aov_amount', + 'visits_count', + 'add_to_cart_count', + 'checkout_started_count', + 'checkout_completed_count', + ]; + + protected function casts(): array + { + return [ + 'orders_count' => 'integer', + 'revenue_amount' => 'integer', + 'aov_amount' => 'integer', + 'visits_count' => 'integer', + 'add_to_cart_count' => 'integer', + 'checkout_started_count' => 'integer', + 'checkout_completed_count' => 'integer', + ]; + } +} diff --git a/app/Models/AnalyticsEvent.php b/app/Models/AnalyticsEvent.php new file mode 100644 index 00000000..ef744741 --- /dev/null +++ b/app/Models/AnalyticsEvent.php @@ -0,0 +1,41 @@ + */ + use BelongsToStore, HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'store_id', + 'type', + 'session_id', + 'customer_id', + 'properties_json', + 'client_event_id', + 'occurred_at', + 'created_at', + ]; + + protected function casts(): array + { + return [ + 'properties_json' => 'array', + 'occurred_at' => 'datetime', + 'created_at' => 'datetime', + ]; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/App.php b/app/Models/App.php new file mode 100644 index 00000000..e1cda76c --- /dev/null +++ b/app/Models/App.php @@ -0,0 +1,40 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'name', + 'status', + 'created_at', + ]; + + protected function casts(): array + { + return [ + 'status' => AppStatus::class, + 'created_at' => 'datetime', + ]; + } + + public function installations(): HasMany + { + return $this->hasMany(AppInstallation::class, 'app_id'); + } + + public function oauthClients(): HasMany + { + return $this->hasMany(OauthClient::class, 'app_id'); + } +} diff --git a/app/Models/AppInstallation.php b/app/Models/AppInstallation.php new file mode 100644 index 00000000..868ca2b4 --- /dev/null +++ b/app/Models/AppInstallation.php @@ -0,0 +1,50 @@ + */ + use BelongsToStore, HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'store_id', + 'app_id', + 'scopes_json', + 'status', + 'installed_at', + ]; + + protected function casts(): array + { + return [ + 'scopes_json' => 'array', + 'status' => AppInstallationStatus::class, + 'installed_at' => 'datetime', + ]; + } + + public function app(): BelongsTo + { + return $this->belongsTo(App::class); + } + + public function subscriptions(): HasMany + { + return $this->hasMany(WebhookSubscription::class, 'app_installation_id'); + } + + public function tokens(): HasMany + { + return $this->hasMany(OauthToken::class, 'installation_id'); + } +} diff --git a/app/Models/Cart.php b/app/Models/Cart.php new file mode 100644 index 00000000..373e0940 --- /dev/null +++ b/app/Models/Cart.php @@ -0,0 +1,57 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'customer_id', + 'currency', + 'cart_version', + 'status', + ]; + + protected function casts(): array + { + return [ + 'status' => CartStatus::class, + 'cart_version' => 'integer', + ]; + } + + 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); + } + + public function itemCount(): int + { + return (int) $this->lines->sum('quantity'); + } + + public function subtotal(): int + { + return (int) $this->lines->sum('line_subtotal_amount'); + } +} diff --git a/app/Models/CartLine.php b/app/Models/CartLine.php new file mode 100644 index 00000000..96f3f073 --- /dev/null +++ b/app/Models/CartLine.php @@ -0,0 +1,46 @@ + */ + 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 function casts(): array + { + return [ + 'quantity' => 'integer', + 'unit_price_amount' => 'integer', + 'line_subtotal_amount' => 'integer', + 'line_discount_amount' => 'integer', + 'line_total_amount' => 'integer', + ]; + } + + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } +} diff --git a/app/Models/Checkout.php b/app/Models/Checkout.php new file mode 100644 index 00000000..364e9bce --- /dev/null +++ b/app/Models/Checkout.php @@ -0,0 +1,58 @@ + */ + 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 function casts(): array + { + return [ + 'status' => CheckoutStatus::class, + 'shipping_address_json' => 'array', + 'billing_address_json' => 'array', + 'tax_provider_snapshot_json' => 'array', + 'totals_json' => 'array', + 'expires_at' => 'datetime', + ]; + } + + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function shippingRate(): BelongsTo + { + return $this->belongsTo(ShippingRate::class, 'shipping_method_id'); + } +} diff --git a/app/Models/Collection.php b/app/Models/Collection.php new file mode 100644 index 00000000..9cd1aefa --- /dev/null +++ b/app/Models/Collection.php @@ -0,0 +1,40 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'description_html', + 'type', + 'status', + ]; + + protected function casts(): array + { + return [ + 'status' => CollectionStatus::class, + 'type' => CollectionType::class, + ]; + } + + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'collection_products') + ->withPivot('position') + ->orderByPivot('position'); + } +} diff --git a/app/Models/Concerns/BelongsToStore.php b/app/Models/Concerns/BelongsToStore.php new file mode 100644 index 00000000..2ca16b11 --- /dev/null +++ b/app/Models/Concerns/BelongsToStore.php @@ -0,0 +1,29 @@ +getAttribute('store_id') && app()->bound('current_store')) { + $store = app('current_store'); + if ($store instanceof Store) { + $model->setAttribute('store_id', $store->id); + } + } + }); + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php new file mode 100644 index 00000000..6f81b4cc --- /dev/null +++ b/app/Models/Customer.php @@ -0,0 +1,60 @@ + */ + use BelongsToStore, HasFactory, Notifiable; + + protected $fillable = [ + 'store_id', + 'email', + 'password', + 'first_name', + 'last_name', + 'phone', + 'state', + 'accepts_marketing', + 'tags_json', + 'metadata_json', + ]; + + protected $hidden = [ + 'password', + 'remember_token', + ]; + + protected function casts(): array + { + return [ + 'accepts_marketing' => 'boolean', + 'tags_json' => 'array', + 'metadata_json' => 'array', + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; + } + + public function addresses(): HasMany + { + return $this->hasMany(CustomerAddress::class); + } + + public function fullName(): string + { + return trim(($this->first_name ?? '').' '.($this->last_name ?? '')) ?: $this->email; + } + + public function getEmailForPasswordReset(): string + { + return $this->email; + } +} diff --git a/app/Models/CustomerAddress.php b/app/Models/CustomerAddress.php new file mode 100644 index 00000000..ba23f1d0 --- /dev/null +++ b/app/Models/CustomerAddress.php @@ -0,0 +1,35 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'customer_id', + 'label', + 'address_json', + 'is_default', + ]; + + protected function casts(): array + { + return [ + 'address_json' => 'array', + 'is_default' => 'boolean', + ]; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/Discount.php b/app/Models/Discount.php new file mode 100644 index 00000000..2af0202d --- /dev/null +++ b/app/Models/Discount.php @@ -0,0 +1,45 @@ + */ + 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 function casts(): array + { + return [ + 'type' => DiscountType::class, + 'value_type' => DiscountValueType::class, + 'status' => DiscountStatus::class, + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'rules_json' => 'array', + 'value_amount' => 'integer', + 'usage_limit' => 'integer', + 'usage_count' => 'integer', + ]; + } +} diff --git a/app/Models/Fulfillment.php b/app/Models/Fulfillment.php new file mode 100644 index 00000000..2379742f --- /dev/null +++ b/app/Models/Fulfillment.php @@ -0,0 +1,48 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'order_id', + 'status', + 'tracking_company', + 'tracking_number', + 'tracking_url', + 'shipped_at', + 'delivered_at', + 'created_at', + ]; + + protected function casts(): array + { + return [ + 'status' => FulfillmentShipmentStatus::class, + 'shipped_at' => 'datetime', + 'delivered_at' => 'datetime', + 'created_at' => 'datetime', + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function lines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } +} diff --git a/app/Models/FulfillmentLine.php b/app/Models/FulfillmentLine.php new file mode 100644 index 00000000..1fdeceeb --- /dev/null +++ b/app/Models/FulfillmentLine.php @@ -0,0 +1,38 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'fulfillment_id', + 'order_line_id', + 'quantity', + ]; + + protected function casts(): array + { + return [ + 'quantity' => 'integer', + ]; + } + + public function fulfillment(): BelongsTo + { + return $this->belongsTo(Fulfillment::class); + } + + public function orderLine(): BelongsTo + { + return $this->belongsTo(OrderLine::class); + } +} diff --git a/app/Models/InventoryItem.php b/app/Models/InventoryItem.php new file mode 100644 index 00000000..ce8fdab3 --- /dev/null +++ b/app/Models/InventoryItem.php @@ -0,0 +1,44 @@ + */ + use BelongsToStore, HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'store_id', + 'variant_id', + 'quantity_on_hand', + 'quantity_reserved', + 'policy', + ]; + + protected function casts(): array + { + return [ + 'policy' => InventoryPolicy::class, + 'quantity_on_hand' => 'integer', + 'quantity_reserved' => 'integer', + ]; + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + public function available(): int + { + return $this->quantity_on_hand - $this->quantity_reserved; + } +} diff --git a/app/Models/NavigationItem.php b/app/Models/NavigationItem.php new file mode 100644 index 00000000..f5d3180a --- /dev/null +++ b/app/Models/NavigationItem.php @@ -0,0 +1,39 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'menu_id', + 'type', + 'label', + 'url', + 'resource_id', + 'position', + ]; + + protected function casts(): array + { + return [ + 'type' => NavigationItemType::class, + 'resource_id' => 'integer', + 'position' => 'integer', + ]; + } + + public function menu(): BelongsTo + { + return $this->belongsTo(NavigationMenu::class, 'menu_id'); + } +} diff --git a/app/Models/NavigationMenu.php b/app/Models/NavigationMenu.php new file mode 100644 index 00000000..8da4ce6f --- /dev/null +++ b/app/Models/NavigationMenu.php @@ -0,0 +1,25 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'handle', + 'title', + ]; + + public function items(): HasMany + { + return $this->hasMany(NavigationItem::class, 'menu_id')->orderBy('position'); + } +} diff --git a/app/Models/OauthClient.php b/app/Models/OauthClient.php new file mode 100644 index 00000000..b9230f17 --- /dev/null +++ b/app/Models/OauthClient.php @@ -0,0 +1,34 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'app_id', + 'client_id', + 'client_secret_encrypted', + 'redirect_uris_json', + ]; + + protected function casts(): array + { + return [ + 'redirect_uris_json' => 'array', + ]; + } + + public function app(): BelongsTo + { + return $this->belongsTo(App::class); + } +} diff --git a/app/Models/OauthToken.php b/app/Models/OauthToken.php new file mode 100644 index 00000000..f9e370fb --- /dev/null +++ b/app/Models/OauthToken.php @@ -0,0 +1,34 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'installation_id', + 'access_token_hash', + 'refresh_token_hash', + 'expires_at', + ]; + + protected function casts(): array + { + return [ + 'expires_at' => 'datetime', + ]; + } + + public function installation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class, 'installation_id'); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 00000000..dd73a3cc --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,88 @@ + */ + 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 function casts(): array + { + return [ + 'status' => OrderStatus::class, + 'financial_status' => FinancialStatus::class, + 'fulfillment_status' => FulfillmentStatus::class, + 'payment_method' => PaymentMethod::class, + 'billing_address_json' => 'array', + 'shipping_address_json' => 'array', + 'placed_at' => 'datetime', + ]; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function checkout(): BelongsTo + { + return $this->belongsTo(Checkout::class); + } + + public function lines(): HasMany + { + return $this->hasMany(OrderLine::class); + } + + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } + + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } + + public function fulfillments(): HasMany + { + return $this->hasMany(Fulfillment::class); + } + + public function totalRefunded(): int + { + return (int) $this->refunds()->where('status', 'processed')->sum('amount'); + } +} diff --git a/app/Models/OrderLine.php b/app/Models/OrderLine.php new file mode 100644 index 00000000..15dc4123 --- /dev/null +++ b/app/Models/OrderLine.php @@ -0,0 +1,65 @@ + */ + 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 function casts(): array + { + return [ + 'quantity' => 'integer', + 'unit_price_amount' => 'integer', + 'total_amount' => 'integer', + 'tax_lines_json' => 'array', + 'discount_allocations_json' => 'array', + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + public function fulfillmentLines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } + + public function fulfilledQuantity(): int + { + return (int) $this->fulfillmentLines()->sum('quantity'); + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php new file mode 100644 index 00000000..0a354294 --- /dev/null +++ b/app/Models/Organization.php @@ -0,0 +1,23 @@ + */ + use HasFactory; + + 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..8453a756 --- /dev/null +++ b/app/Models/Page.php @@ -0,0 +1,45 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'body_html', + 'status', + 'published_at', + ]; + + protected function casts(): array + { + return [ + 'status' => PageStatus::class, + 'published_at' => 'datetime', + ]; + } + + /** + * @param Builder $query + */ + public function scopePublished(Builder $query): Builder + { + return $query->where('status', PageStatus::Published->value); + } + + public function isPublished(): bool + { + return $this->status === PageStatus::Published; + } +} diff --git a/app/Models/Payment.php b/app/Models/Payment.php new file mode 100644 index 00000000..17b1fa24 --- /dev/null +++ b/app/Models/Payment.php @@ -0,0 +1,50 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'order_id', + 'provider', + 'method', + 'provider_payment_id', + 'status', + 'amount', + 'currency', + 'raw_json_encrypted', + 'created_at', + ]; + + protected function casts(): array + { + return [ + 'method' => PaymentMethod::class, + 'status' => PaymentStatus::class, + 'amount' => 'integer', + 'created_at' => 'datetime', + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 00000000..ab4c8303 --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,67 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'status', + 'description_html', + 'vendor', + 'product_type', + 'tags', + 'published_at', + ]; + + protected function casts(): array + { + return [ + 'status' => ProductStatus::class, + 'tags' => 'array', + 'published_at' => 'datetime', + ]; + } + + public function options(): HasMany + { + return $this->hasMany(ProductOption::class)->orderBy('position'); + } + + public function variants(): HasMany + { + return $this->hasMany(ProductVariant::class)->orderBy('position'); + } + + public function media(): HasMany + { + return $this->hasMany(ProductMedia::class)->orderBy('position'); + } + + public function collections(): BelongsToMany + { + return $this->belongsToMany(Collection::class, 'collection_products') + ->withPivot('position'); + } + + public function defaultVariant(): ?ProductVariant + { + return $this->variants()->where('is_default', true)->first() + ?? $this->variants()->orderBy('position')->first(); + } +} diff --git a/app/Models/ProductMedia.php b/app/Models/ProductMedia.php new file mode 100644 index 00000000..00832f1a --- /dev/null +++ b/app/Models/ProductMedia.php @@ -0,0 +1,47 @@ + */ + use HasFactory; + + protected $table = 'product_media'; + + public $timestamps = false; + + protected $fillable = [ + 'product_id', + 'type', + 'storage_key', + 'alt_text', + 'width', + 'height', + 'mime_type', + 'byte_size', + 'position', + 'status', + 'created_at', + ]; + + protected function casts(): array + { + return [ + 'type' => MediaType::class, + 'status' => MediaStatus::class, + 'created_at' => 'datetime', + ]; + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Models/ProductOption.php b/app/Models/ProductOption.php new file mode 100644 index 00000000..bf60d918 --- /dev/null +++ b/app/Models/ProductOption.php @@ -0,0 +1,32 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'product_id', + 'name', + 'position', + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function values(): HasMany + { + return $this->hasMany(ProductOptionValue::class)->orderBy('position'); + } +} diff --git a/app/Models/ProductOptionValue.php b/app/Models/ProductOptionValue.php new file mode 100644 index 00000000..a276cd23 --- /dev/null +++ b/app/Models/ProductOptionValue.php @@ -0,0 +1,37 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'product_option_id', + 'value', + 'position', + ]; + + public function option(): BelongsTo + { + return $this->belongsTo(ProductOption::class, 'product_option_id'); + } + + public function variants(): BelongsToMany + { + return $this->belongsToMany( + ProductVariant::class, + 'variant_option_values', + 'product_option_value_id', + 'variant_id' + ); + } +} diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php new file mode 100644 index 00000000..c6ac0078 --- /dev/null +++ b/app/Models/ProductVariant.php @@ -0,0 +1,59 @@ + */ + use HasFactory; + + protected $fillable = [ + 'product_id', + 'sku', + 'barcode', + 'price_amount', + 'compare_at_amount', + 'currency', + 'weight_g', + 'requires_shipping', + 'is_default', + 'position', + 'status', + ]; + + protected function casts(): array + { + return [ + 'status' => VariantStatus::class, + 'requires_shipping' => 'boolean', + 'is_default' => 'boolean', + ]; + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function inventoryItem(): HasOne + { + return $this->hasOne(InventoryItem::class, 'variant_id'); + } + + public function optionValues(): BelongsToMany + { + return $this->belongsToMany( + ProductOptionValue::class, + 'variant_option_values', + 'variant_id', + 'product_option_value_id' + ); + } +} diff --git a/app/Models/Refund.php b/app/Models/Refund.php new file mode 100644 index 00000000..ee957bfe --- /dev/null +++ b/app/Models/Refund.php @@ -0,0 +1,45 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'order_id', + 'payment_id', + 'amount', + 'reason', + 'status', + 'provider_refund_id', + 'created_at', + ]; + + protected function casts(): array + { + return [ + 'status' => RefundStatus::class, + 'amount' => 'integer', + 'created_at' => 'datetime', + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function payment(): BelongsTo + { + return $this->belongsTo(Payment::class); + } +} diff --git a/app/Models/Scopes/StoreScope.php b/app/Models/Scopes/StoreScope.php new file mode 100644 index 00000000..d8327ebf --- /dev/null +++ b/app/Models/Scopes/StoreScope.php @@ -0,0 +1,26 @@ +bound('current_store')) { + return; + } + + $store = app('current_store'); + + if (! $store instanceof Store) { + return; + } + + $builder->where($model->getTable().'.store_id', $store->id); + } +} diff --git a/app/Models/ShippingRate.php b/app/Models/ShippingRate.php new file mode 100644 index 00000000..fd786626 --- /dev/null +++ b/app/Models/ShippingRate.php @@ -0,0 +1,38 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'zone_id', + 'name', + 'type', + 'config_json', + 'is_active', + ]; + + protected function casts(): array + { + return [ + 'type' => ShippingRateType::class, + 'config_json' => 'array', + 'is_active' => 'boolean', + ]; + } + + public function zone(): BelongsTo + { + return $this->belongsTo(ShippingZone::class, 'zone_id'); + } +} diff --git a/app/Models/ShippingZone.php b/app/Models/ShippingZone.php new file mode 100644 index 00000000..4a9ea1ae --- /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 function casts(): array + { + return [ + 'countries_json' => 'array', + 'regions_json' => 'array', + ]; + } + + public function rates(): HasMany + { + return $this->hasMany(ShippingRate::class, 'zone_id'); + } +} diff --git a/app/Models/Store.php b/app/Models/Store.php new file mode 100644 index 00000000..c48f65d2 --- /dev/null +++ b/app/Models/Store.php @@ -0,0 +1,66 @@ + */ + use HasFactory; + + protected $fillable = [ + 'organization_id', + 'name', + 'handle', + 'status', + 'default_currency', + 'default_locale', + 'timezone', + ]; + + protected function casts(): array + { + return [ + 'status' => StoreStatus::class, + ]; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } + + public function domains(): HasMany + { + return $this->hasMany(StoreDomain::class); + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'store_users') + ->using(StoreUser::class) + ->withPivot('role', 'created_at'); + } + + public function settings(): HasOne + { + return $this->hasOne(StoreSettings::class); + } + + public function isSuspended(): bool + { + return $this->status === StoreStatus::Suspended; + } + + public function primaryStorefrontDomain(): ?StoreDomain + { + return $this->domains()->where('type', 'storefront')->orderByDesc('is_primary')->first(); + } +} diff --git a/app/Models/StoreDomain.php b/app/Models/StoreDomain.php new file mode 100644 index 00000000..1bb04e95 --- /dev/null +++ b/app/Models/StoreDomain.php @@ -0,0 +1,39 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'store_id', + 'hostname', + 'type', + 'is_primary', + 'tls_mode', + 'created_at', + ]; + + protected function casts(): array + { + return [ + 'type' => StoreDomainType::class, + 'is_primary' => 'boolean', + 'created_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/StoreSettings.php b/app/Models/StoreSettings.php new file mode 100644 index 00000000..a17d59d8 --- /dev/null +++ b/app/Models/StoreSettings.php @@ -0,0 +1,40 @@ + */ + use HasFactory; + + protected $table = 'store_settings'; + + protected $primaryKey = 'store_id'; + + public $incrementing = false; + + public $timestamps = false; + + protected $fillable = [ + 'store_id', + 'settings_json', + 'updated_at', + ]; + + protected function casts(): array + { + return [ + 'settings_json' => 'array', + 'updated_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/StoreUser.php b/app/Models/StoreUser.php new file mode 100644 index 00000000..b4e1f617 --- /dev/null +++ b/app/Models/StoreUser.php @@ -0,0 +1,30 @@ + StoreUserRole::class, + 'created_at' => 'datetime', + ]; + } +} diff --git a/app/Models/TaxSettings.php b/app/Models/TaxSettings.php new file mode 100644 index 00000000..2f611b35 --- /dev/null +++ b/app/Models/TaxSettings.php @@ -0,0 +1,46 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $table = 'tax_settings'; + + protected $primaryKey = 'store_id'; + + public $incrementing = false; + + protected $keyType = 'int'; + + protected $fillable = [ + 'store_id', + 'mode', + 'provider', + 'prices_include_tax', + 'config_json', + ]; + + protected function casts(): array + { + return [ + 'mode' => TaxMode::class, + 'prices_include_tax' => 'boolean', + 'config_json' => 'array', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/Theme.php b/app/Models/Theme.php new file mode 100644 index 00000000..cf533e56 --- /dev/null +++ b/app/Models/Theme.php @@ -0,0 +1,47 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'name', + 'version', + 'status', + 'published_at', + ]; + + protected function casts(): array + { + return [ + 'status' => ThemeStatus::class, + 'published_at' => 'datetime', + ]; + } + + public function files(): HasMany + { + return $this->hasMany(ThemeFile::class); + } + + public function settings(): HasOne + { + return $this->hasOne(ThemeSettings::class); + } + + public function isPublished(): bool + { + return $this->status === ThemeStatus::Published; + } +} diff --git a/app/Models/ThemeFile.php b/app/Models/ThemeFile.php new file mode 100644 index 00000000..ba7b9672 --- /dev/null +++ b/app/Models/ThemeFile.php @@ -0,0 +1,35 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'theme_id', + 'path', + 'storage_key', + 'sha256', + 'byte_size', + ]; + + protected function casts(): array + { + return [ + 'byte_size' => 'integer', + ]; + } + + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } +} diff --git a/app/Models/ThemeSettings.php b/app/Models/ThemeSettings.php new file mode 100644 index 00000000..832fc503 --- /dev/null +++ b/app/Models/ThemeSettings.php @@ -0,0 +1,40 @@ + */ + use HasFactory; + + protected $table = 'theme_settings'; + + protected $primaryKey = 'theme_id'; + + public $incrementing = false; + + public $timestamps = false; + + protected $fillable = [ + 'theme_id', + 'settings_json', + 'updated_at', + ]; + + protected function casts(): array + { + return [ + 'settings_json' => 'array', + 'updated_at' => 'datetime', + ]; + } + + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 214bea4e..8ec8604e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,34 +2,28 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Enums\StoreUserRole; +use App\Enums\UserStatus; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; use Laravel\Fortify\TwoFactorAuthenticatable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable, TwoFactorAuthenticatable; + use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; - /** - * The attributes that are mass assignable. - * - * @var list - */ protected $fillable = [ 'name', 'email', 'password', + 'status', ]; - /** - * The attributes that should be hidden for serialization. - * - * @var list - */ protected $hidden = [ 'password', 'two_factor_secret', @@ -37,22 +31,36 @@ class User extends Authenticatable 'remember_token', ]; - /** - * Get the attributes that should be cast. - * - * @return array - */ protected function casts(): array { return [ 'email_verified_at' => 'datetime', + 'last_login_at' => 'datetime', 'password' => 'hashed', + 'status' => UserStatus::class, ]; } - /** - * Get the user's initials - */ + public function stores(): BelongsToMany + { + return $this->belongsToMany(Store::class, 'store_users') + ->using(StoreUser::class) + ->withPivot('role', 'created_at'); + } + + public function roleForStore(Store $store): ?StoreUserRole + { + $pivot = $this->stores()->whereKey($store->id)->first()?->pivot; + + if (! $pivot) { + return null; + } + + return $pivot->role instanceof StoreUserRole + ? $pivot->role + : StoreUserRole::tryFrom($pivot->role); + } + public function initials(): string { return Str::of($this->name) @@ -61,4 +69,9 @@ public function initials(): string ->map(fn ($word) => Str::substr($word, 0, 1)) ->implode(''); } + + public function isActive(): bool + { + return $this->status === UserStatus::Active; + } } diff --git a/app/Models/WebhookDelivery.php b/app/Models/WebhookDelivery.php new file mode 100644 index 00000000..5c072455 --- /dev/null +++ b/app/Models/WebhookDelivery.php @@ -0,0 +1,39 @@ + */ + use HasFactory; + + protected $fillable = [ + 'subscription_id', + 'event_id', + 'attempt_count', + 'status', + 'last_attempt_at', + 'response_code', + 'response_body_snippet', + ]; + + protected function casts(): array + { + return [ + 'status' => WebhookDeliveryStatus::class, + 'attempt_count' => 'integer', + 'response_code' => 'integer', + 'last_attempt_at' => 'datetime', + ]; + } + + public function subscription(): BelongsTo + { + return $this->belongsTo(WebhookSubscription::class, 'subscription_id'); + } +} diff --git a/app/Models/WebhookSubscription.php b/app/Models/WebhookSubscription.php new file mode 100644 index 00000000..9e447c72 --- /dev/null +++ b/app/Models/WebhookSubscription.php @@ -0,0 +1,46 @@ + */ + use BelongsToStore, HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'store_id', + 'app_installation_id', + 'event_type', + 'target_url', + 'signing_secret_encrypted', + 'status', + 'consecutive_failures', + ]; + + protected function casts(): array + { + return [ + 'status' => WebhookSubscriptionStatus::class, + 'consecutive_failures' => 'integer', + ]; + } + + public function installation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class, 'app_installation_id'); + } + + public function deliveries(): HasMany + { + return $this->hasMany(WebhookDelivery::class, 'subscription_id'); + } +} diff --git a/app/Observers/ProductObserver.php b/app/Observers/ProductObserver.php new file mode 100644 index 00000000..09899be2 --- /dev/null +++ b/app/Observers/ProductObserver.php @@ -0,0 +1,21 @@ +search->syncProduct($product); + } + + public function deleted(Product $product): void + { + $this->search->removeProduct($product->id); + } +} diff --git a/app/Policies/CollectionPolicy.php b/app/Policies/CollectionPolicy.php new file mode 100644 index 00000000..64a6323b --- /dev/null +++ b/app/Policies/CollectionPolicy.php @@ -0,0 +1,36 @@ +roleFor($user) !== null; + } + + public function view(User $user): bool + { + return $this->viewAny($user); + } + + public function create(User $user): bool + { + return $this->canWrite($user); + } + + public function update(User $user): bool + { + return $this->canWrite($user); + } + + public function delete(User $user): bool + { + return $this->isOwnerOrAdmin($user); + } +} diff --git a/app/Policies/Concerns/ResolvesCurrentStore.php b/app/Policies/Concerns/ResolvesCurrentStore.php new file mode 100644 index 00000000..15ef49b7 --- /dev/null +++ b/app/Policies/Concerns/ResolvesCurrentStore.php @@ -0,0 +1,35 @@ +bound('current_store')) { + return null; + } + + $store = app('current_store'); + + return $store instanceof Store ? $user->roleForStore($store) : null; + } + + protected function isOwnerOrAdmin(User $user): bool + { + $role = $this->roleFor($user); + + return in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin], true); + } + + protected function canWrite(User $user): bool + { + $role = $this->roleFor($user); + + return in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff], true); + } +} diff --git a/app/Policies/CustomerPolicy.php b/app/Policies/CustomerPolicy.php new file mode 100644 index 00000000..e69db663 --- /dev/null +++ b/app/Policies/CustomerPolicy.php @@ -0,0 +1,26 @@ +roleFor($user) !== null; + } + + public function view(User $user): bool + { + return $this->viewAny($user); + } + + public function update(User $user): bool + { + return $this->canWrite($user); + } +} diff --git a/app/Policies/DiscountPolicy.php b/app/Policies/DiscountPolicy.php new file mode 100644 index 00000000..4451a791 --- /dev/null +++ b/app/Policies/DiscountPolicy.php @@ -0,0 +1,21 @@ +roleFor($user) !== null; + } + + public function manage(User $user): bool + { + return $this->canWrite($user); + } +} diff --git a/app/Policies/FulfillmentPolicy.php b/app/Policies/FulfillmentPolicy.php new file mode 100644 index 00000000..be475f55 --- /dev/null +++ b/app/Policies/FulfillmentPolicy.php @@ -0,0 +1,21 @@ +roleFor($user) !== null; + } + + public function create(User $user): bool + { + return $this->canWrite($user); + } +} diff --git a/app/Policies/OrderPolicy.php b/app/Policies/OrderPolicy.php new file mode 100644 index 00000000..120b880b --- /dev/null +++ b/app/Policies/OrderPolicy.php @@ -0,0 +1,34 @@ +roleFor($user) !== null; + } + + public function view(User $user): bool + { + return $this->viewAny($user); + } + + public function manage(User $user): bool + { + $role = $this->roleFor($user); + + return in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff], true); + } + + public function refund(User $user): bool + { + return $this->isOwnerOrAdmin($user); + } +} diff --git a/app/Policies/PagePolicy.php b/app/Policies/PagePolicy.php new file mode 100644 index 00000000..da141472 --- /dev/null +++ b/app/Policies/PagePolicy.php @@ -0,0 +1,21 @@ +roleFor($user) !== null; + } + + public function manage(User $user): bool + { + return $this->canWrite($user); + } +} diff --git a/app/Policies/ProductPolicy.php b/app/Policies/ProductPolicy.php new file mode 100644 index 00000000..bc407969 --- /dev/null +++ b/app/Policies/ProductPolicy.php @@ -0,0 +1,36 @@ +roleFor($user) !== null; + } + + public function view(User $user): bool + { + return $this->viewAny($user); + } + + public function create(User $user): bool + { + return $this->canWrite($user); + } + + public function update(User $user): bool + { + return $this->canWrite($user); + } + + public function delete(User $user): bool + { + return $this->isOwnerOrAdmin($user); + } +} diff --git a/app/Policies/RefundPolicy.php b/app/Policies/RefundPolicy.php new file mode 100644 index 00000000..09906887 --- /dev/null +++ b/app/Policies/RefundPolicy.php @@ -0,0 +1,21 @@ +roleFor($user) !== null; + } + + public function create(User $user): bool + { + return $this->isOwnerOrAdmin($user); + } +} diff --git a/app/Policies/StorePolicy.php b/app/Policies/StorePolicy.php new file mode 100644 index 00000000..f68360c8 --- /dev/null +++ b/app/Policies/StorePolicy.php @@ -0,0 +1,30 @@ +roleForStore($store) !== null; + } + + public function update(User $user, Store $store): bool + { + return in_array($user->roleForStore($store), [StoreUserRole::Owner, StoreUserRole::Admin], true); + } + + public function delete(User $user, Store $store): bool + { + return $user->roleForStore($store) === StoreUserRole::Owner; + } + + public function manageStaff(User $user, Store $store): bool + { + return $this->update($user, $store); + } +} diff --git a/app/Policies/ThemePolicy.php b/app/Policies/ThemePolicy.php new file mode 100644 index 00000000..62180db4 --- /dev/null +++ b/app/Policies/ThemePolicy.php @@ -0,0 +1,21 @@ +roleFor($user) !== null; + } + + public function manage(User $user): bool + { + return $this->isOwnerOrAdmin($user); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8a29e6f5..36d01146 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,40 +2,37 @@ namespace App\Providers; +use App\Auth\CustomerUserProvider; +use App\Contracts\PaymentProvider; +use App\Http\Middleware\ResolveStore; +use App\Listeners\DispatchWebhooks; +use App\Services\Payments\MockPaymentProvider; +use App\Services\ThemeSettingsService; 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\Event; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; +use Livewire\Livewire; class AppServiceProvider extends ServiceProvider { - /** - * Register any application services. - */ public function register(): void { - // + $this->app->singleton(ThemeSettingsService::class); + $this->app->bind(PaymentProvider::class, MockPaymentProvider::class); } - /** - * Bootstrap any application services. - */ public function boot(): void - { - $this->configureDefaults(); - } - - /** - * Configure default behaviors for production-ready applications. - */ - protected function configureDefaults(): void { Date::use(CarbonImmutable::class); - DB::prohibitDestructiveCommands( - app()->isProduction(), - ); + DB::prohibitDestructiveCommands(app()->isProduction()); Password::defaults(fn (): ?Password => app()->isProduction() ? Password::min(12) @@ -46,5 +43,29 @@ protected function configureDefaults(): void ->uncompromised() : null ); + + Auth::provider('customer', function ($app, array $config): CustomerUserProvider { + return new CustomerUserProvider($app['hash'], $config['model']); + }); + + Livewire::addPersistentMiddleware([ResolveStore::class]); + + Event::subscribe(DispatchWebhooks::class); + + RateLimiter::for('login', function (Request $request): Limit { + return Limit::perMinute(5)->by($request->ip()); + }); + + RateLimiter::for('customer-login', function (Request $request): Limit { + return Limit::perMinute(5)->by($request->ip()); + }); + + RateLimiter::for('storefront-api', function (Request $request): Limit { + return Limit::perMinute(120)->by($request->user()?->id ?: $request->ip()); + }); + + RateLimiter::for('admin-api', function (Request $request): Limit { + return Limit::perMinute(600)->by($request->user()?->id ?: $request->ip()); + }); } } diff --git a/app/Services/AnalyticsService.php b/app/Services/AnalyticsService.php new file mode 100644 index 00000000..e3450326 --- /dev/null +++ b/app/Services/AnalyticsService.php @@ -0,0 +1,120 @@ + $properties + */ + public function track( + Store $store, + string|AnalyticsEventType $type, + array $properties = [], + ?string $sessionId = null, + ?int $customerId = null, + ?string $clientEventId = null, + ?Carbon $occurredAt = null, + ): AnalyticsEvent { + $typeValue = $type instanceof AnalyticsEventType ? $type->value : $type; + + if ($clientEventId !== null) { + $existing = AnalyticsEvent::query() + ->withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('client_event_id', $clientEventId) + ->first(); + + if ($existing !== null) { + return $existing; + } + } + + return AnalyticsEvent::query()->create([ + 'store_id' => $store->id, + 'type' => $typeValue, + 'session_id' => $sessionId, + 'customer_id' => $customerId, + 'properties_json' => $properties, + 'client_event_id' => $clientEventId, + 'occurred_at' => $occurredAt ?? now(), + 'created_at' => now(), + ]); + } + + /** + * @return Collection + */ + public function getDailyMetrics(Store $store, string $startDate, string $endDate): Collection + { + return AnalyticsDaily::query() + ->withoutGlobalScopes() + ->where('store_id', $store->id) + ->whereBetween('date', [$startDate, $endDate]) + ->orderBy('date') + ->get(); + } + + public function aggregate(Store $store, string $date): AnalyticsDaily + { + $start = Carbon::parse($date.' 00:00:00'); + $end = Carbon::parse($date.' 23:59:59.999999'); + + $eventCounts = DB::table('analytics_events') + ->where('store_id', $store->id) + ->whereBetween('created_at', [$start, $end]) + ->selectRaw('type, COUNT(*) as total, COUNT(DISTINCT session_id) as sessions') + ->groupBy('type') + ->get() + ->keyBy('type'); + + $visitsCount = DB::table('analytics_events') + ->where('store_id', $store->id) + ->whereBetween('created_at', [$start, $end]) + ->whereNotNull('session_id') + ->distinct('session_id') + ->count('session_id'); + + $addToCartCount = (int) ($eventCounts[AnalyticsEventType::AddToCart->value]->total ?? 0); + $checkoutStartedCount = (int) ($eventCounts[AnalyticsEventType::CheckoutStarted->value]->total ?? 0); + $checkoutCompletedCount = (int) ($eventCounts[AnalyticsEventType::CheckoutCompleted->value]->total ?? 0); + + $orderStats = DB::table('orders') + ->where('store_id', $store->id) + ->whereBetween('placed_at', [$start, $end]) + ->where('financial_status', 'paid') + ->selectRaw('COUNT(*) as orders_count, COALESCE(SUM(total_amount), 0) as revenue') + ->first(); + + $ordersCount = (int) ($orderStats->orders_count ?? 0); + $revenueAmount = (int) ($orderStats->revenue ?? 0); + $aovAmount = $ordersCount > 0 ? intdiv($revenueAmount, $ordersCount) : 0; + + DB::table('analytics_daily')->updateOrInsert( + ['store_id' => $store->id, 'date' => $date], + [ + 'orders_count' => $ordersCount, + 'revenue_amount' => $revenueAmount, + 'aov_amount' => $aovAmount, + 'visits_count' => $visitsCount, + 'add_to_cart_count' => $addToCartCount, + 'checkout_started_count' => $checkoutStartedCount, + 'checkout_completed_count' => $checkoutCompletedCount, + ], + ); + + return AnalyticsDaily::query() + ->withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('date', $date) + ->first(); + } +} diff --git a/app/Services/CartService.php b/app/Services/CartService.php new file mode 100644 index 00000000..2379699d --- /dev/null +++ b/app/Services/CartService.php @@ -0,0 +1,236 @@ + $store->id, + 'customer_id' => $customer?->id, + 'currency' => $store->default_currency ?? 'USD', + 'cart_version' => 1, + 'status' => CartStatus::Active, + ]); + } + + public function addLine(Cart $cart, int $variantId, int $quantity): CartLine + { + if ($quantity < 1) { + throw ValidationException::withMessages(['quantity' => 'Quantity must be at least 1.']); + } + + return DB::transaction(function () use ($cart, $variantId, $quantity): CartLine { + $variant = $this->loadVariantForCart($cart, $variantId); + + $existing = $cart->lines()->where('variant_id', $variantId)->lockForUpdate()->first(); + + if ($existing) { + $newQuantity = $existing->quantity + $quantity; + $this->guardInventory($variant, $newQuantity); + $this->updateLineAmounts($existing, $newQuantity, $variant->price_amount); + $existing->save(); + $line = $existing; + } else { + $this->guardInventory($variant, $quantity); + $line = new CartLine([ + 'cart_id' => $cart->id, + 'variant_id' => $variantId, + 'quantity' => $quantity, + ]); + $this->updateLineAmounts($line, $quantity, $variant->price_amount); + $line->save(); + } + + $this->bumpVersion($cart); + + return $line; + }); + } + + public function updateLineQuantity(Cart $cart, int $lineId, int $quantity, ?int $expectedVersion = null): CartLine + { + if ($quantity < 1) { + throw ValidationException::withMessages(['quantity' => 'Quantity must be at least 1.']); + } + + return DB::transaction(function () use ($cart, $lineId, $quantity, $expectedVersion): CartLine { + $this->assertVersion($cart, $expectedVersion); + + $line = $cart->lines()->whereKey($lineId)->lockForUpdate()->firstOrFail(); + $variant = $this->loadVariantForCart($cart, $line->variant_id); + $this->guardInventory($variant, $quantity); + + $this->updateLineAmounts($line, $quantity, $variant->price_amount); + $line->save(); + + $this->bumpVersion($cart); + + return $line; + }); + } + + public function removeLine(Cart $cart, int $lineId, ?int $expectedVersion = null): void + { + DB::transaction(function () use ($cart, $lineId, $expectedVersion): void { + $this->assertVersion($cart, $expectedVersion); + + $line = $cart->lines()->whereKey($lineId)->firstOrFail(); + $line->delete(); + + $this->bumpVersion($cart); + }); + } + + public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart + { + $session = session(); + $cartId = $session->get(self::SESSION_KEY); + + if ($cartId) { + $cart = Cart::query() + ->where('store_id', $store->id) + ->where('status', CartStatus::Active) + ->find($cartId); + + if ($cart) { + if ($customer && ! $cart->customer_id) { + $cart->customer_id = $customer->id; + $cart->save(); + } + + return $cart; + } + } + + $cart = $this->create($store, $customer); + $session->put(self::SESSION_KEY, $cart->id); + + return $cart; + } + + public function mergeOnLogin(Cart $guest, Cart $customer): Cart + { + if ($guest->id === $customer->id) { + return $customer; + } + + return DB::transaction(function () use ($guest, $customer): Cart { + foreach ($guest->lines as $guestLine) { + $existing = $customer->lines()->where('variant_id', $guestLine->variant_id)->first(); + $variant = $this->loadVariantForCart($customer, $guestLine->variant_id); + + if ($existing) { + $quantity = max($existing->quantity, $guestLine->quantity); + $this->updateLineAmounts($existing, $quantity, $variant->price_amount); + $existing->save(); + } else { + $line = new CartLine([ + 'cart_id' => $customer->id, + 'variant_id' => $guestLine->variant_id, + 'quantity' => $guestLine->quantity, + ]); + $this->updateLineAmounts($line, $guestLine->quantity, $variant->price_amount); + $line->save(); + } + } + + $guest->status = CartStatus::Abandoned; + $guest->save(); + + $customer->setRelation('lines', $customer->lines()->get()); + $this->bumpVersion($customer); + + return $customer; + }); + } + + public function assertVersion(Cart $cart, ?int $expectedVersion): void + { + if ($expectedVersion === null) { + return; + } + + $cart->refresh(); + if ($cart->cart_version !== $expectedVersion) { + throw new CartVersionMismatchException($expectedVersion, $cart->cart_version); + } + } + + protected function bumpVersion(Cart $cart): void + { + $cart->cart_version = $cart->cart_version + 1; + $cart->save(); + } + + protected function updateLineAmounts(CartLine $line, int $quantity, int $unitPrice): void + { + $line->quantity = $quantity; + $line->unit_price_amount = $unitPrice; + $line->line_subtotal_amount = $unitPrice * $quantity; + $line->line_discount_amount = 0; + $line->line_total_amount = $line->line_subtotal_amount; + } + + protected function loadVariantForCart(Cart $cart, int $variantId): ProductVariant + { + $variant = ProductVariant::query() + ->with(['product', 'inventoryItem']) + ->whereKey($variantId) + ->first(); + + if (! $variant) { + throw ValidationException::withMessages(['variant_id' => 'Variant not found.']); + } + + if ($variant->status !== VariantStatus::Active) { + throw ValidationException::withMessages(['variant_id' => 'Variant is not available.']); + } + + if ($variant->product?->store_id !== $cart->store_id) { + throw ValidationException::withMessages(['variant_id' => 'Variant does not belong to this store.']); + } + + if ($variant->product?->status !== ProductStatus::Active) { + throw ValidationException::withMessages(['variant_id' => 'Product is not available.']); + } + + return $variant; + } + + protected function guardInventory(ProductVariant $variant, int $quantity): void + { + $item = $variant->inventoryItem; + + if (! $item) { + return; + } + + if ($item->policy !== InventoryPolicy::Deny) { + return; + } + + if ($item->available() < $quantity) { + throw new InsufficientInventoryException( + "Requested {$quantity} exceeds available stock of {$item->available()} for variant {$variant->id}." + ); + } + } +} diff --git a/app/Services/CheckoutService.php b/app/Services/CheckoutService.php new file mode 100644 index 00000000..72bf3018 --- /dev/null +++ b/app/Services/CheckoutService.php @@ -0,0 +1,207 @@ +where('cart_id', $cart->id) + ->whereNotIn('status', [CheckoutStatus::Completed, CheckoutStatus::Expired]) + ->first(); + + if ($existing) { + return $existing; + } + + return Checkout::create([ + 'store_id' => $cart->store_id, + 'cart_id' => $cart->id, + 'customer_id' => $cart->customer_id, + 'status' => CheckoutStatus::Started, + ]); + }); + } + + /** + * @param array $data + */ + public function setAddress(Checkout $checkout, array $data): Checkout + { + $this->guardState($checkout, [ + CheckoutStatus::Started, + CheckoutStatus::Addressed, + CheckoutStatus::ShippingSelected, + ]); + + if (empty($data['email'])) { + throw ValidationException::withMessages(['email' => 'Email is required.']); + } + + $address = $data['shipping_address'] ?? []; + foreach (['first_name', 'last_name', 'address1', 'city', 'country_code', 'zip'] as $field) { + if (empty($address[$field])) { + throw ValidationException::withMessages([ + "shipping_address.{$field}" => "Field {$field} is required.", + ]); + } + } + + $checkout->email = $data['email']; + $checkout->shipping_address_json = $address; + $checkout->billing_address_json = $data['billing_address'] ?? $address; + $checkout->shipping_method_id = null; + $checkout->status = CheckoutStatus::Addressed; + $checkout->save(); + + $this->pricing->calculate($checkout); + + return $checkout->refresh(); + } + + public function setShippingMethod(Checkout $checkout, ?int $shippingRateId): Checkout + { + $this->guardState($checkout, [ + CheckoutStatus::Addressed, + CheckoutStatus::ShippingSelected, + ]); + + $cart = $checkout->cart()->with('lines.variant')->first(); + + if (! $this->shipping->requiresShipping($cart)) { + $checkout->shipping_method_id = null; + $checkout->status = CheckoutStatus::ShippingSelected; + $checkout->save(); + $this->pricing->calculate($checkout); + + return $checkout->refresh(); + } + + if ($shippingRateId === null) { + throw ValidationException::withMessages(['shipping_method_id' => 'Shipping method required.']); + } + + $available = $this->shipping->getAvailableRates( + $checkout->store()->first(), + $checkout->shipping_address_json ?? [], + $cart, + ); + + $rate = $available->firstWhere('id', $shippingRateId); + if (! $rate) { + throw ValidationException::withMessages(['shipping_method_id' => 'Shipping method is not available for this address.']); + } + + $checkout->shipping_method_id = $rate->id; + $checkout->status = CheckoutStatus::ShippingSelected; + $checkout->save(); + + $this->pricing->calculate($checkout); + + return $checkout->refresh(); + } + + public function selectPaymentMethod(Checkout $checkout, string $paymentMethod): Checkout + { + $this->guardState($checkout, [ + CheckoutStatus::ShippingSelected, + CheckoutStatus::PaymentPending, + ]); + + if (! in_array($paymentMethod, ['credit_card', 'paypal', 'bank_transfer'], true)) { + throw ValidationException::withMessages(['payment_method' => 'Invalid payment method.']); + } + + DB::transaction(function () use ($checkout, $paymentMethod): void { + $checkout->payment_method = $paymentMethod; + $checkout->status = CheckoutStatus::PaymentPending; + $checkout->expires_at = now()->addHours(24); + + $cart = $checkout->cart()->with('lines.variant.inventoryItem')->first(); + foreach ($cart->lines as $line) { + $item = $line->variant?->inventoryItem; + if ($item) { + $this->inventory->reserve($item, $line->quantity); + } + } + + $checkout->save(); + }); + + return $checkout->refresh(); + } + + public function applyDiscount(Checkout $checkout, ?string $code): Checkout + { + $checkout->discount_code = $code; + $checkout->save(); + + $this->pricing->calculate($checkout); + + return $checkout->refresh(); + } + + public function expireCheckout(Checkout $checkout): void + { + if ($checkout->status === CheckoutStatus::Completed || $checkout->status === CheckoutStatus::Expired) { + return; + } + + DB::transaction(function () use ($checkout): void { + if ($checkout->status === CheckoutStatus::PaymentPending) { + $cart = $checkout->cart()->with('lines.variant.inventoryItem')->first(); + foreach ($cart->lines as $line) { + $item = $line->variant?->inventoryItem; + if ($item) { + $this->inventory->release($item, $line->quantity); + } + } + } + + $checkout->status = CheckoutStatus::Expired; + $checkout->save(); + }); + } + + public function markCompleted(Checkout $checkout): Checkout + { + $checkout->status = CheckoutStatus::Completed; + $checkout->save(); + + return $checkout; + } + + public function totals(Checkout $checkout): PricingResult + { + return $this->pricing->calculate($checkout); + } + + /** + * @param array $allowed + */ + protected function guardState(Checkout $checkout, array $allowed): void + { + if (! in_array($checkout->status, $allowed, true)) { + throw new CheckoutStateException( + "Checkout is in state {$checkout->status->value}; expected one of: " + .implode(',', array_map(fn (CheckoutStatus $s) => $s->value, $allowed)) + ); + } + } +} diff --git a/app/Services/DiscountService.php b/app/Services/DiscountService.php new file mode 100644 index 00000000..9c679b10 --- /dev/null +++ b/app/Services/DiscountService.php @@ -0,0 +1,184 @@ +withoutGlobalScopes() + ->where('store_id', $store->id) + ->whereRaw('LOWER(code) = ?', [mb_strtolower($normalized)]) + ->first(); + + if (! $discount) { + throw new InvalidDiscountException('not_found', 'Discount code is not valid.'); + } + + $now = now(); + + if ($discount->status !== DiscountStatus::Active) { + throw new InvalidDiscountException('expired', 'Discount is not active.'); + } + + if ($discount->starts_at && $discount->starts_at->isFuture()) { + throw new InvalidDiscountException('not_yet_active', 'Discount is not yet active.'); + } + + if ($discount->ends_at && $discount->ends_at->lt($now)) { + throw new InvalidDiscountException('expired', 'Discount has expired.'); + } + + if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) { + throw new InvalidDiscountException('usage_limit_reached', 'Discount usage limit reached.'); + } + + if ($cart) { + $rules = $discount->rules_json ?? []; + $minimum = $rules['min_purchase_amount'] ?? null; + $subtotal = (int) $cart->lines->sum('line_subtotal_amount'); + + if ($minimum !== null && $subtotal < (int) $minimum) { + throw new InvalidDiscountException('minimum_not_met', 'Minimum purchase amount not met.'); + } + + $qualifying = $this->qualifyingLines($discount, $cart->lines); + if ($qualifying->isEmpty() && $this->hasItemRestrictions($discount)) { + throw new InvalidDiscountException('not_applicable', 'No qualifying products in cart.'); + } + } + + return $discount; + } + + /** + * Calculate the total discount and allocations across cart lines. + * + * @return array{total: int, allocations: array} + */ + public function calculate(Discount $discount, Cart $cart): array + { + if ($discount->value_type === DiscountValueType::FreeShipping) { + return ['total' => 0, 'allocations' => []]; + } + + $lines = $cart->lines; + $qualifying = $this->qualifyingLines($discount, $lines); + $qualifyingSubtotal = (int) $qualifying->sum('line_subtotal_amount'); + + if ($qualifyingSubtotal <= 0) { + return ['total' => 0, 'allocations' => []]; + } + + $total = match ($discount->value_type) { + DiscountValueType::Percent => intdiv($qualifyingSubtotal * $discount->value_amount, 100), + DiscountValueType::Fixed => min($discount->value_amount, $qualifyingSubtotal), + default => 0, + }; + + if ($total <= 0) { + return ['total' => 0, 'allocations' => []]; + } + + $allocations = []; + $remaining = $total; + $count = $qualifying->count(); + + foreach ($qualifying->values() as $index => $line) { + if ($index === $count - 1) { + $allocations[$line->id] = $remaining; + } else { + $share = (int) round($total * $line->line_subtotal_amount / $qualifyingSubtotal); + $allocations[$line->id] = $share; + $remaining -= $share; + } + } + + return ['total' => $total, 'allocations' => $allocations]; + } + + public function applyAllocationsToCart(Cart $cart, array $allocations): void + { + DB::transaction(function () use ($cart, $allocations): void { + foreach ($cart->lines as $line) { + $amount = (int) ($allocations[$line->id] ?? 0); + $line->line_discount_amount = $amount; + $line->line_total_amount = $line->line_subtotal_amount - $amount; + $line->save(); + } + }); + } + + public function incrementUsage(Discount $discount): void + { + $discount->increment('usage_count'); + } + + protected function hasItemRestrictions(Discount $discount): bool + { + $rules = $discount->rules_json ?? []; + $products = $rules['applicable_product_ids'] ?? null; + $collections = $rules['applicable_collection_ids'] ?? null; + + return (is_array($products) && ! empty($products)) || (is_array($collections) && ! empty($collections)); + } + + /** + * @param iterable $lines + */ + protected function qualifyingLines(Discount $discount, $lines): \Illuminate\Support\Collection + { + $rules = $discount->rules_json ?? []; + $products = $rules['applicable_product_ids'] ?? null; + $collections = $rules['applicable_collection_ids'] ?? null; + + $collection = collect($lines); + + if (empty($products) && empty($collections)) { + return $collection; + } + + return $collection->filter(function (CartLine $line) use ($products, $collections): bool { + $line->loadMissing('variant.product.collections'); + $product = $line->variant?->product; + + if (! $product) { + return false; + } + + if (! empty($products) && in_array($product->id, $products, true)) { + return true; + } + + if (! empty($collections)) { + $ids = $product->collections->pluck('id')->all(); + foreach ($collections as $id) { + if (in_array($id, $ids, true)) { + return true; + } + } + } + + return false; + })->values(); + } +} diff --git a/app/Services/FulfillmentService.php b/app/Services/FulfillmentService.php new file mode 100644 index 00000000..8a6c2c68 --- /dev/null +++ b/app/Services/FulfillmentService.php @@ -0,0 +1,135 @@ + $lines + * @param array{tracking_company?: ?string, tracking_number?: ?string, tracking_url?: ?string} $tracking + */ + public function create(Order $order, array $lines, array $tracking = []): Fulfillment + { + $this->guardPaid($order); + + if (empty($lines)) { + throw ValidationException::withMessages(['lines' => 'At least one line must be specified.']); + } + + return DB::transaction(function () use ($order, $lines, $tracking): Fulfillment { + $order->loadMissing('lines.fulfillmentLines'); + + foreach ($lines as $entry) { + $orderLine = $order->lines->firstWhere('id', $entry['order_line_id'] ?? null); + $qty = (int) ($entry['quantity'] ?? 0); + + if (! $orderLine) { + throw ValidationException::withMessages(['lines' => 'Unknown order line.']); + } + + $already = $orderLine->fulfilledQuantity(); + $remaining = $orderLine->quantity - $already; + + if ($qty <= 0 || $qty > $remaining) { + throw ValidationException::withMessages(['lines' => "Quantity exceeds unfulfilled amount ({$remaining})."]); + } + } + + $fulfillment = Fulfillment::create([ + 'order_id' => $order->id, + 'status' => FulfillmentShipmentStatus::Pending, + 'tracking_company' => $tracking['tracking_company'] ?? null, + 'tracking_number' => $tracking['tracking_number'] ?? null, + 'tracking_url' => $tracking['tracking_url'] ?? null, + 'created_at' => now(), + ]); + + foreach ($lines as $entry) { + $fulfillment->lines()->create([ + 'order_line_id' => (int) $entry['order_line_id'], + 'quantity' => (int) $entry['quantity'], + ]); + } + + $this->recomputeOrderFulfillment($order->fresh(['lines.fulfillmentLines'])); + + return $fulfillment; + }); + } + + /** + * @param array $tracking + */ + public function markAsShipped(Fulfillment $fulfillment, array $tracking = []): Fulfillment + { + if ($fulfillment->status === FulfillmentShipmentStatus::Delivered) { + return $fulfillment; + } + + $fulfillment->status = FulfillmentShipmentStatus::Shipped; + $fulfillment->shipped_at = now(); + foreach (['tracking_company', 'tracking_number', 'tracking_url'] as $field) { + if (array_key_exists($field, $tracking)) { + $fulfillment->{$field} = $tracking[$field]; + } + } + $fulfillment->save(); + + return $fulfillment; + } + + public function markAsDelivered(Fulfillment $fulfillment): Fulfillment + { + $fulfillment->status = FulfillmentShipmentStatus::Delivered; + $fulfillment->delivered_at = now(); + $fulfillment->save(); + + return $fulfillment; + } + + protected function guardPaid(Order $order): void + { + if (! in_array($order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true)) { + throw new FulfillmentGuardException( + 'Fulfillment cannot be created until payment is confirmed.' + ); + } + } + + protected function recomputeOrderFulfillment(Order $order): void + { + $allFulfilled = true; + $anyFulfilled = false; + + foreach ($order->lines as $line) { + $filled = $line->fulfilledQuantity(); + if ($filled > 0) { + $anyFulfilled = true; + } + if ($filled < $line->quantity) { + $allFulfilled = false; + } + } + + if ($allFulfilled) { + $order->fulfillment_status = FulfillmentStatus::Fulfilled; + $order->status = OrderStatus::Fulfilled; + $order->save(); + OrderFulfilled::dispatch($order); + } elseif ($anyFulfilled) { + $order->fulfillment_status = FulfillmentStatus::Partial; + $order->save(); + } + } +} diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php new file mode 100644 index 00000000..d3f09b0d --- /dev/null +++ b/app/Services/InventoryService.php @@ -0,0 +1,60 @@ +available() >= $quantity; + } + + public function reserve(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity): void { + $item->refresh(); + + if ($item->policy === InventoryPolicy::Deny && ! $this->checkAvailability($item, $quantity)) { + throw new InsufficientInventoryException( + "Insufficient inventory for variant {$item->variant_id}. Requested {$quantity}, available {$item->available()}." + ); + } + + $item->quantity_reserved += $quantity; + $item->save(); + }); + } + + public function release(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity): void { + $item->refresh(); + $item->quantity_reserved = max(0, $item->quantity_reserved - $quantity); + $item->save(); + }); + } + + public function commit(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity): void { + $item->refresh(); + $item->quantity_on_hand -= $quantity; + $item->quantity_reserved = max(0, $item->quantity_reserved - $quantity); + $item->save(); + }); + } + + public function restock(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity): void { + $item->refresh(); + $item->quantity_on_hand += $quantity; + $item->save(); + }); + } +} diff --git a/app/Services/NavigationService.php b/app/Services/NavigationService.php new file mode 100644 index 00000000..46773acb --- /dev/null +++ b/app/Services/NavigationService.php @@ -0,0 +1,112 @@ + + */ + public function buildTree(NavigationMenu $menu): array + { + return Cache::remember( + $this->cacheKey($menu), + self::CACHE_TTL_SECONDS, + fn (): array => $menu->items() + ->orderBy('position') + ->get() + ->map(fn (NavigationItem $item): array => [ + 'id' => $item->id, + 'type' => $item->type->value, + 'label' => $item->label, + 'url' => $this->resolveUrl($item), + 'position' => $item->position, + ]) + ->all() + ); + } + + public function resolveUrl(NavigationItem $item): string + { + return match ($item->type) { + NavigationItemType::Link => $item->url ?? '#', + NavigationItemType::Page => $this->resolvePageUrl($item->resource_id), + NavigationItemType::Collection => $this->resolveCollectionUrl($item->resource_id), + NavigationItemType::Product => $this->resolveProductUrl($item->resource_id), + }; + } + + public function forgetMenu(NavigationMenu $menu): void + { + Cache::forget($this->cacheKey($menu)); + } + + public function forgetStore(int $storeId): void + { + NavigationMenu::withoutGlobalScopes() + ->where('store_id', $storeId) + ->get() + ->each(fn (NavigationMenu $menu): bool => Cache::forget($this->cacheKey($menu))); + } + + protected function cacheKey(NavigationMenu $menu): string + { + return "navigation:{$menu->store_id}:{$menu->id}"; + } + + protected function resolvePageUrl(?int $pageId): string + { + if (! $pageId) { + return '#'; + } + + $page = Page::query()->find($pageId); + + if (! $page) { + return '#'; + } + + return route('storefront.pages.show', $page->handle); + } + + protected function resolveCollectionUrl(?int $collectionId): string + { + if (! $collectionId) { + return '#'; + } + + $handle = DB::table('collections')->where('id', $collectionId)->value('handle'); + + if (! $handle) { + return '#'; + } + + return route('storefront.collections.show', $handle); + } + + protected function resolveProductUrl(?int $productId): string + { + if (! $productId) { + return '#'; + } + + $handle = DB::table('products')->where('id', $productId)->value('handle'); + + if (! $handle) { + return '#'; + } + + return route('storefront.products.show', $handle); + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php new file mode 100644 index 00000000..2bfd1440 --- /dev/null +++ b/app/Services/OrderService.php @@ -0,0 +1,292 @@ + $paymentDetails + */ + public function createFromCheckout(Checkout $checkout, array $paymentDetails = []): Order + { + $existing = Order::query()->where('checkout_id', $checkout->id)->first(); + if ($existing) { + return $existing; + } + + $method = PaymentMethod::from((string) $checkout->payment_method); + + $result = $this->payments->charge($checkout, $method, $paymentDetails); + + if (! $result->success) { + $this->releaseReservedInventory($checkout); + throw new PaymentFailedException((string) $result->errorCode); + } + + return DB::transaction(function () use ($checkout, $method, $result): Order { + $store = $checkout->store()->first(); + $cart = $checkout->cart()->with('lines.variant.product')->first(); + $totals = $checkout->totals_json ?? []; + + $orderStatus = match ($method) { + PaymentMethod::CreditCard, PaymentMethod::Paypal => OrderStatus::Paid, + PaymentMethod::BankTransfer => OrderStatus::Pending, + }; + $financialStatus = match ($method) { + PaymentMethod::CreditCard, PaymentMethod::Paypal => FinancialStatus::Paid, + PaymentMethod::BankTransfer => FinancialStatus::Pending, + }; + + $order = Order::create([ + 'store_id' => $store->id, + 'customer_id' => $checkout->customer_id, + 'checkout_id' => $checkout->id, + 'order_number' => $this->generateOrderNumber($store), + 'payment_method' => $method, + 'status' => $orderStatus, + 'financial_status' => $financialStatus, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled, + 'currency' => $cart->currency, + 'subtotal_amount' => (int) ($totals['subtotal'] ?? 0), + 'discount_amount' => (int) ($totals['discount'] ?? 0), + 'shipping_amount' => (int) ($totals['shipping'] ?? 0), + 'tax_amount' => (int) ($totals['tax'] ?? 0), + 'total_amount' => (int) ($totals['total'] ?? 0), + 'email' => $checkout->email, + 'shipping_address_json' => $checkout->shipping_address_json, + 'billing_address_json' => $checkout->billing_address_json, + 'placed_at' => now(), + ]); + + foreach ($cart->lines as $line) { + OrderLine::create([ + 'order_id' => $order->id, + 'product_id' => $line->variant?->product?->id, + 'variant_id' => $line->variant?->id, + 'title_snapshot' => $line->variant?->product?->title ?? 'Item', + 'sku_snapshot' => $line->variant?->sku, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'total_amount' => $line->line_total_amount, + 'tax_lines_json' => [], + 'discount_allocations_json' => [], + ]); + } + + Payment::create([ + 'order_id' => $order->id, + 'provider' => 'mock', + 'method' => $method, + 'provider_payment_id' => $result->providerPaymentId, + 'status' => $result->status, + 'amount' => $order->total_amount, + 'currency' => $order->currency, + 'raw_json_encrypted' => ! empty($result->raw) ? Crypt::encrypt($result->raw) : null, + 'created_at' => now(), + ]); + + if ($method !== PaymentMethod::BankTransfer) { + $this->commitReservedInventory($checkout); + } + + if ($checkout->discount_code) { + $discount = Discount::query() + ->where('store_id', $store->id) + ->whereRaw('LOWER(code) = ?', [mb_strtolower($checkout->discount_code)]) + ->first(); + if ($discount) { + $this->discounts->incrementUsage($discount); + } + } + + $cart->status = CartStatus::Converted; + $cart->save(); + + $checkout->status = CheckoutStatus::Completed; + $checkout->save(); + + if ($method !== PaymentMethod::BankTransfer && $this->orderIsAllDigital($order)) { + $this->autoFulfillDigital($order); + } + + OrderCreated::dispatch($order); + if ($financialStatus === FinancialStatus::Paid) { + OrderPaid::dispatch($order); + } + + return $order; + }); + } + + public function confirmBankTransferPayment(Order $order): Order + { + if ($order->payment_method !== PaymentMethod::BankTransfer) { + return $order; + } + + if ($order->financial_status !== FinancialStatus::Pending) { + return $order; + } + + return DB::transaction(function () use ($order): Order { + $order->financial_status = FinancialStatus::Paid; + $order->status = OrderStatus::Paid; + $order->save(); + + $payment = $order->payments()->latest('id')->first(); + if ($payment) { + $payment->status = PaymentStatus::Captured; + $payment->save(); + } + + if ($order->checkout) { + $this->commitReservedInventory($order->checkout); + } + + if ($this->orderIsAllDigital($order)) { + $this->autoFulfillDigital($order); + } + + OrderPaid::dispatch($order); + + return $order; + }); + } + + public function cancel(Order $order, ?string $reason = null): Order + { + if ($order->fulfillment_status === FulfillmentStatus::Fulfilled) { + return $order; + } + + return DB::transaction(function () use ($order, $reason): Order { + if ($order->checkout) { + $this->releaseReservedInventory($order->checkout); + } + + $payment = $order->payments()->latest('id')->first(); + if ($payment && $payment->status === PaymentStatus::Pending) { + $payment->status = PaymentStatus::Failed; + $payment->save(); + } + + $order->status = OrderStatus::Cancelled; + if ($order->financial_status === FinancialStatus::Pending) { + $order->financial_status = FinancialStatus::Voided; + } + $order->save(); + + OrderCancelled::dispatch($order, $reason); + + return $order; + }); + } + + public function generateOrderNumber(Store $store): string + { + $max = Order::query() + ->where('store_id', $store->id) + ->max('order_number'); + + $next = $max ? ((int) $max + 1) : 1001; + + return (string) $next; + } + + protected function releaseReservedInventory(Checkout $checkout): void + { + $cart = $checkout->cart()->with('lines.variant.inventoryItem')->first(); + if (! $cart) { + return; + } + + foreach ($cart->lines as $line) { + $item = $line->variant?->inventoryItem; + if ($item) { + $this->inventory->release($item, $line->quantity); + } + } + } + + protected function commitReservedInventory(Checkout $checkout): void + { + $cart = $checkout->cart()->with('lines.variant.inventoryItem')->first(); + if (! $cart) { + return; + } + + foreach ($cart->lines as $line) { + $item = $line->variant?->inventoryItem; + if ($item) { + $this->inventory->commit($item, $line->quantity); + } + } + } + + protected function orderIsAllDigital(Order $order): bool + { + $order->loadMissing('lines.variant'); + foreach ($order->lines as $line) { + if ($line->variant && $line->variant->requires_shipping) { + return false; + } + } + + return true; + } + + protected function autoFulfillDigital(Order $order): void + { + $fulfillment = Fulfillment::create([ + 'order_id' => $order->id, + 'status' => FulfillmentShipmentStatus::Delivered, + 'shipped_at' => now(), + 'delivered_at' => now(), + 'created_at' => now(), + ]); + + foreach ($order->lines as $line) { + $fulfillment->lines()->create([ + 'order_line_id' => $line->id, + 'quantity' => $line->quantity, + ]); + } + + $order->fulfillment_status = FulfillmentStatus::Fulfilled; + $order->status = OrderStatus::Fulfilled; + $order->save(); + + OrderFulfilled::dispatch($order); + } +} diff --git a/app/Services/Payments/MockPaymentProvider.php b/app/Services/Payments/MockPaymentProvider.php new file mode 100644 index 00000000..0e11c663 --- /dev/null +++ b/app/Services/Payments/MockPaymentProvider.php @@ -0,0 +1,72 @@ + $this->chargeCreditCard($details), + PaymentMethod::Paypal => $this->successResult(), + PaymentMethod::BankTransfer => $this->bankTransferResult(), + }; + } + + public function refund(Payment $payment, int $amount): RefundResult + { + return new RefundResult( + success: true, + status: RefundStatus::Processed, + providerRefundId: 'mock_refund_'.Str::random(16), + ); + } + + /** + * @param array $details + */ + protected function chargeCreditCard(array $details): PaymentResult + { + $card = preg_replace('/\s+/', '', (string) ($details['card_number'] ?? '')); + + return match ($card) { + self::CARD_DECLINE => new PaymentResult(false, PaymentStatus::Failed, errorCode: 'card_declined'), + self::CARD_INSUFFICIENT => new PaymentResult(false, PaymentStatus::Failed, errorCode: 'insufficient_funds'), + default => $this->successResult(), + }; + } + + protected function successResult(): PaymentResult + { + return new PaymentResult( + success: true, + status: PaymentStatus::Captured, + providerPaymentId: 'mock_'.Str::random(16), + ); + } + + protected function bankTransferResult(): PaymentResult + { + return new PaymentResult( + success: true, + status: PaymentStatus::Pending, + providerPaymentId: 'mock_bank_'.Str::random(16), + ); + } +} diff --git a/app/Services/PricingEngine.php b/app/Services/PricingEngine.php new file mode 100644 index 00000000..86cb10e6 --- /dev/null +++ b/app/Services/PricingEngine.php @@ -0,0 +1,126 @@ +cart()->with('lines.variant.product')->first(); + $store = $checkout->store()->first(); + + $result = $this->calculateForCart( + cart: $cart, + store: $store, + shippingAddress: $checkout->shipping_address_json ?? [], + shippingRateId: $checkout->shipping_method_id, + discountCode: $checkout->discount_code, + ); + + $checkout->totals_json = $result->toArray(); + $checkout->save(); + + return $result; + } + + /** + * @param array $shippingAddress + */ + public function calculateForCart( + Cart $cart, + Store $store, + array $shippingAddress = [], + ?int $shippingRateId = null, + ?string $discountCode = null, + ): PricingResult { + $cart->loadMissing('lines.variant.product'); + + $subtotal = (int) $cart->lines->sum('line_subtotal_amount'); + + $discountAmount = 0; + $discount = null; + $allocations = []; + + if ($discountCode) { + try { + $discount = $this->discounts->validate($discountCode, $store, $cart); + $outcome = $this->discounts->calculate($discount, $cart); + $discountAmount = (int) $outcome['total']; + $allocations = $outcome['allocations']; + } catch (InvalidDiscountException) { + $discount = null; + $discountAmount = 0; + $allocations = []; + } + } + + $this->discounts->applyAllocationsToCart($cart, $allocations); + $cart->loadMissing('lines.variant.product'); + + $discountedSubtotal = max(0, $subtotal - $discountAmount); + + $shippingAmount = 0; + if ($shippingRateId && $this->shipping->requiresShipping($cart)) { + $rate = ShippingRate::query()->whereKey($shippingRateId)->first(); + if ($rate) { + $shippingAmount = $this->shipping->calculate($rate, $cart) ?? 0; + } + } + + if ($discount && $discount->value_type === DiscountValueType::FreeShipping) { + $shippingAmount = 0; + } + + $taxSettings = TaxSettings::query()->where('store_id', $store->id)->first() + ?? $this->defaultTaxSettings($store); + + $taxableAmount = $discountedSubtotal + $shippingAmount; + $taxResult = $this->tax->calculate($taxableAmount, $taxSettings, $shippingAddress); + + $taxLines = $taxResult['lines']; + $taxTotal = $taxResult['total']; + + if ($taxSettings->prices_include_tax) { + $total = $discountedSubtotal + $shippingAmount; + } else { + $total = $discountedSubtotal + $shippingAmount + $taxTotal; + } + + return new PricingResult( + subtotal: $subtotal, + discount: $discountAmount, + shipping: $shippingAmount, + taxLines: $taxLines, + taxTotal: $taxTotal, + total: $total, + currency: $cart->currency, + ); + } + + protected function defaultTaxSettings(Store $store): TaxSettings + { + $settings = new TaxSettings; + $settings->store_id = $store->id; + $settings->mode = \App\Enums\TaxMode::Manual; + $settings->provider = 'none'; + $settings->prices_include_tax = false; + $settings->config_json = ['default_rate_bps' => 0]; + + return $settings; + } +} diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php new file mode 100644 index 00000000..576b122e --- /dev/null +++ b/app/Services/ProductService.php @@ -0,0 +1,137 @@ + $data + */ + public function create(Store $store, array $data): Product + { + return DB::transaction(function () use ($store, $data): Product { + $handle = $data['handle'] ?? null; + $title = $data['title']; + + if (empty($handle)) { + $handle = HandleGenerator::generate($title, 'products', $store->id); + } + + $product = Product::query()->create([ + 'store_id' => $store->id, + 'title' => $title, + 'handle' => $handle, + 'status' => $data['status'] ?? ProductStatus::Draft, + 'description_html' => $data['description_html'] ?? null, + 'vendor' => $data['vendor'] ?? null, + 'product_type' => $data['product_type'] ?? null, + 'tags' => $data['tags'] ?? [], + ]); + + $variant = ProductVariant::query()->create([ + 'product_id' => $product->id, + 'price_amount' => $data['price_amount'] ?? 0, + 'currency' => $data['currency'] ?? $store->default_currency, + 'is_default' => true, + 'position' => 0, + 'status' => VariantStatus::Active, + ]); + + InventoryItem::query()->create([ + 'store_id' => $store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ]); + + return $product->fresh(['variants']); + }); + } + + /** + * @param array $data + */ + public function update(Product $product, array $data): Product + { + return DB::transaction(function () use ($product, $data): Product { + if (isset($data['title']) && $data['title'] !== $product->title && ! isset($data['handle'])) { + $data['handle'] = HandleGenerator::generate($data['title'], 'products', $product->store_id, $product->id); + } + + $product->update($data); + + return $product->fresh(); + }); + } + + public function transitionStatus(Product $product, ProductStatus $newStatus): void + { + $current = $product->status; + + if ($current === $newStatus) { + return; + } + + if ($newStatus === ProductStatus::Active) { + if (empty($product->title)) { + throw new InvalidProductTransitionException('Product title is required before activating.'); + } + + $hasPricedVariant = $product->variants()->where('price_amount', '>', 0)->exists(); + + if (! $hasPricedVariant) { + throw new InvalidProductTransitionException('Product requires at least one variant with price > 0 before activating.'); + } + } + + if ($newStatus === ProductStatus::Draft) { + if ($this->productHasOrderReferences($product)) { + throw new InvalidProductTransitionException('Cannot revert to draft: order lines reference this product.'); + } + } + + $attributes = ['status' => $newStatus]; + + if ($newStatus === ProductStatus::Active && $product->published_at === null) { + $attributes['published_at'] = now(); + } + + $product->update($attributes); + } + + public function delete(Product $product): void + { + if ($product->status !== ProductStatus::Draft) { + throw new InvalidProductTransitionException('Only draft products may be deleted.'); + } + + if ($this->productHasOrderReferences($product)) { + throw new InvalidProductTransitionException('Cannot delete product with order line references.'); + } + + $product->delete(); + } + + protected function productHasOrderReferences(Product $product): bool + { + if (! \Illuminate\Support\Facades\Schema::hasTable('order_lines')) { + return false; + } + + $variantIds = $product->variants()->pluck('id'); + + return DB::table('order_lines')->whereIn('variant_id', $variantIds)->exists(); + } +} diff --git a/app/Services/RefundService.php b/app/Services/RefundService.php new file mode 100644 index 00000000..a4582caf --- /dev/null +++ b/app/Services/RefundService.php @@ -0,0 +1,102 @@ + $lines + */ + public function create(Order $order, Payment $payment, int $amount, ?string $reason = null, bool $restock = false, array $lines = []): Refund + { + if ($amount <= 0) { + throw ValidationException::withMessages(['amount' => 'Refund amount must be positive.']); + } + + $remaining = $order->total_amount - $order->totalRefunded(); + if ($amount > $remaining) { + throw ValidationException::withMessages(['amount' => "Refund amount exceeds refundable balance of {$remaining}."]); + } + + return DB::transaction(function () use ($order, $payment, $amount, $reason, $restock, $lines): Refund { + $providerResult = $this->provider->refund($payment, $amount); + + $refund = Refund::create([ + 'order_id' => $order->id, + 'payment_id' => $payment->id, + 'amount' => $amount, + 'reason' => $reason, + 'status' => $providerResult->success ? RefundStatus::Processed : RefundStatus::Failed, + 'provider_refund_id' => $providerResult->providerRefundId, + 'created_at' => now(), + ]); + + $totalRefunded = $order->fresh()->totalRefunded(); + if ($totalRefunded >= $order->total_amount) { + $order->financial_status = FinancialStatus::Refunded; + $order->status = OrderStatus::Refunded; + } else { + $order->financial_status = FinancialStatus::PartiallyRefunded; + } + $order->save(); + + if ($totalRefunded >= $order->total_amount && $payment->status !== PaymentStatus::Refunded) { + $payment->status = PaymentStatus::Refunded; + $payment->save(); + } + + if ($restock) { + $this->restockLines($order, $lines); + } + + OrderRefunded::dispatch($order, $refund); + + return $refund; + }); + } + + /** + * @param array $lines + */ + protected function restockLines(Order $order, array $lines): void + { + $order->loadMissing('lines.variant.inventoryItem'); + + if (empty($lines)) { + foreach ($order->lines as $line) { + $item = $line->variant?->inventoryItem; + if ($item) { + $this->inventory->restock($item, $line->quantity); + } + } + + return; + } + + foreach ($lines as $entry) { + $line = $order->lines->firstWhere('id', $entry['order_line_id'] ?? null); + $qty = (int) ($entry['quantity'] ?? 0); + $item = $line?->variant?->inventoryItem; + if ($line && $item && $qty > 0) { + $this->inventory->restock($item, $qty); + } + } + } +} diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php new file mode 100644 index 00000000..3de5c88c --- /dev/null +++ b/app/Services/SearchService.php @@ -0,0 +1,222 @@ + $filters + */ + public function search(Store $store, string $query, array $filters = [], int $perPage = 12, int $page = 1): LengthAwarePaginator + { + $trimmed = trim($query); + + if ($trimmed === '' || ! $this->hasFts()) { + $empty = new Paginator([], 0, $perPage, $page); + $this->logQuery($store, $trimmed, $filters, 0); + + return $empty; + } + + $match = $this->buildMatchExpression($trimmed); + + $ids = DB::table('products_fts') + ->where('store_id', $store->id) + ->whereRaw('products_fts MATCH ?', [$match]) + ->orderByRaw('rank') + ->pluck('product_id') + ->map(fn ($id): int => (int) $id) + ->all(); + + $productsQuery = Product::query() + ->where('store_id', $store->id) + ->where('status', ProductStatus::Active) + ->whereIn('id', $ids); + + if (! empty($filters['vendor'])) { + $productsQuery->where('vendor', $filters['vendor']); + } + + if (! empty($filters['product_type'])) { + $productsQuery->where('product_type', $filters['product_type']); + } + + $total = (clone $productsQuery)->count(); + + if (! empty($ids)) { + $productsQuery->orderByRaw('CASE id '.$this->caseOrder($ids).' END'); + } + + $items = $productsQuery + ->forPage($page, $perPage) + ->get(); + + $paginator = new Paginator($items, $total, $perPage, $page, [ + 'path' => Paginator::resolveCurrentPath(), + 'pageName' => 'page', + ]); + + $this->logQuery($store, $trimmed, $filters, $total); + + return $paginator; + } + + /** + * @return Collection + */ + public function autocomplete(Store $store, string $prefix, int $limit = 8): Collection + { + $trimmed = trim($prefix); + + if (mb_strlen($trimmed) < 2 || ! $this->hasFts()) { + return new Collection; + } + + $match = $this->buildMatchExpression($trimmed, prefix: true); + + $rows = DB::table('products_fts') + ->where('store_id', $store->id) + ->whereRaw('products_fts MATCH ?', [$match]) + ->orderByRaw('rank') + ->limit($limit) + ->pluck('product_id') + ->map(fn ($id): int => (int) $id) + ->all(); + + if (empty($rows)) { + return new Collection; + } + + return Product::query() + ->where('store_id', $store->id) + ->where('status', ProductStatus::Active) + ->whereIn('id', $rows) + ->orderByRaw('CASE id '.$this->caseOrder($rows).' END') + ->get(); + } + + public function syncProduct(Product $product): void + { + if (! $this->hasFts()) { + return; + } + + DB::table('products_fts')->where('product_id', $product->id)->delete(); + + if ($product->status !== ProductStatus::Active) { + return; + } + + DB::table('products_fts')->insert([ + 'product_id' => $product->id, + 'store_id' => $product->store_id, + 'title' => $product->title ?? '', + 'description' => $this->stripTags($product->description_html), + 'vendor' => $product->vendor ?? '', + 'product_type' => $product->product_type ?? '', + 'tags' => $this->tagsToText($product->tags), + ]); + } + + public function removeProduct(int $productId): void + { + if (! $this->hasFts()) { + return; + } + + DB::table('products_fts')->where('product_id', $productId)->delete(); + } + + protected function hasFts(): bool + { + return Schema::hasTable('products_fts'); + } + + /** + * @param array $filters + */ + protected function logQuery(Store $store, string $query, array $filters, int $count): void + { + if (! Schema::hasTable('search_queries') || $query === '') { + return; + } + + DB::table('search_queries')->insert([ + 'store_id' => $store->id, + 'query' => $query, + 'filters_json' => empty($filters) ? null : json_encode($filters), + 'results_count' => $count, + 'created_at' => now(), + ]); + } + + protected function buildMatchExpression(string $query, bool $prefix = false): string + { + $tokens = preg_split('/[^\p{L}\p{N}]+/u', $query) ?: []; + $tokens = array_filter($tokens, fn ($t): bool => $t !== ''); + + $sanitized = array_map(function (string $token) use ($prefix): string { + $quoted = '"'.$token.'"'; + + return $prefix ? $quoted.'*' : $quoted; + }, $tokens); + + if (empty($sanitized)) { + return '""'; + } + + return implode(' ', $sanitized); + } + + /** + * @param array $ids + */ + protected function caseOrder(array $ids): string + { + $clauses = []; + foreach ($ids as $index => $id) { + $clauses[] = 'WHEN '.(int) $id.' THEN '.$index; + } + + return implode(' ', $clauses); + } + + protected function stripTags(?string $html): string + { + if ($html === null) { + return ''; + } + + return trim(preg_replace('/\s+/', ' ', strip_tags($html)) ?? ''); + } + + /** + * @param mixed $tags + */ + protected function tagsToText($tags): string + { + if (is_array($tags)) { + return implode(' ', array_map('strval', $tags)); + } + + if (is_string($tags)) { + $decoded = json_decode($tags, true); + if (is_array($decoded)) { + return implode(' ', array_map('strval', $decoded)); + } + + return $tags; + } + + return ''; + } +} diff --git a/app/Services/ShippingCalculator.php b/app/Services/ShippingCalculator.php new file mode 100644 index 00000000..16fbcbf1 --- /dev/null +++ b/app/Services/ShippingCalculator.php @@ -0,0 +1,145 @@ + + */ + public function getAvailableRates(Store $store, array $address, ?Cart $cart = null): Collection + { + $zone = $this->getMatchingZone($store, $address); + + if (! $zone) { + return collect(); + } + + $rates = $zone->rates()->where('is_active', true)->get(); + + if (! $cart) { + return $rates; + } + + return $rates->filter(fn (ShippingRate $rate): bool => $this->calculate($rate, $cart) !== null)->values(); + } + + public function getMatchingZone(Store $store, array $address): ?ShippingZone + { + $country = $address['country_code'] ?? null; + $region = $address['province_code'] ?? null; + + if (! $country) { + return null; + } + + $zones = ShippingZone::query()->where('store_id', $store->id)->get(); + + $best = null; + $bestSpecificity = -1; + + foreach ($zones as $zone) { + $countries = (array) ($zone->countries_json ?? []); + $regions = (array) ($zone->regions_json ?? []); + + if (! in_array($country, $countries, true)) { + continue; + } + + $specificity = 1; + if ($region && in_array($region, $regions, true)) { + $specificity = 2; + } + + if ($specificity > $bestSpecificity) { + $best = $zone; + $bestSpecificity = $specificity; + } elseif ($specificity === $bestSpecificity && $best && $zone->id < $best->id) { + $best = $zone; + } + } + + return $best; + } + + public function calculate(ShippingRate $rate, Cart $cart): ?int + { + $cart->loadMissing('lines.variant'); + $config = (array) ($rate->config_json ?? []); + + return match ($rate->type) { + ShippingRateType::Flat => (int) ($config['amount'] ?? 0), + ShippingRateType::Weight => $this->calculateWeight($config, $cart), + ShippingRateType::Price => $this->calculatePrice($config, $cart), + ShippingRateType::Carrier => $this->calculateCarrier($config), + }; + } + + protected function calculateWeight(array $config, Cart $cart): ?int + { + $ranges = $config['ranges'] ?? []; + $weight = 0; + + foreach ($cart->lines as $line) { + $variant = $line->variant; + if ($variant && $variant->requires_shipping) { + $weight += (int) ($variant->weight_g ?? 0) * $line->quantity; + } + } + + foreach ($ranges as $range) { + $min = (int) ($range['min_g'] ?? 0); + $max = (int) ($range['max_g'] ?? PHP_INT_MAX); + if ($weight >= $min && $weight <= $max) { + return (int) ($range['amount'] ?? 0); + } + } + + return null; + } + + protected function calculatePrice(array $config, Cart $cart): ?int + { + $ranges = $config['ranges'] ?? []; + $subtotal = (int) $cart->lines->sum('line_subtotal_amount'); + + foreach ($ranges as $range) { + $min = (int) ($range['min_amount'] ?? 0); + if ($subtotal < $min) { + continue; + } + + if (! array_key_exists('max_amount', $range) || $subtotal <= (int) $range['max_amount']) { + return (int) ($range['amount'] ?? 0); + } + } + + return null; + } + + protected function calculateCarrier(array $config): int + { + return (int) ($config['fallback_amount'] ?? 999); + } + + public function requiresShipping(Cart $cart): bool + { + $cart->loadMissing('lines.variant'); + + foreach ($cart->lines as $line) { + if ($line->variant && $line->variant->requires_shipping) { + return true; + } + } + + return false; + } +} diff --git a/app/Services/TaxCalculator.php b/app/Services/TaxCalculator.php new file mode 100644 index 00000000..39bc055f --- /dev/null +++ b/app/Services/TaxCalculator.php @@ -0,0 +1,88 @@ +, total: int} + */ + public function calculate(int $taxableAmount, TaxSettings $settings, array $address = []): array + { + if ($taxableAmount <= 0) { + return ['lines' => [], 'total' => 0]; + } + + $rate = $this->resolveRate($settings, $address); + if ($rate <= 0) { + return ['lines' => [], 'total' => 0]; + } + + if ($settings->prices_include_tax) { + $tax = $this->extractInclusive($taxableAmount, $rate); + } else { + $tax = $this->addExclusive($taxableAmount, $rate); + } + + $line = new TaxLine( + name: $this->resolveRateName($settings, $address), + rate: $rate, + amount: $tax, + ); + + return ['lines' => [$line], 'total' => $tax]; + } + + public function extractInclusive(int $grossAmount, int $rateBasisPoints): int + { + if ($rateBasisPoints <= 0 || $grossAmount <= 0) { + return 0; + } + + $net = intdiv($grossAmount * 10000, 10000 + $rateBasisPoints); + + return $grossAmount - $net; + } + + public function addExclusive(int $netAmount, int $rateBasisPoints): int + { + if ($rateBasisPoints <= 0 || $netAmount <= 0) { + return 0; + } + + return (int) round($netAmount * $rateBasisPoints / 10000); + } + + protected function resolveRate(TaxSettings $settings, array $address): int + { + $config = $settings->config_json ?? []; + + $regionRates = $config['region_rates'] ?? []; + $region = $address['province_code'] ?? null; + if ($region && isset($regionRates[$region])) { + return (int) $regionRates[$region]; + } + + $countryRates = $config['country_rates'] ?? []; + $country = $address['country_code'] ?? null; + if ($country && isset($countryRates[$country])) { + return (int) $countryRates[$country]; + } + + return (int) ($config['default_rate_bps'] ?? 0); + } + + protected function resolveRateName(TaxSettings $settings, array $address): string + { + $config = $settings->config_json ?? []; + + return (string) ($config['rate_name'] ?? 'Tax'); + } +} diff --git a/app/Services/ThemeSettingsService.php b/app/Services/ThemeSettingsService.php new file mode 100644 index 00000000..fafe876e --- /dev/null +++ b/app/Services/ThemeSettingsService.php @@ -0,0 +1,85 @@ +> + */ + protected array $cache = []; + + /** + * @return array + */ + public function forStore(?Store $store = null): array + { + $store = $store ?? $this->currentStore(); + + if (! $store) { + return []; + } + + if (array_key_exists($store->id, $this->cache)) { + return $this->cache[$store->id]; + } + + $settings = Cache::remember( + "theme_settings:{$store->id}", + self::CACHE_TTL_SECONDS, + fn (): array => $this->loadSettings($store) + ); + + return $this->cache[$store->id] = $settings; + } + + public function get(string $key, mixed $default = null): mixed + { + return Arr::get($this->forStore(), $key, $default); + } + + public function forget(Store $store): void + { + Cache::forget("theme_settings:{$store->id}"); + unset($this->cache[$store->id]); + } + + /** + * @return array + */ + protected function loadSettings(Store $store): array + { + $theme = Theme::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('status', ThemeStatus::Published->value) + ->orderByDesc('published_at') + ->first(); + + if (! $theme) { + return []; + } + + $settings = $theme->settings; + + return $settings?->settings_json ?? []; + } + + protected function currentStore(): ?Store + { + if (! app()->bound('current_store')) { + return null; + } + + $store = app('current_store'); + + return $store instanceof Store ? $store : null; + } +} diff --git a/app/Services/VariantMatrixService.php b/app/Services/VariantMatrixService.php new file mode 100644 index 00000000..91e17a91 --- /dev/null +++ b/app/Services/VariantMatrixService.php @@ -0,0 +1,140 @@ +loadMissing(['options.values', 'variants.optionValues']); + + $options = $product->options; + + if ($options->isEmpty()) { + $this->ensureDefaultVariant($product); + + return; + } + + $valueGroups = $options->map(fn ($option) => $option->values->all())->all(); + + $combos = $this->cartesianProduct($valueGroups); + + $existingVariants = $product->variants; + $existingCombos = []; + + foreach ($existingVariants as $variant) { + $key = $this->keyForValues($variant->optionValues->pluck('id')->all()); + $existingCombos[$key] = $variant; + } + + $desiredKeys = []; + $defaultPrice = $existingVariants->first()?->price_amount ?? 0; + $defaultCurrency = $existingVariants->first()?->currency ?? 'USD'; + + $position = 0; + foreach ($combos as $combo) { + $valueIds = array_map(fn ($v) => $v->id, $combo); + $key = $this->keyForValues($valueIds); + $desiredKeys[$key] = true; + + if (isset($existingCombos[$key])) { + continue; + } + + $variant = ProductVariant::query()->create([ + 'product_id' => $product->id, + 'price_amount' => $defaultPrice, + 'currency' => $defaultCurrency, + 'is_default' => false, + 'position' => $position++, + 'status' => VariantStatus::Active, + ]); + + $variant->optionValues()->sync($valueIds); + + \App\Models\InventoryItem::query()->create([ + 'store_id' => $product->store_id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => \App\Enums\InventoryPolicy::Deny, + ]); + } + + foreach ($existingCombos as $key => $variant) { + if (isset($desiredKeys[$key])) { + continue; + } + + if ($this->variantHasOrderReferences($variant)) { + $variant->update(['status' => VariantStatus::Archived]); + } else { + $variant->delete(); + } + } + }); + } + + protected function ensureDefaultVariant(Product $product): void + { + if ($product->variants->isNotEmpty()) { + return; + } + + ProductVariant::query()->create([ + 'product_id' => $product->id, + 'is_default' => true, + 'position' => 0, + 'status' => VariantStatus::Active, + 'currency' => 'USD', + ]); + } + + /** + * @param array> $groups + * @return array> + */ + protected function cartesianProduct(array $groups): array + { + $result = [[]]; + + foreach ($groups as $group) { + $next = []; + foreach ($result as $current) { + foreach ($group as $item) { + $next[] = array_merge($current, [$item]); + } + } + $result = $next; + } + + return $result; + } + + /** + * @param array $valueIds + */ + protected function keyForValues(array $valueIds): string + { + sort($valueIds); + + return implode('-', $valueIds); + } + + protected function variantHasOrderReferences(ProductVariant $variant): bool + { + if (! Schema::hasTable('order_lines')) { + return false; + } + + return DB::table('order_lines')->where('variant_id', $variant->id)->exists(); + } +} diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php new file mode 100644 index 00000000..e9448660 --- /dev/null +++ b/app/Services/WebhookService.php @@ -0,0 +1,46 @@ + $payload + */ + public function dispatchEvent(int $storeId, string $eventType, array $payload): int + { + $subscriptions = WebhookSubscription::query() + ->where('store_id', $storeId) + ->where('event_type', $eventType) + ->where('status', WebhookSubscriptionStatus::Active) + ->get(); + + foreach ($subscriptions as $subscription) { + $eventId = (string) Str::uuid(); + DeliverWebhook::dispatch($subscription->id, $eventType, $payload, $eventId); + } + + return $subscriptions->count(); + } + + public function sign(string $payload, string $secret): string + { + return hash_hmac('sha256', $payload, $secret); + } + + public function verify(string $payload, string $signature, string $secret): bool + { + return hash_equals($this->sign($payload, $secret), $signature); + } + + public function decryptSecret(WebhookSubscription $subscription): string + { + return Crypt::decryptString($subscription->signing_secret_encrypted); + } +} diff --git a/app/Support/HandleGenerator.php b/app/Support/HandleGenerator.php new file mode 100644 index 00000000..c7e32e56 --- /dev/null +++ b/app/Support/HandleGenerator.php @@ -0,0 +1,41 @@ +where('store_id', $storeId) + ->where('handle', $handle); + + if ($excludeId !== null) { + $query->where('id', '!=', $excludeId); + } + + return $query->exists(); + } +} diff --git a/app/ValueObjects/PaymentResult.php b/app/ValueObjects/PaymentResult.php new file mode 100644 index 00000000..d84e7220 --- /dev/null +++ b/app/ValueObjects/PaymentResult.php @@ -0,0 +1,16 @@ + $taxLines + */ + public function __construct( + public int $subtotal, + public int $discount, + public int $shipping, + public array $taxLines, + public int $taxTotal, + public int $total, + public string $currency, + ) {} + + /** + * @return array{ + * subtotal: int, + * discount: int, + * shipping: int, + * tax_lines: array, + * tax: int, + * total: int, + * currency: string, + * } + */ + public function toArray(): array + { + return [ + 'subtotal' => $this->subtotal, + 'discount' => $this->discount, + 'shipping' => $this->shipping, + 'tax_lines' => array_map(fn (TaxLine $line): array => $line->toArray(), $this->taxLines), + 'tax' => $this->taxTotal, + 'total' => $this->total, + 'currency' => $this->currency, + ]; + } +} diff --git a/app/ValueObjects/RefundResult.php b/app/ValueObjects/RefundResult.php new file mode 100644 index 00000000..a13a0f45 --- /dev/null +++ b/app/ValueObjects/RefundResult.php @@ -0,0 +1,15 @@ + $this->name, + 'rate' => $this->rate, + 'amount' => $this->amount, + ]; + } +} diff --git a/boost.json b/boost.json new file mode 100644 index 00000000..785d00da --- /dev/null +++ b/boost.json @@ -0,0 +1,18 @@ +{ + "agents": [ + "opencode", + "copilot" + ], + "guidelines": true, + "mcp": true, + "nightwatch_mcp": false, + "sail": false, + "skills": [ + "developing-with-fortify", + "laravel-best-practices", + "fluxui-development", + "livewire-development", + "pest-testing", + "tailwindcss-development" + ] +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c1832766..f2d44c0a 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,18 +1,70 @@ withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->appendToGroup('storefront', [ + \Illuminate\Cookie\Middleware\EncryptCookies::class, + \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, + \Illuminate\Session\Middleware\StartSession::class, + \Illuminate\View\Middleware\ShareErrorsFromSession::class, + \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class, + \Illuminate\Routing\Middleware\SubstituteBindings::class, + ResolveStore::class.':storefront', + ]); + + $middleware->appendToGroup('admin', [ + \Illuminate\Cookie\Middleware\EncryptCookies::class, + \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, + \Illuminate\Session\Middleware\StartSession::class, + \Illuminate\View\Middleware\ShareErrorsFromSession::class, + \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class, + \Illuminate\Routing\Middleware\SubstituteBindings::class, + ]); + + $middleware->alias([ + 'resolve.store' => ResolveStore::class, + ]); + + $middleware->statefulApi(); }) ->withExceptions(function (Exceptions $exceptions): void { - // + $exceptions->render(function (CartVersionMismatchException $e, Request $request) { + if ($request->expectsJson()) { + return response()->json([ + 'message' => $e->getMessage(), + 'expected_version' => $e->expected, + 'current_version' => $e->current, + ], 409); + } + }); + + $exceptions->render(function (InvalidDiscountException $e, Request $request) { + if ($request->expectsJson()) { + return response()->json([ + 'message' => $e->getMessage(), + 'reason' => $e->reason, + ], 422); + } + }); + + $exceptions->render(function (CheckoutStateException $e, Request $request) { + if ($request->expectsJson()) { + return response()->json(['message' => $e->getMessage()], 422); + } + }); })->create(); diff --git a/composer.json b/composer.json index 1f848aaf..5150f1e1 100644 --- a/composer.json +++ b/composer.json @@ -12,13 +12,14 @@ "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", diff --git a/composer.lock b/composer.lock index e4255dbd..cf239497 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": "0d57e8f92f66c4a9fab1ad2cc5623cd8", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1501,6 +1501,69 @@ }, "time": "2026-02-06T12:17:10+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "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-02-07T17:19:31+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.9", @@ -6877,35 +6940,36 @@ }, { "name": "laravel/boost", - "version": "v1.0.18", + "version": "v2.4.3", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab" + "reference": "841d52905728cfac9f93c778a1758e740ce9a367" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", + "url": "https://api.github.com/repos/laravel/boost/zipball/841d52905728cfac9f93c778a1758e740ce9a367", + "reference": "841d52905728cfac9f93c778a1758e740ce9a367", "shasum": "" }, "require": { "guzzlehttp/guzzle": "^7.9", - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "laravel/mcp": "^0.1.0", - "laravel/prompts": "^0.1.9|^0.3", - "laravel/roster": "^0.2", - "php": "^8.1|^8.2" + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "laravel/mcp": "^0.5.1|^0.6.0", + "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 +6991,7 @@ "license": [ "MIT" ], - "description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.", + "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.", "homepage": "https://github.com/laravel/boost", "keywords": [ "ai", @@ -6938,35 +7002,41 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2025-08-16T09:10:03+00:00" + "time": "2026-04-10T15:59:10+00:00" }, { "name": "laravel/mcp", - "version": "v0.1.1", + "version": "v0.6.5", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713" + "reference": "583a6282bf0f074d754f7ff5cd1fff9d34244691" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713", + "url": "https://api.github.com/repos/laravel/mcp/zipball/583a6282bf0f074d754f7ff5cd1fff9d34244691", + "reference": "583a6282bf0f074d754f7ff5cd1fff9d34244691", "shasum": "" }, "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/http": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "illuminate/validation": "^10.0|^11.0|^12.0", - "php": "^8.1|^8.2" + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2" }, "require-dev": { - "laravel/pint": "^1.14", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "phpstan/phpstan": "^2.0" + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" }, "type": "library", "extra": { @@ -6982,8 +7052,6 @@ "autoload": { "psr-4": { "Laravel\\Mcp\\": "src/", - "Workbench\\App\\": "workbench/app/", - "Laravel\\Mcp\\Tests\\": "tests/", "Laravel\\Mcp\\Server\\": "src/Server/" } }, @@ -6991,10 +7059,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 +7075,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2025-08-16T09:50:43+00:00" + "time": "2026-03-30T19:17:10+00:00" }, { "name": "laravel/pail", @@ -7153,30 +7226,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 +7283,7 @@ "issues": "https://github.com/laravel/roster/issues", "source": "https://github.com/laravel/roster" }, - "time": "2025-07-24T12:31:13+00:00" + "time": "2026-03-05T07:58:43+00:00" }, { "name": "laravel/sail", @@ -9974,5 +10048,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..70aaa27d 100644 --- a/config/auth.php +++ b/config/auth.php @@ -40,6 +40,10 @@ 'driver' => 'session', 'provider' => 'users', ], + 'customer' => [ + 'driver' => 'session', + 'provider' => 'customers', + ], ], /* @@ -64,11 +68,10 @@ 'driver' => 'eloquent', 'model' => env('AUTH_MODEL', App\Models\User::class), ], - - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], + 'customers' => [ + 'driver' => 'customer', + 'model' => App\Models\Customer::class, + ], ], /* @@ -97,6 +100,12 @@ 'expire' => 60, 'throttle' => 60, ], + 'customers' => [ + 'provider' => 'customers', + 'table' => 'customer_password_reset_tokens', + 'expire' => 60, + 'throttle' => 60, + ], ], /* diff --git a/config/database.php b/config/database.php index df933e7f..210e1eac 100644 --- a/config/database.php +++ b/config/database.php @@ -37,9 +37,9 @@ 'database' => env('DB_DATABASE', database_path('database.sqlite')), 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), - 'busy_timeout' => null, - 'journal_mode' => null, - 'synchronous' => null, + 'busy_timeout' => 5000, + 'journal_mode' => 'WAL', + 'synchronous' => 'NORMAL', 'transaction_mode' => 'DEFERRED', ], diff --git a/config/logging.php b/config/logging.php index 9e998a49..c4a6c2ca 100644 --- a/config/logging.php +++ b/config/logging.php @@ -65,6 +65,14 @@ 'replace_placeholders' => true, ], + 'structured' => [ + 'driver' => 'single', + 'path' => storage_path('logs/structured.log'), + 'level' => env('LOG_LEVEL', 'info'), + 'formatter' => Monolog\Formatter\JsonFormatter::class, + 'replace_placeholders' => true, + ], + 'daily' => [ 'driver' => 'daily', 'path' => storage_path('logs/laravel.log'), diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 00000000..44527d68 --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,84 @@ + 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', ''), + + /* + |-------------------------------------------------------------------------- + | 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' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + ], + +]; diff --git a/database/factories/AnalyticsDailyFactory.php b/database/factories/AnalyticsDailyFactory.php new file mode 100644 index 00000000..e03bac4b --- /dev/null +++ b/database/factories/AnalyticsDailyFactory.php @@ -0,0 +1,30 @@ + + */ +class AnalyticsDailyFactory extends Factory +{ + protected $model = AnalyticsDaily::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'date' => now()->toDateString(), + '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..f087e45f --- /dev/null +++ b/database/factories/AnalyticsEventFactory.php @@ -0,0 +1,30 @@ + + */ +class AnalyticsEventFactory extends Factory +{ + protected $model = AnalyticsEvent::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'type' => AnalyticsEventType::PageView->value, + 'session_id' => $this->faker->uuid(), + 'customer_id' => null, + 'properties_json' => [], + 'client_event_id' => $this->faker->unique()->uuid(), + 'occurred_at' => now(), + 'created_at' => now(), + ]; + } +} diff --git a/database/factories/AppFactory.php b/database/factories/AppFactory.php new file mode 100644 index 00000000..2949217e --- /dev/null +++ b/database/factories/AppFactory.php @@ -0,0 +1,24 @@ + + */ +class AppFactory extends Factory +{ + protected $model = App::class; + + public function definition(): array + { + return [ + 'name' => $this->faker->company().' App', + 'status' => AppStatus::Active, + 'created_at' => now(), + ]; + } +} diff --git a/database/factories/AppInstallationFactory.php b/database/factories/AppInstallationFactory.php new file mode 100644 index 00000000..a7aa1a18 --- /dev/null +++ b/database/factories/AppInstallationFactory.php @@ -0,0 +1,28 @@ + + */ +class AppInstallationFactory extends Factory +{ + protected $model = AppInstallation::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'app_id' => App::factory(), + 'scopes_json' => ['read:orders'], + 'status' => AppInstallationStatus::Active, + 'installed_at' => now(), + ]; + } +} diff --git a/database/factories/CartFactory.php b/database/factories/CartFactory.php new file mode 100644 index 00000000..68db3e83 --- /dev/null +++ b/database/factories/CartFactory.php @@ -0,0 +1,27 @@ + + */ +class CartFactory extends Factory +{ + protected $model = Cart::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'customer_id' => null, + 'currency' => 'USD', + 'cart_version' => 1, + 'status' => CartStatus::Active, + ]; + } +} diff --git a/database/factories/CartLineFactory.php b/database/factories/CartLineFactory.php new file mode 100644 index 00000000..fa3598de --- /dev/null +++ b/database/factories/CartLineFactory.php @@ -0,0 +1,32 @@ + + */ +class CartLineFactory extends Factory +{ + protected $model = CartLine::class; + + public function definition(): array + { + $quantity = $this->faker->numberBetween(1, 3); + $unit = $this->faker->numberBetween(500, 5000); + + return [ + 'cart_id' => Cart::factory(), + 'variant_id' => ProductVariant::factory(), + 'quantity' => $quantity, + 'unit_price_amount' => $unit, + 'line_subtotal_amount' => $unit * $quantity, + 'line_discount_amount' => 0, + 'line_total_amount' => $unit * $quantity, + ]; + } +} diff --git a/database/factories/CheckoutFactory.php b/database/factories/CheckoutFactory.php new file mode 100644 index 00000000..3672a008 --- /dev/null +++ b/database/factories/CheckoutFactory.php @@ -0,0 +1,27 @@ + + */ +class CheckoutFactory extends Factory +{ + protected $model = Checkout::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'cart_id' => Cart::factory(), + 'customer_id' => null, + 'status' => CheckoutStatus::Started, + ]; + } +} diff --git a/database/factories/CollectionFactory.php b/database/factories/CollectionFactory.php new file mode 100644 index 00000000..04f22ed3 --- /dev/null +++ b/database/factories/CollectionFactory.php @@ -0,0 +1,37 @@ + + */ +class CollectionFactory extends Factory +{ + protected $model = Collection::class; + + public function definition(): array + { + $title = $this->faker->unique()->words(2, true); + + return [ + 'store_id' => Store::factory(), + 'title' => ucwords($title), + 'handle' => Str::slug($title.'-'.$this->faker->unique()->numberBetween(1000, 99999)), + 'description_html' => '

'.$this->faker->paragraph().'

', + 'type' => CollectionType::Manual, + 'status' => CollectionStatus::Active, + ]; + } + + public function draft(): static + { + return $this->state(['status' => CollectionStatus::Draft]); + } +} diff --git a/database/factories/CustomerAddressFactory.php b/database/factories/CustomerAddressFactory.php new file mode 100644 index 00000000..114ddadc --- /dev/null +++ b/database/factories/CustomerAddressFactory.php @@ -0,0 +1,37 @@ + + */ +class CustomerAddressFactory extends Factory +{ + protected $model = CustomerAddress::class; + + public function definition(): array + { + return [ + 'customer_id' => Customer::factory(), + 'label' => 'Home', + 'address_json' => [ + 'first_name' => $this->faker->firstName(), + 'last_name' => $this->faker->lastName(), + 'address1' => $this->faker->streetAddress(), + 'city' => $this->faker->city(), + 'country_code' => 'US', + 'zip' => $this->faker->postcode(), + ], + 'is_default' => false, + ]; + } + + public function default(): static + { + return $this->state(['is_default' => true]); + } +} diff --git a/database/factories/CustomerFactory.php b/database/factories/CustomerFactory.php new file mode 100644 index 00000000..44f96634 --- /dev/null +++ b/database/factories/CustomerFactory.php @@ -0,0 +1,31 @@ + + */ +class CustomerFactory extends Factory +{ + protected $model = Customer::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'email' => $this->faker->unique()->safeEmail(), + 'password' => Hash::make('password'), + 'first_name' => $this->faker->firstName(), + 'last_name' => $this->faker->lastName(), + 'phone' => $this->faker->phoneNumber(), + 'state' => 'active', + 'accepts_marketing' => false, + 'email_verified_at' => now(), + ]; + } +} diff --git a/database/factories/DiscountFactory.php b/database/factories/DiscountFactory.php new file mode 100644 index 00000000..373222e3 --- /dev/null +++ b/database/factories/DiscountFactory.php @@ -0,0 +1,59 @@ + + */ +class DiscountFactory extends Factory +{ + protected $model = Discount::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'type' => DiscountType::Code, + 'code' => strtoupper($this->faker->unique()->lexify('SAVE????')), + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'starts_at' => now()->subDay(), + 'ends_at' => null, + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [], + 'status' => DiscountStatus::Active, + ]; + } + + public function percent(int $percent): static + { + return $this->state([ + 'value_type' => DiscountValueType::Percent, + 'value_amount' => $percent, + ]); + } + + public function fixed(int $cents): static + { + return $this->state([ + 'value_type' => DiscountValueType::Fixed, + 'value_amount' => $cents, + ]); + } + + public function freeShipping(): static + { + return $this->state([ + 'value_type' => DiscountValueType::FreeShipping, + 'value_amount' => 0, + ]); + } +} diff --git a/database/factories/FulfillmentFactory.php b/database/factories/FulfillmentFactory.php new file mode 100644 index 00000000..429a068f --- /dev/null +++ b/database/factories/FulfillmentFactory.php @@ -0,0 +1,25 @@ + + */ +class FulfillmentFactory extends Factory +{ + protected $model = Fulfillment::class; + + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'status' => FulfillmentShipmentStatus::Pending, + 'created_at' => now(), + ]; + } +} diff --git a/database/factories/FulfillmentLineFactory.php b/database/factories/FulfillmentLineFactory.php new file mode 100644 index 00000000..d5193298 --- /dev/null +++ b/database/factories/FulfillmentLineFactory.php @@ -0,0 +1,25 @@ + + */ +class FulfillmentLineFactory extends Factory +{ + protected $model = FulfillmentLine::class; + + 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..73098861 --- /dev/null +++ b/database/factories/InventoryItemFactory.php @@ -0,0 +1,28 @@ + + */ +class InventoryItemFactory extends Factory +{ + protected $model = InventoryItem::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'variant_id' => ProductVariant::factory(), + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ]; + } +} diff --git a/database/factories/NavigationItemFactory.php b/database/factories/NavigationItemFactory.php new file mode 100644 index 00000000..aecbe5ba --- /dev/null +++ b/database/factories/NavigationItemFactory.php @@ -0,0 +1,28 @@ + + */ +class NavigationItemFactory extends Factory +{ + protected $model = NavigationItem::class; + + public function definition(): array + { + return [ + 'menu_id' => NavigationMenu::factory(), + 'type' => NavigationItemType::Link, + 'label' => $this->faker->words(2, true), + 'url' => '/'.$this->faker->slug(1), + 'resource_id' => null, + 'position' => 0, + ]; + } +} diff --git a/database/factories/NavigationMenuFactory.php b/database/factories/NavigationMenuFactory.php new file mode 100644 index 00000000..fd849d8a --- /dev/null +++ b/database/factories/NavigationMenuFactory.php @@ -0,0 +1,27 @@ + + */ +class NavigationMenuFactory extends Factory +{ + protected $model = NavigationMenu::class; + + public function definition(): array + { + $title = $this->faker->unique()->words(2, true); + + return [ + 'store_id' => Store::factory(), + 'handle' => Str::slug($title).'-'.$this->faker->unique()->numberBetween(1000, 99999), + 'title' => ucfirst($title), + ]; + } +} diff --git a/database/factories/OauthClientFactory.php b/database/factories/OauthClientFactory.php new file mode 100644 index 00000000..5430700a --- /dev/null +++ b/database/factories/OauthClientFactory.php @@ -0,0 +1,27 @@ + + */ +class OauthClientFactory extends Factory +{ + protected $model = OauthClient::class; + + public function definition(): array + { + return [ + 'app_id' => App::factory(), + 'client_id' => 'client_'.Str::random(24), + 'client_secret_encrypted' => Crypt::encryptString('secret_'.Str::random(32)), + 'redirect_uris_json' => ['https://example.com/callback'], + ]; + } +} diff --git a/database/factories/OauthTokenFactory.php b/database/factories/OauthTokenFactory.php new file mode 100644 index 00000000..8b2ce40e --- /dev/null +++ b/database/factories/OauthTokenFactory.php @@ -0,0 +1,26 @@ + + */ +class OauthTokenFactory extends Factory +{ + protected $model = OauthToken::class; + + 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..9c86693a --- /dev/null +++ b/database/factories/OrderFactory.php @@ -0,0 +1,43 @@ + + */ +class OrderFactory extends Factory +{ + protected $model = Order::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'order_number' => '1'.$this->faker->unique()->numberBetween(1000, 999999), + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled, + 'currency' => 'USD', + 'subtotal_amount' => 1000, + 'total_amount' => 1000, + 'placed_at' => now(), + ]; + } + + public function paid(): static + { + return $this->state([ + 'status' => OrderStatus::Paid, + 'financial_status' => FinancialStatus::Paid, + ]); + } +} diff --git a/database/factories/OrderLineFactory.php b/database/factories/OrderLineFactory.php new file mode 100644 index 00000000..2ea970a6 --- /dev/null +++ b/database/factories/OrderLineFactory.php @@ -0,0 +1,29 @@ + + */ +class OrderLineFactory extends Factory +{ + protected $model = OrderLine::class; + + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'title_snapshot' => $this->faker->words(3, true), + 'sku_snapshot' => strtoupper($this->faker->bothify('SKU-####')), + 'quantity' => 1, + 'unit_price_amount' => 1000, + 'total_amount' => 1000, + 'tax_lines_json' => [], + 'discount_allocations_json' => [], + ]; + } +} diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php new file mode 100644 index 00000000..77493628 --- /dev/null +++ b/database/factories/OrganizationFactory.php @@ -0,0 +1,22 @@ + + */ +class OrganizationFactory extends Factory +{ + protected $model = Organization::class; + + public function definition(): array + { + return [ + 'name' => $this->faker->company(), + 'billing_email' => $this->faker->unique()->safeEmail(), + ]; + } +} diff --git a/database/factories/PageFactory.php b/database/factories/PageFactory.php new file mode 100644 index 00000000..2f0e60cc --- /dev/null +++ b/database/factories/PageFactory.php @@ -0,0 +1,39 @@ + + */ +class PageFactory extends Factory +{ + protected $model = Page::class; + + public function definition(): array + { + $title = $this->faker->unique()->sentence(3); + + return [ + 'store_id' => Store::factory(), + 'title' => $title, + 'handle' => Str::slug($title), + 'body_html' => '

'.$this->faker->paragraph().'

', + 'status' => PageStatus::Draft, + 'published_at' => null, + ]; + } + + public function published(): static + { + return $this->state([ + 'status' => PageStatus::Published, + 'published_at' => now(), + ]); + } +} diff --git a/database/factories/PaymentFactory.php b/database/factories/PaymentFactory.php new file mode 100644 index 00000000..a7d08c43 --- /dev/null +++ b/database/factories/PaymentFactory.php @@ -0,0 +1,31 @@ + + */ +class PaymentFactory extends Factory +{ + protected $model = Payment::class; + + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'provider' => 'mock', + 'method' => PaymentMethod::CreditCard, + 'provider_payment_id' => 'mock_'.$this->faker->uuid(), + 'status' => PaymentStatus::Pending, + 'amount' => 1000, + 'currency' => 'USD', + 'created_at' => now(), + ]; + } +} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php new file mode 100644 index 00000000..57040b49 --- /dev/null +++ b/database/factories/ProductFactory.php @@ -0,0 +1,43 @@ + + */ +class ProductFactory extends Factory +{ + protected $model = Product::class; + + public function definition(): array + { + $title = $this->faker->unique()->words(3, true); + + return [ + 'store_id' => Store::factory(), + 'title' => ucwords($title), + 'handle' => Str::slug($title.'-'.$this->faker->unique()->numberBetween(1000, 99999)), + 'status' => ProductStatus::Draft, + 'description_html' => '

'.$this->faker->paragraph().'

', + 'vendor' => $this->faker->company(), + 'product_type' => $this->faker->randomElement(['Apparel', 'Accessories', 'Footwear']), + 'tags' => [], + ]; + } + + public function active(): static + { + return $this->state(['status' => ProductStatus::Active, 'published_at' => now()]); + } + + public function archived(): static + { + return $this->state(['status' => ProductStatus::Archived]); + } +} diff --git a/database/factories/ProductMediaFactory.php b/database/factories/ProductMediaFactory.php new file mode 100644 index 00000000..bd66f8d7 --- /dev/null +++ b/database/factories/ProductMediaFactory.php @@ -0,0 +1,34 @@ + + */ +class ProductMediaFactory extends Factory +{ + protected $model = ProductMedia::class; + + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'type' => MediaType::Image, + 'storage_key' => 'products/'.$this->faker->uuid().'.jpg', + 'alt_text' => $this->faker->sentence(), + 'width' => 1200, + 'height' => 1200, + 'mime_type' => 'image/jpeg', + 'byte_size' => $this->faker->numberBetween(10_000, 500_000), + 'position' => 0, + 'status' => MediaStatus::Ready, + 'created_at' => now(), + ]; + } +} diff --git a/database/factories/ProductOptionFactory.php b/database/factories/ProductOptionFactory.php new file mode 100644 index 00000000..a7463e6f --- /dev/null +++ b/database/factories/ProductOptionFactory.php @@ -0,0 +1,24 @@ + + */ +class ProductOptionFactory extends Factory +{ + protected $model = ProductOption::class; + + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'name' => $this->faker->randomElement(['Size', 'Color', 'Material']), + 'position' => 0, + ]; + } +} diff --git a/database/factories/ProductOptionValueFactory.php b/database/factories/ProductOptionValueFactory.php new file mode 100644 index 00000000..89393b66 --- /dev/null +++ b/database/factories/ProductOptionValueFactory.php @@ -0,0 +1,24 @@ + + */ +class ProductOptionValueFactory extends Factory +{ + protected $model = ProductOptionValue::class; + + public function definition(): array + { + return [ + 'product_option_id' => ProductOption::factory(), + 'value' => $this->faker->unique()->word(), + 'position' => 0, + ]; + } +} diff --git a/database/factories/ProductVariantFactory.php b/database/factories/ProductVariantFactory.php new file mode 100644 index 00000000..49677f3f --- /dev/null +++ b/database/factories/ProductVariantFactory.php @@ -0,0 +1,35 @@ + + */ +class ProductVariantFactory extends Factory +{ + protected $model = ProductVariant::class; + + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'sku' => strtoupper($this->faker->unique()->bothify('SKU-####')), + 'price_amount' => $this->faker->numberBetween(500, 10000), + 'currency' => 'USD', + 'is_default' => false, + 'position' => 0, + 'status' => VariantStatus::Active, + 'requires_shipping' => true, + ]; + } + + public function default(): static + { + return $this->state(['is_default' => true]); + } +} diff --git a/database/factories/RefundFactory.php b/database/factories/RefundFactory.php new file mode 100644 index 00000000..d8521fcd --- /dev/null +++ b/database/factories/RefundFactory.php @@ -0,0 +1,29 @@ + + */ +class RefundFactory extends Factory +{ + protected $model = Refund::class; + + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'payment_id' => Payment::factory(), + 'amount' => 500, + 'reason' => null, + 'status' => RefundStatus::Pending, + 'created_at' => now(), + ]; + } +} diff --git a/database/factories/ShippingRateFactory.php b/database/factories/ShippingRateFactory.php new file mode 100644 index 00000000..427e9fe6 --- /dev/null +++ b/database/factories/ShippingRateFactory.php @@ -0,0 +1,27 @@ + + */ +class ShippingRateFactory extends Factory +{ + protected $model = ShippingRate::class; + + public function definition(): array + { + return [ + 'zone_id' => ShippingZone::factory(), + 'name' => 'Standard Shipping', + 'type' => ShippingRateType::Flat, + 'config_json' => ['amount' => 799], + 'is_active' => true, + ]; + } +} diff --git a/database/factories/ShippingZoneFactory.php b/database/factories/ShippingZoneFactory.php new file mode 100644 index 00000000..03ba33d8 --- /dev/null +++ b/database/factories/ShippingZoneFactory.php @@ -0,0 +1,25 @@ + + */ +class ShippingZoneFactory extends Factory +{ + protected $model = ShippingZone::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'name' => $this->faker->word().' Zone', + 'countries_json' => ['US'], + 'regions_json' => [], + ]; + } +} diff --git a/database/factories/StoreDomainFactory.php b/database/factories/StoreDomainFactory.php new file mode 100644 index 00000000..a9f1b9a8 --- /dev/null +++ b/database/factories/StoreDomainFactory.php @@ -0,0 +1,38 @@ + + */ +class StoreDomainFactory extends Factory +{ + protected $model = StoreDomain::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'hostname' => $this->faker->unique()->domainName(), + 'type' => StoreDomainType::Storefront, + 'is_primary' => true, + 'tls_mode' => 'managed', + 'created_at' => now(), + ]; + } + + public function admin(): static + { + return $this->state(['type' => StoreDomainType::Admin]); + } + + public function storefront(): static + { + return $this->state(['type' => StoreDomainType::Storefront]); + } +} diff --git a/database/factories/StoreFactory.php b/database/factories/StoreFactory.php new file mode 100644 index 00000000..aecff2f6 --- /dev/null +++ b/database/factories/StoreFactory.php @@ -0,0 +1,37 @@ + + */ +class StoreFactory extends Factory +{ + protected $model = Store::class; + + public function definition(): array + { + $name = $this->faker->unique()->company(); + + return [ + 'organization_id' => Organization::factory(), + 'name' => $name, + 'handle' => Str::slug($name.'-'.$this->faker->unique()->numberBetween(1000, 99999)), + 'status' => StoreStatus::Active, + 'default_currency' => 'USD', + 'default_locale' => 'en', + 'timezone' => 'UTC', + ]; + } + + public function suspended(): static + { + return $this->state(['status' => StoreStatus::Suspended]); + } +} diff --git a/database/factories/StoreSettingsFactory.php b/database/factories/StoreSettingsFactory.php new file mode 100644 index 00000000..ead824ef --- /dev/null +++ b/database/factories/StoreSettingsFactory.php @@ -0,0 +1,27 @@ + + */ +class StoreSettingsFactory extends Factory +{ + protected $model = StoreSettings::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'settings_json' => [ + 'announcement_bar' => 'Free shipping on orders over $50', + 'support_email' => 'support@example.com', + ], + 'updated_at' => now(), + ]; + } +} diff --git a/database/factories/TaxSettingsFactory.php b/database/factories/TaxSettingsFactory.php new file mode 100644 index 00000000..055a9d9d --- /dev/null +++ b/database/factories/TaxSettingsFactory.php @@ -0,0 +1,27 @@ + + */ +class TaxSettingsFactory extends Factory +{ + protected $model = TaxSettings::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'mode' => TaxMode::Manual, + 'provider' => 'none', + 'prices_include_tax' => false, + 'config_json' => ['default_rate_bps' => 0], + ]; + } +} diff --git a/database/factories/ThemeFactory.php b/database/factories/ThemeFactory.php new file mode 100644 index 00000000..cf48f60b --- /dev/null +++ b/database/factories/ThemeFactory.php @@ -0,0 +1,35 @@ + + */ +class ThemeFactory extends Factory +{ + protected $model = Theme::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'name' => 'Default', + 'version' => '1.0.0', + 'status' => ThemeStatus::Draft, + 'published_at' => null, + ]; + } + + public function published(): static + { + return $this->state([ + 'status' => ThemeStatus::Published, + 'published_at' => now(), + ]); + } +} diff --git a/database/factories/ThemeFileFactory.php b/database/factories/ThemeFileFactory.php new file mode 100644 index 00000000..d44d3c6d --- /dev/null +++ b/database/factories/ThemeFileFactory.php @@ -0,0 +1,28 @@ + + */ +class ThemeFileFactory extends Factory +{ + protected $model = ThemeFile::class; + + public function definition(): array + { + $path = 'templates/'.$this->faker->unique()->slug(2).'.blade.php'; + + return [ + 'theme_id' => Theme::factory(), + 'path' => $path, + 'storage_key' => 'themes/'.$this->faker->uuid().'/'.$path, + 'sha256' => hash('sha256', $this->faker->sentence()), + 'byte_size' => $this->faker->numberBetween(100, 50000), + ]; + } +} diff --git a/database/factories/ThemeSettingsFactory.php b/database/factories/ThemeSettingsFactory.php new file mode 100644 index 00000000..6adf63ba --- /dev/null +++ b/database/factories/ThemeSettingsFactory.php @@ -0,0 +1,38 @@ + + */ +class ThemeSettingsFactory extends Factory +{ + protected $model = ThemeSettings::class; + + public function definition(): array + { + return [ + 'theme_id' => Theme::factory(), + 'settings_json' => [ + 'hero' => [ + 'heading' => 'Welcome to our shop', + 'subheading' => 'Discover our latest collection', + 'cta_label' => 'Shop now', + 'cta_url' => '/collections', + ], + 'featured_collection_handles' => [], + 'featured_product_handles' => [], + 'colors' => [ + 'primary' => '#111827', + 'accent' => '#4f46e5', + ], + 'dark_mode' => 'system', + ], + 'updated_at' => now(), + ]; + } +} diff --git a/database/factories/WebhookDeliveryFactory.php b/database/factories/WebhookDeliveryFactory.php new file mode 100644 index 00000000..e78a9256 --- /dev/null +++ b/database/factories/WebhookDeliveryFactory.php @@ -0,0 +1,27 @@ + + */ +class WebhookDeliveryFactory extends Factory +{ + protected $model = WebhookDelivery::class; + + public function definition(): array + { + return [ + 'subscription_id' => WebhookSubscription::factory(), + 'event_id' => (string) Str::uuid(), + 'attempt_count' => 1, + 'status' => WebhookDeliveryStatus::Pending, + ]; + } +} diff --git a/database/factories/WebhookSubscriptionFactory.php b/database/factories/WebhookSubscriptionFactory.php new file mode 100644 index 00000000..e962a1ad --- /dev/null +++ b/database/factories/WebhookSubscriptionFactory.php @@ -0,0 +1,31 @@ + + */ +class WebhookSubscriptionFactory extends Factory +{ + protected $model = WebhookSubscription::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'app_installation_id' => null, + 'event_type' => 'order.created', + 'target_url' => 'https://example.com/webhooks/'.Str::random(8), + 'signing_secret_encrypted' => Crypt::encryptString('whsec_'.Str::random(32)), + 'status' => WebhookSubscriptionStatus::Active, + 'consecutive_failures' => 0, + ]; + } +} diff --git a/database/migrations/2026_04_18_000000_update_users_for_shop.php b/database/migrations/2026_04_18_000000_update_users_for_shop.php new file mode 100644 index 00000000..0b145e4c --- /dev/null +++ b/database/migrations/2026_04_18_000000_update_users_for_shop.php @@ -0,0 +1,25 @@ +string('status')->default('active')->after('password'); + $table->timestamp('last_login_at')->nullable()->after('status'); + $table->index('status', 'idx_users_status'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropIndex('idx_users_status'); + $table->dropColumn(['status', 'last_login_at']); + }); + } +}; diff --git a/database/migrations/2026_04_18_000001_create_organizations_table.php b/database/migrations/2026_04_18_000001_create_organizations_table.php new file mode 100644 index 00000000..5cd822bd --- /dev/null +++ b/database/migrations/2026_04_18_000001_create_organizations_table.php @@ -0,0 +1,24 @@ +id(); + $table->string('name'); + $table->string('billing_email'); + $table->timestamps(); + $table->index('billing_email', 'idx_organizations_billing_email'); + }); + } + + public function down(): void + { + Schema::dropIfExists('organizations'); + } +}; diff --git a/database/migrations/2026_04_18_000002_create_stores_table.php b/database/migrations/2026_04_18_000002_create_stores_table.php new file mode 100644 index 00000000..0217e494 --- /dev/null +++ b/database/migrations/2026_04_18_000002_create_stores_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('organization_id')->constrained('organizations')->cascadeOnDelete(); + $table->string('name'); + $table->string('handle')->unique(); + $table->string('status')->default('active'); + $table->string('default_currency', 3)->default('USD'); + $table->string('default_locale', 10)->default('en'); + $table->string('timezone', 40)->default('UTC'); + $table->timestamps(); + $table->index('organization_id', 'idx_stores_organization_id'); + $table->index('status', 'idx_stores_status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('stores'); + } +}; diff --git a/database/migrations/2026_04_18_000003_create_store_domains_table.php b/database/migrations/2026_04_18_000003_create_store_domains_table.php new file mode 100644 index 00000000..6d3c8d75 --- /dev/null +++ b/database/migrations/2026_04_18_000003_create_store_domains_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('hostname')->unique(); + $table->string('type')->default('storefront'); + $table->boolean('is_primary')->default(false); + $table->string('tls_mode')->default('managed'); + $table->timestamp('created_at')->nullable(); + $table->index('store_id', 'idx_store_domains_store_id'); + $table->index(['store_id', 'is_primary'], 'idx_store_domains_store_primary'); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_domains'); + } +}; diff --git a/database/migrations/2026_04_18_000004_create_store_users_table.php b/database/migrations/2026_04_18_000004_create_store_users_table.php new file mode 100644 index 00000000..d361a079 --- /dev/null +++ b/database/migrations/2026_04_18_000004_create_store_users_table.php @@ -0,0 +1,26 @@ +foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('role')->default('staff'); + $table->timestamp('created_at')->nullable(); + $table->primary(['store_id', 'user_id']); + $table->index('user_id', 'idx_store_users_user_id'); + $table->index(['store_id', 'role'], 'idx_store_users_role'); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_users'); + } +}; diff --git a/database/migrations/2026_04_18_000005_create_store_settings_table.php b/database/migrations/2026_04_18_000005_create_store_settings_table.php new file mode 100644 index 00000000..480328c3 --- /dev/null +++ b/database/migrations/2026_04_18_000005_create_store_settings_table.php @@ -0,0 +1,22 @@ +foreignId('store_id')->primary()->constrained('stores')->cascadeOnDelete(); + $table->text('settings_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_settings'); + } +}; diff --git a/database/migrations/2026_04_18_000010_create_customers_table.php b/database/migrations/2026_04_18_000010_create_customers_table.php new file mode 100644 index 00000000..4f626701 --- /dev/null +++ b/database/migrations/2026_04_18_000010_create_customers_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('email'); + $table->string('password')->nullable(); + $table->string('first_name')->nullable(); + $table->string('last_name')->nullable(); + $table->string('phone')->nullable(); + $table->string('state')->default('active'); + $table->boolean('accepts_marketing')->default(false); + $table->text('tags_json')->nullable(); + $table->text('metadata_json')->nullable(); + $table->string('remember_token', 100)->nullable(); + $table->timestamp('email_verified_at')->nullable(); + $table->timestamps(); + $table->unique(['store_id', 'email']); + $table->index('store_id'); + $table->index('email'); + }); + + Schema::create('customer_password_reset_tokens', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('email'); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + $table->unique(['store_id', 'email']); + }); + } + + public function down(): void + { + Schema::dropIfExists('customer_password_reset_tokens'); + Schema::dropIfExists('customers'); + } +}; diff --git a/database/migrations/2026_04_18_0100_create_products_table.php b/database/migrations/2026_04_18_0100_create_products_table.php new file mode 100644 index 00000000..c1b4fa21 --- /dev/null +++ b/database/migrations/2026_04_18_0100_create_products_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->string('status')->default('draft'); + $table->text('description_html')->nullable(); + $table->string('vendor')->nullable(); + $table->string('product_type')->nullable(); + $table->text('tags')->default('[]'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'handle'], '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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('products'); + } +}; diff --git a/database/migrations/2026_04_18_0101_create_product_options_table.php b/database/migrations/2026_04_18_0101_create_product_options_table.php new file mode 100644 index 00000000..fdf6bbf4 --- /dev/null +++ b/database/migrations/2026_04_18_0101_create_product_options_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->string('name'); + $table->unsignedInteger('position')->default(0); + + $table->index('product_id', 'idx_product_options_product_id'); + $table->unique(['product_id', 'position'], 'idx_product_options_product_position'); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_options'); + } +}; diff --git a/database/migrations/2026_04_18_0102_create_product_option_values_table.php b/database/migrations/2026_04_18_0102_create_product_option_values_table.php new file mode 100644 index 00000000..255b4ac5 --- /dev/null +++ b/database/migrations/2026_04_18_0102_create_product_option_values_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('product_option_id')->constrained('product_options')->cascadeOnDelete(); + $table->string('value'); + $table->unsignedInteger('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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_option_values'); + } +}; diff --git a/database/migrations/2026_04_18_0103_create_product_variants_table.php b/database/migrations/2026_04_18_0103_create_product_variants_table.php new file mode 100644 index 00000000..0b8fbfe8 --- /dev/null +++ b/database/migrations/2026_04_18_0103_create_product_variants_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->string('sku')->nullable(); + $table->string('barcode')->nullable(); + $table->unsignedInteger('price_amount')->default(0); + $table->unsignedInteger('compare_at_amount')->nullable(); + $table->string('currency', 3)->default('USD'); + $table->unsignedInteger('weight_g')->nullable(); + $table->boolean('requires_shipping')->default(true); + $table->boolean('is_default')->default(false); + $table->unsignedInteger('position')->default(0); + $table->string('status')->default('active'); + $table->timestamps(); + + $table->index('product_id', 'idx_product_variants_product_id'); + $table->index('sku', 'idx_product_variants_sku'); + $table->index('barcode', 'idx_product_variants_barcode'); + $table->index(['product_id', 'position'], 'idx_product_variants_product_position'); + $table->index(['product_id', 'is_default'], 'idx_product_variants_product_default'); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_variants'); + } +}; diff --git a/database/migrations/2026_04_18_0104_create_variant_option_values_table.php b/database/migrations/2026_04_18_0104_create_variant_option_values_table.php new file mode 100644 index 00000000..3cc58ff1 --- /dev/null +++ b/database/migrations/2026_04_18_0104_create_variant_option_values_table.php @@ -0,0 +1,24 @@ +foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->foreignId('product_option_value_id')->constrained('product_option_values')->cascadeOnDelete(); + + $table->primary(['variant_id', 'product_option_value_id']); + $table->index('product_option_value_id', 'idx_variant_option_values_value_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('variant_option_values'); + } +}; diff --git a/database/migrations/2026_04_18_0105_create_inventory_items_table.php b/database/migrations/2026_04_18_0105_create_inventory_items_table.php new file mode 100644 index 00000000..a5538d5e --- /dev/null +++ b/database/migrations/2026_04_18_0105_create_inventory_items_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->integer('quantity_on_hand')->default(0); + $table->integer('quantity_reserved')->default(0); + $table->string('policy')->default('deny'); + + $table->unique('variant_id', 'idx_inventory_items_variant_id'); + $table->index('store_id', 'idx_inventory_items_store_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('inventory_items'); + } +}; diff --git a/database/migrations/2026_04_18_0106_create_collections_table.php b/database/migrations/2026_04_18_0106_create_collections_table.php new file mode 100644 index 00000000..c81341f4 --- /dev/null +++ b/database/migrations/2026_04_18_0106_create_collections_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->text('description_html')->nullable(); + $table->string('type')->default('manual'); + $table->string('status')->default('active'); + $table->timestamps(); + + $table->unique(['store_id', 'handle'], 'idx_collections_store_handle'); + $table->index('store_id', 'idx_collections_store_id'); + $table->index(['store_id', 'status'], 'idx_collections_store_status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('collections'); + } +}; diff --git a/database/migrations/2026_04_18_0107_create_collection_products_table.php b/database/migrations/2026_04_18_0107_create_collection_products_table.php new file mode 100644 index 00000000..3b6718b7 --- /dev/null +++ b/database/migrations/2026_04_18_0107_create_collection_products_table.php @@ -0,0 +1,26 @@ +foreignId('collection_id')->constrained('collections')->cascadeOnDelete(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->unsignedInteger('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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('collection_products'); + } +}; diff --git a/database/migrations/2026_04_18_0108_create_product_media_table.php b/database/migrations/2026_04_18_0108_create_product_media_table.php new file mode 100644 index 00000000..d7b7653a --- /dev/null +++ b/database/migrations/2026_04_18_0108_create_product_media_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->string('type')->default('image'); + $table->string('storage_key'); + $table->string('alt_text')->nullable(); + $table->unsignedInteger('width')->nullable(); + $table->unsignedInteger('height')->nullable(); + $table->string('mime_type')->nullable(); + $table->unsignedBigInteger('byte_size')->nullable(); + $table->unsignedInteger('position')->default(0); + $table->string('status')->default('processing'); + $table->timestamp('created_at')->nullable(); + + $table->index('product_id', 'idx_product_media_product_id'); + $table->index(['product_id', 'position'], 'idx_product_media_product_position'); + $table->index('status', 'idx_product_media_status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_media'); + } +}; diff --git a/database/migrations/2026_04_18_0200_create_themes_table.php b/database/migrations/2026_04_18_0200_create_themes_table.php new file mode 100644 index 00000000..d0ce3538 --- /dev/null +++ b/database/migrations/2026_04_18_0200_create_themes_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('name'); + $table->string('version')->nullable(); + $table->string('status')->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->index('store_id', 'idx_themes_store_id'); + $table->index(['store_id', 'status'], 'idx_themes_store_status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('themes'); + } +}; diff --git a/database/migrations/2026_04_18_0201_create_theme_files_table.php b/database/migrations/2026_04_18_0201_create_theme_files_table.php new file mode 100644 index 00000000..aa412a66 --- /dev/null +++ b/database/migrations/2026_04_18_0201_create_theme_files_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('theme_id')->constrained('themes')->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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('theme_files'); + } +}; diff --git a/database/migrations/2026_04_18_0202_create_theme_settings_table.php b/database/migrations/2026_04_18_0202_create_theme_settings_table.php new file mode 100644 index 00000000..0d2e6822 --- /dev/null +++ b/database/migrations/2026_04_18_0202_create_theme_settings_table.php @@ -0,0 +1,22 @@ +foreignId('theme_id')->primary()->constrained('themes')->cascadeOnDelete(); + $table->text('settings_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('theme_settings'); + } +}; diff --git a/database/migrations/2026_04_18_0203_create_pages_table.php b/database/migrations/2026_04_18_0203_create_pages_table.php new file mode 100644 index 00000000..5b7940a8 --- /dev/null +++ b/database/migrations/2026_04_18_0203_create_pages_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->text('body_html')->nullable(); + $table->string('status')->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'handle'], 'idx_pages_store_handle'); + $table->index('store_id', 'idx_pages_store_id'); + $table->index(['store_id', 'status'], 'idx_pages_store_status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('pages'); + } +}; diff --git a/database/migrations/2026_04_18_0204_create_navigation_menus_table.php b/database/migrations/2026_04_18_0204_create_navigation_menus_table.php new file mode 100644 index 00000000..f2e4c5a0 --- /dev/null +++ b/database/migrations/2026_04_18_0204_create_navigation_menus_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('navigation_menus'); + } +}; diff --git a/database/migrations/2026_04_18_0205_create_navigation_items_table.php b/database/migrations/2026_04_18_0205_create_navigation_items_table.php new file mode 100644 index 00000000..1410dc5e --- /dev/null +++ b/database/migrations/2026_04_18_0205_create_navigation_items_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('menu_id')->constrained('navigation_menus')->cascadeOnDelete(); + $table->string('type')->default('link'); + $table->string('label'); + $table->string('url')->nullable(); + $table->unsignedBigInteger('resource_id')->nullable(); + $table->integer('position')->default(0); + + $table->index('menu_id', 'idx_navigation_items_menu_id'); + $table->index(['menu_id', 'position'], 'idx_navigation_items_menu_position'); + }); + } + + public function down(): void + { + Schema::dropIfExists('navigation_items'); + } +}; diff --git a/database/migrations/2026_04_18_0400_create_carts_table.php b/database/migrations/2026_04_18_0400_create_carts_table.php new file mode 100644 index 00000000..b67a96dc --- /dev/null +++ b/database/migrations/2026_04_18_0400_create_carts_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained('customers')->nullOnDelete(); + $table->string('currency', 3)->default('USD'); + $table->integer('cart_version')->default(1); + $table->string('status')->default('active'); + $table->timestamps(); + + $table->index('store_id', 'idx_carts_store_id'); + $table->index('customer_id', 'idx_carts_customer_id'); + $table->index(['store_id', 'status'], 'idx_carts_store_status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('carts'); + } +}; diff --git a/database/migrations/2026_04_18_0401_create_cart_lines_table.php b/database/migrations/2026_04_18_0401_create_cart_lines_table.php new file mode 100644 index 00000000..9fe4886f --- /dev/null +++ b/database/migrations/2026_04_18_0401_create_cart_lines_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('cart_id')->constrained('carts')->cascadeOnDelete(); + $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->integer('quantity')->default(1); + $table->integer('unit_price_amount')->default(0); + $table->integer('line_subtotal_amount')->default(0); + $table->integer('line_discount_amount')->default(0); + $table->integer('line_total_amount')->default(0); + + $table->index('cart_id', 'idx_cart_lines_cart_id'); + $table->unique(['cart_id', 'variant_id'], 'idx_cart_lines_cart_variant'); + }); + } + + public function down(): void + { + Schema::dropIfExists('cart_lines'); + } +}; diff --git a/database/migrations/2026_04_18_0402_create_checkouts_table.php b/database/migrations/2026_04_18_0402_create_checkouts_table.php new file mode 100644 index 00000000..fff3f37a --- /dev/null +++ b/database/migrations/2026_04_18_0402_create_checkouts_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('cart_id')->constrained('carts')->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained('customers')->nullOnDelete(); + $table->string('status')->default('started'); + $table->string('payment_method')->nullable(); + $table->string('email')->nullable(); + $table->text('shipping_address_json')->nullable(); + $table->text('billing_address_json')->nullable(); + $table->unsignedBigInteger('shipping_method_id')->nullable(); + $table->string('discount_code')->nullable(); + $table->text('tax_provider_snapshot_json')->nullable(); + $table->text('totals_json')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + + $table->index('store_id', 'idx_checkouts_store_id'); + $table->index('cart_id', 'idx_checkouts_cart_id'); + $table->index('customer_id', 'idx_checkouts_customer_id'); + $table->index(['store_id', 'status'], 'idx_checkouts_status'); + $table->index('expires_at', 'idx_checkouts_expires_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('checkouts'); + } +}; diff --git a/database/migrations/2026_04_18_0403_create_shipping_zones_table.php b/database/migrations/2026_04_18_0403_create_shipping_zones_table.php new file mode 100644 index 00000000..63c9571b --- /dev/null +++ b/database/migrations/2026_04_18_0403_create_shipping_zones_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('name'); + $table->text('countries_json')->default('[]'); + $table->text('regions_json')->default('[]'); + + $table->index('store_id', 'idx_shipping_zones_store_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('shipping_zones'); + } +}; diff --git a/database/migrations/2026_04_18_0404_create_shipping_rates_table.php b/database/migrations/2026_04_18_0404_create_shipping_rates_table.php new file mode 100644 index 00000000..3ecd5cad --- /dev/null +++ b/database/migrations/2026_04_18_0404_create_shipping_rates_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('zone_id')->constrained('shipping_zones')->cascadeOnDelete(); + $table->string('name'); + $table->string('type')->default('flat'); + $table->text('config_json')->default('{}'); + $table->boolean('is_active')->default(true); + + $table->index('zone_id', 'idx_shipping_rates_zone_id'); + $table->index(['zone_id', 'is_active'], 'idx_shipping_rates_zone_active'); + }); + } + + public function down(): void + { + Schema::dropIfExists('shipping_rates'); + } +}; diff --git a/database/migrations/2026_04_18_0405_create_tax_settings_table.php b/database/migrations/2026_04_18_0405_create_tax_settings_table.php new file mode 100644 index 00000000..00fac11e --- /dev/null +++ b/database/migrations/2026_04_18_0405_create_tax_settings_table.php @@ -0,0 +1,24 @@ +foreignId('store_id')->primary()->constrained('stores')->cascadeOnDelete(); + $table->string('mode')->default('manual'); + $table->string('provider')->default('none'); + $table->boolean('prices_include_tax')->default(false); + $table->text('config_json')->default('{}'); + }); + } + + public function down(): void + { + Schema::dropIfExists('tax_settings'); + } +}; diff --git a/database/migrations/2026_04_18_0406_create_discounts_table.php b/database/migrations/2026_04_18_0406_create_discounts_table.php new file mode 100644 index 00000000..7728fc64 --- /dev/null +++ b/database/migrations/2026_04_18_0406_create_discounts_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('type')->default('code'); + $table->string('code')->nullable(); + $table->string('value_type'); + $table->integer('value_amount')->default(0); + $table->timestamp('starts_at'); + $table->timestamp('ends_at')->nullable(); + $table->integer('usage_limit')->nullable(); + $table->integer('usage_count')->default(0); + $table->text('rules_json')->default('{}'); + $table->string('status')->default('active'); + $table->timestamps(); + + $table->unique(['store_id', 'code'], 'idx_discounts_store_code'); + $table->index('store_id', 'idx_discounts_store_id'); + $table->index(['store_id', 'status'], 'idx_discounts_store_status'); + $table->index(['store_id', 'type'], 'idx_discounts_store_type'); + }); + } + + public function down(): void + { + Schema::dropIfExists('discounts'); + } +}; diff --git a/database/migrations/2026_04_18_0500_create_customer_addresses_table.php b/database/migrations/2026_04_18_0500_create_customer_addresses_table.php new file mode 100644 index 00000000..0db05075 --- /dev/null +++ b/database/migrations/2026_04_18_0500_create_customer_addresses_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('customer_id')->constrained('customers')->cascadeOnDelete(); + $table->string('label')->nullable(); + $table->text('address_json')->default('{}'); + $table->boolean('is_default')->default(false); + + $table->index('customer_id', 'idx_customer_addresses_customer_id'); + $table->index(['customer_id', 'is_default'], 'idx_customer_addresses_default'); + }); + } + + public function down(): void + { + Schema::dropIfExists('customer_addresses'); + } +}; diff --git a/database/migrations/2026_04_18_0501_create_orders_table.php b/database/migrations/2026_04_18_0501_create_orders_table.php new file mode 100644 index 00000000..2349af9d --- /dev/null +++ b/database/migrations/2026_04_18_0501_create_orders_table.php @@ -0,0 +1,47 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained('customers')->nullOnDelete(); + $table->foreignId('checkout_id')->nullable()->constrained('checkouts')->nullOnDelete(); + $table->string('order_number'); + $table->string('payment_method'); + $table->string('status')->default('pending'); + $table->string('financial_status')->default('pending'); + $table->string('fulfillment_status')->default('unfulfilled'); + $table->string('currency', 3)->default('USD'); + $table->integer('subtotal_amount')->default(0); + $table->integer('discount_amount')->default(0); + $table->integer('shipping_amount')->default(0); + $table->integer('tax_amount')->default(0); + $table->integer('total_amount')->default(0); + $table->string('email')->nullable(); + $table->text('billing_address_json')->nullable(); + $table->text('shipping_address_json')->nullable(); + $table->timestamp('placed_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'order_number'], 'idx_orders_store_order_number'); + $table->index('store_id', 'idx_orders_store_id'); + $table->index('customer_id', 'idx_orders_customer_id'); + $table->index(['store_id', 'status'], 'idx_orders_store_status'); + $table->index(['store_id', 'financial_status'], 'idx_orders_store_financial'); + $table->index(['store_id', 'fulfillment_status'], 'idx_orders_store_fulfillment'); + $table->index(['store_id', 'placed_at'], 'idx_orders_placed_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('orders'); + } +}; diff --git a/database/migrations/2026_04_18_0502_create_order_lines_table.php b/database/migrations/2026_04_18_0502_create_order_lines_table.php new file mode 100644 index 00000000..26086f57 --- /dev/null +++ b/database/migrations/2026_04_18_0502_create_order_lines_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('order_id')->constrained('orders')->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained('products')->nullOnDelete(); + $table->foreignId('variant_id')->nullable()->constrained('product_variants')->nullOnDelete(); + $table->string('title_snapshot'); + $table->string('sku_snapshot')->nullable(); + $table->integer('quantity')->default(1); + $table->integer('unit_price_amount')->default(0); + $table->integer('total_amount')->default(0); + $table->text('tax_lines_json')->default('[]'); + $table->text('discount_allocations_json')->default('[]'); + + $table->index('order_id', 'idx_order_lines_order_id'); + $table->index('product_id', 'idx_order_lines_product_id'); + $table->index('variant_id', 'idx_order_lines_variant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('order_lines'); + } +}; diff --git a/database/migrations/2026_04_18_0503_create_payments_table.php b/database/migrations/2026_04_18_0503_create_payments_table.php new file mode 100644 index 00000000..366f0d38 --- /dev/null +++ b/database/migrations/2026_04_18_0503_create_payments_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('order_id')->constrained('orders')->cascadeOnDelete(); + $table->string('provider')->default('mock'); + $table->string('method'); + $table->string('provider_payment_id')->nullable(); + $table->string('status')->default('pending'); + $table->integer('amount')->default(0); + $table->string('currency', 3)->default('USD'); + $table->text('raw_json_encrypted')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('order_id', 'idx_payments_order_id'); + $table->index(['provider', 'provider_payment_id'], 'idx_payments_provider_id'); + $table->index('method', 'idx_payments_method'); + $table->index('status', 'idx_payments_status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('payments'); + } +}; diff --git a/database/migrations/2026_04_18_0504_create_refunds_table.php b/database/migrations/2026_04_18_0504_create_refunds_table.php new file mode 100644 index 00000000..284596a3 --- /dev/null +++ b/database/migrations/2026_04_18_0504_create_refunds_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('order_id')->constrained('orders')->cascadeOnDelete(); + $table->foreignId('payment_id')->constrained('payments')->cascadeOnDelete(); + $table->integer('amount')->default(0); + $table->string('reason')->nullable(); + $table->string('status')->default('pending'); + $table->string('provider_refund_id')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('order_id', 'idx_refunds_order_id'); + $table->index('payment_id', 'idx_refunds_payment_id'); + $table->index('status', 'idx_refunds_status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('refunds'); + } +}; diff --git a/database/migrations/2026_04_18_0505_create_fulfillments_table.php b/database/migrations/2026_04_18_0505_create_fulfillments_table.php new file mode 100644 index 00000000..f69c294c --- /dev/null +++ b/database/migrations/2026_04_18_0505_create_fulfillments_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('order_id')->constrained('orders')->cascadeOnDelete(); + $table->string('status')->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('order_id', 'idx_fulfillments_order_id'); + $table->index('status', 'idx_fulfillments_status'); + $table->index(['tracking_company', 'tracking_number'], 'idx_fulfillments_tracking'); + }); + } + + public function down(): void + { + Schema::dropIfExists('fulfillments'); + } +}; diff --git a/database/migrations/2026_04_18_0506_create_fulfillment_lines_table.php b/database/migrations/2026_04_18_0506_create_fulfillment_lines_table.php new file mode 100644 index 00000000..1cf1072d --- /dev/null +++ b/database/migrations/2026_04_18_0506_create_fulfillment_lines_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('fulfillment_id')->constrained('fulfillments')->cascadeOnDelete(); + $table->foreignId('order_line_id')->constrained('order_lines')->cascadeOnDelete(); + $table->integer('quantity')->default(1); + + $table->index('fulfillment_id', 'idx_fulfillment_lines_fulfillment_id'); + $table->unique(['fulfillment_id', 'order_line_id'], 'idx_fulfillment_lines_fulfillment_order_line'); + }); + } + + public function down(): void + { + Schema::dropIfExists('fulfillment_lines'); + } +}; diff --git a/database/migrations/2026_04_18_071939_create_personal_access_tokens_table.php b/database/migrations/2026_04_18_071939_create_personal_access_tokens_table.php new file mode 100644 index 00000000..40ff706e --- /dev/null +++ b/database/migrations/2026_04_18_071939_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_04_18_0800_create_search_settings_table.php b/database/migrations/2026_04_18_0800_create_search_settings_table.php new file mode 100644 index 00000000..5b2d6ec3 --- /dev/null +++ b/database/migrations/2026_04_18_0800_create_search_settings_table.php @@ -0,0 +1,23 @@ +foreignId('store_id')->primary()->constrained('stores')->cascadeOnDelete(); + $table->text('synonyms_json')->default('[]'); + $table->text('stop_words_json')->default('[]'); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('search_settings'); + } +}; diff --git a/database/migrations/2026_04_18_0801_create_search_queries_table.php b/database/migrations/2026_04_18_0801_create_search_queries_table.php new file mode 100644 index 00000000..647b2f33 --- /dev/null +++ b/database/migrations/2026_04_18_0801_create_search_queries_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('query'); + $table->text('filters_json')->nullable(); + $table->unsignedInteger('results_count')->default(0); + $table->timestamp('created_at')->nullable(); + + $table->index('store_id', 'idx_search_queries_store_id'); + $table->index(['store_id', 'created_at'], 'idx_search_queries_store_created'); + $table->index(['store_id', 'query'], 'idx_search_queries_store_query'); + }); + } + + public function down(): void + { + Schema::dropIfExists('search_queries'); + } +}; diff --git a/database/migrations/2026_04_18_0802_create_products_fts_table.php b/database/migrations/2026_04_18_0802_create_products_fts_table.php new file mode 100644 index 00000000..d3468e85 --- /dev/null +++ b/database/migrations/2026_04_18_0802_create_products_fts_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('type'); + $table->string('session_id')->nullable(); + $table->foreignId('customer_id')->nullable()->constrained('customers')->nullOnDelete(); + $table->text('properties_json')->default('{}'); + $table->string('client_event_id')->nullable(); + $table->timestamp('occurred_at')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('store_id', 'idx_analytics_events_store_id'); + $table->index(['store_id', 'type'], 'idx_analytics_events_store_type'); + $table->index(['store_id', 'created_at'], 'idx_analytics_events_store_created'); + $table->index('session_id', 'idx_analytics_events_session'); + $table->index('customer_id', 'idx_analytics_events_customer'); + $table->unique(['store_id', 'client_event_id'], 'idx_analytics_events_client_event'); + }); + } + + public function down(): void + { + Schema::dropIfExists('analytics_events'); + } +}; diff --git a/database/migrations/2026_04_18_0901_create_analytics_daily_table.php b/database/migrations/2026_04_18_0901_create_analytics_daily_table.php new file mode 100644 index 00000000..e2d0c78f --- /dev/null +++ b/database/migrations/2026_04_18_0901_create_analytics_daily_table.php @@ -0,0 +1,31 @@ +foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('date', 10); + $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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('analytics_daily'); + } +}; diff --git a/database/migrations/2026_04_18_1000_create_apps_table.php b/database/migrations/2026_04_18_1000_create_apps_table.php new file mode 100644 index 00000000..d1730928 --- /dev/null +++ b/database/migrations/2026_04_18_1000_create_apps_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('name'); + $table->string('status')->default('active'); + $table->timestamp('created_at')->nullable(); + + $table->index('status', 'idx_apps_status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('apps'); + } +}; diff --git a/database/migrations/2026_04_18_1001_create_app_installations_table.php b/database/migrations/2026_04_18_1001_create_app_installations_table.php new file mode 100644 index 00000000..c2e50725 --- /dev/null +++ b/database/migrations/2026_04_18_1001_create_app_installations_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('app_id')->constrained('apps')->cascadeOnDelete(); + $table->text('scopes_json')->default('[]'); + $table->string('status')->default('active'); + $table->timestamp('installed_at')->nullable(); + + $table->unique(['store_id', 'app_id'], 'idx_app_installations_store_app'); + $table->index('store_id', 'idx_app_installations_store_id'); + $table->index('app_id', 'idx_app_installations_app_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('app_installations'); + } +}; diff --git a/database/migrations/2026_04_18_1002_create_oauth_clients_table.php b/database/migrations/2026_04_18_1002_create_oauth_clients_table.php new file mode 100644 index 00000000..482f6b6d --- /dev/null +++ b/database/migrations/2026_04_18_1002_create_oauth_clients_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('app_id')->constrained('apps')->cascadeOnDelete(); + $table->string('client_id'); + $table->text('client_secret_encrypted'); + $table->text('redirect_uris_json')->default('[]'); + + $table->unique('client_id', 'idx_oauth_clients_client_id'); + $table->index('app_id', 'idx_oauth_clients_app_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('oauth_clients'); + } +}; diff --git a/database/migrations/2026_04_18_1003_create_oauth_tokens_table.php b/database/migrations/2026_04_18_1003_create_oauth_tokens_table.php new file mode 100644 index 00000000..551824e6 --- /dev/null +++ b/database/migrations/2026_04_18_1003_create_oauth_tokens_table.php @@ -0,0 +1,28 @@ +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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('oauth_tokens'); + } +}; diff --git a/database/migrations/2026_04_18_1004_create_webhook_subscriptions_table.php b/database/migrations/2026_04_18_1004_create_webhook_subscriptions_table.php new file mode 100644 index 00000000..1250e62a --- /dev/null +++ b/database/migrations/2026_04_18_1004_create_webhook_subscriptions_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('app_installation_id')->nullable()->constrained('app_installations')->cascadeOnDelete(); + $table->string('event_type'); + $table->string('target_url'); + $table->text('signing_secret_encrypted'); + $table->string('status')->default('active'); + $table->integer('consecutive_failures')->default(0); + + $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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('webhook_subscriptions'); + } +}; diff --git a/database/migrations/2026_04_18_1005_create_webhook_deliveries_table.php b/database/migrations/2026_04_18_1005_create_webhook_deliveries_table.php new file mode 100644 index 00000000..099dfffa --- /dev/null +++ b/database/migrations/2026_04_18_1005_create_webhook_deliveries_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('subscription_id')->constrained('webhook_subscriptions')->cascadeOnDelete(); + $table->string('event_id'); + $table->integer('attempt_count')->default(1); + $table->string('status')->default('pending'); + $table->timestamp('last_attempt_at')->nullable(); + $table->integer('response_code')->nullable(); + $table->text('response_body_snippet')->nullable(); + $table->timestamps(); + + $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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('webhook_deliveries'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef2..46426a56 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,22 +2,12 @@ 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(DemoSeeder::class); } } diff --git a/database/seeders/DemoSeeder.php b/database/seeders/DemoSeeder.php new file mode 100644 index 00000000..d37ec09a --- /dev/null +++ b/database/seeders/DemoSeeder.php @@ -0,0 +1,347 @@ +create([ + 'name' => 'Acme Holdings', + 'billing_email' => 'billing@acme.test', + ]); + + $store = Store::query()->create([ + 'organization_id' => $organization->id, + 'name' => 'Acme Fashion', + 'handle' => 'acme-fashion', + 'status' => StoreStatus::Active, + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ]); + + StoreDomain::query()->create([ + 'store_id' => $store->id, + 'hostname' => 'shop.test', + 'type' => StoreDomainType::Storefront, + 'is_primary' => true, + 'tls_mode' => 'managed', + 'created_at' => now(), + ]); + + StoreSettings::query()->create([ + 'store_id' => $store->id, + 'settings_json' => [ + 'announcement_bar' => 'Free shipping on orders over 50', + 'support_email' => 'support@acme.test', + ], + 'updated_at' => now(), + ]); + + $owner = User::query()->create([ + 'name' => 'Ada Owner', + 'email' => 'owner@acme.test', + 'password' => Hash::make('password'), + 'email_verified_at' => now(), + ]); + + \DB::table('store_users')->insert([ + 'store_id' => $store->id, + 'user_id' => $owner->id, + 'role' => StoreUserRole::Owner->value, + 'created_at' => now(), + ]); + + app()->instance('current_store', $store); + + Customer::query()->create([ + 'store_id' => $store->id, + 'email' => 'buyer@example.com', + 'password' => Hash::make('password'), + 'first_name' => 'Billy', + 'last_name' => 'Buyer', + 'state' => 'active', + 'email_verified_at' => now(), + ]); + + $this->seedTheming($store); + $this->seedCatalog($store); + $this->seedShippingAndTaxes($store); + + app()->forgetInstance('current_store'); + } + + protected function seedShippingAndTaxes(Store $store): void + { + $zone = \App\Models\ShippingZone::query()->create([ + 'store_id' => $store->id, + 'name' => 'Germany', + 'countries_json' => ['DE'], + 'regions_json' => [], + ]); + + \App\Models\ShippingRate::query()->create([ + 'zone_id' => $zone->id, + 'name' => 'Standard', + 'type' => 'flat', + 'config_json' => ['amount' => 499], + 'is_active' => true, + ]); + + $rowZone = \App\Models\ShippingZone::query()->create([ + 'store_id' => $store->id, + 'name' => 'Rest of World', + 'countries_json' => ['US', 'GB', 'FR', 'AT', 'CH', 'IT', 'ES', 'NL'], + 'regions_json' => [], + ]); + + \App\Models\ShippingRate::query()->create([ + 'zone_id' => $rowZone->id, + 'name' => 'International', + 'type' => 'flat', + 'config_json' => ['amount' => 1499], + 'is_active' => true, + ]); + + $existing = \App\Models\TaxSettings::query()->where('store_id', $store->id)->first(); + if (! $existing) { + $settings = new \App\Models\TaxSettings([ + 'store_id' => $store->id, + 'mode' => 'manual', + 'provider' => 'manual', + 'prices_include_tax' => false, + 'config_json' => [ + 'default_rate_bps' => 1900, + 'default_rate_name' => 'VAT', + ], + ]); + $settings->setAttribute('store_id', $store->id); + $settings->save(); + } + } + + protected function seedTheming(Store $store): void + { + $theme = Theme::query()->create([ + 'store_id' => $store->id, + 'name' => 'Default', + 'version' => '1.0.0', + 'status' => ThemeStatus::Published, + 'published_at' => now(), + ]); + + ThemeSettings::query()->create([ + 'theme_id' => $theme->id, + 'settings_json' => [ + 'hero' => [ + 'heading' => 'Elevated essentials', + 'subheading' => 'Timeless pieces for modern wardrobes.', + 'cta_label' => 'Shop the collection', + 'cta_url' => '/collections', + ], + 'featured_collection_handles' => ['featured'], + 'featured_product_handles' => [], + 'colors' => [ + 'primary' => '#111827', + 'accent' => '#4f46e5', + ], + 'dark_mode' => 'system', + ], + 'updated_at' => now(), + ]); + + Page::query()->create([ + 'store_id' => $store->id, + 'title' => 'About Us', + 'handle' => 'about', + 'body_html' => '

Acme Fashion is a demo store created to showcase the platform.

', + 'status' => PageStatus::Published, + 'published_at' => now(), + ]); + + $menu = NavigationMenu::query()->create([ + 'store_id' => $store->id, + 'handle' => 'main-menu', + 'title' => 'Main menu', + ]); + + $mainItems = [ + ['label' => 'Home', 'url' => '/'], + ['label' => 'Collections', 'url' => '/collections'], + ['label' => 'About', 'url' => '/pages/about'], + ]; + + foreach ($mainItems as $position => $item) { + NavigationItem::query()->create([ + 'menu_id' => $menu->id, + 'type' => NavigationItemType::Link, + 'label' => $item['label'], + 'url' => $item['url'], + 'position' => $position, + ]); + } + } + + protected function seedCatalog(Store $store): void + { + $tshirt = Product::query()->create([ + 'store_id' => $store->id, + 'title' => 'Organic Cotton T-Shirt', + 'handle' => 'organic-cotton-t-shirt', + 'status' => ProductStatus::Active, + 'description_html' => '

Soft, breathable, sustainably sourced.

', + 'vendor' => 'Acme Apparel', + 'product_type' => 'Apparel', + 'tags' => ['summer', 'bestseller'], + 'published_at' => now(), + ]); + + $sizeOption = ProductOption::query()->create([ + 'product_id' => $tshirt->id, + 'name' => 'Size', + 'position' => 0, + ]); + + $colorOption = ProductOption::query()->create([ + 'product_id' => $tshirt->id, + 'name' => 'Color', + 'position' => 1, + ]); + + $sizeValues = []; + foreach (['S', 'M', 'L'] as $idx => $size) { + $sizeValues[] = ProductOptionValue::query()->create([ + 'product_option_id' => $sizeOption->id, + 'value' => $size, + 'position' => $idx, + ]); + } + + $colorValues = []; + foreach (['Black', 'White'] as $idx => $color) { + $colorValues[] = ProductOptionValue::query()->create([ + 'product_option_id' => $colorOption->id, + 'value' => $color, + 'position' => $idx, + ]); + } + + $position = 0; + foreach ($sizeValues as $sizeIdx => $sizeValue) { + foreach ($colorValues as $colorIdx => $colorValue) { + $variant = ProductVariant::query()->create([ + 'product_id' => $tshirt->id, + 'sku' => 'TSH-'.$sizeValue->value.'-'.strtoupper(substr($colorValue->value, 0, 3)), + 'price_amount' => 2500, + 'currency' => 'EUR', + 'is_default' => $sizeIdx === 0 && $colorIdx === 0, + 'position' => $position++, + 'status' => VariantStatus::Active, + ]); + + $variant->optionValues()->sync([$sizeValue->id, $colorValue->id]); + + InventoryItem::query()->create([ + 'store_id' => $store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 50, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ]); + } + } + + ProductMedia::query()->create([ + 'product_id' => $tshirt->id, + 'type' => MediaType::Image, + 'storage_key' => 'products/tshirt-front.jpg', + 'alt_text' => 'Organic Cotton T-Shirt front', + 'width' => 1200, + 'height' => 1200, + 'mime_type' => 'image/jpeg', + 'byte_size' => 180_000, + 'position' => 0, + 'status' => MediaStatus::Ready, + 'created_at' => now(), + ]); + + $hoodie = Product::query()->create([ + 'store_id' => $store->id, + 'title' => 'Classic Pullover Hoodie', + 'handle' => 'classic-pullover-hoodie', + 'status' => ProductStatus::Active, + 'description_html' => '

Midweight fleece, relaxed fit.

', + 'vendor' => 'Acme Apparel', + 'product_type' => 'Apparel', + 'tags' => ['fall'], + 'published_at' => now(), + ]); + + $hoodieVariant = ProductVariant::query()->create([ + 'product_id' => $hoodie->id, + 'sku' => 'HD-DEFAULT', + 'price_amount' => 6000, + 'currency' => 'EUR', + 'is_default' => true, + 'position' => 0, + 'status' => VariantStatus::Active, + ]); + + InventoryItem::query()->create([ + 'store_id' => $store->id, + 'variant_id' => $hoodieVariant->id, + 'quantity_on_hand' => 30, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ]); + + $featured = Collection::query()->create([ + 'store_id' => $store->id, + 'title' => 'Featured', + 'handle' => 'featured', + 'description_html' => '

Our picks for the season.

', + 'type' => CollectionType::Manual, + 'status' => CollectionStatus::Active, + ]); + + $featured->products()->attach([ + $tshirt->id => ['position' => 0], + $hoodie->id => ['position' => 1], + ]); + } +} diff --git a/opencode.json b/opencode.json new file mode 100644 index 00000000..9ccd8787 --- /dev/null +++ b/opencode.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "laravel-boost": { + "type": "local", + "enabled": true, + "command": [ + "php", + "artisan", + "boost:mcp" + ] + }, + "playwright": { + "type": "local", + "command": [ + "npx", + "@playwright/mcp@latest" + ], + "enabled": true + } + } +} diff --git a/resources/views/components/action-message.blade.php b/resources/views/components/action-message.blade.php deleted file mode 100644 index d313ee61..00000000 --- a/resources/views/components/action-message.blade.php +++ /dev/null @@ -1,14 +0,0 @@ -@props([ - 'on', -]) - -
merge(['class' => 'text-sm']) }} -> - {{ $slot->isEmpty() ? __('Saved.') : $slot }} -
diff --git a/resources/views/components/admin/sidebar.blade.php b/resources/views/components/admin/sidebar.blade.php new file mode 100644 index 00000000..9f9e76b1 --- /dev/null +++ b/resources/views/components/admin/sidebar.blade.php @@ -0,0 +1,38 @@ +@php + $link = function (string $name, string $label) { + if (! \Illuminate\Support\Facades\Route::has($name)) { + return ''; + } + + $active = request()->routeIs($name) || request()->routeIs(str_replace('.index', '.*', $name)); + $classes = 'block rounded-md px-3 py-2 hover:bg-zinc-100 dark:hover:bg-zinc-800'; + if ($active) { + $classes .= ' bg-zinc-100 font-medium dark:bg-zinc-800'; + } + + return ''.e($label).''; + }; +@endphp + diff --git a/resources/views/components/admin/topbar.blade.php b/resources/views/components/admin/topbar.blade.php new file mode 100644 index 00000000..151f39de --- /dev/null +++ b/resources/views/components/admin/topbar.blade.php @@ -0,0 +1,14 @@ +
+
+ Admin +
+
+ @auth + {{ auth()->user()->email }} +
+ @csrf + Sign out +
+ @endauth +
+
diff --git a/resources/views/components/app-logo-icon.blade.php b/resources/views/components/app-logo-icon.blade.php deleted file mode 100644 index 0adc3a2a..00000000 --- a/resources/views/components/app-logo-icon.blade.php +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/resources/views/components/app-logo.blade.php b/resources/views/components/app-logo.blade.php deleted file mode 100644 index 26e8f686..00000000 --- a/resources/views/components/app-logo.blade.php +++ /dev/null @@ -1,17 +0,0 @@ -@props([ - 'sidebar' => false, -]) - -@if($sidebar) - - - - - -@else - - - - - -@endif diff --git a/resources/views/components/auth-header.blade.php b/resources/views/components/auth-header.blade.php deleted file mode 100644 index e596a3f3..00000000 --- a/resources/views/components/auth-header.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -@props([ - 'title', - 'description', -]) - -
- {{ $title }} - {{ $description }} -
diff --git a/resources/views/components/auth-session-status.blade.php b/resources/views/components/auth-session-status.blade.php deleted file mode 100644 index 98e00112..00000000 --- a/resources/views/components/auth-session-status.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -@props([ - 'status', -]) - -@if ($status) -
merge(['class' => 'font-medium text-sm text-green-600']) }}> - {{ $status }} -
-@endif diff --git a/resources/views/components/desktop-user-menu.blade.php b/resources/views/components/desktop-user-menu.blade.php deleted file mode 100644 index 5b386c5c..00000000 --- a/resources/views/components/desktop-user-menu.blade.php +++ /dev/null @@ -1,39 +0,0 @@ - - only('name') }} - :initials="auth()->user()->initials()" - icon:trailing="chevrons-up-down" - data-test="sidebar-menu-button" - /> - - -
- -
- {{ auth()->user()->name }} - {{ auth()->user()->email }} -
-
- - - - {{ __('Settings') }} - -
- @csrf - - {{ __('Log Out') }} - -
-
-
-
diff --git a/resources/views/components/layouts/admin.blade.php b/resources/views/components/layouts/admin.blade.php new file mode 100644 index 00000000..31ea8eb2 --- /dev/null +++ b/resources/views/components/layouts/admin.blade.php @@ -0,0 +1,27 @@ +@props(['title' => null]) + + + + + + + + + {{ $title ?? 'Admin' }} | {{ config('app.name') }} + + @vite(['resources/css/app.css', 'resources/js/app.js']) + @fluxAppearance + + +
+ +
+ +
+ {{ $slot }} +
+
+
+ @fluxScripts + + diff --git a/resources/views/components/layouts/auth.blade.php b/resources/views/components/layouts/auth.blade.php new file mode 100644 index 00000000..cc8dce49 --- /dev/null +++ b/resources/views/components/layouts/auth.blade.php @@ -0,0 +1,19 @@ +@props(['title' => null]) + + + + + + + + + {{ $title ?? 'Admin' }} | {{ config('app.name') }} + + @vite(['resources/css/app.css', 'resources/js/app.js']) + @fluxAppearance + + + {{ $slot }} + @fluxScripts + + diff --git a/resources/views/components/layouts/storefront.blade.php b/resources/views/components/layouts/storefront.blade.php new file mode 100644 index 00000000..e5c3df4c --- /dev/null +++ b/resources/views/components/layouts/storefront.blade.php @@ -0,0 +1,33 @@ +@props(['title' => null]) + + + + + + + + + {{ $title ?? (app()->bound('current_store') ? app('current_store')->name : config('app.name')) }} + + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + @fluxAppearance + + + Skip to content + + + + +
+ {{ $slot }} +
+ + + + + @fluxScripts + + diff --git a/resources/views/components/placeholder-pattern.blade.php b/resources/views/components/placeholder-pattern.blade.php deleted file mode 100644 index 8a434f04..00000000 --- a/resources/views/components/placeholder-pattern.blade.php +++ /dev/null @@ -1,12 +0,0 @@ -@props([ - 'id' => uniqid(), -]) - - - - - - - - - diff --git a/resources/views/components/settings/layout.blade.php b/resources/views/components/settings/layout.blade.php deleted file mode 100644 index 17c7a0a8..00000000 --- a/resources/views/components/settings/layout.blade.php +++ /dev/null @@ -1,23 +0,0 @@ -
-
- - {{ __('Profile') }} - {{ __('Password') }} - @if (Laravel\Fortify\Features::canManageTwoFactorAuthentication()) - {{ __('Two-Factor Auth') }} - @endif - {{ __('Appearance') }} - -
- - - -
- {{ $heading ?? '' }} - {{ $subheading ?? '' }} - -
- {{ $slot }} -
-
-
diff --git a/resources/views/components/storefront/address-form.blade.php b/resources/views/components/storefront/address-form.blade.php new file mode 100644 index 00000000..2250382c --- /dev/null +++ b/resources/views/components/storefront/address-form.blade.php @@ -0,0 +1,61 @@ +@props([ + 'prefix' => 'address', + 'values' => [], +]) + +@php + $values = is_array($values) ? $values : []; + $get = fn (string $key, mixed $default = '') => $values[$key] ?? $default; +@endphp + +
class(['grid gap-4 sm:grid-cols-2']) }}> + + First name + + + + + Last name + + + + + Company (optional) + + + + + Address line 1 + + + + + Address line 2 (optional) + + + + + City + + + + + Postal code + + + + + Region / State + + + + + Country + + + + + Phone (optional) + + +
diff --git a/resources/views/components/storefront/announcement-bar.blade.php b/resources/views/components/storefront/announcement-bar.blade.php new file mode 100644 index 00000000..6d720efa --- /dev/null +++ b/resources/views/components/storefront/announcement-bar.blade.php @@ -0,0 +1,9 @@ +@php + $settings = app('current_store')->settings?->settings_json ?? []; + $text = $settings['announcement_bar'] ?? null; +@endphp +@if ($text) +
+ {{ $text }} +
+@endif diff --git a/resources/views/components/storefront/badge.blade.php b/resources/views/components/storefront/badge.blade.php new file mode 100644 index 00000000..001df469 --- /dev/null +++ b/resources/views/components/storefront/badge.blade.php @@ -0,0 +1,18 @@ +@props([ + 'variant' => 'neutral', +]) + +@php + $variants = [ + 'neutral' => 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200', + 'success' => 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', + 'warning' => 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', + 'danger' => 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300', + 'accent' => 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300', + ]; + $classes = $variants[$variant] ?? $variants['neutral']; +@endphp + +class(['inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium', $classes]) }}> + {{ $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..ef2d8b1b --- /dev/null +++ b/resources/views/components/storefront/breadcrumbs.blade.php @@ -0,0 +1,27 @@ +@props([ + 'items' => [], +]) + +@if (! empty($items)) + +@endif diff --git a/resources/views/components/storefront/footer.blade.php b/resources/views/components/storefront/footer.blade.php new file mode 100644 index 00000000..af1af2f6 --- /dev/null +++ b/resources/views/components/storefront/footer.blade.php @@ -0,0 +1,5 @@ +
+
+ © {{ date('Y') }} {{ app('current_store')->name }}. All rights reserved. +
+
diff --git a/resources/views/components/storefront/header.blade.php b/resources/views/components/storefront/header.blade.php new file mode 100644 index 00000000..63d7f11a --- /dev/null +++ b/resources/views/components/storefront/header.blade.php @@ -0,0 +1,26 @@ +@php + $store = app('current_store'); +@endphp +
+
+ {{ $store->name }} + +
+
diff --git a/resources/views/components/storefront/order-summary.blade.php b/resources/views/components/storefront/order-summary.blade.php new file mode 100644 index 00000000..a078b931 --- /dev/null +++ b/resources/views/components/storefront/order-summary.blade.php @@ -0,0 +1,38 @@ +@props([ + 'subtotal' => 0, + 'shipping' => 0, + 'tax' => 0, + 'discount' => 0, + 'total' => null, + 'currency' => null, +]) + +@php + $currency = $currency ?? (app()->bound('current_store') ? app('current_store')->default_currency : 'USD'); + $total = $total ?? (int) $subtotal + (int) $shipping + (int) $tax - (int) $discount; +@endphp + +
class(['space-y-2 rounded-lg border border-zinc-200 bg-white p-4 text-sm dark:border-zinc-800 dark:bg-zinc-900']) }}> +
+
Subtotal
+
+
+ @if ((int) $discount > 0) +
+
Discount
+
-
+
+ @endif +
+
Shipping
+
+
+
+
Tax
+
+
+
+
Total
+
+
+
diff --git a/resources/views/components/storefront/pagination.blade.php b/resources/views/components/storefront/pagination.blade.php new file mode 100644 index 00000000..e0cf4074 --- /dev/null +++ b/resources/views/components/storefront/pagination.blade.php @@ -0,0 +1,32 @@ +@props([ + 'paginator' => null, +]) + +@if ($paginator && $paginator->hasPages()) + +@endif diff --git a/resources/views/components/storefront/price.blade.php b/resources/views/components/storefront/price.blade.php new file mode 100644 index 00000000..b0212115 --- /dev/null +++ b/resources/views/components/storefront/price.blade.php @@ -0,0 +1,17 @@ +@props([ + 'amount' => 0, + 'currency' => null, + 'compareAt' => null, +]) + +@php + $currency = $currency ?? (app()->bound('current_store') ? app('current_store')->default_currency : 'USD'); + $format = fn (int $cents): string => number_format($cents / 100, 2, '.', ',').' '.$currency; +@endphp + +class(['inline-flex items-baseline gap-2']) }}> + {{ $format((int) $amount) }} + @if ($compareAt && (int) $compareAt > (int) $amount) + {{ $format((int) $compareAt) }} + @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..172b9db6 --- /dev/null +++ b/resources/views/components/storefront/product-card.blade.php @@ -0,0 +1,32 @@ +@props([ + 'product', +]) + +@php + $handle = data_get($product, 'handle'); + $title = data_get($product, 'title'); + $priceAmount = data_get($product, 'price_amount'); + $compareAt = data_get($product, 'compare_at_amount'); + $currency = data_get($product, 'currency') + ?? (app()->bound('current_store') ? app('current_store')->default_currency : 'USD'); + $image = data_get($product, 'image_url'); + $href = $handle ? route('storefront.products.show', $handle) : '#'; +@endphp + +class(['group block overflow-hidden rounded-lg border border-zinc-200 bg-white transition hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900']) }} data-testid="product-card"> +
+ @if ($image) + {{ $title }} + @else +
+ +
+ @endif +
+
+
{{ $title }}
+ @if ($priceAmount !== null) + + @endif +
+
diff --git a/resources/views/components/storefront/quantity-selector.blade.php b/resources/views/components/storefront/quantity-selector.blade.php new file mode 100644 index 00000000..516d95df --- /dev/null +++ b/resources/views/components/storefront/quantity-selector.blade.php @@ -0,0 +1,34 @@ +@props([ + 'name' => 'quantity', + 'value' => 1, + 'min' => 1, + 'max' => 99, + 'model' => null, +]) + +@php + $wire = $model ? 'wire:model.live='.$model : ''; +@endphp + +
class(['inline-flex items-center rounded-md border border-zinc-200 dark:border-zinc-700']) }} role="group" aria-label="Quantity"> + + + +
diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php deleted file mode 100644 index 8f08c05d..00000000 --- a/resources/views/dashboard.blade.php +++ /dev/null @@ -1,18 +0,0 @@ - -
-
-
- -
-
- -
-
- -
-
-
- -
-
-
diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php new file mode 100644 index 00000000..3f21a89b --- /dev/null +++ b/resources/views/errors/404.blade.php @@ -0,0 +1,25 @@ + + + + + + Page not found + @vite(['resources/css/app.css', 'resources/js/app.js']) + @fluxAppearance + + +
+

404

+

Page not found

+

+ The page you are looking for does not exist or has been moved. +

+ +
+ @fluxScripts + + diff --git a/resources/views/errors/503.blade.php b/resources/views/errors/503.blade.php new file mode 100644 index 00000000..224669ba --- /dev/null +++ b/resources/views/errors/503.blade.php @@ -0,0 +1,20 @@ + + + + + + Temporarily unavailable + @vite(['resources/css/app.css', 'resources/js/app.js']) + @fluxAppearance + + +
+

503

+

We will be right back

+

+ {{ $exception?->getMessage() ?: 'Our store is temporarily unavailable. Please try again shortly.' }} +

+
+ @fluxScripts + + diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php deleted file mode 100644 index 037dd1bd..00000000 --- a/resources/views/layouts/app.blade.php +++ /dev/null @@ -1,5 +0,0 @@ - - - {{ $slot }} - - diff --git a/resources/views/layouts/app/header.blade.php b/resources/views/layouts/app/header.blade.php deleted file mode 100644 index e1f84d92..00000000 --- a/resources/views/layouts/app/header.blade.php +++ /dev/null @@ -1,78 +0,0 @@ - - - - @include('partials.head') - - - - - - - - - - {{ __('Dashboard') }} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{ __('Dashboard') }} - - - - - - - - - {{ __('Repository') }} - - - {{ __('Documentation') }} - - - - - {{ $slot }} - - @fluxScripts - - diff --git a/resources/views/layouts/app/sidebar.blade.php b/resources/views/layouts/app/sidebar.blade.php deleted file mode 100644 index ea25506b..00000000 --- a/resources/views/layouts/app/sidebar.blade.php +++ /dev/null @@ -1,95 +0,0 @@ - - - - @include('partials.head') - - - - - - - - - - - - {{ __('Dashboard') }} - - - - - - - - - {{ __('Repository') }} - - - - {{ __('Documentation') }} - - - - - - - - - - - - - - - - -
-
- - -
- {{ auth()->user()->name }} - {{ auth()->user()->email }} -
-
-
-
- - - - - - {{ __('Settings') }} - - - - - -
- @csrf - - {{ __('Log Out') }} - -
-
-
-
- - {{ $slot }} - - @fluxScripts - - diff --git a/resources/views/layouts/auth.blade.php b/resources/views/layouts/auth.blade.php deleted file mode 100644 index 71500919..00000000 --- a/resources/views/layouts/auth.blade.php +++ /dev/null @@ -1,3 +0,0 @@ - - {{ $slot }} - diff --git a/resources/views/layouts/auth/card.blade.php b/resources/views/layouts/auth/card.blade.php deleted file mode 100644 index db947161..00000000 --- a/resources/views/layouts/auth/card.blade.php +++ /dev/null @@ -1,26 +0,0 @@ - - - - @include('partials.head') - - - - @fluxScripts - - diff --git a/resources/views/layouts/auth/simple.blade.php b/resources/views/layouts/auth/simple.blade.php deleted file mode 100644 index 6e0d9093..00000000 --- a/resources/views/layouts/auth/simple.blade.php +++ /dev/null @@ -1,22 +0,0 @@ - - - - @include('partials.head') - - - - @fluxScripts - - diff --git a/resources/views/layouts/auth/split.blade.php b/resources/views/layouts/auth/split.blade.php deleted file mode 100644 index 4e9788bd..00000000 --- a/resources/views/layouts/auth/split.blade.php +++ /dev/null @@ -1,43 +0,0 @@ - - - - @include('partials.head') - - -
- - -
- @fluxScripts - - diff --git a/resources/views/livewire/admin/analytics/index.blade.php b/resources/views/livewire/admin/analytics/index.blade.php new file mode 100644 index 00000000..d993c758 --- /dev/null +++ b/resources/views/livewire/admin/analytics/index.blade.php @@ -0,0 +1,58 @@ +
+
+ Analytics +
+ + +
+
+ +
+
+ Revenue + {{ number_format($totals['revenue_amount'] / 100, 2) }} +
+
+ Orders + {{ $totals['orders_count'] }} +
+
+ Visits + {{ $totals['visits_count'] }} +
+
+ AOV + {{ number_format($totals['aov_amount'] / 100, 2) }} +
+
+ +
+ Daily breakdown + @if ($metrics->isEmpty()) + No data yet. + @else + + + + + + + + + + + + @foreach ($metrics as $row) + + + + + + + + @endforeach + +
DateRevenueOrdersVisitsCarts
{{ $row->date }}{{ number_format($row->revenue_amount / 100, 2) }}{{ $row->orders_count }}{{ $row->visits_count }}{{ $row->add_to_cart_count }}
+ @endif +
+
diff --git a/resources/views/livewire/admin/apps/index.blade.php b/resources/views/livewire/admin/apps/index.blade.php new file mode 100644 index 00000000..75350d1d --- /dev/null +++ b/resources/views/livewire/admin/apps/index.blade.php @@ -0,0 +1,25 @@ +
+ Apps +
+ + + + + + + + + + @forelse ($installations as $installation) + + + + + + @empty + + @endforelse + +
AppStatusInstalled at
{{ $installation->app?->name ?? $installation->id }}{{ $installation->status?->value ?? $installation->status }}{{ $installation->created_at?->format('Y-m-d') }}
No apps installed.
+
+
diff --git a/resources/views/livewire/admin/apps/show.blade.php b/resources/views/livewire/admin/apps/show.blade.php new file mode 100644 index 00000000..625c00be --- /dev/null +++ b/resources/views/livewire/admin/apps/show.blade.php @@ -0,0 +1,23 @@ +
+
+ {{ $installation->app?->name ?? 'App' }} + Back +
+
+
Status: {{ $installation->status?->value ?? $installation->status }}
+
Scopes: {{ is_array($installation->scopes_json) ? implode(', ', $installation->scopes_json) : '' }}
+
Installed at: {{ $installation->installed_at?->format('Y-m-d H:i') }}
+
+
+ Webhook subscriptions + @if ($installation->subscriptions->isEmpty()) + No subscriptions. + @else +
    + @foreach ($installation->subscriptions as $sub) +
  • {{ $sub->topic ?? $sub->event ?? $sub->id }}
  • + @endforeach +
+ @endif +
+
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..a688d220 --- /dev/null +++ b/resources/views/livewire/admin/auth/login.blade.php @@ -0,0 +1,27 @@ +
+
+ Admin Login + Sign in to manage your store + +
+ + Email + + + + + + Password + + + + + + + + Sign in + Signing 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..36b157d6 --- /dev/null +++ b/resources/views/livewire/admin/collections/form.blade.php @@ -0,0 +1,51 @@ +
+ {{ $collection && $collection->exists ? 'Edit collection' : 'New collection' }} + @if (session('success')) + {{ session('success') }} + @endif +
+
+ + + + + Products + + @foreach ($searchResults as $result) +
+ {{ $result->title }} + Add +
+ @endforeach + +
+ @foreach ($product_ids as $pid) + @php($prod = $pickedProducts->get($pid)) + @if ($prod) +
+ {{ $prod->title }} +
+ Up + Down + Remove +
+
+ @endif + @endforeach +
+
+
+ + @foreach ($types as $case) + {{ ucfirst($case->value) }} + @endforeach + + + @foreach ($statuses as $case) + {{ ucfirst($case->value) }} + @endforeach + + Save +
+
+
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..91c2f034 --- /dev/null +++ b/resources/views/livewire/admin/collections/index.blade.php @@ -0,0 +1,32 @@ +
+
+ Collections + New collection +
+ +
+ + + + + + + + + + + @forelse ($collections as $collection) + + + + + + + @empty + + @endforelse + +
TitleHandleTypeStatus
{{ $collection->title }}{{ $collection->handle }}{{ $collection->type?->value }}{{ $collection->status?->value }}
No collections yet.
+
+
{{ $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..e3114fac --- /dev/null +++ b/resources/views/livewire/admin/customers/index.blade.php @@ -0,0 +1,27 @@ +
+ Customers + +
+ + + + + + + + + + @forelse ($customers as $customer) + + + + + + @empty + + @endforelse + +
EmailNameMarketing
{{ $customer->email }}{{ $customer->fullName() }}{{ $customer->accepts_marketing ? 'Yes' : 'No' }}
No customers yet.
+
+
{{ $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..342f470d --- /dev/null +++ b/resources/views/livewire/admin/customers/show.blade.php @@ -0,0 +1,58 @@ +
+
+ {{ $customer->fullName() }} + Back +
+
+
+
+ Orders + @if ($orders->isEmpty()) + No orders yet. + @else + + + + + + + + + + + @foreach ($orders as $order) + + + + + + + @endforeach + +
OrderDateStatusTotal
#{{ $order->order_number }}{{ $order->placed_at?->format('Y-m-d') }}{{ $order->financial_status?->value }}{{ number_format($order->total_amount / 100, 2) }}
+ @endif +
+
+
+
+ Contact +
+
{{ $customer->email }}
+
{{ $customer->phone }}
+
+
+
+ Addresses + @if ($customer->addresses->isEmpty()) + No addresses saved. + @else +
    + @foreach ($customer->addresses as $addr) +
  • {{ $addr->address1 }}, {{ $addr->city }}, {{ $addr->country }}
  • + @endforeach +
+ @endif +
+
+
+
diff --git a/resources/views/livewire/admin/dashboard.blade.php b/resources/views/livewire/admin/dashboard.blade.php new file mode 100644 index 00000000..55997d2a --- /dev/null +++ b/resources/views/livewire/admin/dashboard.blade.php @@ -0,0 +1,62 @@ +
+
+ Dashboard +
+ + +
+
+ +
+
+ Total sales + {{ number_format($metrics['sales_amount'] / 100, 2) }} +
+
+ Orders + {{ $metrics['order_count'] }} +
+
+ AOV + {{ number_format($metrics['aov_amount'] / 100, 2) }} +
+
+ Conversion + {{ number_format($metrics['conversion_rate'] * 100, 2) }}% +
+
+ +
+ Recent orders + @if ($recentOrders->isEmpty()) + No orders yet. + @else + + + + + + + + + + + @foreach ($recentOrders as $order) + + + + + + + @endforeach + +
OrderEmailStatusTotal
+ @if (Route::has('admin.orders.show')) + #{{ $order->order_number }} + @else + #{{ $order->order_number }} + @endif + {{ $order->email }}{{ $order->financial_status?->value }}{{ number_format($order->total_amount / 100, 2) }}
+ @endif +
+
diff --git a/resources/views/livewire/admin/developers/index.blade.php b/resources/views/livewire/admin/developers/index.blade.php new file mode 100644 index 00000000..d1d4369d --- /dev/null +++ b/resources/views/livewire/admin/developers/index.blade.php @@ -0,0 +1,41 @@ +
+ Developers + +
+ Personal access tokens +
+ + + Create token + + + @if ($newToken) + Copy your token now: {{ $newToken }} + @endif + +
    + @foreach ($tokens as $token) +
  • +
    +
    {{ $token->name }}
    +
    Last used {{ $token->last_used_at?->format('Y-m-d H:i') ?? 'never' }}
    +
    + Revoke +
  • + @endforeach +
+
+ +
+ Webhook subscriptions + @if ($subscriptions->isEmpty()) + No subscriptions. + @else +
    + @foreach ($subscriptions as $sub) +
  • {{ $sub->topic ?? $sub->event ?? 'event' }} → {{ $sub->endpoint_url ?? $sub->url ?? '' }}
  • + @endforeach +
+ @endif +
+
diff --git a/resources/views/livewire/admin/discounts/form.blade.php b/resources/views/livewire/admin/discounts/form.blade.php new file mode 100644 index 00000000..30f31b5d --- /dev/null +++ b/resources/views/livewire/admin/discounts/form.blade.php @@ -0,0 +1,32 @@ +
+ {{ $discount && $discount->exists ? 'Edit discount' : 'New discount' }} + @if (session('success')) + {{ session('success') }} + @endif +
+
+ + @foreach ($types as $case) + {{ ucfirst($case->value) }} + @endforeach + + + + @foreach ($valueTypes as $case) + {{ $case->value }} + @endforeach + + + + + + + + @foreach ($statuses as $case) + {{ ucfirst($case->value) }} + @endforeach + +
+ Save +
+
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..15053fc8 --- /dev/null +++ b/resources/views/livewire/admin/discounts/index.blade.php @@ -0,0 +1,36 @@ +
+
+ Discounts + New discount +
+ +
+ + + + + + + + + + + + @forelse ($discounts as $discount) + + + + + + + + @empty + + @endforelse + +
CodeTypeValueStatusActions
{{ $discount->code ?? '(automatic)' }}{{ $discount->value_type?->value }}{{ $discount->value_amount }}{{ $discount->status?->value }} + Delete +
No discounts yet.
+
+
{{ $discounts->links() }}
+
diff --git a/resources/views/livewire/admin/navigation/index.blade.php b/resources/views/livewire/admin/navigation/index.blade.php new file mode 100644 index 00000000..92ae4a4f --- /dev/null +++ b/resources/views/livewire/admin/navigation/index.blade.php @@ -0,0 +1,35 @@ +
+ Navigation + +
+ + + Add menu + + + @foreach ($menus as $menu) +
+
+ {{ $menu->title }} ({{ $menu->handle }}) + Remove menu +
+
    + @foreach ($menu->items as $item) +
  • + {{ $item->label }} — {{ $item->url }} +
    + Up + Down + Remove +
    +
  • + @endforeach +
+
+ + + Add item +
+
+ @endforeach +
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..e1a8f4ef --- /dev/null +++ b/resources/views/livewire/admin/orders/index.blade.php @@ -0,0 +1,48 @@ +
+ Orders + +
+ + + All statuses + @foreach ($statuses as $case) + {{ ucfirst($case->value) }} + @endforeach + + + +
+ +
+ + + + + + + + + + + + + @forelse ($orders as $order) + + + + + + + + + @empty + + @endforelse + +
OrderDateCustomerFinancialFulfillmentTotal
+ #{{ $order->order_number }} + {{ $order->placed_at?->format('Y-m-d H:i') }}{{ $order->email }}{{ $order->financial_status?->value }}{{ $order->fulfillment_status?->value }}{{ number_format($order->total_amount / 100, 2) }}
No orders found.
+
+ +
{{ $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..943ce25f --- /dev/null +++ b/resources/views/livewire/admin/orders/show.blade.php @@ -0,0 +1,139 @@ +
+
+ Order #{{ $order->order_number }} + Back +
+ + @if (session('success')) + {{ session('success') }} + @endif + @if (session('error')) + {{ session('error') }} + @endif + +
+ @if ($order->fulfillment_status?->value !== 'fulfilled' && $order->status?->value !== 'cancelled') + Fulfill items + @endif + @if (in_array($order->financial_status?->value, ['paid', 'partially_refunded'], true)) + Refund + @endif + @if ($canConfirmBankTransfer) + Confirm payment + @endif + @if ($order->status?->value !== 'cancelled' && $order->fulfillment_status?->value !== 'fulfilled') + Cancel order + @endif +
+ +
+
+
+ Line items + + + + + + + + + + + + @foreach ($order->lines as $line) + + + + + + + + @endforeach + +
TitleSKUQtyPriceTotal
{{ $line->title_snapshot }}{{ $line->sku_snapshot }}{{ $line->quantity }} ({{ $line->fulfilledQuantity() }} fulfilled){{ number_format($line->unit_price_amount / 100, 2) }}{{ number_format($line->total_amount / 100, 2) }}
+
+
+
Subtotal: {{ number_format($order->subtotal_amount / 100, 2) }}
+
Shipping: {{ number_format($order->shipping_amount / 100, 2) }}
+
Tax: {{ number_format($order->tax_amount / 100, 2) }}
+
Total: {{ number_format($order->total_amount / 100, 2) }}
+
+
+
+ +
+ Timeline +
    +
  • Placed at {{ $order->placed_at?->format('Y-m-d H:i') }}
  • + @foreach ($order->fulfillments as $fulfillment) +
  • Fulfillment #{{ $fulfillment->id }} - {{ $fulfillment->status?->value }}
  • + @endforeach + @foreach ($order->refunds as $refund) +
  • Refund of {{ number_format($refund->amount / 100, 2) }} - {{ $refund->status?->value ?? $refund->status }}
  • + @endforeach +
+
+ +
+ Payment +
    +
  • Method: {{ $order->payment_method?->value }}
  • +
  • Financial status: {{ $order->financial_status?->value }}
  • +
  • Total paid: {{ number_format(($order->total_amount - $order->totalRefunded()) / 100, 2) }}
  • +
+
+
+ +
+
+ Customer +
+
{{ $order->email }}
+ @if ($order->customer) + {{ $order->customer->fullName() }} + @endif +
+
+
+ Shipping address +
{{ json_encode($order->shipping_address_json, JSON_PRETTY_PRINT) }}
+
+
+
+ + +
+ Fulfill items + @foreach ($order->lines as $line) + @php($remaining = $line->quantity - $line->fulfilledQuantity()) + @if ($remaining > 0) +
+
{{ $line->title_snapshot }} ({{ $remaining }} left)
+ +
+ @endif + @endforeach + + + +
+ Cancel + Fulfill +
+
+
+ + +
+ Refund + + + +
+ Cancel + 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..f4e72062 --- /dev/null +++ b/resources/views/livewire/admin/pages/form.blade.php @@ -0,0 +1,17 @@ +
+ {{ $page && $page->exists ? 'Edit page' : 'New page' }} + @if (session('success')) + {{ session('success') }} + @endif +
+ + + + + @foreach ($statuses as $case) + {{ ucfirst($case->value) }} + @endforeach + + Save + +
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..8b387b08 --- /dev/null +++ b/resources/views/livewire/admin/pages/index.blade.php @@ -0,0 +1,33 @@ +
+
+ Pages + New page +
+
+ + + + + + + + + + + @forelse ($pages as $page) + + + + + + + @empty + + @endforelse + +
TitleHandleStatusActions
{{ $page->title }}{{ $page->handle }}{{ $page->status?->value }} + Delete +
No pages yet.
+
+
{{ $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..4e8e77c9 --- /dev/null +++ b/resources/views/livewire/admin/products/form.blade.php @@ -0,0 +1,67 @@ +
+
+ {{ $product && $product->exists ? 'Edit product' : 'New product' }} + Back +
+ + @if (session('success')) + {{ session('success') }} + @endif + +
+
+
+
+ + +
+ +
+ Variants + @if (count($variants) === 0) +
+ + +
+ @else + @foreach ($variants as $index => $variant) +
+ + +
+ @endforeach + @endif +
+ +
+ Media + + @if ($product && $product->exists) +
+ @foreach ($product->media as $media) +
+ {{ basename($media->storage_key) }} +
+ @endforeach +
+ @endif +
+
+ +
+
+ + @foreach ($statuses as $case) + {{ ucfirst($case->value) }} + @endforeach + + + + +
+ + Save +
+
+
+
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..18692f77 --- /dev/null +++ b/resources/views/livewire/admin/products/index.blade.php @@ -0,0 +1,60 @@ +
+
+ Products + New product +
+ +
+ + + All statuses + @foreach ($statuses as $case) + {{ ucfirst($case->value) }} + @endforeach + + @if (count($selected) > 0) + + Bulk actions ({{ count($selected) }}) + + Archive + Delete + + + @endif +
+ +
+ + + + + + + + + + + + + @forelse ($products as $product) + + + + + + + + + @empty + + @endforelse + +
TitleStatusVendorTypeActions
+ {{ $product->title }} + {{ $product->status?->value }}{{ $product->vendor }}{{ $product->product_type }} + Edit +
No products found.
+
+ +
{{ $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..2605d7f8 --- /dev/null +++ b/resources/views/livewire/admin/search/settings.blade.php @@ -0,0 +1,22 @@ +
+ Search + @if (session('success')) + {{ session('success') }} + @endif + @if (session('error')) + {{ session('error') }} + @endif +
+ + + Save + +
+ Reindex + Rebuild the FTS index for all products. + Reindex all products + @if ($reindexed > 0) + Reindexed {{ $reindexed }} products. + @endif +
+
diff --git a/resources/views/livewire/admin/settings/index.blade.php b/resources/views/livewire/admin/settings/index.blade.php new file mode 100644 index 00000000..52f5d4f3 --- /dev/null +++ b/resources/views/livewire/admin/settings/index.blade.php @@ -0,0 +1,46 @@ +
+ Settings + + @if (session('success')) + {{ session('success') }} + @endif + + + + @if ($tab === 'general') +
+ + + + + Save + + @elseif ($tab === 'domains') +
+
+ + Add domain + +
    + @foreach ($domains as $domain) +
  • + {{ $domain->hostname }} ({{ $domain->type?->value }}) {{ $domain->is_primary ? '[primary]' : '' }} + @if (! $domain->is_primary) + Remove + @endif +
  • + @endforeach +
+
+ @elseif ($tab === 'notifications') +
+ Notification templates (feature flag). Coming soon. +
+ @endif +
diff --git a/resources/views/livewire/admin/settings/shipping.blade.php b/resources/views/livewire/admin/settings/shipping.blade.php new file mode 100644 index 00000000..3c4dc30f --- /dev/null +++ b/resources/views/livewire/admin/settings/shipping.blade.php @@ -0,0 +1,41 @@ +
+
+ Shipping + Back +
+ +
+ + + Add zone + + + @foreach ($zones as $zone) +
+
+ {{ $zone->name }} {{ implode(', ', (array) $zone->countries_json) }} + Remove zone +
+ +
    + @foreach ($zone->rates as $rate) +
  • + {{ $rate->name }} ({{ $rate->type?->value }}) - {{ (int) ($rate->config_json['amount'] ?? 0) }} cents + Remove +
  • + @endforeach +
+ +
+ + + @foreach ($rateTypes as $case) + {{ $case->value }} + @endforeach + + + Add rate +
+
+ @endforeach +
diff --git a/resources/views/livewire/admin/settings/taxes.blade.php b/resources/views/livewire/admin/settings/taxes.blade.php new file mode 100644 index 00000000..fd6279d4 --- /dev/null +++ b/resources/views/livewire/admin/settings/taxes.blade.php @@ -0,0 +1,19 @@ +
+
+ Taxes + Back +
+ @if (session('success')) + {{ session('success') }} + @endif +
+ + @foreach ($modes as $case) + {{ ucfirst($case->value) }} + @endforeach + + + + Save + +
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..9843e6a8 --- /dev/null +++ b/resources/views/livewire/admin/themes/editor.blade.php @@ -0,0 +1,32 @@ +
+
+ Editing {{ $theme->name }} + Back +
+ @if (session('success')) + {{ session('success') }} + @endif +
+
+ Sections + Sections editor coming soon. +
+
+ +
+
+ + + + + + + + Auto + Light + Dark + + Save + +
+
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..1c27c840 --- /dev/null +++ b/resources/views/livewire/admin/themes/index.blade.php @@ -0,0 +1,25 @@ +
+ Themes +
+ @forelse ($themes as $theme) +
+
+ {{ $theme->name }} + {{ $theme->status?->value }} +
+
+ Edit + @if (! $theme->isPublished()) + Publish + @endif + Duplicate + @if (! $theme->isPublished()) + Delete + @endif +
+
+ @empty + No themes yet. + @endforelse +
+
diff --git a/resources/views/livewire/settings/appearance.blade.php b/resources/views/livewire/settings/appearance.blade.php deleted file mode 100644 index 3272f6e5..00000000 --- a/resources/views/livewire/settings/appearance.blade.php +++ /dev/null @@ -1,13 +0,0 @@ -
- @include('partials.settings-heading') - - {{ __('Appearance Settings') }} - - - - {{ __('Light') }} - {{ __('Dark') }} - {{ __('System') }} - - -
diff --git a/resources/views/livewire/settings/delete-user-form.blade.php b/resources/views/livewire/settings/delete-user-form.blade.php deleted file mode 100644 index f8a0d4ea..00000000 --- a/resources/views/livewire/settings/delete-user-form.blade.php +++ /dev/null @@ -1,34 +0,0 @@ -
-
- {{ __('Delete account') }} - {{ __('Delete your account and all of its resources') }} -
- - - - {{ __('Delete account') }} - - - - -
-
- {{ __('Are you sure you want to delete your account?') }} - - - {{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }} - -
- - - -
- - {{ __('Cancel') }} - - - {{ __('Delete account') }} -
- -
-
diff --git a/resources/views/livewire/settings/password.blade.php b/resources/views/livewire/settings/password.blade.php deleted file mode 100644 index 10868a86..00000000 --- a/resources/views/livewire/settings/password.blade.php +++ /dev/null @@ -1,41 +0,0 @@ -
- @include('partials.settings-heading') - - {{ __('Password Settings') }} - - -
- - - - -
-
- {{ __('Save') }} -
- - - {{ __('Saved.') }} - -
- -
-
diff --git a/resources/views/livewire/settings/profile.blade.php b/resources/views/livewire/settings/profile.blade.php deleted file mode 100644 index 4de634b8..00000000 --- a/resources/views/livewire/settings/profile.blade.php +++ /dev/null @@ -1,47 +0,0 @@ -
- @include('partials.settings-heading') - - {{ __('Profile Settings') }} - - -
- - -
- - - @if ($this->hasUnverifiedEmail) -
- - {{ __('Your email address is unverified.') }} - - - {{ __('Click here to re-send the verification email.') }} - - - - @if (session('status') === 'verification-link-sent') - - {{ __('A new verification link has been sent to your email address.') }} - - @endif -
- @endif -
- -
-
- {{ __('Save') }} -
- - - {{ __('Saved.') }} - -
- - - @if ($this->showDeleteUser) - - @endif -
-
diff --git a/resources/views/livewire/settings/two-factor.blade.php b/resources/views/livewire/settings/two-factor.blade.php deleted file mode 100644 index fc01f3e7..00000000 --- a/resources/views/livewire/settings/two-factor.blade.php +++ /dev/null @@ -1,210 +0,0 @@ -
- @include('partials.settings-heading') - - {{ __('Two-Factor Authentication Settings') }} - - -
- @if ($twoFactorEnabled) -
-
- {{ __('Enabled') }} -
- - - {{ __('With two-factor authentication enabled, you will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }} - - - - -
- - {{ __('Disable 2FA') }} - -
-
- @else -
-
- {{ __('Disabled') }} -
- - - {{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }} - - - - {{ __('Enable 2FA') }} - -
- @endif -
-
- - -
-
-
-
-
- @for ($i = 1; $i <= 5; $i++) -
- @endfor -
- -
- @for ($i = 1; $i <= 5; $i++) -
- @endfor -
- - -
-
- -
- {{ $this->modalConfig['title'] }} - {{ $this->modalConfig['description'] }} -
-
- - @if ($showVerificationStep) -
-
- -
- -
- - {{ __('Back') }} - - - - {{ __('Confirm') }} - -
-
- @else - @error('setupData') - - @enderror - -
-
- @empty($qrCodeSvg) -
- -
- @else -
-
- {!! $qrCodeSvg !!} -
-
- @endempty -
-
- -
- - {{ $this->modalConfig['buttonText'] }} - -
- -
-
-
- - {{ __('or, enter the code manually') }} - -
- -
-
- @empty($manualSetupKey) -
- -
- @else - - - - @endempty -
-
-
- @endif -
-
-
diff --git a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php deleted file mode 100644 index 0c4232a8..00000000 --- a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php +++ /dev/null @@ -1,89 +0,0 @@ -
-
-
- - {{ __('2FA Recovery Codes') }} -
- - {{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }} - -
- -
-
- - - - {{ __('Hide Recovery Codes') }} - - - @if (filled($recoveryCodes)) - - {{ __('Regenerate Codes') }} - - @endif -
- -
-
- @error('recoveryCodes') - - @enderror - - @if (filled($recoveryCodes)) -
- @foreach($recoveryCodes as $code) -
- {{ $code }} -
- @endforeach -
- - {{ __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate Codes above.') }} - - @endif -
-
-
-
diff --git a/resources/views/livewire/storefront/account/addresses/index.blade.php b/resources/views/livewire/storefront/account/addresses/index.blade.php new file mode 100644 index 00000000..448a6dbc --- /dev/null +++ b/resources/views/livewire/storefront/account/addresses/index.blade.php @@ -0,0 +1,147 @@ +@php + $formatAddress = function ($address): string { + $json = $address->address_json ?? []; + $lines = array_filter([ + trim(($json['first_name'] ?? '').' '.($json['last_name'] ?? '')), + $json['company'] ?? null, + $json['address1'] ?? null, + $json['address2'] ?? null, + trim(($json['city'] ?? '').' '.($json['postal_code'] ?? '')), + $json['region'] ?? null, + $json['country_code'] ?? null, + ]); + return implode("\n", $lines); + }; +@endphp +
+ + +
+ Your addresses + + Add address + +
+ + @if ($addresses->isEmpty()) + No addresses yet. Add one to speed up checkout. + @else +
+ @foreach ($addresses as $address) + @php($isDefault = (bool) $address->is_default) +
$isDefault, + 'border-zinc-200 dark:border-zinc-800' => ! $isDefault, + ])> + @if ($isDefault) + Default + @endif + @if ($address->label) +
{{ $address->label }}
+ @endif +
{{ $formatAddress($address) }}
+
+ + + @if (! $isDefault) + + @endif +
+
+ @endforeach +
+ @endif + + @if ($showForm) + + @endif +
diff --git a/resources/views/livewire/storefront/account/auth/login.blade.php b/resources/views/livewire/storefront/account/auth/login.blade.php new file mode 100644 index 00000000..1e9221da --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/login.blade.php @@ -0,0 +1,28 @@ +
+ Sign in + +
+ + Email + + + + + + Password + + + + + + + + Sign in + Signing in... + + + + No account yet? Create one + + +
diff --git a/resources/views/livewire/storefront/account/auth/register.blade.php b/resources/views/livewire/storefront/account/auth/register.blade.php new file mode 100644 index 00000000..f0f21b09 --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/register.blade.php @@ -0,0 +1,42 @@ +
+ Create an account + +
+
+ + First name + + + + + + Last name + + + +
+ + + Email + + + + + + Password + + + + + + Confirm password + + + + Create account + + + Already have an account? 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..13bbd18b --- /dev/null +++ b/resources/views/livewire/storefront/account/dashboard.blade.php @@ -0,0 +1,74 @@ +@php + $statusVariant = [ + 'pending' => 'warning', + 'paid' => 'success', + 'fulfilled' => 'accent', + 'cancelled' => 'neutral', + 'refunded' => 'danger', + ]; +@endphp +
+ Account dashboard + @auth('customer') + Welcome back, {{ auth('customer')->user()->first_name ?? auth('customer')->user()->email }}. + @endauth + + + + @if ($recentOrders->isNotEmpty()) +
+ Recent orders +
+ + + + + + + + + + + + @foreach ($recentOrders as $order) + + + + + + + + @endforeach + +
OrderDateStatusTotal
#{{ $order->order_number }}{{ $order->placed_at?->format('M j, Y') }} + + {{ ucfirst($order->status->value) }} + + + + + View +
+
+
+ @endif +
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..3790ec96 --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/index.blade.php @@ -0,0 +1,82 @@ +@php + $statusVariant = [ + 'pending' => 'warning', + 'paid' => 'success', + 'fulfilled' => 'accent', + 'cancelled' => 'neutral', + 'refunded' => 'danger', + ]; +@endphp +
+ + + Order history + + @if ($orders->isEmpty()) + + You have no orders yet. + Start shopping. + + @else + + +
    + @foreach ($orders as $order) +
  • +
    + #{{ $order->order_number }} + + {{ ucfirst($order->status->value) }} + +
    +
    + {{ $order->placed_at?->format('M j, Y') }} +
    +
    + + View +
    +
  • + @endforeach +
+ + + @endif +
diff --git a/resources/views/livewire/storefront/account/orders/show.blade.php b/resources/views/livewire/storefront/account/orders/show.blade.php new file mode 100644 index 00000000..77412be6 --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/show.blade.php @@ -0,0 +1,129 @@ +@php + $statusVariant = [ + 'pending' => 'warning', + 'paid' => 'success', + 'fulfilled' => 'accent', + 'cancelled' => 'neutral', + 'refunded' => 'danger', + ]; + $shipping = $order->shipping_address_json ?? []; + $billing = $order->billing_address_json ?? []; + $formatAddress = function (array $addr): string { + $lines = array_filter([ + trim(($addr['first_name'] ?? '').' '.($addr['last_name'] ?? '')), + $addr['address1'] ?? null, + $addr['address2'] ?? null, + trim(($addr['city'] ?? '').' '.($addr['postal_code'] ?? $addr['zip'] ?? '')), + $addr['country_code'] ?? null, + ]); + return implode("\n", $lines); + }; +@endphp +
+ + +
+
+ Order #{{ $order->order_number }} + + Placed on {{ $order->placed_at?->format('F j, Y') }} + +
+
+ + {{ ucfirst($order->status->value) }} + + + {{ ucfirst(str_replace('_', ' ', $order->fulfillment_status->value)) }} + +
+
+ +
+ + + + + + + + + + @foreach ($order->lines as $line) + + + + + + @endforeach + +
ItemQtyTotal
+
{{ $line->title_snapshot }}
+ @if ($line->sku_snapshot) +
SKU: {{ $line->sku_snapshot }}
+ @endif +
{{ $line->quantity }} + +
+
+ +
+ @if (! empty($shipping)) +
+
Shipping address
+
{{ $formatAddress($shipping) }}
+
+ @endif + @if (! empty($billing)) +
+
Billing address
+
{{ $formatAddress($billing) }}
+
+ @endif +
+
Payment
+
+ {{ ucfirst(str_replace('_', ' ', $order->payment_method->value)) }} + ({{ ucfirst($order->financial_status->value) }}) +
+
+
+ +
+ +
+ + @if ($order->fulfillments->isNotEmpty()) +
+
Fulfillments
+
    + @foreach ($order->fulfillments as $fulfillment) +
  • + @if ($fulfillment->tracking_company) + Shipped via {{ $fulfillment->tracking_company }} + @else + Fulfillment #{{ $fulfillment->id }} + @endif + @if ($fulfillment->tracking_number) + - {{ $fulfillment->tracking_number }} + @endif + @if ($fulfillment->tracking_url) + Track shipment + @endif +
  • + @endforeach +
+
+ @endif +
diff --git a/resources/views/livewire/storefront/cart-drawer.blade.php b/resources/views/livewire/storefront/cart-drawer.blade.php new file mode 100644 index 00000000..86f55140 --- /dev/null +++ b/resources/views/livewire/storefront/cart-drawer.blade.php @@ -0,0 +1,77 @@ +
+
! $open, + 'opacity-100' => $open, + ])> +
+ + +
+
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..153131da --- /dev/null +++ b/resources/views/livewire/storefront/cart/show.blade.php @@ -0,0 +1,84 @@ +
+ + + Your cart + + @if ($isEmpty) + + Your cart is empty. Browse our collections to get started. + + @else +
+
+
    + @foreach ($lines as $line) +
  • +
    +
    + {{ $line['title'] }} + @if (! empty($line['sku'])) + {{ $line['sku'] }} + @endif + {{ number_format($line['unit_price_amount'] / 100, 2) }} {{ $currency }} +
    + - + {{ $line['quantity'] }} + + + Remove +
    +
    +
    + {{ number_format($line['line_total_amount'] / 100, 2) }} {{ $currency }} +
    +
  • + @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..285cf9d6 --- /dev/null +++ b/resources/views/livewire/storefront/checkout/confirmation.blade.php @@ -0,0 +1,78 @@ +
+
+ + Thank you for your order + Order number: #{{ $number }} +
+ + @if ($order) +
+
+ Order summary + {{ $order->financial_status?->value ?? 'pending' }} +
+ + + + + + + + + + + @foreach ($order->lines as $line) + + + + + + @endforeach + + + + + + + @if ($order->shipping_amount > 0) + + + + + @endif + @if ($order->tax_amount > 0) + + + + + @endif + @if ($order->discount_amount > 0) + + + + + @endif + + + + + +
ItemQtyTotal
+
{{ $line->title_snapshot }}
+ @if ($line->sku_snapshot) + {{ $line->sku_snapshot }} + @endif +
{{ $line->quantity }}{{ number_format($line->total_amount / 100, 2) }} {{ $order->currency }}
Subtotal{{ number_format($order->subtotal_amount / 100, 2) }} {{ $order->currency }}
Shipping{{ number_format($order->shipping_amount / 100, 2) }} {{ $order->currency }}
Tax{{ number_format($order->tax_amount / 100, 2) }} {{ $order->currency }}
Discount-{{ number_format($order->discount_amount / 100, 2) }} {{ $order->currency }}
Total{{ number_format($order->total_amount / 100, 2) }} {{ $order->currency }}
+ + @if ($order->financial_status?->value === 'pending') + + Your bank transfer is awaiting payment. We'll update you once it has been confirmed. + + @endif +
+ @endif + +
+ 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..97214228 --- /dev/null +++ b/resources/views/livewire/storefront/checkout/show.blade.php @@ -0,0 +1,104 @@ +
+ + + Checkout + +
+
+
+ 1. Contact & shipping + +
+ + + + + + + + + +
+ + Continue +
+ +
+ 2. Shipping method + @if (empty($availableRates)) + No shipping methods available yet. Continue past step 1 first. + @else +
    + @foreach ($availableRates as $rate) +
  • + +
  • + @endforeach +
+ @endif +
+ +
+ 3. Payment +
+ + + +
+ + Place order - {{ number_format(($pricing?->total ?? 0) / 100, 2) }} {{ $pricing?->currency }} + + + @if (! empty($errorMessages)) +
    + @foreach ($errorMessages as $error) +
  • {{ $error }}
  • + @endforeach +
+ @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..75efb006 --- /dev/null +++ b/resources/views/livewire/storefront/collections/index.blade.php @@ -0,0 +1,22 @@ +
+ + + Collections + + @if (empty($collections)) + No collections yet. Check back soon. + @else +
+ @foreach ($collections as $collection) + + {{ $collection->title }} + + @endforeach +
+ @endif +
diff --git a/resources/views/livewire/storefront/collections/show.blade.php b/resources/views/livewire/storefront/collections/show.blade.php new file mode 100644 index 00000000..08c15ea5 --- /dev/null +++ b/resources/views/livewire/storefront/collections/show.blade.php @@ -0,0 +1,36 @@ +
+ + + {{ $collection->title }} + @if ($collection->description_html) +
{!! $collection->description_html !!}
+ @endif + +
+ + + Sort: default + Title A-Z + Title Z-A + Newest + +
+ + @if ($products->isEmpty()) + No products found. + @else +
+ @foreach ($products as $product) +
+ +
+ @endforeach +
+ + + @endif +
diff --git a/resources/views/livewire/storefront/home.blade.php b/resources/views/livewire/storefront/home.blade.php new file mode 100644 index 00000000..6aa1676f --- /dev/null +++ b/resources/views/livewire/storefront/home.blade.php @@ -0,0 +1,44 @@ +@php + $hero = $themeSettings['hero'] ?? []; + $heading = $hero['heading'] ?? app('current_store')->name; + $subheading = $hero['subheading'] ?? 'Welcome to our shop. Browse products, add them to your cart, and check out in a few clicks.'; + $ctaLabel = $hero['cta_label'] ?? 'Shop collections'; + $ctaUrl = $hero['cta_url'] ?? route('storefront.collections.index'); +@endphp +
+
+ {{ $heading }} + {{ $subheading }} +
+ {{ $ctaLabel }} +
+
+ + @if (! empty($featuredCollections)) +
+ Featured collections +
+ @foreach ($featuredCollections as $collection) + + {{ $collection['title'] }} + + @endforeach +
+
+ @endif + + @if (! empty($featuredProducts)) +
+ Featured products +
+ @foreach ($featuredProducts as $product) +
+ +
+ @endforeach +
+
+ @endif +
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..be172556 --- /dev/null +++ b/resources/views/livewire/storefront/pages/show.blade.php @@ -0,0 +1,11 @@ +
+ + +
+

{{ $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..40e22365 --- /dev/null +++ b/resources/views/livewire/storefront/products/show.blade.php @@ -0,0 +1,90 @@ +@php + $selectedVariant = collect($variants)->firstWhere('id', $selectedVariantId); + $currency = $selectedVariant->currency ?? (app()->bound('current_store') ? app('current_store')->default_currency : 'USD'); + $tags = is_string($product->tags ?? null) ? (json_decode($product->tags, true) ?: []) : ($product->tags ?? []); +@endphp +
+ + +
+
+ @php + $resolveMediaUrl = fn ($item) => str_starts_with($item->storage_key, 'http') ? $item->storage_key : '/storage/'.$item->storage_key; + @endphp + @if (! empty($media)) +
+ {{ $media[0]->alt_text ?? $product->title }} +
+ @if (count($media) > 1) +
+ @foreach (array_slice($media, 1, 7) as $item) +
+ {{ $item->alt_text ?? '' }} +
+ @endforeach +
+ @endif + @else +
+ +
+ @endif +
+ +
+ {{ $product->title }} + + @if ($selectedVariant) + + @endif + + @if (count($variants) > 1) +
+ + + @foreach ($variants as $variant) + + {{ $variant->sku ?: 'Variant #'.$variant->id }} + + @endforeach + +
+ @endif + +
+ + +
+ + + Add to cart + + + @if (! empty($product->description_html)) +
{!! $product->description_html !!}
+ @endif + + @if (! empty($tags)) +
+ @foreach ($tags as $tag) + {{ $tag }} + @endforeach +
+ @endif +
+
+
diff --git a/resources/views/livewire/storefront/search/index.blade.php b/resources/views/livewire/storefront/search/index.blade.php new file mode 100644 index 00000000..81601977 --- /dev/null +++ b/resources/views/livewire/storefront/search/index.blade.php @@ -0,0 +1,28 @@ +
+ + + Search + +
+ +
+ + @if (trim($query) === '') + Type a query to search for products. + @elseif ($results->isEmpty()) + No products matched "{{ $query }}". + @else +
+ @foreach ($results as $product) +
+ +
+ @endforeach +
+ + + @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..05856815 --- /dev/null +++ b/resources/views/livewire/storefront/search/modal.blade.php @@ -0,0 +1,35 @@ +
+ @if ($open) + + @endif +
diff --git a/resources/views/partials/head.blade.php b/resources/views/partials/head.blade.php deleted file mode 100644 index dce80588..00000000 --- a/resources/views/partials/head.blade.php +++ /dev/null @@ -1,14 +0,0 @@ - - - -{{ $title ?? config('app.name') }} - - - - - - - - -@vite(['resources/css/app.css', 'resources/js/app.js']) -@fluxAppearance diff --git a/resources/views/partials/settings-heading.blade.php b/resources/views/partials/settings-heading.blade.php deleted file mode 100644 index 925ace9a..00000000 --- a/resources/views/partials/settings-heading.blade.php +++ /dev/null @@ -1,5 +0,0 @@ -
- {{ __('Settings') }} - {{ __('Manage your profile and account settings') }} - -
diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php deleted file mode 100644 index a808a399..00000000 --- a/resources/views/welcome.blade.php +++ /dev/null @@ -1,278 +0,0 @@ - - - - - - - Laravel - - - - - - - - - - - - - -
- @if (Route::has('login')) - - @endif -
-
-
-
-

Let's get started

-

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

- - -
-
- {{-- Laravel Logo --}} - - - - - - - - - - - {{-- Light Mode 12 SVG --}} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{-- Dark Mode 12 SVG --}} - -
-
-
-
- - @if (Route::has('login')) - - @endif - - diff --git a/routes/admin.php b/routes/admin.php new file mode 100644 index 00000000..78b4be85 --- /dev/null +++ b/routes/admin.php @@ -0,0 +1,82 @@ +name('login'); + +Route::middleware(['auth', 'resolve.store:admin'])->group(function (): void { + Route::get('/', Dashboard::class)->name('dashboard'); + + Route::get('/products', ProductsIndex::class)->name('products.index'); + Route::get('/products/new', ProductsForm::class)->name('products.create'); + Route::get('/products/{product}', ProductsForm::class)->name('products.edit'); + + Route::get('/orders', OrdersIndex::class)->name('orders.index'); + Route::get('/orders/{order}', OrdersShow::class)->name('orders.show'); + + Route::get('/collections', CollectionsIndex::class)->name('collections.index'); + Route::get('/collections/new', CollectionsForm::class)->name('collections.create'); + Route::get('/collections/{collection}', CollectionsForm::class)->name('collections.edit'); + + Route::get('/customers', CustomersIndex::class)->name('customers.index'); + Route::get('/customers/{customer}', CustomersShow::class)->name('customers.show'); + + Route::get('/discounts', DiscountsIndex::class)->name('discounts.index'); + Route::get('/discounts/new', DiscountsForm::class)->name('discounts.create'); + Route::get('/discounts/{discount}', DiscountsForm::class)->name('discounts.edit'); + + Route::get('/analytics', AnalyticsIndex::class)->name('analytics.index'); + + Route::get('/content/pages', PagesIndex::class)->name('pages.index'); + Route::get('/content/pages/new', PagesForm::class)->name('pages.create'); + Route::get('/content/pages/{page}', PagesForm::class)->name('pages.edit'); + + Route::get('/content/navigation', NavigationIndex::class)->name('navigation.index'); + + Route::get('/content/themes', ThemesIndex::class)->name('themes.index'); + Route::get('/content/themes/{theme}', ThemesEditor::class)->name('themes.editor'); + + Route::get('/settings', SettingsIndex::class)->name('settings.index'); + Route::get('/settings/shipping', SettingsShipping::class)->name('settings.shipping'); + Route::get('/settings/taxes', SettingsTaxes::class)->name('settings.taxes'); + + Route::get('/search/settings', SearchSettings::class)->name('search.settings'); + + Route::get('/apps', AppsIndex::class)->name('apps.index'); + Route::get('/apps/{installation}', AppsShow::class)->name('apps.show'); + + Route::get('/developers', DevelopersIndex::class)->name('developers.index'); + + Route::post('/logout', function () { + auth()->guard('web')->logout(); + request()->session()->invalidate(); + request()->session()->regenerateToken(); + + return redirect()->route('admin.login'); + })->name('logout'); +}); diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 00000000..dbb6d3c5 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,11 @@ +prefix('storefront/v1')->name('api.storefront.')->group(function (): void { + require __DIR__.'/api/storefront.php'; +}); + +Route::middleware(['auth:sanctum', 'throttle:admin-api'])->prefix('admin/v1')->name('api.admin.')->group(function (): void { + require __DIR__.'/api/admin.php'; +}); diff --git a/routes/api/admin.php b/routes/api/admin.php new file mode 100644 index 00000000..b73fd731 --- /dev/null +++ b/routes/api/admin.php @@ -0,0 +1,6 @@ + response()->json(['ok' => true]))->name('health'); diff --git a/routes/api/storefront.php b/routes/api/storefront.php new file mode 100644 index 00000000..088a2731 --- /dev/null +++ b/routes/api/storefront.php @@ -0,0 +1,12 @@ + response()->json(['ok' => true]))->name('health'); + +Route::post('/carts', [CartController::class, 'store'])->name('carts.store'); +Route::get('/carts/{cartId}', [CartController::class, 'show'])->whereNumber('cartId')->name('carts.show'); +Route::post('/carts/{cartId}/lines', [CartController::class, 'addLine'])->whereNumber('cartId')->name('carts.lines.store'); +Route::put('/carts/{cartId}/lines/{lineId}', [CartController::class, 'updateLine'])->whereNumber(['cartId', 'lineId'])->name('carts.lines.update'); +Route::delete('/carts/{cartId}/lines/{lineId}', [CartController::class, 'removeLine'])->whereNumber(['cartId', 'lineId'])->name('carts.lines.destroy'); diff --git a/routes/console.php b/routes/console.php index 3c9adf1a..031733dc 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,18 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::job(new ExpireAbandonedCheckouts)->everyFifteenMinutes()->name('expire-abandoned-checkouts'); +Schedule::job(new CleanupAbandonedCarts)->daily()->name('cleanup-abandoned-carts'); +Schedule::job(new CancelUnpaidBankTransferOrders)->daily()->name('cancel-unpaid-bank-transfer-orders'); +Schedule::job(new AggregateAnalytics)->dailyAt('01:00')->name('aggregate-analytics'); diff --git a/routes/settings.php b/routes/settings.php deleted file mode 100644 index 2019a287..00000000 --- a/routes/settings.php +++ /dev/null @@ -1,30 +0,0 @@ -group(function () { - Route::redirect('settings', 'settings/profile'); - - Route::livewire('settings/profile', Profile::class)->name('profile.edit'); -}); - -Route::middleware(['auth', 'verified'])->group(function () { - Route::livewire('settings/password', Password::class)->name('user-password.edit'); - Route::livewire('settings/appearance', Appearance::class)->name('appearance.edit'); - - Route::livewire('settings/two-factor', TwoFactor::class) - ->middleware( - when( - Features::canManageTwoFactorAuthentication() - && Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword'), - ['password.confirm'], - [], - ), - ) - ->name('two-factor.show'); -}); diff --git a/routes/storefront.php b/routes/storefront.php new file mode 100644 index 00000000..33c3162e --- /dev/null +++ b/routes/storefront.php @@ -0,0 +1,48 @@ +name('storefront.home'); + +Route::get('/collections', CollectionsIndex::class)->name('storefront.collections.index'); +Route::get('/collections/{handle}', CollectionShow::class)->name('storefront.collections.show'); +Route::get('/products/{handle}', ProductShow::class)->name('storefront.products.show'); +Route::get('/cart', CartShow::class)->name('storefront.cart.show'); +Route::get('/checkout', CheckoutShow::class)->name('storefront.checkout.show'); +Route::get('/checkout/confirmation/{number}', CheckoutConfirmation::class)->name('storefront.checkout.confirmation'); +Route::get('/search', SearchIndex::class)->name('storefront.search.index'); +Route::get('/pages/{handle}', PageShow::class)->name('storefront.pages.show'); + +Route::prefix('account')->name('account.')->group(function (): void { + Route::get('/login', CustomerLogin::class)->name('login'); + Route::get('/register', CustomerRegister::class)->name('register'); + + Route::middleware('auth:customer')->group(function (): void { + Route::get('/', AccountDashboard::class)->name('dashboard'); + Route::get('/orders', AccountOrdersIndex::class)->name('orders.index'); + Route::get('/orders/{number}', AccountOrderShow::class)->name('orders.show'); + Route::get('/addresses', AccountAddresses::class)->name('addresses.index'); + Route::post('/logout', function () { + auth()->guard('customer')->logout(); + request()->session()->invalidate(); + request()->session()->regenerateToken(); + + return redirect()->route('account.login'); + })->name('logout'); + }); +}); diff --git a/routes/web.php b/routes/web.php index f755f111..6df9ea08 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,12 +2,10 @@ use Illuminate\Support\Facades\Route; -Route::get('/', function () { - return view('welcome'); -})->name('home'); +Route::middleware('admin')->prefix('admin')->name('admin.')->group(function (): void { + require __DIR__.'/admin.php'; +}); -Route::view('dashboard', 'dashboard') - ->middleware(['auth', 'verified']) - ->name('dashboard'); - -require __DIR__.'/settings.php'; +Route::middleware('storefront')->group(function (): void { + require __DIR__.'/storefront.php'; +}); diff --git a/specs/progress.md b/specs/progress.md new file mode 100644 index 00000000..c38098ea --- /dev/null +++ b/specs/progress.md @@ -0,0 +1,53 @@ +# Shop Build Progress + +Started: 2026-04-18 +Branch: `2026-04-16-claude-code-opus-4-7-xhigh` +Mode: Agent Team (lead + parallel teammates) + +## Phase Status + +| Phase | Title | Status | Owner | +|-------|-------|--------|-------| +| 1 | Foundation (config, tenancy, auth, policies) | done | lead | +| 2 | Catalog (products, variants, inventory, collections, media) | done | catalog-engineer | +| 3 | Themes, Pages, Navigation, Storefront layout | done | storefront-engineer | +| 4 | Cart, Checkout, Discounts, Shipping, Taxes | done | commerce-engineer | +| 5 | Payments, Orders, Fulfillment | done | commerce-engineer | +| 6 | Customer Accounts | done | storefront-engineer | +| 7 | Admin Panel | done | admin-engineer | +| 8 | Search (FTS5) | done | catalog-engineer | +| 9 | Analytics | done | catalog-engineer | +| 10 | Apps and Webhooks | done | commerce-engineer | +| 11 | Polish (structured JSON log channel, seeds) | done | lead | +| 12 | Full Test Suite Run + Playwright review | done | lead | + +## Final Test Stats +- `php artisan test`: 232 passed, 1 skipped (481 assertions) +- `vendor/bin/pint --dirty`: pass +- `php artisan migrate:fresh --seed`: clean + +## Playwright Review Log +- http://shop.test/ renders Acme Fashion home with featured collections + featured products (theme-driven) +- Product page (/products/organic-cotton-t-shirt) renders variant selector, quantity, Add to cart +- Cart drawer opens and shows added line (1 x Organic Cotton T-Shirt = 25.00 EUR) +- /checkout stepper collects contact + shipping address, shows Standard 4.99 EUR rate, tax line 5.70 EUR (19% VAT), total 35.69 EUR +- Placing order with bank_transfer creates order #1001 and redirects to /checkout/confirmation/1001 with itemised summary +- Account dashboard (signed in as buyer@example.com) renders correctly +- Admin login (owner@acme.test / password) lands on /admin dashboard with the recent order +- /admin/orders/1 shows line items, shipping address JSON, timeline, payment; Confirm payment flips financial status to paid, Refund button appears, order is fulfillable +- /admin/products, /admin/discounts, /admin/settings/shipping, /admin/analytics all render without errors + +## Commit History +- `1ae25a28` Phase 1 foundation +- `47eb2e79` Phase 2 catalog +- `8185dec8` Phase 3 storefront layout +- `d4fff273` Phase 8 FTS5 search +- `d308930e` Phase 4 cart / checkout / pricing +- `cd736074` Phase 5 orders / payments / fulfillment +- `08c073ea` Phase 9 analytics +- `024af740` Phase 10 webhook delivery +- `51e65e76` Phase 6 customer accounts +- `fe8ea0d6` Phase 6 follow-up (guest-cart merge) +- `cb09128a` Phase 7 admin panel +- `5dbf86c1` Phase 11 polish (structured JSON log) +- `d77c13b1` Phase 12 review fixes (shipping/tax seed, confirmation page) diff --git a/tests/Feature/Admin/DashboardTest.php b/tests/Feature/Admin/DashboardTest.php new file mode 100644 index 00000000..829816c0 --- /dev/null +++ b/tests/Feature/Admin/DashboardTest.php @@ -0,0 +1,54 @@ +createStoreContext(['hostname' => 'dash-metrics.test']); + + Order::factory()->count(3)->create([ + 'store_id' => $ctx['store']->id, + 'total_amount' => 5000, + 'status' => OrderStatus::Paid, + 'financial_status' => FinancialStatus::Paid, + 'placed_at' => now()->subDays(2), + ]); + + Order::factory()->create([ + 'store_id' => $ctx['store']->id, + 'total_amount' => 9999, + 'status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + 'placed_at' => now()->subDays(2), + ]); + + $this->actingAsAdmin($ctx['owner'], $ctx['store']); + + Livewire::test(Dashboard::class) + ->assertSee('Dashboard') + ->assertViewHas('metrics', function ($metrics) { + return $metrics['order_count'] === 3 + && $metrics['sales_amount'] === 15000 + && $metrics['aov_amount'] === 5000; + }); +}); + +it('renders recent orders in the dashboard', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'dash-recent.test']); + + Order::factory()->paid()->create([ + 'store_id' => $ctx['store']->id, + 'email' => 'buyer@example.com', + 'order_number' => '2001', + 'placed_at' => now(), + ]); + + $this->actingAsAdmin($ctx['owner'], $ctx['store']); + + Livewire::test(Dashboard::class) + ->assertSee('#2001') + ->assertSee('buyer@example.com'); +}); diff --git a/tests/Feature/Admin/DiscountManagementTest.php b/tests/Feature/Admin/DiscountManagementTest.php new file mode 100644 index 00000000..8f03955d --- /dev/null +++ b/tests/Feature/Admin/DiscountManagementTest.php @@ -0,0 +1,71 @@ +createStoreContext(['hostname' => 'dm-create.test']); + $this->actingAsAdmin($ctx['owner'], $ctx['store']); + + Livewire::test(Form::class) + ->set('type', 'code') + ->set('code', 'SAVE10') + ->set('value_type', 'percent') + ->set('value_amount', 10) + ->set('starts_at', now()->format('Y-m-d\TH:i')) + ->set('status', 'active') + ->call('save'); + + $discount = Discount::query()->where('code', 'SAVE10')->first(); + expect($discount)->not->toBeNull(); + expect($discount->value_amount)->toBe(10); +}); + +it('validates the discount form', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'dm-valid.test']); + $this->actingAsAdmin($ctx['owner'], $ctx['store']); + + Livewire::test(Form::class) + ->set('type', 'invalid') + ->set('value_type', 'bogus') + ->set('value_amount', -1) + ->set('starts_at', '') + ->set('status', 'foo') + ->call('save') + ->assertHasErrors(['type', 'value_type', 'value_amount', 'starts_at', 'status']); +}); + +it('lists discounts and deletes', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'dm-list.test']); + $discount = Discount::factory()->create(['store_id' => $ctx['store']->id, 'code' => 'KEEPME']); + $this->actingAsAdmin($ctx['owner'], $ctx['store']); + + Livewire::test(Index::class) + ->assertSee('KEEPME') + ->call('delete', $discount->id); + + expect(Discount::query()->find($discount->id))->toBeNull(); +}); + +it('updates an existing discount', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'dm-edit.test']); + $discount = Discount::factory()->create([ + 'store_id' => $ctx['store']->id, + 'code' => 'OLD', + 'value_type' => 'percent', + 'value_amount' => 5, + ]); + + $this->actingAsAdmin($ctx['owner'], $ctx['store']); + + Livewire::test(Form::class, ['discount' => $discount]) + ->set('code', 'NEW') + ->set('value_amount', 25) + ->call('save'); + + $fresh = $discount->fresh(); + expect($fresh->code)->toBe('NEW'); + expect($fresh->value_amount)->toBe(25); +}); diff --git a/tests/Feature/Admin/OrderManagementTest.php b/tests/Feature/Admin/OrderManagementTest.php new file mode 100644 index 00000000..350c6e10 --- /dev/null +++ b/tests/Feature/Admin/OrderManagementTest.php @@ -0,0 +1,175 @@ +create(['store_id' => $storeId, 'status' => ProductStatus::Active]); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 2000, 'requires_shipping' => true]); + InventoryItem::factory()->create([ + 'store_id' => $storeId, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 10, + 'policy' => InventoryPolicy::Deny, + ]); + + $cart = Cart::factory()->create(['store_id' => $storeId, 'currency' => 'USD', 'status' => CartStatus::Active]); + CartLine::factory()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => 1, + 'unit_price_amount' => 2000, + 'line_total_amount' => 2000, + ]); + $checkout = Checkout::factory()->create([ + 'store_id' => $storeId, + 'cart_id' => $cart->id, + 'status' => CheckoutStatus::Completed, + 'email' => 'buyer@example.com', + ]); + + $order = Order::factory()->create(array_merge([ + 'store_id' => $storeId, + 'checkout_id' => $checkout->id, + 'email' => 'buyer@example.com', + 'status' => OrderStatus::Paid, + 'financial_status' => FinancialStatus::Paid, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled, + 'payment_method' => PaymentMethod::CreditCard, + 'subtotal_amount' => 2000, + 'total_amount' => 2000, + 'placed_at' => now(), + ], $overrides)); + + OrderLine::factory()->create([ + 'order_id' => $order->id, + 'product_id' => $product->id, + 'variant_id' => $variant->id, + 'title_snapshot' => $product->title, + 'sku_snapshot' => $variant->sku, + 'quantity' => 1, + 'unit_price_amount' => 2000, + 'total_amount' => 2000, + ]); + + Payment::create([ + 'order_id' => $order->id, + 'provider' => 'mock', + 'method' => $order->payment_method, + 'status' => PaymentStatus::Captured, + 'amount' => $order->total_amount, + 'currency' => 'USD', + 'created_at' => now(), + ]); + + return $order->fresh(['lines.variant.product', 'payments', 'refunds', 'fulfillments.lines']); +} + +it('lists and filters orders', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'om-list.test']); + $pending = makeAdminOrder($ctx['store']->id, ['status' => OrderStatus::Pending, 'financial_status' => FinancialStatus::Pending]); + $paid = makeAdminOrder($ctx['store']->id, ['status' => OrderStatus::Paid]); + + $this->actingAsAdmin($ctx['owner'], $ctx['store']); + + Livewire::test(OrdersIndex::class) + ->assertSee('#'.$pending->order_number) + ->assertSee('#'.$paid->order_number) + ->set('status', 'paid') + ->assertSee('#'.$paid->order_number) + ->assertDontSee('#'.$pending->order_number); +}); + +it('shows order detail page', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'om-show.test']); + $order = makeAdminOrder($ctx['store']->id); + $this->actingAsAdmin($ctx['owner'], $ctx['store']); + + Livewire::test(OrdersShow::class, ['order' => $order]) + ->assertSee('Order #'.$order->order_number) + ->assertSee('buyer@example.com'); +}); + +it('fulfills order lines via the modal', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'om-fulfill.test']); + $order = makeAdminOrder($ctx['store']->id); + $this->actingAsAdmin($ctx['owner'], $ctx['store']); + + Livewire::test(OrdersShow::class, ['order' => $order]) + ->call('openFulfillment') + ->call('fulfill'); + + $fresh = $order->fresh(['fulfillments.lines']); + expect($fresh->fulfillments->count())->toBe(1); + expect($fresh->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled); +}); + +it('refunds an order fully via the modal', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'om-refund.test']); + $order = makeAdminOrder($ctx['store']->id); + $this->actingAsAdmin($ctx['owner'], $ctx['store']); + + Livewire::test(OrdersShow::class, ['order' => $order]) + ->call('openRefund') + ->set('refundAmount', $order->total_amount) + ->call('refund'); + + $fresh = $order->fresh(['refunds']); + expect($fresh->refunds->count())->toBe(1); + expect($fresh->financial_status)->toBe(FinancialStatus::Refunded); +}); + +it('confirms bank transfer payment', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'om-bt.test']); + $order = makeAdminOrder($ctx['store']->id, [ + 'status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + 'payment_method' => PaymentMethod::BankTransfer, + ]); + $order->payments()->update(['status' => PaymentStatus::Pending]); + + $this->actingAsAdmin($ctx['owner'], $ctx['store']); + + Livewire::test(OrdersShow::class, ['order' => $order]) + ->call('confirmBankTransfer'); + + $fresh = $order->fresh(); + expect($fresh->financial_status)->toBe(FinancialStatus::Paid); + expect($fresh->status)->toBe(OrderStatus::Paid); +}); + +it('cancels an order', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'om-cancel.test']); + $order = makeAdminOrder($ctx['store']->id, [ + 'status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + 'payment_method' => PaymentMethod::BankTransfer, + ]); + + $this->actingAsAdmin($ctx['owner'], $ctx['store']); + + Livewire::test(OrdersShow::class, ['order' => $order]) + ->call('cancelOrder'); + + expect($order->fresh()->status)->toBe(OrderStatus::Cancelled); +}); diff --git a/tests/Feature/Admin/ProductManagementTest.php b/tests/Feature/Admin/ProductManagementTest.php new file mode 100644 index 00000000..6004259a --- /dev/null +++ b/tests/Feature/Admin/ProductManagementTest.php @@ -0,0 +1,109 @@ +createStoreContext(['hostname' => 'pm-list.test']); + + Product::factory()->create([ + 'store_id' => $ctx['store']->id, + 'title' => 'Glacier Hoodie', + 'status' => ProductStatus::Active, + ]); + Product::factory()->create([ + 'store_id' => $ctx['store']->id, + 'title' => 'Ember Socks', + 'status' => ProductStatus::Draft, + ]); + + $this->actingAsAdmin($ctx['owner'], $ctx['store']); + + Livewire::test(Index::class) + ->assertSee('Glacier Hoodie') + ->assertSee('Ember Socks') + ->set('search', 'Hoodie') + ->assertSee('Glacier Hoodie') + ->assertDontSee('Ember Socks') + ->set('search', '') + ->set('status', 'draft') + ->assertSee('Ember Socks') + ->assertDontSee('Glacier Hoodie'); +}); + +it('creates a product via the form', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'pm-create.test']); + $this->actingAsAdmin($ctx['owner'], $ctx['store']); + + Livewire::test(Form::class) + ->set('title', 'Arctic Parka') + ->set('status', 'draft') + ->set('price_amount', 19900) + ->call('save'); + + $product = Product::query()->where('title', 'Arctic Parka')->first(); + expect($product)->not->toBeNull(); + expect($product->handle)->not->toBeEmpty(); + expect($product->variants()->count())->toBe(1); +}); + +it('edits an existing product', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'pm-edit.test']); + $product = Product::factory()->create([ + 'store_id' => $ctx['store']->id, + 'title' => 'Old title', + ]); + + $this->actingAsAdmin($ctx['owner'], $ctx['store']); + + Livewire::test(Form::class, ['product' => $product]) + ->set('title', 'New title') + ->call('save'); + + expect($product->fresh()->title)->toBe('New title'); +}); + +it('transitions status from draft to active when priced variants exist', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'pm-status.test']); + $product = Product::factory()->create([ + 'store_id' => $ctx['store']->id, + 'title' => 'Priced item', + 'status' => ProductStatus::Draft, + ]); + $product->variants()->create([ + 'price_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + 'position' => 0, + 'status' => 'active', + ]); + + $this->actingAsAdmin($ctx['owner'], $ctx['store']); + + Livewire::test(Form::class, ['product' => $product]) + ->set('status', 'active') + ->call('save'); + + expect($product->fresh()->status)->toBe(ProductStatus::Active); +}); + +it('bulk archives products', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'pm-archive.test']); + $products = Product::factory()->count(2)->create([ + 'store_id' => $ctx['store']->id, + 'status' => ProductStatus::Active, + ]); + + $this->actingAsAdmin($ctx['owner'], $ctx['store']); + + Livewire::test(Index::class) + ->set('selected', $products->pluck('id')->toArray()) + ->call('bulkArchive'); + + foreach ($products as $p) { + expect($p->fresh()->status)->toBe(ProductStatus::Archived); + } +}); diff --git a/tests/Feature/Admin/SettingsTest.php b/tests/Feature/Admin/SettingsTest.php new file mode 100644 index 00000000..b763b11a --- /dev/null +++ b/tests/Feature/Admin/SettingsTest.php @@ -0,0 +1,85 @@ +createStoreContext(['hostname' => 'st-general.test']); + $this->actingAsAdmin($ctx['owner'], $ctx['store']); + + Livewire::test(SettingsIndex::class) + ->set('name', 'Brand New Name') + ->set('default_currency', 'EUR') + ->set('timezone', 'Europe/Berlin') + ->set('default_locale', 'de') + ->call('saveGeneral'); + + $fresh = $ctx['store']->fresh(); + expect($fresh->name)->toBe('Brand New Name'); + expect($fresh->default_currency)->toBe('EUR'); + expect($fresh->timezone)->toBe('Europe/Berlin'); +}); + +it('adds and removes store domains', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'st-dom.test']); + $this->actingAsAdmin($ctx['owner'], $ctx['store']); + + Livewire::test(SettingsIndex::class) + ->set('tab', 'domains') + ->set('newDomain', 'extra.example.test') + ->call('addDomain'); + + $domain = StoreDomain::query()->where('hostname', 'extra.example.test')->first(); + expect($domain)->not->toBeNull(); + + Livewire::test(SettingsIndex::class) + ->call('removeDomain', $domain->id); + + expect(StoreDomain::query()->find($domain->id))->toBeNull(); +}); + +it('creates a shipping zone and rate', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'st-ship.test']); + $this->actingAsAdmin($ctx['owner'], $ctx['store']); + + Livewire::test(SettingsShipping::class) + ->set('newZoneName', 'US Zone') + ->set('newZoneCountries', 'US,CA') + ->call('addZone'); + + $zone = ShippingZone::query()->where('name', 'US Zone')->first(); + expect($zone)->not->toBeNull(); + expect($zone->countries_json)->toBe(['US', 'CA']); + + Livewire::test(SettingsShipping::class) + ->set("newRate.{$zone->id}.name", 'Standard') + ->set("newRate.{$zone->id}.type", 'flat') + ->set("newRate.{$zone->id}.amount", 799) + ->call('addRate', $zone->id); + + $rate = ShippingRate::query()->where('zone_id', $zone->id)->first(); + expect($rate)->not->toBeNull(); + expect($rate->config_json['amount'])->toBe(799); +}); + +it('saves tax settings', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'st-tax.test']); + $this->actingAsAdmin($ctx['owner'], $ctx['store']); + + Livewire::test(SettingsTaxes::class) + ->set('mode', 'manual') + ->set('prices_include_tax', true) + ->set('default_rate_bp', 2000) + ->call('save'); + + $row = TaxSettings::query()->where('store_id', $ctx['store']->id)->first(); + expect($row)->not->toBeNull(); + expect($row->prices_include_tax)->toBeTrue(); + expect($row->config_json['default_rate_bp'])->toBe(2000); +}); diff --git a/tests/Feature/Analytics/AggregationTest.php b/tests/Feature/Analytics/AggregationTest.php new file mode 100644 index 00000000..d2f185e4 --- /dev/null +++ b/tests/Feature/Analytics/AggregationTest.php @@ -0,0 +1,176 @@ +createStoreContext(); + $this->store = $context['store']; + $this->analytics = app(AnalyticsService::class); +}); + +it('aggregates daily metrics from raw events', function (): void { + $date = '2026-04-10'; + $start = $date.' 12:00:00'; + + for ($i = 0; $i < 5; $i++) { + DB::table('analytics_events')->insert([ + 'store_id' => $this->store->id, + 'type' => AnalyticsEventType::PageView->value, + 'session_id' => "s-pv-{$i}", + 'properties_json' => '{}', + 'client_event_id' => "pv-{$i}", + 'occurred_at' => $start, + 'created_at' => $start, + ]); + } + + for ($i = 0; $i < 3; $i++) { + DB::table('analytics_events')->insert([ + 'store_id' => $this->store->id, + 'type' => AnalyticsEventType::AddToCart->value, + 'session_id' => "s-atc-{$i}", + 'properties_json' => '{}', + 'client_event_id' => "atc-{$i}", + 'occurred_at' => $start, + 'created_at' => $start, + ]); + } + + for ($i = 0; $i < 2; $i++) { + DB::table('analytics_events')->insert([ + 'store_id' => $this->store->id, + 'type' => AnalyticsEventType::CheckoutCompleted->value, + 'session_id' => "s-cc-{$i}", + 'properties_json' => '{}', + 'client_event_id' => "cc-{$i}", + 'occurred_at' => $start, + 'created_at' => $start, + ]); + } + + $row = $this->analytics->aggregate($this->store, $date); + + expect($row->add_to_cart_count)->toBe(3); + expect($row->checkout_completed_count)->toBe(2); + expect($row->visits_count)->toBe(10); +}); + +it('calculates revenue and AOV correctly', function (): void { + $date = '2026-04-11'; + $placed = $date.' 12:00:00'; + + foreach ([1000, 2000, 3000] as $i => $total) { + DB::table('orders')->insert([ + 'store_id' => $this->store->id, + 'order_number' => "ORD-{$date}-{$i}", + 'payment_method' => 'credit_card', + 'status' => 'confirmed', + 'financial_status' => 'paid', + 'fulfillment_status' => 'unfulfilled', + 'currency' => 'USD', + 'subtotal_amount' => $total, + 'discount_amount' => 0, + 'shipping_amount' => 0, + 'tax_amount' => 0, + 'total_amount' => $total, + 'placed_at' => $placed, + 'created_at' => $placed, + 'updated_at' => $placed, + ]); + } + + $row = $this->analytics->aggregate($this->store, $date); + + expect($row->orders_count)->toBe(3); + expect($row->revenue_amount)->toBe(6000); + expect($row->aov_amount)->toBe(2000); +}); + +it('runs idempotently when aggregated twice', function (): void { + $date = '2026-04-12'; + $placed = $date.' 12:00:00'; + + DB::table('orders')->insert([ + 'store_id' => $this->store->id, + 'order_number' => "ORD-{$date}-0", + 'payment_method' => 'credit_card', + 'status' => 'confirmed', + 'financial_status' => 'paid', + 'fulfillment_status' => 'unfulfilled', + 'currency' => 'USD', + 'subtotal_amount' => 5000, + 'discount_amount' => 0, + 'shipping_amount' => 0, + 'tax_amount' => 0, + 'total_amount' => 5000, + 'placed_at' => $placed, + 'created_at' => $placed, + 'updated_at' => $placed, + ]); + + $this->analytics->aggregate($this->store, $date); + $this->analytics->aggregate($this->store, $date); + + $rows = AnalyticsDaily::query()->withoutGlobalScopes()->where('store_id', $this->store->id)->where('date', $date)->get(); + expect($rows)->toHaveCount(1); + expect($rows->first()->revenue_amount)->toBe(5000); + expect($rows->first()->orders_count)->toBe(1); +}); + +it('processes all stores when run via the AggregateAnalytics job', function (): void { + $date = '2026-04-13'; + $placed = $date.' 12:00:00'; + + $other = $this->createStoreContext(['hostname' => 'other.test']); + + foreach ([$this->store, $other['store']] as $i => $store) { + DB::table('orders')->insert([ + 'store_id' => $store->id, + 'order_number' => "ORD-{$date}-{$i}", + 'payment_method' => 'credit_card', + 'status' => 'confirmed', + 'financial_status' => 'paid', + 'fulfillment_status' => 'unfulfilled', + 'currency' => 'USD', + 'subtotal_amount' => 1500, + 'discount_amount' => 0, + 'shipping_amount' => 0, + 'tax_amount' => 0, + 'total_amount' => 1500, + 'placed_at' => $placed, + 'created_at' => $placed, + 'updated_at' => $placed, + ]); + } + + (new AggregateAnalytics($date))->handle($this->analytics); + + $rows = AnalyticsDaily::query()->withoutGlobalScopes()->where('date', $date)->get(); + expect($rows)->toHaveCount(2); + expect($rows->pluck('revenue_amount')->all())->toEqualCanonicalizing([1500, 1500]); +}); + +it('getDailyMetrics returns rows between start and end date inclusive', function (): void { + foreach (['2026-04-01', '2026-04-05', '2026-04-10'] as $date) { + AnalyticsDaily::query()->create([ + 'store_id' => $this->store->id, + 'date' => $date, + 'orders_count' => 1, + 'revenue_amount' => 1000, + 'aov_amount' => 1000, + 'visits_count' => 5, + 'add_to_cart_count' => 2, + 'checkout_started_count' => 1, + 'checkout_completed_count' => 1, + ]); + } + + $rows = $this->analytics->getDailyMetrics($this->store, '2026-04-02', '2026-04-09'); + + expect($rows)->toHaveCount(1); + expect($rows->first()->date)->toBe('2026-04-05'); +}); diff --git a/tests/Feature/Analytics/EventIngestionTest.php b/tests/Feature/Analytics/EventIngestionTest.php new file mode 100644 index 00000000..41190a00 --- /dev/null +++ b/tests/Feature/Analytics/EventIngestionTest.php @@ -0,0 +1,73 @@ +createStoreContext(); + $this->store = $context['store']; + $this->analytics = app(AnalyticsService::class); +}); + +it('tracks a page view event', function (): void { + $this->analytics->track($this->store, AnalyticsEventType::PageView); + + expect(AnalyticsEvent::query()->where('type', 'page_view')->count())->toBe(1); +}); + +it('tracks an add to cart event with properties', function (): void { + $this->analytics->track($this->store, AnalyticsEventType::AddToCart, [ + 'product_id' => 42, + 'quantity' => 2, + ]); + + $event = AnalyticsEvent::query()->where('type', 'add_to_cart')->first(); + expect($event)->not->toBeNull(); + expect($event->properties_json)->toBe(['product_id' => 42, 'quantity' => 2]); +}); + +it('scopes events to the current store', function (): void { + $other = $this->createStoreContext(['hostname' => 'other.test']); + + app()->instance('current_store', $this->store->fresh()); + $this->analytics->track($this->store, AnalyticsEventType::PageView); + + app()->instance('current_store', $other['store']->fresh()); + $this->analytics->track($other['store'], AnalyticsEventType::PageView); + + expect(AnalyticsEvent::query()->withoutGlobalScopes()->where('store_id', $this->store->id)->count())->toBe(1); + expect(AnalyticsEvent::query()->withoutGlobalScopes()->where('store_id', $other['store']->id)->count())->toBe(1); +}); + +it('includes session id when available', function (): void { + $this->analytics->track( + $this->store, + AnalyticsEventType::PageView, + [], + sessionId: 'sess-abc-123' + ); + + expect(AnalyticsEvent::query()->value('session_id'))->toBe('sess-abc-123'); +}); + +it('includes customer id when authenticated', function (): void { + $customer = Customer::factory()->create(['store_id' => $this->store->id]); + + $this->analytics->track( + $this->store, + AnalyticsEventType::ProductView, + [], + customerId: $customer->id, + ); + + expect(AnalyticsEvent::query()->value('customer_id'))->toBe($customer->id); +}); + +it('deduplicates events by client_event_id', function (): void { + $this->analytics->track($this->store, AnalyticsEventType::PageView, clientEventId: 'evt-1'); + $this->analytics->track($this->store, AnalyticsEventType::PageView, clientEventId: 'evt-1'); + + expect(AnalyticsEvent::query()->count())->toBe(1); +}); diff --git a/tests/Feature/Auth/AdminAuthTest.php b/tests/Feature/Auth/AdminAuthTest.php new file mode 100644 index 00000000..2e9895cd --- /dev/null +++ b/tests/Feature/Auth/AdminAuthTest.php @@ -0,0 +1,57 @@ +get('http://shop.test/admin/login') + ->assertOk() + ->assertSee('Admin Login'); +})->skip('requires primary domain setup'); // baseline smoke replaced below + +it('renders the admin login page on a real store host', function (): void { + $this->createStoreContext(['hostname' => 'admin-auth.test']); + // admin/login is not store-scoped - the storefront middleware group still runs, so use a host with a store + $this->get('http://admin-auth.test/admin/login')->assertOk()->assertSee('Admin Login'); +}); + +it('authenticates an admin user with valid credentials', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'admin-login.test']); + $user = $ctx['owner']; + $user->forceFill(['password' => Hash::make('pa55word!')])->save(); + + $this->from('http://admin-login.test/admin/login'); + + \Livewire\Livewire::test(\App\Livewire\Admin\Auth\Login::class) + ->set('email', $user->email) + ->set('password', 'pa55word!') + ->call('login') + ->assertRedirect(route('admin.dashboard')); + + expect(Auth::guard('web')->check())->toBeTrue(); +}); + +it('rejects invalid credentials', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'admin-bad.test']); + + \Livewire\Livewire::test(\App\Livewire\Admin\Auth\Login::class) + ->set('email', $ctx['owner']->email) + ->set('password', 'wrong') + ->call('login') + ->assertHasErrors(['email']); + + expect(Auth::guard('web')->check())->toBeFalse(); +}); + +it('logs the last_login_at timestamp', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'admin-ll.test']); + $user = $ctx['owner']; + $user->forceFill(['password' => Hash::make('letmein123')])->save(); + + \Livewire\Livewire::test(\App\Livewire\Admin\Auth\Login::class) + ->set('email', $user->email) + ->set('password', 'letmein123') + ->call('login'); + + expect($user->fresh()->last_login_at)->not->toBeNull(); +}); diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php deleted file mode 100644 index fff11fd7..00000000 --- a/tests/Feature/Auth/AuthenticationTest.php +++ /dev/null @@ -1,69 +0,0 @@ -get(route('login')); - - $response->assertOk(); -}); - -test('users can authenticate using the login screen', function () { - $user = User::factory()->create(); - - $response = $this->post(route('login.store'), [ - 'email' => $user->email, - 'password' => 'password', - ]); - - $response - ->assertSessionHasNoErrors() - ->assertRedirect(route('dashboard', absolute: false)); - - $this->assertAuthenticated(); -}); - -test('users can not authenticate with invalid password', function () { - $user = User::factory()->create(); - - $response = $this->post(route('login.store'), [ - 'email' => $user->email, - 'password' => 'wrong-password', - ]); - - $response->assertSessionHasErrorsIn('email'); - - $this->assertGuest(); -}); - -test('users with two factor enabled are redirected to two factor challenge', function () { - if (! Features::canManageTwoFactorAuthentication()) { - $this->markTestSkipped('Two-factor authentication is not enabled.'); - } - Features::twoFactorAuthentication([ - 'confirm' => true, - 'confirmPassword' => true, - ]); - - $user = User::factory()->withTwoFactor()->create(); - - $response = $this->post(route('login.store'), [ - 'email' => $user->email, - 'password' => 'password', - ]); - - $response->assertRedirect(route('two-factor.login')); - $this->assertGuest(); -}); - -test('users can logout', function () { - $user = User::factory()->create(); - - $response = $this->actingAs($user)->post(route('logout')); - - $response->assertRedirect(route('home')); - $this->assertGuest(); -}); \ No newline at end of file diff --git a/tests/Feature/Auth/CustomerAuthTest.php b/tests/Feature/Auth/CustomerAuthTest.php new file mode 100644 index 00000000..9d776a8c --- /dev/null +++ b/tests/Feature/Auth/CustomerAuthTest.php @@ -0,0 +1,96 @@ +createStoreContext(['hostname' => 'customer-login.test']); + + $this->get('http://customer-login.test/account/login') + ->assertOk() + ->assertSee('Sign in'); +}); + +it('registers a new customer and logs them in', function (): void { + $this->createStoreContext(['hostname' => 'customer-reg.test']); + + \Livewire\Livewire::test(\App\Livewire\Storefront\Account\Auth\Register::class) + ->set('first_name', 'Billy') + ->set('last_name', 'Buyer') + ->set('email', 'billy@example.com') + ->set('password', 'password') + ->set('password_confirmation', 'password') + ->call('register') + ->assertRedirect(route('account.dashboard')); + + expect(Auth::guard('customer')->check())->toBeTrue(); + expect(Customer::withoutGlobalScopes()->where('email', 'billy@example.com')->exists())->toBeTrue(); +}); + +it('authenticates a customer with valid credentials', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'customer-a.test']); + + Customer::withoutGlobalScopes()->create([ + 'store_id' => $ctx['store']->id, + 'email' => 'c@example.com', + 'password' => Hash::make('password'), + 'first_name' => 'C', + 'last_name' => 'User', + 'state' => 'active', + 'email_verified_at' => now(), + ]); + + \Livewire\Livewire::test(\App\Livewire\Storefront\Account\Auth\Login::class) + ->set('email', 'c@example.com') + ->set('password', 'password') + ->call('login') + ->assertRedirect(route('account.dashboard')); + + expect(Auth::guard('customer')->check())->toBeTrue(); +}); + +it('scopes customer login to current store', function (): void { + $a = $this->createStoreContext(['hostname' => 'store-a.test']); + + Customer::withoutGlobalScopes()->create([ + 'store_id' => $a['store']->id, + 'email' => 'only-in-a@example.com', + 'password' => Hash::make('password'), + 'state' => 'active', + ]); + + // Switch to store B + $b = $this->createStoreContext(['hostname' => 'store-b.test']); + + \Livewire\Livewire::test(\App\Livewire\Storefront\Account\Auth\Login::class) + ->set('email', 'only-in-a@example.com') + ->set('password', 'password') + ->call('login') + ->assertHasErrors(['email']); + + expect(Auth::guard('customer')->check())->toBeFalse(); +}); + +it('allows the same email in different stores', function (): void { + $a = $this->createStoreContext(['hostname' => 'both-a.test']); + Customer::withoutGlobalScopes()->create([ + 'store_id' => $a['store']->id, + 'email' => 'shared@example.com', + 'password' => Hash::make('password'), + 'state' => 'active', + ]); + + $b = $this->createStoreContext(['hostname' => 'both-b.test']); + + \Livewire\Livewire::test(\App\Livewire\Storefront\Account\Auth\Register::class) + ->set('first_name', 'S') + ->set('last_name', 'B') + ->set('email', 'shared@example.com') + ->set('password', 'password') + ->set('password_confirmation', 'password') + ->call('register') + ->assertRedirect(route('account.dashboard')); + + expect(Customer::withoutGlobalScopes()->where('email', 'shared@example.com')->count())->toBe(2); +}); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php deleted file mode 100644 index 66f58e36..00000000 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ /dev/null @@ -1,69 +0,0 @@ -unverified()->create(); - - $response = $this->actingAs($user)->get(route('verification.notice')); - - $response->assertOk(); -}); - -test('email can be verified', function () { - $user = User::factory()->unverified()->create(); - - Event::fake(); - - $verificationUrl = URL::temporarySignedRoute( - 'verification.verify', - now()->addMinutes(60), - ['id' => $user->id, 'hash' => sha1($user->email)] - ); - - $response = $this->actingAs($user)->get($verificationUrl); - - Event::assertDispatched(Verified::class); - - expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); - $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); -}); - -test('email is not verified with invalid hash', function () { - $user = User::factory()->unverified()->create(); - - $verificationUrl = URL::temporarySignedRoute( - 'verification.verify', - now()->addMinutes(60), - ['id' => $user->id, 'hash' => sha1('wrong-email')] - ); - - $this->actingAs($user)->get($verificationUrl); - - expect($user->fresh()->hasVerifiedEmail())->toBeFalse(); -}); - -test('already verified user visiting verification link is redirected without firing event again', function () { - $user = User::factory()->create([ - 'email_verified_at' => now(), - ]); - - Event::fake(); - - $verificationUrl = URL::temporarySignedRoute( - 'verification.verify', - now()->addMinutes(60), - ['id' => $user->id, 'hash' => sha1($user->email)] - ); - - $this->actingAs($user)->get($verificationUrl) - ->assertRedirect(route('dashboard', absolute: false).'?verified=1'); - - expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); - Event::assertNotDispatched(Verified::class); -}); \ No newline at end of file diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php deleted file mode 100644 index f42a259e..00000000 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ /dev/null @@ -1,13 +0,0 @@ -create(); - - $response = $this->actingAs($user)->get(route('password.confirm')); - - $response->assertOk(); -}); \ No newline at end of file diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php deleted file mode 100644 index bea78251..00000000 --- a/tests/Feature/Auth/PasswordResetTest.php +++ /dev/null @@ -1,61 +0,0 @@ -get(route('password.request')); - - $response->assertOk(); -}); - -test('reset password link can be requested', function () { - Notification::fake(); - - $user = User::factory()->create(); - - $this->post(route('password.request'), ['email' => $user->email]); - - Notification::assertSentTo($user, ResetPassword::class); -}); - -test('reset password screen can be rendered', function () { - Notification::fake(); - - $user = User::factory()->create(); - - $this->post(route('password.request'), ['email' => $user->email]); - - Notification::assertSentTo($user, ResetPassword::class, function ($notification) { - $response = $this->get(route('password.reset', $notification->token)); - $response->assertOk(); - - return true; - }); -}); - -test('password can be reset with valid token', function () { - Notification::fake(); - - $user = User::factory()->create(); - - $this->post(route('password.request'), ['email' => $user->email]); - - Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { - $response = $this->post(route('password.update'), [ - 'token' => $notification->token, - 'email' => $user->email, - 'password' => 'password', - 'password_confirmation' => 'password', - ]); - - $response - ->assertSessionHasNoErrors() - ->assertRedirect(route('login', absolute: false)); - - return true; - }); -}); \ No newline at end of file diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php deleted file mode 100644 index c22ea5e1..00000000 --- a/tests/Feature/Auth/RegistrationTest.php +++ /dev/null @@ -1,23 +0,0 @@ -get(route('register')); - - $response->assertOk(); -}); - -test('new users can register', function () { - $response = $this->post(route('register.store'), [ - 'name' => 'John Doe', - 'email' => 'test@example.com', - 'password' => 'password', - 'password_confirmation' => 'password', - ]); - - $response->assertSessionHasNoErrors() - ->assertRedirect(route('dashboard', absolute: false)); - - $this->assertAuthenticated(); -}); \ No newline at end of file diff --git a/tests/Feature/Auth/SanctumTokenTest.php b/tests/Feature/Auth/SanctumTokenTest.php new file mode 100644 index 00000000..1283787c --- /dev/null +++ b/tests/Feature/Auth/SanctumTokenTest.php @@ -0,0 +1,27 @@ +createStoreContext(['hostname' => 'sanctum-a.test']); + Sanctum::actingAs($ctx['owner'], ['read-admin']); + + $this->getJson('http://sanctum-a.test/api/admin/v1/health') + ->assertOk() + ->assertJson(['ok' => true]); +}); + +it('rejects API requests without a token', function (): void { + $this->createStoreContext(['hostname' => 'sanctum-b.test']); + $this->getJson('http://sanctum-b.test/api/admin/v1/health') + ->assertUnauthorized(); +}); + +it('creates a personal access token with abilities', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'sanctum-c.test']); + + $token = $ctx['owner']->createToken('cli', ['read-products', 'write-products']); + + expect($token->accessToken->exists)->toBeTrue(); + expect($token->accessToken->abilities)->toEqual(['read-products', 'write-products']); +}); diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php deleted file mode 100644 index cda794f2..00000000 --- a/tests/Feature/Auth/TwoFactorChallengeTest.php +++ /dev/null @@ -1,34 +0,0 @@ -markTestSkipped('Two-factor authentication is not enabled.'); - } - - $response = $this->get(route('two-factor.login')); - - $response->assertRedirect(route('login')); -}); - -test('two factor challenge can be rendered', function () { - if (! Features::canManageTwoFactorAuthentication()) { - $this->markTestSkipped('Two-factor authentication is not enabled.'); - } - - Features::twoFactorAuthentication([ - 'confirm' => true, - 'confirmPassword' => true, - ]); - - $user = User::factory()->withTwoFactor()->create(); - - $this->post(route('login.store'), [ - 'email' => $user->email, - 'password' => 'password', - ])->assertRedirect(route('two-factor.login')); -}); \ No newline at end of file diff --git a/tests/Feature/Cart/CartApiTest.php b/tests/Feature/Cart/CartApiTest.php new file mode 100644 index 00000000..b023138d --- /dev/null +++ b/tests/Feature/Cart/CartApiTest.php @@ -0,0 +1,90 @@ +createStoreContext(); + $this->store = $ctx['store']; + $this->bindStoreToAppHost($this->store); + $this->service = app(CartService::class); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => ProductStatus::Active]); + $this->variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 2500]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $this->variant->id, + 'quantity_on_hand' => 50, + 'policy' => InventoryPolicy::Deny, + ]); +}); + +it('creates a cart via the API', function (): void { + $response = $this->postJson('/api/storefront/v1/carts', ['currency' => 'EUR']); + + $response->assertCreated() + ->assertJsonPath('currency', 'EUR') + ->assertJsonPath('cart_version', 1) + ->assertJsonPath('status', 'active') + ->assertJsonPath('totals.subtotal', 0); +}); + +it('retrieves an existing cart with totals', function (): void { + $cart = $this->service->create($this->store); + $this->service->addLine($cart, $this->variant->id, 2); + + $response = $this->getJson("/api/storefront/v1/carts/{$cart->id}"); + + $response->assertOk() + ->assertJsonPath('id', $cart->id) + ->assertJsonPath('totals.subtotal', 5000) + ->assertJsonPath('totals.item_count', 2); +}); + +it('adds a line via the API', function (): void { + $cart = $this->service->create($this->store); + + $response = $this->postJson("/api/storefront/v1/carts/{$cart->id}/lines", [ + 'variant_id' => $this->variant->id, + 'quantity' => 3, + ]); + + $response->assertOk() + ->assertJsonPath('totals.item_count', 3) + ->assertJsonPath('cart_version', 2); +}); + +it('updates a cart line with matching version', function (): void { + $cart = $this->service->create($this->store); + $line = $this->service->addLine($cart, $this->variant->id, 1); + + $response = $this->putJson("/api/storefront/v1/carts/{$cart->id}/lines/{$line->id}", [ + 'quantity' => 4, + 'cart_version' => $cart->fresh()->cart_version, + ]); + + $response->assertOk() + ->assertJsonPath('totals.item_count', 4); +}); + +it('removes a cart line', function (): void { + $cart = $this->service->create($this->store); + $line = $this->service->addLine($cart, $this->variant->id, 1); + + $response = $this->deleteJson("/api/storefront/v1/carts/{$cart->id}/lines/{$line->id}", [ + 'cart_version' => $cart->fresh()->cart_version, + ]); + + $response->assertOk() + ->assertJsonPath('totals.item_count', 0); +}); + +it('returns 404 for a missing cart', function (): void { + $response = $this->getJson('/api/storefront/v1/carts/99999'); + + $response->assertNotFound(); +}); diff --git a/tests/Feature/Cart/CartServiceTest.php b/tests/Feature/Cart/CartServiceTest.php new file mode 100644 index 00000000..bb120c84 --- /dev/null +++ b/tests/Feature/Cart/CartServiceTest.php @@ -0,0 +1,118 @@ +createStoreContext(); + $this->store = $ctx['store']; + $this->service = app(CartService::class); +}); + +function makeVariant(int $storeId, int $stock = 100, int $price = 1000): ProductVariant +{ + $product = Product::factory()->create([ + 'store_id' => $storeId, + 'status' => ProductStatus::Active, + ]); + + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'price_amount' => $price, + ]); + + InventoryItem::factory()->create([ + 'store_id' => $storeId, + 'variant_id' => $variant->id, + 'quantity_on_hand' => $stock, + 'policy' => InventoryPolicy::Deny, + ]); + + return $variant->fresh(['product', 'inventoryItem']); +} + +it('creates a cart with store currency and version 1', function (): void { + $cart = $this->service->create($this->store); + + expect($cart->currency)->toBe($this->store->default_currency ?? 'USD'); + expect($cart->cart_version)->toBe(1); + expect($cart->status)->toBe(CartStatus::Active); +}); + +it('adds a line and increments version', function (): void { + $variant = makeVariant($this->store->id); + $cart = $this->service->create($this->store); + + $line = $this->service->addLine($cart, $variant->id, 2); + + expect($line->quantity)->toBe(2); + expect($line->line_total_amount)->toBe(2000); + expect($cart->fresh()->cart_version)->toBe(2); +}); + +it('increments quantity when adding an existing variant', function (): void { + $variant = makeVariant($this->store->id); + $cart = $this->service->create($this->store); + + $this->service->addLine($cart, $variant->id, 1); + $this->service->addLine($cart, $variant->id, 2); + + $lines = CartLine::query()->where('cart_id', $cart->id)->get(); + expect($lines)->toHaveCount(1); + expect($lines->first()->quantity)->toBe(3); +}); + +it('rejects adding more than available inventory when policy is deny', function (): void { + $variant = makeVariant($this->store->id, stock: 2); + $cart = $this->service->create($this->store); + + $this->service->addLine($cart, $variant->id, 2); + + expect(fn () => $this->service->addLine($cart, $variant->id, 1)) + ->toThrow(InsufficientInventoryException::class); +}); + +it('updates line quantity and recalculates amounts', function (): void { + $variant = makeVariant($this->store->id); + $cart = $this->service->create($this->store); + $line = $this->service->addLine($cart, $variant->id, 1); + + $updated = $this->service->updateLineQuantity($cart, $line->id, 4); + + expect($updated->quantity)->toBe(4); + expect($updated->line_total_amount)->toBe(4000); +}); + +it('removes a line and increments version', function (): void { + $variant = makeVariant($this->store->id); + $cart = $this->service->create($this->store); + $line = $this->service->addLine($cart, $variant->id, 1); + + $this->service->removeLine($cart, $line->id); + + expect(CartLine::query()->where('cart_id', $cart->id)->count())->toBe(0); + expect($cart->fresh()->cart_version)->toBe(3); +}); + +it('merges guest cart into customer cart keeping max quantity', function (): void { + $variant = makeVariant($this->store->id); + + $guest = $this->service->create($this->store); + $customer = $this->service->create($this->store); + + $this->service->addLine($guest, $variant->id, 2); + $this->service->addLine($customer, $variant->id, 1); + + $merged = $this->service->mergeOnLogin($guest, $customer); + + $mergedLine = $merged->fresh()->lines->firstWhere('variant_id', $variant->id); + expect($mergedLine->quantity)->toBe(2); + expect($guest->fresh()->status)->toBe(CartStatus::Abandoned); +}); diff --git a/tests/Feature/Cart/CartVersionTest.php b/tests/Feature/Cart/CartVersionTest.php new file mode 100644 index 00000000..db07bf05 --- /dev/null +++ b/tests/Feature/Cart/CartVersionTest.php @@ -0,0 +1,93 @@ +createStoreContext(); + $this->store = $ctx['store']; + $this->service = app(CartService::class); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => ProductStatus::Active]); + $this->variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 1000]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $this->variant->id, + 'quantity_on_hand' => 100, + 'policy' => InventoryPolicy::Deny, + ]); +}); + +it('allows updates when expected version matches', function (): void { + $cart = $this->service->create($this->store); + $line = $this->service->addLine($cart, $this->variant->id, 1); + + $current = $cart->fresh()->cart_version; + $updated = $this->service->updateLineQuantity($cart, $line->id, 3, $current); + + expect($updated->quantity)->toBe(3); +}); + +it('throws when expected version is stale', function (): void { + $cart = $this->service->create($this->store); + $line = $this->service->addLine($cart, $this->variant->id, 1); + + expect(fn () => $this->service->updateLineQuantity($cart, $line->id, 5, 99)) + ->toThrow(CartVersionMismatchException::class); +}); + +it('increments version on add, update, and remove', function (): void { + $cart = $this->service->create($this->store); + expect($cart->cart_version)->toBe(1); + + $line = $this->service->addLine($cart, $this->variant->id, 1); + expect($cart->fresh()->cart_version)->toBe(2); + + $this->service->updateLineQuantity($cart, $line->id, 2); + expect($cart->fresh()->cart_version)->toBe(3); + + $this->service->removeLine($cart, $line->id); + expect($cart->fresh()->cart_version)->toBe(4); +}); + +it('returns HTTP 409 when API client sends a stale version', function (): void { + $this->bindStoreToAppHost($this->store); + + $cart = $this->service->create($this->store); + $line = $this->service->addLine($cart, $this->variant->id, 1); + + $response = $this->putJson("/api/storefront/v1/carts/{$cart->id}/lines/{$line->id}", [ + 'quantity' => 2, + 'cart_version' => 0, + ]); + + $response->assertStatus(409) + ->assertJsonStructure(['message', 'expected_version', 'current_version']); +}); + +it('exposes expected and current versions on the exception', function (): void { + $cart = $this->service->create($this->store); + $line = $this->service->addLine($cart, $this->variant->id, 1); + + try { + $this->service->updateLineQuantity($cart, $line->id, 5, 99); + $this->fail('Expected CartVersionMismatchException'); + } catch (CartVersionMismatchException $e) { + expect($e->expected)->toBe(99); + expect($e->current)->toBe($cart->fresh()->cart_version); + } +}); + +it('does not bump version on read-only fetches', function (): void { + $cart = $this->service->create($this->store); + $before = $cart->cart_version; + + $cart->refresh(); + + expect($cart->cart_version)->toBe($before); +}); diff --git a/tests/Feature/Checkout/CheckoutFlowTest.php b/tests/Feature/Checkout/CheckoutFlowTest.php new file mode 100644 index 00000000..2fd8f900 --- /dev/null +++ b/tests/Feature/Checkout/CheckoutFlowTest.php @@ -0,0 +1,99 @@ +createStoreContext(); + $this->store = $ctx['store']; + $this->cartService = app(CartService::class); + $this->service = app(CheckoutService::class); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => ProductStatus::Active]); + $this->variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 2000, 'requires_shipping' => true]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $this->variant->id, + 'quantity_on_hand' => 10, + 'policy' => InventoryPolicy::Deny, + ]); + + $zone = ShippingZone::factory()->create(['store_id' => $this->store->id, 'countries_json' => ['US']]); + $this->rate = ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'type' => ShippingRateType::Flat, + 'config_json' => ['amount' => 799], + ]); +}); + +it('runs the full happy-path checkout flow', function (): void { + $cart = $this->cartService->create($this->store); + $this->cartService->addLine($cart, $this->variant->id, 2); + + $checkout = $this->service->startFromCart($cart); + $this->service->setAddress($checkout, [ + 'email' => 'a@b.co', + 'shipping_address' => [ + 'first_name' => 'A', 'last_name' => 'B', + 'address1' => '1', 'city' => 'NY', + 'country_code' => 'US', 'zip' => '10001', + ], + ]); + $this->service->setShippingMethod($checkout->fresh(), $this->rate->id); + $checkout = $this->service->selectPaymentMethod($checkout->fresh(), 'credit_card'); + + expect($checkout->status)->toBe(CheckoutStatus::PaymentPending); + expect($checkout->expires_at)->not->toBeNull(); +}); + +it('releases reserved inventory when checkout expires via job', function (): void { + $cart = $this->cartService->create($this->store); + $this->cartService->addLine($cart, $this->variant->id, 1); + + $checkout = $this->service->startFromCart($cart); + $this->service->setAddress($checkout, [ + 'email' => 'a@b.co', + 'shipping_address' => [ + 'first_name' => 'A', 'last_name' => 'B', + 'address1' => '1', 'city' => 'NY', + 'country_code' => 'US', 'zip' => '10001', + ], + ]); + $this->service->setShippingMethod($checkout->fresh(), $this->rate->id); + $this->service->selectPaymentMethod($checkout->fresh(), 'credit_card'); + + // Force checkout into expired candidate by pushing expires_at into the past + $fresh = Checkout::query()->find($checkout->id); + $fresh->expires_at = now()->subHour(); + $fresh->save(); + + (new ExpireAbandonedCheckouts)->handle($this->service); + + expect($fresh->fresh()->status)->toBe(CheckoutStatus::Expired); + expect($this->variant->fresh()->inventoryItem->quantity_reserved)->toBe(0); +}); + +it('marks old carts as abandoned', function (): void { + $cart = $this->cartService->create($this->store); + $this->cartService->addLine($cart, $this->variant->id, 1); + + Cart::query()->whereKey($cart->id)->update(['updated_at' => now()->subDays(20)]); + + (new CleanupAbandonedCarts)->handle(); + + expect(Cart::query()->find($cart->id)->status)->toBe(CartStatus::Abandoned); +}); diff --git a/tests/Feature/Checkout/CheckoutStateTest.php b/tests/Feature/Checkout/CheckoutStateTest.php new file mode 100644 index 00000000..15d29ed7 --- /dev/null +++ b/tests/Feature/Checkout/CheckoutStateTest.php @@ -0,0 +1,164 @@ +createStoreContext(); + $this->store = $ctx['store']; + $this->cartService = app(CartService::class); + $this->service = app(CheckoutService::class); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => ProductStatus::Active]); + $this->variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 2000, 'requires_shipping' => true]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $this->variant->id, + 'quantity_on_hand' => 10, + 'policy' => InventoryPolicy::Deny, + ]); + + $zone = ShippingZone::factory()->create(['store_id' => $this->store->id, 'countries_json' => ['US']]); + $this->rate = ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'type' => ShippingRateType::Flat, + 'config_json' => ['amount' => 799], + ]); +}); + +function makeCart(mixed $ctx): \App\Models\Cart +{ + $cart = $ctx->cartService->create($ctx->store); + $ctx->cartService->addLine($cart, $ctx->variant->id, 1); + + return $cart->fresh(); +} + +it('starts at status=started', function (): void { + $cart = makeCart($this); + $checkout = $this->service->startFromCart($cart); + expect($checkout->status)->toBe(CheckoutStatus::Started); +}); + +it('reuses an existing active checkout for the same cart', function (): void { + $cart = makeCart($this); + $first = $this->service->startFromCart($cart); + $second = $this->service->startFromCart($cart); + expect($first->id)->toBe($second->id); +}); + +it('transitions to addressed after setAddress', function (): void { + $cart = makeCart($this); + $checkout = $this->service->startFromCart($cart); + + $checkout = $this->service->setAddress($checkout, [ + 'email' => 'a@b.co', + 'shipping_address' => [ + 'first_name' => 'A', 'last_name' => 'B', + 'address1' => '1', 'city' => 'NY', + 'country_code' => 'US', 'zip' => '10001', + ], + ]); + + expect($checkout->status)->toBe(CheckoutStatus::Addressed); + expect($checkout->email)->toBe('a@b.co'); +}); + +it('transitions to shipping_selected after setShippingMethod', function (): void { + $cart = makeCart($this); + $checkout = $this->service->startFromCart($cart); + + $this->service->setAddress($checkout, [ + 'email' => 'a@b.co', + 'shipping_address' => [ + 'first_name' => 'A', 'last_name' => 'B', + 'address1' => '1', 'city' => 'NY', + 'country_code' => 'US', 'zip' => '10001', + ], + ]); + + $checkout = $this->service->setShippingMethod($checkout->fresh(), $this->rate->id); + + expect($checkout->status)->toBe(CheckoutStatus::ShippingSelected); + expect($checkout->shipping_method_id)->toBe($this->rate->id); +}); + +it('refuses to set shipping before address', function (): void { + $cart = makeCart($this); + $checkout = $this->service->startFromCart($cart); + + expect(fn () => $this->service->setShippingMethod($checkout, $this->rate->id)) + ->toThrow(CheckoutStateException::class); +}); + +it('reserves inventory when selectPaymentMethod is called', function (): void { + $cart = makeCart($this); + $checkout = $this->service->startFromCart($cart); + + $this->service->setAddress($checkout, [ + 'email' => 'a@b.co', + 'shipping_address' => [ + 'first_name' => 'A', 'last_name' => 'B', + 'address1' => '1', 'city' => 'NY', + 'country_code' => 'US', 'zip' => '10001', + ], + ]); + $checkout = $this->service->setShippingMethod($checkout->fresh(), $this->rate->id); + + $checkout = $this->service->selectPaymentMethod($checkout, 'credit_card'); + + expect($checkout->status)->toBe(CheckoutStatus::PaymentPending); + expect($checkout->payment_method)->toBe('credit_card'); + + $item = $this->variant->fresh()->inventoryItem; + expect($item->quantity_reserved)->toBe(1); +}); + +it('releases inventory on expireCheckout when previously reserved', function (): void { + $cart = makeCart($this); + $checkout = $this->service->startFromCart($cart); + + $this->service->setAddress($checkout, [ + 'email' => 'a@b.co', + 'shipping_address' => [ + 'first_name' => 'A', 'last_name' => 'B', + 'address1' => '1', 'city' => 'NY', + 'country_code' => 'US', 'zip' => '10001', + ], + ]); + $checkout = $this->service->setShippingMethod($checkout->fresh(), $this->rate->id); + $checkout = $this->service->selectPaymentMethod($checkout, 'credit_card'); + + $this->service->expireCheckout($checkout); + + expect($checkout->fresh()->status)->toBe(CheckoutStatus::Expired); + expect($this->variant->fresh()->inventoryItem->quantity_reserved)->toBe(0); +}); + +it('rejects invalid payment methods', function (): void { + $cart = makeCart($this); + $checkout = $this->service->startFromCart($cart); + + $this->service->setAddress($checkout, [ + 'email' => 'a@b.co', + 'shipping_address' => [ + 'first_name' => 'A', 'last_name' => 'B', + 'address1' => '1', 'city' => 'NY', + 'country_code' => 'US', 'zip' => '10001', + ], + ]); + $checkout = $this->service->setShippingMethod($checkout->fresh(), $this->rate->id); + + expect(fn () => $this->service->selectPaymentMethod($checkout, 'bitcoin')) + ->toThrow(\Illuminate\Validation\ValidationException::class); +}); diff --git a/tests/Feature/Checkout/DiscountTest.php b/tests/Feature/Checkout/DiscountTest.php new file mode 100644 index 00000000..c60d979c --- /dev/null +++ b/tests/Feature/Checkout/DiscountTest.php @@ -0,0 +1,170 @@ +createStoreContext(); + $this->store = $ctx['store']; + $this->cartService = app(CartService::class); + $this->discountService = app(DiscountService::class); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => ProductStatus::Active]); + $this->variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 1000]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $this->variant->id, + 'quantity_on_hand' => 100, + 'policy' => InventoryPolicy::Deny, + ]); +}); + +it('looks up codes case-insensitively', function (): void { + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'SUMMER20', + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 20, + ]); + + expect($this->discountService->validate('summer20', $this->store))->toBeInstanceOf(Discount::class); +}); + +it('throws not_found for missing codes', function (): void { + expect(fn () => $this->discountService->validate('BOGUS', $this->store)) + ->toThrow(InvalidDiscountException::class); +}); + +it('throws not_yet_active when starts_at is in the future', function (): void { + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'FUTURE', + 'starts_at' => now()->addDay(), + ]); + + try { + $this->discountService->validate('FUTURE', $this->store); + $this->fail('Expected InvalidDiscountException'); + } catch (InvalidDiscountException $e) { + expect($e->reason)->toBe('not_yet_active'); + } +}); + +it('throws expired when ends_at is in the past', function (): void { + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'OLD', + 'starts_at' => now()->subWeek(), + 'ends_at' => now()->subDay(), + ]); + + try { + $this->discountService->validate('OLD', $this->store); + $this->fail('Expected InvalidDiscountException'); + } catch (InvalidDiscountException $e) { + expect($e->reason)->toBe('expired'); + } +}); + +it('throws usage_limit_reached when hit', function (): void { + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'LIMITED', + 'usage_limit' => 5, + 'usage_count' => 5, + ]); + + try { + $this->discountService->validate('LIMITED', $this->store); + $this->fail('Expected InvalidDiscountException'); + } catch (InvalidDiscountException $e) { + expect($e->reason)->toBe('usage_limit_reached'); + } +}); + +it('enforces minimum purchase amount', function (): void { + $discount = Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'MIN50', + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'rules_json' => ['min_purchase_amount' => 5000], + ]); + + $cart = $this->cartService->create($this->store); + $this->cartService->addLine($cart, $this->variant->id, 1); // 1000 cents + + try { + $this->discountService->validate('MIN50', $this->store, $cart->fresh()->load('lines')); + $this->fail('Expected InvalidDiscountException'); + } catch (InvalidDiscountException $e) { + expect($e->reason)->toBe('minimum_not_met'); + } +}); + +it('calculates percent discounts correctly', function (): void { + $discount = Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'TENPCT', + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + ]); + + $cart = $this->cartService->create($this->store); + $this->cartService->addLine($cart, $this->variant->id, 3); // 3000 cents + + $result = $this->discountService->calculate($discount, $cart->fresh()->load('lines')); + expect($result['total'])->toBe(300); +}); + +it('caps fixed discounts at qualifying subtotal', function (): void { + $discount = Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'CAPIT', + 'value_type' => DiscountValueType::Fixed, + 'value_amount' => 10000, + ]); + + $cart = $this->cartService->create($this->store); + $this->cartService->addLine($cart, $this->variant->id, 2); // 2000 cents + + $result = $this->discountService->calculate($discount, $cart->fresh()->load('lines')); + expect($result['total'])->toBe(2000); +}); + +it('returns zero when free_shipping discount', function (): void { + $discount = Discount::factory()->freeShipping()->create([ + 'store_id' => $this->store->id, + 'code' => 'FREESHIP', + ]); + + $cart = $this->cartService->create($this->store); + $this->cartService->addLine($cart, $this->variant->id, 1); + + $result = $this->discountService->calculate($discount, $cart->fresh()->load('lines')); + expect($result['total'])->toBe(0); +}); + +it('rejects non-active statuses', function (): void { + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'OFF', + 'status' => DiscountStatus::Disabled, + ]); + + try { + $this->discountService->validate('OFF', $this->store); + $this->fail('Expected InvalidDiscountException'); + } catch (InvalidDiscountException $e) { + expect($e->reason)->toBe('expired'); + } +}); diff --git a/tests/Feature/Checkout/PricingIntegrationTest.php b/tests/Feature/Checkout/PricingIntegrationTest.php new file mode 100644 index 00000000..990d8a3e --- /dev/null +++ b/tests/Feature/Checkout/PricingIntegrationTest.php @@ -0,0 +1,150 @@ +createStoreContext(); + $this->store = $ctx['store']; + $this->cart = app(CartService::class); + $this->checkout = app(CheckoutService::class); + $this->pricing = app(PricingEngine::class); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => ProductStatus::Active]); + $this->variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'price_amount' => 2500, + 'weight_g' => 200, + 'requires_shipping' => true, + ]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $this->variant->id, + 'quantity_on_hand' => 100, + 'policy' => InventoryPolicy::Deny, + ]); + + $this->zone = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'countries_json' => ['US'], + ]); + $this->rate = ShippingRate::factory()->create([ + 'zone_id' => $this->zone->id, + 'type' => ShippingRateType::Flat, + 'config_json' => ['amount' => 799], + ]); +}); + +it('runs the full pipeline end to end', function (): void { + TaxSettings::factory()->create([ + 'store_id' => $this->store->id, + 'prices_include_tax' => false, + 'config_json' => ['default_rate_bps' => 1000], + ]); + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'SAVE10', + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + ]); + + $cart = $this->cart->create($this->store); + $this->cart->addLine($cart, $this->variant->id, 2); // 5000 cents + + $checkout = $this->checkout->startFromCart($cart); + $this->checkout->setAddress($checkout, [ + 'email' => 'customer@example.com', + 'shipping_address' => [ + 'first_name' => 'Jane', 'last_name' => 'Doe', + 'address1' => '1 Main', 'city' => 'LA', + 'country_code' => 'US', 'province_code' => 'US-CA', 'zip' => '10001', + ], + ]); + $this->checkout->setShippingMethod($checkout->fresh(), $this->rate->id); + $checkout = $this->checkout->applyDiscount($checkout->fresh(), 'SAVE10'); + + $result = $this->pricing->calculate($checkout->fresh()); + + expect($result->subtotal)->toBe(5000); + expect($result->discount)->toBe(500); + expect($result->shipping)->toBe(799); + expect($result->taxTotal)->toBe(530); // 10% of (4500 + 799) = 529.9 -> 530 rounded + expect($result->total)->toBe(5000 - 500 + 799 + 530); +}); + +it('stores totals snapshot on the checkout', function (): void { + $cart = $this->cart->create($this->store); + $this->cart->addLine($cart, $this->variant->id, 1); + + $checkout = $this->checkout->startFromCart($cart); + $result = $this->pricing->calculate($checkout); + + expect($checkout->fresh()->totals_json)->toBe($result->toArray()); +}); + +it('applies free shipping discount to zero out shipping', function (): void { + Discount::factory()->freeShipping()->create([ + 'store_id' => $this->store->id, + 'code' => 'FREESHIP', + ]); + + $cart = $this->cart->create($this->store); + $this->cart->addLine($cart, $this->variant->id, 1); + + $checkout = $this->checkout->startFromCart($cart); + $this->checkout->setAddress($checkout, [ + 'email' => 'c@x.co', + 'shipping_address' => [ + 'first_name' => 'X', 'last_name' => 'Y', + 'address1' => '1', 'city' => 'NY', + 'country_code' => 'US', 'zip' => '10001', + ], + ]); + $this->checkout->setShippingMethod($checkout->fresh(), $this->rate->id); + $checkout = $this->checkout->applyDiscount($checkout->fresh(), 'FREESHIP'); + $result = $this->pricing->calculate($checkout->fresh()); + + expect($result->shipping)->toBe(0); +}); + +it('skips shipping for digital-only carts', function (): void { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => ProductStatus::Active]); + $digital = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'price_amount' => 1000, + 'requires_shipping' => false, + ]); + InventoryItem::factory()->create(['store_id' => $this->store->id, 'variant_id' => $digital->id, 'quantity_on_hand' => 10, 'policy' => InventoryPolicy::Deny]); + + $cart = $this->cart->create($this->store); + $this->cart->addLine($cart, $digital->id, 1); + + $result = $this->pricing->calculateForCart($cart->fresh()->load('lines.variant'), $this->store, shippingRateId: $this->rate->id); + + expect($result->shipping)->toBe(0); +}); + +it('ignores invalid discount codes silently during pricing', function (): void { + $cart = $this->cart->create($this->store); + $this->cart->addLine($cart, $this->variant->id, 1); + + $result = $this->pricing->calculateForCart( + $cart->fresh()->load('lines.variant'), + $this->store, + discountCode: 'DOES-NOT-EXIST', + ); + + expect($result->discount)->toBe(0); +}); diff --git a/tests/Feature/Checkout/ShippingTest.php b/tests/Feature/Checkout/ShippingTest.php new file mode 100644 index 00000000..8efdf2fd --- /dev/null +++ b/tests/Feature/Checkout/ShippingTest.php @@ -0,0 +1,141 @@ +createStoreContext(); + $this->store = $ctx['store']; + $this->calc = app(ShippingCalculator::class); +}); + +it('matches a country-only zone when no region given', function (): void { + $zone = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'countries_json' => ['US'], + 'regions_json' => [], + ]); + + $matched = $this->calc->getMatchingZone($this->store, ['country_code' => 'US']); + expect($matched?->id)->toBe($zone->id); +}); + +it('prefers region-specific match over country-only', function (): void { + $country = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'countries_json' => ['US'], + 'regions_json' => [], + ]); + + $specific = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'countries_json' => ['US'], + 'regions_json' => ['US-CA'], + ]); + + $matched = $this->calc->getMatchingZone($this->store, [ + 'country_code' => 'US', + 'province_code' => 'US-CA', + ]); + + expect($matched?->id)->toBe($specific->id); +}); + +it('returns null when no zone matches the country', function (): void { + ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'countries_json' => ['US'], + ]); + + expect($this->calc->getMatchingZone($this->store, ['country_code' => 'DE']))->toBeNull(); +}); + +it('calculates a flat rate regardless of cart size', function (): void { + $zone = ShippingZone::factory()->create(['store_id' => $this->store->id, 'countries_json' => ['US']]); + $rate = ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'type' => ShippingRateType::Flat, + 'config_json' => ['amount' => 799], + ]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + + expect($this->calc->calculate($rate, $cart))->toBe(799); +}); + +it('applies weight-based rates to physical-only cart', function (): void { + $zone = ShippingZone::factory()->create(['store_id' => $this->store->id, 'countries_json' => ['US']]); + $rate = ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'type' => ShippingRateType::Weight, + 'config_json' => [ + 'ranges' => [ + ['min_g' => 0, 'max_g' => 500, 'amount' => 500], + ['min_g' => 501, 'max_g' => 5000, 'amount' => 1000], + ], + ], + ]); + + $product = Product::factory()->create(['store_id' => $this->store->id]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'weight_g' => 300, + 'requires_shipping' => true, + 'price_amount' => 1000, + ]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 100, + 'policy' => InventoryPolicy::Deny, + ]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + CartLine::factory()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => 2, + 'unit_price_amount' => 1000, + 'line_subtotal_amount' => 2000, + 'line_total_amount' => 2000, + ]); + + // 600g total -> second range (1000) + expect($this->calc->calculate($rate->fresh(), $cart->fresh()))->toBe(1000); +}); + +it('applies price-based rates with open-ended max', function (): void { + $zone = ShippingZone::factory()->create(['store_id' => $this->store->id, 'countries_json' => ['US']]); + $rate = ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'type' => ShippingRateType::Price, + 'config_json' => [ + 'ranges' => [ + ['min_amount' => 0, 'max_amount' => 5000, 'amount' => 799], + ['min_amount' => 5001, 'amount' => 0], + ], + ], + ]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + $product = Product::factory()->create(['store_id' => $this->store->id]); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 6000]); + CartLine::factory()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => 1, + 'unit_price_amount' => 6000, + 'line_subtotal_amount' => 6000, + 'line_total_amount' => 6000, + ]); + + expect($this->calc->calculate($rate, $cart->fresh()))->toBe(0); +}); diff --git a/tests/Feature/Checkout/TaxTest.php b/tests/Feature/Checkout/TaxTest.php new file mode 100644 index 00000000..53e3a904 --- /dev/null +++ b/tests/Feature/Checkout/TaxTest.php @@ -0,0 +1,121 @@ +createStoreContext(); + $this->store = $ctx['store']; + $this->cart = app(CartService::class); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => ProductStatus::Active]); + $this->variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 1000]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $this->variant->id, + 'quantity_on_hand' => 100, + 'policy' => InventoryPolicy::Deny, + ]); +}); + +it('adds tax on top when prices exclude tax', function (): void { + TaxSettings::factory()->create([ + 'store_id' => $this->store->id, + 'prices_include_tax' => false, + 'config_json' => ['default_rate_bps' => 1900, 'rate_name' => 'VAT'], + ]); + + $cart = $this->cart->create($this->store); + $this->cart->addLine($cart, $this->variant->id, 1); + + $result = app(PricingEngine::class)->calculateForCart($cart->fresh()->load('lines'), $this->store); + + expect($result->subtotal)->toBe(1000); + expect($result->taxTotal)->toBe(190); + expect($result->total)->toBe(1190); +}); + +it('extracts tax from gross when prices include tax', function (): void { + TaxSettings::factory()->create([ + 'store_id' => $this->store->id, + 'prices_include_tax' => true, + 'config_json' => ['default_rate_bps' => 1900, 'rate_name' => 'VAT'], + ]); + + // variant price stays at 1000 (gross when include_tax) + $cart = $this->cart->create($this->store); + $this->cart->addLine($cart, $this->variant->id, 1); + + $result = app(PricingEngine::class)->calculateForCart($cart->fresh()->load('lines'), $this->store); + + expect($result->subtotal)->toBe(1000); + expect($result->taxTotal)->toBe(160); // 1000 - intdiv(1000*10000, 11900) = 1000 - 840 = 160 +}); + +it('returns zero tax when no settings exist', function (): void { + $cart = $this->cart->create($this->store); + $this->cart->addLine($cart, $this->variant->id, 1); + + $result = app(PricingEngine::class)->calculateForCart($cart->fresh()->load('lines'), $this->store); + + expect($result->taxTotal)->toBe(0); + expect($result->total)->toBe(1000); +}); + +it('reduces tax proportionally after discount', function (): void { + TaxSettings::factory()->create([ + 'store_id' => $this->store->id, + 'prices_include_tax' => false, + 'config_json' => ['default_rate_bps' => 1000], + ]); + + $cart = $this->cart->create($this->store); + $this->cart->addLine($cart, $this->variant->id, 1); + + $result = app(PricingEngine::class)->calculateForCart($cart->fresh()->load('lines'), $this->store); + + expect($result->taxTotal)->toBe(100); +}); + +it('handles region-specific rates', function (): void { + TaxSettings::factory()->create([ + 'store_id' => $this->store->id, + 'prices_include_tax' => false, + 'config_json' => [ + 'default_rate_bps' => 0, + 'region_rates' => ['US-CA' => 800], + ], + ]); + + $cart = $this->cart->create($this->store); + $this->cart->addLine($cart, $this->variant->id, 1); + + $result = app(PricingEngine::class)->calculateForCart( + $cart->fresh()->load('lines'), + $this->store, + ['country_code' => 'US', 'province_code' => 'US-CA'], + ); + + expect($result->taxTotal)->toBe(80); +}); + +it('applies tax to shipping when in taxable amount', function (): void { + TaxSettings::factory()->create([ + 'store_id' => $this->store->id, + 'prices_include_tax' => false, + 'config_json' => ['default_rate_bps' => 1000], + ]); + + $cart = $this->cart->create($this->store); + $this->cart->addLine($cart, $this->variant->id, 1); // 1000 cents + + // No shipping selected, so only lines taxed + $result = app(PricingEngine::class)->calculateForCart($cart->fresh()->load('lines'), $this->store); + expect($result->taxTotal)->toBe(100); +}); diff --git a/tests/Feature/Customers/AddressManagementTest.php b/tests/Feature/Customers/AddressManagementTest.php new file mode 100644 index 00000000..92f4ac27 --- /dev/null +++ b/tests/Feature/Customers/AddressManagementTest.php @@ -0,0 +1,131 @@ +createStoreContext(['hostname' => 'addr-'.uniqid().'.test']); + + $this->customer = Customer::withoutGlobalScopes()->create([ + 'store_id' => $ctx['store']->id, + 'email' => 'addr-'.uniqid().'@example.com', + 'password' => Hash::make('password'), + 'state' => 'active', + ]); + + $this->actingAsCustomer($this->customer); +}); + +it('creates the first address and marks it default', function (): void { + Livewire::test(AddressesIndex::class) + ->call('openCreate') + ->set('first_name', 'Billy') + ->set('last_name', 'Buyer') + ->set('address1', '1 Main St') + ->set('city', 'Berlin') + ->set('postal_code', '10115') + ->set('country_code', 'DE') + ->call('save') + ->assertSet('showForm', false) + ->assertHasNoErrors(); + + $address = CustomerAddress::query()->where('customer_id', $this->customer->id)->first(); + expect($address)->not->toBeNull(); + expect($address->is_default)->toBeTrue(); +}); + +it('enforces only one default address per customer', function (): void { + $first = CustomerAddress::query()->create([ + 'customer_id' => $this->customer->id, + 'label' => 'First', + 'address_json' => ['first_name' => 'A', 'last_name' => 'B', 'address1' => 'x', 'city' => 'c', 'postal_code' => 'p', 'country_code' => 'DE'], + 'is_default' => true, + ]); + + Livewire::test(AddressesIndex::class) + ->call('openCreate') + ->set('first_name', 'New') + ->set('last_name', 'One') + ->set('address1', '2 Elm St') + ->set('city', 'Hamburg') + ->set('postal_code', '20095') + ->set('country_code', 'DE') + ->set('is_default', true) + ->call('save') + ->assertHasNoErrors(); + + $addresses = CustomerAddress::query()->where('customer_id', $this->customer->id)->get(); + expect($addresses)->toHaveCount(2); + expect($addresses->where('is_default', true))->toHaveCount(1); + expect($first->fresh()->is_default)->toBeFalse(); +}); + +it('validates required fields', function (): void { + Livewire::test(AddressesIndex::class) + ->call('openCreate') + ->call('save') + ->assertHasErrors(['first_name', 'last_name', 'address1', 'city', 'postal_code', 'country_code']); +}); + +it('promotes an existing address to default', function (): void { + $a = CustomerAddress::query()->create([ + 'customer_id' => $this->customer->id, + 'address_json' => ['first_name' => 'A', 'last_name' => 'A', 'address1' => '1', 'city' => 'c', 'postal_code' => 'p', 'country_code' => 'DE'], + 'is_default' => true, + ]); + + $b = CustomerAddress::query()->create([ + 'customer_id' => $this->customer->id, + 'address_json' => ['first_name' => 'B', 'last_name' => 'B', 'address1' => '2', 'city' => 'c', 'postal_code' => 'p', 'country_code' => 'DE'], + 'is_default' => false, + ]); + + Livewire::test(AddressesIndex::class) + ->call('setDefault', $b->id); + + expect($a->fresh()->is_default)->toBeFalse(); + expect($b->fresh()->is_default)->toBeTrue(); +}); + +it('deletes an address and promotes another to default if needed', function (): void { + $default = CustomerAddress::query()->create([ + 'customer_id' => $this->customer->id, + 'address_json' => ['first_name' => 'D', 'last_name' => 'D', 'address1' => '1', 'city' => 'c', 'postal_code' => 'p', 'country_code' => 'DE'], + 'is_default' => true, + ]); + + $other = CustomerAddress::query()->create([ + 'customer_id' => $this->customer->id, + 'address_json' => ['first_name' => 'O', 'last_name' => 'O', 'address1' => '2', 'city' => 'c', 'postal_code' => 'p', 'country_code' => 'DE'], + 'is_default' => false, + ]); + + Livewire::test(AddressesIndex::class) + ->call('delete', $default->id); + + expect(CustomerAddress::query()->find($default->id))->toBeNull(); + expect($other->fresh()->is_default)->toBeTrue(); +}); + +it('prevents customers from touching addresses they do not own', function (): void { + $other = Customer::withoutGlobalScopes()->create([ + 'store_id' => $this->customer->store_id, + 'email' => 'other-'.uniqid().'@example.com', + 'password' => Hash::make('password'), + 'state' => 'active', + ]); + + $otherAddress = CustomerAddress::query()->create([ + 'customer_id' => $other->id, + 'address_json' => ['first_name' => 'X'], + 'is_default' => true, + ]); + + Livewire::test(AddressesIndex::class) + ->call('delete', $otherAddress->id); + + expect(CustomerAddress::query()->find($otherAddress->id))->not->toBeNull(); +}); diff --git a/tests/Feature/Customers/CustomerAccountTest.php b/tests/Feature/Customers/CustomerAccountTest.php new file mode 100644 index 00000000..a7eb0c2d --- /dev/null +++ b/tests/Feature/Customers/CustomerAccountTest.php @@ -0,0 +1,172 @@ +createStoreContext(['hostname' => 'guard-store.test']); + + $response = $this->get('http://guard-store.test/account'); + + $response->assertRedirect(); + expect($response->headers->get('Location'))->toContain('login'); +}); + +it('renders dashboard and recent orders for authenticated customer', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'dash-store.test']); + + $customer = Customer::withoutGlobalScopes()->create([ + 'store_id' => $ctx['store']->id, + 'email' => 'dash@example.com', + 'password' => Hash::make('password'), + 'first_name' => 'Dash', + 'last_name' => 'User', + 'state' => 'active', + 'email_verified_at' => now(), + ]); + + Order::factory()->create([ + 'store_id' => $ctx['store']->id, + 'customer_id' => $customer->id, + 'order_number' => '100042', + 'status' => OrderStatus::Paid, + 'financial_status' => FinancialStatus::Paid, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled, + 'payment_method' => PaymentMethod::CreditCard, + 'currency' => 'EUR', + 'total_amount' => 4200, + 'placed_at' => now(), + ]); + + $this->actingAsCustomer($customer) + ->get('http://dash-store.test/account') + ->assertOk() + ->assertSee('Recent orders') + ->assertSee('#100042'); +}); + +it('lists only the authenticated customer orders', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'orders-store.test']); + + $mine = Customer::withoutGlobalScopes()->create([ + 'store_id' => $ctx['store']->id, + 'email' => 'mine@example.com', + 'password' => Hash::make('password'), + 'state' => 'active', + ]); + + $other = Customer::withoutGlobalScopes()->create([ + 'store_id' => $ctx['store']->id, + 'email' => 'other@example.com', + 'password' => Hash::make('password'), + 'state' => 'active', + ]); + + Order::factory()->create([ + 'store_id' => $ctx['store']->id, + 'customer_id' => $mine->id, + 'order_number' => 'MINE-1', + 'currency' => 'EUR', + 'total_amount' => 1000, + ]); + + Order::factory()->create([ + 'store_id' => $ctx['store']->id, + 'customer_id' => $other->id, + 'order_number' => 'OTHER-1', + 'currency' => 'EUR', + 'total_amount' => 2000, + ]); + + $this->actingAsCustomer($mine) + ->get('http://orders-store.test/account/orders') + ->assertOk() + ->assertSee('MINE-1') + ->assertDontSee('OTHER-1'); +}); + +it('returns 404 when opening another customer order', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'order-404.test']); + + $mine = Customer::withoutGlobalScopes()->create([ + 'store_id' => $ctx['store']->id, + 'email' => 'mine2@example.com', + 'password' => Hash::make('password'), + 'state' => 'active', + ]); + + $other = Customer::withoutGlobalScopes()->create([ + 'store_id' => $ctx['store']->id, + 'email' => 'other2@example.com', + 'password' => Hash::make('password'), + 'state' => 'active', + ]); + + $foreignOrder = Order::factory()->create([ + 'store_id' => $ctx['store']->id, + 'customer_id' => $other->id, + 'order_number' => 'FOREIGN-1', + ]); + + $this->actingAsCustomer($mine) + ->get('http://order-404.test/account/orders/'.$foreignOrder->order_number) + ->assertNotFound(); +}); + +it('shows order detail with lines for the owner', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'order-show.test']); + + $customer = Customer::withoutGlobalScopes()->create([ + 'store_id' => $ctx['store']->id, + 'email' => 'show@example.com', + 'password' => Hash::make('password'), + 'state' => 'active', + ]); + + $order = Order::factory()->create([ + 'store_id' => $ctx['store']->id, + 'customer_id' => $customer->id, + 'order_number' => '200001', + 'currency' => 'EUR', + 'subtotal_amount' => 2500, + 'total_amount' => 3000, + 'shipping_amount' => 500, + ]); + + OrderLine::factory()->create([ + 'order_id' => $order->id, + 'title_snapshot' => 'Mocha Mug', + 'quantity' => 2, + 'unit_price_amount' => 1250, + 'total_amount' => 2500, + ]); + + $this->actingAsCustomer($customer) + ->get('http://order-show.test/account/orders/200001') + ->assertOk() + ->assertSee('Mocha Mug') + ->assertSee('Order #200001'); +}); + +it('logs the customer out via POST', function (): void { + $ctx = $this->createStoreContext(['hostname' => 'logout-store.test']); + + $customer = Customer::withoutGlobalScopes()->create([ + 'store_id' => $ctx['store']->id, + 'email' => 'logout@example.com', + 'password' => Hash::make('password'), + 'state' => 'active', + ]); + + $this->actingAsCustomer($customer) + ->post('http://logout-store.test/account/logout') + ->assertRedirect(route('account.login')); + + expect(auth('customer')->check())->toBeFalse(); +}); diff --git a/tests/Feature/Customers/GuestCartMergeTest.php b/tests/Feature/Customers/GuestCartMergeTest.php new file mode 100644 index 00000000..ffc39084 --- /dev/null +++ b/tests/Feature/Customers/GuestCartMergeTest.php @@ -0,0 +1,71 @@ +createStoreContext(['hostname' => 'cart-merge.test']); + + $customer = Customer::withoutGlobalScopes()->create([ + 'store_id' => $ctx['store']->id, + 'email' => 'merge@example.com', + 'password' => Hash::make('password'), + 'state' => 'active', + ]); + + $product = Product::factory()->create(['store_id' => $ctx['store']->id]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'price_amount' => 1500, + 'currency' => 'EUR', + 'is_default' => true, + ]); + InventoryItem::factory()->create([ + 'store_id' => $ctx['store']->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 50, + ]); + + $guest = Cart::query()->create([ + 'store_id' => $ctx['store']->id, + 'customer_id' => null, + 'currency' => 'EUR', + 'status' => CartStatus::Active, + 'subtotal_amount' => 1500, + 'total_amount' => 1500, + 'cart_version' => 1, + ]); + + CartLine::query()->create([ + 'cart_id' => $guest->id, + 'variant_id' => $variant->id, + 'quantity' => 2, + 'unit_price_amount' => 1500, + 'total_amount' => 3000, + ]); + + session()->put(CartService::SESSION_KEY, $guest->id); + + Livewire::test(\App\Livewire\Storefront\Account\Auth\Login::class) + ->set('email', 'merge@example.com') + ->set('password', 'password') + ->call('login') + ->assertRedirect(route('account.dashboard')); + + $customerCartId = session()->get(CartService::SESSION_KEY); + $customerCart = Cart::query()->find($customerCartId); + + expect($customerCart)->not->toBeNull(); + expect($customerCart->customer_id)->toBe($customer->id); + expect($customerCart->lines()->count())->toBe(1); + expect($customerCart->lines()->first()->variant_id)->toBe($variant->id); + expect($customerCart->lines()->first()->quantity)->toBe(2); +}); diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php deleted file mode 100644 index fcd0258d..00000000 --- a/tests/Feature/DashboardTest.php +++ /dev/null @@ -1,18 +0,0 @@ -get(route('dashboard')); - $response->assertRedirect(route('login')); -}); - -test('authenticated users can visit the dashboard', function () { - $user = User::factory()->create(); - $this->actingAs($user); - - $response = $this->get(route('dashboard')); - $response->assertOk(); -}); \ No newline at end of file diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index 8b5843f4..00000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,7 +0,0 @@ -get('/'); - - $response->assertStatus(200); -}); diff --git a/tests/Feature/Orders/FulfillmentTest.php b/tests/Feature/Orders/FulfillmentTest.php new file mode 100644 index 00000000..70d5a767 --- /dev/null +++ b/tests/Feature/Orders/FulfillmentTest.php @@ -0,0 +1,101 @@ +createStoreContext(); + $this->store = $ctx['store']; +}); + +it('creates a pending fulfillment when order is paid', function (): void { + Event::fake([OrderFulfilled::class]); + + $order = Order::factory()->paid()->create(['store_id' => $this->store->id]); + $line = OrderLine::factory()->create(['order_id' => $order->id, 'quantity' => 2]); + + $fulfillment = app(FulfillmentService::class)->create($order->fresh(), [ + ['order_line_id' => $line->id, 'quantity' => 2], + ], ['tracking_company' => 'UPS', 'tracking_number' => '1Z']); + + expect($fulfillment->status)->toBe(FulfillmentShipmentStatus::Pending); + expect($fulfillment->tracking_company)->toBe('UPS'); + expect($order->fresh()->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled); + expect($order->fresh()->status)->toBe(OrderStatus::Fulfilled); + Event::assertDispatched(OrderFulfilled::class); +}); + +it('blocks fulfillment when financial_status is pending', function (): void { + $order = Order::factory()->create([ + 'store_id' => $this->store->id, + 'financial_status' => FinancialStatus::Pending, + ]); + $line = OrderLine::factory()->create(['order_id' => $order->id, 'quantity' => 1]); + + expect(fn () => app(FulfillmentService::class)->create($order, [ + ['order_line_id' => $line->id, 'quantity' => 1], + ]))->toThrow(FulfillmentGuardException::class); +}); + +it('allows fulfillment when partially refunded', function (): void { + $order = Order::factory()->create([ + 'store_id' => $this->store->id, + 'financial_status' => FinancialStatus::PartiallyRefunded, + ]); + $line = OrderLine::factory()->create(['order_id' => $order->id, 'quantity' => 1]); + + $fulfillment = app(FulfillmentService::class)->create($order, [ + ['order_line_id' => $line->id, 'quantity' => 1], + ]); + + expect($fulfillment->id)->not->toBeNull(); +}); + +it('marks partial fulfillment correctly', function (): void { + $order = Order::factory()->paid()->create(['store_id' => $this->store->id]); + $line = OrderLine::factory()->create(['order_id' => $order->id, 'quantity' => 3]); + + app(FulfillmentService::class)->create($order, [ + ['order_line_id' => $line->id, 'quantity' => 1], + ]); + + expect($order->fresh()->fulfillment_status)->toBe(FulfillmentStatus::Partial); +}); + +it('rejects quantity exceeding unfulfilled amount', function (): void { + $order = Order::factory()->paid()->create(['store_id' => $this->store->id]); + $line = OrderLine::factory()->create(['order_id' => $order->id, 'quantity' => 2]); + + app(FulfillmentService::class)->create($order, [ + ['order_line_id' => $line->id, 'quantity' => 2], + ]); + + expect(fn () => app(FulfillmentService::class)->create($order->fresh(), [ + ['order_line_id' => $line->id, 'quantity' => 1], + ]))->toThrow(\Illuminate\Validation\ValidationException::class); +}); + +it('marks a fulfillment as shipped and then delivered', function (): void { + $order = Order::factory()->paid()->create(['store_id' => $this->store->id]); + $line = OrderLine::factory()->create(['order_id' => $order->id, 'quantity' => 1]); + + $fulfillment = app(FulfillmentService::class)->create($order, [ + ['order_line_id' => $line->id, 'quantity' => 1], + ]); + + $shipped = app(FulfillmentService::class)->markAsShipped($fulfillment, ['tracking_number' => 'TRK1']); + expect($shipped->status)->toBe(FulfillmentShipmentStatus::Shipped); + expect($shipped->shipped_at)->not->toBeNull(); + + $delivered = app(FulfillmentService::class)->markAsDelivered($shipped); + expect($delivered->status)->toBe(FulfillmentShipmentStatus::Delivered); + expect($delivered->delivered_at)->not->toBeNull(); +}); diff --git a/tests/Feature/Orders/OrderCreationTest.php b/tests/Feature/Orders/OrderCreationTest.php new file mode 100644 index 00000000..8f73dd18 --- /dev/null +++ b/tests/Feature/Orders/OrderCreationTest.php @@ -0,0 +1,145 @@ +createStoreContext(); + $this->store = $ctx['store']; + $this->cartService = app(CartService::class); + $this->checkoutService = app(CheckoutService::class); + $this->orderService = app(OrderService::class); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => ProductStatus::Active]); + $this->variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 2000, 'requires_shipping' => true]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $this->variant->id, + 'quantity_on_hand' => 10, + 'policy' => InventoryPolicy::Deny, + ]); + + $zone = ShippingZone::factory()->create(['store_id' => $this->store->id, 'countries_json' => ['US']]); + $this->rate = ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'type' => ShippingRateType::Flat, + 'config_json' => ['amount' => 799], + ]); +}); + +function prepCheckout(mixed $ctx, string $paymentMethod = 'credit_card'): \App\Models\Checkout +{ + $cart = $ctx->cartService->create($ctx->store); + $ctx->cartService->addLine($cart, $ctx->variant->id, 1); + $checkout = $ctx->checkoutService->startFromCart($cart); + $ctx->checkoutService->setAddress($checkout, [ + 'email' => 'a@b.co', + 'shipping_address' => [ + 'first_name' => 'A', 'last_name' => 'B', + 'address1' => '1', 'city' => 'NY', + 'country_code' => 'US', 'zip' => '10001', + ], + ]); + $ctx->checkoutService->setShippingMethod($checkout->fresh(), $ctx->rate->id); + $ctx->checkoutService->selectPaymentMethod($checkout->fresh(), $paymentMethod); + + return $checkout->fresh(); +} + +it('creates a paid order with committed inventory for credit_card', function (): void { + Event::fake([OrderCreated::class, OrderPaid::class]); + + $checkout = prepCheckout($this); + $order = $this->orderService->createFromCheckout($checkout, ['card_number' => MockPaymentProvider::CARD_SUCCESS]); + + expect($order->status)->toBe(OrderStatus::Paid); + expect($order->financial_status)->toBe(FinancialStatus::Paid); + expect($order->payment_method)->toBe(PaymentMethod::CreditCard); + + $item = $this->variant->fresh()->inventoryItem; + expect($item->quantity_on_hand)->toBe(9); + expect($item->quantity_reserved)->toBe(0); + + Event::assertDispatched(OrderCreated::class); + Event::assertDispatched(OrderPaid::class); +}); + +it('produces sequential order numbers starting at 1001', function (): void { + $first = $this->orderService->createFromCheckout(prepCheckout($this), ['card_number' => MockPaymentProvider::CARD_SUCCESS]); + + // Need new cart since previous was converted + $second = $this->orderService->createFromCheckout(prepCheckout($this), ['card_number' => MockPaymentProvider::CARD_SUCCESS]); + + expect($first->order_number)->toBe('1001'); + expect($second->order_number)->toBe('1002'); +}); + +it('marks cart as converted and checkout as completed', function (): void { + $checkout = prepCheckout($this); + $this->orderService->createFromCheckout($checkout, ['card_number' => MockPaymentProvider::CARD_SUCCESS]); + + expect($checkout->cart->fresh()->status)->toBe(CartStatus::Converted); + expect($checkout->fresh()->status)->toBe(CheckoutStatus::Completed); +}); + +it('is idempotent when called twice for the same checkout', function (): void { + $checkout = prepCheckout($this); + $first = $this->orderService->createFromCheckout($checkout, ['card_number' => MockPaymentProvider::CARD_SUCCESS]); + $second = $this->orderService->createFromCheckout($checkout->fresh(), ['card_number' => MockPaymentProvider::CARD_SUCCESS]); + + expect($first->id)->toBe($second->id); + expect(Order::query()->count())->toBe(1); +}); + +it('releases reserved inventory on payment decline', function (): void { + $checkout = prepCheckout($this); + + expect(fn () => $this->orderService->createFromCheckout($checkout, ['card_number' => MockPaymentProvider::CARD_DECLINE])) + ->toThrow(\App\Exceptions\PaymentFailedException::class); + + $item = $this->variant->fresh()->inventoryItem; + expect($item->quantity_on_hand)->toBe(10); + expect($item->quantity_reserved)->toBe(0); +}); + +it('keeps inventory reserved for bank_transfer orders', function (): void { + $checkout = prepCheckout($this, 'bank_transfer'); + $order = $this->orderService->createFromCheckout($checkout); + + expect($order->status)->toBe(OrderStatus::Pending); + expect($order->financial_status)->toBe(FinancialStatus::Pending); + + $item = $this->variant->fresh()->inventoryItem; + expect($item->quantity_on_hand)->toBe(10); + expect($item->quantity_reserved)->toBe(1); +}); + +it('stores line snapshots on order creation', function (): void { + $checkout = prepCheckout($this); + $order = $this->orderService->createFromCheckout($checkout, ['card_number' => MockPaymentProvider::CARD_SUCCESS]); + + $line = $order->lines()->first(); + expect($line->title_snapshot)->not->toBeNull(); + expect($line->sku_snapshot)->not->toBeNull(); + expect($line->quantity)->toBe(1); + expect($line->unit_price_amount)->toBe(2000); +}); diff --git a/tests/Feature/Orders/RefundTest.php b/tests/Feature/Orders/RefundTest.php new file mode 100644 index 00000000..5d059277 --- /dev/null +++ b/tests/Feature/Orders/RefundTest.php @@ -0,0 +1,89 @@ +createStoreContext(); + $this->store = $ctx['store']; + + $product = \App\Models\Product::factory()->create(['store_id' => $this->store->id, 'status' => \App\Enums\ProductStatus::Active]); + $this->variant = \App\Models\ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 5000, 'requires_shipping' => true]); + \App\Models\InventoryItem::factory()->create(['store_id' => $this->store->id, 'variant_id' => $this->variant->id, 'quantity_on_hand' => 10, 'policy' => \App\Enums\InventoryPolicy::Deny]); + + $zone = \App\Models\ShippingZone::factory()->create(['store_id' => $this->store->id, 'countries_json' => ['US']]); + $this->rate = \App\Models\ShippingRate::factory()->create(['zone_id' => $zone->id, 'type' => \App\Enums\ShippingRateType::Flat, 'config_json' => ['amount' => 0]]); +}); + +function makeOrder(mixed $ctx, int $quantity = 2): \App\Models\Order +{ + $cart = app(CartService::class)->create($ctx->store); + app(CartService::class)->addLine($cart, $ctx->variant->id, $quantity); + $checkout = app(CheckoutService::class)->startFromCart($cart); + app(CheckoutService::class)->setAddress($checkout, [ + 'email' => 'a@b.co', + 'shipping_address' => [ + 'first_name' => 'A', 'last_name' => 'B', + 'address1' => '1', 'city' => 'NY', + 'country_code' => 'US', 'zip' => '10001', + ], + ]); + app(CheckoutService::class)->setShippingMethod($checkout->fresh(), $ctx->rate->id); + app(CheckoutService::class)->selectPaymentMethod($checkout->fresh(), 'credit_card'); + + return app(OrderService::class)->createFromCheckout($checkout->fresh(), ['card_number' => MockPaymentProvider::CARD_SUCCESS]); +} + +it('processes a full refund, marks order refunded, updates payment', function (): void { + Event::fake([OrderRefunded::class]); + + $order = makeOrder($this); + $payment = $order->payments()->first(); + $total = $order->total_amount; + + $refund = app(RefundService::class)->create($order, $payment, $total); + + expect($refund->status)->toBe(RefundStatus::Processed); + expect($order->fresh()->financial_status)->toBe(FinancialStatus::Refunded); + expect($order->fresh()->status)->toBe(OrderStatus::Refunded); + expect($payment->fresh()->status)->toBe(PaymentStatus::Refunded); + Event::assertDispatched(OrderRefunded::class); +}); + +it('marks partial refunds correctly', function (): void { + $order = makeOrder($this); + $payment = $order->payments()->first(); + + app(RefundService::class)->create($order, $payment, 1000); + + expect($order->fresh()->financial_status)->toBe(FinancialStatus::PartiallyRefunded); +}); + +it('rejects refunds larger than the refundable balance', function (): void { + $order = makeOrder($this); + $payment = $order->payments()->first(); + + expect(fn () => app(RefundService::class)->create($order, $payment, $order->total_amount + 1)) + ->toThrow(\Illuminate\Validation\ValidationException::class); +}); + +it('restocks inventory when flagged', function (): void { + $order = makeOrder($this, 2); + $payment = $order->payments()->first(); + + $before = $this->variant->fresh()->inventoryItem->quantity_on_hand; + + app(RefundService::class)->create($order, $payment, $order->total_amount, null, true); + + $after = $this->variant->fresh()->inventoryItem->quantity_on_hand; + expect($after)->toBe($before + 2); +}); diff --git a/tests/Feature/Payments/BankTransferConfirmationTest.php b/tests/Feature/Payments/BankTransferConfirmationTest.php new file mode 100644 index 00000000..a7c21365 --- /dev/null +++ b/tests/Feature/Payments/BankTransferConfirmationTest.php @@ -0,0 +1,84 @@ +createStoreContext(); + $this->store = $ctx['store']; +}); + +function prepBankOrder(mixed $ctx): \App\Models\Order +{ + $product = \App\Models\Product::factory()->create(['store_id' => $ctx->store->id, 'status' => \App\Enums\ProductStatus::Active]); + $variant = \App\Models\ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 3000, 'requires_shipping' => true]); + \App\Models\InventoryItem::factory()->create(['store_id' => $ctx->store->id, 'variant_id' => $variant->id, 'quantity_on_hand' => 5, 'policy' => \App\Enums\InventoryPolicy::Deny]); + + $zone = \App\Models\ShippingZone::factory()->create(['store_id' => $ctx->store->id, 'countries_json' => ['US']]); + $rate = \App\Models\ShippingRate::factory()->create(['zone_id' => $zone->id, 'type' => \App\Enums\ShippingRateType::Flat, 'config_json' => ['amount' => 500]]); + + $cart = app(\App\Services\CartService::class)->create($ctx->store); + app(\App\Services\CartService::class)->addLine($cart, $variant->id, 1); + $checkout = app(\App\Services\CheckoutService::class)->startFromCart($cart); + app(\App\Services\CheckoutService::class)->setAddress($checkout, [ + 'email' => 'a@b.co', + 'shipping_address' => [ + 'first_name' => 'A', 'last_name' => 'B', + 'address1' => '1', 'city' => 'NY', + 'country_code' => 'US', 'zip' => '10001', + ], + ]); + app(\App\Services\CheckoutService::class)->setShippingMethod($checkout->fresh(), $rate->id); + app(\App\Services\CheckoutService::class)->selectPaymentMethod($checkout->fresh(), 'bank_transfer'); + + return app(OrderService::class)->createFromCheckout($checkout->fresh()); +} + +it('marks the order as paid and commits inventory on confirmation', function (): void { + Event::fake([OrderPaid::class]); + + $order = prepBankOrder($this); + expect($order->financial_status)->toBe(FinancialStatus::Pending); + expect($order->status)->toBe(OrderStatus::Pending); + + $variant = $order->lines()->first()->variant; + expect($variant->fresh()->inventoryItem->quantity_reserved)->toBe(1); + + $updated = app(OrderService::class)->confirmBankTransferPayment($order->fresh()); + + expect($updated->financial_status)->toBe(FinancialStatus::Paid); + expect($updated->status)->toBe(OrderStatus::Paid); + + $payment = $updated->payments()->first(); + expect($payment->status)->toBe(PaymentStatus::Captured); + + $item = $variant->fresh()->inventoryItem; + expect($item->quantity_on_hand)->toBe(4); + expect($item->quantity_reserved)->toBe(0); + + Event::assertDispatched(OrderPaid::class); +}); + +it('is a no-op when order is not bank_transfer pending', function (): void { + $order = \App\Models\Order::factory()->paid()->create(['store_id' => $this->store->id]); + + $result = app(OrderService::class)->confirmBankTransferPayment($order); + + expect($result->financial_status)->toBe(FinancialStatus::Paid); +}); + +it('cancels unpaid bank transfer orders via the job', function (): void { + $order = prepBankOrder($this); + $order->placed_at = now()->subDays(10); + $order->save(); + + (new \App\Jobs\CancelUnpaidBankTransferOrders)->handle(app(OrderService::class)); + + $order->refresh(); + expect($order->status)->toBe(OrderStatus::Cancelled); + expect($order->financial_status)->toBe(FinancialStatus::Voided); +}); diff --git a/tests/Feature/Payments/MockPaymentProviderTest.php b/tests/Feature/Payments/MockPaymentProviderTest.php new file mode 100644 index 00000000..3b6a5903 --- /dev/null +++ b/tests/Feature/Payments/MockPaymentProviderTest.php @@ -0,0 +1,72 @@ +createStoreContext(); + $this->provider = app(MockPaymentProvider::class); +}); + +it('returns success for the magic visa card', function (): void { + $checkout = Checkout::factory()->create(['store_id' => app('current_store')->id]); + + $result = $this->provider->charge($checkout, PaymentMethod::CreditCard, [ + 'card_number' => MockPaymentProvider::CARD_SUCCESS, + ]); + + expect($result->success)->toBeTrue(); + expect($result->status)->toBe(PaymentStatus::Captured); + expect($result->providerPaymentId)->toStartWith('mock_'); +}); + +it('declines the magic card', function (): void { + $checkout = Checkout::factory()->create(['store_id' => app('current_store')->id]); + + $result = $this->provider->charge($checkout, PaymentMethod::CreditCard, [ + 'card_number' => MockPaymentProvider::CARD_DECLINE, + ]); + + expect($result->success)->toBeFalse(); + expect($result->errorCode)->toBe('card_declined'); +}); + +it('returns insufficient_funds for the matching magic card', function (): void { + $checkout = Checkout::factory()->create(['store_id' => app('current_store')->id]); + + $result = $this->provider->charge($checkout, PaymentMethod::CreditCard, [ + 'card_number' => MockPaymentProvider::CARD_INSUFFICIENT, + ]); + + expect($result->success)->toBeFalse(); + expect($result->errorCode)->toBe('insufficient_funds'); +}); + +it('paypal always succeeds with instant capture', function (): void { + $checkout = Checkout::factory()->create(['store_id' => app('current_store')->id]); + + $result = $this->provider->charge($checkout, PaymentMethod::Paypal); + + expect($result->success)->toBeTrue(); + expect($result->status)->toBe(PaymentStatus::Captured); +}); + +it('bank transfer returns pending status', function (): void { + $checkout = Checkout::factory()->create(['store_id' => app('current_store')->id]); + + $result = $this->provider->charge($checkout, PaymentMethod::BankTransfer); + + expect($result->success)->toBeTrue(); + expect($result->status)->toBe(PaymentStatus::Pending); +}); + +it('refund returns a processed result with provider id', function (): void { + $payment = \App\Models\Payment::factory()->create(); + + $result = $this->provider->refund($payment, 500); + + expect($result->success)->toBeTrue(); + expect($result->providerRefundId)->toStartWith('mock_refund_'); +}); diff --git a/tests/Feature/Payments/PaymentServiceTest.php b/tests/Feature/Payments/PaymentServiceTest.php new file mode 100644 index 00000000..2f9d264a --- /dev/null +++ b/tests/Feature/Payments/PaymentServiceTest.php @@ -0,0 +1,32 @@ +toBeInstanceOf(MockPaymentProvider::class); +}); + +it('can charge via the contract for credit card success', function (): void { + $this->createStoreContext(); + $checkout = \App\Models\Checkout::factory()->create(['store_id' => app('current_store')->id]); + + $result = app(PaymentProvider::class)->charge($checkout, \App\Enums\PaymentMethod::CreditCard, [ + 'card_number' => MockPaymentProvider::CARD_SUCCESS, + ]); + + expect($result->status)->toBe(PaymentStatus::Captured); +}); + +it('produces unique mock payment ids per charge', function (): void { + $this->createStoreContext(); + $checkout = \App\Models\Checkout::factory()->create(['store_id' => app('current_store')->id]); + + $a = app(PaymentProvider::class)->charge($checkout, \App\Enums\PaymentMethod::Paypal); + $b = app(PaymentProvider::class)->charge($checkout, \App\Enums\PaymentMethod::Paypal); + + expect($a->providerPaymentId)->not->toBe($b->providerPaymentId); +}); diff --git a/tests/Feature/Products/CollectionTest.php b/tests/Feature/Products/CollectionTest.php new file mode 100644 index 00000000..ee728892 --- /dev/null +++ b/tests/Feature/Products/CollectionTest.php @@ -0,0 +1,107 @@ +createStoreContext(); + $this->store = $context['store']; +}); + +it('creates a collection with a unique handle', function (): void { + $handle = HandleGenerator::generate('Summer Sale', 'collections', $this->store->id); + + $collection = Collection::query()->create([ + 'store_id' => $this->store->id, + 'title' => 'Summer Sale', + 'handle' => $handle, + 'type' => CollectionType::Manual, + 'status' => CollectionStatus::Active, + ]); + + expect($collection->handle)->toBe('summer-sale'); +}); + +it('adds products to a collection', function (): void { + $collection = Collection::factory()->create(['store_id' => $this->store->id]); + $products = Product::factory()->count(3)->create(['store_id' => $this->store->id]); + + $collection->products()->sync( + $products->mapWithKeys(fn ($p, $i) => [$p->id => ['position' => $i]])->all() + ); + + expect($collection->products)->toHaveCount(3); +}); + +it('removes products from a collection', function (): void { + $collection = Collection::factory()->create(['store_id' => $this->store->id]); + $products = Product::factory()->count(3)->create(['store_id' => $this->store->id]); + $collection->products()->sync( + $products->mapWithKeys(fn ($p, $i) => [$p->id => ['position' => $i]])->all() + ); + + $collection->products()->detach($products->first()->id); + + expect($collection->fresh()->products)->toHaveCount(2); +}); + +it('reorders products within a collection', function (): void { + $collection = Collection::factory()->create(['store_id' => $this->store->id]); + $products = Product::factory()->count(3)->create(['store_id' => $this->store->id]); + $collection->products()->sync( + $products->mapWithKeys(fn ($p, $i) => [$p->id => ['position' => $i]])->all() + ); + + $reorder = [ + $products[2]->id => ['position' => 0], + $products[0]->id => ['position' => 1], + $products[1]->id => ['position' => 2], + ]; + $collection->products()->sync($reorder); + + $ordered = $collection->fresh()->products; + expect($ordered->first()->id)->toBe($products[2]->id); + expect($ordered->last()->id)->toBe($products[1]->id); +}); + +it('transitions collection from draft to active', function (): void { + $collection = Collection::factory()->draft()->create(['store_id' => $this->store->id]); + + $collection->update(['status' => CollectionStatus::Active]); + + expect($collection->fresh()->status)->toBe(CollectionStatus::Active); +}); + +it('lists collections with product count', function (): void { + $a = Collection::factory()->create(['store_id' => $this->store->id]); + $b = Collection::factory()->create(['store_id' => $this->store->id]); + + $productsA = Product::factory()->count(5)->create(['store_id' => $this->store->id]); + $productsB = Product::factory()->count(3)->create(['store_id' => $this->store->id]); + + $a->products()->sync($productsA->mapWithKeys(fn ($p, $i) => [$p->id => ['position' => $i]])->all()); + $b->products()->sync($productsB->mapWithKeys(fn ($p, $i) => [$p->id => ['position' => $i]])->all()); + + $collections = Collection::query()->withCount('products')->get(); + $aRow = $collections->firstWhere('id', $a->id); + $bRow = $collections->firstWhere('id', $b->id); + + expect($aRow->products_count)->toBe(5); + expect($bRow->products_count)->toBe(3); +}); + +it('scopes collections to the current store', function (): void { + Collection::factory()->count(2)->create(['store_id' => $this->store->id]); + + $other = $this->createStoreContext(['hostname' => 'other.test']); + Collection::factory()->count(4)->create(['store_id' => $other['store']->id]); + + app()->instance('current_store', $this->store->fresh()); + expect(Collection::query()->count())->toBe(2); + + app()->instance('current_store', $other['store']->fresh()); + expect(Collection::query()->count())->toBe(4); +}); diff --git a/tests/Feature/Products/InventoryTest.php b/tests/Feature/Products/InventoryTest.php new file mode 100644 index 00000000..2d11b143 --- /dev/null +++ b/tests/Feature/Products/InventoryTest.php @@ -0,0 +1,95 @@ +createStoreContext(); + $this->store = $context['store']; + $this->products = app(ProductService::class); + $this->inventory = app(InventoryService::class); +}); + +it('creates an inventory item when a variant is created', function (): void { + $product = $this->products->create($this->store, ['title' => 'Tee']); + + $item = $product->variants->first()->inventoryItem; + + expect($item)->not->toBeNull(); + expect($item->quantity_on_hand)->toBe(0); + expect($item->quantity_reserved)->toBe(0); +}); + +it('checks availability correctly', function (): void { + $product = $this->products->create($this->store, ['title' => 'Tee']); + $item = $product->variants->first()->inventoryItem; + $item->update(['quantity_on_hand' => 10, 'quantity_reserved' => 3]); + + expect($this->inventory->checkAvailability($item->fresh(), 7))->toBeTrue(); + expect($this->inventory->checkAvailability($item->fresh(), 8))->toBeFalse(); +}); + +it('reserves inventory', function (): void { + $product = $this->products->create($this->store, ['title' => 'Tee']); + $item = $product->variants->first()->inventoryItem; + $item->update(['quantity_on_hand' => 10]); + + $this->inventory->reserve($item->fresh(), 3); + + $fresh = $item->fresh(); + expect($fresh->quantity_reserved)->toBe(3); + expect($fresh->available())->toBe(7); +}); + +it('throws InsufficientInventoryException when reserving more than available with deny policy', function (): void { + $product = $this->products->create($this->store, ['title' => 'Tee']); + $item = $product->variants->first()->inventoryItem; + $item->update(['quantity_on_hand' => 5, 'quantity_reserved' => 3, 'policy' => InventoryPolicy::Deny]); + + expect(fn () => $this->inventory->reserve($item->fresh(), 3)) + ->toThrow(InsufficientInventoryException::class); +}); + +it('allows overselling with continue policy', function (): void { + $product = $this->products->create($this->store, ['title' => 'Tee']); + $item = $product->variants->first()->inventoryItem; + $item->update(['quantity_on_hand' => 2, 'policy' => InventoryPolicy::Continue]); + + $this->inventory->reserve($item->fresh(), 5); + + expect($item->fresh()->quantity_reserved)->toBe(5); +}); + +it('releases reserved inventory', function (): void { + $product = $this->products->create($this->store, ['title' => 'Tee']); + $item = $product->variants->first()->inventoryItem; + $item->update(['quantity_on_hand' => 10, 'quantity_reserved' => 5]); + + $this->inventory->release($item->fresh(), 3); + + expect($item->fresh()->quantity_reserved)->toBe(2); +}); + +it('commits inventory on order completion', function (): void { + $product = $this->products->create($this->store, ['title' => 'Tee']); + $item = $product->variants->first()->inventoryItem; + $item->update(['quantity_on_hand' => 10, 'quantity_reserved' => 3]); + + $this->inventory->commit($item->fresh(), 3); + + $fresh = $item->fresh(); + expect($fresh->quantity_on_hand)->toBe(7); + expect($fresh->quantity_reserved)->toBe(0); +}); + +it('restocks inventory', function (): void { + $product = $this->products->create($this->store, ['title' => 'Tee']); + $item = $product->variants->first()->inventoryItem; + $item->update(['quantity_on_hand' => 5]); + + $this->inventory->restock($item->fresh(), 10); + + expect($item->fresh()->quantity_on_hand)->toBe(15); +}); diff --git a/tests/Feature/Products/MediaUploadTest.php b/tests/Feature/Products/MediaUploadTest.php new file mode 100644 index 00000000..fd813cb0 --- /dev/null +++ b/tests/Feature/Products/MediaUploadTest.php @@ -0,0 +1,113 @@ +createStoreContext(); + $this->store = $context['store']; + Storage::fake('public'); +}); + +it('creates a media row for an uploaded image', function (): void { + $product = Product::factory()->create(['store_id' => $this->store->id]); + $file = UploadedFile::fake()->image('product.jpg', 1200, 1200); + $path = $file->store('products', 'public'); + + $media = ProductMedia::query()->create([ + 'product_id' => $product->id, + 'type' => MediaType::Image, + 'storage_key' => $path, + 'mime_type' => 'image/jpeg', + 'byte_size' => $file->getSize(), + 'position' => 0, + 'status' => MediaStatus::Processing, + 'created_at' => now(), + ]); + + expect($media->status)->toBe(MediaStatus::Processing); + expect(Storage::disk('public')->exists($path))->toBeTrue(); +}); + +it('processes an uploaded image and marks status ready', function (): void { + $product = Product::factory()->create(['store_id' => $this->store->id]); + $file = UploadedFile::fake()->image('product.jpg'); + $path = $file->store('products', 'public'); + + $media = ProductMedia::query()->create([ + 'product_id' => $product->id, + 'type' => MediaType::Image, + 'storage_key' => $path, + 'status' => MediaStatus::Processing, + 'created_at' => now(), + ]); + + (new ProcessMediaUpload($media))->handle(); + + expect($media->fresh()->status)->toBe(MediaStatus::Ready); +}); + +it('marks media as failed when the source file is missing', function (): void { + $product = Product::factory()->create(['store_id' => $this->store->id]); + + $media = ProductMedia::query()->create([ + 'product_id' => $product->id, + 'type' => MediaType::Image, + 'storage_key' => 'products/missing.jpg', + 'status' => MediaStatus::Processing, + 'created_at' => now(), + ]); + + (new ProcessMediaUpload($media))->handle(); + + expect($media->fresh()->status)->toBe(MediaStatus::Failed); +}); + +it('allows setting alt text on media', function (): void { + $product = Product::factory()->create(['store_id' => $this->store->id]); + $media = ProductMedia::factory()->create(['product_id' => $product->id, 'alt_text' => null]); + + $media->update(['alt_text' => 'Front view']); + + expect($media->fresh()->alt_text)->toBe('Front view'); +}); + +it('reorders media positions', function (): void { + $product = Product::factory()->create(['store_id' => $this->store->id]); + $media = collect([0, 1, 2])->map(fn ($i) => ProductMedia::factory()->create([ + 'product_id' => $product->id, + 'position' => $i, + ])); + + $media[0]->update(['position' => 2]); + $media[1]->update(['position' => 0]); + $media[2]->update(['position' => 1]); + + $ordered = $product->media()->orderBy('position')->pluck('id')->all(); + expect($ordered)->toBe([$media[1]->id, $media[2]->id, $media[0]->id]); +}); + +it('deletes media and removes the file from disk', function (): void { + $product = Product::factory()->create(['store_id' => $this->store->id]); + $file = UploadedFile::fake()->image('del.jpg'); + $path = $file->store('products', 'public'); + + $media = ProductMedia::query()->create([ + 'product_id' => $product->id, + 'type' => MediaType::Image, + 'storage_key' => $path, + 'status' => MediaStatus::Ready, + 'created_at' => now(), + ]); + + Storage::disk('public')->delete($media->storage_key); + $media->delete(); + + expect(ProductMedia::query()->find($media->id))->toBeNull(); + expect(Storage::disk('public')->exists($path))->toBeFalse(); +}); diff --git a/tests/Feature/Products/ProductCrudTest.php b/tests/Feature/Products/ProductCrudTest.php new file mode 100644 index 00000000..b562fac2 --- /dev/null +++ b/tests/Feature/Products/ProductCrudTest.php @@ -0,0 +1,142 @@ +createStoreContext(); + $this->store = $context['store']; + $this->service = app(ProductService::class); +}); + +it('lists products for the current store', function (): void { + Product::factory()->count(5)->create(['store_id' => $this->store->id]); + + expect(Product::query()->count())->toBe(5); +}); + +it('creates a product with a default variant', function (): void { + $product = $this->service->create($this->store, [ + 'title' => 'New Tee', + 'price_amount' => 2500, + ]); + + expect($product->handle)->toBe('new-tee'); + expect($product->variants)->toHaveCount(1); + expect($product->variants->first()->is_default)->toBeTrue(); + expect($product->variants->first()->inventoryItem)->not->toBeNull(); +}); + +it('generates a unique handle from the title', function (): void { + $product = $this->service->create($this->store, [ + 'title' => 'Summer T-Shirt', + ]); + + expect($product->handle)->toBe('summer-t-shirt'); +}); + +it('appends suffix when handle collides', function (): void { + $first = $this->service->create($this->store, ['title' => 'T-Shirt']); + $second = $this->service->create($this->store, ['title' => 'T-Shirt']); + + expect($first->handle)->toBe('t-shirt'); + expect($second->handle)->toBe('t-shirt-1'); +}); + +it('updates a product', function (): void { + $product = $this->service->create($this->store, ['title' => 'Before']); + + $this->service->update($product, [ + 'title' => 'After', + 'description_html' => '

Updated

', + ]); + + $updated = $product->fresh(); + expect($updated->title)->toBe('After'); + expect($updated->description_html)->toBe('

Updated

'); +}); + +it('transitions product from draft to active', function (): void { + $product = $this->service->create($this->store, [ + 'title' => 'Priced Product', + 'price_amount' => 1500, + ]); + + $this->service->transitionStatus($product, ProductStatus::Active); + + $fresh = $product->fresh(); + expect($fresh->status)->toBe(ProductStatus::Active); + expect($fresh->published_at)->not->toBeNull(); +}); + +it('rejects draft to active without a priced variant', function (): void { + $product = $this->service->create($this->store, [ + 'title' => 'Unpriced Product', + 'price_amount' => 0, + ]); + + expect(fn () => $this->service->transitionStatus($product, ProductStatus::Active)) + ->toThrow(InvalidProductTransitionException::class); + + expect($product->fresh()->status)->toBe(ProductStatus::Draft); +}); + +it('transitions product from active to archived', function (): void { + $product = $this->service->create($this->store, [ + 'title' => 'To Archive', + 'price_amount' => 1000, + ]); + $this->service->transitionStatus($product, ProductStatus::Active); + + $this->service->transitionStatus($product, ProductStatus::Archived); + + expect($product->fresh()->status)->toBe(ProductStatus::Archived); +}); + +it('prevents active to draft when order lines exist', function (): void { + $product = $this->service->create($this->store, [ + 'title' => 'Ordered', + 'price_amount' => 1000, + ]); + $this->service->transitionStatus($product, ProductStatus::Active); + + $order = \App\Models\Order::factory()->create(['store_id' => $this->store->id]); + \App\Models\OrderLine::factory()->create([ + 'order_id' => $order->id, + 'variant_id' => $product->variants()->first()->id, + ]); + + expect(fn () => $this->service->transitionStatus($product, ProductStatus::Draft)) + ->toThrow(InvalidProductTransitionException::class); +}); + +it('hard deletes a draft product with no order references', function (): void { + $product = $this->service->create($this->store, ['title' => 'Deletable']); + $id = $product->id; + + $this->service->delete($product); + + expect(Product::query()->find($id))->toBeNull(); +}); + +it('prevents deletion of an active product', function (): void { + $product = $this->service->create($this->store, [ + 'title' => 'Active', + 'price_amount' => 1000, + ]); + $this->service->transitionStatus($product, ProductStatus::Active); + + expect(fn () => $this->service->delete($product)) + ->toThrow(InvalidProductTransitionException::class); +}); + +it('filters products by status', function (): void { + Product::factory()->count(3)->active()->create(['store_id' => $this->store->id]); + Product::factory()->count(2)->create(['store_id' => $this->store->id, 'status' => ProductStatus::Draft]); + Product::factory()->archived()->create(['store_id' => $this->store->id]); + + expect(Product::query()->where('status', ProductStatus::Active)->count())->toBe(3); + expect(Product::query()->where('status', ProductStatus::Draft)->count())->toBe(2); +}); diff --git a/tests/Feature/Products/VariantTest.php b/tests/Feature/Products/VariantTest.php new file mode 100644 index 00000000..231ffb66 --- /dev/null +++ b/tests/Feature/Products/VariantTest.php @@ -0,0 +1,191 @@ +createStoreContext(); + $this->store = $context['store']; + $this->products = app(ProductService::class); + $this->matrix = app(VariantMatrixService::class); +}); + +it('creates variants from option matrix', function (): void { + $product = $this->products->create($this->store, ['title' => 'Shirt']); + $product->variants()->delete(); + + $size = ProductOption::query()->create(['product_id' => $product->id, 'name' => 'Size', 'position' => 0]); + $color = ProductOption::query()->create(['product_id' => $product->id, 'name' => 'Color', 'position' => 1]); + + foreach (['S', 'M', 'L'] as $i => $v) { + ProductOptionValue::query()->create(['product_option_id' => $size->id, 'value' => $v, 'position' => $i]); + } + foreach (['Red', 'Blue'] as $i => $v) { + ProductOptionValue::query()->create(['product_option_id' => $color->id, 'value' => $v, 'position' => $i]); + } + + $this->matrix->rebuildMatrix($product); + + expect($product->fresh()->variants)->toHaveCount(6); +}); + +it('preserves existing variants when adding an option value', function (): void { + $product = $this->products->create($this->store, ['title' => 'Shirt', 'price_amount' => 2000]); + $product->variants()->delete(); + + $size = ProductOption::query()->create(['product_id' => $product->id, 'name' => 'Size', 'position' => 0]); + foreach (['S', 'M'] as $i => $v) { + ProductOptionValue::query()->create(['product_option_id' => $size->id, 'value' => $v, 'position' => $i]); + } + + ProductVariant::query()->create([ + 'product_id' => $product->id, + 'price_amount' => 1234, + 'currency' => 'USD', + 'position' => 0, + 'status' => VariantStatus::Active, + ])->optionValues()->sync([$size->values()->where('value', 'S')->first()->id]); + + ProductVariant::query()->create([ + 'product_id' => $product->id, + 'price_amount' => 5678, + 'currency' => 'USD', + 'position' => 1, + 'status' => VariantStatus::Active, + ])->optionValues()->sync([$size->values()->where('value', 'M')->first()->id]); + + ProductOptionValue::query()->create(['product_option_id' => $size->id, 'value' => 'L', 'position' => 2]); + + $this->matrix->rebuildMatrix($product->fresh()); + + $fresh = $product->fresh(['variants.optionValues']); + expect($fresh->variants)->toHaveCount(3); + + $small = $fresh->variants->first(fn ($v) => $v->optionValues->first()?->value === 'S'); + expect($small->price_amount)->toBe(1234); +}); + +it('archives orphaned variants with order references', function (): void { + $product = $this->products->create($this->store, ['title' => 'Shirt']); + $product->variants()->delete(); + + $size = ProductOption::query()->create(['product_id' => $product->id, 'name' => 'Size', 'position' => 0]); + $sm = ProductOptionValue::query()->create(['product_option_id' => $size->id, 'value' => 'S', 'position' => 0]); + $md = ProductOptionValue::query()->create(['product_option_id' => $size->id, 'value' => 'M', 'position' => 1]); + + $variantS = ProductVariant::query()->create([ + 'product_id' => $product->id, + 'price_amount' => 1000, + 'currency' => 'USD', + 'position' => 0, + 'status' => VariantStatus::Active, + ]); + $variantS->optionValues()->sync([$sm->id]); + + $variantM = ProductVariant::query()->create([ + 'product_id' => $product->id, + 'price_amount' => 1000, + 'currency' => 'USD', + 'position' => 1, + 'status' => VariantStatus::Active, + ]); + $variantM->optionValues()->sync([$md->id]); + + $order = \App\Models\Order::factory()->create(['store_id' => $this->store->id]); + \App\Models\OrderLine::factory()->create(['order_id' => $order->id, 'variant_id' => $variantS->id]); + + $sm->delete(); + + $this->matrix->rebuildMatrix($product->fresh()); + + expect(ProductVariant::query()->find($variantS->id)->status)->toBe(VariantStatus::Archived); +}); + +it('deletes orphaned variants without order references', function (): void { + $product = $this->products->create($this->store, ['title' => 'Shirt']); + $product->variants()->delete(); + + $size = ProductOption::query()->create(['product_id' => $product->id, 'name' => 'Size', 'position' => 0]); + $sm = ProductOptionValue::query()->create(['product_option_id' => $size->id, 'value' => 'S', 'position' => 0]); + $md = ProductOptionValue::query()->create(['product_option_id' => $size->id, 'value' => 'M', 'position' => 1]); + + $variantS = ProductVariant::query()->create([ + 'product_id' => $product->id, + 'price_amount' => 1000, + 'currency' => 'USD', + 'position' => 0, + 'status' => VariantStatus::Active, + ]); + $variantS->optionValues()->sync([$sm->id]); + + $variantM = ProductVariant::query()->create([ + 'product_id' => $product->id, + 'price_amount' => 1000, + 'currency' => 'USD', + 'position' => 1, + 'status' => VariantStatus::Active, + ]); + $variantM->optionValues()->sync([$md->id]); + + $sm->delete(); + + $this->matrix->rebuildMatrix($product->fresh()); + + expect(ProductVariant::query()->find($variantS->id))->toBeNull(); + expect(ProductVariant::query()->find($variantM->id))->not->toBeNull(); +}); + +it('auto-creates a default variant for products without options', function (): void { + $product = $this->products->create($this->store, ['title' => 'Simple']); + + expect($product->variants)->toHaveCount(1); + expect($product->variants->first()->is_default)->toBeTrue(); +}); + +it('validates SKU uniqueness within store', function (): void { + $product = $this->products->create($this->store, ['title' => 'A']); + $product->variants->first()->update(['sku' => 'TSH-001']); + + $product2 = $this->products->create($this->store, ['title' => 'B']); + $variant2 = $product2->variants->first(); + + $existing = ProductVariant::query() + ->where('sku', 'TSH-001') + ->whereHas('product', fn ($q) => $q->where('store_id', $this->store->id)) + ->where('id', '!=', $variant2->id) + ->exists(); + + expect($existing)->toBeTrue(); +}); + +it('allows duplicate SKU across different stores', function (): void { + $product = $this->products->create($this->store, ['title' => 'A']); + $product->variants->first()->update(['sku' => 'TSH-001']); + + $other = $this->createStoreContext(['hostname' => 'other.test']); + $otherStore = $other['store']; + + $otherProduct = $this->products->create($otherStore, ['title' => 'B']); + $otherProduct->variants->first()->update(['sku' => 'TSH-001']); + + expect(ProductVariant::query()->where('sku', 'TSH-001')->count())->toBe(2); +}); + +it('allows null SKUs on multiple variants', function (): void { + $product = $this->products->create($this->store, ['title' => 'A']); + + $extra = ProductVariant::query()->create([ + 'product_id' => $product->id, + 'sku' => null, + 'price_amount' => 1000, + 'currency' => 'USD', + 'position' => 1, + 'status' => VariantStatus::Active, + ]); + + expect(ProductVariant::query()->whereNull('sku')->where('product_id', $product->id)->count())->toBe(2); +}); diff --git a/tests/Feature/Search/AutocompleteTest.php b/tests/Feature/Search/AutocompleteTest.php new file mode 100644 index 00000000..cc987a61 --- /dev/null +++ b/tests/Feature/Search/AutocompleteTest.php @@ -0,0 +1,83 @@ +createStoreContext(); + $this->store = $context['store']; + $this->search = app(SearchService::class); +}); + +it('returns suggestions matching prefix', function (): void { + Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => 'Summer Dress', + 'status' => ProductStatus::Active, + ]); + Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => 'Summer Hat', + 'status' => ProductStatus::Active, + ]); + Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => 'Winter Coat', + 'status' => ProductStatus::Active, + ]); + + $results = $this->search->autocomplete($this->store, 'sum', 8); + + expect($results)->toHaveCount(2); + $titles = $results->pluck('title')->all(); + expect($titles)->toContain('Summer Dress'); + expect($titles)->toContain('Summer Hat'); +}); + +it('limits results to the configured count', function (): void { + for ($i = 1; $i <= 20; $i++) { + Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => "Matchable Product {$i}", + 'status' => ProductStatus::Active, + ]); + } + + $results = $this->search->autocomplete($this->store, 'matchable', 5); + + expect($results)->toHaveCount(5); +}); + +it('returns empty for very short prefix', function (): void { + Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => 'Apples', + 'status' => ProductStatus::Active, + ]); + + $results = $this->search->autocomplete($this->store, 'a', 8); + + expect($results)->toBeEmpty(); +}); + +it('scopes autocomplete suggestions to the current store', function (): void { + Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => 'Summer Dress', + 'status' => ProductStatus::Active, + ]); + + $other = $this->createStoreContext(['hostname' => 'other.test']); + Product::factory()->create([ + 'store_id' => $other['store']->id, + 'title' => 'Summer Deluxe Bag', + 'status' => ProductStatus::Active, + ]); + + app()->instance('current_store', $this->store->fresh()); + $results = $this->search->autocomplete($this->store, 'sum', 8); + + expect($results)->toHaveCount(1); + expect($results->first()->title)->toBe('Summer Dress'); +}); diff --git a/tests/Feature/Search/SearchTest.php b/tests/Feature/Search/SearchTest.php new file mode 100644 index 00000000..1e8c2ec7 --- /dev/null +++ b/tests/Feature/Search/SearchTest.php @@ -0,0 +1,128 @@ +createStoreContext(); + $this->store = $context['store']; + $this->search = app(SearchService::class); +}); + +it('returns products matching search query', function (): void { + Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => 'Blue Cotton T-Shirt', + 'status' => ProductStatus::Active, + ]); + Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => 'Red Wool Sweater', + 'status' => ProductStatus::Active, + ]); + + $results = $this->search->search($this->store, 'cotton'); + + expect($results->total())->toBe(1); + expect($results->items()[0]->title)->toBe('Blue Cotton T-Shirt'); +}); + +it('scopes search to the current store', function (): void { + Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => 'Store A T-Shirt', + 'status' => ProductStatus::Active, + ]); + + $other = $this->createStoreContext(['hostname' => 'other-store.test']); + Product::factory()->create([ + 'store_id' => $other['store']->id, + 'title' => 'Store B T-Shirt Deluxe', + 'status' => ProductStatus::Active, + ]); + + app()->instance('current_store', $this->store->fresh()); + $results = $this->search->search($this->store, 't-shirt'); + + expect($results->total())->toBe(1); + expect($results->items()[0]->title)->toBe('Store A T-Shirt'); +}); + +it('returns empty for no matches', function (): void { + Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => 'Blue Cotton T-Shirt', + 'status' => ProductStatus::Active, + ]); + + $results = $this->search->search($this->store, 'xyznonexistent'); + + expect($results->total())->toBe(0); +}); + +it('logs search query for analytics', function (): void { + Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => 'Blue Cotton T-Shirt', + 'status' => ProductStatus::Active, + ]); + + $this->search->search($this->store, 'cotton'); + + $row = \DB::table('search_queries')->where('store_id', $this->store->id)->first(); + expect($row)->not->toBeNull(); + expect($row->query)->toBe('cotton'); + expect((int) $row->results_count)->toBe(1); +}); + +it('paginates search results', function (): void { + for ($i = 1; $i <= 25; $i++) { + Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => "Cotton Tee {$i}", + 'status' => ProductStatus::Active, + ]); + } + + $page1 = $this->search->search($this->store, 'cotton', [], 12, 1); + $page2 = $this->search->search($this->store, 'cotton', [], 12, 2); + $page3 = $this->search->search($this->store, 'cotton', [], 12, 3); + + expect($page1->total())->toBe(25); + expect($page1->count())->toBe(12); + expect($page2->count())->toBe(12); + expect($page3->count())->toBe(1); +}); + +it('excludes non-active products from search results', function (): void { + Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => 'Active Cotton Tee', + 'status' => ProductStatus::Active, + ]); + Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => 'Draft Cotton Tee', + 'status' => ProductStatus::Draft, + ]); + + $results = $this->search->search($this->store, 'cotton'); + + expect($results->total())->toBe(1); + expect($results->items()[0]->title)->toBe('Active Cotton Tee'); +}); + +it('removes product from index on delete', function (): void { + $product = Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => 'Gone Soon Cotton Tee', + 'status' => ProductStatus::Active, + ]); + + expect(\DB::table('products_fts')->where('product_id', $product->id)->exists())->toBeTrue(); + + $product->delete(); + + expect(\DB::table('products_fts')->where('product_id', $product->id)->exists())->toBeFalse(); +}); diff --git a/tests/Feature/Settings/PasswordUpdateTest.php b/tests/Feature/Settings/PasswordUpdateTest.php deleted file mode 100644 index a6379b2b..00000000 --- a/tests/Feature/Settings/PasswordUpdateTest.php +++ /dev/null @@ -1,42 +0,0 @@ -create([ - 'password' => Hash::make('password'), - ]); - - $this->actingAs($user); - - $response = Livewire::test(Password::class) - ->set('current_password', 'password') - ->set('password', 'new-password') - ->set('password_confirmation', 'new-password') - ->call('updatePassword'); - - $response->assertHasNoErrors(); - - expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue(); -}); - -test('correct password must be provided to update password', function () { - $user = User::factory()->create([ - 'password' => Hash::make('password'), - ]); - - $this->actingAs($user); - - $response = Livewire::test(Password::class) - ->set('current_password', 'wrong-password') - ->set('password', 'new-password') - ->set('password_confirmation', 'new-password') - ->call('updatePassword'); - - $response->assertHasErrors(['current_password']); -}); \ No newline at end of file diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php deleted file mode 100644 index 276e9fef..00000000 --- a/tests/Feature/Settings/ProfileUpdateTest.php +++ /dev/null @@ -1,78 +0,0 @@ -actingAs($user = User::factory()->create()); - - $this->get('/settings/profile')->assertOk(); -}); - -test('profile information can be updated', function () { - $user = User::factory()->create(); - - $this->actingAs($user); - - $response = Livewire::test(Profile::class) - ->set('name', 'Test User') - ->set('email', 'test@example.com') - ->call('updateProfileInformation'); - - $response->assertHasNoErrors(); - - $user->refresh(); - - expect($user->name)->toEqual('Test User'); - expect($user->email)->toEqual('test@example.com'); - expect($user->email_verified_at)->toBeNull(); -}); - -test('email verification status is unchanged when email address is unchanged', function () { - $user = User::factory()->create(); - - $this->actingAs($user); - - $response = Livewire::test(Profile::class) - ->set('name', 'Test User') - ->set('email', $user->email) - ->call('updateProfileInformation'); - - $response->assertHasNoErrors(); - - expect($user->refresh()->email_verified_at)->not->toBeNull(); -}); - -test('user can delete their account', function () { - $user = User::factory()->create(); - - $this->actingAs($user); - - $response = Livewire::test('settings.delete-user-form') - ->set('password', 'password') - ->call('deleteUser'); - - $response - ->assertHasNoErrors() - ->assertRedirect('/'); - - expect($user->fresh())->toBeNull(); - expect(auth()->check())->toBeFalse(); -}); - -test('correct password must be provided to delete account', function () { - $user = User::factory()->create(); - - $this->actingAs($user); - - $response = Livewire::test('settings.delete-user-form') - ->set('password', 'wrong-password') - ->call('deleteUser'); - - $response->assertHasErrors(['password']); - - expect($user->fresh())->not->toBeNull(); -}); \ No newline at end of file diff --git a/tests/Feature/Settings/TwoFactorAuthenticationTest.php b/tests/Feature/Settings/TwoFactorAuthenticationTest.php deleted file mode 100644 index e2d530fb..00000000 --- a/tests/Feature/Settings/TwoFactorAuthenticationTest.php +++ /dev/null @@ -1,72 +0,0 @@ -markTestSkipped('Two-factor authentication is not enabled.'); - } - - Features::twoFactorAuthentication([ - 'confirm' => true, - 'confirmPassword' => true, - ]); -}); - -test('two factor settings page can be rendered', function () { - $user = User::factory()->create(); - - $this->actingAs($user) - ->withSession(['auth.password_confirmed_at' => time()]) - ->get(route('two-factor.show')) - ->assertOk() - ->assertSee('Two Factor Authentication') - ->assertSee('Disabled'); -}); - -test('two factor settings page requires password confirmation when enabled', function () { - $user = User::factory()->create(); - - $response = $this->actingAs($user) - ->get(route('two-factor.show')); - - $response->assertRedirect(route('password.confirm')); -}); - -test('two factor settings page returns forbidden response when two factor is disabled', function () { - config(['fortify.features' => []]); - - $user = User::factory()->create(); - - $response = $this->actingAs($user) - ->withSession(['auth.password_confirmed_at' => time()]) - ->get(route('two-factor.show')); - - $response->assertForbidden(); -}); - -test('two factor authentication disabled when confirmation abandoned between requests', function () { - $user = User::factory()->create(); - - $user->forceFill([ - 'two_factor_secret' => encrypt('test-secret'), - 'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])), - 'two_factor_confirmed_at' => null, - ])->save(); - - $this->actingAs($user); - - $component = Livewire::test('settings.two-factor'); - - $component->assertSet('twoFactorEnabled', false); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'two_factor_secret' => null, - 'two_factor_recovery_codes' => null, - ]); -}); \ No newline at end of file diff --git a/tests/Feature/Storefront/HomepageTest.php b/tests/Feature/Storefront/HomepageTest.php new file mode 100644 index 00000000..81a68f95 --- /dev/null +++ b/tests/Feature/Storefront/HomepageTest.php @@ -0,0 +1,39 @@ +createStoreContext(['hostname' => 'home-store.test']); + + $response = $this->get('http://home-store.test/'); + + $response->assertOk(); + $response->assertSee($context['store']->name); +}); + +it('renders hero heading from theme settings when available', function (): void { + $context = $this->createStoreContext(['hostname' => 'hero-store.test']); + + $theme = Theme::factory()->published()->create([ + 'store_id' => $context['store']->id, + ]); + + ThemeSettings::query()->create([ + 'theme_id' => $theme->id, + 'settings_json' => [ + 'hero' => [ + 'heading' => 'Curated finds for spring', + 'subheading' => 'Only the good stuff.', + 'cta_label' => 'Shop now', + 'cta_url' => '/collections', + ], + ], + 'updated_at' => now(), + ]); + + $response = $this->get('http://hero-store.test/'); + + $response->assertOk(); + $response->assertSee('Curated finds for spring'); +}); diff --git a/tests/Feature/Storefront/NavigationTest.php b/tests/Feature/Storefront/NavigationTest.php new file mode 100644 index 00000000..c8153548 --- /dev/null +++ b/tests/Feature/Storefront/NavigationTest.php @@ -0,0 +1,64 @@ +createStoreContext(['hostname' => 'nav-store.test']); + + $menu = NavigationMenu::query()->create([ + 'store_id' => $context['store']->id, + 'handle' => 'main-menu', + 'title' => 'Main menu', + ]); + + NavigationItem::query()->create([ + 'menu_id' => $menu->id, + 'type' => NavigationItemType::Link, + 'label' => 'Home', + 'url' => '/', + 'position' => 0, + ]); + + NavigationItem::query()->create([ + 'menu_id' => $menu->id, + 'type' => NavigationItemType::Link, + 'label' => 'Collections', + 'url' => '/collections', + 'position' => 1, + ]); + + $service = app(NavigationService::class); + $cacheKey = "navigation:{$context['store']->id}:{$menu->id}"; + + expect(Cache::has($cacheKey))->toBeFalse(); + + $tree = $service->buildTree($menu); + + expect($tree)->toHaveCount(2); + expect($tree[0]['label'])->toBe('Home'); + expect($tree[0]['url'])->toBe('/'); + expect($tree[1]['label'])->toBe('Collections'); + expect(Cache::has($cacheKey))->toBeTrue(); +}); + +it('resolves URLs for link items without database hits', function (): void { + $this->createStoreContext(['hostname' => 'url-store.test']); + + $menu = NavigationMenu::factory()->create(); + + $item = NavigationItem::query()->create([ + 'menu_id' => $menu->id, + 'type' => NavigationItemType::Link, + 'label' => 'External', + 'url' => 'https://example.com', + 'position' => 0, + ]); + + $service = app(NavigationService::class); + + expect($service->resolveUrl($item))->toBe('https://example.com'); +}); diff --git a/tests/Feature/Storefront/PageShowTest.php b/tests/Feature/Storefront/PageShowTest.php new file mode 100644 index 00000000..60a9fc7d --- /dev/null +++ b/tests/Feature/Storefront/PageShowTest.php @@ -0,0 +1,43 @@ +createStoreContext(['hostname' => 'cms-store.test']); + + Page::query()->create([ + 'store_id' => $context['store']->id, + 'title' => 'About Us', + 'handle' => 'about', + 'body_html' => '

Our story begins here.

', + 'status' => PageStatus::Published, + 'published_at' => now(), + ]); + + $response = $this->get('http://cms-store.test/pages/about'); + + $response->assertOk(); + $response->assertSee('About Us'); + $response->assertSee('Our story begins here.', false); +}); + +it('returns 404 for a draft page', function (): void { + $context = $this->createStoreContext(['hostname' => 'draft-store.test']); + + Page::query()->create([ + 'store_id' => $context['store']->id, + 'title' => 'Secret', + 'handle' => 'secret', + 'body_html' => '

hidden

', + 'status' => PageStatus::Draft, + ]); + + $this->get('http://draft-store.test/pages/secret')->assertNotFound(); +}); + +it('returns 404 for missing page', function (): void { + $this->createStoreContext(['hostname' => 'missing-store.test']); + + $this->get('http://missing-store.test/pages/nope')->assertNotFound(); +}); diff --git a/tests/Feature/Storefront/ProductShowTest.php b/tests/Feature/Storefront/ProductShowTest.php new file mode 100644 index 00000000..12c04f46 --- /dev/null +++ b/tests/Feature/Storefront/ProductShowTest.php @@ -0,0 +1,33 @@ +createStoreContext(['hostname' => 'product-404-store.test']); + + $this->get('http://product-404-store.test/products/does-not-exist')->assertNotFound(); +}); + +it('renders a product page when the product exists', function (): void { + if (! Schema::hasTable('products')) { + $this->markTestSkipped('Product table not available yet.'); + } + + $context = $this->createStoreContext(['hostname' => 'product-show-store.test']); + + if (! class_exists(\App\Models\Product::class)) { + $this->markTestSkipped('Product model not ready.'); + } + + $product = \App\Models\Product::factory()->create([ + 'store_id' => $context['store']->id, + 'status' => \App\Enums\ProductStatus::Active, + 'handle' => 'showcase-tee', + 'title' => 'Showcase Tee', + ]); + + $response = $this->get('http://product-show-store.test/products/'.$product->handle); + + $response->assertOk(); + $response->assertSee('Showcase Tee'); +}); diff --git a/tests/Feature/Tenancy/TenantResolutionTest.php b/tests/Feature/Tenancy/TenantResolutionTest.php new file mode 100644 index 00000000..0d402acb --- /dev/null +++ b/tests/Feature/Tenancy/TenantResolutionTest.php @@ -0,0 +1,38 @@ +createStoreContext(['hostname' => 'acme-fashion.test']); + + $response = $this->get('http://acme-fashion.test/'); + + $response->assertOk(); + expect(app('current_store')->id)->toBe($context['store']->id); +}); + +it('returns 404 for unknown hostname', function (): void { + $response = $this->get('http://nonexistent.test/'); + $response->assertNotFound(); +}); + +it('returns 503 for suspended store on storefront', function (): void { + $context = $this->createStoreContext(['hostname' => 'suspended-store.test']); + $context['store']->update(['status' => StoreStatus::Suspended]); + Cache::forget('store_domain:suspended-store.test'); + + $response = $this->get('http://suspended-store.test/'); + + $response->assertStatus(503); +}); + +it('caches hostname lookup', function (): void { + $this->createStoreContext(['hostname' => 'cache-store.test']); + + expect(Cache::has('store_domain:cache-store.test'))->toBeFalse(); + + $this->get('http://cache-store.test/')->assertOk(); + + expect(Cache::has('store_domain:cache-store.test'))->toBeTrue(); +}); diff --git a/tests/Feature/Webhooks/WebhookDeliveryTest.php b/tests/Feature/Webhooks/WebhookDeliveryTest.php new file mode 100644 index 00000000..694eb2a3 --- /dev/null +++ b/tests/Feature/Webhooks/WebhookDeliveryTest.php @@ -0,0 +1,116 @@ +createStoreContext(); + $this->store = $ctx['store']; +}); + +it('queues a DeliverWebhook job for each active subscription', function (): void { + Bus::fake(); + + WebhookSubscription::factory()->count(2)->create([ + 'store_id' => $this->store->id, + 'event_type' => 'order.created', + ]); + WebhookSubscription::factory()->create([ + 'store_id' => $this->store->id, + 'event_type' => 'order.created', + 'status' => WebhookSubscriptionStatus::Disabled, + ]); + WebhookSubscription::factory()->create([ + 'store_id' => $this->store->id, + 'event_type' => 'order.paid', + ]); + + $count = app(WebhookService::class)->dispatchEvent($this->store->id, 'order.created', ['hi' => 'there']); + + expect($count)->toBe(2); + Bus::assertDispatchedTimes(DeliverWebhook::class, 2); +}); + +it('delivers a webhook successfully and records response', function (): void { + Http::fake([ + '*' => Http::response('ok', 200), + ]); + + $subscription = WebhookSubscription::factory()->create([ + 'store_id' => $this->store->id, + 'target_url' => 'https://example.com/hooks/1', + ]); + + (new DeliverWebhook($subscription->id, 'order.created', ['k' => 'v'], 'evt-1'))->handle(app(WebhookService::class)); + + $delivery = WebhookDelivery::query()->where('subscription_id', $subscription->id)->first(); + expect($delivery->status)->toBe(WebhookDeliveryStatus::Success); + expect($delivery->response_code)->toBe(200); + expect($delivery->attempt_count)->toBe(1); + + Http::assertSent(function ($request) { + return $request->hasHeader('X-Platform-Signature') + && $request->hasHeader('X-Platform-Event', 'order.created') + && $request->hasHeader('X-Platform-Delivery-Id', 'evt-1'); + }); +}); + +it('increments consecutive_failures and pauses after 5 failures', function (): void { + Http::fake([ + '*' => Http::response('down', 500), + ]); + + $subscription = WebhookSubscription::factory()->create([ + 'store_id' => $this->store->id, + 'consecutive_failures' => 4, + ]); + + try { + (new DeliverWebhook($subscription->id, 'order.created', [], 'evt-fail'))->handle(app(WebhookService::class)); + } catch (\Throwable) { + } + + $subscription->refresh(); + expect($subscription->consecutive_failures)->toBe(5); + expect($subscription->status)->toBe(WebhookSubscriptionStatus::Paused); +}); + +it('resets consecutive_failures on success', function (): void { + Http::fake([ + '*' => Http::response('ok', 200), + ]); + + $subscription = WebhookSubscription::factory()->create([ + 'store_id' => $this->store->id, + 'consecutive_failures' => 3, + ]); + + (new DeliverWebhook($subscription->id, 'order.created', [], 'evt-recover'))->handle(app(WebhookService::class)); + + expect($subscription->fresh()->consecutive_failures)->toBe(0); +}); + +it('uses configured backoff schedule', function (): void { + $job = new DeliverWebhook(1, 'order.created', [], 'evt'); + expect($job->backoff())->toBe([60, 300, 1800, 7200, 43200]); + expect($job->tries)->toBe(6); +}); + +it('marks delivery as failed via failed() hook', function (): void { + $subscription = WebhookSubscription::factory()->create(['store_id' => $this->store->id]); + $delivery = WebhookDelivery::factory()->create([ + 'subscription_id' => $subscription->id, + 'event_id' => 'evt-boom', + 'status' => WebhookDeliveryStatus::Pending, + ]); + + (new DeliverWebhook($subscription->id, 'order.created', [], 'evt-boom'))->failed(new \Exception('boom')); + + expect($delivery->fresh()->status)->toBe(WebhookDeliveryStatus::Failed); +}); diff --git a/tests/Feature/Webhooks/WebhookSignatureTest.php b/tests/Feature/Webhooks/WebhookSignatureTest.php new file mode 100644 index 00000000..b57e54a1 --- /dev/null +++ b/tests/Feature/Webhooks/WebhookSignatureTest.php @@ -0,0 +1,26 @@ +sign('{"hello":"world"}', 'topsecret'); + + expect($sig)->toMatch('/^[a-f0-9]{64}$/'); + expect($sig)->toBe(hash_hmac('sha256', '{"hello":"world"}', 'topsecret')); +}); + +it('verifies a matching signature', function (): void { + $service = app(WebhookService::class); + $sig = $service->sign('payload', 'secret'); + + expect($service->verify('payload', $sig, 'secret'))->toBeTrue(); +}); + +it('rejects a mismatched signature', function (): void { + $service = app(WebhookService::class); + $sig = $service->sign('payload', 'secret'); + + expect($service->verify('payload', $sig, 'wrong_secret'))->toBeFalse(); + expect($service->verify('tampered', $sig, 'secret'))->toBeFalse(); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 60f04a45..174632fc 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,47 +1,12 @@ extend(Tests\TestCase::class) - // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) + ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) ->in('Feature'); -/* -|-------------------------------------------------------------------------- -| Expectations -|-------------------------------------------------------------------------- -| -| When you're writing tests, you often need to check that values meet certain conditions. The -| "expect()" function gives you access to a set of "expectations" methods that you can use -| to assert different things. Of course, you may extend the Expectation API at any time. -| -*/ +pest()->extend(Tests\TestCase::class) + ->in('Unit'); expect()->extend('toBeOne', function () { return $this->toBe(1); }); - -/* -|-------------------------------------------------------------------------- -| Functions -|-------------------------------------------------------------------------- -| -| While Pest is very powerful out-of-the-box, you may have some testing code specific to your -| project that you don't want to repeat in every file. Here you can also expose helpers as -| global functions to help you to reduce the number of lines of code in your test files. -| -*/ - -function something() -{ - // .. -} diff --git a/tests/TestCase.php b/tests/TestCase.php index fe1ffc2f..1bb5ba95 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,9 +2,89 @@ namespace Tests; +use App\Enums\StoreDomainType; +use App\Enums\StoreStatus; +use App\Enums\StoreUserRole; +use App\Models\Customer; +use App\Models\Organization; +use App\Models\Store; +use App\Models\StoreDomain; +use App\Models\StoreSettings; +use App\Models\User; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; +use Illuminate\Support\Facades\DB; abstract class TestCase extends BaseTestCase { - // + /** + * @return array{organization: Organization, store: Store, domain: StoreDomain, owner: User} + */ + protected function createStoreContext(array $overrides = []): array + { + $organization = Organization::factory()->create(); + + $store = Store::factory()->create(array_merge([ + 'organization_id' => $organization->id, + 'status' => StoreStatus::Active, + ], $overrides['store'] ?? [])); + + $domain = StoreDomain::factory()->create([ + 'store_id' => $store->id, + 'hostname' => $overrides['hostname'] ?? ($store->handle.'.test'), + 'type' => StoreDomainType::Storefront, + 'is_primary' => true, + ]); + + StoreSettings::factory()->create(['store_id' => $store->id]); + + $owner = User::factory()->create(); + + DB::table('store_users')->insert([ + 'store_id' => $store->id, + 'user_id' => $owner->id, + 'role' => StoreUserRole::Owner->value, + 'created_at' => now(), + ]); + + app()->instance('current_store', $store->fresh(['settings'])); + + return [ + 'organization' => $organization, + 'store' => $store->fresh(), + 'domain' => $domain, + 'owner' => $owner, + ]; + } + + /** + * Wire an additional storefront domain using the APP_URL host so HTTP/API tests resolve. + */ + protected function bindStoreToAppHost(Store $store): StoreDomain + { + $host = parse_url((string) config('app.url'), PHP_URL_HOST) ?: 'localhost'; + + return StoreDomain::factory()->create([ + 'store_id' => $store->id, + 'hostname' => $host, + 'type' => StoreDomainType::Storefront, + 'is_primary' => false, + ]); + } + + protected function actingAsAdmin(User $user, ?Store $store = null): static + { + $this->actingAs($user); + if ($store) { + session()->put('current_store_id', $store->id); + } + + return $this; + } + + protected function actingAsCustomer(Customer $customer): static + { + $this->actingAs($customer, 'customer'); + + return $this; + } } diff --git a/tests/Unit/HandleGeneratorTest.php b/tests/Unit/HandleGeneratorTest.php new file mode 100644 index 00000000..ae3e9ebc --- /dev/null +++ b/tests/Unit/HandleGeneratorTest.php @@ -0,0 +1,81 @@ +createStoreContext(); + $this->store = $context['store']; +}); + +it('generates a slug from title', function (): void { + $handle = HandleGenerator::generate('My Amazing Product', 'products', $this->store->id); + expect($handle)->toBe('my-amazing-product'); +}); + +it('appends suffix on collision', function (): void { + \DB::table('products')->insert([ + 'store_id' => $this->store->id, + 'title' => 'T-Shirt', + 'handle' => 't-shirt', + 'status' => 'draft', + 'tags' => '[]', + ]); + + $handle = HandleGenerator::generate('T-Shirt', 'products', $this->store->id); + expect($handle)->toBe('t-shirt-1'); +}); + +it('increments suffix on multiple collisions', function (): void { + foreach (['t-shirt', 't-shirt-1'] as $existing) { + \DB::table('products')->insert([ + 'store_id' => $this->store->id, + 'title' => 'T-Shirt', + 'handle' => $existing, + 'status' => 'draft', + 'tags' => '[]', + ]); + } + + $handle = HandleGenerator::generate('T-Shirt', 'products', $this->store->id); + expect($handle)->toBe('t-shirt-2'); +}); + +it('handles special characters in the title', function (): void { + $handle = HandleGenerator::generate("Loewe's Fall/Winter 2026", 'products', $this->store->id); + expect($handle)->toMatch('/^[a-z0-9-]+$/'); + expect($handle)->toContain('loewe'); + expect($handle)->toContain('fall'); + expect($handle)->toContain('2026'); +}); + +it('excludes current record id from collision check', function (): void { + $id = \DB::table('products')->insertGetId([ + 'store_id' => $this->store->id, + 'title' => 'T-Shirt', + 'handle' => 't-shirt', + 'status' => 'draft', + 'tags' => '[]', + ]); + + $handle = HandleGenerator::generate('T-Shirt', 'products', $this->store->id, $id); + expect($handle)->toBe('t-shirt'); +}); + +it('scopes uniqueness check to store', function (): void { + $otherContext = $this->createStoreContext(['hostname' => 'other-store.test']); + $otherStore = $otherContext['store']; + + \DB::table('products')->insert([ + 'store_id' => $otherStore->id, + 'title' => 'T-Shirt', + 'handle' => 't-shirt', + 'status' => 'draft', + 'tags' => '[]', + ]); + + $handle = HandleGenerator::generate('T-Shirt', 'products', $this->store->id); + expect($handle)->toBe('t-shirt'); +}); diff --git a/tests/Unit/Pricing/PricingResultTest.php b/tests/Unit/Pricing/PricingResultTest.php new file mode 100644 index 00000000..0a7b30b8 --- /dev/null +++ b/tests/Unit/Pricing/PricingResultTest.php @@ -0,0 +1,37 @@ +toArray())->toBe([ + 'subtotal' => 5000, + 'discount' => 500, + 'shipping' => 799, + 'tax_lines' => [[ + 'name' => 'VAT', + 'rate' => 1900, + 'amount' => 850, + ]], + 'tax' => 850, + 'total' => 6149, + 'currency' => 'EUR', + ]); +}); + +it('keeps totals read-only', function (): void { + $result = new PricingResult(1000, 0, 0, [], 0, 1000, 'USD'); + + expect(fn () => $result->subtotal = 500) + ->toThrow(Error::class); +}); diff --git a/tests/Unit/Pricing/TaxCalculatorTest.php b/tests/Unit/Pricing/TaxCalculatorTest.php new file mode 100644 index 00000000..0692eef9 --- /dev/null +++ b/tests/Unit/Pricing/TaxCalculatorTest.php @@ -0,0 +1,77 @@ +store_id = 1; + $settings->mode = TaxMode::Manual; + $settings->provider = 'none'; + $settings->prices_include_tax = false; + $settings->config_json = ['default_rate_bps' => 0]; + + $result = app(TaxCalculator::class)->calculate(5000, $settings); + + expect($result['total'])->toBe(0); + expect($result['lines'])->toBeEmpty(); +}); + +it('calculates tax-exclusive amounts using basis points', function (): void { + $calc = app(TaxCalculator::class); + + expect($calc->addExclusive(1000, 1900))->toBe(190); + expect($calc->addExclusive(2500, 800))->toBe(200); +}); + +it('extracts tax-inclusive amounts via integer division', function (): void { + $calc = app(TaxCalculator::class); + + expect($calc->extractInclusive(1190, 1900))->toBe(190); + expect($calc->extractInclusive(0, 1900))->toBe(0); + expect($calc->extractInclusive(1000, 0))->toBe(0); +}); + +it('calculates with region override when set', function (): void { + $settings = new TaxSettings; + $settings->store_id = 1; + $settings->mode = TaxMode::Manual; + $settings->provider = 'none'; + $settings->prices_include_tax = false; + $settings->config_json = [ + 'default_rate_bps' => 1000, + 'region_rates' => ['US-CA' => 800], + ]; + + $result = app(TaxCalculator::class)->calculate(1000, $settings, ['country_code' => 'US', 'province_code' => 'US-CA']); + + expect($result['total'])->toBe(80); + expect($result['lines'][0]->rate)->toBe(800); +}); + +it('names tax lines using config label', function (): void { + $settings = new TaxSettings; + $settings->store_id = 1; + $settings->mode = TaxMode::Manual; + $settings->provider = 'none'; + $settings->prices_include_tax = false; + $settings->config_json = ['default_rate_bps' => 1900, 'rate_name' => 'VAT']; + + $result = app(TaxCalculator::class)->calculate(1000, $settings); + + expect($result['lines'][0]->name)->toBe('VAT'); +}); + +it('handles tax-inclusive pricing correctly', function (): void { + $settings = new TaxSettings; + $settings->store_id = 1; + $settings->mode = TaxMode::Manual; + $settings->provider = 'none'; + $settings->prices_include_tax = true; + $settings->config_json = ['default_rate_bps' => 1900]; + + $result = app(TaxCalculator::class)->calculate(1190, $settings); + + expect($result['total'])->toBe(190); +});