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/.claude/skills/developing-with-fortify/SKILL.md b/.claude/skills/developing-with-fortify/SKILL.md new file mode 100644 index 00000000..2ff71a4b --- /dev/null +++ b/.claude/skills/developing-with-fortify/SKILL.md @@ -0,0 +1,116 @@ +--- +name: developing-with-fortify +description: Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications. +--- + +# Laravel Fortify Development + +Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications. + +## Documentation + +Use `search-docs` for detailed Laravel Fortify patterns and documentation. + +## Usage + +- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints +- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.) +- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field +- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.) +- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc. + +## Available Features + +Enable in `config/fortify.php` features array: + +- `Features::registration()` - User registration +- `Features::resetPasswords()` - Password reset via email +- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail` +- `Features::updateProfileInformation()` - Profile updates +- `Features::updatePasswords()` - Password changes +- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes + +> Use `search-docs` for feature configuration options and customization patterns. + +## Setup Workflows + +### Two-Factor Authentication Setup + +``` +- [ ] Add TwoFactorAuthenticatable trait to User model +- [ ] Enable feature in config/fortify.php +- [ ] Run migrations for 2FA columns +- [ ] Set up view callbacks in FortifyServiceProvider +- [ ] Create 2FA management UI +- [ ] Test QR code and recovery codes +``` + +> Use `search-docs` for TOTP implementation and recovery code handling patterns. + +### Email Verification Setup + +``` +- [ ] Enable emailVerification feature in config +- [ ] Implement MustVerifyEmail interface on User model +- [ ] Set up verifyEmailView callback +- [ ] Add verified middleware to protected routes +- [ ] Test verification email flow +``` + +> Use `search-docs` for MustVerifyEmail implementation patterns. + +### Password Reset Setup + +``` +- [ ] Enable resetPasswords feature in config +- [ ] Set up requestPasswordResetLinkView callback +- [ ] Set up resetPasswordView callback +- [ ] Define password.reset named route (if views disabled) +- [ ] Test reset email and link flow +``` + +> Use `search-docs` for custom password reset flow patterns. + +### SPA Authentication Setup + +``` +- [ ] Set 'views' => false in config/fortify.php +- [ ] Install and configure Laravel Sanctum +- [ ] Use 'web' guard in fortify config +- [ ] Set up CSRF token handling +- [ ] Test XHR authentication flows +``` + +> Use `search-docs` for integration and SPA authentication patterns. + +## Best Practices + +### Custom Authentication Logic + +Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects. + +### Registration Customization + +Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields. + +### Rate Limiting + +Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination. + +## Key Endpoints + +| Feature | Method | Endpoint | +|------------------------|----------|---------------------------------------------| +| Login | POST | `/login` | +| Logout | POST | `/logout` | +| Register | POST | `/register` | +| Password Reset Request | POST | `/forgot-password` | +| Password Reset | POST | `/reset-password` | +| Email Verify Notice | GET | `/email/verify` | +| Resend Verification | POST | `/email/verification-notification` | +| Password Confirm | POST | `/user/confirm-password` | +| Enable 2FA | POST | `/user/two-factor-authentication` | +| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` | +| 2FA Challenge | POST | `/two-factor-challenge` | +| Get QR Code | GET | `/user/two-factor-qr-code` | +| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` | \ No newline at end of file diff --git a/.claude/skills/fluxui-development/SKILL.md b/.claude/skills/fluxui-development/SKILL.md new file mode 100644 index 00000000..4b5aabb1 --- /dev/null +++ b/.claude/skills/fluxui-development/SKILL.md @@ -0,0 +1,81 @@ +--- +name: fluxui-development +description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling." +license: MIT +metadata: + author: laravel +--- + +# Flux UI Development + +## Documentation + +Use `search-docs` for detailed Flux UI patterns and documentation. + +## Basic Usage + +This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components. + +Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize. + +Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs. + + +```blade +Click me +``` + +## Available Components (Free Edition) + +Available: avatar, badge, brand, breadcrumbs, button, callout, 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/.claude/skills/livewire-development/SKILL.md b/.claude/skills/livewire-development/SKILL.md new file mode 100644 index 00000000..c009dae6 --- /dev/null +++ b/.claude/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/.claude/skills/livewire-development/reference/javascript-hooks.md b/.claude/skills/livewire-development/reference/javascript-hooks.md new file mode 100644 index 00000000..d6a44170 --- /dev/null +++ b/.claude/skills/livewire-development/reference/javascript-hooks.md @@ -0,0 +1,39 @@ +# Livewire 4 JavaScript Integration + +## Interceptor System (v4) + +### Intercept Messages + +```js +Livewire.interceptMessage(({ component, message, onFinish, onSuccess, onError }) => { + onFinish(() => { /* After response, before processing */ }); + onSuccess(({ payload }) => { /* payload.snapshot, payload.effects */ }); + onError(() => { /* Server errors */ }); +}); +``` + +### Intercept Requests + +```js +Livewire.interceptRequest(({ request, onResponse, onSuccess, onError, onFailure }) => { + onResponse(({ response }) => { /* When received */ }); + onSuccess(({ response, responseJson }) => { /* Success */ }); + onError(({ response, responseBody, preventDefault }) => { /* 4xx/5xx */ }); + onFailure(({ error }) => { /* Network failures */ }); +}); +``` + +### Component-Scoped Interceptors + +```blade + +``` + +## Magic Properties + +- `$errors` - Access validation errors from JavaScript +- `$intercept` - Component-scoped interceptors \ No newline at end of file diff --git a/.claude/skills/pest-testing/SKILL.md b/.claude/skills/pest-testing/SKILL.md new file mode 100644 index 00000000..ba774e71 --- /dev/null +++ b/.claude/skills/pest-testing/SKILL.md @@ -0,0 +1,157 @@ +--- +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: 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 + + +```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/.claude/skills/tailwindcss-development/SKILL.md b/.claude/skills/tailwindcss-development/SKILL.md new file mode 100644 index 00000000..7c8e295e --- /dev/null +++ b/.claude/skills/tailwindcss-development/SKILL.md @@ -0,0 +1,119 @@ +--- +name: tailwindcss-development +description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS." +license: MIT +metadata: + author: laravel +--- + +# Tailwind CSS Development + +## Documentation + +Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. + +## Basic Usage + +- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. +- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). +- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. + +## Tailwind CSS v4 Specifics + +- Always use Tailwind CSS v4 and avoid deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. + +### CSS-First Configuration + +In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: + + +```css +@theme { + --color-brand: oklch(0.72 0.11 178); +} +``` + +### Import Syntax + +In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: + + +```diff +- @tailwind base; +- @tailwind components; +- @tailwind utilities; ++ @import "tailwindcss"; +``` + +### Replaced Utilities + +Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. + +| Deprecated | Replacement | +|------------|-------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + +## Spacing + +Use `gap` utilities instead of margins for spacing between siblings: + + +```html +
+
Item 1
+
Item 2
+
+``` + +## Dark Mode + +If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: + + +```html +
+ Content adapts to color scheme +
+``` + +## Common Patterns + +### Flexbox Layout + + +```html +
+
Left content
+
Right content
+
+``` + +### Grid Layout + + +```html +
+
Card 1
+
Card 2
+
Card 3
+
+``` + +## Common Pitfalls + +- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.) +- Using `@tailwind` directives instead of `@import "tailwindcss"` +- Trying to use `tailwind.config.js` instead of CSS `@theme` directive +- Using margins for spacing between siblings instead of gap utilities +- Forgetting to add dark mode variants when the project uses dark mode \ No newline at end of file diff --git a/.gitignore b/.gitignore index c7cf1fa6..d1e131be 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ yarn-error.log /.nova /.vscode /.zed +.playwright-mcp/ +*.png diff --git a/.mcp.json b/.mcp.json index 0ad95248..b2d6bef5 100644 --- a/.mcp.json +++ b/.mcp.json @@ -3,7 +3,7 @@ "laravel-boost": { "command": "php", "args": [ - "./artisan", + "artisan", "boost:mcp" ] }, diff --git a/.playwright-mcp/console-2026-03-18T16-33-17-273Z.log b/.playwright-mcp/console-2026-03-18T16-33-17-273Z.log new file mode 100644 index 00000000..120d1bc0 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T16-33-17-273Z.log @@ -0,0 +1,2 @@ +[ 164ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/:0 +[ 187ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-03-18T16-33-23-085Z.log b/.playwright-mcp/console-2026-03-18T16-33-23-085Z.log new file mode 100644 index 00000000..a1c74cb0 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T16-33-23-085Z.log @@ -0,0 +1 @@ +[ 21744ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/admin:0 diff --git a/.playwright-mcp/console-2026-03-18T16-34-18-890Z.log b/.playwright-mcp/console-2026-03-18T16-34-18-890Z.log new file mode 100644 index 00000000..e4a26d94 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T16-34-18-890Z.log @@ -0,0 +1 @@ +[ 79ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/account/login:0 diff --git a/.playwright-mcp/console-2026-03-18T16-34-23-356Z.log b/.playwright-mcp/console-2026-03-18T16-34-23-356Z.log new file mode 100644 index 00000000..6f7f0685 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T16-34-23-356Z.log @@ -0,0 +1 @@ +[ 49ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/account/register:0 diff --git a/.playwright-mcp/console-2026-03-18T16-34-28-923Z.log b/.playwright-mcp/console-2026-03-18T16-34-28-923Z.log new file mode 100644 index 00000000..eb6a66f7 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T16-34-28-923Z.log @@ -0,0 +1 @@ +[ 52ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/some-nonexistent-page:0 diff --git a/.playwright-mcp/console-2026-03-18T16-35-56-028Z.log b/.playwright-mcp/console-2026-03-18T16-35-56-028Z.log new file mode 100644 index 00000000..0467aebc --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T16-35-56-028Z.log @@ -0,0 +1,3 @@ +[ 13748ms] [WARNING] The resource http://shop.test/build/assets/app-4S3Q_fSl.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://shop.test/account/register:0 +[ 26296ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://shop.test/livewire-6701cc17/update:0 +[ 29711ms] [WARNING] The resource http://shop.test/build/assets/app-4S3Q_fSl.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://shop.test/account/register:0 diff --git a/.playwright-mcp/console-2026-03-18T16-36-42-692Z.log b/.playwright-mcp/console-2026-03-18T16-36-42-692Z.log new file mode 100644 index 00000000..6beb6c45 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T16-36-42-692Z.log @@ -0,0 +1 @@ +[ 7024ms] [ERROR] Failed to load resource: the server responded with a status of 419 (unknown status) @ http://shop.test/admin/logout:0 diff --git a/.playwright-mcp/console-2026-03-18T16-37-41-398Z.log b/.playwright-mcp/console-2026-03-18T16-37-41-398Z.log new file mode 100644 index 00000000..34aebdc8 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T16-37-41-398Z.log @@ -0,0 +1 @@ +[ 56ms] [ERROR] Failed to load resource: the server responded with a status of 503 (Service Unavailable) @ http://shop.test/:0 diff --git a/.playwright-mcp/console-2026-03-18T16-47-33-580Z.log b/.playwright-mcp/console-2026-03-18T16-47-33-580Z.log new file mode 100644 index 00000000..2212ce58 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T16-47-33-580Z.log @@ -0,0 +1 @@ +[ 22985ms] [WARNING] The resource http://shop.test/build/assets/app-4S3Q_fSl.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://shop.test/admin/login:0 diff --git a/.playwright-mcp/console-2026-03-18T17-29-59-744Z.log b/.playwright-mcp/console-2026-03-18T17-29-59-744Z.log new file mode 100644 index 00000000..4238c376 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T17-29-59-744Z.log @@ -0,0 +1,2 @@ +[ 75ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://acme-fashion.test/:0 +[ 219ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://acme-fashion.test/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-03-18T17-30-12-085Z.log b/.playwright-mcp/console-2026-03-18T17-30-12-085Z.log new file mode 100644 index 00000000..ef4c31d0 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T17-30-12-085Z.log @@ -0,0 +1,2 @@ +[ 99ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/:0 +[ 136ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-03-18T17-30-38-272Z.log b/.playwright-mcp/console-2026-03-18T17-30-38-272Z.log new file mode 100644 index 00000000..de71a19a --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T17-30-38-272Z.log @@ -0,0 +1,4 @@ +[ 172ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/handmade-tote-bag.jpg:0 +[ 181ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/limited-edition-sneakers.jpg:0 +[ 199ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/cotton-polo-shirt.jpg:0 +[ 201ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/high-waist-wide-leg-jeans.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T17-31-52-927Z.log b/.playwright-mcp/console-2026-03-18T17-31-52-927Z.log new file mode 100644 index 00000000..e02d236c --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T17-31-52-927Z.log @@ -0,0 +1,4 @@ +[ 110ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/classic-cotton-t-shirt.jpg:0 +[ 111ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/organic-cotton-hoodie.jpg:0 +[ 120ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/relaxed-fit-t-shirt.jpg:0 +[ 120ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/cotton-polo-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T17-32-07-222Z.log b/.playwright-mcp/console-2026-03-18T17-32-07-222Z.log new file mode 100644 index 00000000..9b063c6b --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T17-32-07-222Z.log @@ -0,0 +1 @@ +[ 104ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/classic-cotton-t-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T17-32-50-186Z.log b/.playwright-mcp/console-2026-03-18T17-32-50-186Z.log new file mode 100644 index 00000000..41b7e331 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T17-32-50-186Z.log @@ -0,0 +1 @@ +[ 108ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/premium-slim-fit-jeans.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T17-33-09-988Z.log b/.playwright-mcp/console-2026-03-18T17-33-09-988Z.log new file mode 100644 index 00000000..4611cd5d --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T17-33-09-988Z.log @@ -0,0 +1 @@ +[ 95ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/limited-edition-sneakers.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T17-33-25-751Z.log b/.playwright-mcp/console-2026-03-18T17-33-25-751Z.log new file mode 100644 index 00000000..788df185 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T17-33-25-751Z.log @@ -0,0 +1 @@ +[ 56ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/unreleased-summer-piece:0 diff --git a/.playwright-mcp/console-2026-03-18T17-33-37-032Z.log b/.playwright-mcp/console-2026-03-18T17-33-37-032Z.log new file mode 100644 index 00000000..25162e7e --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T17-33-37-032Z.log @@ -0,0 +1 @@ +[ 59ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/nonexistent-product:0 diff --git a/.playwright-mcp/console-2026-03-18T17-33-40-464Z.log b/.playwright-mcp/console-2026-03-18T17-33-40-464Z.log new file mode 100644 index 00000000..eb3424ed --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T17-33-40-464Z.log @@ -0,0 +1 @@ +[ 44ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/nonexistent-collection:0 diff --git a/.playwright-mcp/console-2026-03-18T17-34-00-494Z.log b/.playwright-mcp/console-2026-03-18T17-34-00-494Z.log new file mode 100644 index 00000000..8b5bf5c1 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T17-34-00-494Z.log @@ -0,0 +1,5 @@ +[ 110ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/handmade-tote-bag.jpg:0 +[ 111ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/cotton-polo-shirt.jpg:0 +[ 114ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/limited-edition-sneakers.jpg:0 +[ 117ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/high-waist-wide-leg-jeans.jpg:0 +[ 5593ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/handmade-tote-bag.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T17-34-33-660Z.log b/.playwright-mcp/console-2026-03-18T17-34-33-660Z.log new file mode 100644 index 00000000..e5d378f6 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T17-34-33-660Z.log @@ -0,0 +1,4 @@ +[ 93ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/classic-cotton-t-shirt.jpg:0 +[ 97ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/relaxed-fit-t-shirt.jpg:0 +[ 97ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/organic-cotton-hoodie.jpg:0 +[ 108ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/cotton-polo-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T18-02-22-358Z.log b/.playwright-mcp/console-2026-03-18T18-02-22-358Z.log new file mode 100644 index 00000000..e39b577b --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T18-02-22-358Z.log @@ -0,0 +1 @@ +[ 532ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/classic-cotton-t-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T18-06-22-632Z.log b/.playwright-mcp/console-2026-03-18T18-06-22-632Z.log new file mode 100644 index 00000000..0e3f0ca5 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T18-06-22-632Z.log @@ -0,0 +1 @@ +[ 103ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/limited-edition-sneakers.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T18-06-30-288Z.log b/.playwright-mcp/console-2026-03-18T18-06-30-288Z.log new file mode 100644 index 00000000..01e681d4 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T18-06-30-288Z.log @@ -0,0 +1,5 @@ +[ 81ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/premium-slim-fit-jeans.jpg:0 +[ 36654ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/handmade-tote-bag.jpg:0 +[ 36664ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/limited-edition-sneakers.jpg:0 +[ 36667ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/cotton-polo-shirt.jpg:0 +[ 36668ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/high-waist-wide-leg-jeans.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T18-12-42-395Z.log b/.playwright-mcp/console-2026-03-18T18-12-42-395Z.log new file mode 100644 index 00000000..0496ce20 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T18-12-42-395Z.log @@ -0,0 +1 @@ +[ 79ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/classic-cotton-t-shirt:0 diff --git a/.playwright-mcp/console-2026-03-18T18-12-51-558Z.log b/.playwright-mcp/console-2026-03-18T18-12-51-558Z.log new file mode 100644 index 00000000..5d7b98d4 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T18-12-51-558Z.log @@ -0,0 +1 @@ +[ 53ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/classic-cotton-t-shirt:0 diff --git a/.playwright-mcp/console-2026-03-18T18-13-08-046Z.log b/.playwright-mcp/console-2026-03-18T18-13-08-046Z.log new file mode 100644 index 00000000..b2f30051 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T18-13-08-046Z.log @@ -0,0 +1,3 @@ +[ 118ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/classic-cotton-t-shirt.jpg:0 +[ 8484ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://shop.test/livewire-6701cc17/update:0 +[ 8931ms] Object diff --git a/.playwright-mcp/console-2026-03-18T18-15-21-879Z.log b/.playwright-mcp/console-2026-03-18T18-15-21-879Z.log new file mode 100644 index 00000000..97bbef2e --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T18-15-21-879Z.log @@ -0,0 +1 @@ +[ 130ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/classic-cotton-t-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T18-34-06-482Z.log b/.playwright-mcp/console-2026-03-18T18-34-06-482Z.log new file mode 100644 index 00000000..bb474140 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T18-34-06-482Z.log @@ -0,0 +1 @@ +[ -6ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/:0 diff --git a/.playwright-mcp/console-2026-03-18T18-35-31-998Z.log b/.playwright-mcp/console-2026-03-18T18-35-31-998Z.log new file mode 100644 index 00000000..4a461ca1 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T18-35-31-998Z.log @@ -0,0 +1,4 @@ +[ 148ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/handmade-tote-bag.jpg:0 +[ 149ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/cotton-polo-shirt.jpg:0 +[ 150ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/high-waist-wide-leg-jeans.jpg:0 +[ 156ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/limited-edition-sneakers.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T18-35-47-789Z.log b/.playwright-mcp/console-2026-03-18T18-35-47-789Z.log new file mode 100644 index 00000000..70034f3a --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T18-35-47-789Z.log @@ -0,0 +1 @@ +[ 145ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/classic-cotton-t-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T18-37-06-203Z.log b/.playwright-mcp/console-2026-03-18T18-37-06-203Z.log new file mode 100644 index 00000000..2da35ef3 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T18-37-06-203Z.log @@ -0,0 +1 @@ +[ 103ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/classic-cotton-t-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T18-38-31-358Z.log b/.playwright-mcp/console-2026-03-18T18-38-31-358Z.log new file mode 100644 index 00000000..03c9c047 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T18-38-31-358Z.log @@ -0,0 +1 @@ +[ 89ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/classic-cotton-t-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T18-39-27-704Z.log b/.playwright-mcp/console-2026-03-18T18-39-27-704Z.log new file mode 100644 index 00000000..50ae413c --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T18-39-27-704Z.log @@ -0,0 +1,4 @@ +[ 108ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/handmade-tote-bag.jpg:0 +[ 109ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/cotton-polo-shirt.jpg:0 +[ 110ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/limited-edition-sneakers.jpg:0 +[ 121ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/high-waist-wide-leg-jeans.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T18-44-38-173Z.log b/.playwright-mcp/console-2026-03-18T18-44-38-173Z.log new file mode 100644 index 00000000..c7b3d213 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T18-44-38-173Z.log @@ -0,0 +1 @@ +[ 329ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/classic-cotton-t-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T18-45-34-686Z.log b/.playwright-mcp/console-2026-03-18T18-45-34-686Z.log new file mode 100644 index 00000000..79a41fe4 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T18-45-34-686Z.log @@ -0,0 +1,2 @@ +[ 97ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/classic-cotton-t-shirt.jpg:0 +[ 56453ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://shop.test/livewire-6701cc17/update:0 diff --git a/.playwright-mcp/console-2026-03-18T18-46-40-990Z.log b/.playwright-mcp/console-2026-03-18T18-46-40-990Z.log new file mode 100644 index 00000000..5a470c40 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T18-46-40-990Z.log @@ -0,0 +1 @@ +[ 124ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/classic-cotton-t-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T18-46-54-974Z.log b/.playwright-mcp/console-2026-03-18T18-46-54-974Z.log new file mode 100644 index 00000000..4d08ae70 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T18-46-54-974Z.log @@ -0,0 +1 @@ +[ 90ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/classic-cotton-t-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T18-49-36-310Z.log b/.playwright-mcp/console-2026-03-18T18-49-36-310Z.log new file mode 100644 index 00000000..f5d56e4a --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T18-49-36-310Z.log @@ -0,0 +1 @@ +[ 204ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/classic-cotton-t-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T19-22-56-387Z.log b/.playwright-mcp/console-2026-03-18T19-22-56-387Z.log new file mode 100644 index 00000000..2546b1b8 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T19-22-56-387Z.log @@ -0,0 +1 @@ +[ 430ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-03-18T19-23-23-611Z.log b/.playwright-mcp/console-2026-03-18T19-23-23-611Z.log new file mode 100644 index 00000000..61a25f27 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T19-23-23-611Z.log @@ -0,0 +1 @@ +[ 46ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/account/orders/1001:0 diff --git a/.playwright-mcp/console-2026-03-18T19-28-06-496Z.log b/.playwright-mcp/console-2026-03-18T19-28-06-496Z.log new file mode 100644 index 00000000..3b0692b5 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T19-28-06-496Z.log @@ -0,0 +1,3 @@ +[ 360ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 +[ 597516ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/account:0 +[ 599169ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/account:0 diff --git a/.playwright-mcp/console-2026-03-18T19-42-08-620Z.log b/.playwright-mcp/console-2026-03-18T19-42-08-620Z.log new file mode 100644 index 00000000..1f65a496 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T19-42-08-620Z.log @@ -0,0 +1,2 @@ +[ 90ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/account/orders/1001:0 +[ 144ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-03-18T19-42-40-386Z.log b/.playwright-mcp/console-2026-03-18T19-42-40-386Z.log new file mode 100644 index 00000000..ea86d57c --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T19-42-40-386Z.log @@ -0,0 +1 @@ +[ 89ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/account/login:0 diff --git a/.playwright-mcp/console-2026-03-18T20-14-28-791Z.log b/.playwright-mcp/console-2026-03-18T20-14-28-791Z.log new file mode 100644 index 00000000..7176240d --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T20-14-28-791Z.log @@ -0,0 +1 @@ +[ 352ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-03-18T20-17-20-728Z.log b/.playwright-mcp/console-2026-03-18T20-17-20-728Z.log new file mode 100644 index 00000000..9fffd6a3 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T20-17-20-728Z.log @@ -0,0 +1,32 @@ +[ 19928ms] [WARNING] Alpine Expression Error: $call is not defined + +Expression: "argumentsToArray('selectedZoneId', 1); $call('addRate')" + + JSHandle@node @ http://shop.test/admin/orders/3:111 +[ 19929ms] [WARNING] Alpine Expression Error: $call is not defined + +Expression: "argumentsToArray('selectedZoneId', 2); $call('addRate')" + + JSHandle@node @ http://shop.test/admin/orders/3:111 +[ 19964ms] ReferenceError: $call is not defined + at [Alpine] argumentsToArray('selectedZoneId', 1); $call('addRate') (eval at safeAsyncFunction (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:1385:21), :3:71) + at http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:1410:28 + at tryCatch (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:1309:14) + at Object.evaluate (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:1343:34) + at Directive.parseOutMethodsAndParams (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:5783:29) + at get methods (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:5764:19) + at getTargets (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:14665:18) + at http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:14515:33 + at Array. (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:5702:9) + at trigger2 (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:4294:34) +[ 19964ms] ReferenceError: $call is not defined + at [Alpine] argumentsToArray('selectedZoneId', 2); $call('addRate') (eval at safeAsyncFunction (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:1385:21), :3:71) + at http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:1410:28 + at tryCatch (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:1309:14) + at Object.evaluate (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:1343:34) + at Directive.parseOutMethodsAndParams (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:5783:29) + at get methods (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:5764:19) + at getTargets (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:14665:18) + at http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:14515:33 + at Array. (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:5702:9) + at trigger2 (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:4294:34) diff --git a/.playwright-mcp/console-2026-03-18T22-02-27-725Z.log b/.playwright-mcp/console-2026-03-18T22-02-27-725Z.log new file mode 100644 index 00000000..6c7e6849 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T22-02-27-725Z.log @@ -0,0 +1,2 @@ +[ 62ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/search:0 +[ 92ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-03-18T22-02-39-735Z.log b/.playwright-mcp/console-2026-03-18T22-02-39-735Z.log new file mode 100644 index 00000000..a179129a --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T22-02-39-735Z.log @@ -0,0 +1,2 @@ +[ 82ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://acme-fashion.test/search:0 +[ 229ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://acme-fashion.test/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-03-18T22-02-58-818Z.log b/.playwright-mcp/console-2026-03-18T22-02-58-818Z.log new file mode 100644 index 00000000..20037860 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T22-02-58-818Z.log @@ -0,0 +1,5 @@ +[ 3857ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/organic-cotton-hoodie.jpg:0 +[ 3857ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/classic-cotton-t-shirt.jpg:0 +[ 3859ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/cotton-polo-shirt.jpg:0 +[ 16724ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/organic-cotton-hoodie.jpg:0 +[ 16727ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/cotton-polo-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T22-04-46-366Z.log b/.playwright-mcp/console-2026-03-18T22-04-46-366Z.log new file mode 100644 index 00000000..a007ddde --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T22-04-46-366Z.log @@ -0,0 +1,5 @@ +[ 105ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/cotton-polo-shirt.jpg:0 +[ 114ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/limited-edition-sneakers.jpg:0 +[ 114ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/handmade-tote-bag.jpg:0 +[ 117ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/high-waist-wide-leg-jeans.jpg:0 +[ 3568ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/cotton-polo-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T22-17-08-989Z.log b/.playwright-mcp/console-2026-03-18T22-17-08-989Z.log new file mode 100644 index 00000000..5ef184dc --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T22-17-08-989Z.log @@ -0,0 +1 @@ +[ 54ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/:0 diff --git a/.playwright-mcp/console-2026-03-18T22-17-16-517Z.log b/.playwright-mcp/console-2026-03-18T22-17-16-517Z.log new file mode 100644 index 00000000..ed80d11c --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T22-17-16-517Z.log @@ -0,0 +1 @@ +[ 68ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://acme-fashion.test/:0 diff --git a/.playwright-mcp/console-2026-03-18T22-17-36-160Z.log b/.playwright-mcp/console-2026-03-18T22-17-36-160Z.log new file mode 100644 index 00000000..dc9510b6 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T22-17-36-160Z.log @@ -0,0 +1,9 @@ +[ 129ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/handmade-tote-bag.jpg:0 +[ 134ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/cotton-polo-shirt.jpg:0 +[ 145ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/high-waist-wide-leg-jeans.jpg:0 +[ 148ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/limited-edition-sneakers.jpg:0 +[ 20625ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/high-waist-wide-leg-jeans.jpg:0 +[ 20626ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/premium-slim-fit-jeans.jpg:0 +[ 20744ms] undefined +[ 20757ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/high-waist-wide-leg-jeans.jpg:0 +[ 20757ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/premium-slim-fit-jeans.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T22-18-59-317Z.log b/.playwright-mcp/console-2026-03-18T22-18-59-317Z.log new file mode 100644 index 00000000..63ba68d4 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T22-18-59-317Z.log @@ -0,0 +1,40 @@ +[ 89ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/classic-cotton-t-shirt.jpg:0 +[ 89ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/organic-cotton-hoodie.jpg:0 +[ 92ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/cotton-polo-shirt.jpg:0 +[ 103ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/relaxed-fit-t-shirt.jpg:0 +[ 364099ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/classic-cotton-t-shirt.jpg:0 +[ 364100ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/organic-cotton-hoodie.jpg:0 +[ 364103ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/relaxed-fit-t-shirt.jpg:0 +[ 364111ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/cotton-polo-shirt.jpg:0 +[ 381484ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/relaxed-fit-t-shirt.jpg:0 +[ 381485ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/classic-cotton-t-shirt.jpg:0 +[ 381487ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/cotton-polo-shirt.jpg:0 +[ 381495ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/organic-cotton-hoodie.jpg:0 +[ 388382ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/classic-cotton-t-shirt.jpg:0 +[ 388384ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/organic-cotton-hoodie.jpg:0 +[ 388384ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/relaxed-fit-t-shirt.jpg:0 +[ 388390ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/cotton-polo-shirt.jpg:0 +[ 403989ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/classic-cotton-t-shirt.jpg:0 +[ 403989ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/relaxed-fit-t-shirt.jpg:0 +[ 403989ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/organic-cotton-hoodie.jpg:0 +[ 403998ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/cotton-polo-shirt.jpg:0 +[ 425117ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/cotton-polo-shirt.jpg:0 +[ 425120ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/classic-cotton-t-shirt.jpg:0 +[ 425126ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/relaxed-fit-t-shirt.jpg:0 +[ 425129ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/organic-cotton-hoodie.jpg:0 +[ 430877ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/cotton-polo-shirt.jpg:0 +[ 430878ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/classic-cotton-t-shirt.jpg:0 +[ 430881ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/organic-cotton-hoodie.jpg:0 +[ 430887ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/relaxed-fit-t-shirt.jpg:0 +[ 434006ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/organic-cotton-hoodie.jpg:0 +[ 434009ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/cotton-polo-shirt.jpg:0 +[ 434010ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/classic-cotton-t-shirt.jpg:0 +[ 434029ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/relaxed-fit-t-shirt.jpg:0 +[ 442738ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/classic-cotton-t-shirt.jpg:0 +[ 442739ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/relaxed-fit-t-shirt.jpg:0 +[ 442748ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/cotton-polo-shirt.jpg:0 +[ 442752ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/organic-cotton-hoodie.jpg:0 +[ 498953ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/classic-cotton-t-shirt.jpg:0 +[ 498953ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/relaxed-fit-t-shirt.jpg:0 +[ 498959ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/organic-cotton-hoodie.jpg:0 +[ 498968ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/cotton-polo-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T22-28-56-840Z.log b/.playwright-mcp/console-2026-03-18T22-28-56-840Z.log new file mode 100644 index 00000000..baf40b8b --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T22-28-56-840Z.log @@ -0,0 +1,5 @@ +[ 121ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/limited-edition-sneakers.jpg:0 +[ 122ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/handmade-tote-bag.jpg:0 +[ 126ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/cotton-polo-shirt.jpg:0 +[ 132ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/high-waist-wide-leg-jeans.jpg:0 +[ 380941ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/:0 diff --git a/.playwright-mcp/console-2026-03-18T22-42-40-930Z.log b/.playwright-mcp/console-2026-03-18T22-42-40-930Z.log new file mode 100644 index 00000000..616dc2f5 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T22-42-40-930Z.log @@ -0,0 +1 @@ +[ 64ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/nonexistent-page:0 diff --git a/.playwright-mcp/console-2026-03-18T22-43-19-865Z.log b/.playwright-mcp/console-2026-03-18T22-43-19-865Z.log new file mode 100644 index 00000000..41ba5040 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T22-43-19-865Z.log @@ -0,0 +1 @@ +[ 67ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/:0 diff --git a/.playwright-mcp/console-2026-03-18T22-43-41-327Z.log b/.playwright-mcp/console-2026-03-18T22-43-41-327Z.log new file mode 100644 index 00000000..07a5d1ad --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T22-43-41-327Z.log @@ -0,0 +1 @@ +[ 91ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://acme-fashion.test/:0 diff --git a/.playwright-mcp/console-2026-03-18T22-44-01-730Z.log b/.playwright-mcp/console-2026-03-18T22-44-01-730Z.log new file mode 100644 index 00000000..1ac66aa6 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T22-44-01-730Z.log @@ -0,0 +1,4 @@ +[ 134ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/handmade-tote-bag.jpg:0 +[ 135ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/limited-edition-sneakers.jpg:0 +[ 135ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/cotton-polo-shirt.jpg:0 +[ 151ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/high-waist-wide-leg-jeans.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T22-55-03-221Z.log b/.playwright-mcp/console-2026-03-18T22-55-03-221Z.log new file mode 100644 index 00000000..3ccc0899 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T22-55-03-221Z.log @@ -0,0 +1,4 @@ +[ 205ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/limited-edition-sneakers.jpg:0 +[ 216ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/handmade-tote-bag.jpg:0 +[ 217ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/cotton-polo-shirt.jpg:0 +[ 219ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/high-waist-wide-leg-jeans.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T22-55-15-678Z.log b/.playwright-mcp/console-2026-03-18T22-55-15-678Z.log new file mode 100644 index 00000000..4c3505a4 --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T22-55-15-678Z.log @@ -0,0 +1,4 @@ +[ 107ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/classic-cotton-t-shirt.jpg:0 +[ 108ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/cotton-polo-shirt.jpg:0 +[ 109ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/organic-cotton-hoodie.jpg:0 +[ 117ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/relaxed-fit-t-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T22-55-20-285Z.log b/.playwright-mcp/console-2026-03-18T22-55-20-285Z.log new file mode 100644 index 00000000..390d5fdc --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T22-55-20-285Z.log @@ -0,0 +1 @@ +[ 94ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/classic-cotton-t-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T22-55-48-347Z.log b/.playwright-mcp/console-2026-03-18T22-55-48-347Z.log new file mode 100644 index 00000000..549cff1f --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T22-55-48-347Z.log @@ -0,0 +1,4 @@ +[ 94ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/relaxed-fit-t-shirt.jpg:0 +[ 94ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/cotton-polo-shirt.jpg:0 +[ 95ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/classic-cotton-t-shirt.jpg:0 +[ 108ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/organic-cotton-hoodie.jpg:0 diff --git a/.playwright-mcp/console-2026-03-18T22-55-57-988Z.log b/.playwright-mcp/console-2026-03-18T22-55-57-988Z.log new file mode 100644 index 00000000..b59f19aa --- /dev/null +++ b/.playwright-mcp/console-2026-03-18T22-55-57-988Z.log @@ -0,0 +1,5 @@ +[ 104ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/cashmere-v-neck-sweater.jpg:0 +[ 104ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/limited-edition-sneakers.jpg:0 +[ 105ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/classic-cotton-t-shirt.jpg:0 +[ 115ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/premium-slim-fit-jeans.jpg:0 +[ 115ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/wool-blend-cardigan.jpg:0 diff --git a/.playwright-mcp/console-2026-03-19T01-06-00-519Z.log b/.playwright-mcp/console-2026-03-19T01-06-00-519Z.log new file mode 100644 index 00000000..fd7c03c1 --- /dev/null +++ b/.playwright-mcp/console-2026-03-19T01-06-00-519Z.log @@ -0,0 +1 @@ +[ 104ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/premium-slim-fit-jeans.jpg:0 diff --git a/.playwright-mcp/console-2026-03-19T01-06-08-690Z.log b/.playwright-mcp/console-2026-03-19T01-06-08-690Z.log new file mode 100644 index 00000000..3c2bce1f --- /dev/null +++ b/.playwright-mcp/console-2026-03-19T01-06-08-690Z.log @@ -0,0 +1 @@ +[ 109ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/limited-edition-sneakers.jpg:0 diff --git a/.playwright-mcp/console-2026-03-19T01-06-17-053Z.log b/.playwright-mcp/console-2026-03-19T01-06-17-053Z.log new file mode 100644 index 00000000..004f63c9 --- /dev/null +++ b/.playwright-mcp/console-2026-03-19T01-06-17-053Z.log @@ -0,0 +1 @@ +[ 59ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/backorder-denim-jacket:0 diff --git a/.playwright-mcp/console-2026-03-19T01-06-47-575Z.log b/.playwright-mcp/console-2026-03-19T01-06-47-575Z.log new file mode 100644 index 00000000..2e79263b --- /dev/null +++ b/.playwright-mcp/console-2026-03-19T01-06-47-575Z.log @@ -0,0 +1 @@ +[ 114ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/handmade-tote-bag.jpg:0 diff --git a/.playwright-mcp/console-2026-03-19T01-07-15-745Z.log b/.playwright-mcp/console-2026-03-19T01-07-15-745Z.log new file mode 100644 index 00000000..5374098f --- /dev/null +++ b/.playwright-mcp/console-2026-03-19T01-07-15-745Z.log @@ -0,0 +1,8 @@ +[ 117ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/handmade-tote-bag.jpg:0 +[ 118ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/cotton-polo-shirt.jpg:0 +[ 124ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/limited-edition-sneakers.jpg:0 +[ 126ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/high-waist-wide-leg-jeans.jpg:0 +[ 7118ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/classic-cotton-t-shirt.jpg:0 +[ 7119ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/organic-cotton-hoodie.jpg:0 +[ 7121ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/cotton-polo-shirt.jpg:0 +[ 7127ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/relaxed-fit-t-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-19T01-46-46-962Z.log b/.playwright-mcp/console-2026-03-19T01-46-46-962Z.log new file mode 100644 index 00000000..f81c900f --- /dev/null +++ b/.playwright-mcp/console-2026-03-19T01-46-46-962Z.log @@ -0,0 +1 @@ +[ 14510ms] [WARNING] The specified value "2026-03-01" does not conform to the required format. The format is "yyyy-MM-ddThh:mm" followed by optional ":ss" or ":ss.SSS". @ :6937 diff --git a/.playwright-mcp/console-2026-03-19T01-48-07-144Z.log b/.playwright-mcp/console-2026-03-19T01-48-07-144Z.log new file mode 100644 index 00000000..ac11cddc --- /dev/null +++ b/.playwright-mcp/console-2026-03-19T01-48-07-144Z.log @@ -0,0 +1,32 @@ +[ 22659ms] [WARNING] Alpine Expression Error: $call is not defined + +Expression: "argumentsToArray('selectedZoneId', 1); $call('addRate')" + + JSHandle@node @ http://shop.test/admin/settings:111 +[ 22660ms] [WARNING] Alpine Expression Error: $call is not defined + +Expression: "argumentsToArray('selectedZoneId', 2); $call('addRate')" + + JSHandle@node @ http://shop.test/admin/settings:111 +[ 22677ms] ReferenceError: $call is not defined + at [Alpine] argumentsToArray('selectedZoneId', 1); $call('addRate') (eval at safeAsyncFunction (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:1385:21), :3:71) + at http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:1410:28 + at tryCatch (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:1309:14) + at Object.evaluate (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:1343:34) + at Directive.parseOutMethodsAndParams (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:5783:29) + at get methods (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:5764:19) + at getTargets (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:14665:18) + at http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:14515:33 + at Array. (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:5702:9) + at trigger2 (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:4294:34) +[ 22677ms] ReferenceError: $call is not defined + at [Alpine] argumentsToArray('selectedZoneId', 2); $call('addRate') (eval at safeAsyncFunction (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:1385:21), :3:71) + at http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:1410:28 + at tryCatch (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:1309:14) + at Object.evaluate (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:1343:34) + at Directive.parseOutMethodsAndParams (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:5783:29) + at get methods (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:5764:19) + at getTargets (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:14665:18) + at http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:14515:33 + at Array. (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:5702:9) + at trigger2 (http://shop.test/livewire-6701cc17/livewire.js?id=cfc5c1ae:4294:34) diff --git a/.playwright-mcp/console-2026-03-19T01-52-24-858Z.log b/.playwright-mcp/console-2026-03-19T01-52-24-858Z.log new file mode 100644 index 00000000..4d8feeca --- /dev/null +++ b/.playwright-mcp/console-2026-03-19T01-52-24-858Z.log @@ -0,0 +1 @@ +[ 107ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/limited-edition-sneakers.jpg:0 diff --git a/.playwright-mcp/console-2026-03-19T01-52-32-648Z.log b/.playwright-mcp/console-2026-03-19T01-52-32-648Z.log new file mode 100644 index 00000000..5c35c598 --- /dev/null +++ b/.playwright-mcp/console-2026-03-19T01-52-32-648Z.log @@ -0,0 +1 @@ +[ 95ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/handmade-tote-bag.jpg:0 diff --git a/.playwright-mcp/console-2026-03-19T01-52-39-777Z.log b/.playwright-mcp/console-2026-03-19T01-52-39-777Z.log new file mode 100644 index 00000000..05c76a64 --- /dev/null +++ b/.playwright-mcp/console-2026-03-19T01-52-39-777Z.log @@ -0,0 +1 @@ +[ 99ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/classic-cotton-t-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-19T01-58-14-180Z.log b/.playwright-mcp/console-2026-03-19T01-58-14-180Z.log new file mode 100644 index 00000000..6efd808c --- /dev/null +++ b/.playwright-mcp/console-2026-03-19T01-58-14-180Z.log @@ -0,0 +1,5 @@ +[ 363ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/handmade-tote-bag.jpg:0 +[ 363ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/cotton-polo-shirt.jpg:0 +[ 364ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/limited-edition-sneakers.jpg:0 +[ 367ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 +[ 400ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/high-waist-wide-leg-jeans.jpg:0 diff --git a/.playwright-mcp/console-2026-03-19T02-03-51-315Z.log b/.playwright-mcp/console-2026-03-19T02-03-51-315Z.log new file mode 100644 index 00000000..1f78c7a1 --- /dev/null +++ b/.playwright-mcp/console-2026-03-19T02-03-51-315Z.log @@ -0,0 +1 @@ +[ 305ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-03-19T02-07-11-797Z.log b/.playwright-mcp/console-2026-03-19T02-07-11-797Z.log new file mode 100644 index 00000000..a7627f31 --- /dev/null +++ b/.playwright-mcp/console-2026-03-19T02-07-11-797Z.log @@ -0,0 +1,5 @@ +[ 320ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/handmade-tote-bag.jpg:0 +[ 323ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/cotton-polo-shirt.jpg:0 +[ 327ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/high-waist-wide-leg-jeans.jpg:0 +[ 329ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 +[ 369ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/limited-edition-sneakers.jpg:0 diff --git a/.playwright-mcp/console-2026-03-19T02-08-12-605Z.log b/.playwright-mcp/console-2026-03-19T02-08-12-605Z.log new file mode 100644 index 00000000..df384ec8 --- /dev/null +++ b/.playwright-mcp/console-2026-03-19T02-08-12-605Z.log @@ -0,0 +1,4 @@ +[ 203ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/classic-cotton-t-shirt.jpg:0 +[ 203ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/relaxed-fit-t-shirt.jpg:0 +[ 209ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/organic-cotton-hoodie.jpg:0 +[ 225ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/cotton-polo-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-19T02-08-23-678Z.log b/.playwright-mcp/console-2026-03-19T02-08-23-678Z.log new file mode 100644 index 00000000..f8e3ff79 --- /dev/null +++ b/.playwright-mcp/console-2026-03-19T02-08-23-678Z.log @@ -0,0 +1,4 @@ +[ 229ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/limited-edition-sneakers.jpg:0 +[ 248ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/high-waist-wide-leg-jeans.jpg:0 +[ 248ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/handmade-tote-bag.jpg:0 +[ 258ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/cotton-polo-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-19T02-08-34-989Z.log b/.playwright-mcp/console-2026-03-19T02-08-34-989Z.log new file mode 100644 index 00000000..ef9a0cbb --- /dev/null +++ b/.playwright-mcp/console-2026-03-19T02-08-34-989Z.log @@ -0,0 +1 @@ +[ 141ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/classic-cotton-t-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-19T02-09-01-136Z.log b/.playwright-mcp/console-2026-03-19T02-09-01-136Z.log new file mode 100644 index 00000000..32c6e94d --- /dev/null +++ b/.playwright-mcp/console-2026-03-19T02-09-01-136Z.log @@ -0,0 +1,5 @@ +[ 144ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/cotton-polo-shirt.jpg:0 +[ 144ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/high-waist-wide-leg-jeans.jpg:0 +[ 157ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/handmade-tote-bag.jpg:0 +[ 157ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 +[ 167ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/limited-edition-sneakers.jpg:0 diff --git a/.playwright-mcp/console-2026-03-19T02-09-13-720Z.log b/.playwright-mcp/console-2026-03-19T02-09-13-720Z.log new file mode 100644 index 00000000..87b962e5 --- /dev/null +++ b/.playwright-mcp/console-2026-03-19T02-09-13-720Z.log @@ -0,0 +1,4 @@ +[ 272ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/cotton-polo-shirt.jpg:0 +[ 302ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/classic-cotton-t-shirt.jpg:0 +[ 309ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/organic-cotton-hoodie.jpg:0 +[ 357ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/relaxed-fit-t-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-19T02-22-27-448Z.log b/.playwright-mcp/console-2026-03-19T02-22-27-448Z.log new file mode 100644 index 00000000..decfc1f8 --- /dev/null +++ b/.playwright-mcp/console-2026-03-19T02-22-27-448Z.log @@ -0,0 +1 @@ +[ 178ms] [ERROR] Failed to load resource: the server responded with a status of 405 (Method Not Allowed) @ http://shop.test/admin/logout:0 diff --git a/.playwright-mcp/console-2026-03-19T02-24-06-962Z.log b/.playwright-mcp/console-2026-03-19T02-24-06-962Z.log new file mode 100644 index 00000000..ccfcf839 --- /dev/null +++ b/.playwright-mcp/console-2026-03-19T02-24-06-962Z.log @@ -0,0 +1,2 @@ +[ 95ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products:0 +[ 125ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-03-19T02-24-09-845Z.log b/.playwright-mcp/console-2026-03-19T02-24-09-845Z.log new file mode 100644 index 00000000..d59ec595 --- /dev/null +++ b/.playwright-mcp/console-2026-03-19T02-24-09-845Z.log @@ -0,0 +1,5 @@ +[ 3335ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/relaxed-fit-t-shirt.jpg:0 +[ 3342ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/classic-cotton-t-shirt.jpg:0 +[ 3348ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/organic-cotton-hoodie.jpg:0 +[ 3378ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/collections/products/cotton-polo-shirt.jpg:0 +[ 7244ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/products/classic-cotton-t-shirt.jpg:0 diff --git a/.playwright-mcp/console-2026-03-19T02-25-26-233Z.log b/.playwright-mcp/console-2026-03-19T02-25-26-233Z.log new file mode 100644 index 00000000..bb5d0717 --- /dev/null +++ b/.playwright-mcp/console-2026-03-19T02-25-26-233Z.log @@ -0,0 +1 @@ +[ 117ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 diff --git a/.playwright-mcp/page-2026-03-18T18-02-45-829Z.png b/.playwright-mcp/page-2026-03-18T18-02-45-829Z.png new file mode 100644 index 00000000..30a81399 Binary files /dev/null and b/.playwright-mcp/page-2026-03-18T18-02-45-829Z.png differ diff --git a/.playwright-mcp/page-2026-03-18T18-36-38-098Z.png b/.playwright-mcp/page-2026-03-18T18-36-38-098Z.png new file mode 100644 index 00000000..417c4ccf Binary files /dev/null and b/.playwright-mcp/page-2026-03-18T18-36-38-098Z.png differ diff --git a/CLAUDE.md b/CLAUDE.md index 7b0f1e95..03d47579 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,432 +29,268 @@ The complete specification is in `specs/`. Start with `specs/09-IMPLEMENTATION-R # Laravel Boost Guidelines -The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications. ## Foundational Context + This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. -- php - 8.4.17 +- php - 8.4 +- laravel/fortify (FORTIFY) - v1 - laravel/framework (LARAVEL) - v12 - laravel/prompts (PROMPTS) - v0 - livewire/flux (FLUXUI_FREE) - v2 - livewire/livewire (LIVEWIRE) - v4 +- laravel/boost (BOOST) - v2 +- laravel/mcp (MCP) - v0 +- laravel/pail (PAIL) - v1 - laravel/pint (PINT) - v1 +- laravel/sail (SAIL) - v1 - pestphp/pest (PEST) - v4 +- phpunit/phpunit (PHPUNIT) - v12 - tailwindcss (TAILWINDCSS) - v4 +## Skills Activation + +This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. + +- `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: 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. +- `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. ## Conventions -- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. + +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming. - Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. - Check for existing components to reuse before writing a new one. ## Verification Scripts -- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important. ## Application Structure & Architecture -- Stick to existing directory structure - don't create new base folders without approval. + +- Stick to existing directory structure; don't create new base folders without approval. - Do not change the application's dependencies without approval. ## Frontend Bundling -- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. -## Replies -- Be concise in your explanations - focus on what's important rather than explaining obvious details. +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. ## Documentation Files + - You must only create documentation files if explicitly requested by the user. +## Replies + +- Be concise in your explanations - focus on what's important rather than explaining obvious details. === boost rules === -## Laravel Boost +# Laravel Boost + - Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. -## Artisan -- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. +## Artisan Commands + +- Run Artisan commands directly via the command line (e.g., `php artisan route:list`, `php artisan tinker --execute "..."`). +- Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. ## URLs -- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. -## Tinker / Debugging -- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port. + +## Debugging + - Use the `database-query` tool when you only need to read from the database. +- Use the `database-schema` tool to inspect table structure before writing migrations or models. +- To execute PHP code for debugging, run `php artisan tinker --execute "your code here"` directly. +- To read configuration values, read the config files directly or run `php artisan config:show [key]`. +- To inspect routes, run `php artisan route:list` directly. +- To check environment variables, read the `.env` file directly. ## Reading Browser Logs With the `browser-logs` Tool + - You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. - Only recent browser logs will be useful - ignore old logs. ## Searching Documentation (Critically Important) -- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. -- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. -- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. + +- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. - Search the documentation before making code changes to ensure we are taking the correct approach. -- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. -- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. +- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first. +- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. ### Available Search Syntax -- You can and should pass multiple queries at once. The most relevant results will be returned first. - -1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' -2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" -3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order -4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" -5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'. +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit". +3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order. +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit". +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms. === php rules === -## PHP +# PHP -- Always use curly braces for control structures, even if it has one line. +- Always use curly braces for control structures, even for single-line bodies. + +## Constructors -### Constructors - Use PHP 8 constructor property promotion in `__construct()`. - - public function __construct(public GitHub $github) { } -- Do not allow empty `__construct()` methods with zero parameters. + - `public function __construct(public GitHub $github) { }` +- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private. + +## Type Declarations -### Type Declarations - Always use explicit return type declarations for methods and functions. - Use appropriate PHP type hints for method parameters. - + +```php protected function isAccessible(User $user, ?string $path = null): bool { ... } - +``` + +## Enums + +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. ## Comments -- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. -## PHPDoc Blocks -- Add useful array shape type definitions for arrays when appropriate. +- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex. -## Enums -- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. +## PHPDoc Blocks +- Add useful array shape type definitions when appropriate. === herd rules === -## Laravel Herd +# Laravel Herd -- The application is served by Laravel Herd and will be available at: https?://[kebab-case-project-dir].test. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs. -- You must not run any commands to make the site available via HTTP(s). It is _always_ available through Laravel Herd. +- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs for the user. +- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd. +=== 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. === laravel/core rules === -## Do Things the Laravel Way +# Do Things the Laravel Way -- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. -- If you're creating a generic PHP class, use `artisan make:class`. +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`. +- If you're creating a generic PHP class, use `php artisan make:class`. - Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. -### Database +## Database + - Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. -- Use Eloquent models and relationships before suggesting raw database queries +- Use Eloquent models and relationships before suggesting raw database queries. - Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. - Generate code that prevents N+1 query problems by using eager loading. - Use Laravel's query builder for very complex database operations. ### Model Creation -- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. + +- 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. -### Controllers & Validation +## Controllers & Validation + - Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. - Check sibling Form Requests to see if the application uses array or string based validation rules. -### Queues -- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. +## Authentication & Authorization -### Authentication & Authorization - Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). -### URL Generation +## URL Generation + - When generating links to other pages, prefer named routes and the `route()` function. -### Configuration +## Queues + +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +## Configuration + - Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. -### Testing +## Testing + - When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. - Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. -- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. +- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. -### Vite Error -- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. +## Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. === laravel/v12 rules === -## Laravel 12 +# Laravel 12 -- Use the `search-docs` tool to get version specific documentation. +- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples. - Since Laravel 11, Laravel has a new streamlined file structure which this project uses. -### Laravel 12 Structure -- No middleware files in `app/Http/Middleware/`. +## Laravel 12 Structure + +- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`. +- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`. - `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. - `bootstrap/providers.php` contains application specific service providers. -- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. -- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. +- The `app/Console/Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. +- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration. + +## Database -### Database - When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. -- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. +- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. ### Models -- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. - - -=== fluxui-free/core rules === - -## Flux UI Free - -- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components. -- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize. -- You should use Flux UI components when available. -- Fallback to standard Blade components if Flux is unavailable. -- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project. -- Flux UI components look like this: - - - - - - -### Available Components -This is correct as of Boost installation, but there may be additional components within the codebase. - - -avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip - +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. === livewire/core rules === -## Livewire Core -- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. -- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components -- State should live on the server, with the UI reflecting it. -- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. - -## Livewire Best Practices -- Livewire components require a single root element. -- Use `wire:loading` and `wire:dirty` for delightful loading states. -- Add `wire:key` in loops: - - ```blade - @foreach ($items as $item) -
- {{ $item->name }} -
- @endforeach - ``` - -- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects: - - - public function mount(User $user) { $this->user = $user; } - public function updatedSearch() { $this->resetPage(); } - - - -## Testing Livewire - - - Livewire::test(Counter::class) - ->assertSet('count', 0) - ->call('increment') - ->assertSet('count', 1) - ->assertSee(1) - ->assertStatus(200); - - - - - $this->get('/posts/create') - ->assertSeeLivewire(CreatePost::class); - +# Livewire +- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript. +- You can use Alpine.js for client-side interactions instead of JavaScript frameworks. +- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests. === pint/core rules === -## Laravel Pint Code Formatter - -- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. -- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. +# Laravel Pint Code Formatter +- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues. === pest/core rules === ## Pest -### Testing -- If you need to verify a feature is working, write or update a Unit / Feature test. - -### Pest Tests -- All tests must be written using Pest. Use `php artisan make:test --pest `. -- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. -- Tests should test all of the happy paths, failure paths, and weird paths. -- Tests live in the `tests/Feature` and `tests/Unit` directories. -- Pest tests look and behave like this: - -it('is true', function () { - expect(true)->toBeTrue(); -}); - - -### Running Tests -- Run the minimal number of tests using an appropriate filter before finalizing code edits. -- To run all tests: `php artisan test`. -- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. -- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). -- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. - -### Pest Assertions -- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: - -it('returns all', function () { - $response = $this->postJson('/api/docs', []); - - $response->assertSuccessful(); -}); - - -### Mocking -- Mocking can be very helpful when appropriate. -- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. -- You can also create partial mocks using the same import or self method. - -### Datasets -- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. - - -it('has emails', function (string $email) { - expect($email)->not->toBeEmpty(); -})->with([ - 'james' => 'james@laravel.com', - 'taylor' => 'taylor@laravel.com', -]); - - - -=== pest/v4 rules === - -## Pest 4 - -- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage. -- Browser testing is incredibly powerful and useful for this project. -- Browser tests should live in `tests/Browser/`. -- Use the `search-docs` tool for detailed guidance on utilizing these features. - -### Browser Testing -- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test. -- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test. -- If requested, test on multiple browsers (Chrome, Firefox, Safari). -- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints). -- Switch color schemes (light/dark mode) when appropriate. -- Take screenshots or pause tests for debugging when appropriate. - -### Example Tests +- 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. - -it('may reset the password', function () { - Notification::fake(); +=== laravel/fortify rules === - $this->actingAs(User::factory()->create()); +# Laravel Fortify - $page = visit('/sign-in'); // Visit on a real browser... +- 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. - $page->assertSee('Sign In') - ->assertNoJavascriptErrors() // or ->assertNoConsoleLogs() - ->click('Forgot Password?') - ->fill('email', 'nuno@laravel.com') - ->click('Send Reset Link') - ->assertSee('We have emailed your password reset link!') - - Notification::assertSent(ResetPassword::class); -}); - - - - - -$pages = visit(['/', '/about', '/contact']); - -$pages->assertNoJavascriptErrors()->assertNoConsoleLogs(); - - - -=== tailwindcss/core rules === - -## Tailwind Core - -- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. -- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) -- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically -- You can use the `search-docs` tool to get exact examples from the official documentation when needed. - -### Spacing -- When listing items, use gap utilities for spacing, don't use margins. - - -
-
Superior
-
Michigan
-
Erie
-
-
- - -### Dark Mode -- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. - - -=== tailwindcss/v4 rules === - -## Tailwind 4 - -- Always use Tailwind CSS v4 - do not use the deprecated utilities. -- `corePlugins` is not supported in Tailwind v4. -- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: - - - - -### Replaced Utilities -- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. -- Opacity values are still numeric. - -| Deprecated | Replacement | -|------------+--------------| -| bg-opacity-* | bg-black/* | -| text-opacity-* | text-black/* | -| border-opacity-* | border-black/* | -| divide-opacity-* | divide-black/* | -| ring-opacity-* | ring-black/* | -| placeholder-opacity-* | placeholder-black/* | -| flex-shrink-* | shrink-* | -| flex-grow-* | grow-* | -| overflow-ellipsis | text-ellipsis | -| decoration-slice | box-decoration-slice | -| decoration-clone | box-decoration-clone | - - -=== tests rules === - -## Test Enforcement - -- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. -- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. diff --git a/README.md b/README.md new file mode 100644 index 00000000..2fef56ee --- /dev/null +++ b/README.md @@ -0,0 +1,246 @@ +# Mission + +Implement a complete shop system based on the specifications in `specs/*`. Do not stop until all phases are complete, all requirements are implemented, all acceptance criteria are met, and all 143 test cases pass verification. + +Before starting, read the team mode documentation: https://code.claude.com/docs/en/agent-teams +You must use team mode (not sub-agents). + +--- + +# Phases & Sequencing + +- Phases are defined in `specs/*`. Read all spec files before creating the project plan. +- Phases are developed strictly sequentially. No parallel development. +- Only one phase is active at a time. +- For each phase, spawn a fresh team (details below). Do not reuse teammates across phases. +- After all phases: run Final E2E QA, then Adversarial QA, then fix any issues, then re-run E2E QA until clean. + +--- + +# Team Composition + +For each phase, the team lead spawns the following roles. All teammates except the Controller are replaced per phase to keep context fresh. + +| Role | Scope | Persists across phases? | +|---|---|---| +| **Team Lead** | Management, supervision, delegation. Does no coding, reviewing, or verification. | Yes | +| **Controller** | Artifact auditor with veto power. Does not code, run tools, or use Playwright. Works exclusively from markdown artifacts and team lead reports. Enforces rigor by catching incomplete, ambiguous, or sloppy reporting. | Yes | +| **BDD Expert** | Writes Gherkin specs from the phase requirements. | No (fresh per phase) | +| **Gherkin Reviewer** | Confirms Gherkin specs are 100% complete vs. the phase spec, and consistent with preceding/succeeding phases. | No (fresh per phase) | +| **Developers** (1+, specialized as needed: backend, frontend, schema, etc.) | Implementation using TDD with Pest. Follow the prepared Gherkin specs. No Pest tests required for pure UI work. | No (fresh per phase) | +| **Code Reviewer** | Reviews code for clean code, SOLID, Laravel best practices. Runs static analysis (phpmetrics or similar) for duplication, vulnerabilities, complexity. Produces a quality report as markdown. | No (fresh per phase) | +| **QA Analyst** | Verifies functionality non-scripted using Playwright and Chrome. Tests all routes AND rendered pages (not just route existence). Tracks all checks in a markdown file. | No (fresh per phase) | + +### Available tooling + +All teammates must use the available MCP servers: +- **Laravel Boost** (MCP server, already configured) +- **PHP LSP** (MCP server, already configured) + +The Code Reviewer additionally uses phpmetrics or equivalent for static analysis. + +--- + +# Artifact Requirements + +Every teammate must document their work in markdown files. These artifacts are the Controller's sole input. If it's not written down, it didn't happen. + +### General rules for all artifacts +- Every artifact must include: **what** was done, **how** it was done, **why** decisions were made, and an **honest self-assessment** of the work (including uncertainties, risks, or areas of concern). +- No artifact may contain only checklists or status tables. There must be prose that explains the reasoning. +- Ambiguous statuses (UNKNOWN, SKIPPED, PARTIAL, N/A, blank) are not permitted. Every item is PASS or FAIL. If something could not be verified, that is a FAIL with an explanation. + +### Per-role artifact specifications + +**BDD Expert** writes `work/phase-{N}/gherkin-specs.md`: +- List of every acceptance criterion from the phase spec, each mapped to one or more Gherkin scenarios. +- Traceability table: spec requirement ID/description -> Gherkin scenario name. +- Self-assessment: Are there any requirements that were ambiguous or hard to translate? How were they interpreted and why? + +**Gherkin Reviewer** writes `work/phase-{N}/gherkin-review.md`: +- Confirmation that every spec requirement has a corresponding Gherkin scenario (with the count). +- Check for consistency with preceding and succeeding phases. +- List of any concerns, ambiguities, or gaps found (even if resolved). +- Self-assessment: Confidence level in completeness. What could be missing? Were any edge cases intentionally excluded and why? + +**Developers** write `work/phase-{N}/dev-report.md`: +- What was implemented and how (architecture decisions, patterns used). +- List of Pest test cases created, mapped to Gherkin scenarios. +- Any deviations from the Gherkin specs and why. +- Known limitations or technical debt introduced. +- Self-assessment: What are the weakest parts of this implementation? What would break first under load or edge cases? + +**Code Reviewer** writes `work/phase-{N}/code-review.md`: +- Quality metrics defined upfront (what was measured and what thresholds were set). +- Item-level checklist with PASS/FAIL per item. +- Static analysis results (phpmetrics or equivalent) with key numbers. +- For any finding that was fixed: what the issue was, how it was fixed, and confirmation it was re-checked. +- Self-assessment: Overall code quality rating with justification. What are the remaining risks? Is there anything that passed the checklist but still feels fragile? + +**QA Analyst** writes `work/phase-{N}/qa-report.md`: +- Every Gherkin scenario mapped to a verification entry. +- For each entry: what was tested, how it was tested (specific Playwright actions), what the expected result was, what the actual result was, and PASS/FAIL. +- For any FAIL that was fixed and re-tested: the original failure, what was fixed, and the re-test result. +- **Asset verification section**: Every page visited must be checked for broken images, missing assets, and broken links. Document each page URL, the number of images/assets found, how many loaded successfully, and PASS/FAIL. If a product image is referenced but doesn't render, that is a FAIL. +- **URL verification section**: Every internal link and navigation element across all pages must be clicked and verified. Document each URL, expected destination, actual result (page loaded / 404 / error / redirect), and PASS/FAIL. +- Regression check: confirmation that previous phase functionality still works. +- Self-assessment: Are there areas that felt undertested? Anything that passed but seemed brittle? Any concerns about behavior that technically meets the spec but feels wrong? + +**UAT Analyst** writes `work/final-e2e-qa.md`: +- All 143 test cases with the same detail level as QA Analyst entries above (what, how, expected, actual, PASS/FAIL). +- Self-assessment section at the end. + +**Adversarial QA Analyst** writes `work/adversarial-qa.md`: +- Edge cases tested, organized by the baseline test case they extend. +- For each: what was tried, what the expected behavior was, what actually happened, and PASS/FAIL. +- Log file inspection results (any exceptions or errors found). +- Self-assessment: How hard did you try to break it? What attack vectors were explored? What remains untested? + +--- + +# Per-Phase Workflow + +Every phase follows this exact sequence: + +### 1. BDD Specification +- The **BDD Expert** rewrites the phase requirements into Gherkin specs. +- The **Gherkin Reviewer** confirms the specs are complete, correct, and consistent with adjacent phases. +- The **Controller** approves the Gherkin specs before any coding begins. + +### 2. Implementation (TDD) +- The **Team Lead** decides which specialized developer teammates to spawn. +- Developers implement using TDD with Pest, driven by the approved Gherkin specs. +- Pure UI work is exempt from Pest tests but must still follow the specs. + +### 3. Code Review +- The **Code Reviewer** reviews all code from the phase. +- Must create a checklist, define quality metrics, and produce results as a markdown report. +- Uses Laravel Boost, PHP LSP, and phpmetrics (or equivalent). +- Code must follow clean code, SOLID, and Laravel best practices. +- No code duplication, no vulnerabilities, no syntax errors. + +### 4. QA Verification +- The **QA Analyst** verifies all functionality non-scripted using Playwright and Chrome. +- All links, routes, and rendered pages must work. Route existence alone is insufficient. +- All checks are tracked in a phase-specific markdown file. +- Every check must PASS. No gaps, no compromises, no skipped cases. +- Every page must be visually checked, to ensure there are no UI issues. +- If bugs are found: developers fix them, then the QA Analyst re-verifies. + +### 5. Controller Sign-Off + +The **Controller** does not look at code, does not run Playwright, and does not use any tooling directly. The Controller works exclusively from the markdown artifacts produced by other teammates and from statements by the Team Lead. The Controller's job is to catch laziness, sloppiness, or incompleteness in other agents' work by auditing the quality and honesty of their documentation. + +The Controller reviews the following artifacts and applies these acceptance criteria. Every criterion must be MET. If any criterion is NOT MET, the Controller issues a veto and the relevant step is re-executed. + +**A. Gherkin Specs** (`work/phase-{N}/gherkin-specs.md` and `work/phase-{N}/gherkin-review.md`) +- [ ] Both files exist. +- [ ] The traceability table maps every spec requirement to a Gherkin scenario. None are missing. +- [ ] The Gherkin Reviewer's report provides a written confirmation with the exact count of requirements vs. scenarios. +- [ ] Both artifacts contain self-assessments that are substantive (not just "everything looks good"). If the self-assessment raises concerns, those concerns must be addressed or acknowledged. + +**B. Dev Report** (`work/phase-{N}/dev-report.md`) +- [ ] The report exists and explains what was built and how. +- [ ] Pest test cases are listed and mapped to Gherkin scenarios. +- [ ] Any deviations from spec are documented with reasoning. +- [ ] The self-assessment section exists and is honest (identifies weaknesses, not just confirms success). + +**C. Code Review Report** (`work/phase-{N}/code-review.md`) +- [ ] The report exists with quality metrics, thresholds, and item-level PASS/FAIL. +- [ ] Every item is PASS. No UNKNOWN, SKIPPED, PARTIAL, or blank. +- [ ] Static analysis results are included with actual numbers (not just "passed"). +- [ ] The self-assessment provides an overall quality rating with justification and identifies remaining risks. +- [ ] If any finding was initially FAIL: the fix and re-check are documented. + +**D. QA Report** (`work/phase-{N}/qa-report.md`) +- [ ] The report exists with an entry for every Gherkin scenario. +- [ ] Every entry describes what was tested, how (Playwright actions), expected vs. actual result, and PASS/FAIL. +- [ ] Every entry is PASS. No UNKNOWN, SKIPPED, PARTIAL, N/A, or blank. +- [ ] If any entry was initially FAIL: the failure, fix, and re-test are all documented. +- [ ] The report contains an **asset verification section** listing every page, the number of images/assets found, how many loaded, and PASS/FAIL per page. No missing or broken images allowed. +- [ ] The report contains a **URL verification section** listing every internal link and navigation element, the expected destination, the actual result, and PASS/FAIL. No 404s, broken links, or dead-end navigation allowed. +- [ ] Regression check for previous phases is documented. +- [ ] The self-assessment is substantive and flags any concerns, even if everything passed. + +**E. Completeness & Consistency** +- [ ] The number of Gherkin scenarios matches or exceeds the number of spec requirements. +- [ ] The number of QA entries matches or exceeds the number of Gherkin scenarios. +- [ ] The number of Pest tests matches or exceeds the number of Gherkin scenarios (excluding pure UI). +- [ ] No requirement is unaccounted for across the chain: spec -> Gherkin -> dev -> code review -> QA. +- [ ] Self-assessments across all artifacts are consistent (no contradictions, e.g. dev says "this is fragile" but QA says "no concerns"). + +**F. Artifact Quality** +- [ ] No artifact is a bare checklist without explanatory prose. +- [ ] Every artifact contains a self-assessment section with honest evaluation. +- [ ] If any self-assessment raises a risk or concern, it has been either resolved (with evidence) or explicitly accepted with justification by the Team Lead. + +If all criteria are MET, the Controller produces `work/signoff-phase-{N}.md` containing the filled checklist above with status per item, plus a brief narrative assessment of the phase. Development of the next phase may only begin after this sign-off. + +### 6. Progress Tracking +- The **Team Lead** updates `work/progress.md` and makes a git commit with a meaningful message after each phase. + +--- + +# Final E2E QA + +After all phases are complete: + +1. The Team Lead spawns a **fresh UAT Analyst** (not reused from any phase). +2. The UAT Analyst executes all 143 test cases from `specs/08-PLAYWRIGHT-E2E-PLAN.md` using Playwright and Chrome. +3. Verification is non-scripted (agent-driven, not pre-written test scripts). +4. Every test case must be executed. No skipping. +5. All results are tracked in `work/final-e2e-qa.md` with pass/fail per test case. +6. Every page must be screenshotted, visually checked and confirmed in `work/final-e2e-qa.md` +7. All links (Storefront + Admin) must be checked, so there are no broken links +8. If any test case fails: developers fix the issue, then the UAT Analyst re-verifies. +9. The **Controller** reviews `work/final-e2e-qa.md` and applies these acceptance criteria: + - [ ] The file contains exactly 143 test case entries (matching `specs/08-PLAYWRIGHT-E2E-PLAN.md`). + - [ ] Every entry describes what was tested, how, expected vs. actual, and has an explicit PASS status. + - [ ] No FAIL, UNKNOWN, SKIPPED, PARTIAL, N/A, or blank entries. + - [ ] If any test was initially FAIL: the failure, fix, and re-test with PASS are all documented. + - [ ] No test case IDs from the E2E plan are missing. + - [ ] The file contains a self-assessment section at the end. + - [ ] Cross-reference: 143 unique IDs, 143 PASS results. + - [ ] Every page is visually checked and confirmed + +--- + +# Adversarial QA + +After the Final E2E QA passes: + +1. The Team Lead spawns one **Adversarial QA Analyst**. +2. This analyst takes the existing 143 test cases as a baseline and tests edge cases around them. +3. The goal is to break the system: malformed input, broken URLs, missing routes, unexpected navigation, boundary conditions. +4. The analyst also inspects log files for unhandled exceptions or errors. +5. All findings are tracked in `work/adversarial-qa.md`. +6. If issues are found: developers fix them, then a fresh Final E2E QA round is run (all 143 test cases again). +7. This cycle repeats until the system is clean. + +--- + +# Final Sign-Off + +The **Controller** produces `work/final-signoff.md` only after verifying every item below. Each item must reference the specific artifact file that proves it. + +- [ ] All phase sign-off files exist (`work/signoff-phase-{N}.md` for every phase) and every checklist item within them is MET. +- [ ] All per-phase artifacts exist in `work/phase-{N}/` (gherkin-specs, gherkin-review, dev-report, code-review, qa-report) for every phase. +- [ ] `work/final-e2e-qa.md` exists, contains 143 test cases all with PASS status, and includes a self-assessment. +- [ ] `work/adversarial-qa.md` exists, all findings are resolved (no open issues), and includes a self-assessment. +- [ ] If a fix cycle occurred after adversarial QA: a subsequent E2E QA round was executed and all 143 test cases passed again. +- [ ] `work/progress.md` is up to date and reflects all phases as complete. +- [ ] No markdown artifact anywhere in the project contains UNKNOWN, SKIPPED, PARTIAL, N/A, or blank statuses. +- [ ] Every artifact contains a substantive self-assessment (not just "all good"). +- [ ] No self-assessment raises an unresolved concern. + +Only after this sign-off is the project complete. + +--- + +# Team Lead Responsibilities + +- Read all specs before creating the project plan. Have the Controller approve the plan. +- Keep `work/progress.md` current. Git commit with a meaningful message after every relevant iteration. Ensure a final commit at the end. +- Spawn and manage teammates per the rules above. Stay focused on management and supervision. +- **Do not** code, review, verify, or research directly. Delegate everything. +- Consult the Controller after every phase and before closing the project. diff --git a/app/Auth/CustomerUserProvider.php b/app/Auth/CustomerUserProvider.php new file mode 100644 index 00000000..c7b4631e --- /dev/null +++ b/app/Auth/CustomerUserProvider.php @@ -0,0 +1,21 @@ +bound('current_store') ? app('current_store') : null; + + if ($store instanceof Store) { + $credentials['store_id'] = $store->id; + } + + return parent::retrieveByCredentials($credentials); + } +} diff --git a/app/Contracts/PaymentProvider.php b/app/Contracts/PaymentProvider.php new file mode 100644 index 00000000..bd13f79d --- /dev/null +++ b/app/Contracts/PaymentProvider.php @@ -0,0 +1,18 @@ + $paymentMethodData + */ + public function charge(Checkout $checkout, array $paymentMethodData): PaymentResult; + + public function refund(Payment $payment, int $amount): RefundResult; +} diff --git a/app/Enums/AppInstallationStatus.php b/app/Enums/AppInstallationStatus.php new file mode 100644 index 00000000..d4a43b46 --- /dev/null +++ b/app/Enums/AppInstallationStatus.php @@ -0,0 +1,10 @@ +user(); + $store = app('current_store'); + + if (! $user || ! $store instanceof Store) { + abort(403); + } + + $userRole = $user->roleForStore($store); + + if (! $userRole) { + abort(403); + } + + if (! empty($roles)) { + $allowedRoles = array_map( + fn (string $role) => StoreUserRole::from($role), + $roles + ); + + if (! in_array($userRole, $allowedRoles)) { + abort(403); + } + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/CustomerAuthenticate.php b/app/Http/Middleware/CustomerAuthenticate.php new file mode 100644 index 00000000..503d7dec --- /dev/null +++ b/app/Http/Middleware/CustomerAuthenticate.php @@ -0,0 +1,22 @@ +check()) { + $request->session()->put('url.intended', $request->url()); + + return redirect('/account/login'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/ResolveStore.php b/app/Http/Middleware/ResolveStore.php new file mode 100644 index 00000000..29e53f1d --- /dev/null +++ b/app/Http/Middleware/ResolveStore.php @@ -0,0 +1,75 @@ +resolveFromSession($request, $next); + } + + return $this->resolveFromHostname($request, $next); + } + + protected function resolveFromHostname(Request $request, Closure $next): Response + { + $store = app('current_store'); + + if (! $store) { + abort(404); + } + + if ($store->status->value === 'suspended') { + abort(503, 'This store is currently unavailable'); + } + + return $next($request); + } + + protected function resolveFromSession(Request $request, Closure $next): Response + { + $user = $request->user(); + + if (! $user) { + abort(403); + } + + $storeId = $request->session()->get('current_store_id'); + + if ($storeId) { + $store = Store::find($storeId); + + if ($store && $user->stores()->where('stores.id', $store->id)->exists()) { + $this->bindStore($store); + + return $next($request); + } + } + + // Fallback: pick the user's first store + $store = $user->stores()->first(); + + if (! $store) { + abort(403); + } + + $request->session()->put('current_store_id', $store->id); + $this->bindStore($store); + + return $next($request); + } + + protected function bindStore(Store $store): void + { + app()->instance('current_store', $store); + View::share('currentStore', $store); + } +} diff --git a/app/Jobs/AggregateAnalytics.php b/app/Jobs/AggregateAnalytics.php new file mode 100644 index 00000000..8460fca3 --- /dev/null +++ b/app/Jobs/AggregateAnalytics.php @@ -0,0 +1,86 @@ +date ?? now()->subDay()->format('Y-m-d'); + + $startOfDay = $date.'T00:00:00'; + $endOfDay = $date.'T23:59:59'; + + $storeIds = AnalyticsEvent::withoutGlobalScopes() + ->where('created_at', '>=', $startOfDay) + ->where('created_at', '<=', $endOfDay) + ->distinct() + ->pluck('store_id'); + + foreach ($storeIds as $storeId) { + $this->aggregateForStore($storeId, $date, $startOfDay, $endOfDay); + } + } + + protected function aggregateForStore(int $storeId, string $date, string $startOfDay, string $endOfDay): void + { + $baseQuery = AnalyticsEvent::withoutGlobalScopes() + ->where('store_id', $storeId) + ->where('created_at', '>=', $startOfDay) + ->where('created_at', '<=', $endOfDay); + + $visitsCount = (clone $baseQuery) + ->where('type', 'page_view') + ->distinct('session_id') + ->count('session_id'); + + $addToCartCount = (clone $baseQuery) + ->where('type', 'add_to_cart') + ->count(); + + $checkoutStartedCount = (clone $baseQuery) + ->where('type', 'checkout_started') + ->count(); + + $checkoutCompletedEvents = (clone $baseQuery) + ->where('type', 'checkout_completed') + ->get(); + + $ordersCount = $checkoutCompletedEvents->count(); + + $revenueAmount = $checkoutCompletedEvents->sum(function ($event) { + $properties = is_array($event->properties_json) + ? $event->properties_json + : json_decode($event->properties_json, true); + + return $properties['order_total'] ?? 0; + }); + + $aovAmount = $ordersCount > 0 ? (int) round($revenueAmount / $ordersCount) : 0; + + AnalyticsDaily::withoutGlobalScopes()->upsert( + [ + 'store_id' => $storeId, + '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' => $ordersCount, + ], + ['store_id', 'date'], + ['orders_count', 'revenue_amount', 'aov_amount', 'visits_count', 'add_to_cart_count', 'checkout_started_count', 'checkout_completed_count'], + ); + } +} diff --git a/app/Jobs/CancelUnpaidBankTransferOrders.php b/app/Jobs/CancelUnpaidBankTransferOrders.php new file mode 100644 index 00000000..6574a230 --- /dev/null +++ b/app/Jobs/CancelUnpaidBankTransferOrders.php @@ -0,0 +1,75 @@ +where('payment_method', PaymentMethod::BankTransfer->value) + ->where('financial_status', FinancialStatus::Pending->value) + ->where('status', OrderStatus::Pending->value) + ->get(); + + foreach ($orders as $order) { + $cancelDays = $this->getCancelDays($order); + $placedAt = $order->placed_at ? \Carbon\Carbon::parse($order->placed_at) : $order->created_at; + + if (! $placedAt || $placedAt->greaterThan(now()->subDays($cancelDays))) { + continue; + } + + DB::transaction(function () use ($order, $inventoryService) { + // Release reserved inventory + $order->load('lines.variant.inventoryItem'); + foreach ($order->lines as $line) { + if ($line->variant?->inventoryItem) { + $inventoryService->release($line->variant->inventoryItem, $line->quantity); + } + } + + $order->update([ + 'financial_status' => FinancialStatus::Voided, + 'status' => OrderStatus::Cancelled, + ]); + + $order->payments() + ->where('status', PaymentStatus::Pending->value) + ->update(['status' => PaymentStatus::Failed->value]); + + OrderCancelled::dispatch($order); + }); + } + } + + private function getCancelDays(Order $order): int + { + $store = $order->store; + + if ($store) { + $settings = $store->settings; + if ($settings) { + $settingsJson = $settings->settings_json ?? []; + if (isset($settingsJson['bank_transfer_cancel_days'])) { + return (int) $settingsJson['bank_transfer_cancel_days']; + } + } + } + + return 7; + } +} diff --git a/app/Jobs/CleanupAbandonedCarts.php b/app/Jobs/CleanupAbandonedCarts.php new file mode 100644 index 00000000..87c52897 --- /dev/null +++ b/app/Jobs/CleanupAbandonedCarts.php @@ -0,0 +1,21 @@ +where('status', CartStatus::Active->value) + ->where('updated_at', '<', now()->subDays(14)->toIso8601String()) + ->update(['status' => CartStatus::Abandoned->value]); + } +} diff --git a/app/Jobs/DeliverWebhook.php b/app/Jobs/DeliverWebhook.php new file mode 100644 index 00000000..11b5dd93 --- /dev/null +++ b/app/Jobs/DeliverWebhook.php @@ -0,0 +1,150 @@ + + */ + public function backoff(): array + { + return [60, 300, 1800, 7200, 43200]; + } + + public function __construct( + public WebhookDelivery $delivery, + public string $eventType, + public array $payload, + ) {} + + public function handle(WebhookService $webhookService): void + { + $subscription = WebhookSubscription::withoutGlobalScopes() + ->find($this->delivery->subscription_id); + + if (! $subscription || $subscription->status !== WebhookSubscriptionStatus::Active) { + $this->delivery->update([ + 'status' => WebhookDeliveryStatus::Failed, + 'last_attempt_at' => now()->toIso8601String(), + ]); + + return; + } + + $payloadJson = json_encode($this->payload); + $secret = $subscription->signing_secret_encrypted; + $signature = $webhookService->sign($payloadJson, $secret); + $deliveryId = Str::uuid()->toString(); + $timestamp = (string) time(); + + $this->delivery->update([ + 'attempt_count' => $this->delivery->attempt_count + 1, + 'last_attempt_at' => now()->toIso8601String(), + ]); + + try { + $response = Http::timeout(30) + ->withHeaders([ + 'X-Platform-Signature' => $signature, + 'X-Platform-Event' => $this->eventType, + 'X-Platform-Delivery-Id' => $deliveryId, + 'X-Platform-Timestamp' => $timestamp, + 'Content-Type' => 'application/json', + ]) + ->withBody($payloadJson, 'application/json') + ->post($subscription->target_url); + + $this->delivery->update([ + 'response_code' => $response->status(), + 'response_body_snippet' => Str::limit($response->body(), 500), + ]); + + if ($response->successful()) { + $this->delivery->update(['status' => WebhookDeliveryStatus::Success]); + $this->resetConsecutiveFailures($subscription); + + return; + } + + throw new \RuntimeException("Webhook delivery failed with status {$response->status()}"); + } catch (\Exception $e) { + $this->incrementConsecutiveFailures($subscription); + + if ($this->delivery->attempt_count >= $this->tries) { + $this->delivery->update(['status' => WebhookDeliveryStatus::Failed]); + + return; + } + + throw $e; + } + } + + public function failed(?\Throwable $exception): void + { + $this->delivery->update([ + 'status' => WebhookDeliveryStatus::Failed, + 'last_attempt_at' => now()->toIso8601String(), + ]); + + $subscription = WebhookSubscription::withoutGlobalScopes() + ->find($this->delivery->subscription_id); + + if ($subscription) { + $this->incrementConsecutiveFailures($subscription); + } + } + + protected function resetConsecutiveFailures(WebhookSubscription $subscription): void + { + $consecutiveFailures = $this->countConsecutiveFailures($subscription); + if ($consecutiveFailures > 0) { + // The counter resets naturally since we just had a success + } + } + + protected function incrementConsecutiveFailures(WebhookSubscription $subscription): void + { + $consecutiveFailures = $this->countConsecutiveFailures($subscription); + + if ($consecutiveFailures >= 5) { + $subscription->update(['status' => WebhookSubscriptionStatus::Paused]); + Log::warning("Webhook subscription {$subscription->id} paused after {$consecutiveFailures} consecutive failures."); + } + } + + protected function countConsecutiveFailures(WebhookSubscription $subscription): int + { + $recentDeliveries = WebhookDelivery::where('subscription_id', $subscription->id) + ->orderByDesc('id') + ->limit(5) + ->pluck('status'); + + $consecutiveFailures = 0; + foreach ($recentDeliveries as $status) { + if ($status === WebhookDeliveryStatus::Failed) { + $consecutiveFailures++; + } else { + break; + } + } + + return $consecutiveFailures; + } +} diff --git a/app/Jobs/ExpireAbandonedCheckouts.php b/app/Jobs/ExpireAbandonedCheckouts.php new file mode 100644 index 00000000..0a343a9a --- /dev/null +++ b/app/Jobs/ExpireAbandonedCheckouts.php @@ -0,0 +1,29 @@ +whereNotIn('status', [ + CheckoutStatus::Completed->value, + CheckoutStatus::Expired->value, + ]) + ->where('updated_at', '<', now()->subHours(24)->toIso8601String()) + ->get(); + + foreach ($checkouts as $checkout) { + $checkoutService->expireCheckout($checkout); + } + } +} diff --git a/app/Jobs/ProcessMediaUpload.php b/app/Jobs/ProcessMediaUpload.php new file mode 100644 index 00000000..c18a41e5 --- /dev/null +++ b/app/Jobs/ProcessMediaUpload.php @@ -0,0 +1,74 @@ + [150, 150], + 'medium' => [600, 600], + 'large' => [1200, 1200], + ]; + + public function __construct( + public readonly ProductMedia $media, + ) {} + + public function handle(): void + { + $disk = Storage::disk('public'); + + try { + $originalPath = $this->media->storage_key; + + if (! $disk->exists($originalPath)) { + $this->media->update(['status' => MediaStatus::Failed]); + + return; + } + + $manager = new ImageManager(new Driver); + $image = $manager->read($disk->get($originalPath)); + + $this->media->update([ + 'width' => $image->width(), + 'height' => $image->height(), + 'byte_size' => $disk->size($originalPath), + 'mime_type' => $disk->mimeType($originalPath), + ]); + + $pathInfo = pathinfo($originalPath); + $baseName = $pathInfo['filename']; + $extension = $pathInfo['extension'] ?? 'jpg'; + $directory = $pathInfo['dirname']; + + foreach (self::SIZES as $sizeName => [$width, $height]) { + $resized = $manager->read($disk->get($originalPath)); + $resized->cover($width, $height); + + $sizedPath = $directory.'/'.$baseName.'_'.$sizeName.'.'.$extension; + $disk->put($sizedPath, $resized->toJpeg()); + } + + $this->media->update(['status' => MediaStatus::Ready]); + } catch (\Throwable $e) { + Log::error('Media processing failed', [ + 'media_id' => $this->media->id, + 'error' => $e->getMessage(), + ]); + + $this->media->update(['status' => MediaStatus::Failed]); + } + } +} diff --git a/app/Livewire/Admin/Analytics/Index.php b/app/Livewire/Admin/Analytics/Index.php new file mode 100644 index 00000000..393315cc --- /dev/null +++ b/app/Livewire/Admin/Analytics/Index.php @@ -0,0 +1,112 @@ +dateRange !== 'custom') { + $this->customStartDate = null; + $this->customEndDate = null; + } + } + + #[Computed] + public function kpis(): array + { + [$start, $end] = $this->getDateRange(); + + $metrics = AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $this->getStoreId()) + ->where('date', '>=', $start) + ->where('date', '<=', $end) + ->selectRaw('COALESCE(SUM(revenue_amount), 0) as total_sales') + ->selectRaw('COALESCE(SUM(orders_count), 0) as orders_count') + ->selectRaw('COALESCE(SUM(visits_count), 0) as visits_count') + ->selectRaw('COALESCE(SUM(add_to_cart_count), 0) as add_to_cart_count') + ->selectRaw('COALESCE(SUM(checkout_started_count), 0) as checkout_started_count') + ->selectRaw('COALESCE(SUM(checkout_completed_count), 0) as checkout_completed_count') + ->first(); + + $ordersCount = (int) $metrics->orders_count; + $totalSales = (int) $metrics->total_sales; + $visitsCount = (int) $metrics->visits_count; + $checkoutCompletedCount = (int) $metrics->checkout_completed_count; + + $conversionRate = $visitsCount > 0 + ? round(($checkoutCompletedCount / $visitsCount) * 100, 1) + : 0.0; + + return [ + 'totalSales' => $totalSales, + 'ordersCount' => $ordersCount, + 'averageOrderValue' => $ordersCount > 0 ? (int) round($totalSales / $ordersCount) : 0, + 'conversionRate' => $conversionRate, + 'visitsCount' => $visitsCount, + ]; + } + + #[Computed] + public function salesChartData(): array + { + [$start, $end] = $this->getDateRange(); + + $dailyData = AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $this->getStoreId()) + ->where('date', '>=', $start) + ->where('date', '<=', $end) + ->orderBy('date') + ->get(['date', 'revenue_amount', 'orders_count']); + + return [ + 'labels' => $dailyData->pluck('date')->toArray(), + 'revenue' => $dailyData->pluck('revenue_amount')->toArray(), + 'orders' => $dailyData->pluck('orders_count')->toArray(), + ]; + } + + public function render(): mixed + { + return view('livewire.admin.analytics.index') + ->layout('layouts.admin', ['breadcrumbs' => [['label' => 'Analytics']]]); + } + + protected function getStoreId(): ?int + { + $store = app()->bound('current_store') ? app('current_store') : null; + + return $store?->id; + } + + /** + * @return array{string, string} + */ + protected function getDateRange(): array + { + return match ($this->dateRange) { + 'today' => [Carbon::today()->format('Y-m-d'), Carbon::today()->format('Y-m-d')], + 'last_7_days' => [Carbon::now()->subDays(6)->format('Y-m-d'), Carbon::today()->format('Y-m-d')], + 'last_30_days' => [Carbon::now()->subDays(29)->format('Y-m-d'), Carbon::today()->format('Y-m-d')], + 'custom' => [ + $this->customStartDate ?? Carbon::now()->subDays(29)->format('Y-m-d'), + $this->customEndDate ?? Carbon::today()->format('Y-m-d'), + ], + default => [Carbon::now()->subDays(29)->format('Y-m-d'), Carbon::today()->format('Y-m-d')], + }; + } +} diff --git a/app/Livewire/Admin/Apps/Index.php b/app/Livewire/Admin/Apps/Index.php new file mode 100644 index 00000000..b49d312f --- /dev/null +++ b/app/Livewire/Admin/Apps/Index.php @@ -0,0 +1,38 @@ +getStoreId(); + + if (! $storeId) { + return collect(); + } + + return AppInstallation::withoutGlobalScopes() + ->where('store_id', $storeId) + ->with('app') + ->get(); + } + + public function render(): mixed + { + return view('livewire.admin.apps.index') + ->layout('layouts.admin', ['breadcrumbs' => [['label' => 'Apps']]]); + } + + protected function getStoreId(): ?int + { + $store = app()->bound('current_store') ? app('current_store') : null; + + return $store?->id; + } +} diff --git a/app/Livewire/Admin/Auth/Login.php b/app/Livewire/Admin/Auth/Login.php new file mode 100644 index 00000000..e05d43fc --- /dev/null +++ b/app/Livewire/Admin/Auth/Login.php @@ -0,0 +1,63 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required'], + ]); + + $throttleKey = 'login|'.$this->getIpAddress(); + + if (RateLimiter::tooManyAttempts($throttleKey, 5)) { + $seconds = RateLimiter::availableIn($throttleKey); + $this->addError('email', "Too many attempts. Try again in {$seconds} seconds."); + + return; + } + + if (Auth::guard('web')->attempt( + ['email' => $this->email, 'password' => $this->password], + $this->remember + )) { + RateLimiter::clear($throttleKey); + session()->regenerate(); + + $user = Auth::guard('web')->user(); + $user->update(['last_login_at' => now()]); + + $this->redirect('/admin'); + + return; + } + + RateLimiter::hit($throttleKey, 60); + + $this->addError('email', 'Invalid credentials'); + } + + protected function getIpAddress(): string + { + return request()->ip() ?? '127.0.0.1'; + } + + public function render(): mixed + { + return view('livewire.admin.auth.login') + ->layout('layouts::guest'); + } +} diff --git a/app/Livewire/Admin/Auth/Logout.php b/app/Livewire/Admin/Auth/Logout.php new file mode 100644 index 00000000..7ad6b86a --- /dev/null +++ b/app/Livewire/Admin/Auth/Logout.php @@ -0,0 +1,24 @@ +logout(); + + session()->invalidate(); + session()->regenerateToken(); + + $this->redirect('/admin/login'); + } + + public function render(): mixed + { + return view('livewire.admin.auth.logout'); + } +} diff --git a/app/Livewire/Admin/Collections/Form.php b/app/Livewire/Admin/Collections/Form.php new file mode 100644 index 00000000..b83b4079 --- /dev/null +++ b/app/Livewire/Admin/Collections/Form.php @@ -0,0 +1,143 @@ + */ + public array $productIds = []; + + public function mount(?Collection $collection = null): void + { + if ($collection && $collection->exists) { + $this->collection = $collection; + $this->title = $collection->title; + $this->handle = $collection->handle; + $this->descriptionHtml = $collection->description_html ?? ''; + $this->status = $collection->status->value; + $this->productIds = $collection->products()->pluck('products.id')->toArray(); + } + } + + public function updatedTitle(): void + { + if (! $this->isEditing) { + $this->handle = Str::slug($this->title); + } + } + + public function addProduct(int $productId): void + { + if (! in_array($productId, $this->productIds)) { + $this->productIds[] = $productId; + } + $this->productSearch = ''; + } + + public function removeProduct(int $productId): void + { + $this->productIds = array_values(array_filter($this->productIds, fn ($id) => $id !== $productId)); + } + + public function save(): void + { + $storeId = app('current_store')->id; + + $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => [ + 'required', 'string', 'max:255', + Rule::unique('collections', 'handle') + ->where('store_id', $storeId) + ->ignore($this->collection?->id), + ], + 'descriptionHtml' => ['nullable', 'string', 'max:65535'], + 'status' => ['required', Rule::in(['active', 'archived'])], + ]); + + $data = [ + 'store_id' => $storeId, + 'title' => $this->title, + 'handle' => $this->handle, + 'description_html' => $this->descriptionHtml ?: null, + 'status' => $this->status, + 'type' => 'manual', + ]; + + if ($this->collection && $this->collection->exists) { + $this->collection->update($data); + $collection = $this->collection; + } else { + $collection = Collection::withoutGlobalScopes()->create($data); + $this->collection = $collection; + } + + $syncData = []; + foreach ($this->productIds as $pos => $pid) { + $syncData[$pid] = ['position' => $pos]; + } + $collection->products()->sync($syncData); + + $this->dispatch('toast', type: 'success', message: 'Collection saved.'); + } + + #[Computed] + public function isEditing(): bool + { + return $this->collection !== null && $this->collection->exists; + } + + #[Computed] + public function searchResults(): mixed + { + if ($this->productSearch === '') { + return collect(); + } + + return Product::query() + ->where('title', 'like', "%{$this->productSearch}%") + ->whereNotIn('id', $this->productIds) + ->limit(10) + ->get(); + } + + #[Computed] + public function assignedProducts(): mixed + { + if (empty($this->productIds)) { + return collect(); + } + + return Product::withoutGlobalScopes()->whereIn('id', $this->productIds)->get(); + } + + public function render(): mixed + { + $breadcrumbs = [ + ['label' => 'Collections', 'url' => route('admin.collections.index')], + ['label' => $this->isEditing ? $this->collection->title : 'Add collection'], + ]; + + return view('livewire.admin.collections.form') + ->layout('layouts.admin', ['breadcrumbs' => $breadcrumbs]); + } +} diff --git a/app/Livewire/Admin/Collections/Index.php b/app/Livewire/Admin/Collections/Index.php new file mode 100644 index 00000000..e76964f4 --- /dev/null +++ b/app/Livewire/Admin/Collections/Index.php @@ -0,0 +1,60 @@ +resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function deleteCollection(int $id): void + { + Collection::withoutGlobalScopes()->where('id', $id)->delete(); + $this->dispatch('toast', type: 'success', message: 'Collection deleted.'); + } + + #[Computed] + public function collections(): mixed + { + $query = Collection::query()->withCount('products'); + + if ($this->search !== '') { + $query->where('title', 'like', "%{$this->search}%"); + } + + if ($this->statusFilter !== 'all') { + $query->where('status', $this->statusFilter); + } + + return $query->latest('updated_at')->paginate(15); + } + + public function render(): mixed + { + return view('livewire.admin.collections.index') + ->layout('layouts.admin', [ + 'breadcrumbs' => [['label' => 'Collections']], + ]); + } +} diff --git a/app/Livewire/Admin/Customers/Index.php b/app/Livewire/Admin/Customers/Index.php new file mode 100644 index 00000000..bb08af50 --- /dev/null +++ b/app/Livewire/Admin/Customers/Index.php @@ -0,0 +1,47 @@ +resetPage(); + } + + #[Computed] + public function customers(): mixed + { + $query = Customer::query() + ->withCount('orders') + ->withSum('orders', 'total_amount'); + + if ($this->search !== '') { + $query->where(function ($q) { + $q->where('name', 'like', "%{$this->search}%") + ->orWhere('email', 'like', "%{$this->search}%"); + }); + } + + return $query->latest('created_at')->paginate(15); + } + + public function render(): mixed + { + return view('livewire.admin.customers.index') + ->layout('layouts.admin', ['breadcrumbs' => [['label' => 'Customers']]]); + } +} diff --git a/app/Livewire/Admin/Customers/Show.php b/app/Livewire/Admin/Customers/Show.php new file mode 100644 index 00000000..0420e128 --- /dev/null +++ b/app/Livewire/Admin/Customers/Show.php @@ -0,0 +1,148 @@ +customer = $customer->load(['orders', 'addresses']); + } + + public function openAddAddress(): void + { + $this->resetAddressForm(); + $this->editAddressId = null; + $this->modal('address-form')->show(); + } + + public function editAddress(int $addressId): void + { + $address = $this->customer->addresses->firstWhere('id', $addressId); + if (! $address) { + return; + } + + $this->editAddressId = $addressId; + $this->addressLabel = $address->label ?? ''; + $addrData = $address->address_json ?? []; + $this->address1 = data_get($addrData, 'address1', ''); + $this->city = data_get($addrData, 'city', ''); + $this->zip = data_get($addrData, 'zip', ''); + $this->country = data_get($addrData, 'country', ''); + $this->isDefault = (bool) $address->is_default; + $this->modal('address-form')->show(); + } + + public function saveAddress(): void + { + $this->validate([ + 'addressLabel' => ['required', 'string', 'max:255'], + 'address1' => ['required', 'string', 'max:255'], + 'city' => ['required', 'string', 'max:255'], + 'country' => ['required', 'string', 'max:255'], + ]); + + $addressJson = [ + 'address1' => $this->address1, + 'city' => $this->city, + 'zip' => $this->zip, + 'country' => $this->country, + ]; + + if ($this->isDefault) { + $this->customer->addresses()->update(['is_default' => false]); + } + + if ($this->editAddressId) { + CustomerAddress::where('id', $this->editAddressId)->update([ + 'label' => $this->addressLabel, + 'address_json' => $addressJson, + 'is_default' => $this->isDefault, + ]); + } else { + CustomerAddress::create([ + 'customer_id' => $this->customer->id, + 'label' => $this->addressLabel, + 'address_json' => $addressJson, + 'is_default' => $this->isDefault, + ]); + } + + $this->customer->refresh(); + $this->customer->load('addresses'); + $this->resetAddressForm(); + $this->dispatch('toast', type: 'success', message: 'Address saved.'); + $this->modal('address-form')->close(); + } + + public function deleteAddress(int $addressId): void + { + CustomerAddress::where('id', $addressId)->delete(); + $this->customer->refresh(); + $this->customer->load('addresses'); + $this->dispatch('toast', type: 'success', message: 'Address deleted.'); + } + + public function setDefaultAddress(int $addressId): void + { + $this->customer->addresses()->update(['is_default' => false]); + CustomerAddress::where('id', $addressId)->update(['is_default' => true]); + $this->customer->refresh(); + $this->customer->load('addresses'); + $this->dispatch('toast', type: 'success', message: 'Default address updated.'); + } + + #[Computed] + public function totalSpent(): int + { + return (int) $this->customer->orders()->sum('total_amount'); + } + + public function render(): mixed + { + return view('livewire.admin.customers.show') + ->layout('layouts.admin', [ + 'breadcrumbs' => [ + ['label' => 'Customers', 'url' => route('admin.customers.index')], + ['label' => $this->customer->name], + ], + ]); + } + + protected function resetAddressForm(): void + { + $this->editAddressId = null; + $this->addressLabel = ''; + $this->address1 = ''; + $this->city = ''; + $this->zip = ''; + $this->country = ''; + $this->isDefault = false; + } +} diff --git a/app/Livewire/Admin/Dashboard.php b/app/Livewire/Admin/Dashboard.php new file mode 100644 index 00000000..0cc0bb45 --- /dev/null +++ b/app/Livewire/Admin/Dashboard.php @@ -0,0 +1,160 @@ +dateRange !== 'custom') { + $this->customStartDate = null; + $this->customEndDate = null; + } + } + + #[Computed] + public function kpis(): array + { + [$start, $end] = $this->getDateRange(); + [$prevStart, $prevEnd] = $this->getPreviousDateRange(); + + $current = $this->getKpiData($start, $end); + $previous = $this->getKpiData($prevStart, $prevEnd); + + return [ + 'totalSales' => $current['totalSales'], + 'ordersCount' => $current['ordersCount'], + 'averageOrderValue' => $current['ordersCount'] > 0 + ? (int) round($current['totalSales'] / $current['ordersCount']) + : 0, + 'salesChange' => $this->percentChange($previous['totalSales'], $current['totalSales']), + 'ordersChange' => $this->percentChange($previous['ordersCount'], $current['ordersCount']), + 'aovChange' => $this->percentChange( + $previous['ordersCount'] > 0 ? $previous['totalSales'] / $previous['ordersCount'] : 0, + $current['ordersCount'] > 0 ? $current['totalSales'] / $current['ordersCount'] : 0, + ), + ]; + } + + #[Computed] + public function recentOrders(): mixed + { + return Order::query() + ->withoutGlobalScopes() + ->where('store_id', $this->getStoreId()) + ->with('customer') + ->latest('placed_at') + ->limit(10) + ->get(); + } + + #[Computed] + public function topProducts(): array + { + [$start, $end] = $this->getDateRange(); + + return OrderLine::query() + ->join('orders', 'order_lines.order_id', '=', 'orders.id') + ->where('orders.store_id', $this->getStoreId()) + ->whereBetween('orders.placed_at', [$start, $end]) + ->select( + 'order_lines.title_snapshot as title', + DB::raw('SUM(order_lines.quantity) as units_sold'), + DB::raw('SUM(order_lines.total_amount) as revenue'), + ) + ->groupBy('order_lines.title_snapshot') + ->orderByDesc('revenue') + ->limit(5) + ->get() + ->toArray(); + } + + public function render(): mixed + { + return view('livewire.admin.dashboard') + ->layout('layouts.admin', [ + 'breadcrumbs' => [], + ]); + } + + protected function getStoreId(): ?int + { + $store = app()->bound('current_store') ? app('current_store') : null; + + return $store?->id; + } + + /** + * @return array{string, string} + */ + protected function getDateRange(): array + { + return match ($this->dateRange) { + 'today' => [Carbon::today()->toDateTimeString(), Carbon::now()->toDateTimeString()], + 'last_7_days' => [Carbon::now()->subDays(7)->toDateTimeString(), Carbon::now()->toDateTimeString()], + 'last_30_days' => [Carbon::now()->subDays(30)->toDateTimeString(), Carbon::now()->toDateTimeString()], + 'custom' => [ + $this->customStartDate ? Carbon::parse($this->customStartDate)->startOfDay()->toDateTimeString() : Carbon::now()->subDays(30)->toDateTimeString(), + $this->customEndDate ? Carbon::parse($this->customEndDate)->endOfDay()->toDateTimeString() : Carbon::now()->toDateTimeString(), + ], + default => [Carbon::now()->subDays(30)->toDateTimeString(), Carbon::now()->toDateTimeString()], + }; + } + + /** + * @return array{string, string} + */ + protected function getPreviousDateRange(): array + { + [$start, $end] = $this->getDateRange(); + $diff = Carbon::parse($start)->diffInSeconds(Carbon::parse($end)); + + return [ + Carbon::parse($start)->subSeconds($diff)->toDateTimeString(), + Carbon::parse($start)->toDateTimeString(), + ]; + } + + /** + * @return array{totalSales: int, ordersCount: int} + */ + protected function getKpiData(string $start, string $end): array + { + $result = Order::query() + ->withoutGlobalScopes() + ->where('store_id', $this->getStoreId()) + ->whereBetween('placed_at', [$start, $end]) + ->selectRaw('COALESCE(SUM(total_amount), 0) as total_sales, COUNT(*) as orders_count') + ->first(); + + return [ + 'totalSales' => (int) $result->total_sales, + 'ordersCount' => (int) $result->orders_count, + ]; + } + + protected function percentChange(float $previous, float $current): float + { + if ($previous == 0) { + return $current > 0 ? 100.0 : 0.0; + } + + return round(($current - $previous) / $previous * 100, 1); + } +} diff --git a/app/Livewire/Admin/Developers/Index.php b/app/Livewire/Admin/Developers/Index.php new file mode 100644 index 00000000..8a03d41c --- /dev/null +++ b/app/Livewire/Admin/Developers/Index.php @@ -0,0 +1,37 @@ +getStoreId(); + + if (! $storeId) { + return collect(); + } + + return WebhookSubscription::withoutGlobalScopes() + ->where('store_id', $storeId) + ->get(); + } + + public function render(): mixed + { + return view('livewire.admin.developers.index') + ->layout('layouts.admin', ['breadcrumbs' => [['label' => 'Developers']]]); + } + + protected function getStoreId(): ?int + { + $store = app()->bound('current_store') ? app('current_store') : null; + + return $store?->id; + } +} diff --git a/app/Livewire/Admin/Discounts/Form.php b/app/Livewire/Admin/Discounts/Form.php new file mode 100644 index 00000000..843d3df8 --- /dev/null +++ b/app/Livewire/Admin/Discounts/Form.php @@ -0,0 +1,114 @@ +exists) { + $this->discount = $discount; + $this->type = $discount->type->value; + $this->code = $discount->code ?? ''; + $this->valueType = $discount->value_type->value; + $this->valueAmount = $discount->value_amount; + $this->status = $discount->status->value; + $this->startsAt = $discount->starts_at; + $this->endsAt = $discount->ends_at; + $this->usageLimit = $discount->usage_limit; + $this->minimumPurchaseAmount = $discount->minimum_purchase_amount; + } + } + + public function generateCode(): void + { + $this->code = strtoupper(Str::random(8)); + } + + public function save(): void + { + $storeId = app('current_store')->id; + + $rules = [ + 'type' => ['required', Rule::in(['code', 'automatic'])], + 'valueType' => ['required', Rule::in(['percent', 'fixed', 'free_shipping'])], + 'valueAmount' => ['required', 'integer', 'min:0'], + 'status' => ['required', Rule::in(['active', 'disabled', 'expired', 'draft'])], + ]; + + if ($this->type === 'code') { + $rules['code'] = [ + 'required', 'string', 'max:255', + Rule::unique('discounts', 'code') + ->where('store_id', $storeId) + ->ignore($this->discount?->id), + ]; + } + + $this->validate($rules); + + $data = [ + 'store_id' => $storeId, + 'type' => $this->type, + 'code' => $this->type === 'code' ? $this->code : null, + 'value_type' => $this->valueType, + 'value_amount' => $this->valueAmount, + 'status' => $this->status, + 'starts_at' => $this->startsAt, + 'ends_at' => $this->endsAt, + 'usage_limit' => $this->usageLimit, + 'minimum_purchase_amount' => $this->minimumPurchaseAmount, + ]; + + if ($this->discount && $this->discount->exists) { + $this->discount->update($data); + } else { + $this->discount = Discount::withoutGlobalScopes()->create($data); + } + + $this->dispatch('toast', type: 'success', message: 'Discount saved.'); + } + + #[Computed] + public function isEditing(): bool + { + return $this->discount !== null && $this->discount->exists; + } + + public function render(): mixed + { + $breadcrumbs = [ + ['label' => 'Discounts', 'url' => route('admin.discounts.index')], + ['label' => $this->isEditing ? ($this->discount->code ?? 'Automatic') : 'Create discount'], + ]; + + return view('livewire.admin.discounts.form') + ->layout('layouts.admin', ['breadcrumbs' => $breadcrumbs]); + } +} diff --git a/app/Livewire/Admin/Discounts/Index.php b/app/Livewire/Admin/Discounts/Index.php new file mode 100644 index 00000000..408cda6c --- /dev/null +++ b/app/Livewire/Admin/Discounts/Index.php @@ -0,0 +1,52 @@ +resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + #[Computed] + public function discounts(): mixed + { + $query = Discount::query(); + + if ($this->search !== '') { + $query->where('code', 'like', "%{$this->search}%"); + } + + if ($this->statusFilter !== 'all') { + $query->where('status', $this->statusFilter); + } + + return $query->latest('created_at')->paginate(15); + } + + public function render(): mixed + { + return view('livewire.admin.discounts.index') + ->layout('layouts.admin', ['breadcrumbs' => [['label' => 'Discounts']]]); + } +} diff --git a/app/Livewire/Admin/Inventory/Index.php b/app/Livewire/Admin/Inventory/Index.php new file mode 100644 index 00000000..c66afbde --- /dev/null +++ b/app/Livewire/Admin/Inventory/Index.php @@ -0,0 +1,14 @@ +layout('layouts.admin', ['breadcrumbs' => [['label' => 'Inventory']]]); + } +} diff --git a/app/Livewire/Admin/Navigation/Index.php b/app/Livewire/Admin/Navigation/Index.php new file mode 100644 index 00000000..cd363546 --- /dev/null +++ b/app/Livewire/Admin/Navigation/Index.php @@ -0,0 +1,14 @@ +layout('layouts.admin', ['breadcrumbs' => [['label' => 'Navigation']]]); + } +} diff --git a/app/Livewire/Admin/Orders/Index.php b/app/Livewire/Admin/Orders/Index.php new file mode 100644 index 00000000..f818e37f --- /dev/null +++ b/app/Livewire/Admin/Orders/Index.php @@ -0,0 +1,78 @@ +resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function sortBy(string $field): void + { + if ($this->sortField === $field) { + $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortField = $field; + $this->sortDirection = 'asc'; + } + } + + #[Computed] + public function orders(): mixed + { + $query = Order::query() + ->with('customer'); + + if ($this->search !== '') { + $query->where(function ($q) { + $q->where('order_number', 'like', "%{$this->search}%") + ->orWhere('email', 'like', "%{$this->search}%"); + }); + } + + if ($this->statusFilter !== 'all') { + $query->where('financial_status', $this->statusFilter); + } + + $query->orderBy($this->sortField, $this->sortDirection); + + return $query->paginate(15); + } + + public function render(): mixed + { + return view('livewire.admin.orders.index') + ->layout('layouts.admin', [ + 'breadcrumbs' => [ + ['label' => 'Orders'], + ], + ]); + } +} diff --git a/app/Livewire/Admin/Orders/Show.php b/app/Livewire/Admin/Orders/Show.php new file mode 100644 index 00000000..bdaec01d --- /dev/null +++ b/app/Livewire/Admin/Orders/Show.php @@ -0,0 +1,202 @@ + */ + public array $fulfillLines = []; + + // Refund modal + public string $refundAmount = ''; + + public string $refundReason = ''; + + public function mount(Order $order): void + { + $this->order = $order->load([ + 'lines', + 'customer', + 'payments', + 'refunds', + 'fulfillments.fulfillmentLines', + ]); + + // Pre-populate fulfillment lines with unfulfilled quantities + foreach ($this->order->lines as $line) { + $fulfilled = $line->fulfillmentLines()->sum('quantity'); + $remaining = $line->quantity - $fulfilled; + if ($remaining > 0) { + $this->fulfillLines[$line->id] = $remaining; + } + } + } + + public function confirmPayment(): void + { + if ($this->order->payment_method !== PaymentMethod::BankTransfer) { + return; + } + + $payment = $this->order->payments()->first(); + + if ($payment) { + $payment->update(['status' => PaymentStatus::Captured]); + } + + $this->order->update([ + 'financial_status' => FinancialStatus::Paid, + ]); + + $this->order->refresh(); + $this->dispatch('toast', type: 'success', message: 'Payment confirmed.'); + } + + public function createFulfillment(): void + { + $lines = array_filter($this->fulfillLines, fn ($qty) => $qty > 0); + + if (empty($lines)) { + $this->dispatch('toast', type: 'error', message: 'Please select items to fulfill.'); + + return; + } + + try { + $service = app(FulfillmentService::class); + $service->create($this->order, $lines, [ + 'tracking_company' => $this->trackingCompany ?: null, + 'tracking_number' => $this->trackingNumber ?: null, + 'tracking_url' => $this->trackingUrl ?: null, + ]); + + $this->order->refresh(); + $this->order->load('fulfillments.fulfillmentLines'); + + // Reset form + $this->trackingCompany = ''; + $this->trackingNumber = ''; + $this->trackingUrl = ''; + + // Update unfulfilled lines + $this->fulfillLines = []; + foreach ($this->order->lines as $line) { + $fulfilled = $line->fulfillmentLines()->sum('quantity'); + $remaining = $line->quantity - $fulfilled; + if ($remaining > 0) { + $this->fulfillLines[$line->id] = $remaining; + } + } + + $this->dispatch('toast', type: 'success', message: 'Fulfillment created.'); + $this->modal('create-fulfillment')->close(); + } catch (\Exception $e) { + $this->dispatch('toast', type: 'error', message: $e->getMessage()); + } + } + + public function markAsShipped(int $fulfillmentId): void + { + $fulfillment = $this->order->fulfillments->firstWhere('id', $fulfillmentId); + + if (! $fulfillment) { + return; + } + + try { + app(FulfillmentService::class)->markAsShipped($fulfillment); + $this->order->refresh(); + $this->order->load('fulfillments.fulfillmentLines'); + $this->dispatch('toast', type: 'success', message: 'Marked as shipped.'); + } catch (\Exception $e) { + $this->dispatch('toast', type: 'error', message: $e->getMessage()); + } + } + + public function markAsDelivered(int $fulfillmentId): void + { + $fulfillment = $this->order->fulfillments->firstWhere('id', $fulfillmentId); + + if (! $fulfillment) { + return; + } + + try { + app(FulfillmentService::class)->markAsDelivered($fulfillment); + $this->order->refresh(); + $this->order->load('fulfillments.fulfillmentLines'); + $this->dispatch('toast', type: 'success', message: 'Marked as delivered.'); + } catch (\Exception $e) { + $this->dispatch('toast', type: 'error', message: $e->getMessage()); + } + } + + public function createRefund(): void + { + $amount = (int) round((float) $this->refundAmount * 100); + + if ($amount <= 0) { + $this->dispatch('toast', type: 'error', message: 'Please enter a valid refund amount.'); + + return; + } + + $payment = $this->order->payments()->first(); + + if (! $payment) { + $this->dispatch('toast', type: 'error', message: 'No payment found for this order.'); + + return; + } + + try { + app(RefundService::class)->create( + $this->order, + $payment, + $amount, + $this->refundReason ?: null, + ); + + $this->order->refresh(); + $this->order->load('refunds'); + $this->refundAmount = ''; + $this->refundReason = ''; + + $this->dispatch('toast', type: 'success', message: 'Refund processed.'); + $this->modal('create-refund')->close(); + } catch (\Exception $e) { + $this->dispatch('toast', type: 'error', message: $e->getMessage()); + } + } + + public function render(): mixed + { + return view('livewire.admin.orders.show') + ->layout('layouts.admin', [ + 'breadcrumbs' => [ + ['label' => 'Orders', 'url' => route('admin.orders.index')], + ['label' => $this->order->order_number], + ], + ]); + } +} diff --git a/app/Livewire/Admin/Pages/Form.php b/app/Livewire/Admin/Pages/Form.php new file mode 100644 index 00000000..572537c9 --- /dev/null +++ b/app/Livewire/Admin/Pages/Form.php @@ -0,0 +1,91 @@ +exists) { + $this->page = $page; + $this->title = $page->title; + $this->handle = $page->handle; + $this->bodyHtml = $page->body_html ?? ''; + $this->status = $page->status->value; + } + } + + public function updatedTitle(): void + { + if (! $this->isEditing) { + $this->handle = Str::slug($this->title); + } + } + + public function save(): void + { + $storeId = app('current_store')->id; + + $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => [ + 'required', 'string', 'max:255', + Rule::unique('pages', 'handle') + ->where('store_id', $storeId) + ->ignore($this->page?->id), + ], + 'bodyHtml' => ['nullable', 'string'], + 'status' => ['required', Rule::in(['draft', 'published'])], + ]); + + $data = [ + 'store_id' => $storeId, + 'title' => $this->title, + 'handle' => $this->handle, + 'body_html' => $this->bodyHtml ?: null, + 'status' => $this->status, + 'published_at' => $this->status === 'published' ? now()->toIso8601String() : null, + ]; + + if ($this->page && $this->page->exists) { + $this->page->update($data); + } else { + $this->page = Page::withoutGlobalScopes()->create($data); + } + + $this->dispatch('toast', type: 'success', message: 'Page saved.'); + } + + #[Computed] + public function isEditing(): bool + { + return $this->page !== null && $this->page->exists; + } + + public function render(): mixed + { + return view('livewire.admin.pages.form') + ->layout('layouts.admin', [ + 'breadcrumbs' => [ + ['label' => 'Pages', 'url' => route('admin.pages.index')], + ['label' => $this->isEditing ? $this->page->title : 'Add page'], + ], + ]); + } +} diff --git a/app/Livewire/Admin/Pages/Index.php b/app/Livewire/Admin/Pages/Index.php new file mode 100644 index 00000000..1141fdfb --- /dev/null +++ b/app/Livewire/Admin/Pages/Index.php @@ -0,0 +1,46 @@ +resetPage(); + } + + public function deletePage(int $id): void + { + Page::withoutGlobalScopes()->where('id', $id)->delete(); + $this->dispatch('toast', type: 'success', message: 'Page deleted.'); + } + + #[Computed] + public function pages(): mixed + { + $query = Page::query(); + + if ($this->search !== '') { + $query->where('title', 'like', "%{$this->search}%"); + } + + return $query->latest('updated_at')->paginate(15); + } + + public function render(): mixed + { + return view('livewire.admin.pages.index') + ->layout('layouts.admin', ['breadcrumbs' => [['label' => 'Pages']]]); + } +} diff --git a/app/Livewire/Admin/Products/Form.php b/app/Livewire/Admin/Products/Form.php new file mode 100644 index 00000000..d77ee9db --- /dev/null +++ b/app/Livewire/Admin/Products/Form.php @@ -0,0 +1,344 @@ + */ + public array $collectionIds = []; + + /** @var array}> */ + public array $options = []; + + /** @var array}> */ + public array $variants = []; + + /** @var array<\Livewire\Features\SupportFileUploads\TemporaryUploadedFile> */ + public array $newMedia = []; + + public bool $showSeo = false; + + public function mount(?Product $product = null): void + { + if ($product && $product->exists) { + $this->product = $product; + $this->title = $product->title; + $this->descriptionHtml = $product->description_html ?? ''; + $this->status = $product->status->value; + $this->vendor = $product->vendor ?? ''; + $this->productType = $product->product_type ?? ''; + $this->tags = is_array($product->tags) ? implode(', ', $product->tags) : ''; + $this->handle = $product->handle; + $this->publishedAt = $product->published_at; + $this->collectionIds = $product->collections()->pluck('collections.id')->toArray(); + + $this->options = $product->options->map(fn ($opt) => [ + 'name' => $opt->name, + 'values' => $opt->values()->orderBy('position')->pluck('value')->toArray(), + ])->toArray(); + + $this->variants = $product->variants->map(fn ($v) => [ + 'sku' => $v->sku ?? '', + 'price' => $v->price_amount, + 'compareAtPrice' => $v->compare_at_amount, + 'quantity' => $v->inventoryItem?->quantity_on_hand ?? 0, + 'requiresShipping' => (bool) $v->requires_shipping, + 'optionValues' => $v->optionValues()->pluck('value')->toArray(), + ])->toArray(); + } + + if (empty($this->variants)) { + $this->variants = [ + ['sku' => '', 'price' => 0, 'compareAtPrice' => null, 'quantity' => 0, 'requiresShipping' => true, 'optionValues' => []], + ]; + } + } + + public function updatedTitle(): void + { + if (! $this->isEditing) { + $this->handle = Str::slug($this->title); + } + } + + public function addOption(): void + { + $this->options[] = ['name' => '', 'values' => ['']]; + } + + public function removeOption(int $index): void + { + unset($this->options[$index]); + $this->options = array_values($this->options); + $this->generateVariants(); + } + + public function addOptionValue(int $optionIndex): void + { + $this->options[$optionIndex]['values'][] = ''; + } + + public function removeOptionValue(int $optionIndex, int $valueIndex): void + { + unset($this->options[$optionIndex]['values'][$valueIndex]); + $this->options[$optionIndex]['values'] = array_values($this->options[$optionIndex]['values']); + $this->generateVariants(); + } + + public function generateVariants(): void + { + $filteredOptions = array_filter($this->options, function ($opt) { + return $opt['name'] !== '' && count(array_filter($opt['values'], fn ($v) => $v !== '')) > 0; + }); + + if (empty($filteredOptions)) { + if (empty($this->variants)) { + $this->variants = [ + ['sku' => '', 'price' => 0, 'compareAtPrice' => null, 'quantity' => 0, 'requiresShipping' => true, 'optionValues' => []], + ]; + } + + return; + } + + $valueSets = array_map(fn ($opt) => array_filter($opt['values'], fn ($v) => $v !== ''), $filteredOptions); + $combinations = $this->cartesianProduct($valueSets); + + $this->variants = array_map(fn ($combo) => [ + 'sku' => '', + 'price' => 0, + 'compareAtPrice' => null, + 'quantity' => 0, + 'requiresShipping' => true, + 'optionValues' => is_array($combo) ? $combo : [$combo], + ], $combinations); + } + + public function removeMedia(int $mediaId): void + { + ProductMedia::where('id', $mediaId)->delete(); + $this->dispatch('toast', type: 'success', message: 'Media removed.'); + } + + public function save(): void + { + $storeId = app('current_store')->id; + + $rules = [ + 'title' => ['required', 'string', 'max:255'], + 'descriptionHtml' => ['nullable', 'string', 'max:65535'], + 'status' => ['required', Rule::in(['draft', 'active', 'archived'])], + 'vendor' => ['nullable', 'string', 'max:255'], + 'productType' => ['nullable', 'string', 'max:255'], + 'tags' => ['nullable', 'string'], + 'handle' => [ + 'required', 'string', 'max:255', + Rule::unique('products', 'handle') + ->where('store_id', $storeId) + ->ignore($this->product?->id), + ], + 'variants.*.price' => ['required', 'integer', 'min:0'], + 'variants.*.sku' => ['nullable', 'string', 'max:255'], + 'variants.*.quantity' => ['required', 'integer', 'min:0'], + ]; + + $this->validate($rules); + + $productData = [ + 'store_id' => $storeId, + 'title' => $this->title, + 'handle' => $this->handle, + 'description_html' => $this->descriptionHtml ?: null, + 'status' => $this->status, + 'vendor' => $this->vendor ?: null, + 'product_type' => $this->productType ?: null, + 'tags' => $this->tags ? array_map('trim', explode(',', $this->tags)) : [], + 'published_at' => $this->publishedAt, + ]; + + if ($this->product && $this->product->exists) { + $this->product->update($productData); + $product = $this->product; + } else { + $product = Product::withoutGlobalScopes()->create($productData); + $this->product = $product; + } + + // Sync collections + $product->collections()->sync($this->collectionIds); + + // Sync options + $product->options()->delete(); + foreach ($this->options as $pos => $opt) { + if ($opt['name'] === '') { + continue; + } + $option = ProductOption::create([ + 'product_id' => $product->id, + 'name' => $opt['name'], + 'position' => $pos, + ]); + foreach ($opt['values'] as $vPos => $val) { + if ($val === '') { + continue; + } + ProductOptionValue::create([ + 'product_option_id' => $option->id, + 'value' => $val, + 'position' => $vPos, + ]); + } + } + + // Sync variants + $product->variants()->each(function ($variant) { + $variant->inventoryItem?->delete(); + $variant->optionValues()->detach(); + $variant->delete(); + }); + + foreach ($this->variants as $pos => $variantData) { + $variant = ProductVariant::create([ + 'product_id' => $product->id, + 'sku' => $variantData['sku'] ?: null, + 'price_amount' => $variantData['price'], + 'compare_at_amount' => $variantData['compareAtPrice'], + 'requires_shipping' => $variantData['requiresShipping'], + 'is_default' => $pos === 0, + 'position' => $pos, + 'status' => 'active', + 'currency' => 'USD', + ]); + + $variant->inventoryItem()->create([ + 'store_id' => $storeId, + 'variant_id' => $variant->id, + 'quantity_on_hand' => $variantData['quantity'], + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]); + } + + // Handle file uploads + foreach ($this->newMedia as $file) { + $path = $file->store('product-media', 'public'); + ProductMedia::create([ + 'product_id' => $product->id, + 'type' => 'image', + 'storage_key' => $path, + 'alt_text' => '', + 'mime_type' => $file->getMimeType(), + 'byte_size' => $file->getSize(), + 'position' => $product->media()->count(), + 'status' => 'active', + 'created_at' => now()->toIso8601String(), + ]); + } + $this->newMedia = []; + + $this->dispatch('toast', type: 'success', message: 'Product saved.'); + } + + public function deleteProduct(): void + { + if ($this->product) { + $this->product->update(['status' => ProductStatus::Archived->value]); + $this->dispatch('toast', type: 'success', message: 'Product archived.'); + $this->redirect(route('admin.products.index'), navigate: true); + } + } + + #[Computed] + public function isEditing(): bool + { + return $this->product !== null && $this->product->exists; + } + + #[Computed] + public function availableCollections(): mixed + { + return Collection::query()->get(); + } + + #[Computed] + public function existingMedia(): mixed + { + if ($this->product && $this->product->exists) { + return $this->product->media()->orderBy('position')->get(); + } + + return collect(); + } + + public function render(): mixed + { + $breadcrumbs = [ + ['label' => 'Products', 'url' => route('admin.products.index')], + ]; + + if ($this->isEditing) { + $breadcrumbs[] = ['label' => $this->product->title]; + } else { + $breadcrumbs[] = ['label' => 'Add product']; + } + + return view('livewire.admin.products.form') + ->layout('layouts.admin', [ + 'breadcrumbs' => $breadcrumbs, + ]); + } + + /** + * @param array> $arrays + * @return array> + */ + protected function cartesianProduct(array $arrays): array + { + $result = [[]]; + + foreach ($arrays as $array) { + $append = []; + foreach ($result as $current) { + foreach ($array as $item) { + $append[] = array_merge($current, [$item]); + } + } + $result = $append; + } + + return $result; + } +} diff --git a/app/Livewire/Admin/Products/Index.php b/app/Livewire/Admin/Products/Index.php new file mode 100644 index 00000000..5c6e37d1 --- /dev/null +++ b/app/Livewire/Admin/Products/Index.php @@ -0,0 +1,145 @@ + */ + public array $selectedIds = []; + + public bool $selectAll = false; + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function updatedTypeFilter(): void + { + $this->resetPage(); + } + + public function sortBy(string $field): void + { + if ($this->sortField === $field) { + $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortField = $field; + $this->sortDirection = 'asc'; + } + } + + public function toggleSelectAll(): void + { + if ($this->selectAll) { + $this->selectedIds = $this->products->pluck('id')->toArray(); + } else { + $this->selectedIds = []; + } + } + + public function bulkArchive(): void + { + Product::withoutGlobalScopes() + ->whereIn('id', $this->selectedIds) + ->update(['status' => ProductStatus::Archived->value]); + + $this->selectedIds = []; + $this->selectAll = false; + $this->dispatch('toast', type: 'success', message: 'Products archived.'); + } + + public function bulkSetActive(): void + { + Product::withoutGlobalScopes() + ->whereIn('id', $this->selectedIds) + ->update(['status' => ProductStatus::Active->value, 'published_at' => now()->toIso8601String()]); + + $this->selectedIds = []; + $this->selectAll = false; + $this->dispatch('toast', type: 'success', message: 'Products set to active.'); + } + + public function bulkDelete(): void + { + Product::withoutGlobalScopes() + ->whereIn('id', $this->selectedIds) + ->update(['status' => ProductStatus::Archived->value]); + + $this->selectedIds = []; + $this->selectAll = false; + $this->dispatch('toast', type: 'success', message: 'Products deleted.'); + $this->modal('confirm-bulk-delete')->close(); + } + + #[Computed] + public function products(): mixed + { + $query = Product::query() + ->withCount('variants') + ->with(['media' => fn ($q) => $q->limit(1)]); + + if ($this->search !== '') { + $query->where('title', 'like', "%{$this->search}%"); + } + + if ($this->statusFilter !== 'all') { + $query->where('status', $this->statusFilter); + } + + if ($this->typeFilter !== '') { + $query->where('product_type', $this->typeFilter); + } + + $query->orderBy($this->sortField, $this->sortDirection); + + return $query->paginate(15); + } + + #[Computed] + public function productTypes(): array + { + return Product::query() + ->whereNotNull('product_type') + ->distinct() + ->pluck('product_type') + ->toArray(); + } + + public function render(): mixed + { + return view('livewire.admin.products.index') + ->layout('layouts.admin', [ + 'breadcrumbs' => [ + ['label' => 'Products'], + ], + ]); + } +} diff --git a/app/Livewire/Admin/Settings/Index.php b/app/Livewire/Admin/Settings/Index.php new file mode 100644 index 00000000..44e90149 --- /dev/null +++ b/app/Livewire/Admin/Settings/Index.php @@ -0,0 +1,256 @@ +user(); + $store = app('current_store'); + + $role = $user->roleForStore($store); + if ($role && ! in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin])) { + abort(403); + } + + $this->storeName = $store->name; + $this->storeHandle = $store->handle; + $this->defaultCurrency = $store->default_currency; + $this->defaultLocale = $store->default_locale; + $this->timezone = $store->timezone; + + $tax = TaxSettings::where('store_id', $store->id)->first(); + if ($tax) { + $this->taxMode = $tax->mode instanceof \App\Enums\TaxMode ? $tax->mode->value : ($tax->mode ?? 'manual'); + $this->taxRate = $tax->rate ?? 0; + $this->taxName = $tax->tax_name ?? 'Tax'; + $this->pricesIncludeTax = (bool) $tax->prices_include_tax; + } + } + + public function saveGeneral(): void + { + $this->validate([ + 'storeName' => ['required', 'string', 'max:255'], + 'defaultCurrency' => ['required', 'string', 'max:3'], + 'defaultLocale' => ['required', 'string', 'max:5'], + 'timezone' => ['required', 'string', 'max:64'], + ]); + + $store = app('current_store'); + $store->update([ + 'name' => $this->storeName, + 'default_currency' => $this->defaultCurrency, + 'default_locale' => $this->defaultLocale, + 'timezone' => $this->timezone, + ]); + + $this->dispatch('toast', type: 'success', message: 'Settings saved.'); + } + + public function addDomain(): void + { + $this->validate([ + 'newHostname' => ['required', 'string', 'max:255'], + ]); + + $store = app('current_store'); + StoreDomain::create([ + 'store_id' => $store->id, + 'hostname' => $this->newHostname, + 'type' => $this->newDomainType, + 'is_primary' => false, + 'tls_mode' => 'auto', + 'created_at' => now(), + ]); + + $this->newHostname = ''; + $this->dispatch('toast', type: 'success', message: 'Domain added.'); + $this->modal('add-domain')->close(); + } + + public function setPrimary(int $domainId): void + { + $store = app('current_store'); + StoreDomain::where('store_id', $store->id)->update(['is_primary' => false]); + StoreDomain::where('id', $domainId)->update(['is_primary' => true]); + $this->dispatch('toast', type: 'success', message: 'Primary domain updated.'); + } + + public function deleteDomain(int $domainId): void + { + StoreDomain::where('id', $domainId)->delete(); + $this->dispatch('toast', type: 'success', message: 'Domain removed.'); + } + + public function saveZone(): void + { + $this->validate([ + 'zoneName' => ['required', 'string', 'max:255'], + ]); + + $store = app('current_store'); + $countries = array_map('trim', explode(',', $this->zoneCountries)); + + if ($this->editZoneId) { + ShippingZone::where('id', $this->editZoneId)->update([ + 'name' => $this->zoneName, + 'countries_json' => $countries, + ]); + } else { + ShippingZone::create([ + 'store_id' => $store->id, + 'name' => $this->zoneName, + 'countries_json' => $countries, + 'regions_json' => [], + 'is_active' => true, + ]); + } + + $this->zoneName = ''; + $this->zoneCountries = ''; + $this->editZoneId = null; + $this->dispatch('toast', type: 'success', message: 'Shipping zone saved.'); + $this->modal('zone-form')->close(); + } + + public function editZone(int $zoneId): void + { + $zone = ShippingZone::find($zoneId); + if ($zone) { + $this->editZoneId = $zoneId; + $this->zoneName = $zone->name; + $this->zoneCountries = implode(', ', $zone->countries_json ?? []); + $this->modal('zone-form')->show(); + } + } + + public function deleteZone(int $zoneId): void + { + ShippingZone::where('id', $zoneId)->delete(); + $this->dispatch('toast', type: 'success', message: 'Zone removed.'); + } + + public function openAddRate(int $zoneId): void + { + $this->rateZoneId = $zoneId; + $this->rateName = ''; + $this->rateType = 'flat_rate'; + $this->ratePrice = 0; + $this->modal('rate-form')->show(); + } + + public function saveRate(): void + { + $this->validate([ + 'rateName' => ['required', 'string', 'max:255'], + ]); + + ShippingRate::create([ + 'zone_id' => $this->rateZoneId, + 'name' => $this->rateName, + 'type' => $this->rateType, + 'config_json' => ['price' => $this->ratePrice], + 'is_active' => true, + ]); + + $this->dispatch('toast', type: 'success', message: 'Rate added.'); + $this->modal('rate-form')->close(); + } + + public function deleteRate(int $rateId): void + { + ShippingRate::where('id', $rateId)->delete(); + $this->dispatch('toast', type: 'success', message: 'Rate removed.'); + } + + public function saveTax(): void + { + $store = app('current_store'); + + TaxSettings::updateOrCreate( + ['store_id' => $store->id], + [ + 'mode' => $this->taxMode, + 'rate' => $this->taxRate, + 'tax_name' => $this->taxName, + 'prices_include_tax' => $this->pricesIncludeTax, + 'is_active' => true, + ], + ); + + $this->dispatch('toast', type: 'success', message: 'Tax settings saved.'); + } + + #[Computed] + public function domains(): mixed + { + return StoreDomain::where('store_id', app('current_store')->id)->get(); + } + + #[Computed] + public function shippingZones(): mixed + { + return ShippingZone::where('store_id', app('current_store')->id) + ->with('rates') + ->get(); + } + + public function render(): mixed + { + return view('livewire.admin.settings.index') + ->layout('layouts.admin', ['breadcrumbs' => [['label' => 'Settings']]]); + } +} diff --git a/app/Livewire/Admin/Themes/Index.php b/app/Livewire/Admin/Themes/Index.php new file mode 100644 index 00000000..22e33ae3 --- /dev/null +++ b/app/Livewire/Admin/Themes/Index.php @@ -0,0 +1,14 @@ +layout('layouts.admin', ['breadcrumbs' => [['label' => 'Themes']]]); + } +} diff --git a/app/Livewire/Storefront/Account/Addresses/Index.php b/app/Livewire/Storefront/Account/Addresses/Index.php new file mode 100644 index 00000000..650c6a85 --- /dev/null +++ b/app/Livewire/Storefront/Account/Addresses/Index.php @@ -0,0 +1,141 @@ + '', + 'last_name' => '', + 'company' => '', + 'address1' => '', + 'address2' => '', + 'city' => '', + 'province' => '', + 'zip' => '', + 'country' => '', + 'phone' => '', + ]; + + public string $label = ''; + + public function openAddForm(): void + { + $this->resetForm(); + $this->editingAddressId = null; + $this->showModal = true; + } + + public function editAddress(int $addressId): void + { + $customer = Auth::guard('customer')->user(); + $address = CustomerAddress::find($addressId); + + if (! $address || $address->customer_id !== $customer->id) { + abort(403); + } + + $this->editingAddressId = $address->id; + $this->label = $address->label ?? ''; + $this->form = array_merge($this->form, $address->address_json ?? []); + $this->showModal = true; + } + + public function saveAddress(): void + { + $this->validate([ + 'form.first_name' => ['required', 'max:255'], + 'form.last_name' => ['required', 'max:255'], + 'form.address1' => ['required', 'max:255'], + 'form.city' => ['required', 'max:255'], + 'form.zip' => ['required', 'max:20'], + 'form.country' => ['required', 'max:2'], + ]); + + $customer = Auth::guard('customer')->user(); + $hasNoAddresses = $customer->addresses()->count() === 0; + + $data = [ + 'customer_id' => $customer->id, + 'label' => $this->label ?: null, + 'address_json' => $this->form, + 'is_default' => $hasNoAddresses && ! $this->editingAddressId, + ]; + + if ($this->editingAddressId) { + $address = CustomerAddress::where('id', $this->editingAddressId) + ->where('customer_id', $customer->id) + ->firstOrFail(); + $address->update([ + 'label' => $data['label'], + 'address_json' => $data['address_json'], + ]); + } else { + CustomerAddress::create($data); + } + + $this->resetForm(); + $this->showModal = false; + } + + public function deleteAddress(int $addressId): void + { + $customer = Auth::guard('customer')->user(); + $address = CustomerAddress::find($addressId); + + if (! $address || $address->customer_id !== $customer->id) { + abort(403); + } + + $address->delete(); + } + + public function setDefault(int $addressId): void + { + $customer = Auth::guard('customer')->user(); + $address = CustomerAddress::where('id', $addressId) + ->where('customer_id', $customer->id) + ->firstOrFail(); + + $customer->addresses()->update(['is_default' => false]); + $address->update(['is_default' => true]); + } + + protected function resetForm(): void + { + $this->editingAddressId = null; + $this->label = ''; + $this->form = [ + 'first_name' => '', + 'last_name' => '', + 'company' => '', + 'address1' => '', + 'address2' => '', + 'city' => '', + 'province' => '', + 'zip' => '', + 'country' => '', + 'phone' => '', + ]; + } + + public function render(): mixed + { + $customer = Auth::guard('customer')->user(); + $addresses = $customer->addresses() + ->orderByDesc('is_default') + ->get(); + + return view('livewire.storefront.account.addresses.index', [ + 'addresses' => $addresses, + ])->layout('layouts::storefront'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Login.php b/app/Livewire/Storefront/Account/Auth/Login.php new file mode 100644 index 00000000..ef78628c --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Login.php @@ -0,0 +1,104 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required'], + ]); + + $throttleKey = 'login|'.$this->getIpAddress(); + + if (RateLimiter::tooManyAttempts($throttleKey, 5)) { + $seconds = RateLimiter::availableIn($throttleKey); + $this->addError('email', "Too many attempts. Try again in {$seconds} seconds."); + + return; + } + + $guestCartId = session('cart_id'); + + if (Auth::guard('customer')->attempt( + ['email' => $this->email, 'password' => $this->password], + $this->remember + )) { + RateLimiter::clear($throttleKey); + session()->regenerate(); + + $this->mergeGuestCart($guestCartId); + + $this->redirect(session()->pull('url.intended', '/account')); + + return; + } + + RateLimiter::hit($throttleKey, 60); + + $this->addError('email', 'Invalid credentials'); + } + + protected function mergeGuestCart(?int $guestCartId): void + { + if (! $guestCartId) { + return; + } + + $customer = Auth::guard('customer')->user(); + $store = app('current_store'); + + $guestCart = Cart::withoutGlobalScopes() + ->where('id', $guestCartId) + ->where('store_id', $store->id) + ->where('status', CartStatus::Active) + ->whereNull('customer_id') + ->first(); + + if (! $guestCart) { + return; + } + + $customerCart = Cart::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('customer_id', $customer->id) + ->where('status', CartStatus::Active) + ->first(); + + $cartService = app(CartService::class); + + if ($customerCart) { + $merged = $cartService->mergeOnLogin($guestCart, $customerCart); + session(['cart_id' => $merged->id]); + } else { + $guestCart->update(['customer_id' => $customer->id]); + session(['cart_id' => $guestCart->id]); + } + } + + protected function getIpAddress(): string + { + return request()->ip() ?? '127.0.0.1'; + } + + public function render(): mixed + { + return view('livewire.storefront.account.auth.login') + ->layout('layouts::guest'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Register.php b/app/Livewire/Storefront/Account/Auth/Register.php new file mode 100644 index 00000000..c412bbc4 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Register.php @@ -0,0 +1,60 @@ +validate([ + 'name' => ['required', 'max:255'], + 'email' => [ + 'required', + 'email', + function (string $attribute, mixed $value, \Closure $fail) use ($store): void { + if ($store instanceof Store && Customer::withoutGlobalScopes()->where('store_id', $store->id)->where('email', $value)->exists()) { + $fail('The email has already been taken.'); + } + }, + ], + 'password' => ['required', 'min:8', 'confirmed', Password::defaults()], + ]); + + $customer = Customer::create([ + 'store_id' => $store->id, + 'name' => $this->name, + 'email' => $this->email, + 'password' => $this->password, + 'marketing_opt_in' => $this->marketing_opt_in, + ]); + + Auth::guard('customer')->login($customer); + session()->regenerate(); + + $this->redirect('/account'); + } + + public function render(): mixed + { + return view('livewire.storefront.account.auth.register') + ->layout('layouts::guest'); + } +} diff --git a/app/Livewire/Storefront/Account/Dashboard.php b/app/Livewire/Storefront/Account/Dashboard.php new file mode 100644 index 00000000..806b9424 --- /dev/null +++ b/app/Livewire/Storefront/Account/Dashboard.php @@ -0,0 +1,47 @@ +user(); + $this->name = $customer->name; + $this->marketingOptIn = (bool) $customer->marketing_opt_in; + } + + public function updateProfile(): void + { + $this->validate([ + 'name' => ['required', 'max:255'], + ]); + + $customer = Auth::guard('customer')->user(); + $customer->update([ + 'name' => $this->name, + 'marketing_opt_in' => $this->marketingOptIn, + ]); + } + + public function render(): mixed + { + $customer = Auth::guard('customer')->user(); + $recentOrders = $customer->orders() + ->latest() + ->limit(5) + ->get(); + + return view('livewire.storefront.account.dashboard', [ + 'customer' => $customer, + 'recentOrders' => $recentOrders, + ])->layout('layouts::storefront'); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Index.php b/app/Livewire/Storefront/Account/Orders/Index.php new file mode 100644 index 00000000..00017e7a --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Index.php @@ -0,0 +1,24 @@ +user(); + $orders = $customer->orders() + ->latest() + ->paginate(10); + + return view('livewire.storefront.account.orders.index', [ + 'orders' => $orders, + ])->layout('layouts::storefront'); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Show.php b/app/Livewire/Storefront/Account/Orders/Show.php new file mode 100644 index 00000000..8493bd92 --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Show.php @@ -0,0 +1,35 @@ +orderNumber = $orderNumber; + } + + public function render(): mixed + { + $customer = Auth::guard('customer')->user(); + + $order = Order::where('customer_id', $customer->id) + ->where('order_number', '#'.$this->orderNumber) + ->with(['lines', 'fulfillments']) + ->first(); + + if (! $order) { + abort(404); + } + + return view('livewire.storefront.account.orders.show', [ + 'order' => $order, + ])->layout('layouts::storefront'); + } +} diff --git a/app/Livewire/Storefront/Cart/Show.php b/app/Livewire/Storefront/Cart/Show.php new file mode 100644 index 00000000..0c83c3ea --- /dev/null +++ b/app/Livewire/Storefront/Cart/Show.php @@ -0,0 +1,78 @@ +getCart(); + + if (! $cart) { + return; + } + + $cartService = app(CartService::class); + + try { + $cartService->updateLineQuantity($cart, $lineId, $quantity); + $this->dispatch('cart-updated'); + } catch (\App\Exceptions\InsufficientInventoryException) { + session()->flash('error', 'Not enough stock available.'); + } + } + + public function removeLine(int $lineId): void + { + $cart = $this->getCart(); + + if (! $cart) { + return; + } + + $cartService = app(CartService::class); + $cartService->removeLine($cart, $lineId); + $this->dispatch('cart-updated'); + } + + public function proceedToCheckout(): mixed + { + return $this->redirect(route('storefront.checkout')); + } + + private function getCart(): ?Cart + { + $cartId = session('cart_id'); + + if (! $cartId) { + return null; + } + + return Cart::withoutGlobalScopes() + ->with('lines.variant.product') + ->find($cartId); + } + + public function render(): mixed + { + $cart = $this->getCart(); + + return view('livewire.storefront.cart.show', [ + 'cart' => $cart, + 'lines' => $cart ? $cart->lines : collect(), + 'subtotal' => $cart ? $cart->lines->sum('line_total_amount') : 0, + ])->layout('layouts::storefront'); + } +} diff --git a/app/Livewire/Storefront/CartDrawer.php b/app/Livewire/Storefront/CartDrawer.php new file mode 100644 index 00000000..c48442b2 --- /dev/null +++ b/app/Livewire/Storefront/CartDrawer.php @@ -0,0 +1,81 @@ +open = true; + } + + public function close(): void + { + $this->open = false; + } + + public function updateQuantity(int $lineId, int $quantity): void + { + if ($quantity < 0) { + return; + } + + $cart = $this->getCart(); + + if (! $cart) { + return; + } + + $cartService = app(CartService::class); + + try { + $cartService->updateLineQuantity($cart, $lineId, $quantity); + } catch (\Exception) { + // Silently handle errors in drawer + } + } + + public function removeLine(int $lineId): void + { + $cart = $this->getCart(); + + if (! $cart) { + return; + } + + $cartService = app(CartService::class); + $cartService->removeLine($cart, $lineId); + } + + public function getCart(): ?Cart + { + $cartId = session('cart_id'); + + if (! $cartId) { + return null; + } + + return Cart::withoutGlobalScopes() + ->with('lines.variant.product') + ->find($cartId); + } + + public function render(): mixed + { + $cart = $this->getCart(); + + return view('livewire.storefront.cart-drawer', [ + 'cart' => $cart, + 'lines' => $cart ? $cart->lines : collect(), + 'subtotal' => $cart ? $cart->lines->sum('line_total_amount') : 0, + ]); + } +} diff --git a/app/Livewire/Storefront/Checkout/Confirmation.php b/app/Livewire/Storefront/Checkout/Confirmation.php new file mode 100644 index 00000000..c2a64e97 --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Confirmation.php @@ -0,0 +1,71 @@ +customer_id && auth('customer')->id() === $checkout->customer_id) { + $isOwner = true; + } + + if (session('completed_checkout_id') === $checkout->id) { + $isOwner = true; + } + + if (! $isOwner) { + abort(403); + } + + $this->checkout = $checkout; + $this->checkout->load('cart.lines.variant.product'); + + $this->order = Order::withoutGlobalScopes() + ->where('store_id', $this->checkout->store_id) + ->where('email', $this->checkout->email) + ->where('customer_id', $this->checkout->customer_id) + ->latest() + ->first(); + } + + public function render(): mixed + { + $bankTransferDetails = null; + + if ($this->order && $this->order->payment_method->value === 'bank_transfer') { + $payment = $this->order->payments()->first(); + if ($payment && $payment->raw_json_encrypted) { + $rawData = is_string($payment->raw_json_encrypted) + ? json_decode($payment->raw_json_encrypted, true) + : $payment->raw_json_encrypted; + $bankTransferDetails = $rawData; + } + + if (! $bankTransferDetails) { + $bankTransferDetails = [ + 'bank_name' => 'Mock Bank AG', + 'iban' => 'DE89 3704 0044 0532 0130 00', + 'bic' => 'COBADEFFXXX', + ]; + } + } + + return view('livewire.storefront.checkout.confirmation', [ + 'checkout' => $this->checkout, + 'order' => $this->order, + 'totals' => $this->checkout->totals_json ?? [], + 'bankTransferDetails' => $bankTransferDetails, + ])->layout('layouts::storefront'); + } +} diff --git a/app/Livewire/Storefront/Checkout/Show.php b/app/Livewire/Storefront/Checkout/Show.php new file mode 100644 index 00000000..a4737e61 --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Show.php @@ -0,0 +1,244 @@ +getCart(); + + if (! $cart || $cart->lines->isEmpty()) { + $this->redirect(route('storefront.cart')); + + return; + } + + $store = app('current_store'); + $checkoutService = app(CheckoutService::class); + + $checkout = Checkout::withoutGlobalScopes() + ->where('cart_id', $cart->id) + ->whereNotIn('status', [CheckoutStatus::Completed->value, CheckoutStatus::Expired->value]) + ->first(); + + if (! $checkout) { + $checkout = $checkoutService->createFromCart($store, $cart); + } + + $this->checkoutId = $checkout->id; + $this->step = $this->stepFromStatus($checkout->status); + + if ($checkout->email) { + $this->email = $checkout->email; + } + + if ($checkout->shipping_address_json) { + $addr = $checkout->shipping_address_json; + $this->firstName = $addr['first_name'] ?? ''; + $this->lastName = $addr['last_name'] ?? ''; + $this->address1 = $addr['address1'] ?? ''; + $this->address2 = $addr['address2'] ?? ''; + $this->city = $addr['city'] ?? ''; + $this->country = $addr['country'] ?? 'DE'; + $this->postalCode = $addr['postal_code'] ?? ''; + $this->provinceCode = $addr['province_code'] ?? ''; + } + } + + public function submitAddress(): void + { + $this->validate([ + 'email' => 'required|email', + 'firstName' => 'required|string', + 'lastName' => 'required|string', + 'address1' => 'required|string', + 'city' => 'required|string', + 'country' => 'required|string|size:2', + 'postalCode' => ['required', 'string', 'regex:/^[a-zA-Z0-9\s\-]{3,10}$/'], + ], [ + 'postalCode.regex' => 'The postal code format is invalid. Use only letters, numbers, spaces, and hyphens (3-10 characters).', + ]); + + $this->error = null; + + try { + $checkoutService = app(CheckoutService::class); + $checkout = $this->getCheckout(); + + $checkoutService->setAddress($checkout, [ + 'email' => $this->email, + 'shipping_address' => [ + 'first_name' => $this->firstName, + 'last_name' => $this->lastName, + 'address1' => $this->address1, + 'address2' => $this->address2, + 'city' => $this->city, + 'country' => $this->country, + 'postal_code' => $this->postalCode, + 'province_code' => $this->provinceCode, + ], + ]); + + $this->step = 'shipping'; + } catch (\Exception $e) { + $this->error = $e->getMessage(); + } + } + + public function submitShipping(): void + { + $this->error = null; + + try { + $checkoutService = app(CheckoutService::class); + $checkout = $this->getCheckout(); + + $checkoutService->setShippingMethod($checkout, $this->selectedShippingRateId); + + $this->step = 'payment'; + } catch (\Exception $e) { + $this->error = $e->getMessage(); + } + } + + public function submitPayment(string $cardNumber = ''): void + { + $this->error = null; + + try { + $checkoutService = app(CheckoutService::class); + $checkout = $this->getCheckout(); + + $checkoutService->selectPaymentMethod($checkout, $this->paymentMethod); + + $paymentMethodData = []; + if ($this->paymentMethod === 'credit_card') { + $paymentMethodData['card_number'] = $cardNumber; + } + + $checkoutService->completeCheckout($checkout->fresh(), $paymentMethodData); + + session()->forget('cart_id'); + + session(['completed_checkout_id' => $checkout->id]); + + $this->redirect(route('storefront.checkout.confirmation', $checkout)); + } catch (\App\Exceptions\PaymentFailedException $e) { + $this->error = 'Payment declined: '.$e->getMessage(); + } catch (\Exception $e) { + $this->error = $e->getMessage(); + } + } + + public function applyDiscount(): void + { + $this->discountError = null; + + try { + $checkoutService = app(CheckoutService::class); + $checkout = $this->getCheckout(); + $checkoutService->applyDiscount($checkout, $this->discountCode); + } catch (\App\Exceptions\InvalidDiscountException $e) { + $this->discountError = $e->getMessage(); + } + } + + public function removeDiscount(): void + { + $checkoutService = app(CheckoutService::class); + $checkout = $this->getCheckout(); + $checkoutService->removeDiscount($checkout); + $this->discountCode = ''; + } + + private function getCart(): ?Cart + { + $cartId = session('cart_id'); + + if (! $cartId) { + return null; + } + + return Cart::withoutGlobalScopes() + ->with('lines.variant.product') + ->find($cartId); + } + + private function getCheckout(): Checkout + { + return Checkout::withoutGlobalScopes()->findOrFail($this->checkoutId); + } + + private function stepFromStatus(CheckoutStatus $status): string + { + return match ($status) { + CheckoutStatus::Started => 'contact', + CheckoutStatus::Addressed => 'shipping', + CheckoutStatus::ShippingSelected => 'payment', + default => 'contact', + }; + } + + public function render(): mixed + { + $checkout = $this->getCheckout(); + $store = app('current_store'); + + $availableRates = collect(); + + if ($checkout->shipping_address_json) { + $shippingCalculator = app(ShippingCalculator::class); + $availableRates = $shippingCalculator->getAvailableRates($store, $checkout->shipping_address_json); + } + + return view('livewire.storefront.checkout.show', [ + 'checkout' => $checkout, + 'totals' => $checkout->totals_json ?? [], + 'availableRates' => $availableRates, + ])->layout('layouts::storefront'); + } +} diff --git a/app/Livewire/Storefront/Collections/Index.php b/app/Livewire/Storefront/Collections/Index.php new file mode 100644 index 00000000..f3d961b0 --- /dev/null +++ b/app/Livewire/Storefront/Collections/Index.php @@ -0,0 +1,26 @@ + + */ + public function getCollectionsProperty(): \Illuminate\Database\Eloquent\Collection + { + return Collection::query() + ->where('status', CollectionStatus::Active) + ->get(); + } + + public function render(): mixed + { + return view('livewire.storefront.collections.index') + ->layout('layouts::storefront'); + } +} diff --git a/app/Livewire/Storefront/Collections/Show.php b/app/Livewire/Storefront/Collections/Show.php new file mode 100644 index 00000000..813ee06d --- /dev/null +++ b/app/Livewire/Storefront/Collections/Show.php @@ -0,0 +1,91 @@ +collection = Collection::query() + ->where('handle', $handle) + ->where('status', CollectionStatus::Active) + ->firstOrFail(); + } + + public function updatedSort(): void + { + $this->resetPage(); + } + + public function updatedAvailability(): void + { + $this->resetPage(); + } + + public function updatedProductType(): void + { + $this->resetPage(); + } + + public function updatedVendor(): void + { + $this->resetPage(); + } + + public function clearFilters(): void + { + $this->reset(['availability', 'productType', 'vendor', 'priceMin', 'priceMax']); + $this->resetPage(); + } + + public function render(): mixed + { + $query = $this->collection->products()->where('products.status', 'active'); + + if ($this->productType) { + $query->where('products.product_type', $this->productType); + } + + if ($this->vendor) { + $query->where('products.vendor', $this->vendor); + } + + $query = match ($this->sort) { + 'price-asc' => $query->join('product_variants', 'products.id', '=', 'product_variants.product_id') + ->orderBy('product_variants.price_amount', 'asc') + ->select('products.*') + ->distinct(), + 'price-desc' => $query->join('product_variants', 'products.id', '=', 'product_variants.product_id') + ->orderBy('product_variants.price_amount', 'desc') + ->select('products.*') + ->distinct(), + 'newest' => $query->orderBy('products.created_at', 'desc'), + default => $query->orderBy('collection_products.position'), + }; + + return view('livewire.storefront.collections.show', [ + 'products' => $query->paginate(24), + ])->layout('layouts::storefront'); + } +} diff --git a/app/Livewire/Storefront/Home.php b/app/Livewire/Storefront/Home.php new file mode 100644 index 00000000..5c94f74d --- /dev/null +++ b/app/Livewire/Storefront/Home.php @@ -0,0 +1,59 @@ + */ + public array $themeSettings = []; + + /** @var array */ + public array $sectionOrder = []; + + public function mount(): void + { + $store = app('current_store'); + $service = app(ThemeSettingsService::class); + $this->themeSettings = $service->getSettings($store); + $this->sectionOrder = $this->themeSettings['section_order'] ?? []; + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getFeaturedCollectionsProperty(): \Illuminate\Database\Eloquent\Collection + { + $ids = $this->themeSettings['sections']['featured_collections']['collection_ids'] ?? []; + + if (empty($ids)) { + return Collection::query()->limit(4)->get(); + } + + return Collection::query()->whereIn('id', $ids)->get(); + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getFeaturedProductsProperty(): \Illuminate\Database\Eloquent\Collection + { + $ids = $this->themeSettings['sections']['featured_products']['product_ids'] ?? []; + + if (empty($ids)) { + return Product::query()->where('status', 'active')->limit(8)->get(); + } + + return Product::query()->whereIn('id', $ids)->get(); + } + + public function render(): mixed + { + return view('livewire.storefront.home') + ->layout('layouts::storefront'); + } +} diff --git a/app/Livewire/Storefront/Pages/Show.php b/app/Livewire/Storefront/Pages/Show.php new file mode 100644 index 00000000..dbda82dd --- /dev/null +++ b/app/Livewire/Storefront/Pages/Show.php @@ -0,0 +1,26 @@ +page = Page::query() + ->where('handle', $handle) + ->where('status', PageStatus::Published) + ->firstOrFail(); + } + + public function render(): mixed + { + return view('livewire.storefront.pages.show') + ->layout('layouts::storefront'); + } +} diff --git a/app/Livewire/Storefront/Products/Show.php b/app/Livewire/Storefront/Products/Show.php new file mode 100644 index 00000000..885dfba0 --- /dev/null +++ b/app/Livewire/Storefront/Products/Show.php @@ -0,0 +1,111 @@ +product = Product::query() + ->where('handle', $handle) + ->where('status', 'active') + ->with(['variants.optionValues', 'variants.inventoryItem', 'options.values', 'media']) + ->firstOrFail(); + + $defaultVariant = $this->product->variants->first(); + + if ($defaultVariant) { + $this->selectedVariantId = $defaultVariant->id; + } + } + + public function getSelectedVariantProperty(): ?ProductVariant + { + if (! $this->selectedVariantId) { + return null; + } + + return $this->product->variants->firstWhere('id', $this->selectedVariantId); + } + + public function selectVariant(int $variantId): void + { + $this->selectedVariantId = $variantId; + $this->quantity = 1; + } + + public function getIsSoldOutProperty(): bool + { + $variant = $this->selectedVariant; + + if (! $variant || ! $variant->inventoryItem) { + return false; + } + + $inventory = $variant->inventoryItem; + $available = $inventory->quantity_on_hand - $inventory->quantity_reserved; + + return $available <= 0 && $inventory->policy === InventoryPolicy::Deny; + } + + public function getIsBackorderProperty(): bool + { + $variant = $this->selectedVariant; + + if (! $variant || ! $variant->inventoryItem) { + return false; + } + + $inventory = $variant->inventoryItem; + $available = $inventory->quantity_on_hand - $inventory->quantity_reserved; + + return $available <= 0 && $inventory->policy === InventoryPolicy::Continue; + } + + public function addToCart(): void + { + $this->cartError = null; + + if (! $this->selectedVariantId) { + return; + } + + if ($this->quantity < 1) { + $this->cartError = 'Quantity must be at least 1.'; + + return; + } + + try { + $store = app('current_store'); + $cartService = app(CartService::class); + $cart = $cartService->getOrCreateForSession($store); + $cartService->addLine($cart, $this->selectedVariantId, $this->quantity); + $this->dispatch('cart-updated'); + } catch (\App\Exceptions\InsufficientInventoryException $e) { + $this->cartError = 'Not enough stock available.'; + } catch (\App\Exceptions\InvalidCartException $e) { + $this->cartError = $e->getMessage(); + } + } + + public function render(): mixed + { + return view('livewire.storefront.products.show') + ->layout('layouts::storefront'); + } +} diff --git a/app/Livewire/Storefront/Search/Index.php b/app/Livewire/Storefront/Search/Index.php new file mode 100644 index 00000000..b1143a77 --- /dev/null +++ b/app/Livewire/Storefront/Search/Index.php @@ -0,0 +1,86 @@ +query = request()->query('q', ''); + } + + public function updatedSort(): void + { + $this->resetPage(); + } + + public function updatedVendor(): void + { + $this->resetPage(); + } + + public function clearFilters(): void + { + $this->reset(['vendor', 'priceMin', 'priceMax']); + $this->resetPage(); + } + + public function render(): mixed + { + $results = null; + $vendors = collect(); + + if ($this->query && app()->bound('current_store')) { + $store = app('current_store'); + + if ($store instanceof Store) { + $service = app(SearchService::class); + + $filters = array_filter([ + 'vendor' => $this->vendor ?: null, + 'price_min' => $this->priceMin, + 'price_max' => $this->priceMax, + 'sort' => $this->sort, + ]); + + $results = $service->search($store, $this->query, $filters, 24); + + // Get vendors from all matching products (without vendor filter) + $vendors = Product::query() + ->withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('status', 'active') + ->whereIn('id', $results->pluck('id')) + ->whereNotNull('vendor') + ->where('vendor', '!=', '') + ->distinct() + ->pluck('vendor') + ->sort() + ->values(); + } + } + + return view('livewire.storefront.search.index', [ + 'results' => $results, + 'vendors' => $vendors, + ])->layout('layouts::storefront'); + } +} diff --git a/app/Livewire/Storefront/Search/Modal.php b/app/Livewire/Storefront/Search/Modal.php new file mode 100644 index 00000000..7d3560fa --- /dev/null +++ b/app/Livewire/Storefront/Search/Modal.php @@ -0,0 +1,50 @@ +open = true; + } + + public function closeModal(): void + { + $this->open = false; + $this->query = ''; + } + + public function search(): void + { + if ($this->query) { + $this->redirect('/search?q='.urlencode($this->query)); + } + } + + public function render(): mixed + { + $suggestions = collect(); + + if (mb_strlen($this->query) >= 2 && app()->bound('current_store')) { + $store = app('current_store'); + if ($store instanceof Store) { + $suggestions = app(SearchService::class)->autocomplete($store, $this->query, 5); + } + } + + return view('livewire.storefront.search.modal', [ + 'suggestions' => $suggestions, + ]); + } +} diff --git a/app/Models/AnalyticsDaily.php b/app/Models/AnalyticsDaily.php new file mode 100644 index 00000000..c54cce77 --- /dev/null +++ b/app/Models/AnalyticsDaily.php @@ -0,0 +1,54 @@ + 'integer', + 'revenue_amount' => 'integer', + 'aov_amount' => 'integer', + 'visits_count' => 'integer', + 'add_to_cart_count' => 'integer', + 'checkout_started_count' => 'integer', + 'checkout_completed_count' => 'integer', + ]; + } + + /** + * Override getKeyForSaveQuery for composite primary key support. + */ + protected function setKeysForSaveQuery($query) + { + return $query + ->where('store_id', $this->getAttribute('store_id')) + ->where('date', $this->getAttribute('date')); + } +} diff --git a/app/Models/AnalyticsEvent.php b/app/Models/AnalyticsEvent.php new file mode 100644 index 00000000..b5e7c882 --- /dev/null +++ b/app/Models/AnalyticsEvent.php @@ -0,0 +1,40 @@ + 'array', + ]; + } + + 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..ed4f6192 --- /dev/null +++ b/app/Models/App.php @@ -0,0 +1,38 @@ + AppStatus::class, + ]; + } + + public function installations(): HasMany + { + return $this->hasMany(AppInstallation::class); + } + + public function oauthClients(): HasMany + { + return $this->hasMany(OauthClient::class); + } +} diff --git a/app/Models/AppInstallation.php b/app/Models/AppInstallation.php new file mode 100644 index 00000000..897c3807 --- /dev/null +++ b/app/Models/AppInstallation.php @@ -0,0 +1,52 @@ + 'array', + 'status' => AppInstallationStatus::class, + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function app(): BelongsTo + { + return $this->belongsTo(App::class); + } + + public function oauthTokens(): HasMany + { + return $this->hasMany(OauthToken::class, 'installation_id'); + } + + public function webhookSubscriptions(): HasMany + { + return $this->hasMany(WebhookSubscription::class); + } +} diff --git a/app/Models/Cart.php b/app/Models/Cart.php new file mode 100644 index 00000000..a62f51e0 --- /dev/null +++ b/app/Models/Cart.php @@ -0,0 +1,46 @@ + 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); + } +} diff --git a/app/Models/CartLine.php b/app/Models/CartLine.php new file mode 100644 index 00000000..d0ddaeab --- /dev/null +++ b/app/Models/CartLine.php @@ -0,0 +1,45 @@ + '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..52485993 --- /dev/null +++ b/app/Models/Checkout.php @@ -0,0 +1,58 @@ + CheckoutStatus::class, + 'payment_method' => PaymentMethod::class, + 'shipping_address_json' => 'array', + 'billing_address_json' => 'array', + 'tax_provider_snapshot_json' => 'array', + 'totals_json' => 'array', + ]; + } + + 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..bf4569a1 --- /dev/null +++ b/app/Models/Collection.php @@ -0,0 +1,38 @@ + CollectionStatus::class, + 'type' => CollectionType::class, + ]; + } + + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'collection_products') + ->withPivot('position'); + } +} diff --git a/app/Models/Concerns/BelongsToStore.php b/app/Models/Concerns/BelongsToStore.php new file mode 100644 index 00000000..858f5810 --- /dev/null +++ b/app/Models/Concerns/BelongsToStore.php @@ -0,0 +1,30 @@ +store_id && app()->bound('current_store')) { + $store = app('current_store'); + + if ($store instanceof Store) { + $model->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..0c95b945 --- /dev/null +++ b/app/Models/Customer.php @@ -0,0 +1,44 @@ + 'boolean', + 'password' => 'hashed', + ]; + } + + public function addresses(): HasMany + { + return $this->hasMany(CustomerAddress::class); + } + + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } +} diff --git a/app/Models/CustomerAddress.php b/app/Models/CustomerAddress.php new file mode 100644 index 00000000..cb4ba568 --- /dev/null +++ b/app/Models/CustomerAddress.php @@ -0,0 +1,34 @@ + '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..f377c6f2 --- /dev/null +++ b/app/Models/Discount.php @@ -0,0 +1,44 @@ + DiscountType::class, + 'value_type' => DiscountValueType::class, + 'status' => DiscountStatus::class, + 'value_amount' => 'integer', + 'usage_limit' => 'integer', + 'usage_count' => 'integer', + 'minimum_purchase_amount' => 'integer', + 'rules_json' => 'array', + ]; + } +} diff --git a/app/Models/Fulfillment.php b/app/Models/Fulfillment.php new file mode 100644 index 00000000..d1d5e2cc --- /dev/null +++ b/app/Models/Fulfillment.php @@ -0,0 +1,44 @@ + FulfillmentShipmentStatus::class, + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function fulfillmentLines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } +} diff --git a/app/Models/FulfillmentLine.php b/app/Models/FulfillmentLine.php new file mode 100644 index 00000000..06d7632a --- /dev/null +++ b/app/Models/FulfillmentLine.php @@ -0,0 +1,37 @@ + '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..b7a1bcca --- /dev/null +++ b/app/Models/InventoryItem.php @@ -0,0 +1,38 @@ + InventoryPolicy::class, + 'quantity_on_hand' => 'integer', + 'quantity_reserved' => 'integer', + ]; + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } +} diff --git a/app/Models/NavigationItem.php b/app/Models/NavigationItem.php new file mode 100644 index 00000000..c8bb229f --- /dev/null +++ b/app/Models/NavigationItem.php @@ -0,0 +1,36 @@ + NavigationItemType::class, + ]; + } + + 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..67fa3a5c --- /dev/null +++ b/app/Models/NavigationMenu.php @@ -0,0 +1,24 @@ +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..492a057b --- /dev/null +++ b/app/Models/OauthClient.php @@ -0,0 +1,34 @@ + 'array', + 'client_secret_encrypted' => 'encrypted', + ]; + } + + 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..4490480f --- /dev/null +++ b/app/Models/OauthToken.php @@ -0,0 +1,26 @@ +belongsTo(AppInstallation::class, 'installation_id'); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 00000000..13875132 --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,80 @@ + PaymentMethod::class, + 'status' => OrderStatus::class, + 'financial_status' => FinancialStatus::class, + 'fulfillment_status' => FulfillmentStatus::class, + 'subtotal_amount' => 'integer', + 'discount_amount' => 'integer', + 'shipping_amount' => 'integer', + 'tax_amount' => 'integer', + 'total_amount' => 'integer', + 'billing_address_json' => 'array', + 'shipping_address_json' => 'array', + ]; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::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); + } +} diff --git a/app/Models/OrderLine.php b/app/Models/OrderLine.php new file mode 100644 index 00000000..0091ca34 --- /dev/null +++ b/app/Models/OrderLine.php @@ -0,0 +1,59 @@ + '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); + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php new file mode 100644 index 00000000..7adc1950 --- /dev/null +++ b/app/Models/Organization.php @@ -0,0 +1,22 @@ +hasMany(Store::class); + } +} diff --git a/app/Models/Page.php b/app/Models/Page.php new file mode 100644 index 00000000..b2d6da4e --- /dev/null +++ b/app/Models/Page.php @@ -0,0 +1,29 @@ + PageStatus::class, + ]; + } +} diff --git a/app/Models/Payment.php b/app/Models/Payment.php new file mode 100644 index 00000000..30c379ed --- /dev/null +++ b/app/Models/Payment.php @@ -0,0 +1,43 @@ + PaymentMethod::class, + 'status' => PaymentStatus::class, + 'amount' => 'integer', + 'raw_json_encrypted' => 'encrypted', + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 00000000..7c2b66e9 --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,56 @@ + ProductStatus::class, + 'tags' => 'array', + ]; + } + + 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'); + } +} diff --git a/app/Models/ProductMedia.php b/app/Models/ProductMedia.php new file mode 100644 index 00000000..d096074e --- /dev/null +++ b/app/Models/ProductMedia.php @@ -0,0 +1,51 @@ + MediaType::class, + 'status' => MediaStatus::class, + 'width' => 'integer', + 'height' => 'integer', + 'byte_size' => 'integer', + ]; + } + + 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..8b06bc0c --- /dev/null +++ b/app/Models/ProductOption.php @@ -0,0 +1,31 @@ +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..6bd54a1d --- /dev/null +++ b/app/Models/ProductOptionValue.php @@ -0,0 +1,25 @@ +belongsTo(ProductOption::class); + } +} diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php new file mode 100644 index 00000000..71d297f6 --- /dev/null +++ b/app/Models/ProductVariant.php @@ -0,0 +1,65 @@ + VariantStatus::class, + 'price_amount' => 'integer', + 'compare_at_amount' => 'integer', + 'requires_shipping' => 'boolean', + 'is_default' => 'boolean', + ]; + } + + protected function title(): Attribute + { + return Attribute::make( + get: fn () => $this->optionValues + ->pluck('value') + ->implode(' / ') ?: 'Default', + ); + } + + 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..f3cf409f --- /dev/null +++ b/app/Models/Refund.php @@ -0,0 +1,43 @@ + 'integer', + 'status' => RefundStatus::class, + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function payment(): BelongsTo + { + return $this->belongsTo(Payment::class); + } +} diff --git a/app/Models/Scopes/StoreScope.php b/app/Models/Scopes/StoreScope.php new file mode 100644 index 00000000..2d74df76 --- /dev/null +++ b/app/Models/Scopes/StoreScope.php @@ -0,0 +1,22 @@ +bound('current_store')) { + $store = app('current_store'); + + if ($store instanceof Store) { + $builder->where($model->getTable().'.store_id', $store->id); + } + } + } +} diff --git a/app/Models/SearchQuery.php b/app/Models/SearchQuery.php new file mode 100644 index 00000000..91a485f1 --- /dev/null +++ b/app/Models/SearchQuery.php @@ -0,0 +1,30 @@ + 'array', + 'results_count' => 'integer', + 'created_at' => 'datetime', + ]; + } +} diff --git a/app/Models/SearchSettings.php b/app/Models/SearchSettings.php new file mode 100644 index 00000000..93be6c1e --- /dev/null +++ b/app/Models/SearchSettings.php @@ -0,0 +1,34 @@ + 'array', + 'stop_words_json' => 'array', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/ShippingRate.php b/app/Models/ShippingRate.php new file mode 100644 index 00000000..ac549de9 --- /dev/null +++ b/app/Models/ShippingRate.php @@ -0,0 +1,37 @@ + 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..2cc45513 --- /dev/null +++ b/app/Models/ShippingZone.php @@ -0,0 +1,37 @@ + 'array', + 'regions_json' => 'array', + 'is_active' => 'boolean', + ]; + } + + 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..3b5bf73b --- /dev/null +++ b/app/Models/Store.php @@ -0,0 +1,130 @@ + StoreStatus::class, + ]; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } + + public function domains(): HasMany + { + return $this->hasMany(StoreDomain::class); + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'store_users') + ->using(StoreUser::class) + ->withPivot('role'); + } + + public function settings(): HasOne + { + return $this->hasOne(StoreSettings::class); + } + + public function themes(): HasMany + { + return $this->hasMany(Theme::class); + } + + public function pages(): HasMany + { + return $this->hasMany(Page::class); + } + + public function navigationMenus(): HasMany + { + return $this->hasMany(NavigationMenu::class); + } + + public function carts(): HasMany + { + return $this->hasMany(Cart::class); + } + + public function checkouts(): HasMany + { + return $this->hasMany(Checkout::class); + } + + public function shippingZones(): HasMany + { + return $this->hasMany(ShippingZone::class); + } + + public function taxSettings(): HasOne + { + return $this->hasOne(TaxSettings::class); + } + + public function discounts(): HasMany + { + return $this->hasMany(Discount::class); + } + + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + public function searchSettings(): HasOne + { + return $this->hasOne(SearchSettings::class); + } + + public function searchQueries(): HasMany + { + return $this->hasMany(SearchQuery::class); + } + + public function analyticsEvents(): HasMany + { + return $this->hasMany(AnalyticsEvent::class); + } + + public function analyticsDaily(): HasMany + { + return $this->hasMany(AnalyticsDaily::class); + } + + public function appInstallations(): HasMany + { + return $this->hasMany(AppInstallation::class); + } + + public function webhookSubscriptions(): HasMany + { + return $this->hasMany(WebhookSubscription::class); + } +} diff --git a/app/Models/StoreDomain.php b/app/Models/StoreDomain.php new file mode 100644 index 00000000..73cb6002 --- /dev/null +++ b/app/Models/StoreDomain.php @@ -0,0 +1,44 @@ + StoreDomainType::class, + 'is_primary' => 'boolean', + 'created_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + protected static function booted(): void + { + static::creating(function (StoreDomain $domain): void { + $domain->created_at = now(); + }); + } +} diff --git a/app/Models/StoreSettings.php b/app/Models/StoreSettings.php new file mode 100644 index 00000000..ae1fba4c --- /dev/null +++ b/app/Models/StoreSettings.php @@ -0,0 +1,45 @@ + 'array', + 'updated_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + protected static function booted(): void + { + static::saving(function (StoreSettings $settings): void { + $settings->updated_at = now(); + }); + } +} diff --git a/app/Models/StoreUser.php b/app/Models/StoreUser.php new file mode 100644 index 00000000..97611516 --- /dev/null +++ b/app/Models/StoreUser.php @@ -0,0 +1,28 @@ + StoreUserRole::class, + ]; + } +} diff --git a/app/Models/TaxSettings.php b/app/Models/TaxSettings.php new file mode 100644 index 00000000..c01c6f5c --- /dev/null +++ b/app/Models/TaxSettings.php @@ -0,0 +1,46 @@ + TaxMode::class, + 'rate' => 'integer', + 'prices_include_tax' => 'boolean', + 'is_active' => '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..5f646344 --- /dev/null +++ b/app/Models/Theme.php @@ -0,0 +1,40 @@ + ThemeStatus::class, + ]; + } + + public function files(): HasMany + { + return $this->hasMany(ThemeFile::class); + } + + public function settings(): HasOne + { + return $this->hasOne(ThemeSettings::class); + } +} diff --git a/app/Models/ThemeFile.php b/app/Models/ThemeFile.php new file mode 100644 index 00000000..e88cece5 --- /dev/null +++ b/app/Models/ThemeFile.php @@ -0,0 +1,27 @@ +belongsTo(Theme::class); + } +} diff --git a/app/Models/ThemeSettings.php b/app/Models/ThemeSettings.php new file mode 100644 index 00000000..37cded57 --- /dev/null +++ b/app/Models/ThemeSettings.php @@ -0,0 +1,38 @@ + 'array', + ]; + } + + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 214bea4e..128d22db 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,8 +2,9 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Enums\StoreUserRole; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; @@ -23,6 +24,8 @@ class User extends Authenticatable 'name', 'email', 'password', + 'status', + 'last_login_at', ]; /** @@ -46,10 +49,38 @@ protected function casts(): array { return [ 'email_verified_at' => 'datetime', + 'last_login_at' => 'datetime', 'password' => 'hashed', ]; } + public function stores(): BelongsToMany + { + return $this->belongsToMany(Store::class, 'store_users') + ->using(StoreUser::class) + ->withPivot('role'); + } + + public function roleForStore(Store $store): ?StoreUserRole + { + $pivot = $this->stores() + ->where('stores.id', $store->id) + ->first() + ?->pivot; + + if (! $pivot) { + return null; + } + + $role = $pivot->role; + + if ($role instanceof StoreUserRole) { + return $role; + } + + return StoreUserRole::tryFrom($role); + } + /** * Get the user's initials */ diff --git a/app/Models/WebhookDelivery.php b/app/Models/WebhookDelivery.php new file mode 100644 index 00000000..2f6b3a3d --- /dev/null +++ b/app/Models/WebhookDelivery.php @@ -0,0 +1,39 @@ + WebhookDeliveryStatus::class, + 'attempt_count' => 'integer', + 'response_code' => 'integer', + ]; + } + + public function subscription(): BelongsTo + { + return $this->belongsTo(WebhookSubscription::class); + } +} diff --git a/app/Models/WebhookSubscription.php b/app/Models/WebhookSubscription.php new file mode 100644 index 00000000..4deda8cd --- /dev/null +++ b/app/Models/WebhookSubscription.php @@ -0,0 +1,44 @@ + 'encrypted', + 'status' => WebhookSubscriptionStatus::class, + ]; + } + + public function appInstallation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class); + } + + 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..21765f89 --- /dev/null +++ b/app/Observers/ProductObserver.php @@ -0,0 +1,28 @@ +searchService->syncProduct($product); + } + + public function updated(Product $product): void + { + $this->searchService->syncProduct($product); + } + + public function deleting(Product $product): void + { + $this->searchService->removeProduct($product->id); + } +} diff --git a/app/Policies/CollectionPolicy.php b/app/Policies/CollectionPolicy.php new file mode 100644 index 00000000..9f23d204 --- /dev/null +++ b/app/Policies/CollectionPolicy.php @@ -0,0 +1,41 @@ +id; + } + + public function viewAny(User $user): bool + { + return $this->isAnyRole($user, $this->getStoreId()); + } + + public function view(User $user, $collection): bool + { + return $this->isAnyRole($user, $collection->store_id); + } + + public function create(User $user): bool + { + return $this->isOwnerAdminOrStaff($user, $this->getStoreId()); + } + + public function update(User $user, $collection): bool + { + return $this->isOwnerAdminOrStaff($user, $collection->store_id); + } + + public function delete(User $user, $collection): bool + { + return $this->isOwnerOrAdmin($user, $collection->store_id); + } +} diff --git a/app/Policies/CustomerPolicy.php b/app/Policies/CustomerPolicy.php new file mode 100644 index 00000000..81f3720f --- /dev/null +++ b/app/Policies/CustomerPolicy.php @@ -0,0 +1,31 @@ +id; + } + + public function viewAny(User $user): bool + { + return $this->isAnyRole($user, $this->getStoreId()); + } + + public function view(User $user, $customer): bool + { + return $this->isAnyRole($user, $customer->store_id); + } + + public function update(User $user, $customer): bool + { + return $this->isOwnerAdminOrStaff($user, $customer->store_id); + } +} diff --git a/app/Policies/DiscountPolicy.php b/app/Policies/DiscountPolicy.php new file mode 100644 index 00000000..7b2165ce --- /dev/null +++ b/app/Policies/DiscountPolicy.php @@ -0,0 +1,41 @@ +id; + } + + public function viewAny(User $user): bool + { + return $this->isAnyRole($user, $this->getStoreId()); + } + + public function view(User $user, $discount): bool + { + return $this->isAnyRole($user, $discount->store_id); + } + + public function create(User $user): bool + { + return $this->isOwnerAdminOrStaff($user, $this->getStoreId()); + } + + public function update(User $user, $discount): bool + { + return $this->isOwnerAdminOrStaff($user, $discount->store_id); + } + + public function delete(User $user, $discount): bool + { + return $this->isOwnerOrAdmin($user, $discount->store_id); + } +} diff --git a/app/Policies/FulfillmentPolicy.php b/app/Policies/FulfillmentPolicy.php new file mode 100644 index 00000000..fa74e270 --- /dev/null +++ b/app/Policies/FulfillmentPolicy.php @@ -0,0 +1,26 @@ +isOwnerAdminOrStaff($user, $order->store_id); + } + + public function update(User $user, $fulfillment): bool + { + return $this->isOwnerAdminOrStaff($user, $fulfillment->order->store_id); + } + + public function cancel(User $user, $fulfillment): bool + { + return $this->isOwnerAdminOrStaff($user, $fulfillment->order->store_id); + } +} diff --git a/app/Policies/NavigationMenuPolicy.php b/app/Policies/NavigationMenuPolicy.php new file mode 100644 index 00000000..3983aad6 --- /dev/null +++ b/app/Policies/NavigationMenuPolicy.php @@ -0,0 +1,26 @@ +id; + } + + public function viewAny(User $user): bool + { + return $this->isOwnerAdminOrStaff($user, $this->getStoreId()); + } + + public function manage(User $user): bool + { + return $this->isOwnerOrAdmin($user, $this->getStoreId()); + } +} diff --git a/app/Policies/OrderPolicy.php b/app/Policies/OrderPolicy.php new file mode 100644 index 00000000..0a4c950c --- /dev/null +++ b/app/Policies/OrderPolicy.php @@ -0,0 +1,46 @@ +id; + } + + public function viewAny(User $user): bool + { + return $this->isAnyRole($user, $this->getStoreId()); + } + + public function view(User $user, $order): bool + { + return $this->isAnyRole($user, $order->store_id); + } + + public function update(User $user, $order): bool + { + return $this->isOwnerAdminOrStaff($user, $order->store_id); + } + + public function cancel(User $user, $order): bool + { + return $this->isOwnerOrAdmin($user, $order->store_id); + } + + public function createFulfillment(User $user, $order): bool + { + return $this->isOwnerAdminOrStaff($user, $order->store_id); + } + + public function createRefund(User $user, $order): bool + { + return $this->isOwnerOrAdmin($user, $order->store_id); + } +} diff --git a/app/Policies/PagePolicy.php b/app/Policies/PagePolicy.php new file mode 100644 index 00000000..b8d61cc3 --- /dev/null +++ b/app/Policies/PagePolicy.php @@ -0,0 +1,41 @@ +id; + } + + public function viewAny(User $user): bool + { + return $this->isOwnerAdminOrStaff($user, $this->getStoreId()); + } + + public function view(User $user, $page): bool + { + return $this->isOwnerAdminOrStaff($user, $page->store_id); + } + + public function create(User $user): bool + { + return $this->isOwnerAdminOrStaff($user, $this->getStoreId()); + } + + public function update(User $user, $page): bool + { + return $this->isOwnerAdminOrStaff($user, $page->store_id); + } + + public function delete(User $user, $page): bool + { + return $this->isOwnerOrAdmin($user, $page->store_id); + } +} diff --git a/app/Policies/ProductPolicy.php b/app/Policies/ProductPolicy.php new file mode 100644 index 00000000..e887fb0a --- /dev/null +++ b/app/Policies/ProductPolicy.php @@ -0,0 +1,51 @@ +id; + } + + public function viewAny(User $user): bool + { + return $this->isAnyRole($user, $this->getStoreId()); + } + + public function view(User $user, $product): bool + { + return $this->isAnyRole($user, $product->store_id); + } + + public function create(User $user): bool + { + return $this->isOwnerAdminOrStaff($user, $this->getStoreId()); + } + + public function update(User $user, $product): bool + { + return $this->isOwnerAdminOrStaff($user, $product->store_id); + } + + public function delete(User $user, $product): bool + { + return $this->isOwnerOrAdmin($user, $product->store_id); + } + + public function archive(User $user, $product): bool + { + return $this->isOwnerOrAdmin($user, $product->store_id); + } + + public function restore(User $user, $product): bool + { + return $this->isOwnerOrAdmin($user, $product->store_id); + } +} diff --git a/app/Policies/RefundPolicy.php b/app/Policies/RefundPolicy.php new file mode 100644 index 00000000..e89f256b --- /dev/null +++ b/app/Policies/RefundPolicy.php @@ -0,0 +1,16 @@ +isOwnerOrAdmin($user, $order->store_id); + } +} diff --git a/app/Policies/StorePolicy.php b/app/Policies/StorePolicy.php new file mode 100644 index 00000000..eb79584c --- /dev/null +++ b/app/Policies/StorePolicy.php @@ -0,0 +1,28 @@ +isOwnerOrAdmin($user, $store->id); + } + + public function updateSettings(User $user, Store $store): bool + { + return $this->isOwnerOrAdmin($user, $store->id); + } + + public function delete(User $user, Store $store): bool + { + return $this->hasRole($user, $store->id, [StoreUserRole::Owner]); + } +} diff --git a/app/Policies/ThemePolicy.php b/app/Policies/ThemePolicy.php new file mode 100644 index 00000000..8abf36a0 --- /dev/null +++ b/app/Policies/ThemePolicy.php @@ -0,0 +1,46 @@ +id; + } + + public function viewAny(User $user): bool + { + return $this->isOwnerOrAdmin($user, $this->getStoreId()); + } + + public function view(User $user, $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } + + public function create(User $user): bool + { + return $this->isOwnerOrAdmin($user, $this->getStoreId()); + } + + public function update(User $user, $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } + + public function delete(User $user, $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } + + public function publish(User $user, $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8a29e6f5..78ca66a6 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,11 +2,31 @@ namespace App\Providers; +use App\Auth\CustomerUserProvider; +use App\Contracts\PaymentProvider; +use App\Enums\StoreUserRole; +use App\Http\Middleware\ResolveStore; +use App\Models\Product; +use App\Models\Store; +use App\Models\StoreDomain; +use App\Models\User; +use App\Observers\ProductObserver; +use App\Services\NavigationService; +use App\Services\Payment\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\Cache; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; +use Livewire\Livewire; class AppServiceProvider extends ServiceProvider { @@ -15,7 +35,31 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->singleton(ThemeSettingsService::class); + $this->app->singleton(NavigationService::class); + $this->app->bind(PaymentProvider::class, MockPaymentProvider::class); + + $this->app->singleton('current_store', function () { + $hostname = request()->getHost(); + + $storeId = Cache::remember( + "store_domain:{$hostname}", + 300, + fn () => StoreDomain::where('hostname', $hostname)->value('store_id') + ); + + if (! $storeId) { + return null; + } + + $store = Store::find($storeId); + + if ($store) { + View::share('currentStore', $store); + } + + return $store; + }); } /** @@ -24,6 +68,12 @@ public function register(): void public function boot(): void { $this->configureDefaults(); + $this->configureAuth(); + $this->configureRateLimiting(); + $this->configureGates(); + $this->configureLivewire(); + + Product::observe(ProductObserver::class); } /** @@ -47,4 +97,85 @@ protected function configureDefaults(): void : null ); } + + /** + * Configure custom auth providers. + */ + protected function configureAuth(): void + { + Auth::provider('customer', function ($app, array $config) { + return new CustomerUserProvider( + $app['hash'], + $config['model'], + ); + }); + } + + /** + * Configure rate limiting. + */ + protected function configureRateLimiting(): void + { + RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by($request->ip()); + }); + } + + /** + * Configure authorization gates. + */ + protected function configureGates(): void + { + $ownerOrAdminGates = [ + 'manage-store-settings', + 'manage-staff', + 'manage-developers', + 'manage-shipping', + 'manage-taxes', + 'manage-search-settings', + 'manage-navigation', + ]; + + foreach ($ownerOrAdminGates as $gate) { + Gate::define($gate, function (User $user) { + if (! app()->bound('current_store')) { + return false; + } + + $store = app('current_store'); + if (! $store instanceof Store) { + return false; + } + + $role = $user->roleForStore($store); + + return $role !== null && in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin], true); + }); + } + + Gate::define('view-analytics', function (User $user) { + if (! app()->bound('current_store')) { + return false; + } + + $store = app('current_store'); + if (! $store instanceof Store) { + return false; + } + + $role = $user->roleForStore($store); + + return $role !== null && in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff], true); + }); + } + + /** + * Configure Livewire persistent middleware. + */ + protected function configureLivewire(): void + { + Livewire::addPersistentMiddleware([ + ResolveStore::class, + ]); + } } diff --git a/app/Services/AnalyticsService.php b/app/Services/AnalyticsService.php new file mode 100644 index 00000000..7d86ad42 --- /dev/null +++ b/app/Services/AnalyticsService.php @@ -0,0 +1,38 @@ +create([ + 'store_id' => $store->id, + 'type' => $type, + 'properties_json' => $properties, + 'session_id' => $sessionId, + 'customer_id' => $customerId, + 'created_at' => now()->toIso8601String(), + ]); + } + + public function getDailyMetrics(Store $store, string $startDate, string $endDate): Collection + { + return AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('date', '>=', $startDate) + ->where('date', '<=', $endDate) + ->orderBy('date') + ->get(); + } +} diff --git a/app/Services/CartService.php b/app/Services/CartService.php new file mode 100644 index 00000000..7750ad43 --- /dev/null +++ b/app/Services/CartService.php @@ -0,0 +1,204 @@ + $store->id, + 'customer_id' => $customer?->id, + 'currency' => $store->default_currency, + 'cart_version' => 1, + 'status' => CartStatus::Active, + ]); + } + + public function addLine(Cart $cart, int $variantId, int $quantity): CartLine + { + if ($quantity <= 0) { + throw new InvalidCartException('Quantity must be greater than zero.'); + } + + return DB::transaction(function () use ($cart, $variantId, $quantity) { + $variant = ProductVariant::with(['product', 'inventoryItem'])->find($variantId); + + if (! $variant) { + throw new InvalidCartException('Variant not found.'); + } + + if ($variant->product->store_id !== $cart->store_id) { + throw new InvalidCartException('Variant does not belong to this store.'); + } + + if ($variant->product->status !== ProductStatus::Active) { + throw new InvalidCartException('Product is not active.'); + } + + if ($variant->status !== VariantStatus::Active) { + throw new InvalidCartException('Variant is not active.'); + } + + $existingLine = $cart->lines()->where('variant_id', $variantId)->first(); + $totalQuantity = $existingLine ? $existingLine->quantity + $quantity : $quantity; + + if ($variant->inventoryItem) { + if (! $this->inventoryService->checkAvailability($variant->inventoryItem, $totalQuantity)) { + throw new InsufficientInventoryException( + requested: $totalQuantity, + available: $variant->inventoryItem->quantity_on_hand - $variant->inventoryItem->quantity_reserved, + ); + } + } + + if ($existingLine) { + $existingLine->quantity = $totalQuantity; + $existingLine->line_subtotal_amount = $existingLine->unit_price_amount * $totalQuantity; + $existingLine->line_total_amount = $existingLine->line_subtotal_amount - $existingLine->line_discount_amount; + $existingLine->save(); + + $cart->increment('cart_version'); + + return $existingLine; + } + + $unitPrice = $variant->price_amount; + $subtotal = $unitPrice * $quantity; + + $line = CartLine::create([ + 'cart_id' => $cart->id, + 'variant_id' => $variantId, + 'quantity' => $quantity, + 'unit_price_amount' => $unitPrice, + 'line_subtotal_amount' => $subtotal, + 'line_discount_amount' => 0, + 'line_total_amount' => $subtotal, + ]); + + $cart->increment('cart_version'); + + return $line; + }); + } + + public function updateLineQuantity(Cart $cart, int $lineId, int $quantity): ?CartLine + { + if ($quantity < 0) { + throw new InvalidCartException('Quantity must not be negative.'); + } + + return DB::transaction(function () use ($cart, $lineId, $quantity) { + $line = $cart->lines()->findOrFail($lineId); + + if ($quantity === 0) { + $this->removeLine($cart, $lineId); + + return null; + } + + $variant = $line->variant()->with('inventoryItem')->first(); + + if ($variant->inventoryItem) { + if (! $this->inventoryService->checkAvailability($variant->inventoryItem, $quantity)) { + throw new InsufficientInventoryException( + requested: $quantity, + available: $variant->inventoryItem->quantity_on_hand - $variant->inventoryItem->quantity_reserved, + ); + } + } + + $line->quantity = $quantity; + $line->line_subtotal_amount = $line->unit_price_amount * $quantity; + $line->line_total_amount = $line->line_subtotal_amount - $line->line_discount_amount; + $line->save(); + + $cart->increment('cart_version'); + + return $line; + }); + } + + public function removeLine(Cart $cart, int $lineId): void + { + DB::transaction(function () use ($cart, $lineId) { + $cart->lines()->where('id', $lineId)->delete(); + $cart->increment('cart_version'); + }); + } + + public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart + { + $cartId = session('cart_id'); + + if ($cartId) { + $cart = Cart::withoutGlobalScopes() + ->where('id', $cartId) + ->where('store_id', $store->id) + ->where('status', CartStatus::Active) + ->first(); + + if ($cart) { + return $cart; + } + } + + $cart = $this->create($store, $customer); + session(['cart_id' => $cart->id]); + + return $cart; + } + + public function mergeOnLogin(Cart $guestCart, Cart $customerCart): Cart + { + return DB::transaction(function () use ($guestCart, $customerCart) { + $guestLines = $guestCart->lines()->with('variant')->get(); + + foreach ($guestLines as $guestLine) { + $existingLine = $customerCart->lines() + ->where('variant_id', $guestLine->variant_id) + ->first(); + + if ($existingLine) { + $existingLine->quantity = max($existingLine->quantity, $guestLine->quantity); + $existingLine->line_subtotal_amount = $existingLine->unit_price_amount * $existingLine->quantity; + $existingLine->line_total_amount = $existingLine->line_subtotal_amount - $existingLine->line_discount_amount; + $existingLine->save(); + } else { + CartLine::create([ + 'cart_id' => $customerCart->id, + 'variant_id' => $guestLine->variant_id, + 'quantity' => $guestLine->quantity, + 'unit_price_amount' => $guestLine->unit_price_amount, + 'line_subtotal_amount' => $guestLine->line_subtotal_amount, + 'line_discount_amount' => 0, + 'line_total_amount' => $guestLine->line_subtotal_amount, + ]); + } + } + + $guestCart->update(['status' => CartStatus::Abandoned]); + + $customerCart->increment('cart_version'); + $customerCart->load('lines'); + + return $customerCart; + }); + } +} diff --git a/app/Services/CheckoutService.php b/app/Services/CheckoutService.php new file mode 100644 index 00000000..df11f16c --- /dev/null +++ b/app/Services/CheckoutService.php @@ -0,0 +1,260 @@ +lines()->count() === 0) { + throw new InvalidCartException('Cannot create checkout from empty cart.'); + } + + $checkout = Checkout::create([ + 'store_id' => $store->id, + 'cart_id' => $cart->id, + 'customer_id' => $cart->customer_id, + 'status' => CheckoutStatus::Started, + ]); + + $this->recalculateTotals($checkout); + + return $checkout->fresh(); + } + + /** + * @param array{email: string, shipping_address: array, billing_address?: array} $addressData + */ + public function setAddress(Checkout $checkout, array $addressData): Checkout + { + $this->assertTransition($checkout, CheckoutStatus::Addressed); + + $shippingAddress = $addressData['shipping_address']; + $billingAddress = $addressData['billing_address'] ?? $shippingAddress; + + $checkout->update([ + 'email' => $addressData['email'], + 'shipping_address_json' => $shippingAddress, + 'billing_address_json' => $billingAddress, + 'status' => CheckoutStatus::Addressed, + ]); + + $this->recalculateTotals($checkout); + + return $checkout->fresh(); + } + + public function setShippingMethod(Checkout $checkout, ?int $shippingRateId): Checkout + { + $this->assertTransition($checkout, CheckoutStatus::ShippingSelected); + + $cart = $checkout->cart()->with('lines.variant')->first(); + $requiresShipping = $cart->lines->contains(fn ($line) => $line->variant->requires_shipping); + + if (! $requiresShipping) { + $checkout->update([ + 'shipping_method_id' => null, + 'status' => CheckoutStatus::ShippingSelected, + ]); + + $this->recalculateTotals($checkout); + + return $checkout->fresh(); + } + + if ($shippingRateId) { + $address = $checkout->shipping_address_json ?? []; + $availableRates = $this->shippingCalculator->getAvailableRates($checkout->store, $address); + $validRate = $availableRates->firstWhere('id', $shippingRateId); + + if (! $validRate) { + throw new InvalidCheckoutTransitionException( + $checkout->status->value, + 'shipping_selected', + 'Selected shipping rate is not available for this address.', + ); + } + } + + $checkout->update([ + 'shipping_method_id' => $shippingRateId, + 'status' => CheckoutStatus::ShippingSelected, + ]); + + $this->recalculateTotals($checkout); + + return $checkout->fresh(); + } + + public function selectPaymentMethod(Checkout $checkout, string $paymentMethod): Checkout + { + $this->assertTransition($checkout, CheckoutStatus::PaymentSelected); + + $method = PaymentMethod::tryFrom($paymentMethod); + + if (! $method) { + throw new InvalidCheckoutTransitionException( + $checkout->status->value, + 'payment_selected', + 'Invalid payment method.', + ); + } + + return DB::transaction(function () use ($checkout, $method) { + // Reserve inventory for all cart lines + $cart = $checkout->cart()->with('lines.variant.inventoryItem')->first(); + + foreach ($cart->lines as $line) { + if ($line->variant->inventoryItem) { + $this->inventoryService->reserve($line->variant->inventoryItem, $line->quantity); + } + } + + $checkout->update([ + 'payment_method' => $method, + 'status' => CheckoutStatus::PaymentSelected, + 'expires_at' => now()->addHours(24)->toIso8601String(), + ]); + + return $checkout->fresh(); + }); + } + + /** + * @param array $paymentMethodData + */ + public function completeCheckout(Checkout $checkout, array $paymentMethodData = []): Order + { + if ($checkout->status === CheckoutStatus::Completed) { + // Idempotency: return the existing order + $existingOrder = Order::withoutGlobalScopes() + ->where('store_id', $checkout->store_id) + ->where('email', $checkout->email) + ->latest() + ->first(); + + if ($existingOrder) { + return $existingOrder; + } + } + + $this->assertTransition($checkout, CheckoutStatus::Completed); + + // Process payment + $paymentResult = $this->paymentProvider->charge($checkout, $paymentMethodData); + + if (! $paymentResult->success) { + // Release reserved inventory on payment failure + $cart = $checkout->cart()->with('lines.variant.inventoryItem')->first(); + foreach ($cart->lines as $line) { + if ($line->variant?->inventoryItem) { + $this->inventoryService->release($line->variant->inventoryItem, $line->quantity); + } + } + + throw new PaymentFailedException( + errorCode: $paymentResult->errorCode ?? 'unknown', + message: $paymentResult->errorMessage ?? 'Payment failed', + ); + } + + // Create order (handles inventory commit, cart conversion, checkout completion, events) + return $this->orderService->createFromCheckout($checkout, $paymentResult); + } + + public function expireCheckout(Checkout $checkout): Checkout + { + if ($checkout->status === CheckoutStatus::Completed || $checkout->status === CheckoutStatus::Expired) { + return $checkout; + } + + return DB::transaction(function () use ($checkout) { + // Release reserved inventory if it was reserved + if ($checkout->status === CheckoutStatus::PaymentSelected) { + $cart = $checkout->cart()->with('lines.variant.inventoryItem')->first(); + + foreach ($cart->lines as $line) { + if ($line->variant->inventoryItem) { + $this->inventoryService->release($line->variant->inventoryItem, $line->quantity); + } + } + } + + $checkout->update(['status' => CheckoutStatus::Expired]); + + return $checkout->fresh(); + }); + } + + public function applyDiscount(Checkout $checkout, string $code): Checkout + { + $cart = $checkout->cart()->with('lines.variant')->first(); + $this->discountService->validate($code, $checkout->store, $cart); + + $checkout->update(['discount_code' => $code]); + $this->recalculateTotals($checkout); + + return $checkout->fresh(); + } + + public function removeDiscount(Checkout $checkout): Checkout + { + $checkout->update(['discount_code' => null]); + $this->recalculateTotals($checkout); + + return $checkout->fresh(); + } + + private function recalculateTotals(Checkout $checkout): void + { + $checkout->refresh(); + $result = $this->pricingEngine->calculate($checkout); + + $checkout->update([ + 'totals_json' => $result->toArray(), + ]); + } + + private function assertTransition(Checkout $checkout, CheckoutStatus $to): void + { + $validTransitions = [ + CheckoutStatus::Started->value => [CheckoutStatus::Addressed->value], + CheckoutStatus::Addressed->value => [ + CheckoutStatus::ShippingSelected->value, + CheckoutStatus::Addressed->value, + ], + CheckoutStatus::ShippingSelected->value => [CheckoutStatus::PaymentSelected->value], + CheckoutStatus::PaymentSelected->value => [CheckoutStatus::Completed->value], + ]; + + $allowed = $validTransitions[$checkout->status->value] ?? []; + + if (! in_array($to->value, $allowed)) { + throw new InvalidCheckoutTransitionException( + $checkout->status->value, + $to->value, + ); + } + } +} diff --git a/app/Services/DiscountService.php b/app/Services/DiscountService.php new file mode 100644 index 00000000..e4a71d71 --- /dev/null +++ b/app/Services/DiscountService.php @@ -0,0 +1,190 @@ +where('store_id', $store->id) + ->whereRaw('LOWER(code) = ?', [strtolower($code)]) + ->first(); + + if (! $discount) { + throw new InvalidDiscountException('discount_not_found', 'Discount code not found.'); + } + + if ($discount->status !== DiscountStatus::Active) { + throw new InvalidDiscountException('discount_expired', 'Discount is not active.'); + } + + if (Carbon::parse($discount->starts_at)->isFuture()) { + throw new InvalidDiscountException('discount_not_yet_active', 'Discount is not yet active.'); + } + + if ($discount->ends_at && Carbon::parse($discount->ends_at)->isPast()) { + throw new InvalidDiscountException('discount_expired', 'Discount has expired.'); + } + + if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) { + throw new InvalidDiscountException('discount_usage_limit_reached', 'Discount usage limit reached.'); + } + + $subtotal = $this->getCartSubtotal($cart); + + if ($discount->minimum_purchase_amount !== null && $subtotal < $discount->minimum_purchase_amount) { + throw new InvalidDiscountException('discount_min_purchase_not_met', 'Minimum purchase amount not met.'); + } + + $rules = $discount->rules_json ?? []; + $minPurchase = $rules['min_purchase_amount'] ?? null; + + if ($minPurchase !== null && $subtotal < $minPurchase) { + throw new InvalidDiscountException('discount_min_purchase_not_met', 'Minimum purchase amount not met.'); + } + + $productIds = $rules['applicable_product_ids'] ?? null; + $collectionIds = $rules['applicable_collection_ids'] ?? null; + + if ($this->hasProductRestrictions($productIds, $collectionIds)) { + $qualifyingLines = $this->getQualifyingLines($cart, $productIds, $collectionIds); + + if ($qualifyingLines->isEmpty()) { + throw new InvalidDiscountException('discount_not_applicable', 'No qualifying products in cart.'); + } + } + + return $discount; + } + + /** + * @param Collection $lines + */ + public function calculate(Discount $discount, int $subtotal, Collection $lines): DiscountResult + { + if ($discount->value_type === DiscountValueType::FreeShipping) { + return new DiscountResult( + totalDiscount: 0, + lineAllocations: [], + isFreeShipping: true, + ); + } + + $rules = $discount->rules_json ?? []; + $productIds = $rules['applicable_product_ids'] ?? null; + $collectionIds = $rules['applicable_collection_ids'] ?? null; + + if ($this->hasProductRestrictions($productIds, $collectionIds)) { + $qualifyingLines = $lines->filter(function ($line) use ($productIds, $collectionIds) { + return $this->lineQualifies($line, $productIds, $collectionIds); + }); + } else { + $qualifyingLines = $lines; + } + + $qualifyingSubtotal = $qualifyingLines->sum('line_subtotal_amount'); + + if ($qualifyingSubtotal <= 0) { + return new DiscountResult(totalDiscount: 0, lineAllocations: []); + } + + $totalDiscount = $this->calculateTotalDiscount($discount, $qualifyingSubtotal); + + $allocations = $this->allocateProportionally($totalDiscount, $qualifyingLines, $qualifyingSubtotal); + + return new DiscountResult( + totalDiscount: $totalDiscount, + lineAllocations: $allocations, + ); + } + + private function calculateTotalDiscount(Discount $discount, int $qualifyingSubtotal): int + { + return match ($discount->value_type) { + DiscountValueType::Percent => (int) round($qualifyingSubtotal * $discount->value_amount / 100), + DiscountValueType::Fixed => min($discount->value_amount, $qualifyingSubtotal), + default => 0, + }; + } + + /** + * @return array + */ + private function allocateProportionally(int $totalDiscount, Collection $qualifyingLines, int $qualifyingSubtotal): array + { + $allocations = []; + $remaining = $totalDiscount; + $lineCount = $qualifyingLines->count(); + $index = 0; + + foreach ($qualifyingLines as $line) { + $index++; + + if ($index === $lineCount) { + $allocations[$line->id] = $remaining; + } else { + $allocation = (int) round($totalDiscount * $line->line_subtotal_amount / $qualifyingSubtotal); + $allocations[$line->id] = $allocation; + $remaining -= $allocation; + } + } + + return $allocations; + } + + private function getCartSubtotal(Cart $cart): int + { + return $cart->lines()->sum('line_subtotal_amount'); + } + + /** + * @param array|null $productIds + * @param array|null $collectionIds + */ + private function hasProductRestrictions(?array $productIds, ?array $collectionIds): bool + { + return (! empty($productIds)) || (! empty($collectionIds)); + } + + /** + * @param array|null $productIds + * @param array|null $collectionIds + */ + private function getQualifyingLines(Cart $cart, ?array $productIds, ?array $collectionIds): Collection + { + return $cart->lines()->with('variant.product.collections')->get() + ->filter(fn ($line) => $this->lineQualifies($line, $productIds, $collectionIds)); + } + + /** + * @param array|null $productIds + * @param array|null $collectionIds + */ + private function lineQualifies(mixed $line, ?array $productIds, ?array $collectionIds): bool + { + $productId = $line->variant->product_id; + + if (! empty($productIds) && in_array($productId, $productIds)) { + return true; + } + + if (! empty($collectionIds)) { + $lineCollectionIds = $line->variant->product->collections->pluck('id')->toArray(); + + return ! empty(array_intersect($lineCollectionIds, $collectionIds)); + } + + return false; + } +} diff --git a/app/Services/FulfillmentService.php b/app/Services/FulfillmentService.php new file mode 100644 index 00000000..27b50a44 --- /dev/null +++ b/app/Services/FulfillmentService.php @@ -0,0 +1,147 @@ + $lines Map of order_line_id => quantity + * @param array{tracking_company?: string|null, tracking_number?: string|null, tracking_url?: string|null} $trackingData + */ + public function create(Order $order, array $lines, array $trackingData = []): Fulfillment + { + $this->guardFinancialStatus($order); + + return DB::transaction(function () use ($order, $lines, $trackingData) { + // Validate each line + $order->load('lines.fulfillmentLines'); + + foreach ($lines as $orderLineId => $quantity) { + $orderLine = $order->lines->firstWhere('id', $orderLineId); + + if (! $orderLine) { + throw new RuntimeException("Order line {$orderLineId} does not belong to this order."); + } + + $fulfilledSoFar = $orderLine->fulfillmentLines->sum('quantity'); + $unfulfilled = $orderLine->quantity - $fulfilledSoFar; + + if ($quantity > $unfulfilled) { + throw new RuntimeException( + "Cannot fulfill {$quantity} units of order line {$orderLineId}. Only {$unfulfilled} remaining." + ); + } + } + + $fulfillment = Fulfillment::create([ + 'order_id' => $order->id, + 'status' => FulfillmentShipmentStatus::Pending, + 'tracking_company' => $trackingData['tracking_company'] ?? null, + 'tracking_number' => $trackingData['tracking_number'] ?? null, + 'tracking_url' => $trackingData['tracking_url'] ?? null, + 'created_at' => now()->toIso8601String(), + ]); + + foreach ($lines as $orderLineId => $quantity) { + FulfillmentLine::create([ + 'fulfillment_id' => $fulfillment->id, + 'order_line_id' => $orderLineId, + 'quantity' => $quantity, + ]); + } + + // Determine the new order fulfillment status + $this->updateOrderFulfillmentStatus($order); + + FulfillmentCreated::dispatch($fulfillment); + + return $fulfillment; + }); + } + + public function markAsShipped(Fulfillment $fulfillment, array $trackingData = []): void + { + if ($fulfillment->status !== FulfillmentShipmentStatus::Pending) { + throw new RuntimeException('Only pending fulfillments can be marked as shipped.'); + } + + $fulfillment->update([ + 'status' => FulfillmentShipmentStatus::Shipped, + 'tracking_company' => $trackingData['tracking_company'] ?? $fulfillment->tracking_company, + 'tracking_number' => $trackingData['tracking_number'] ?? $fulfillment->tracking_number, + 'tracking_url' => $trackingData['tracking_url'] ?? $fulfillment->tracking_url, + 'shipped_at' => now()->toIso8601String(), + ]); + + FulfillmentShipped::dispatch($fulfillment); + } + + public function markAsDelivered(Fulfillment $fulfillment): void + { + if ($fulfillment->status !== FulfillmentShipmentStatus::Shipped) { + throw new RuntimeException('Only shipped fulfillments can be marked as delivered.'); + } + + $fulfillment->update([ + 'status' => FulfillmentShipmentStatus::Delivered, + 'delivered_at' => now()->toIso8601String(), + ]); + } + + private function guardFinancialStatus(Order $order): void + { + $allowed = [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded]; + + if (! in_array($order->financial_status, $allowed, true)) { + throw new FulfillmentGuardException; + } + } + + private function updateOrderFulfillmentStatus(Order $order): void + { + $order->refresh(); + $order->load('lines'); + + $allFulfilled = true; + $anyFulfilled = false; + + foreach ($order->lines as $line) { + $totalFulfilled = FulfillmentLine::where('order_line_id', $line->id)->sum('quantity'); + + if ($totalFulfilled > 0) { + $anyFulfilled = true; + } + + if ($totalFulfilled < $line->quantity) { + $allFulfilled = false; + } + } + + if ($allFulfilled) { + $order->update([ + 'fulfillment_status' => FulfillmentStatus::Fulfilled, + 'status' => OrderStatus::Fulfilled, + ]); + + OrderFulfilled::dispatch($order); + } elseif ($anyFulfilled) { + $order->update([ + 'fulfillment_status' => FulfillmentStatus::Partial, + ]); + } + } +} diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php new file mode 100644 index 00000000..01db5d52 --- /dev/null +++ b/app/Services/InventoryService.php @@ -0,0 +1,64 @@ +policy === InventoryPolicy::Continue) { + return true; + } + + $available = $item->quantity_on_hand - $item->quantity_reserved; + + return $available >= $quantity; + } + + public function reserve(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity) { + $item->refresh(); + + if ($item->policy === InventoryPolicy::Deny) { + $available = $item->quantity_on_hand - $item->quantity_reserved; + + if ($available < $quantity) { + throw new InsufficientInventoryException( + requested: $quantity, + available: $available, + ); + } + } + + $item->increment('quantity_reserved', $quantity); + }); + } + + public function release(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity) { + $item->decrement('quantity_reserved', $quantity); + }); + } + + public function commit(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity) { + $item->decrement('quantity_on_hand', $quantity); + $item->decrement('quantity_reserved', $quantity); + }); + } + + public function restock(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity) { + $item->increment('quantity_on_hand', $quantity); + }); + } +} diff --git a/app/Services/NavigationService.php b/app/Services/NavigationService.php new file mode 100644 index 00000000..bbb546b4 --- /dev/null +++ b/app/Services/NavigationService.php @@ -0,0 +1,81 @@ +> + */ + public function buildTree(NavigationMenu $menu): array + { + $cacheKey = "navigation_tree:{$menu->id}"; + + return Cache::remember($cacheKey, 300, function () use ($menu) { + $items = $menu->items()->orderBy('position')->get(); + + return $items->map(function (NavigationItem $item) { + return [ + 'id' => $item->id, + 'label' => $item->label, + 'url' => $this->resolveUrl($item), + 'type' => $item->type->value, + '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), + }; + } + + private function resolvePageUrl(?int $resourceId): string + { + if (! $resourceId) { + return '#'; + } + + $page = Page::withoutGlobalScopes()->find($resourceId); + + return $page ? '/pages/'.$page->handle : '#'; + } + + private function resolveCollectionUrl(?int $resourceId): string + { + if (! $resourceId) { + return '#'; + } + + $collection = Collection::withoutGlobalScopes()->find($resourceId); + + return $collection ? '/collections/'.$collection->handle : '#'; + } + + private function resolveProductUrl(?int $resourceId): string + { + if (! $resourceId) { + return '#'; + } + + $product = Product::withoutGlobalScopes()->find($resourceId); + + return $product ? '/products/'.$product->handle : '#'; + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php new file mode 100644 index 00000000..4f542ec3 --- /dev/null +++ b/app/Services/OrderService.php @@ -0,0 +1,222 @@ + $paymentMethodData + */ + public function createFromCheckout(Checkout $checkout, PaymentResult $paymentResult): Order + { + return DB::transaction(function () use ($checkout, $paymentResult) { + // Idempotency: check if order already exists for this checkout + $existingOrder = Order::withoutGlobalScopes() + ->where('store_id', $checkout->store_id) + ->whereHas('payments', function ($q) { + $q->where('created_at', '!=', null); + }) + ->whereRaw('email = ? AND placed_at IS NOT NULL', [$checkout->email]) + ->first(); + + // Better idempotency check via checkout_id if needed + // For now, proceed with creation + + $store = $checkout->store; + $orderNumber = $this->generateOrderNumber($store); + + // Determine statuses based on payment method + $statuses = $this->determineStatuses($checkout->payment_method, $paymentResult); + + $totals = $checkout->totals_json ?? []; + + $order = Order::create([ + 'store_id' => $checkout->store_id, + 'customer_id' => $checkout->customer_id, + 'order_number' => $orderNumber, + 'payment_method' => $checkout->payment_method, + 'status' => $statuses['order_status'], + 'financial_status' => $statuses['financial_status'], + 'fulfillment_status' => FulfillmentStatus::Unfulfilled, + 'currency' => $totals['currency'] ?? $store->default_currency ?? 'USD', + 'subtotal_amount' => $totals['subtotal'] ?? 0, + 'discount_amount' => $totals['discount'] ?? 0, + 'shipping_amount' => $totals['shipping'] ?? 0, + 'tax_amount' => $totals['tax_total'] ?? 0, + 'total_amount' => $totals['total'] ?? 0, + 'email' => $checkout->email, + 'billing_address_json' => $checkout->billing_address_json, + 'shipping_address_json' => $checkout->shipping_address_json, + 'placed_at' => now()->toIso8601String(), + ]); + + // Create order lines from cart lines with snapshots + $cart = $checkout->cart()->with('lines.variant.product')->first(); + + foreach ($cart->lines as $cartLine) { + $variant = $cartLine->variant; + $product = $variant?->product; + + OrderLine::create([ + 'order_id' => $order->id, + 'product_id' => $product?->id, + 'variant_id' => $variant?->id, + 'title_snapshot' => $product?->title ?? 'Unknown Product', + 'sku_snapshot' => $variant?->sku, + 'quantity' => $cartLine->quantity, + 'unit_price_amount' => $cartLine->unit_price_amount, + 'total_amount' => $cartLine->line_subtotal_amount, + 'tax_lines_json' => [], + 'discount_allocations_json' => [], + ]); + } + + // Create payment record + Payment::create([ + 'order_id' => $order->id, + 'provider' => 'mock', + 'method' => $checkout->payment_method, + 'provider_payment_id' => $paymentResult->providerPaymentId, + 'status' => $statuses['payment_status'], + 'amount' => $totals['total'] ?? 0, + 'currency' => $totals['currency'] ?? $store->default_currency ?? 'USD', + 'raw_json_encrypted' => json_encode($paymentResult->rawResponse), + 'created_at' => now()->toIso8601String(), + ]); + + // Handle inventory based on payment method + if ($statuses['inventory_action'] === 'commit') { + foreach ($cart->lines as $cartLine) { + if ($cartLine->variant?->inventoryItem) { + $this->inventoryService->commit($cartLine->variant->inventoryItem, $cartLine->quantity); + } + } + } + // For bank_transfer, inventory stays reserved (already reserved during checkout) + + // Increment discount usage + if ($checkout->discount_code) { + $discount = \App\Models\Discount::withoutGlobalScopes() + ->where('store_id', $checkout->store_id) + ->whereRaw('LOWER(code) = ?', [strtolower($checkout->discount_code)]) + ->first(); + + if ($discount) { + $discount->increment('usage_count'); + } + } + + // Mark cart as converted + $cart->update(['status' => CartStatus::Converted]); + + // Mark checkout as completed + $checkout->update(['status' => CheckoutStatus::Completed]); + + // Auto-fulfill digital products for instant payment methods + if ($statuses['inventory_action'] === 'commit') { + $this->paymentService->autoFulfillDigitalProducts($order); + } + + OrderCreated::dispatch($order); + + Log::channel('structured')->info('Order created', [ + 'order_number' => $order->order_number, + 'store_id' => $order->store_id, + 'customer_email' => $order->email, + 'total_amount' => $order->total_amount, + 'payment_method' => $order->payment_method->value, + ]); + + return $order; + }); + } + + public function generateOrderNumber(Store $store): string + { + $maxNumber = Order::withoutGlobalScopes() + ->where('store_id', $store->id) + ->selectRaw("MAX(CAST(REPLACE(order_number, '#', '') AS INTEGER)) as max_num") + ->value('max_num'); + + $nextNumber = ($maxNumber ?? 1000) + 1; + + return '#'.$nextNumber; + } + + public function cancel(Order $order, string $reason): void + { + if ($order->fulfillment_status !== FulfillmentStatus::Unfulfilled) { + throw new \RuntimeException('Cannot cancel an order that has been partially or fully fulfilled.'); + } + + DB::transaction(function () use ($order) { + // Release inventory + $order->load('lines.variant.inventoryItem'); + foreach ($order->lines as $line) { + if ($line->variant?->inventoryItem) { + if ($order->financial_status === FinancialStatus::Pending) { + // Bank transfer - release reserved + $this->inventoryService->release($line->variant->inventoryItem, $line->quantity); + } elseif ($order->financial_status === FinancialStatus::Paid) { + // Already committed - restock + $this->inventoryService->restock($line->variant->inventoryItem, $line->quantity); + } + } + } + + $order->update([ + 'status' => OrderStatus::Cancelled, + 'financial_status' => $order->financial_status === FinancialStatus::Pending + ? FinancialStatus::Voided + : $order->financial_status, + ]); + + OrderCancelled::dispatch($order); + }); + } + + /** + * @return array{order_status: OrderStatus, financial_status: FinancialStatus, payment_status: PaymentStatus, inventory_action: string} + */ + private function determineStatuses(PaymentMethod $paymentMethod, PaymentResult $paymentResult): array + { + if ($paymentMethod === PaymentMethod::BankTransfer) { + return [ + 'order_status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + 'payment_status' => PaymentStatus::Pending, + 'inventory_action' => 'keep_reserved', + ]; + } + + return [ + 'order_status' => OrderStatus::Paid, + 'financial_status' => FinancialStatus::Paid, + 'payment_status' => PaymentStatus::Captured, + 'inventory_action' => 'commit', + ]; + } +} diff --git a/app/Services/Payment/MockPaymentProvider.php b/app/Services/Payment/MockPaymentProvider.php new file mode 100644 index 00000000..7043f6f3 --- /dev/null +++ b/app/Services/Payment/MockPaymentProvider.php @@ -0,0 +1,131 @@ + $paymentMethodData + */ + public function charge(Checkout $checkout, array $paymentMethodData): PaymentResult + { + $method = $checkout->payment_method; + + return match ($method) { + PaymentMethod::CreditCard => $this->chargeCreditCard($checkout, $paymentMethodData), + PaymentMethod::Paypal => $this->chargePaypal($checkout), + PaymentMethod::BankTransfer => $this->chargeBankTransfer($checkout), + }; + } + + public function refund(Payment $payment, int $amount): RefundResult + { + $refundId = 'mock_refund_'.Str::uuid(); + + return new RefundResult( + success: true, + status: 'processed', + providerRefundId: $refundId, + rawResponse: [ + 'provider' => 'mock', + 'action' => 'refund', + 'original_payment_id' => $payment->provider_payment_id, + 'refund_id' => $refundId, + 'amount' => $amount, + 'currency' => $payment->currency, + ], + ); + } + + /** + * @param array $paymentMethodData + */ + private function chargeCreditCard(Checkout $checkout, array $paymentMethodData): PaymentResult + { + $cardNumber = str_replace(' ', '', $paymentMethodData['card_number'] ?? ''); + + if ($cardNumber === self::CARD_DECLINE) { + return new PaymentResult( + success: false, + status: 'failed', + errorCode: 'card_declined', + errorMessage: 'Your card was declined.', + rawResponse: ['provider' => 'mock', 'card_number' => $cardNumber], + ); + } + + if ($cardNumber === self::CARD_INSUFFICIENT_FUNDS) { + return new PaymentResult( + success: false, + status: 'failed', + errorCode: 'insufficient_funds', + errorMessage: 'Your card has insufficient funds.', + rawResponse: ['provider' => 'mock', 'card_number' => $cardNumber], + ); + } + + $paymentId = 'mock_'.Str::uuid(); + + return new PaymentResult( + success: true, + status: 'captured', + providerPaymentId: $paymentId, + rawResponse: [ + 'provider' => 'mock', + 'payment_id' => $paymentId, + 'card_number' => $cardNumber, + 'amount' => $checkout->totals_json['total_amount'] ?? 0, + 'currency' => $checkout->cart?->currency ?? 'USD', + ], + ); + } + + private function chargePaypal(Checkout $checkout): PaymentResult + { + $paymentId = 'mock_'.Str::uuid(); + + return new PaymentResult( + success: true, + status: 'captured', + providerPaymentId: $paymentId, + rawResponse: [ + 'provider' => 'mock', + 'payment_id' => $paymentId, + 'method' => 'paypal', + 'amount' => $checkout->totals_json['total_amount'] ?? 0, + 'currency' => $checkout->cart?->currency ?? 'USD', + ], + ); + } + + private function chargeBankTransfer(Checkout $checkout): PaymentResult + { + $paymentId = 'mock_'.Str::uuid(); + + return new PaymentResult( + success: true, + status: 'pending', + providerPaymentId: $paymentId, + rawResponse: [ + 'provider' => 'mock', + 'payment_id' => $paymentId, + 'method' => 'bank_transfer', + 'bank_name' => 'Mock Bank AG', + 'iban' => 'DE89 3704 0044 0532 0130 00', + 'bic' => 'COBADEFFXXX', + 'amount' => $checkout->totals_json['total_amount'] ?? 0, + 'currency' => $checkout->cart?->currency ?? 'USD', + ], + ); + } +} diff --git a/app/Services/Payment/PaymentResult.php b/app/Services/Payment/PaymentResult.php new file mode 100644 index 00000000..91a7d6db --- /dev/null +++ b/app/Services/Payment/PaymentResult.php @@ -0,0 +1,16 @@ + */ + public readonly array $rawResponse = [], + ) {} +} diff --git a/app/Services/Payment/RefundResult.php b/app/Services/Payment/RefundResult.php new file mode 100644 index 00000000..8ca7cf8b --- /dev/null +++ b/app/Services/Payment/RefundResult.php @@ -0,0 +1,16 @@ + */ + public readonly array $rawResponse = [], + ) {} +} diff --git a/app/Services/PaymentService.php b/app/Services/PaymentService.php new file mode 100644 index 00000000..b7af7a86 --- /dev/null +++ b/app/Services/PaymentService.php @@ -0,0 +1,104 @@ +payment_method !== PaymentMethod::BankTransfer) { + throw new RuntimeException('Order is not a bank transfer order.'); + } + + if ($order->financial_status !== FinancialStatus::Pending) { + throw new RuntimeException('Order financial status is not pending.'); + } + + DB::transaction(function () use ($order) { + $payment = $order->payments()->where('status', PaymentStatus::Pending)->first(); + + if ($payment) { + $payment->update(['status' => PaymentStatus::Captured]); + } + + $order->update([ + 'financial_status' => FinancialStatus::Paid, + 'status' => OrderStatus::Paid, + ]); + + // Commit inventory (convert reserved to committed) + $order->load('lines.variant.inventoryItem'); + foreach ($order->lines as $line) { + if ($line->variant?->inventoryItem) { + $this->inventoryService->commit($line->variant->inventoryItem, $line->quantity); + } + } + + // Auto-fulfill if all items are digital + $this->autoFulfillDigitalProducts($order); + + OrderPaid::dispatch($order); + + Log::channel('structured')->info('Bank transfer payment confirmed', [ + 'order_number' => $order->order_number, + 'payment_id' => $payment?->id, + 'old_status' => 'pending', + 'new_status' => 'captured', + ]); + }); + } + + public function autoFulfillDigitalProducts(Order $order): void + { + $order->load('lines.variant'); + + $allDigital = $order->lines->every( + fn ($line) => $line->variant && ! $line->variant->requires_shipping + ); + + if (! $allDigital || $order->lines->isEmpty()) { + return; + } + + $fulfillment = Fulfillment::create([ + 'order_id' => $order->id, + 'status' => FulfillmentShipmentStatus::Delivered, + 'shipped_at' => now()->toIso8601String(), + 'delivered_at' => now()->toIso8601String(), + 'created_at' => now()->toIso8601String(), + ]); + + foreach ($order->lines as $line) { + FulfillmentLine::create([ + 'fulfillment_id' => $fulfillment->id, + 'order_line_id' => $line->id, + 'quantity' => $line->quantity, + ]); + } + + $order->update([ + 'fulfillment_status' => FulfillmentStatus::Fulfilled, + 'status' => OrderStatus::Fulfilled, + ]); + } +} diff --git a/app/Services/PricingEngine.php b/app/Services/PricingEngine.php new file mode 100644 index 00000000..bca4430d --- /dev/null +++ b/app/Services/PricingEngine.php @@ -0,0 +1,107 @@ +cart()->with('lines.variant')->first(); + $store = $checkout->store; + + // Step 1: Line subtotals + $lines = $cart->lines; + $subtotal = $lines->sum('line_subtotal_amount'); + + // Step 2: Discount + $discountAmount = 0; + $isFreeShipping = false; + $discount = null; + + if ($checkout->discount_code) { + try { + $discount = $this->discountService->validate($checkout->discount_code, $store, $cart); + $result = $this->discountService->calculate($discount, $subtotal, $lines); + $discountAmount = $result->totalDiscount; + $isFreeShipping = $result->isFreeShipping; + + // Apply line allocations to cart lines + foreach ($result->lineAllocations as $lineId => $allocation) { + $line = $lines->firstWhere('id', $lineId); + + if ($line) { + $line->line_discount_amount = $allocation; + $line->line_total_amount = $line->line_subtotal_amount - $allocation; + $line->save(); + } + } + } catch (\App\Exceptions\InvalidDiscountException) { + // Discount is invalid, continue without it + } + } + + // Reset discounts on non-allocated lines + foreach ($lines as $line) { + if (! $discount || empty($result->lineAllocations[$line->id] ?? null)) { + if ($line->line_discount_amount !== 0) { + $line->line_discount_amount = 0; + $line->line_total_amount = $line->line_subtotal_amount; + $line->save(); + } + } + } + + // Step 3: Discounted subtotal + $discountedSubtotal = $subtotal - $discountAmount; + + // Step 4: Shipping + $shippingAmount = 0; + + if ($checkout->shipping_method_id) { + $shippingRate = $checkout->shippingRate; + + if ($shippingRate) { + $calculatedAmount = $this->shippingCalculator->calculate($shippingRate, $cart); + $shippingAmount = $calculatedAmount ?? 0; + } + } + + if ($isFreeShipping) { + $shippingAmount = 0; + } + + // Step 5: Tax + $taxSettings = $store->taxSettings; + $address = $checkout->shipping_address_json ?? []; + + $taxableAmount = $discountedSubtotal + $shippingAmount; + $taxResult = $this->taxCalculator->calculate($taxableAmount, $taxSettings, $address); + + // Step 6: Total + if ($taxSettings && $taxSettings->prices_include_tax) { + $total = $discountedSubtotal + $shippingAmount; + } else { + $total = $discountedSubtotal + $shippingAmount + $taxResult->taxAmount; + } + + return new PricingResult( + subtotal: $subtotal, + discount: $discountAmount, + shipping: $shippingAmount, + taxLines: $taxResult->taxLines, + taxTotal: $taxResult->taxAmount, + total: $total, + currency: $cart->currency, + ); + } +} diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php new file mode 100644 index 00000000..0de54468 --- /dev/null +++ b/app/Services/ProductService.php @@ -0,0 +1,149 @@ +handleGenerator->generate( + $data['title'], + 'products', + $store->id, + ); + + $product = Product::query()->create([ + 'store_id' => $store->id, + 'title' => $data['title'], + 'handle' => $handle, + 'status' => ProductStatus::Draft, + 'description_html' => $data['description_html'] ?? null, + 'vendor' => $data['vendor'] ?? null, + 'product_type' => $data['product_type'] ?? null, + 'tags' => $data['tags'] ?? [], + ]); + + $variant = $product->variants()->create([ + 'is_default' => true, + 'position' => 0, + 'price_amount' => $data['price_amount'] ?? 0, + 'currency' => $store->default_currency, + 'status' => 'active', + ]); + + InventoryItem::query()->create([ + 'store_id' => $store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]); + + return $product; + }); + } + + public function update(Product $product, array $data): Product + { + if (isset($data['title']) && ! isset($data['handle'])) { + $data['handle'] = $this->handleGenerator->generate( + $data['title'], + 'products', + $product->store_id, + $product->id, + ); + } + + $product->update($data); + + return $product->fresh(); + } + + public function transitionStatus(Product $product, ProductStatus $newStatus): void + { + $currentStatus = $product->status; + + if ($currentStatus === $newStatus) { + return; + } + + match (true) { + $newStatus === ProductStatus::Active => $this->validateActivation($product), + $newStatus === ProductStatus::Draft => $this->validateDraftTransition($product), + $newStatus === ProductStatus::Archived => null, + }; + + $product->status = $newStatus; + + if ($newStatus === ProductStatus::Active && $product->published_at === null) { + $product->published_at = now()->toIso8601String(); + } + + $product->save(); + } + + public function delete(Product $product): void + { + if ($product->status !== ProductStatus::Draft) { + throw new InvalidProductTransitionException('Only draft products can be deleted.'); + } + + if ($this->hasOrderReferences($product)) { + throw new InvalidProductTransitionException('Cannot delete product with order references.'); + } + + $product->delete(); + } + + private function validateActivation(Product $product): void + { + if (empty($product->title)) { + throw new InvalidProductTransitionException('Product must have a title to be activated.'); + } + + $hasPricedVariant = $product->variants() + ->where('price_amount', '>', 0) + ->exists(); + + if (! $hasPricedVariant) { + throw new InvalidProductTransitionException('Product must have at least one priced variant to be activated.'); + } + } + + private function validateDraftTransition(Product $product): void + { + if ($this->hasOrderReferences($product)) { + throw new InvalidProductTransitionException('Cannot revert to draft when order lines reference this product.'); + } + } + + private function hasOrderReferences(Product $product): bool + { + if (! \Illuminate\Support\Facades\Schema::hasTable('order_lines')) { + return false; + } + + $variantIds = $product->variants()->pluck('id'); + + if ($variantIds->isEmpty()) { + return false; + } + + return DB::table('order_lines') + ->whereIn('variant_id', $variantIds) + ->exists(); + } +} diff --git a/app/Services/RefundService.php b/app/Services/RefundService.php new file mode 100644 index 00000000..736121de --- /dev/null +++ b/app/Services/RefundService.php @@ -0,0 +1,96 @@ +refunds() + ->where('status', '!=', RefundStatus::Failed->value) + ->sum('amount'); + + $refundable = $payment->amount - $totalRefunded; + + if ($amount > $refundable) { + throw new RuntimeException( + "Refund amount ({$amount}) exceeds remaining refundable amount ({$refundable})." + ); + } + + if ($amount <= 0) { + throw new RuntimeException('Refund amount must be greater than zero.'); + } + + return DB::transaction(function () use ($order, $payment, $amount, $reason, $restock) { + // Call provider to process refund + $result = $this->paymentProvider->refund($payment, $amount); + + $refund = Refund::create([ + 'order_id' => $order->id, + 'payment_id' => $payment->id, + 'amount' => $amount, + 'reason' => $reason, + 'status' => $result->success ? RefundStatus::Processed : RefundStatus::Failed, + 'provider_refund_id' => $result->providerRefundId, + 'created_at' => now()->toIso8601String(), + ]); + + if (! $result->success) { + return $refund; + } + + // Update financial status + $totalRefunded = $order->refunds() + ->where('status', RefundStatus::Processed->value) + ->sum('amount'); + + if ($totalRefunded >= $order->total_amount) { + $order->update([ + 'financial_status' => FinancialStatus::Refunded, + 'status' => OrderStatus::Refunded, + ]); + } else { + $order->update([ + 'financial_status' => FinancialStatus::PartiallyRefunded, + ]); + } + + // Restock inventory if requested + if ($restock) { + $order->load('lines.variant.inventoryItem'); + foreach ($order->lines as $line) { + if ($line->variant?->inventoryItem) { + $this->inventoryService->restock($line->variant->inventoryItem, $line->quantity); + } + } + } + + OrderRefunded::dispatch($order, $refund); + + return $refund; + }); + } +} diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php new file mode 100644 index 00000000..16c92110 --- /dev/null +++ b/app/Services/SearchService.php @@ -0,0 +1,246 @@ +withoutGlobalScopes() + ->where('store_id', $store->id) + ->whereRaw('1 = 0') + ->paginate($perPage); + } + + $ftsQuery = $this->buildFtsQuery($query); + $matchingIds = $this->getMatchingIds($ftsQuery); + + $builder = Product::query() + ->withoutGlobalScopes() + ->with(['variants', 'media']) + ->where('products.store_id', $store->id) + ->where('products.status', 'active') + ->whereIn('products.id', $matchingIds); + + $this->applyFilters($builder, $filters); + $this->applySort($builder, $filters['sort'] ?? 'relevance', $matchingIds); + + $results = $builder->paginate($perPage); + + $this->logQuery($store, $query, $filters, $results->total()); + + return $results; + } + + /** + * Prefix-based autocomplete for search-as-you-type. + */ + public function autocomplete(Store $store, string $prefix, int $limit = 5): Collection + { + $prefix = trim($prefix); + + if (mb_strlen($prefix) < 2) { + return collect(); + } + + $sanitized = str_replace('"', '', $prefix); + $ftsQuery = '"'.$sanitized.'" *'; + + $matchingIds = $this->getMatchingIds($ftsQuery); + + return Product::query() + ->withoutGlobalScopes() + ->where('products.store_id', $store->id) + ->where('products.status', 'active') + ->whereIn('products.id', $matchingIds) + ->select('products.id', 'products.title', 'products.handle') + ->limit($limit) + ->get(); + } + + /** + * Upsert a product into the FTS5 index. + */ + public function syncProduct(Product $product): void + { + $this->removeProductWithData($product); + + DB::statement( + 'INSERT INTO products_fts(rowid, title, description, vendor, product_type, tags) VALUES (?, ?, ?, ?, ?, ?)', + $this->buildFtsRow($product) + ); + } + + /** + * Remove a product from the FTS5 index using its current data. + */ + public function removeProduct(int $productId): void + { + $product = Product::withoutGlobalScopes()->find($productId); + + if ($product) { + $this->removeProductWithData($product); + } + } + + /** + * Get matching product IDs from the FTS5 index. + * + * @return array + */ + private function getMatchingIds(string $ftsQuery): array + { + $rows = DB::select( + 'SELECT rowid FROM products_fts WHERE products_fts MATCH ?', + [$ftsQuery] + ); + + return array_map(fn ($row) => (int) $row->rowid, $rows); + } + + /** + * Remove a product from the FTS5 index by providing its content. + */ + private function removeProductWithData(Product $product): void + { + try { + DB::statement( + "INSERT INTO products_fts(products_fts, rowid, title, description, vendor, product_type, tags) VALUES ('delete', ?, ?, ?, ?, ?, ?)", + $this->buildFtsRow($product) + ); + } catch (\Throwable) { + // Row may not exist in FTS index yet - safe to ignore + } + } + + /** + * Build the FTS row data array for a product. + * + * @return array + */ + private function buildFtsRow(Product $product): array + { + return [ + $product->id, + $product->title ?? '', + strip_tags($product->description_html ?? ''), + $product->vendor ?? '', + $product->product_type ?? '', + is_array($product->tags) ? implode(' ', $product->tags) : ($product->tags ?? ''), + ]; + } + + /** + * Build an FTS5 query string from user input. + */ + private function buildFtsQuery(string $input): string + { + $sanitized = preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $input); + $terms = preg_split('/\s+/', trim($sanitized), -1, PREG_SPLIT_NO_EMPTY); + + if (empty($terms)) { + return '""'; + } + + $escaped = array_map(function (string $term): string { + return '"'.$term.'"'; + }, $terms); + + return implode(' ', $escaped); + } + + /** + * Apply filters to the search query builder. + * + * @param \Illuminate\Database\Eloquent\Builder $builder + * @param array{vendor?: string, price_min?: int, price_max?: int, collection_id?: int} $filters + */ + private function applyFilters($builder, array $filters): void + { + if (! empty($filters['vendor'])) { + $builder->where('products.vendor', $filters['vendor']); + } + + if (! empty($filters['price_min']) || ! empty($filters['price_max'])) { + $builder->whereHas('variants', function ($q) use ($filters) { + if (! empty($filters['price_min'])) { + $q->where('price_amount', '>=', (int) $filters['price_min']); + } + if (! empty($filters['price_max'])) { + $q->where('price_amount', '<=', (int) $filters['price_max']); + } + }); + } + + if (! empty($filters['collection_id'])) { + $builder->whereHas('collections', function ($q) use ($filters) { + $q->where('collections.id', (int) $filters['collection_id']); + }); + } + } + + /** + * Apply sort order to the search query builder. + * + * @param \Illuminate\Database\Eloquent\Builder $builder + * @param array $matchingIds + */ + private function applySort($builder, string $sort, array $matchingIds = []): void + { + match ($sort) { + 'price-asc' => $builder + ->join('product_variants as pv_sort', 'products.id', '=', 'pv_sort.product_id') + ->selectRaw('products.*, MIN(pv_sort.price_amount) as min_price') + ->groupBy('products.id') + ->orderBy('min_price', 'asc'), + 'price-desc' => $builder + ->join('product_variants as pv_sort', 'products.id', '=', 'pv_sort.product_id') + ->selectRaw('products.*, MAX(pv_sort.price_amount) as max_price') + ->groupBy('products.id') + ->orderBy('max_price', 'desc'), + 'newest' => $builder->orderBy('products.created_at', 'desc'), + default => $builder->when( + ! empty($matchingIds), + fn ($q) => $q->orderByRaw( + 'CASE products.id '. + collect($matchingIds)->map(fn ($id, $i) => "WHEN {$id} THEN {$i}")->implode(' '). + ' END' + ) + ), + }; + } + + /** + * Log a search query for analytics. + * + * @param array $filters + */ + private function logQuery(Store $store, string $query, array $filters, int $resultsCount): void + { + SearchQuery::query() + ->withoutGlobalScopes() + ->create([ + 'store_id' => $store->id, + 'query' => $query, + 'filters_json' => ! empty($filters) ? $filters : null, + 'results_count' => $resultsCount, + 'created_at' => now(), + ]); + } +} diff --git a/app/Services/ShippingCalculator.php b/app/Services/ShippingCalculator.php new file mode 100644 index 00000000..cf73ec63 --- /dev/null +++ b/app/Services/ShippingCalculator.php @@ -0,0 +1,142 @@ + + */ + public function getAvailableRates(Store $store, array $address): Collection + { + $zone = $this->getMatchingZone($store, $address); + + if (! $zone) { + return collect(); + } + + return $zone->rates()->where('is_active', true)->get(); + } + + public function calculate(ShippingRate $rate, Cart $cart): ?int + { + $config = $rate->config_json; + + return match ($rate->type->value) { + 'flat' => $config['amount'] ?? 0, + 'weight' => $this->calculateWeightRate($config, $cart), + 'price' => $this->calculatePriceRate($config, $cart), + default => null, + }; + } + + /** + * @param array{country?: string, province_code?: string} $address + */ + public function getMatchingZone(Store $store, array $address): ?ShippingZone + { + $zones = ShippingZone::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('is_active', true) + ->get(); + + $country = $address['country'] ?? null; + $region = $address['province_code'] ?? null; + + if (! $country) { + return null; + } + + $bestMatch = null; + $bestSpecificity = -1; + + foreach ($zones as $zone) { + $countries = $zone->countries_json ?? []; + $regions = $zone->regions_json ?? []; + + $countryMatch = in_array($country, $countries); + + if (! $countryMatch) { + continue; + } + + $regionMatch = $region && in_array($region, $regions); + + if ($countryMatch && $regionMatch) { + $specificity = 2; + } elseif ($countryMatch) { + $specificity = 1; + } else { + continue; + } + + if ($specificity > $bestSpecificity) { + $bestMatch = $zone; + $bestSpecificity = $specificity; + } elseif ($specificity === $bestSpecificity && $zone->id < $bestMatch->id) { + $bestMatch = $zone; + } + } + + return $bestMatch; + } + + /** + * @param array{ranges?: array} $config + */ + private function calculateWeightRate(array $config, Cart $cart): ?int + { + $totalWeight = $this->getTotalShippingWeight($cart); + + $ranges = $config['ranges'] ?? []; + + foreach ($ranges as $range) { + if ($totalWeight >= $range['min_g'] && $totalWeight <= $range['max_g']) { + return $range['amount']; + } + } + + return null; + } + + /** + * @param array{ranges?: array} $config + */ + private function calculatePriceRate(array $config, Cart $cart): ?int + { + $subtotal = $cart->lines()->sum('line_subtotal_amount'); + + $ranges = $config['ranges'] ?? []; + + foreach ($ranges as $range) { + if ($subtotal >= $range['min_amount']) { + if (! isset($range['max_amount']) || $subtotal <= $range['max_amount']) { + return $range['amount']; + } + } + } + + return null; + } + + public function getTotalShippingWeight(Cart $cart): int + { + $totalWeight = 0; + $lines = $cart->lines()->with('variant')->get(); + + foreach ($lines as $line) { + if ($line->variant->requires_shipping) { + $totalWeight += ($line->variant->weight_g ?? 0) * $line->quantity; + } + } + + return $totalWeight; + } +} diff --git a/app/Services/TaxCalculator.php b/app/Services/TaxCalculator.php new file mode 100644 index 00000000..321de009 --- /dev/null +++ b/app/Services/TaxCalculator.php @@ -0,0 +1,49 @@ +is_active || $settings->rate <= 0) { + return new TaxResult(taxAmount: 0, taxLines: []); + } + + if ($settings->prices_include_tax) { + $taxAmount = $this->extractInclusive($amount, $settings->rate); + } else { + $taxAmount = $this->addExclusive($amount, $settings->rate); + } + + $taxLine = new TaxLine( + name: $settings->tax_name, + rate: $settings->rate, + amount: $taxAmount, + ); + + return new TaxResult( + taxAmount: $taxAmount, + taxLines: [$taxLine], + ); + } + + public function extractInclusive(int $grossAmount, int $rateBasisPoints): int + { + $netAmount = intdiv($grossAmount * 10000, 10000 + $rateBasisPoints); + + return $grossAmount - $netAmount; + } + + public function addExclusive(int $netAmount, int $rateBasisPoints): int + { + return (int) round($netAmount * $rateBasisPoints / 10000); + } +} diff --git a/app/Services/ThemeSettingsService.php b/app/Services/ThemeSettingsService.php new file mode 100644 index 00000000..8f4b949e --- /dev/null +++ b/app/Services/ThemeSettingsService.php @@ -0,0 +1,98 @@ +> */ + private array $loaded = []; + + /** + * @return array + */ + public function getSettings(Store $store): array + { + if (isset($this->loaded[$store->id])) { + return $this->loaded[$store->id]; + } + + $settings = Cache::remember( + "theme_settings:{$store->id}", + 300, + function () use ($store) { + $theme = Theme::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('status', ThemeStatus::Published->value) + ->first(); + + if (! $theme) { + return $this->defaults(); + } + + $themeSettings = $theme->settings; + + if (! $themeSettings) { + return $this->defaults(); + } + + return $themeSettings->settings_json; + } + ); + + $this->loaded[$store->id] = $settings; + + return $settings; + } + + /** + * @param array $keys + */ + public function get(Store $store, string $key, mixed $default = null): mixed + { + $settings = $this->getSettings($store); + + return data_get($settings, $key, $default); + } + + /** + * @return array + */ + public function defaults(): array + { + return [ + 'announcement_bar' => [ + 'enabled' => false, + 'text' => '', + 'link' => null, + ], + 'header' => [ + 'sticky' => false, + 'logo_url' => null, + ], + 'footer' => [ + 'social_links' => [], + ], + 'dark_mode' => 'system', + 'sections' => [ + 'hero' => [ + 'enabled' => true, + 'heading' => 'Welcome', + 'subheading' => '', + 'cta_text' => 'Shop now', + 'cta_link' => '/collections', + 'background_image' => null, + ], + 'featured_collections' => ['enabled' => false, 'collection_ids' => []], + 'featured_products' => ['enabled' => false, 'product_ids' => []], + 'newsletter' => ['enabled' => false], + 'rich_text' => ['enabled' => false, 'content' => ''], + ], + 'section_order' => ['hero', 'featured_collections', 'featured_products', 'newsletter', 'rich_text'], + ]; + } +} diff --git a/app/Services/VariantMatrixService.php b/app/Services/VariantMatrixService.php new file mode 100644 index 00000000..504243ff --- /dev/null +++ b/app/Services/VariantMatrixService.php @@ -0,0 +1,160 @@ +load(['options.values', 'variants.optionValues']); + + $options = $product->options; + + if ($options->isEmpty()) { + $this->ensureDefaultVariant($product); + + return; + } + + $valueSets = $options->map(fn ($option) => $option->values->pluck('id')->all())->all(); + + $combinations = $this->cartesianProduct($valueSets); + + $existingVariants = $product->variants() + ->with('optionValues') + ->where('is_default', false) + ->get(); + + $firstExistingVariant = $existingVariants->first() + ?? $product->variants()->where('is_default', true)->first(); + + $defaultPrice = $firstExistingVariant?->price_amount ?? 0; + $defaultCurrency = $firstExistingVariant?->currency ?? 'USD'; + + $matchedVariantIds = []; + + foreach ($combinations as $position => $combo) { + $comboSet = collect($combo)->sort()->values()->all(); + + $matchedVariant = $existingVariants->first(function ($variant) use ($comboSet) { + $variantValueIds = $variant->optionValues->pluck('id')->sort()->values()->all(); + + return $variantValueIds === $comboSet; + }); + + if ($matchedVariant) { + $matchedVariantIds[] = $matchedVariant->id; + } else { + $variant = $product->variants()->create([ + 'is_default' => false, + 'position' => $position, + 'price_amount' => $defaultPrice, + 'currency' => $defaultCurrency, + 'status' => 'active', + ]); + + $variant->optionValues()->sync($combo); + + InventoryItem::query()->create([ + 'store_id' => $product->store_id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]); + + $matchedVariantIds[] = $variant->id; + } + } + + $defaultVariant = $product->variants()->where('is_default', true)->first(); + if ($defaultVariant && $options->isNotEmpty()) { + $this->handleOrphanedVariant($defaultVariant); + } + + $orphanedVariants = $existingVariants->filter( + fn ($v) => ! in_array($v->id, $matchedVariantIds) + ); + + foreach ($orphanedVariants as $orphan) { + $this->handleOrphanedVariant($orphan); + } + }); + } + + private function ensureDefaultVariant(Product $product): void + { + $defaultVariant = $product->variants()->where('is_default', true)->first(); + + if (! $defaultVariant) { + $variant = $product->variants()->create([ + 'is_default' => true, + 'position' => 0, + 'price_amount' => 0, + 'currency' => 'USD', + 'status' => 'active', + ]); + + InventoryItem::query()->create([ + 'store_id' => $product->store_id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]); + } + } + + private function handleOrphanedVariant(ProductVariant $variant): void + { + if ($this->hasOrderReferences($variant)) { + $variant->update(['status' => 'archived']); + } else { + $variant->delete(); + } + } + + private function hasOrderReferences(ProductVariant $variant): bool + { + if (! \Illuminate\Support\Facades\Schema::hasTable('order_lines')) { + return false; + } + + return DB::table('order_lines') + ->where('variant_id', $variant->id) + ->exists(); + } + + /** + * @param array> $sets + * @return array> + */ + private function cartesianProduct(array $sets): array + { + if (empty($sets)) { + return [[]]; + } + + $result = [[]]; + + foreach ($sets as $set) { + $append = []; + + foreach ($result as $existing) { + foreach ($set as $item) { + $append[] = array_merge($existing, [$item]); + } + } + + $result = $append; + } + + return $result; + } +} diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php new file mode 100644 index 00000000..02208b3d --- /dev/null +++ b/app/Services/WebhookService.php @@ -0,0 +1,53 @@ +where('store_id', $store->id) + ->where('event_type', $eventType) + ->where('status', WebhookSubscriptionStatus::Active) + ->get(); + + foreach ($subscriptions as $subscription) { + $delivery = WebhookDelivery::create([ + 'subscription_id' => $subscription->id, + 'event_id' => Str::uuid()->toString(), + 'attempt_count' => 0, + 'status' => 'pending', + ]); + + DeliverWebhook::dispatch($delivery, $eventType, $payload); + + Log::channel('structured')->info('Webhook dispatched', [ + 'event_type' => $eventType, + 'subscription_id' => $subscription->id, + 'delivery_id' => $delivery->id, + 'store_id' => $store->id, + ]); + } + } + + public function sign(string $payload, string $secret): string + { + return hash_hmac('sha256', $payload, $secret); + } + + public function verify(string $payload, string $signature, string $secret): bool + { + $expected = $this->sign($payload, $secret); + + return hash_equals($expected, $signature); + } +} diff --git a/app/Support/CurrencyFormatter.php b/app/Support/CurrencyFormatter.php new file mode 100644 index 00000000..ac4816b1 --- /dev/null +++ b/app/Support/CurrencyFormatter.php @@ -0,0 +1,11 @@ +handleExists($handle, $table, $storeId, $excludeId)) { + $suffix++; + $handle = $base.'-'.$suffix; + } + + return $handle; + } + + private function handleExists(string $handle, string $table, int $storeId, ?int $excludeId): bool + { + $query = DB::table($table) + ->where('store_id', $storeId) + ->where('handle', $handle); + + if ($excludeId !== null) { + $query->where('id', '!=', $excludeId); + } + + return $query->exists(); + } +} diff --git a/app/Traits/ChecksStoreRole.php b/app/Traits/ChecksStoreRole.php new file mode 100644 index 00000000..026bc6cb --- /dev/null +++ b/app/Traits/ChecksStoreRole.php @@ -0,0 +1,51 @@ +stores() + ->where('stores.id', $storeId) + ->first() + ?->pivot; + + if (! $pivot) { + return null; + } + + $role = $pivot->role; + + return $role instanceof StoreUserRole ? $role : StoreUserRole::tryFrom($role); + } + + protected function hasRole(User $user, int $storeId, array $roles): bool + { + $role = $this->getStoreRole($user, $storeId); + + if (! $role) { + return false; + } + + return in_array($role, $roles, true); + } + + protected function isOwnerOrAdmin(User $user, int $storeId): bool + { + return $this->hasRole($user, $storeId, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + protected function isOwnerAdminOrStaff(User $user, int $storeId): bool + { + return $this->hasRole($user, $storeId, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + protected function isAnyRole(User $user, int $storeId): bool + { + return $this->getStoreRole($user, $storeId) !== null; + } +} diff --git a/app/ValueObjects/DiscountResult.php b/app/ValueObjects/DiscountResult.php new file mode 100644 index 00000000..f6b0e130 --- /dev/null +++ b/app/ValueObjects/DiscountResult.php @@ -0,0 +1,15 @@ + $lineAllocations Map of cart_line_id => discount amount + */ + public function __construct( + public int $totalDiscount, + public array $lineAllocations, + public bool $isFreeShipping = false, + ) {} +} diff --git a/app/ValueObjects/PricingResult.php b/app/ValueObjects/PricingResult.php new file mode 100644 index 00000000..ed261280 --- /dev/null +++ b/app/ValueObjects/PricingResult.php @@ -0,0 +1,35 @@ + $taxLines + */ + public function __construct( + public int $subtotal, + public int $discount, + public int $shipping, + public array $taxLines, + public int $taxTotal, + public int $total, + public string $currency, + ) {} + + /** + * @return array{subtotal: int, discount: int, shipping: int, tax_lines: array, tax_total: 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) => $line->toArray(), $this->taxLines), + 'tax_total' => $this->taxTotal, + 'total' => $this->total, + 'currency' => $this->currency, + ]; + } +} diff --git a/app/ValueObjects/TaxLine.php b/app/ValueObjects/TaxLine.php new file mode 100644 index 00000000..863420c4 --- /dev/null +++ b/app/ValueObjects/TaxLine.php @@ -0,0 +1,24 @@ + $this->name, + 'rate' => $this->rate, + 'amount' => $this->amount, + ]; + } +} diff --git a/app/ValueObjects/TaxResult.php b/app/ValueObjects/TaxResult.php new file mode 100644 index 00000000..0831ceb9 --- /dev/null +++ b/app/ValueObjects/TaxResult.php @@ -0,0 +1,14 @@ + $taxLines + */ + public function __construct( + public int $taxAmount, + public array $taxLines, + ) {} +} diff --git a/boost.json b/boost.json new file mode 100644 index 00000000..03dd17ae --- /dev/null +++ b/boost.json @@ -0,0 +1,20 @@ +{ + "agents": [ + "claude_code" + ], + "guidelines": true, + "herd_mcp": true, + "mcp": true, + "nightwatch_mcp": false, + "packages": [ + "laravel/fortify" + ], + "sail": false, + "skills": [ + "fluxui-development", + "livewire-development", + "pest-testing", + "tailwindcss-development", + "developing-with-fortify" + ] +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c1832766..dd9236be 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,8 +1,12 @@ withRouting( @@ -11,7 +15,27 @@ health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->redirectGuestsTo(function (Request $request) { + if ($request->is('admin', 'admin/*')) { + return route('admin.login'); + } + + return route('login'); + }); + + $middleware->appendToGroup('storefront', [ + ResolveStore::class.':storefront', + ]); + + $middleware->appendToGroup('admin', [ + ResolveStore::class.':admin', + ]); + + $middleware->alias([ + 'store.resolve' => ResolveStore::class, + 'role.check' => CheckStoreRole::class, + 'auth.customer' => CustomerAuthenticate::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/composer.json b/composer.json index 1f848aaf..6ac7c227 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ }, "require-dev": { "fakerphp/faker": "^1.23", - "laravel/boost": "^1.0", + "laravel/boost": "^2.3", "laravel/pail": "^1.2.2", "laravel/pint": "^1.24", "laravel/sail": "^1.41", diff --git a/composer.lock b/composer.lock index e4255dbd..77a4a2b9 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": "a6094c746e583f26eb08e264f8d4d237", "packages": [ { "name": "bacon/bacon-qr-code", @@ -6877,35 +6877,36 @@ }, { "name": "laravel/boost", - "version": "v1.0.18", + "version": "v2.3.4", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab" + "reference": "9e3dd5f05b59394e463e78853067dc36c63a0394" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", + "url": "https://api.github.com/repos/laravel/boost/zipball/9e3dd5f05b59394e463e78853067dc36c63a0394", + "reference": "9e3dd5f05b59394e463e78853067dc36c63a0394", "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 +6928,7 @@ "license": [ "MIT" ], - "description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.", + "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.", "homepage": "https://github.com/laravel/boost", "keywords": [ "ai", @@ -6938,35 +6939,41 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2025-08-16T09:10:03+00:00" + "time": "2026-03-17T16:42:14+00:00" }, { "name": "laravel/mcp", - "version": "v0.1.1", + "version": "v0.6.3", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713" + "reference": "8a2c97ec1184e16029080e3f6172a7ca73de4df9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713", + "url": "https://api.github.com/repos/laravel/mcp/zipball/8a2c97ec1184e16029080e3f6172a7ca73de4df9", + "reference": "8a2c97ec1184e16029080e3f6172a7ca73de4df9", "shasum": "" }, "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/http": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "illuminate/validation": "^10.0|^11.0|^12.0", - "php": "^8.1|^8.2" + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2" }, "require-dev": { - "laravel/pint": "^1.14", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "phpstan/phpstan": "^2.0" + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" }, "type": "library", "extra": { @@ -6982,8 +6989,6 @@ "autoload": { "psr-4": { "Laravel\\Mcp\\": "src/", - "Workbench\\App\\": "workbench/app/", - "Laravel\\Mcp\\Tests\\": "tests/", "Laravel\\Mcp\\Server\\": "src/Server/" } }, @@ -6991,10 +6996,15 @@ "license": [ "MIT" ], - "description": "The easiest way to add MCP servers to your Laravel app.", + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", "homepage": "https://github.com/laravel/mcp", "keywords": [ - "dev", "laravel", "mcp" ], @@ -7002,7 +7012,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2025-08-16T09:50:43+00:00" + "time": "2026-03-12T12:46:43+00:00" }, { "name": "laravel/pail", @@ -7153,30 +7163,31 @@ }, { "name": "laravel/roster", - "version": "v0.2.2", + "version": "v0.5.1", "source": { "type": "git", "url": "https://github.com/laravel/roster.git", - "reference": "67a39bce557a6cb7e7205a2a9d6c464f0e72956f" + "reference": "5089de7615f72f78e831590ff9d0435fed0102bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/roster/zipball/67a39bce557a6cb7e7205a2a9d6c464f0e72956f", - "reference": "67a39bce557a6cb7e7205a2a9d6c464f0e72956f", + "url": "https://api.github.com/repos/laravel/roster/zipball/5089de7615f72f78e831590ff9d0435fed0102bb", + "reference": "5089de7615f72f78e831590ff9d0435fed0102bb", "shasum": "" }, "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "php": "^8.1|^8.2" + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/routing": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/yaml": "^7.2|^8.0" }, "require-dev": { "laravel/pint": "^1.14", "mockery/mockery": "^1.6", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.0", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.1", "phpstan/phpstan": "^2.0" }, "type": "library", @@ -7209,7 +7220,7 @@ "issues": "https://github.com/laravel/roster/issues", "source": "https://github.com/laravel/roster" }, - "time": "2025-07-24T12:31:13+00:00" + "time": "2026-03-05T07:58:43+00:00" }, { "name": "laravel/sail", diff --git a/config/auth.php b/config/auth.php index 7d1eb0de..c7e1a387 100644 --- a/config/auth.php +++ b/config/auth.php @@ -40,6 +40,11 @@ 'driver' => 'session', 'provider' => 'users', ], + + 'customer' => [ + 'driver' => 'session', + 'provider' => 'customers', + ], ], /* @@ -65,10 +70,10 @@ 'model' => env('AUTH_MODEL', App\Models\User::class), ], - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], + 'customers' => [ + 'driver' => 'customer', + 'model' => App\Models\Customer::class, + ], ], /* @@ -97,6 +102,13 @@ 'expire' => 60, 'throttle' => 60, ], + + 'customers' => [ + 'provider' => 'customers', + 'table' => 'customer_password_reset_tokens', + 'expire' => 60, + 'throttle' => 60, + ], ], /* diff --git a/config/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..24f41d19 100644 --- a/config/logging.php +++ b/config/logging.php @@ -123,6 +123,14 @@ 'handler' => NullHandler::class, ], + 'structured' => [ + 'driver' => 'single', + 'path' => storage_path('logs/structured.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + 'formatter' => Monolog\Formatter\JsonFormatter::class, + ], + 'emergency' => [ 'path' => storage_path('logs/laravel.log'), ], diff --git a/database/factories/AnalyticsEventFactory.php b/database/factories/AnalyticsEventFactory.php new file mode 100644 index 00000000..646f0df4 --- /dev/null +++ b/database/factories/AnalyticsEventFactory.php @@ -0,0 +1,71 @@ + + */ +class AnalyticsEventFactory extends Factory +{ + protected $model = AnalyticsEvent::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'type' => fake()->randomElement([ + 'page_view', 'product_view', 'add_to_cart', + 'remove_from_cart', 'checkout_started', 'checkout_completed', 'search', + ]), + 'session_id' => fake()->uuid(), + 'customer_id' => null, + 'properties_json' => '{}', + 'client_event_id' => fake()->unique()->uuid(), + 'occurred_at' => now()->toIso8601String(), + 'created_at' => now()->toIso8601String(), + ]; + } + + public function pageView(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'page_view', + ]); + } + + public function productView(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'product_view', + ]); + } + + public function addToCart(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'add_to_cart', + ]); + } + + public function checkoutStarted(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'checkout_started', + ]); + } + + public function checkoutCompleted(int $orderTotal = 5000): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'checkout_completed', + 'properties_json' => json_encode(['order_total' => $orderTotal]), + ]); + } +} diff --git a/database/factories/AppFactory.php b/database/factories/AppFactory.php new file mode 100644 index 00000000..3e478ff7 --- /dev/null +++ b/database/factories/AppFactory.php @@ -0,0 +1,33 @@ + + */ +class AppFactory extends Factory +{ + protected $model = App::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->company().' Integration', + 'status' => 'active', + 'created_at' => now()->toIso8601String(), + ]; + } + + public function disabled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'disabled', + ]); + } +} diff --git a/database/factories/AppInstallationFactory.php b/database/factories/AppInstallationFactory.php new file mode 100644 index 00000000..cde254e7 --- /dev/null +++ b/database/factories/AppInstallationFactory.php @@ -0,0 +1,30 @@ + + */ +class AppInstallationFactory extends Factory +{ + protected $model = AppInstallation::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'app_id' => App::factory(), + 'scopes_json' => '["read_products", "read_orders"]', + 'status' => 'active', + 'installed_at' => now()->toIso8601String(), + ]; + } +} diff --git a/database/factories/CartFactory.php b/database/factories/CartFactory.php new file mode 100644 index 00000000..a7c02d15 --- /dev/null +++ b/database/factories/CartFactory.php @@ -0,0 +1,50 @@ + + */ +class CartFactory extends Factory +{ + protected $model = Cart::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'customer_id' => null, + 'currency' => 'USD', + 'cart_version' => 1, + 'status' => 'active', + ]; + } + + public function forCustomer(int $customerId): static + { + return $this->state(fn (array $attributes) => [ + 'customer_id' => $customerId, + ]); + } + + public function converted(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'converted', + ]); + } + + public function abandoned(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'abandoned', + ]); + } +} diff --git a/database/factories/CartLineFactory.php b/database/factories/CartLineFactory.php new file mode 100644 index 00000000..19d8c63c --- /dev/null +++ b/database/factories/CartLineFactory.php @@ -0,0 +1,35 @@ + + */ +class CartLineFactory extends Factory +{ + protected $model = CartLine::class; + + /** + * @return array + */ + public function definition(): array + { + $unitPrice = fake()->numberBetween(500, 10000); + $quantity = fake()->numberBetween(1, 5); + + return [ + 'cart_id' => Cart::factory(), + 'variant_id' => ProductVariant::factory(), + 'quantity' => $quantity, + 'unit_price_amount' => $unitPrice, + 'line_subtotal_amount' => $unitPrice * $quantity, + 'line_discount_amount' => 0, + 'line_total_amount' => $unitPrice * $quantity, + ]; + } +} diff --git a/database/factories/CheckoutFactory.php b/database/factories/CheckoutFactory.php new file mode 100644 index 00000000..736ba4f7 --- /dev/null +++ b/database/factories/CheckoutFactory.php @@ -0,0 +1,70 @@ + + */ +class CheckoutFactory extends Factory +{ + protected $model = Checkout::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'cart_id' => Cart::factory(), + 'customer_id' => null, + 'status' => 'started', + ]; + } + + public function addressed(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'addressed', + 'email' => fake()->safeEmail(), + 'shipping_address_json' => json_encode($this->sampleAddress()), + 'billing_address_json' => json_encode($this->sampleAddress()), + ]); + } + + public function shippingSelected(): static + { + return $this->addressed()->state(fn (array $attributes) => [ + 'status' => 'shipping_selected', + ]); + } + + public function paymentSelected(): static + { + return $this->shippingSelected()->state(fn (array $attributes) => [ + 'status' => 'payment_selected', + 'payment_method' => 'credit_card', + 'expires_at' => now()->addHours(24)->toIso8601String(), + ]); + } + + /** + * @return array + */ + private function sampleAddress(): array + { + return [ + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'address1' => fake()->streetAddress(), + 'city' => fake()->city(), + 'country' => 'DE', + 'postal_code' => fake()->postcode(), + ]; + } +} diff --git a/database/factories/CollectionFactory.php b/database/factories/CollectionFactory.php new file mode 100644 index 00000000..52459025 --- /dev/null +++ b/database/factories/CollectionFactory.php @@ -0,0 +1,40 @@ + + */ +class CollectionFactory extends Factory +{ + protected $model = Collection::class; + + /** + * @return array + */ + public function definition(): array + { + $title = fake()->unique()->words(2, true); + + return [ + 'store_id' => Store::factory(), + 'title' => $title, + 'handle' => Str::slug($title), + 'description_html' => '

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

', + 'type' => 'manual', + 'status' => 'active', + ]; + } + + public function draft(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'draft', + ]); + } +} diff --git a/database/factories/CustomerAddressFactory.php b/database/factories/CustomerAddressFactory.php new file mode 100644 index 00000000..28f7a328 --- /dev/null +++ b/database/factories/CustomerAddressFactory.php @@ -0,0 +1,48 @@ + + */ +class CustomerAddressFactory extends Factory +{ + protected $model = CustomerAddress::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'customer_id' => Customer::factory(), + 'label' => fake()->randomElement(['Home', 'Work', 'Other']), + 'address_json' => [ + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'company' => fake()->optional()->company(), + 'address1' => fake()->streetAddress(), + 'address2' => null, + 'city' => fake()->city(), + 'province' => fake()->state(), + 'province_code' => fake()->stateAbbr(), + 'country' => 'United States', + 'country_code' => 'US', + 'zip' => fake()->postcode(), + 'phone' => fake()->optional()->phoneNumber(), + ], + 'is_default' => false, + ]; + } + + public function default(): static + { + return $this->state(fn (array $attributes) => [ + 'is_default' => true, + ]); + } +} diff --git a/database/factories/CustomerFactory.php b/database/factories/CustomerFactory.php new file mode 100644 index 00000000..d8902b1c --- /dev/null +++ b/database/factories/CustomerFactory.php @@ -0,0 +1,39 @@ + + */ +class CustomerFactory extends Factory +{ + protected $model = Customer::class; + + protected static ?string $password; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'email' => fake()->unique()->safeEmail(), + 'password' => static::$password ??= Hash::make('password'), + 'name' => fake()->name(), + 'marketing_opt_in' => false, + ]; + } + + public function withMarketing(): static + { + return $this->state(fn (array $attributes) => [ + 'marketing_opt_in' => true, + ]); + } +} diff --git a/database/factories/DiscountFactory.php b/database/factories/DiscountFactory.php new file mode 100644 index 00000000..c20ef78f --- /dev/null +++ b/database/factories/DiscountFactory.php @@ -0,0 +1,76 @@ + + */ +class DiscountFactory extends Factory +{ + protected $model = Discount::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'code' => strtoupper(fake()->unique()->bothify('??##??')), + 'type' => 'code', + 'value_type' => 'percent', + 'value_amount' => 10, + 'status' => 'active', + 'starts_at' => now()->subDay()->toIso8601String(), + 'ends_at' => now()->addMonth()->toIso8601String(), + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [], + 'minimum_purchase_amount' => null, + ]; + } + + public function fixed(int $amount = 500): static + { + return $this->state(fn (array $attributes) => [ + 'value_type' => 'fixed', + 'value_amount' => $amount, + ]); + } + + public function freeShipping(): static + { + return $this->state(fn (array $attributes) => [ + 'value_type' => 'free_shipping', + 'value_amount' => 0, + ]); + } + + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'expired', + 'starts_at' => now()->subMonth()->toIso8601String(), + 'ends_at' => now()->subDay()->toIso8601String(), + ]); + } + + public function disabled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'disabled', + ]); + } + + public function automatic(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'automatic', + 'code' => null, + ]); + } +} diff --git a/database/factories/FulfillmentFactory.php b/database/factories/FulfillmentFactory.php new file mode 100644 index 00000000..8bac5d01 --- /dev/null +++ b/database/factories/FulfillmentFactory.php @@ -0,0 +1,51 @@ + + */ +class FulfillmentFactory extends Factory +{ + protected $model = Fulfillment::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'status' => FulfillmentShipmentStatus::Pending, + 'tracking_company' => null, + 'tracking_number' => null, + 'tracking_url' => null, + 'shipped_at' => null, + 'delivered_at' => null, + 'created_at' => now()->toIso8601String(), + ]; + } + + public function shipped(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => FulfillmentShipmentStatus::Shipped, + 'tracking_company' => 'DHL', + 'tracking_number' => fake()->bothify('??########'), + 'shipped_at' => now()->toIso8601String(), + ]); + } + + public function delivered(): static + { + return $this->shipped()->state(fn (array $attributes) => [ + 'status' => FulfillmentShipmentStatus::Delivered, + 'delivered_at' => now()->toIso8601String(), + ]); + } +} diff --git a/database/factories/FulfillmentLineFactory.php b/database/factories/FulfillmentLineFactory.php new file mode 100644 index 00000000..7b8a5105 --- /dev/null +++ b/database/factories/FulfillmentLineFactory.php @@ -0,0 +1,28 @@ + + */ +class FulfillmentLineFactory extends Factory +{ + protected $model = FulfillmentLine::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'fulfillment_id' => Fulfillment::factory(), + 'order_line_id' => OrderLine::factory(), + 'quantity' => 1, + ]; + } +} diff --git a/database/factories/InventoryItemFactory.php b/database/factories/InventoryItemFactory.php new file mode 100644 index 00000000..8423ba43 --- /dev/null +++ b/database/factories/InventoryItemFactory.php @@ -0,0 +1,30 @@ + + */ +class InventoryItemFactory extends Factory +{ + protected $model = InventoryItem::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'variant_id' => ProductVariant::factory(), + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]; + } +} diff --git a/database/factories/NavigationItemFactory.php b/database/factories/NavigationItemFactory.php new file mode 100644 index 00000000..2f2c766d --- /dev/null +++ b/database/factories/NavigationItemFactory.php @@ -0,0 +1,30 @@ + + */ +class NavigationItemFactory extends Factory +{ + protected $model = NavigationItem::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'menu_id' => NavigationMenu::factory(), + 'type' => 'link', + 'label' => fake()->words(2, true), + 'url' => '/'.fake()->slug(2), + 'resource_id' => null, + 'position' => fake()->numberBetween(0, 10), + ]; + } +} diff --git a/database/factories/NavigationMenuFactory.php b/database/factories/NavigationMenuFactory.php new file mode 100644 index 00000000..89a1b5a2 --- /dev/null +++ b/database/factories/NavigationMenuFactory.php @@ -0,0 +1,30 @@ + + */ +class NavigationMenuFactory extends Factory +{ + protected $model = NavigationMenu::class; + + /** + * @return array + */ + public function definition(): array + { + $title = fake()->unique()->words(2, true); + + return [ + 'store_id' => Store::factory(), + 'handle' => Str::slug($title), + 'title' => ucwords($title), + ]; + } +} diff --git a/database/factories/OauthClientFactory.php b/database/factories/OauthClientFactory.php new file mode 100644 index 00000000..c69a36ca --- /dev/null +++ b/database/factories/OauthClientFactory.php @@ -0,0 +1,28 @@ + + */ +class OauthClientFactory extends Factory +{ + protected $model = OauthClient::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'app_id' => App::factory(), + 'client_id' => fake()->unique()->uuid(), + 'client_secret_encrypted' => fake()->sha256(), + '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..62ab0721 --- /dev/null +++ b/database/factories/OauthTokenFactory.php @@ -0,0 +1,28 @@ + + */ +class OauthTokenFactory extends Factory +{ + protected $model = OauthToken::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'installation_id' => AppInstallation::factory(), + 'access_token_hash' => hash('sha256', fake()->unique()->uuid()), + 'refresh_token_hash' => hash('sha256', fake()->uuid()), + 'expires_at' => now()->addHour()->toIso8601String(), + ]; + } +} diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php new file mode 100644 index 00000000..d910adb0 --- /dev/null +++ b/database/factories/OrderFactory.php @@ -0,0 +1,94 @@ + + */ +class OrderFactory extends Factory +{ + protected $model = Order::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'customer_id' => Customer::factory(), + 'order_number' => '#'.fake()->unique()->numberBetween(1001, 99999), + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Paid, + 'financial_status' => FinancialStatus::Paid, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled, + 'currency' => 'USD', + 'subtotal_amount' => 5000, + 'discount_amount' => 0, + 'shipping_amount' => 500, + 'tax_amount' => 475, + 'total_amount' => 5975, + 'email' => fake()->safeEmail(), + 'billing_address_json' => [ + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'address1' => fake()->streetAddress(), + 'city' => fake()->city(), + 'country' => 'US', + 'zip' => fake()->postcode(), + ], + 'shipping_address_json' => [ + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'address1' => fake()->streetAddress(), + 'city' => fake()->city(), + 'country' => 'US', + 'zip' => fake()->postcode(), + ], + 'placed_at' => now()->toIso8601String(), + ]; + } + + public function pending(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + 'payment_method' => PaymentMethod::BankTransfer, + ]); + } + + public function paid(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => OrderStatus::Paid, + 'financial_status' => FinancialStatus::Paid, + ]); + } + + public function cancelled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => OrderStatus::Cancelled, + 'financial_status' => FinancialStatus::Voided, + ]); + } + + public function fulfilled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => OrderStatus::Fulfilled, + 'financial_status' => FinancialStatus::Paid, + 'fulfillment_status' => FulfillmentStatus::Fulfilled, + ]); + } +} diff --git a/database/factories/OrderLineFactory.php b/database/factories/OrderLineFactory.php new file mode 100644 index 00000000..d2b53345 --- /dev/null +++ b/database/factories/OrderLineFactory.php @@ -0,0 +1,36 @@ + + */ +class OrderLineFactory extends Factory +{ + protected $model = OrderLine::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'product_id' => Product::factory(), + 'variant_id' => ProductVariant::factory(), + 'title_snapshot' => fake()->words(3, true), + 'sku_snapshot' => fake()->optional()->bothify('SKU-####'), + 'quantity' => 1, + 'unit_price_amount' => 2500, + 'total_amount' => 2500, + 'tax_lines_json' => [], + 'discount_allocations_json' => [], + ]; + } +} diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php new file mode 100644 index 00000000..b77d4279 --- /dev/null +++ b/database/factories/OrganizationFactory.php @@ -0,0 +1,25 @@ + + */ +class OrganizationFactory extends Factory +{ + protected $model = Organization::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->company(), + 'billing_email' => fake()->unique()->companyEmail(), + ]; + } +} diff --git a/database/factories/PageFactory.php b/database/factories/PageFactory.php new file mode 100644 index 00000000..7f5f00db --- /dev/null +++ b/database/factories/PageFactory.php @@ -0,0 +1,40 @@ + + */ +class PageFactory extends Factory +{ + protected $model = Page::class; + + /** + * @return array + */ + public function definition(): array + { + $title = fake()->unique()->words(3, true); + + return [ + 'store_id' => Store::factory(), + 'title' => ucwords($title), + 'handle' => Str::slug($title), + 'body_html' => '

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

', + 'status' => 'draft', + ]; + } + + public function published(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'published', + 'published_at' => now()->toIso8601String(), + ]); + } +} diff --git a/database/factories/PaymentFactory.php b/database/factories/PaymentFactory.php new file mode 100644 index 00000000..dc4902e8 --- /dev/null +++ b/database/factories/PaymentFactory.php @@ -0,0 +1,49 @@ + + */ +class PaymentFactory extends Factory +{ + protected $model = Payment::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'provider' => 'mock', + 'method' => PaymentMethod::CreditCard, + 'provider_payment_id' => 'mock_'.fake()->uuid(), + 'status' => PaymentStatus::Captured, + 'amount' => 5975, + 'currency' => 'USD', + 'created_at' => now()->toIso8601String(), + ]; + } + + public function pending(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PaymentStatus::Pending, + 'method' => PaymentMethod::BankTransfer, + ]); + } + + public function failed(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PaymentStatus::Failed, + ]); + } +} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php new file mode 100644 index 00000000..46652d23 --- /dev/null +++ b/database/factories/ProductFactory.php @@ -0,0 +1,50 @@ + + */ +class ProductFactory extends Factory +{ + protected $model = Product::class; + + /** + * @return array + */ + public function definition(): array + { + $title = fake()->unique()->words(3, true); + + return [ + 'store_id' => Store::factory(), + 'title' => $title, + 'handle' => Str::slug($title), + 'status' => 'draft', + 'description_html' => '

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

', + 'vendor' => fake()->company(), + 'product_type' => fake()->randomElement(['Apparel', 'Electronics', 'Home', 'Sports']), + 'tags' => [fake()->word(), fake()->word()], + ]; + } + + public function active(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'active', + 'published_at' => now()->toIso8601String(), + ]); + } + + public function archived(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'archived', + ]); + } +} diff --git a/database/factories/ProductMediaFactory.php b/database/factories/ProductMediaFactory.php new file mode 100644 index 00000000..950b5bac --- /dev/null +++ b/database/factories/ProductMediaFactory.php @@ -0,0 +1,45 @@ + + */ +class ProductMediaFactory extends Factory +{ + protected $model = ProductMedia::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'type' => 'image', + 'storage_key' => 'products/'.fake()->uuid().'.jpg', + 'alt_text' => fake()->sentence(3), + 'width' => 1200, + 'height' => 800, + 'mime_type' => 'image/jpeg', + 'byte_size' => fake()->numberBetween(50000, 500000), + 'position' => 0, + 'status' => 'ready', + ]; + } + + public function processing(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'processing', + 'width' => null, + 'height' => null, + 'mime_type' => null, + 'byte_size' => null, + ]); + } +} diff --git a/database/factories/ProductOptionFactory.php b/database/factories/ProductOptionFactory.php new file mode 100644 index 00000000..67acc66e --- /dev/null +++ b/database/factories/ProductOptionFactory.php @@ -0,0 +1,27 @@ + + */ +class ProductOptionFactory extends Factory +{ + protected $model = ProductOption::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'name' => fake()->randomElement(['Size', 'Color', 'Material']), + 'position' => 0, + ]; + } +} diff --git a/database/factories/ProductOptionValueFactory.php b/database/factories/ProductOptionValueFactory.php new file mode 100644 index 00000000..03b5fee6 --- /dev/null +++ b/database/factories/ProductOptionValueFactory.php @@ -0,0 +1,27 @@ + + */ +class ProductOptionValueFactory extends Factory +{ + protected $model = ProductOptionValue::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'product_option_id' => ProductOption::factory(), + 'value' => fake()->randomElement(['S', 'M', 'L', 'XL', 'Red', 'Blue', 'Green']), + 'position' => 0, + ]; + } +} diff --git a/database/factories/ProductVariantFactory.php b/database/factories/ProductVariantFactory.php new file mode 100644 index 00000000..2de370cf --- /dev/null +++ b/database/factories/ProductVariantFactory.php @@ -0,0 +1,49 @@ + + */ +class ProductVariantFactory extends Factory +{ + protected $model = ProductVariant::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'sku' => null, + 'barcode' => null, + 'price_amount' => fake()->numberBetween(500, 50000), + 'compare_at_amount' => null, + 'currency' => 'USD', + 'weight_g' => fake()->numberBetween(100, 5000), + 'requires_shipping' => true, + 'is_default' => false, + 'position' => 0, + 'status' => 'active', + ]; + } + + public function default(): static + { + return $this->state(fn (array $attributes) => [ + 'is_default' => true, + ]); + } + + public function priced(int $amount = 2999): static + { + return $this->state(fn (array $attributes) => [ + 'price_amount' => $amount, + ]); + } +} diff --git a/database/factories/RefundFactory.php b/database/factories/RefundFactory.php new file mode 100644 index 00000000..d8f24b99 --- /dev/null +++ b/database/factories/RefundFactory.php @@ -0,0 +1,33 @@ + + */ +class RefundFactory extends Factory +{ + protected $model = Refund::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'payment_id' => Payment::factory(), + 'amount' => 2500, + 'reason' => fake()->optional()->sentence(), + 'status' => RefundStatus::Processed, + 'provider_refund_id' => 'mock_refund_'.fake()->uuid(), + 'created_at' => now()->toIso8601String(), + ]; + } +} diff --git a/database/factories/ShippingRateFactory.php b/database/factories/ShippingRateFactory.php new file mode 100644 index 00000000..b94ff669 --- /dev/null +++ b/database/factories/ShippingRateFactory.php @@ -0,0 +1,57 @@ + + */ +class ShippingRateFactory extends Factory +{ + protected $model = ShippingRate::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'zone_id' => ShippingZone::factory(), + 'name' => 'Standard Shipping', + 'type' => 'flat', + 'config_json' => ['amount' => 499], + 'is_active' => true, + ]; + } + + public function weightBased(): static + { + return $this->state(fn (array $attributes) => [ + 'name' => 'Weight-Based Shipping', + 'type' => 'weight', + 'config_json' => [ + 'ranges' => [ + ['min_g' => 0, 'max_g' => 500, 'amount' => 499], + ['min_g' => 501, 'max_g' => 2000, 'amount' => 899], + ], + ], + ]); + } + + public function priceBased(): static + { + return $this->state(fn (array $attributes) => [ + 'name' => 'Price-Based Shipping', + 'type' => 'price', + 'config_json' => [ + 'ranges' => [ + ['min_amount' => 0, 'max_amount' => 5000, 'amount' => 799], + ['min_amount' => 5001, 'amount' => 0], + ], + ], + ]); + } +} diff --git a/database/factories/ShippingZoneFactory.php b/database/factories/ShippingZoneFactory.php new file mode 100644 index 00000000..3114a311 --- /dev/null +++ b/database/factories/ShippingZoneFactory.php @@ -0,0 +1,37 @@ + + */ +class ShippingZoneFactory extends Factory +{ + protected $model = ShippingZone::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'name' => 'Domestic', + 'countries_json' => ['DE'], + 'regions_json' => [], + 'is_active' => true, + ]; + } + + public function international(): static + { + return $this->state(fn (array $attributes) => [ + 'name' => 'International', + 'countries_json' => ['US', 'GB', 'FR'], + ]); + } +} diff --git a/database/factories/StoreDomainFactory.php b/database/factories/StoreDomainFactory.php new file mode 100644 index 00000000..240792cc --- /dev/null +++ b/database/factories/StoreDomainFactory.php @@ -0,0 +1,50 @@ + + */ +class StoreDomainFactory extends Factory +{ + protected $model = StoreDomain::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'hostname' => fake()->unique()->domainName(), + 'type' => 'storefront', + 'is_primary' => false, + 'tls_mode' => 'managed', + ]; + } + + public function primary(): static + { + return $this->state(fn (array $attributes) => [ + 'is_primary' => true, + ]); + } + + public function admin(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'admin', + ]); + } + + public function api(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'api', + ]); + } +} diff --git a/database/factories/StoreFactory.php b/database/factories/StoreFactory.php new file mode 100644 index 00000000..514b5534 --- /dev/null +++ b/database/factories/StoreFactory.php @@ -0,0 +1,41 @@ + + */ +class StoreFactory extends Factory +{ + protected $model = Store::class; + + /** + * @return array + */ + public function definition(): array + { + $name = fake()->unique()->company().' Store'; + + return [ + 'organization_id' => Organization::factory(), + 'name' => $name, + 'handle' => Str::slug($name).'-'.fake()->unique()->randomNumber(4), + 'status' => 'active', + 'default_currency' => 'USD', + 'default_locale' => 'en', + 'timezone' => 'UTC', + ]; + } + + public function suspended(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'suspended', + ]); + } +} diff --git a/database/factories/StoreSettingsFactory.php b/database/factories/StoreSettingsFactory.php new file mode 100644 index 00000000..25dbdc36 --- /dev/null +++ b/database/factories/StoreSettingsFactory.php @@ -0,0 +1,29 @@ + + */ +class StoreSettingsFactory extends Factory +{ + protected $model = StoreSettings::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'settings_json' => [ + 'notifications_email' => fake()->safeEmail(), + 'order_prefix' => strtoupper(fake()->lexify('???')), + ], + ]; + } +} diff --git a/database/factories/TaxSettingsFactory.php b/database/factories/TaxSettingsFactory.php new file mode 100644 index 00000000..3c78a01a --- /dev/null +++ b/database/factories/TaxSettingsFactory.php @@ -0,0 +1,46 @@ + + */ +class TaxSettingsFactory extends Factory +{ + protected $model = TaxSettings::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'mode' => 'manual', + 'provider' => 'none', + 'rate' => 1900, + 'prices_include_tax' => false, + 'tax_name' => 'VAT', + 'is_active' => true, + 'config_json' => [], + ]; + } + + public function inclusive(): static + { + return $this->state(fn (array $attributes) => [ + 'prices_include_tax' => true, + ]); + } + + public function zeroRate(): static + { + return $this->state(fn (array $attributes) => [ + 'rate' => 0, + ]); + } +} diff --git a/database/factories/ThemeFactory.php b/database/factories/ThemeFactory.php new file mode 100644 index 00000000..55d757fb --- /dev/null +++ b/database/factories/ThemeFactory.php @@ -0,0 +1,36 @@ + + */ +class ThemeFactory extends Factory +{ + protected $model = Theme::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'name' => fake()->words(2, true).' Theme', + 'version' => fake()->semver(), + 'status' => 'draft', + ]; + } + + public function published(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'published', + 'published_at' => now()->toIso8601String(), + ]); + } +} diff --git a/database/factories/ThemeFileFactory.php b/database/factories/ThemeFileFactory.php new file mode 100644 index 00000000..d45bb879 --- /dev/null +++ b/database/factories/ThemeFileFactory.php @@ -0,0 +1,32 @@ + + */ +class ThemeFileFactory extends Factory +{ + protected $model = ThemeFile::class; + + /** + * @return array + */ + public function definition(): array + { + $path = 'templates/'.fake()->unique()->word().'.html'; + + return [ + 'theme_id' => Theme::factory(), + 'path' => $path, + 'storage_key' => 'themes/'.Str::random(32), + 'sha256' => hash('sha256', fake()->text()), + 'byte_size' => fake()->numberBetween(100, 50000), + ]; + } +} diff --git a/database/factories/ThemeSettingsFactory.php b/database/factories/ThemeSettingsFactory.php new file mode 100644 index 00000000..1fa20aa6 --- /dev/null +++ b/database/factories/ThemeSettingsFactory.php @@ -0,0 +1,66 @@ + + */ +class ThemeSettingsFactory extends Factory +{ + protected $model = ThemeSettings::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'theme_id' => Theme::factory(), + 'settings_json' => [ + 'announcement_bar' => [ + 'enabled' => true, + 'text' => 'Free shipping on orders over 50 EUR', + 'link' => null, + ], + 'header' => [ + 'sticky' => true, + 'logo_url' => null, + ], + 'footer' => [ + 'social_links' => [], + ], + 'dark_mode' => 'system', + 'sections' => [ + 'hero' => [ + 'enabled' => true, + 'heading' => 'Welcome to our store', + 'subheading' => 'Discover amazing products', + 'cta_text' => 'Shop now', + 'cta_link' => '/collections', + 'background_image' => null, + ], + 'featured_collections' => [ + 'enabled' => true, + 'collection_ids' => [], + ], + 'featured_products' => [ + 'enabled' => true, + 'product_ids' => [], + ], + 'newsletter' => [ + 'enabled' => true, + ], + 'rich_text' => [ + 'enabled' => false, + 'content' => '', + ], + ], + 'section_order' => ['hero', 'featured_collections', 'featured_products', 'newsletter', 'rich_text'], + ], + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 80da5ac7..7aab0d38 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -29,6 +29,7 @@ public function definition(): array 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), + 'status' => 'active', 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, 'two_factor_confirmed_at' => null, diff --git a/database/factories/WebhookDeliveryFactory.php b/database/factories/WebhookDeliveryFactory.php new file mode 100644 index 00000000..06005857 --- /dev/null +++ b/database/factories/WebhookDeliveryFactory.php @@ -0,0 +1,49 @@ + + */ +class WebhookDeliveryFactory extends Factory +{ + protected $model = WebhookDelivery::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'subscription_id' => WebhookSubscription::factory(), + 'event_id' => fake()->uuid(), + 'attempt_count' => 1, + 'status' => 'pending', + 'last_attempt_at' => null, + 'response_code' => null, + 'response_body_snippet' => null, + ]; + } + + public function success(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'success', + 'response_code' => 200, + 'last_attempt_at' => now()->toIso8601String(), + ]); + } + + public function failed(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'failed', + 'response_code' => 500, + 'last_attempt_at' => now()->toIso8601String(), + ]); + } +} diff --git a/database/factories/WebhookSubscriptionFactory.php b/database/factories/WebhookSubscriptionFactory.php new file mode 100644 index 00000000..17878295 --- /dev/null +++ b/database/factories/WebhookSubscriptionFactory.php @@ -0,0 +1,48 @@ + + */ +class WebhookSubscriptionFactory extends Factory +{ + protected $model = WebhookSubscription::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'app_installation_id' => null, + 'event_type' => fake()->randomElement([ + 'order.created', 'order.paid', 'order.fulfilled', 'order.refunded', + 'product.created', 'product.updated', 'product.deleted', + 'checkout.completed', + ]), + 'target_url' => fake()->url().'/webhooks', + 'signing_secret_encrypted' => fake()->sha256(), + 'status' => 'active', + ]; + } + + public function paused(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'paused', + ]); + } + + public function disabled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'disabled', + ]); + } +} diff --git a/database/migrations/2026_03_20_062606_create_organizations_table.php b/database/migrations/2026_03_20_062606_create_organizations_table.php new file mode 100644 index 00000000..bd71e3da --- /dev/null +++ b/database/migrations/2026_03_20_062606_create_organizations_table.php @@ -0,0 +1,31 @@ +id(); + $table->text('name'); + $table->text('billing_email'); + $table->timestamps(); + + $table->index('billing_email', 'idx_organizations_billing_email'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('organizations'); + } +}; diff --git a/database/migrations/2026_03_20_062611_add_status_and_login_columns_to_users_table.php b/database/migrations/2026_03_20_062611_add_status_and_login_columns_to_users_table.php new file mode 100644 index 00000000..00701aff --- /dev/null +++ b/database/migrations/2026_03_20_062611_add_status_and_login_columns_to_users_table.php @@ -0,0 +1,32 @@ +text('status')->default('active')->after('password'); + $table->timestamp('last_login_at')->nullable()->after('status'); + + $table->index('status', 'idx_users_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropIndex('idx_users_status'); + $table->dropColumn(['status', 'last_login_at']); + }); + } +}; diff --git a/database/migrations/2026_03_20_062611_create_stores_table.php b/database/migrations/2026_03_20_062611_create_stores_table.php new file mode 100644 index 00000000..a267bac4 --- /dev/null +++ b/database/migrations/2026_03_20_062611_create_stores_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('organization_id')->constrained('organizations')->cascadeOnDelete(); + $table->text('name'); + $table->text('handle')->unique('idx_stores_handle'); + $table->text('status')->default('active'); + $table->text('default_currency')->default('USD'); + $table->text('default_locale')->default('en'); + $table->text('timezone')->default('UTC'); + $table->timestamps(); + + $table->index('organization_id', 'idx_stores_organization_id'); + $table->index('status', 'idx_stores_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('stores'); + } +}; diff --git a/database/migrations/2026_03_20_062612_create_customer_password_reset_tokens_table.php b/database/migrations/2026_03_20_062612_create_customer_password_reset_tokens_table.php new file mode 100644 index 00000000..cc7e92b7 --- /dev/null +++ b/database/migrations/2026_03_20_062612_create_customer_password_reset_tokens_table.php @@ -0,0 +1,32 @@ +string('email'); + $table->unsignedBigInteger('store_id'); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + + $table->primary(['email', 'store_id']); + $table->foreign('store_id')->references('id')->on('stores')->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('customer_password_reset_tokens'); + } +}; diff --git a/database/migrations/2026_03_20_062612_create_store_settings_table.php b/database/migrations/2026_03_20_062612_create_store_settings_table.php new file mode 100644 index 00000000..1969769f --- /dev/null +++ b/database/migrations/2026_03_20_062612_create_store_settings_table.php @@ -0,0 +1,29 @@ +unsignedBigInteger('store_id')->primary(); + $table->foreign('store_id')->references('id')->on('stores')->cascadeOnDelete(); + $table->text('settings_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('store_settings'); + } +}; diff --git a/database/migrations/2026_03_20_062612_create_store_users_table.php b/database/migrations/2026_03_20_062612_create_store_users_table.php new file mode 100644 index 00000000..a82bb82e --- /dev/null +++ b/database/migrations/2026_03_20_062612_create_store_users_table.php @@ -0,0 +1,33 @@ +foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->text('role')->default('staff'); + $table->timestamp('created_at')->nullable(); + + $table->primary(['store_id', 'user_id']); + $table->index('user_id', 'idx_store_users_user_id'); + $table->index(['store_id', 'role'], 'idx_store_users_role'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('store_users'); + } +}; diff --git a/database/migrations/2026_03_20_062613_create_store_domains_table.php b/database/migrations/2026_03_20_062613_create_store_domains_table.php new file mode 100644 index 00000000..d4f24fcd --- /dev/null +++ b/database/migrations/2026_03_20_062613_create_store_domains_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->text('hostname')->unique('idx_store_domains_hostname'); + $table->text('type')->default('storefront'); + $table->integer('is_primary')->default(0); + $table->text('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'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('store_domains'); + } +}; diff --git a/database/migrations/2026_03_20_062913_create_customers_table.php b/database/migrations/2026_03_20_062913_create_customers_table.php new file mode 100644 index 00000000..6fd987ba --- /dev/null +++ b/database/migrations/2026_03_20_062913_create_customers_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->text('email'); + $table->string('password')->nullable(); + $table->text('name')->nullable(); + $table->integer('marketing_opt_in')->default(0); + $table->timestamps(); + + $table->unique(['store_id', 'email'], 'idx_customers_store_email'); + $table->index('store_id', 'idx_customers_store_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('customers'); + } +}; diff --git a/database/migrations/2026_03_20_073214_create_product_options_table.php b/database/migrations/2026_03_20_073214_create_product_options_table.php new file mode 100644 index 00000000..64af1ca0 --- /dev/null +++ b/database/migrations/2026_03_20_073214_create_product_options_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->text('name'); + $table->integer('position')->default(0); + + $table->index('product_id', 'idx_product_options_product_id'); + $table->unique(['product_id', 'position'], 'idx_product_options_product_position'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_options'); + } +}; diff --git a/database/migrations/2026_03_20_073214_create_products_table.php b/database/migrations/2026_03_20_073214_create_products_table.php new file mode 100644 index 00000000..3fe1ce9d --- /dev/null +++ b/database/migrations/2026_03_20_073214_create_products_table.php @@ -0,0 +1,57 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->text('title'); + $table->text('handle'); + $table->text('status')->default('draft'); + $table->text('description_html')->nullable(); + $table->text('vendor')->nullable(); + $table->text('product_type')->nullable(); + $table->text('tags')->default('[]'); + $table->text('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'); + }); + + DB::statement("CREATE TRIGGER products_status_check BEFORE INSERT ON products + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('draft', 'active', 'archived') + THEN RAISE(ABORT, 'Invalid product status') + END; + END;"); + + DB::statement("CREATE TRIGGER products_status_check_update BEFORE UPDATE ON products + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('draft', 'active', 'archived') + THEN RAISE(ABORT, 'Invalid product status') + END; + END;"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('products'); + } +}; diff --git a/database/migrations/2026_03_20_073215_create_product_option_values_table.php b/database/migrations/2026_03_20_073215_create_product_option_values_table.php new file mode 100644 index 00000000..69a0a623 --- /dev/null +++ b/database/migrations/2026_03_20_073215_create_product_option_values_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('product_option_id')->constrained('product_options')->cascadeOnDelete(); + $table->text('value'); + $table->integer('position')->default(0); + + $table->index('product_option_id', 'idx_product_option_values_option_id'); + $table->unique(['product_option_id', 'position'], 'idx_product_option_values_option_position'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_option_values'); + } +}; diff --git a/database/migrations/2026_03_20_073216_create_product_variants_table.php b/database/migrations/2026_03_20_073216_create_product_variants_table.php new file mode 100644 index 00000000..761c9050 --- /dev/null +++ b/database/migrations/2026_03_20_073216_create_product_variants_table.php @@ -0,0 +1,58 @@ +id(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->text('sku')->nullable(); + $table->text('barcode')->nullable(); + $table->integer('price_amount')->default(0); + $table->integer('compare_at_amount')->nullable(); + $table->text('currency')->default('USD'); + $table->integer('weight_g')->nullable(); + $table->integer('requires_shipping')->default(1); + $table->integer('is_default')->default(0); + $table->integer('position')->default(0); + $table->text('status')->default('active'); + $table->timestamps(); + + $table->index('product_id', 'idx_product_variants_product_id'); + $table->index('sku', 'idx_product_variants_sku'); + $table->index('barcode', 'idx_product_variants_barcode'); + $table->index(['product_id', 'position'], 'idx_product_variants_product_position'); + $table->index(['product_id', 'is_default'], 'idx_product_variants_product_default'); + }); + + DB::statement("CREATE TRIGGER product_variants_status_check BEFORE INSERT ON product_variants + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('active', 'archived') + THEN RAISE(ABORT, 'Invalid variant status') + END; + END;"); + + DB::statement("CREATE TRIGGER product_variants_status_check_update BEFORE UPDATE ON product_variants + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('active', 'archived') + THEN RAISE(ABORT, 'Invalid variant status') + END; + END;"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_variants'); + } +}; diff --git a/database/migrations/2026_03_20_073216_create_variant_option_values_table.php b/database/migrations/2026_03_20_073216_create_variant_option_values_table.php new file mode 100644 index 00000000..ae39a329 --- /dev/null +++ b/database/migrations/2026_03_20_073216_create_variant_option_values_table.php @@ -0,0 +1,30 @@ +foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->foreignId('product_option_value_id')->constrained('product_option_values')->cascadeOnDelete(); + + $table->primary(['variant_id', 'product_option_value_id']); + $table->index('product_option_value_id', 'idx_variant_option_values_value_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('variant_option_values'); + } +}; diff --git a/database/migrations/2026_03_20_073217_create_collections_table.php b/database/migrations/2026_03_20_073217_create_collections_table.php new file mode 100644 index 00000000..3c10fa4f --- /dev/null +++ b/database/migrations/2026_03_20_073217_create_collections_table.php @@ -0,0 +1,65 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->text('title'); + $table->text('handle'); + $table->text('description_html')->nullable(); + $table->text('type')->default('manual'); + $table->text('status')->default('active'); + $table->timestamps(); + + $table->unique(['store_id', 'handle'], 'idx_collections_store_handle'); + $table->index('store_id', 'idx_collections_store_id'); + $table->index(['store_id', 'status'], 'idx_collections_store_status'); + }); + + DB::statement("CREATE TRIGGER collections_status_check BEFORE INSERT ON collections + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('draft', 'active', 'archived') + THEN RAISE(ABORT, 'Invalid collection status') + END; + END;"); + + DB::statement("CREATE TRIGGER collections_status_check_update BEFORE UPDATE ON collections + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('draft', 'active', 'archived') + THEN RAISE(ABORT, 'Invalid collection status') + END; + END;"); + + DB::statement("CREATE TRIGGER collections_type_check BEFORE INSERT ON collections + BEGIN + SELECT CASE WHEN NEW.type NOT IN ('manual', 'automated') + THEN RAISE(ABORT, 'Invalid collection type') + END; + END;"); + + DB::statement("CREATE TRIGGER collections_type_check_update BEFORE UPDATE ON collections + BEGIN + SELECT CASE WHEN NEW.type NOT IN ('manual', 'automated') + THEN RAISE(ABORT, 'Invalid collection type') + END; + END;"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('collections'); + } +}; diff --git a/database/migrations/2026_03_20_073217_create_inventory_items_table.php b/database/migrations/2026_03_20_073217_create_inventory_items_table.php new file mode 100644 index 00000000..175072f7 --- /dev/null +++ b/database/migrations/2026_03_20_073217_create_inventory_items_table.php @@ -0,0 +1,47 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('variant_id')->unique('idx_inventory_items_variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->integer('quantity_on_hand')->default(0); + $table->integer('quantity_reserved')->default(0); + $table->text('policy')->default('deny'); + + $table->index('store_id', 'idx_inventory_items_store_id'); + }); + + DB::statement("CREATE TRIGGER inventory_items_policy_check BEFORE INSERT ON inventory_items + BEGIN + SELECT CASE WHEN NEW.policy NOT IN ('deny', 'continue') + THEN RAISE(ABORT, 'Invalid inventory policy') + END; + END;"); + + DB::statement("CREATE TRIGGER inventory_items_policy_check_update BEFORE UPDATE ON inventory_items + BEGIN + SELECT CASE WHEN NEW.policy NOT IN ('deny', 'continue') + THEN RAISE(ABORT, 'Invalid inventory policy') + END; + END;"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('inventory_items'); + } +}; diff --git a/database/migrations/2026_03_20_073218_create_collection_products_table.php b/database/migrations/2026_03_20_073218_create_collection_products_table.php new file mode 100644 index 00000000..0b618315 --- /dev/null +++ b/database/migrations/2026_03_20_073218_create_collection_products_table.php @@ -0,0 +1,32 @@ +foreignId('collection_id')->constrained('collections')->cascadeOnDelete(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->integer('position')->default(0); + + $table->primary(['collection_id', 'product_id']); + $table->index('product_id', 'idx_collection_products_product_id'); + $table->index(['collection_id', 'position'], 'idx_collection_products_position'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('collection_products'); + } +}; diff --git a/database/migrations/2026_03_20_073219_create_product_media_table.php b/database/migrations/2026_03_20_073219_create_product_media_table.php new file mode 100644 index 00000000..cf200178 --- /dev/null +++ b/database/migrations/2026_03_20_073219_create_product_media_table.php @@ -0,0 +1,69 @@ +id(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->text('type')->default('image'); + $table->text('storage_key'); + $table->text('alt_text')->nullable(); + $table->integer('width')->nullable(); + $table->integer('height')->nullable(); + $table->text('mime_type')->nullable(); + $table->integer('byte_size')->nullable(); + $table->integer('position')->default(0); + $table->text('status')->default('processing'); + $table->text('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'); + }); + + DB::statement("CREATE TRIGGER product_media_type_check BEFORE INSERT ON product_media + BEGIN + SELECT CASE WHEN NEW.type NOT IN ('image', 'video') + THEN RAISE(ABORT, 'Invalid media type') + END; + END;"); + + DB::statement("CREATE TRIGGER product_media_type_check_update BEFORE UPDATE ON product_media + BEGIN + SELECT CASE WHEN NEW.type NOT IN ('image', 'video') + THEN RAISE(ABORT, 'Invalid media type') + END; + END;"); + + DB::statement("CREATE TRIGGER product_media_status_check BEFORE INSERT ON product_media + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('processing', 'ready', 'failed') + THEN RAISE(ABORT, 'Invalid media status') + END; + END;"); + + DB::statement("CREATE TRIGGER product_media_status_check_update BEFORE UPDATE ON product_media + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('processing', 'ready', 'failed') + THEN RAISE(ABORT, 'Invalid media status') + END; + END;"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_media'); + } +}; diff --git a/database/migrations/2026_03_20_080349_create_themes_table.php b/database/migrations/2026_03_20_080349_create_themes_table.php new file mode 100644 index 00000000..cc66013e --- /dev/null +++ b/database/migrations/2026_03_20_080349_create_themes_table.php @@ -0,0 +1,50 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->text('name'); + $table->text('version')->nullable(); + $table->text('status')->default('draft'); + $table->text('published_at')->nullable(); + $table->timestamps(); + + $table->index('store_id', 'idx_themes_store_id'); + $table->index(['store_id', 'status'], 'idx_themes_store_status'); + }); + + DB::statement("CREATE TRIGGER themes_status_check BEFORE INSERT ON themes + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('draft', 'published') + THEN RAISE(ABORT, 'Invalid theme status') + END; + END;"); + + DB::statement("CREATE TRIGGER themes_status_check_update BEFORE UPDATE ON themes + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('draft', 'published') + THEN RAISE(ABORT, 'Invalid theme status') + END; + END;"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('themes'); + } +}; diff --git a/database/migrations/2026_03_20_080350_create_theme_files_table.php b/database/migrations/2026_03_20_080350_create_theme_files_table.php new file mode 100644 index 00000000..79f44326 --- /dev/null +++ b/database/migrations/2026_03_20_080350_create_theme_files_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('theme_id')->constrained('themes')->cascadeOnDelete(); + $table->text('path'); + $table->text('storage_key'); + $table->text('sha256'); + $table->integer('byte_size')->default(0); + + $table->unique(['theme_id', 'path'], 'idx_theme_files_theme_path'); + $table->index('theme_id', 'idx_theme_files_theme_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('theme_files'); + } +}; diff --git a/database/migrations/2026_03_20_080351_create_pages_table.php b/database/migrations/2026_03_20_080351_create_pages_table.php new file mode 100644 index 00000000..d1b8ce17 --- /dev/null +++ b/database/migrations/2026_03_20_080351_create_pages_table.php @@ -0,0 +1,52 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->text('title'); + $table->text('handle'); + $table->text('body_html')->nullable(); + $table->text('status')->default('draft'); + $table->text('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'); + }); + + DB::statement("CREATE TRIGGER pages_status_check BEFORE INSERT ON pages + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('draft', 'published', 'archived') + THEN RAISE(ABORT, 'Invalid page status') + END; + END;"); + + DB::statement("CREATE TRIGGER pages_status_check_update BEFORE UPDATE ON pages + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('draft', 'published', 'archived') + THEN RAISE(ABORT, 'Invalid page status') + END; + END;"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('pages'); + } +}; diff --git a/database/migrations/2026_03_20_080351_create_theme_settings_table.php b/database/migrations/2026_03_20_080351_create_theme_settings_table.php new file mode 100644 index 00000000..3b6fa5de --- /dev/null +++ b/database/migrations/2026_03_20_080351_create_theme_settings_table.php @@ -0,0 +1,30 @@ +unsignedBigInteger('theme_id')->primary(); + $table->text('settings_json')->default('{}'); + $table->text('updated_at')->nullable(); + + $table->foreign('theme_id')->references('id')->on('themes')->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('theme_settings'); + } +}; diff --git a/database/migrations/2026_03_20_080352_create_navigation_menus_table.php b/database/migrations/2026_03_20_080352_create_navigation_menus_table.php new file mode 100644 index 00000000..ac6444c9 --- /dev/null +++ b/database/migrations/2026_03_20_080352_create_navigation_menus_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->text('handle'); + $table->text('title'); + $table->timestamps(); + + $table->unique(['store_id', 'handle'], 'idx_navigation_menus_store_handle'); + $table->index('store_id', 'idx_navigation_menus_store_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('navigation_menus'); + } +}; diff --git a/database/migrations/2026_03_20_080353_create_navigation_items_table.php b/database/migrations/2026_03_20_080353_create_navigation_items_table.php new file mode 100644 index 00000000..de3f8c16 --- /dev/null +++ b/database/migrations/2026_03_20_080353_create_navigation_items_table.php @@ -0,0 +1,50 @@ +id(); + $table->foreignId('menu_id')->constrained('navigation_menus')->cascadeOnDelete(); + $table->text('type')->default('link'); + $table->text('label'); + $table->text('url')->nullable(); + $table->integer('resource_id')->nullable(); + $table->integer('position')->default(0); + + $table->index('menu_id', 'idx_navigation_items_menu_id'); + $table->index(['menu_id', 'position'], 'idx_navigation_items_menu_position'); + }); + + DB::statement("CREATE TRIGGER navigation_items_type_check BEFORE INSERT ON navigation_items + BEGIN + SELECT CASE WHEN NEW.type NOT IN ('link', 'page', 'collection', 'product') + THEN RAISE(ABORT, 'Invalid navigation item type') + END; + END;"); + + DB::statement("CREATE TRIGGER navigation_items_type_check_update BEFORE UPDATE ON navigation_items + BEGIN + SELECT CASE WHEN NEW.type NOT IN ('link', 'page', 'collection', 'product') + THEN RAISE(ABORT, 'Invalid navigation item type') + END; + END;"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('navigation_items'); + } +}; diff --git a/database/migrations/2026_03_20_083350_create_cart_lines_table.php b/database/migrations/2026_03_20_083350_create_cart_lines_table.php new file mode 100644 index 00000000..661df8f6 --- /dev/null +++ b/database/migrations/2026_03_20_083350_create_cart_lines_table.php @@ -0,0 +1,36 @@ +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'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cart_lines'); + } +}; diff --git a/database/migrations/2026_03_20_083350_create_carts_table.php b/database/migrations/2026_03_20_083350_create_carts_table.php new file mode 100644 index 00000000..0c39b9fa --- /dev/null +++ b/database/migrations/2026_03_20_083350_create_carts_table.php @@ -0,0 +1,50 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained('customers')->nullOnDelete(); + $table->text('currency')->default('USD'); + $table->integer('cart_version')->default(1); + $table->text('status')->default('active'); + $table->timestamps(); + + $table->index('store_id', 'idx_carts_store_id'); + $table->index('customer_id', 'idx_carts_customer_id'); + $table->index(['store_id', 'status'], 'idx_carts_store_status'); + }); + + DB::statement("CREATE TRIGGER carts_status_check BEFORE INSERT ON carts + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('active', 'converted', 'abandoned') + THEN RAISE(ABORT, 'Invalid cart status') + END; + END;"); + + DB::statement("CREATE TRIGGER carts_status_check_update BEFORE UPDATE ON carts + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('active', 'converted', 'abandoned') + THEN RAISE(ABORT, 'Invalid cart status') + END; + END;"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('carts'); + } +}; diff --git a/database/migrations/2026_03_20_083351_create_checkouts_table.php b/database/migrations/2026_03_20_083351_create_checkouts_table.php new file mode 100644 index 00000000..4e17204e --- /dev/null +++ b/database/migrations/2026_03_20_083351_create_checkouts_table.php @@ -0,0 +1,60 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('cart_id')->constrained('carts')->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained('customers')->nullOnDelete(); + $table->text('status')->default('started'); + $table->text('payment_method')->nullable(); + $table->text('email')->nullable(); + $table->text('shipping_address_json')->nullable(); + $table->text('billing_address_json')->nullable(); + $table->integer('shipping_method_id')->nullable(); + $table->text('discount_code')->nullable(); + $table->text('tax_provider_snapshot_json')->nullable(); + $table->text('totals_json')->nullable(); + $table->text('expires_at')->nullable(); + $table->timestamps(); + + $table->index('store_id', 'idx_checkouts_store_id'); + $table->index('cart_id', 'idx_checkouts_cart_id'); + $table->index('customer_id', 'idx_checkouts_customer_id'); + $table->index(['store_id', 'status'], 'idx_checkouts_status'); + $table->index('expires_at', 'idx_checkouts_expires_at'); + }); + + DB::statement("CREATE TRIGGER checkouts_status_check BEFORE INSERT ON checkouts + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired') + THEN RAISE(ABORT, 'Invalid checkout status') + END; + END;"); + + DB::statement("CREATE TRIGGER checkouts_status_check_update BEFORE UPDATE ON checkouts + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired') + THEN RAISE(ABORT, 'Invalid checkout status') + END; + END;"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('checkouts'); + } +}; diff --git a/database/migrations/2026_03_20_083352_create_shipping_zones_table.php b/database/migrations/2026_03_20_083352_create_shipping_zones_table.php new file mode 100644 index 00000000..b9cd97cd --- /dev/null +++ b/database/migrations/2026_03_20_083352_create_shipping_zones_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->text('name'); + $table->text('countries_json')->default('[]'); + $table->text('regions_json')->default('[]'); + $table->integer('is_active')->default(1); + + $table->index('store_id', 'idx_shipping_zones_store_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shipping_zones'); + } +}; diff --git a/database/migrations/2026_03_20_083353_create_shipping_rates_table.php b/database/migrations/2026_03_20_083353_create_shipping_rates_table.php new file mode 100644 index 00000000..7e17c7fa --- /dev/null +++ b/database/migrations/2026_03_20_083353_create_shipping_rates_table.php @@ -0,0 +1,48 @@ +id(); + $table->foreignId('zone_id')->constrained('shipping_zones')->cascadeOnDelete(); + $table->text('name'); + $table->text('type')->default('flat'); + $table->text('config_json')->default('{}'); + $table->integer('is_active')->default(1); + + $table->index('zone_id', 'idx_shipping_rates_zone_id'); + $table->index(['zone_id', 'is_active'], 'idx_shipping_rates_zone_active'); + }); + + DB::statement("CREATE TRIGGER shipping_rates_type_check BEFORE INSERT ON shipping_rates + BEGIN + SELECT CASE WHEN NEW.type NOT IN ('flat', 'weight', 'price', 'carrier') + THEN RAISE(ABORT, 'Invalid shipping rate type') + END; + END;"); + + DB::statement("CREATE TRIGGER shipping_rates_type_check_update BEFORE UPDATE ON shipping_rates + BEGIN + SELECT CASE WHEN NEW.type NOT IN ('flat', 'weight', 'price', 'carrier') + THEN RAISE(ABORT, 'Invalid shipping rate type') + END; + END;"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shipping_rates'); + } +}; diff --git a/database/migrations/2026_03_20_083353_create_tax_settings_table.php b/database/migrations/2026_03_20_083353_create_tax_settings_table.php new file mode 100644 index 00000000..f0e20f16 --- /dev/null +++ b/database/migrations/2026_03_20_083353_create_tax_settings_table.php @@ -0,0 +1,48 @@ +unsignedBigInteger('store_id')->primary(); + $table->foreign('store_id')->references('id')->on('stores')->cascadeOnDelete(); + $table->text('mode')->default('manual'); + $table->text('provider')->default('none'); + $table->integer('rate')->default(0); + $table->integer('prices_include_tax')->default(0); + $table->text('tax_name')->default('Tax'); + $table->integer('is_active')->default(1); + $table->text('config_json')->default('{}'); + }); + + DB::statement("CREATE TRIGGER tax_settings_mode_check BEFORE INSERT ON tax_settings + BEGIN + SELECT CASE WHEN NEW.mode NOT IN ('manual', 'provider') + THEN RAISE(ABORT, 'Invalid tax mode') + END; + END;"); + + DB::statement("CREATE TRIGGER tax_settings_mode_check_update BEFORE UPDATE ON tax_settings + BEGIN + SELECT CASE WHEN NEW.mode NOT IN ('manual', 'provider') + THEN RAISE(ABORT, 'Invalid tax mode') + END; + END;"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tax_settings'); + } +}; diff --git a/database/migrations/2026_03_20_083354_create_discounts_table.php b/database/migrations/2026_03_20_083354_create_discounts_table.php new file mode 100644 index 00000000..20d20f68 --- /dev/null +++ b/database/migrations/2026_03_20_083354_create_discounts_table.php @@ -0,0 +1,86 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->text('code')->nullable(); + $table->text('type')->default('code'); + $table->text('value_type'); + $table->integer('value_amount')->default(0); + $table->text('status')->default('active'); + $table->text('starts_at'); + $table->text('ends_at')->nullable(); + $table->integer('usage_limit')->nullable(); + $table->integer('usage_count')->default(0); + $table->text('rules_json')->default('{}'); + $table->integer('minimum_purchase_amount')->nullable(); + $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'); + }); + + DB::statement("CREATE TRIGGER discounts_type_check BEFORE INSERT ON discounts + BEGIN + SELECT CASE WHEN NEW.type NOT IN ('code', 'automatic') + THEN RAISE(ABORT, 'Invalid discount type') + END; + END;"); + + DB::statement("CREATE TRIGGER discounts_type_check_update BEFORE UPDATE ON discounts + BEGIN + SELECT CASE WHEN NEW.type NOT IN ('code', 'automatic') + THEN RAISE(ABORT, 'Invalid discount type') + END; + END;"); + + DB::statement("CREATE TRIGGER discounts_value_type_check BEFORE INSERT ON discounts + BEGIN + SELECT CASE WHEN NEW.value_type NOT IN ('fixed', 'percent', 'free_shipping') + THEN RAISE(ABORT, 'Invalid discount value type') + END; + END;"); + + DB::statement("CREATE TRIGGER discounts_value_type_check_update BEFORE UPDATE ON discounts + BEGIN + SELECT CASE WHEN NEW.value_type NOT IN ('fixed', 'percent', 'free_shipping') + THEN RAISE(ABORT, 'Invalid discount value type') + END; + END;"); + + DB::statement("CREATE TRIGGER discounts_status_check BEFORE INSERT ON discounts + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('draft', 'active', 'expired', 'disabled') + THEN RAISE(ABORT, 'Invalid discount status') + END; + END;"); + + DB::statement("CREATE TRIGGER discounts_status_check_update BEFORE UPDATE ON discounts + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('draft', 'active', 'expired', 'disabled') + THEN RAISE(ABORT, 'Invalid discount status') + END; + END;"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('discounts'); + } +}; diff --git a/database/migrations/2026_03_20_092200_create_customer_addresses_table.php b/database/migrations/2026_03_20_092200_create_customer_addresses_table.php new file mode 100644 index 00000000..8b23823f --- /dev/null +++ b/database/migrations/2026_03_20_092200_create_customer_addresses_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('customer_id')->constrained('customers')->cascadeOnDelete(); + $table->text('label')->nullable(); + $table->text('address_json')->default('{}'); + $table->integer('is_default')->default(0); + + $table->index('customer_id', 'idx_customer_addresses_customer_id'); + $table->index(['customer_id', 'is_default'], 'idx_customer_addresses_default'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('customer_addresses'); + } +}; diff --git a/database/migrations/2026_03_20_092204_create_fulfillments_table.php b/database/migrations/2026_03_20_092204_create_fulfillments_table.php new file mode 100644 index 00000000..3c7b7b89 --- /dev/null +++ b/database/migrations/2026_03_20_092204_create_fulfillments_table.php @@ -0,0 +1,51 @@ +id(); + $table->foreignId('order_id')->constrained('orders')->cascadeOnDelete(); + $table->text('status')->default('pending'); + $table->text('tracking_company')->nullable(); + $table->text('tracking_number')->nullable(); + $table->text('tracking_url')->nullable(); + $table->text('shipped_at')->nullable(); + $table->text('delivered_at')->nullable(); + $table->text('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'); + }); + + DB::statement("CREATE TRIGGER fulfillments_status_check BEFORE INSERT ON fulfillments + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('pending', 'shipped', 'delivered') + THEN RAISE(ABORT, 'Invalid status') END; + END"); + + DB::statement("CREATE TRIGGER fulfillments_status_check_update BEFORE UPDATE ON fulfillments + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('pending', 'shipped', 'delivered') + THEN RAISE(ABORT, 'Invalid status') END; + END"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('fulfillments'); + } +}; diff --git a/database/migrations/2026_03_20_092204_create_order_lines_table.php b/database/migrations/2026_03_20_092204_create_order_lines_table.php new file mode 100644 index 00000000..9880f617 --- /dev/null +++ b/database/migrations/2026_03_20_092204_create_order_lines_table.php @@ -0,0 +1,40 @@ +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->text('title_snapshot'); + $table->text('sku_snapshot')->nullable(); + $table->integer('quantity')->default(1); + $table->integer('unit_price_amount')->default(0); + $table->integer('total_amount')->default(0); + $table->text('tax_lines_json')->default('[]'); + $table->text('discount_allocations_json')->default('[]'); + + $table->index('order_id', 'idx_order_lines_order_id'); + $table->index('product_id', 'idx_order_lines_product_id'); + $table->index('variant_id', 'idx_order_lines_variant_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('order_lines'); + } +}; diff --git a/database/migrations/2026_03_20_092204_create_orders_table.php b/database/migrations/2026_03_20_092204_create_orders_table.php new file mode 100644 index 00000000..31770b32 --- /dev/null +++ b/database/migrations/2026_03_20_092204_create_orders_table.php @@ -0,0 +1,101 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained('customers')->nullOnDelete(); + $table->text('order_number'); + $table->text('payment_method'); + $table->text('status')->default('pending'); + $table->text('financial_status')->default('pending'); + $table->text('fulfillment_status')->default('unfulfilled'); + $table->text('currency')->default('USD'); + $table->integer('subtotal_amount')->default(0); + $table->integer('discount_amount')->default(0); + $table->integer('shipping_amount')->default(0); + $table->integer('tax_amount')->default(0); + $table->integer('total_amount')->default(0); + $table->text('email')->nullable(); + $table->text('billing_address_json')->nullable(); + $table->text('shipping_address_json')->nullable(); + $table->text('placed_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'order_number'], 'idx_orders_store_order_number'); + $table->index('store_id', 'idx_orders_store_id'); + $table->index('customer_id', 'idx_orders_customer_id'); + $table->index(['store_id', 'status'], 'idx_orders_store_status'); + $table->index(['store_id', 'financial_status'], 'idx_orders_store_financial'); + $table->index(['store_id', 'fulfillment_status'], 'idx_orders_store_fulfillment'); + $table->index(['store_id', 'placed_at'], 'idx_orders_placed_at'); + }); + + DB::statement("CREATE TRIGGER check_order_payment_method BEFORE INSERT ON orders + BEGIN + SELECT CASE WHEN NEW.payment_method NOT IN ('credit_card', 'paypal', 'bank_transfer') + THEN RAISE(ABORT, 'Invalid payment_method') END; + END"); + + DB::statement("CREATE TRIGGER check_order_status BEFORE INSERT ON orders + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') + THEN RAISE(ABORT, 'Invalid status') END; + END"); + + DB::statement("CREATE TRIGGER check_order_financial_status BEFORE INSERT ON orders + BEGIN + SELECT CASE WHEN NEW.financial_status NOT IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') + THEN RAISE(ABORT, 'Invalid financial_status') END; + END"); + + DB::statement("CREATE TRIGGER check_order_fulfillment_status BEFORE INSERT ON orders + BEGIN + SELECT CASE WHEN NEW.fulfillment_status NOT IN ('unfulfilled', 'partial', 'fulfilled') + THEN RAISE(ABORT, 'Invalid fulfillment_status') END; + END"); + + DB::statement("CREATE TRIGGER check_order_payment_method_update BEFORE UPDATE ON orders + BEGIN + SELECT CASE WHEN NEW.payment_method NOT IN ('credit_card', 'paypal', 'bank_transfer') + THEN RAISE(ABORT, 'Invalid payment_method') END; + END"); + + DB::statement("CREATE TRIGGER check_order_status_update BEFORE UPDATE ON orders + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') + THEN RAISE(ABORT, 'Invalid status') END; + END"); + + DB::statement("CREATE TRIGGER check_order_financial_status_update BEFORE UPDATE ON orders + BEGIN + SELECT CASE WHEN NEW.financial_status NOT IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') + THEN RAISE(ABORT, 'Invalid financial_status') END; + END"); + + DB::statement("CREATE TRIGGER check_order_fulfillment_status_update BEFORE UPDATE ON orders + BEGIN + SELECT CASE WHEN NEW.fulfillment_status NOT IN ('unfulfilled', 'partial', 'fulfilled') + THEN RAISE(ABORT, 'Invalid fulfillment_status') END; + END"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('orders'); + } +}; diff --git a/database/migrations/2026_03_20_092204_create_payments_table.php b/database/migrations/2026_03_20_092204_create_payments_table.php new file mode 100644 index 00000000..72c71154 --- /dev/null +++ b/database/migrations/2026_03_20_092204_create_payments_table.php @@ -0,0 +1,77 @@ +id(); + $table->foreignId('order_id')->constrained('orders')->cascadeOnDelete(); + $table->text('provider')->default('mock'); + $table->text('method'); + $table->text('provider_payment_id')->nullable(); + $table->text('status')->default('pending'); + $table->integer('amount')->default(0); + $table->text('currency')->default('USD'); + $table->text('raw_json_encrypted')->nullable(); + $table->text('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'); + }); + + DB::statement("CREATE TRIGGER payments_provider_check BEFORE INSERT ON payments + BEGIN + SELECT CASE WHEN NEW.provider NOT IN ('mock') + THEN RAISE(ABORT, 'Invalid provider') END; + END"); + + DB::statement("CREATE TRIGGER payments_provider_check_update BEFORE UPDATE ON payments + BEGIN + SELECT CASE WHEN NEW.provider NOT IN ('mock') + THEN RAISE(ABORT, 'Invalid provider') END; + END"); + + DB::statement("CREATE TRIGGER payments_method_check BEFORE INSERT ON payments + BEGIN + SELECT CASE WHEN NEW.method NOT IN ('credit_card', 'paypal', 'bank_transfer') + THEN RAISE(ABORT, 'Invalid method') END; + END"); + + DB::statement("CREATE TRIGGER payments_method_check_update BEFORE UPDATE ON payments + BEGIN + SELECT CASE WHEN NEW.method NOT IN ('credit_card', 'paypal', 'bank_transfer') + THEN RAISE(ABORT, 'Invalid method') END; + END"); + + DB::statement("CREATE TRIGGER payments_status_check BEFORE INSERT ON payments + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('pending', 'captured', 'failed', 'refunded') + THEN RAISE(ABORT, 'Invalid status') END; + END"); + + DB::statement("CREATE TRIGGER payments_status_check_update BEFORE UPDATE ON payments + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('pending', 'captured', 'failed', 'refunded') + THEN RAISE(ABORT, 'Invalid status') END; + END"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('payments'); + } +}; diff --git a/database/migrations/2026_03_20_092204_create_refunds_table.php b/database/migrations/2026_03_20_092204_create_refunds_table.php new file mode 100644 index 00000000..68f6ae19 --- /dev/null +++ b/database/migrations/2026_03_20_092204_create_refunds_table.php @@ -0,0 +1,50 @@ +id(); + $table->foreignId('order_id')->constrained('orders')->cascadeOnDelete(); + $table->foreignId('payment_id')->constrained('payments')->cascadeOnDelete(); + $table->integer('amount')->default(0); + $table->text('reason')->nullable(); + $table->text('status')->default('pending'); + $table->text('provider_refund_id')->nullable(); + $table->text('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'); + }); + + DB::statement("CREATE TRIGGER refunds_status_check BEFORE INSERT ON refunds + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('pending', 'processed', 'failed') + THEN RAISE(ABORT, 'Invalid status') END; + END"); + + DB::statement("CREATE TRIGGER refunds_status_check_update BEFORE UPDATE ON refunds + BEGIN + SELECT CASE WHEN NEW.status NOT IN ('pending', 'processed', 'failed') + THEN RAISE(ABORT, 'Invalid status') END; + END"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('refunds'); + } +}; diff --git a/database/migrations/2026_03_20_092205_create_fulfillment_lines_table.php b/database/migrations/2026_03_20_092205_create_fulfillment_lines_table.php new file mode 100644 index 00000000..ec0b7c13 --- /dev/null +++ b/database/migrations/2026_03_20_092205_create_fulfillment_lines_table.php @@ -0,0 +1,32 @@ +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'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('fulfillment_lines'); + } +}; diff --git a/database/migrations/2026_03_20_130812_create_search_settings_table.php b/database/migrations/2026_03_20_130812_create_search_settings_table.php new file mode 100644 index 00000000..9c01886d --- /dev/null +++ b/database/migrations/2026_03_20_130812_create_search_settings_table.php @@ -0,0 +1,30 @@ +unsignedBigInteger('store_id')->primary(); + $table->foreign('store_id')->references('id')->on('stores')->cascadeOnDelete(); + $table->text('synonyms_json')->default('[]'); + $table->text('stop_words_json')->default('[]'); + $table->timestamp('updated_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('search_settings'); + } +}; diff --git a/database/migrations/2026_03_20_130815_create_search_queries_table.php b/database/migrations/2026_03_20_130815_create_search_queries_table.php new file mode 100644 index 00000000..4b1e5b39 --- /dev/null +++ b/database/migrations/2026_03_20_130815_create_search_queries_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->text('query'); + $table->text('filters_json')->nullable(); + $table->integer('results_count')->default(0); + $table->timestamp('created_at')->nullable(); + + $table->index('store_id', 'idx_search_queries_store_id'); + $table->index(['store_id', 'created_at'], 'idx_search_queries_store_created'); + $table->index(['store_id', 'query'], 'idx_search_queries_store_query'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('search_queries'); + } +}; diff --git a/database/migrations/2026_03_20_130818_create_products_fts_table.php b/database/migrations/2026_03_20_130818_create_products_fts_table.php new file mode 100644 index 00000000..228d141b --- /dev/null +++ b/database/migrations/2026_03_20_130818_create_products_fts_table.php @@ -0,0 +1,32 @@ +foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->text('date'); + $table->integer('orders_count')->default(0); + $table->integer('revenue_amount')->default(0); + $table->integer('aov_amount')->default(0); + $table->integer('visits_count')->default(0); + $table->integer('add_to_cart_count')->default(0); + $table->integer('checkout_started_count')->default(0); + $table->integer('checkout_completed_count')->default(0); + + $table->primary(['store_id', 'date']); + $table->index(['store_id', 'date'], 'idx_analytics_daily_store_date'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('analytics_daily'); + } +}; diff --git a/database/migrations/2026_03_20_131922_create_analytics_events_table.php b/database/migrations/2026_03_20_131922_create_analytics_events_table.php new file mode 100644 index 00000000..1eefaee1 --- /dev/null +++ b/database/migrations/2026_03_20_131922_create_analytics_events_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->text('type'); + $table->text('session_id')->nullable(); + $table->foreignId('customer_id')->nullable()->constrained('customers')->nullOnDelete(); + $table->text('properties_json')->default('{}'); + $table->text('client_event_id')->nullable(); + $table->text('occurred_at')->nullable(); + $table->text('ip_address')->nullable(); + $table->text('user_agent')->nullable(); + $table->text('created_at')->nullable(); + + $table->index('store_id', 'idx_analytics_events_store_id'); + $table->index(['store_id', 'type'], 'idx_analytics_events_store_type'); + $table->index(['store_id', 'created_at'], 'idx_analytics_events_store_created'); + $table->index('session_id', 'idx_analytics_events_session'); + $table->index('customer_id', 'idx_analytics_events_customer'); + $table->unique(['store_id', 'client_event_id'], 'idx_analytics_events_client_event'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('analytics_events'); + } +}; diff --git a/database/migrations/2026_03_20_140000_create_apps_table.php b/database/migrations/2026_03_20_140000_create_apps_table.php new file mode 100644 index 00000000..f7c69689 --- /dev/null +++ b/database/migrations/2026_03_20_140000_create_apps_table.php @@ -0,0 +1,31 @@ +id(); + $table->text('name'); + $table->text('status')->default('active'); + $table->text('created_at')->nullable(); + + $table->index('status', 'idx_apps_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('apps'); + } +}; diff --git a/database/migrations/2026_03_20_140001_create_app_installations_table.php b/database/migrations/2026_03_20_140001_create_app_installations_table.php new file mode 100644 index 00000000..a68148b3 --- /dev/null +++ b/database/migrations/2026_03_20_140001_create_app_installations_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('app_id')->constrained('apps')->cascadeOnDelete(); + $table->text('scopes_json')->default('[]'); + $table->text('status')->default('active'); + $table->text('installed_at')->nullable(); + + $table->unique(['store_id', 'app_id'], 'idx_app_installations_store_app'); + $table->index('store_id', 'idx_app_installations_store_id'); + $table->index('app_id', 'idx_app_installations_app_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('app_installations'); + } +}; diff --git a/database/migrations/2026_03_20_140002_create_oauth_clients_table.php b/database/migrations/2026_03_20_140002_create_oauth_clients_table.php new file mode 100644 index 00000000..49be90b2 --- /dev/null +++ b/database/migrations/2026_03_20_140002_create_oauth_clients_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('app_id')->constrained('apps')->cascadeOnDelete(); + $table->text('client_id'); + $table->text('client_secret_encrypted'); + $table->text('redirect_uris_json')->default('[]'); + + $table->unique('client_id', 'idx_oauth_clients_client_id'); + $table->index('app_id', 'idx_oauth_clients_app_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_clients'); + } +}; diff --git a/database/migrations/2026_03_20_140003_create_oauth_tokens_table.php b/database/migrations/2026_03_20_140003_create_oauth_tokens_table.php new file mode 100644 index 00000000..57e0c6e0 --- /dev/null +++ b/database/migrations/2026_03_20_140003_create_oauth_tokens_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('installation_id')->constrained('app_installations')->cascadeOnDelete(); + $table->text('access_token_hash'); + $table->text('refresh_token_hash')->nullable(); + $table->text('expires_at'); + + $table->index('installation_id', 'idx_oauth_tokens_installation_id'); + $table->unique('access_token_hash', 'idx_oauth_tokens_access_hash'); + $table->index('expires_at', 'idx_oauth_tokens_expires_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_tokens'); + } +}; diff --git a/database/migrations/2026_03_20_140004_create_webhook_subscriptions_table.php b/database/migrations/2026_03_20_140004_create_webhook_subscriptions_table.php new file mode 100644 index 00000000..e8fc7b0b --- /dev/null +++ b/database/migrations/2026_03_20_140004_create_webhook_subscriptions_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('app_installation_id')->nullable()->constrained('app_installations')->cascadeOnDelete(); + $table->text('event_type'); + $table->text('target_url'); + $table->text('signing_secret_encrypted'); + $table->text('status')->default('active'); + + $table->index('store_id', 'idx_webhook_subscriptions_store_id'); + $table->index(['store_id', 'event_type'], 'idx_webhook_subscriptions_store_event'); + $table->index('app_installation_id', 'idx_webhook_subscriptions_installation'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_subscriptions'); + } +}; diff --git a/database/migrations/2026_03_20_140005_create_webhook_deliveries_table.php b/database/migrations/2026_03_20_140005_create_webhook_deliveries_table.php new file mode 100644 index 00000000..2d259cee --- /dev/null +++ b/database/migrations/2026_03_20_140005_create_webhook_deliveries_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('subscription_id')->constrained('webhook_subscriptions')->cascadeOnDelete(); + $table->text('event_id'); + $table->integer('attempt_count')->default(1); + $table->text('status')->default('pending'); + $table->text('last_attempt_at')->nullable(); + $table->integer('response_code')->nullable(); + $table->text('response_body_snippet')->nullable(); + + $table->index('subscription_id', 'idx_webhook_deliveries_subscription_id'); + $table->index('event_id', 'idx_webhook_deliveries_event_id'); + $table->index('status', 'idx_webhook_deliveries_status'); + $table->index('last_attempt_at', 'idx_webhook_deliveries_last_attempt'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_deliveries'); + } +}; diff --git a/database/seeders/AnalyticsSeeder.php b/database/seeders/AnalyticsSeeder.php new file mode 100644 index 00000000..f0dda66c --- /dev/null +++ b/database/seeders/AnalyticsSeeder.php @@ -0,0 +1,131 @@ +first(); + + app()->instance('current_store', $fashion); + + $this->seedDailyAnalytics($fashion); + $this->seedAnalyticsEvents($fashion); + } + + private function seedDailyAnalytics(Store $store): void + { + for ($i = 30; $i >= 0; $i--) { + $dayFactor = 1 + (30 - $i) * 0.03; + $visits = (int) round(rand(50, 100) * $dayFactor); + $addToCart = (int) round($visits * rand(18, 25) / 100); + $checkoutStarted = (int) round($addToCart * rand(40, 55) / 100); + $orders = max(2, (int) round($checkoutStarted * rand(35, 55) / 100)); + $aov = rand(4000, 9000); + $revenue = $orders * $aov; + + AnalyticsDaily::create([ + 'store_id' => $store->id, + 'date' => now()->subDays($i)->format('Y-m-d'), + 'visits_count' => $visits, + 'add_to_cart_count' => $addToCart, + 'checkout_started_count' => $checkoutStarted, + 'checkout_completed_count' => $orders, + 'orders_count' => $orders, + 'revenue_amount' => $revenue, + 'aov_amount' => $aov, + ]); + } + } + + private function seedAnalyticsEvents(Store $store): void + { + $customers = Customer::where('store_id', $store->id)->pluck('id')->toArray(); + $products = Product::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('status', 'active') + ->get(); + + // Create ~30-40 sessions + $sessionCount = rand(30, 40); + $sessions = []; + for ($s = 0; $s < $sessionCount; $s++) { + $sessions[] = Str::uuid()->toString(); + } + + $eventTypes = [ + 'page_view' => 40, + 'product_view' => 25, + 'add_to_cart' => 15, + 'checkout_started' => 10, + 'checkout_completed' => 5, + 'search' => 5, + ]; + + $totalEvents = 220; + $searchQueries = ['cotton t-shirt', 'jeans', 'gift card', 'hoodie', 'shoes', 'sneakers', 'belt', 'scarf']; + $urls = ['/', '/collections/t-shirts', '/collections/new-arrivals', '/collections/sale', '/products/classic-cotton-t-shirt', '/search']; + + foreach ($eventTypes as $type => $pct) { + $count = (int) round($totalEvents * $pct / 100); + + for ($e = 0; $e < $count; $e++) { + $sessionId = $sessions[array_rand($sessions)]; + $customerId = rand(1, 100) <= 30 ? $customers[array_rand($customers)] : null; + $createdAt = now()->subDays(rand(0, 6))->subHours(rand(0, 23))->subMinutes(rand(0, 59)); + + $properties = match ($type) { + 'page_view' => [ + 'url' => $urls[array_rand($urls)], + 'referrer' => rand(1, 100) <= 40 ? 'https://www.google.com' : null, + ], + 'product_view' => [ + 'product_id' => $products->random()->id, + 'product_title' => $products->random()->title, + 'url' => '/products/'.$products->random()->handle, + ], + 'add_to_cart' => [ + 'product_id' => $products->random()->id, + 'variant_id' => $products->random()->id, + 'quantity' => rand(1, 3), + 'price_amount' => rand(1999, 11999), + ], + 'checkout_started' => [ + 'cart_id' => rand(1, 100), + 'item_count' => rand(1, 4), + 'cart_total' => rand(2499, 50000), + ], + 'checkout_completed' => [ + 'order_id' => rand(1, 15), + 'order_number' => '#'.rand(1001, 1015), + 'total_amount' => rand(2998, 50498), + ], + 'search' => [ + 'query' => $searchQueries[array_rand($searchQueries)], + 'results_count' => rand(0, 20), + ], + }; + + AnalyticsEvent::create([ + 'store_id' => $store->id, + 'type' => $type, + 'session_id' => $sessionId, + 'customer_id' => $customerId, + 'properties_json' => $properties, + 'client_event_id' => Str::uuid()->toString(), + 'occurred_at' => $createdAt->toIso8601String(), + 'created_at' => $createdAt->toIso8601String(), + ]); + } + } + } +} diff --git a/database/seeders/CollectionSeeder.php b/database/seeders/CollectionSeeder.php new file mode 100644 index 00000000..8368a609 --- /dev/null +++ b/database/seeders/CollectionSeeder.php @@ -0,0 +1,74 @@ +first(); + $electronics = Store::where('handle', 'acme-electronics')->first(); + + app()->instance('current_store', $fashion); + + // Acme Fashion collections + Collection::factory()->create([ + 'store_id' => $fashion->id, + 'title' => 'New Arrivals', + 'handle' => 'new-arrivals', + 'type' => 'manual', + 'status' => 'active', + 'description_html' => '

Discover the latest additions to our store.

', + ]); + + Collection::factory()->create([ + 'store_id' => $fashion->id, + 'title' => 'T-Shirts', + 'handle' => 't-shirts', + 'type' => 'manual', + 'status' => 'active', + 'description_html' => '

Premium cotton tees for every occasion.

', + ]); + + Collection::factory()->create([ + 'store_id' => $fashion->id, + 'title' => 'Pants & Jeans', + 'handle' => 'pants-jeans', + 'type' => 'manual', + 'status' => 'active', + 'description_html' => '

Find the perfect fit from our denim and trouser range.

', + ]); + + Collection::factory()->create([ + 'store_id' => $fashion->id, + 'title' => 'Sale', + 'handle' => 'sale', + 'type' => 'manual', + 'status' => 'active', + 'description_html' => '

Great deals on selected items.

', + ]); + + // Acme Electronics collections + app()->instance('current_store', $electronics); + + Collection::factory()->create([ + 'store_id' => $electronics->id, + 'title' => 'Featured', + 'handle' => 'featured', + 'type' => 'manual', + 'status' => 'active', + ]); + + Collection::factory()->create([ + 'store_id' => $electronics->id, + 'title' => 'Accessories', + 'handle' => 'accessories', + 'type' => 'manual', + 'status' => 'active', + ]); + } +} diff --git a/database/seeders/CustomerSeeder.php b/database/seeders/CustomerSeeder.php new file mode 100644 index 00000000..e8a49c5f --- /dev/null +++ b/database/seeders/CustomerSeeder.php @@ -0,0 +1,186 @@ +first(); + $electronics = Store::where('handle', 'acme-electronics')->first(); + + app()->instance('current_store', $fashion); + + // Acme Fashion - 10 customers + $fashionCustomers = [ + ['email' => 'customer@acme.test', 'name' => 'John Doe', 'marketing_opt_in' => true], + ['email' => 'jane@example.com', 'name' => 'Jane Smith', 'marketing_opt_in' => false], + ['email' => 'michael@example.com', 'name' => 'Michael Brown', 'marketing_opt_in' => true], + ['email' => 'sarah@example.com', 'name' => 'Sarah Wilson', 'marketing_opt_in' => false], + ['email' => 'david@example.com', 'name' => 'David Lee', 'marketing_opt_in' => true], + ['email' => 'emma@example.com', 'name' => 'Emma Garcia', 'marketing_opt_in' => false], + ['email' => 'james@example.com', 'name' => 'James Taylor', 'marketing_opt_in' => false], + ['email' => 'lisa@example.com', 'name' => 'Lisa Anderson', 'marketing_opt_in' => true], + ['email' => 'robert@example.com', 'name' => 'Robert Martinez', 'marketing_opt_in' => false], + ['email' => 'anna@example.com', 'name' => 'Anna Thomas', 'marketing_opt_in' => true], + ]; + + foreach ($fashionCustomers as $c) { + Customer::factory()->create([ + 'store_id' => $fashion->id, + 'email' => $c['email'], + 'name' => $c['name'], + 'password' => 'password', + 'marketing_opt_in' => $c['marketing_opt_in'], + ]); + } + + // Customer 1 (John Doe) - 2 addresses + $john = Customer::where('email', 'customer@acme.test')->first(); + CustomerAddress::factory()->create([ + 'customer_id' => $john->id, + 'label' => 'Home', + 'is_default' => true, + 'address_json' => [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'company' => null, + 'address1' => 'Hauptstrasse 1', + 'address2' => null, + 'city' => 'Berlin', + 'province' => 'Berlin', + 'province_code' => 'BE', + 'country' => 'Germany', + 'country_code' => 'DE', + 'zip' => '10115', + 'phone' => '+49 30 12345678', + ], + ]); + + CustomerAddress::factory()->create([ + 'customer_id' => $john->id, + 'label' => 'Work', + 'is_default' => false, + 'address_json' => [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'company' => 'Acme Corp', + 'address1' => 'Friedrichstrasse 100', + 'address2' => '3rd Floor', + 'city' => 'Berlin', + 'province' => 'Berlin', + 'province_code' => 'BE', + 'country' => 'Germany', + 'country_code' => 'DE', + 'zip' => '10117', + 'phone' => '+49 30 87654321', + ], + ]); + + // Customer 2 (Jane Smith) - 1 address + $jane = Customer::where('email', 'jane@example.com')->first(); + CustomerAddress::factory()->create([ + 'customer_id' => $jane->id, + 'label' => 'Home', + 'is_default' => true, + 'address_json' => [ + 'first_name' => 'Jane', + 'last_name' => 'Smith', + 'company' => null, + 'address1' => 'Schillerstrasse 45', + 'address2' => null, + 'city' => 'Munich', + 'province' => 'Bavaria', + 'province_code' => 'BY', + 'country' => 'Germany', + 'country_code' => 'DE', + 'zip' => '80336', + 'phone' => null, + ], + ]); + + // Customers 3-10: one default address each + $remainingCustomers = Customer::where('store_id', $fashion->id) + ->whereNotIn('email', ['customer@acme.test', 'jane@example.com']) + ->get(); + + $germanCities = [ + ['city' => 'Hamburg', 'zip' => '20095', 'province' => 'Hamburg', 'code' => 'HH'], + ['city' => 'Frankfurt', 'zip' => '60311', 'province' => 'Hesse', 'code' => 'HE'], + ['city' => 'Cologne', 'zip' => '50667', 'province' => 'North Rhine-Westphalia', 'code' => 'NW'], + ['city' => 'Stuttgart', 'zip' => '70173', 'province' => 'Baden-Wuerttemberg', 'code' => 'BW'], + ['city' => 'Dusseldorf', 'zip' => '40213', 'province' => 'North Rhine-Westphalia', 'code' => 'NW'], + ['city' => 'Leipzig', 'zip' => '04109', 'province' => 'Saxony', 'code' => 'SN'], + ['city' => 'Dresden', 'zip' => '01067', 'province' => 'Saxony', 'code' => 'SN'], + ['city' => 'Nuremberg', 'zip' => '90402', 'province' => 'Bavaria', 'code' => 'BY'], + ]; + + foreach ($remainingCustomers as $idx => $customer) { + $nameParts = explode(' ', $customer->name); + $cityData = $germanCities[$idx % count($germanCities)]; + + CustomerAddress::factory()->create([ + 'customer_id' => $customer->id, + 'label' => 'Home', + 'is_default' => true, + 'address_json' => [ + 'first_name' => $nameParts[0], + 'last_name' => $nameParts[1] ?? '', + 'company' => null, + 'address1' => 'Musterstrasse '.($idx + 10), + 'address2' => null, + 'city' => $cityData['city'], + 'province' => $cityData['province'], + 'province_code' => $cityData['code'], + 'country' => 'Germany', + 'country_code' => 'DE', + 'zip' => $cityData['zip'], + 'phone' => null, + ], + ]); + } + + // Acme Electronics - 2 customers + app()->instance('current_store', $electronics); + + $elecCustomers = [ + ['email' => 'techfan@example.com', 'name' => 'Tech Fan'], + ['email' => 'gadgetlover@example.com', 'name' => 'Gadget Lover'], + ]; + + foreach ($elecCustomers as $c) { + $customer = Customer::factory()->create([ + 'store_id' => $electronics->id, + 'email' => $c['email'], + 'name' => $c['name'], + 'password' => 'password', + ]); + + $nameParts = explode(' ', $c['name']); + CustomerAddress::factory()->create([ + 'customer_id' => $customer->id, + 'label' => 'Home', + 'is_default' => true, + 'address_json' => [ + 'first_name' => $nameParts[0], + 'last_name' => $nameParts[1] ?? '', + 'company' => null, + 'address1' => 'Techstrasse '.rand(1, 99), + 'address2' => null, + 'city' => 'Berlin', + 'province' => 'Berlin', + 'province_code' => 'BE', + 'country' => 'Germany', + 'country_code' => 'DE', + 'zip' => '10115', + 'phone' => null, + ], + ]); + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef2..2a7a474e 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,22 +2,31 @@ namespace Database\Seeders; -use App\Models\User; -// use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder { - /** - * Seed the application's database. - */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', + $this->call([ + OrganizationSeeder::class, + StoreSeeder::class, + StoreDomainSeeder::class, + UserSeeder::class, + StoreUserSeeder::class, + StoreSettingsSeeder::class, + TaxSettingsSeeder::class, + ShippingZoneSeeder::class, + CollectionSeeder::class, + ProductSeeder::class, + DiscountSeeder::class, + CustomerSeeder::class, + OrderSeeder::class, + ThemeSeeder::class, + PageSeeder::class, + NavigationSeeder::class, + AnalyticsSeeder::class, + SearchSettingsSeeder::class, ]); } } diff --git a/database/seeders/DiscountSeeder.php b/database/seeders/DiscountSeeder.php new file mode 100644 index 00000000..d1595735 --- /dev/null +++ b/database/seeders/DiscountSeeder.php @@ -0,0 +1,78 @@ +first(); + + // WELCOME10 - 10% off, min 20 EUR purchase + Discount::factory()->create([ + 'store_id' => $store->id, + 'code' => 'WELCOME10', + 'type' => 'code', + 'value_type' => 'percent', + 'value_amount' => 10, + 'status' => 'active', + 'starts_at' => '2025-01-01T00:00:00+00:00', + 'ends_at' => '2027-12-31T23:59:59+00:00', + 'usage_limit' => null, + 'usage_count' => 3, + 'rules_json' => ['min_purchase_amount' => 2000], + 'minimum_purchase_amount' => 2000, + ]); + + // FLAT5 - 5 EUR fixed + Discount::factory()->fixed(500)->create([ + 'store_id' => $store->id, + 'code' => 'FLAT5', + 'status' => 'active', + 'starts_at' => '2025-01-01T00:00:00+00:00', + 'ends_at' => '2027-12-31T23:59:59+00:00', + 'usage_limit' => null, + 'usage_count' => 0, + ]); + + // FREESHIP - free shipping + Discount::factory()->freeShipping()->create([ + 'store_id' => $store->id, + 'code' => 'FREESHIP', + 'status' => 'active', + 'starts_at' => '2025-01-01T00:00:00+00:00', + 'ends_at' => '2027-12-31T23:59:59+00:00', + 'usage_limit' => null, + 'usage_count' => 1, + ]); + + // EXPIRED20 - expired + Discount::factory()->expired()->create([ + 'store_id' => $store->id, + 'code' => 'EXPIRED20', + 'value_type' => 'percent', + 'value_amount' => 20, + 'starts_at' => '2024-01-01T00:00:00+00:00', + 'ends_at' => '2024-12-31T23:59:59+00:00', + 'usage_count' => 0, + ]); + + // MAXED - usage limit reached + Discount::factory()->create([ + 'store_id' => $store->id, + 'code' => 'MAXED', + 'type' => 'code', + 'value_type' => 'percent', + 'value_amount' => 10, + 'status' => 'active', + 'starts_at' => '2025-01-01T00:00:00+00:00', + 'ends_at' => '2027-12-31T23:59:59+00:00', + 'usage_limit' => 5, + 'usage_count' => 5, + ]); + } +} diff --git a/database/seeders/NavigationSeeder.php b/database/seeders/NavigationSeeder.php new file mode 100644 index 00000000..ed0c945c --- /dev/null +++ b/database/seeders/NavigationSeeder.php @@ -0,0 +1,182 @@ +first(); + $electronics = Store::where('handle', 'acme-electronics')->first(); + + $this->seedFashionNavigation($fashion); + $this->seedElectronicsNavigation($electronics); + } + + private function seedFashionNavigation(Store $store): void + { + app()->instance('current_store', $store); + + // Load collections and pages by handle + $newArrivals = Collection::where('store_id', $store->id)->where('handle', 'new-arrivals')->first(); + $tShirts = Collection::where('store_id', $store->id)->where('handle', 't-shirts')->first(); + $pantsJeans = Collection::where('store_id', $store->id)->where('handle', 'pants-jeans')->first(); + $sale = Collection::where('store_id', $store->id)->where('handle', 'sale')->first(); + + $about = Page::where('store_id', $store->id)->where('handle', 'about')->first(); + $faq = Page::where('store_id', $store->id)->where('handle', 'faq')->first(); + $shippingReturns = Page::where('store_id', $store->id)->where('handle', 'shipping-returns')->first(); + $privacy = Page::where('store_id', $store->id)->where('handle', 'privacy-policy')->first(); + $terms = Page::where('store_id', $store->id)->where('handle', 'terms')->first(); + + // Main Menu + $mainMenu = NavigationMenu::factory()->create([ + 'store_id' => $store->id, + 'handle' => 'main-menu', + 'title' => 'Main Menu', + ]); + + NavigationItem::factory()->create([ + 'menu_id' => $mainMenu->id, + 'type' => 'link', + 'label' => 'Home', + 'url' => '/', + 'position' => 0, + ]); + + NavigationItem::factory()->create([ + 'menu_id' => $mainMenu->id, + 'type' => 'collection', + 'label' => 'New Arrivals', + 'url' => null, + 'resource_id' => $newArrivals->id, + 'position' => 1, + ]); + + NavigationItem::factory()->create([ + 'menu_id' => $mainMenu->id, + 'type' => 'collection', + 'label' => 'T-Shirts', + 'url' => null, + 'resource_id' => $tShirts->id, + 'position' => 2, + ]); + + NavigationItem::factory()->create([ + 'menu_id' => $mainMenu->id, + 'type' => 'collection', + 'label' => 'Pants & Jeans', + 'url' => null, + 'resource_id' => $pantsJeans->id, + 'position' => 3, + ]); + + NavigationItem::factory()->create([ + 'menu_id' => $mainMenu->id, + 'type' => 'collection', + 'label' => 'Sale', + 'url' => null, + 'resource_id' => $sale->id, + 'position' => 4, + ]); + + // Footer Menu + $footerMenu = NavigationMenu::factory()->create([ + 'store_id' => $store->id, + 'handle' => 'footer-menu', + 'title' => 'Footer Menu', + ]); + + NavigationItem::factory()->create([ + 'menu_id' => $footerMenu->id, + 'type' => 'page', + 'label' => 'About Us', + 'url' => null, + 'resource_id' => $about->id, + 'position' => 0, + ]); + + NavigationItem::factory()->create([ + 'menu_id' => $footerMenu->id, + 'type' => 'page', + 'label' => 'FAQ', + 'url' => null, + 'resource_id' => $faq->id, + 'position' => 1, + ]); + + NavigationItem::factory()->create([ + 'menu_id' => $footerMenu->id, + 'type' => 'page', + 'label' => 'Shipping & Returns', + 'url' => null, + 'resource_id' => $shippingReturns->id, + 'position' => 2, + ]); + + NavigationItem::factory()->create([ + 'menu_id' => $footerMenu->id, + 'type' => 'page', + 'label' => 'Privacy Policy', + 'url' => null, + 'resource_id' => $privacy->id, + 'position' => 3, + ]); + + NavigationItem::factory()->create([ + 'menu_id' => $footerMenu->id, + 'type' => 'page', + 'label' => 'Terms of Service', + 'url' => null, + 'resource_id' => $terms->id, + 'position' => 4, + ]); + } + + private function seedElectronicsNavigation(Store $store): void + { + app()->instance('current_store', $store); + + $featured = Collection::where('store_id', $store->id)->where('handle', 'featured')->first(); + $accessories = Collection::where('store_id', $store->id)->where('handle', 'accessories')->first(); + + $mainMenu = NavigationMenu::factory()->create([ + 'store_id' => $store->id, + 'handle' => 'main-menu', + 'title' => 'Main Menu', + ]); + + NavigationItem::factory()->create([ + 'menu_id' => $mainMenu->id, + 'type' => 'link', + 'label' => 'Home', + 'url' => '/', + 'position' => 0, + ]); + + NavigationItem::factory()->create([ + 'menu_id' => $mainMenu->id, + 'type' => 'collection', + 'label' => 'Featured', + 'url' => null, + 'resource_id' => $featured->id, + 'position' => 1, + ]); + + NavigationItem::factory()->create([ + 'menu_id' => $mainMenu->id, + 'type' => 'collection', + 'label' => 'Accessories', + 'url' => null, + 'resource_id' => $accessories->id, + 'position' => 2, + ]); + } +} diff --git a/database/seeders/OrderSeeder.php b/database/seeders/OrderSeeder.php new file mode 100644 index 00000000..c11ab1cc --- /dev/null +++ b/database/seeders/OrderSeeder.php @@ -0,0 +1,640 @@ +first(); + $electronics = Store::where('handle', 'acme-electronics')->first(); + + $this->seedFashionOrders($fashion); + $this->seedElectronicsOrders($electronics); + } + + private function seedFashionOrders(Store $store): void + { + app()->instance('current_store', $store); + + // Preload all needed customers and products + $john = Customer::where('email', 'customer@acme.test')->first(); + $jane = Customer::where('email', 'jane@example.com')->first(); + $michael = Customer::where('email', 'michael@example.com')->first(); + $sarah = Customer::where('email', 'sarah@example.com')->first(); + $david = Customer::where('email', 'david@example.com')->first(); + $emma = Customer::where('email', 'emma@example.com')->first(); + $james = Customer::where('email', 'james@example.com')->first(); + $lisa = Customer::where('email', 'lisa@example.com')->first(); + $robert = Customer::where('email', 'robert@example.com')->first(); + $anna = Customer::where('email', 'anna@example.com')->first(); + + $johnAddr = $john->addresses()->where('is_default', true)->first(); + $addressJson = $johnAddr ? $johnAddr->address_json : $this->defaultAddress(); + + // #1001 - Awaiting fulfillment (Credit Card) + $this->createOrder($store, $john, [ + 'order_number' => '#1001', + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'fulfillment_status' => 'unfulfilled', + 'placed_at' => now()->subDays(2)->toIso8601String(), + 'lines' => [ + ['handle' => 'classic-cotton-t-shirt', 'option_match' => ['S', 'White'], 'qty' => 2, 'price' => 2499], + ], + 'subtotal' => 4998, + 'discount' => 0, + 'shipping' => 499, + 'tax' => 798, + 'total' => 5497, + 'payment_id' => 'mock_test_order1001', + 'payment_status' => 'captured', + ]); + + // #1002 - Fully delivered (Credit Card) + $order1002 = $this->createOrder($store, $john, [ + 'order_number' => '#1002', + 'payment_method' => 'credit_card', + 'status' => 'fulfilled', + 'financial_status' => 'paid', + 'fulfillment_status' => 'fulfilled', + 'placed_at' => now()->subDays(10)->toIso8601String(), + 'lines' => [ + ['handle' => 'organic-hoodie', 'option_match' => ['M'], 'qty' => 1, 'price' => 5999], + ['handle' => 'classic-cotton-t-shirt', 'option_match' => ['L', 'Black'], 'qty' => 1, 'price' => 2499], + ], + 'subtotal' => 8498, + 'discount' => 0, + 'shipping' => 499, + 'tax' => 1357, + 'total' => 8997, + 'payment_id' => 'mock_test_order1002', + 'payment_status' => 'captured', + ]); + + $this->createFulfillment($order1002, 'delivered', 'DHL', 'DHL1234567890', now()->subDays(8), true); + + // #1003 - Partially fulfilled (Credit Card) + $order1003 = $this->createOrder($store, $jane, [ + 'order_number' => '#1003', + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'fulfillment_status' => 'partial', + 'placed_at' => now()->subDays(5)->toIso8601String(), + 'lines' => [ + ['handle' => 'premium-slim-fit-jeans', 'option_match' => ['32', 'Blue'], 'qty' => 1, 'price' => 7999], + ['handle' => 'leather-belt', 'option_match' => ['L/XL', 'Brown'], 'qty' => 1, 'price' => 3499], + ], + 'subtotal' => 11498, + 'discount' => 0, + 'shipping' => 499, + 'tax' => 1836, + 'total' => 11997, + 'payment_id' => 'mock_test_order1003', + 'payment_status' => 'captured', + ]); + + // Only fulfill the jeans line + $this->createPartialFulfillment($order1003, 'shipped', 'DHL', 'DHL9876543210', now()->subDays(3), [0]); + + // #1004 - Cancelled with full refund (Credit Card) + $order1004 = $this->createOrder($store, $john, [ + 'order_number' => '#1004', + 'payment_method' => 'credit_card', + 'status' => 'cancelled', + 'financial_status' => 'refunded', + 'fulfillment_status' => 'unfulfilled', + 'placed_at' => now()->subDays(15)->toIso8601String(), + 'lines' => [ + ['handle' => 'classic-cotton-t-shirt', 'option_match' => ['M', 'Navy'], 'qty' => 1, 'price' => 2499], + ], + 'subtotal' => 2499, + 'discount' => 0, + 'shipping' => 499, + 'tax' => 399, + 'total' => 2998, + 'payment_id' => 'mock_test_order1004', + 'payment_status' => 'refunded', + ]); + + $payment1004 = $order1004->payments()->first(); + Refund::create([ + 'order_id' => $order1004->id, + 'payment_id' => $payment1004->id, + 'amount' => 2998, + 'reason' => 'Customer requested cancellation', + 'status' => 'processed', + 'provider_refund_id' => 'mock_re_test_order1004', + 'created_at' => now()->subDays(14)->toIso8601String(), + ]); + + // #1005 - Bank transfer awaiting payment + $this->createOrder($store, $jane, [ + 'order_number' => '#1005', + 'payment_method' => 'bank_transfer', + 'status' => 'pending', + 'financial_status' => 'pending', + 'fulfillment_status' => 'unfulfilled', + 'placed_at' => now()->subHours(2)->toIso8601String(), + 'lines' => [ + ['handle' => 'leather-belt', 'option_match' => ['S/M', 'Black'], 'qty' => 1, 'price' => 3499], + ], + 'subtotal' => 3499, + 'discount' => 0, + 'shipping' => 499, + 'tax' => 559, + 'total' => 3998, + 'payment_id' => 'mock_test_order1005', + 'payment_status' => 'pending', + ]); + + // #1006 - Standard paid order + $this->createOrder($store, $michael, [ + 'order_number' => '#1006', + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'fulfillment_status' => 'unfulfilled', + 'placed_at' => now()->subDay()->toIso8601String(), + 'lines' => [ + ['handle' => 'running-sneakers', 'option_match' => ['EU 42', 'Black'], 'qty' => 1, 'price' => 11999], + ], + 'subtotal' => 11999, + 'discount' => 0, + 'shipping' => 499, + 'tax' => 1916, + 'total' => 12498, + 'payment_id' => 'mock_test_order1006', + 'payment_status' => 'captured', + ]); + + // #1007 - Multi-item delivered (PayPal) + $order1007 = $this->createOrder($store, $sarah, [ + 'order_number' => '#1007', + 'payment_method' => 'paypal', + 'status' => 'fulfilled', + 'financial_status' => 'paid', + 'fulfillment_status' => 'fulfilled', + 'placed_at' => now()->subDays(20)->toIso8601String(), + 'lines' => [ + ['handle' => 'v-neck-linen-tee', 'option_match' => ['M', 'Beige'], 'qty' => 2, 'price' => 3499], + ['handle' => 'wool-scarf', 'option_match' => ['Grey'], 'qty' => 1, 'price' => 2999], + ], + 'subtotal' => 9997, + 'discount' => 0, + 'shipping' => 499, + 'tax' => 1596, + 'total' => 10496, + 'payment_id' => 'mock_test_order1007', + 'payment_status' => 'captured', + ]); + + $this->createFulfillment($order1007, 'delivered', 'DHL', 'DHL1112223334', now()->subDays(18), true); + + // #1008 - Partial refund + $order1008 = $this->createOrder($store, $david, [ + 'order_number' => '#1008', + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'partially_refunded', + 'fulfillment_status' => 'fulfilled', + 'placed_at' => now()->subDays(12)->toIso8601String(), + 'lines' => [ + ['handle' => 'cargo-pants', 'option_match' => ['32', 'Khaki'], 'qty' => 1, 'price' => 5499], + ['handle' => 'graphic-print-tee', 'option_match' => ['L'], 'qty' => 1, 'price' => 2999], + ], + 'subtotal' => 8498, + 'discount' => 0, + 'shipping' => 499, + 'tax' => 1357, + 'total' => 8997, + 'payment_id' => 'mock_test_order1008', + 'payment_status' => 'captured', + ]); + + $this->createFulfillment($order1008, 'delivered', 'UPS', 'UPS5556667778', now()->subDays(10), true); + + $payment1008 = $order1008->payments()->first(); + Refund::create([ + 'order_id' => $order1008->id, + 'payment_id' => $payment1008->id, + 'amount' => 2999, + 'reason' => 'Item returned', + 'status' => 'processed', + 'provider_refund_id' => 'mock_re_test_order1008', + 'created_at' => now()->subDays(8)->toIso8601String(), + ]); + + // #1009 - Accessories order + $this->createOrder($store, $emma, [ + 'order_number' => '#1009', + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'fulfillment_status' => 'unfulfilled', + 'placed_at' => now()->subDays(3)->toIso8601String(), + 'lines' => [ + ['handle' => 'canvas-tote-bag', 'option_match' => ['Natural'], 'qty' => 1, 'price' => 1999], + ['handle' => 'bucket-hat', 'option_match' => ['S/M', 'Black'], 'qty' => 1, 'price' => 2499], + ], + 'subtotal' => 4498, + 'discount' => 0, + 'shipping' => 499, + 'tax' => 718, + 'total' => 4997, + 'payment_id' => 'mock_test_order1009', + 'payment_status' => 'captured', + ]); + + // #1010 - High-value order (PayPal) + $this->createOrder($store, $john, [ + 'order_number' => '#1010', + 'payment_method' => 'paypal', + 'status' => 'paid', + 'financial_status' => 'paid', + 'fulfillment_status' => 'unfulfilled', + 'placed_at' => now()->subDay()->toIso8601String(), + 'lines' => [ + ['handle' => 'cashmere-overcoat', 'option_match' => ['M', 'Camel'], 'qty' => 1, 'price' => 49999], + ], + 'subtotal' => 49999, + 'discount' => 0, + 'shipping' => 499, + 'tax' => 7983, + 'total' => 50498, + 'payment_id' => 'mock_test_order1010', + 'payment_status' => 'captured', + ]); + + // #1011 - Single item delivered + $order1011 = $this->createOrder($store, $james, [ + 'order_number' => '#1011', + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'fulfillment_status' => 'fulfilled', + 'placed_at' => now()->subDays(25)->toIso8601String(), + 'lines' => [ + ['handle' => 'striped-polo-shirt', 'option_match' => ['XL'], 'qty' => 1, 'price' => 2799], + ], + 'subtotal' => 2799, + 'discount' => 0, + 'shipping' => 499, + 'tax' => 447, + 'total' => 3298, + 'payment_id' => 'mock_test_order1011', + 'payment_status' => 'captured', + ]); + + $this->createFulfillment($order1011, 'delivered', 'FedEx', 'FX9998887776', now()->subDays(23), true); + + // #1012 - Multi-quantity order + $this->createOrder($store, $lisa, [ + 'order_number' => '#1012', + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'fulfillment_status' => 'unfulfilled', + 'placed_at' => now()->subDays(4)->toIso8601String(), + 'lines' => [ + ['handle' => 'chino-shorts', 'option_match' => ['34', 'Navy'], 'qty' => 2, 'price' => 3999], + ], + 'subtotal' => 7998, + 'discount' => 0, + 'shipping' => 499, + 'tax' => 1277, + 'total' => 8497, + 'payment_id' => 'mock_test_order1012', + 'payment_status' => 'captured', + ]); + + // #1013 - Multi-item order + $this->createOrder($store, $robert, [ + 'order_number' => '#1013', + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'fulfillment_status' => 'unfulfilled', + 'placed_at' => now()->subDay()->toIso8601String(), + 'lines' => [ + ['handle' => 'wide-leg-trousers', 'option_match' => ['M'], 'qty' => 1, 'price' => 4999], + ['handle' => 'wool-scarf', 'option_match' => ['Burgundy'], 'qty' => 1, 'price' => 2999], + ], + 'subtotal' => 7998, + 'discount' => 0, + 'shipping' => 499, + 'tax' => 1277, + 'total' => 8497, + 'payment_id' => 'mock_test_order1013', + 'payment_status' => 'captured', + ]); + + // #1014 - Digital product order (auto-fulfilled) + $order1014 = $this->createOrder($store, $anna, [ + 'order_number' => '#1014', + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'fulfillment_status' => 'fulfilled', + 'placed_at' => now()->subDays(14)->toIso8601String(), + 'lines' => [ + ['handle' => 'gift-card', 'option_match' => ['50 EUR'], 'qty' => 1, 'price' => 5000], + ], + 'subtotal' => 5000, + 'discount' => 0, + 'shipping' => 0, + 'tax' => 798, + 'total' => 5000, + 'payment_id' => 'mock_test_order1014', + 'payment_status' => 'captured', + ]); + + $this->createDigitalFulfillment($order1014, now()->subDays(14)); + + // #1015 - Order with discount (Bank Transfer, confirmed) + $this->createOrder($store, $john, [ + 'order_number' => '#1015', + 'payment_method' => 'bank_transfer', + 'status' => 'paid', + 'financial_status' => 'paid', + 'fulfillment_status' => 'unfulfilled', + 'placed_at' => now()->toIso8601String(), + 'lines' => [ + ['handle' => 'classic-cotton-t-shirt', 'option_match' => ['M', 'White'], 'qty' => 1, 'price' => 2499], + ['handle' => 'graphic-print-tee', 'option_match' => ['M'], 'qty' => 1, 'price' => 2999], + ], + 'subtotal' => 5498, + 'discount' => 550, + 'shipping' => 499, + 'tax' => 790, + 'total' => 5447, + 'payment_id' => 'mock_test_order1015', + 'payment_status' => 'captured', + ]); + } + + private function seedElectronicsOrders(Store $store): void + { + app()->instance('current_store', $store); + + $techfan = Customer::where('email', 'techfan@example.com')->first(); + $gadgetlover = Customer::where('email', 'gadgetlover@example.com')->first(); + + // #5001 + $order5001 = $this->createOrder($store, $techfan, [ + 'order_number' => '#5001', + 'payment_method' => 'credit_card', + 'status' => 'fulfilled', + 'financial_status' => 'paid', + 'fulfillment_status' => 'fulfilled', + 'placed_at' => now()->subDays(5)->toIso8601String(), + 'lines' => [ + ['handle' => 'pro-laptop-15', 'option_match' => ['512GB'], 'qty' => 1, 'price' => 119999], + ['handle' => 'usb-c-cable-2m', 'option_match' => [], 'qty' => 1, 'price' => 1299], + ], + 'subtotal' => 121298, + 'discount' => 0, + 'shipping' => 0, + 'tax' => 0, + 'total' => 121298, + 'payment_id' => 'mock_test_order5001', + 'payment_status' => 'captured', + ]); + + $this->createFulfillment($order5001, 'delivered', 'DHL', 'DHL5001000001', now()->subDays(3), true); + + // #5002 + $this->createOrder($store, $gadgetlover, [ + 'order_number' => '#5002', + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'fulfillment_status' => 'unfulfilled', + 'placed_at' => now()->subDays(2)->toIso8601String(), + 'lines' => [ + ['handle' => 'wireless-headphones', 'option_match' => ['Black'], 'qty' => 1, 'price' => 14999], + ], + 'subtotal' => 14999, + 'discount' => 0, + 'shipping' => 0, + 'tax' => 0, + 'total' => 14999, + 'payment_id' => 'mock_test_order5002', + 'payment_status' => 'captured', + ]); + + // #5003 - Bank transfer pending + $this->createOrder($store, $techfan, [ + 'order_number' => '#5003', + 'payment_method' => 'bank_transfer', + 'status' => 'pending', + 'financial_status' => 'pending', + 'fulfillment_status' => 'unfulfilled', + 'placed_at' => now()->subDay()->toIso8601String(), + 'lines' => [ + ['handle' => 'monitor-stand', 'option_match' => [], 'qty' => 1, 'price' => 4999], + ], + 'subtotal' => 4999, + 'discount' => 0, + 'shipping' => 0, + 'tax' => 0, + 'total' => 4999, + 'payment_id' => 'mock_test_order5003', + 'payment_status' => 'pending', + ]); + } + + /** + * @param array $data + */ + private function createOrder(Store $store, Customer $customer, array $data): Order + { + $defaultAddr = $customer->addresses()->where('is_default', true)->first(); + $addressJson = $defaultAddr ? $defaultAddr->address_json : $this->defaultAddress(); + + $order = Order::create([ + 'store_id' => $store->id, + 'customer_id' => $customer->id, + 'order_number' => $data['order_number'], + 'payment_method' => $data['payment_method'], + 'status' => $data['status'], + 'financial_status' => $data['financial_status'], + 'fulfillment_status' => $data['fulfillment_status'], + 'currency' => 'EUR', + 'subtotal_amount' => $data['subtotal'], + 'discount_amount' => $data['discount'], + 'shipping_amount' => $data['shipping'], + 'tax_amount' => $data['tax'], + 'total_amount' => $data['total'], + 'email' => $customer->email, + 'billing_address_json' => $addressJson, + 'shipping_address_json' => $addressJson, + 'placed_at' => $data['placed_at'], + ]); + + foreach ($data['lines'] as $lineData) { + $product = Product::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('handle', $lineData['handle']) + ->first(); + + $variant = $this->findVariant($product, $lineData['option_match']); + + OrderLine::create([ + 'order_id' => $order->id, + 'product_id' => $product->id, + 'variant_id' => $variant->id, + 'title_snapshot' => $product->title, + 'sku_snapshot' => $variant->sku, + 'quantity' => $lineData['qty'], + 'unit_price_amount' => $lineData['price'], + 'total_amount' => $lineData['price'] * $lineData['qty'], + 'tax_lines_json' => [], + 'discount_allocations_json' => [], + ]); + } + + Payment::create([ + 'order_id' => $order->id, + 'provider' => 'mock', + 'method' => $data['payment_method'], + 'provider_payment_id' => $data['payment_id'], + 'status' => $data['payment_status'], + 'amount' => $data['total'], + 'currency' => 'EUR', + 'raw_json_encrypted' => null, + 'created_at' => $data['placed_at'], + ]); + + return $order; + } + + /** + * @param list $optionMatch + */ + private function findVariant(Product $product, array $optionMatch): ProductVariant + { + if (empty($optionMatch)) { + return $product->variants()->where('is_default', true)->first() + ?? $product->variants()->first(); + } + + $variants = $product->variants()->with('optionValues')->get(); + + foreach ($variants as $variant) { + $variantValues = $variant->optionValues->pluck('value')->sort()->values()->toArray(); + $matchValues = collect($optionMatch)->sort()->values()->toArray(); + + if ($variantValues === $matchValues) { + return $variant; + } + } + + // Fallback to default variant + return $product->variants()->where('is_default', true)->first() + ?? $product->variants()->first(); + } + + private function createFulfillment(Order $order, string $status, string $company, string $trackingNumber, \Carbon\CarbonInterface $shippedAt, bool $allLines): void + { + $fulfillment = Fulfillment::create([ + 'order_id' => $order->id, + 'status' => $status, + 'tracking_company' => $company, + 'tracking_number' => $trackingNumber, + 'tracking_url' => 'https://tracking.example.com/'.$trackingNumber, + 'shipped_at' => $shippedAt->toIso8601String(), + 'delivered_at' => $status === 'delivered' ? $shippedAt->addDays(2)->toIso8601String() : null, + 'created_at' => $shippedAt->toIso8601String(), + ]); + + if ($allLines) { + foreach ($order->lines as $line) { + FulfillmentLine::create([ + 'fulfillment_id' => $fulfillment->id, + 'order_line_id' => $line->id, + 'quantity' => $line->quantity, + ]); + } + } + } + + /** + * @param list $lineIndices + */ + private function createPartialFulfillment(Order $order, string $status, string $company, string $trackingNumber, \Carbon\CarbonInterface $shippedAt, array $lineIndices): void + { + $fulfillment = Fulfillment::create([ + 'order_id' => $order->id, + 'status' => $status, + 'tracking_company' => $company, + 'tracking_number' => $trackingNumber, + 'tracking_url' => 'https://tracking.example.com/'.$trackingNumber, + 'shipped_at' => $shippedAt->toIso8601String(), + 'created_at' => $shippedAt->toIso8601String(), + ]); + + $lines = $order->lines()->get(); + foreach ($lineIndices as $idx) { + if (isset($lines[$idx])) { + FulfillmentLine::create([ + 'fulfillment_id' => $fulfillment->id, + 'order_line_id' => $lines[$idx]->id, + 'quantity' => $lines[$idx]->quantity, + ]); + } + } + } + + private function createDigitalFulfillment(Order $order, \Carbon\CarbonInterface $placedAt): void + { + $fulfillment = Fulfillment::create([ + 'order_id' => $order->id, + 'status' => 'delivered', + 'tracking_company' => null, + 'tracking_number' => null, + 'tracking_url' => null, + 'shipped_at' => $placedAt->toIso8601String(), + 'delivered_at' => $placedAt->toIso8601String(), + 'created_at' => $placedAt->toIso8601String(), + ]); + + foreach ($order->lines as $line) { + FulfillmentLine::create([ + 'fulfillment_id' => $fulfillment->id, + 'order_line_id' => $line->id, + 'quantity' => $line->quantity, + ]); + } + } + + /** + * @return array + */ + private function defaultAddress(): array + { + return [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'address1' => 'Hauptstrasse 1', + 'city' => 'Berlin', + 'country' => 'Germany', + 'country_code' => 'DE', + 'zip' => '10115', + ]; + } +} diff --git a/database/seeders/OrganizationSeeder.php b/database/seeders/OrganizationSeeder.php new file mode 100644 index 00000000..95c0affb --- /dev/null +++ b/database/seeders/OrganizationSeeder.php @@ -0,0 +1,17 @@ +create([ + 'name' => 'Acme Corp', + 'billing_email' => 'billing@acme.test', + ]); + } +} diff --git a/database/seeders/PageSeeder.php b/database/seeders/PageSeeder.php new file mode 100644 index 00000000..60f7b9c2 --- /dev/null +++ b/database/seeders/PageSeeder.php @@ -0,0 +1,57 @@ +first(); + + $publishedAt = now()->subMonths(3)->toIso8601String(); + + Page::factory()->published()->create([ + 'store_id' => $fashion->id, + 'title' => 'About Us', + 'handle' => 'about', + 'published_at' => $publishedAt, + 'body_html' => '

Our Story

Acme Fashion was founded with a simple mission: to bring modern, high-quality fashion to everyone. Based in Berlin, we curate collections that blend timeless style with contemporary trends.

Our philosophy is rooted in the belief that great fashion should be accessible, sustainable, and made to last.

Our Values

We are committed to ethical sourcing, sustainable materials, and fair labor practices across our entire supply chain.

Our Team

Our Berlin-based team of designers and fashion enthusiasts work tirelessly to bring you the best curated selection of clothing and accessories.

', + ]); + + Page::factory()->published()->create([ + 'store_id' => $fashion->id, + 'title' => 'FAQ', + 'handle' => 'faq', + 'published_at' => $publishedAt, + 'body_html' => '

Frequently Asked Questions

How long does shipping take?

Standard shipping within Germany takes 2-4 business days. Express shipping arrives in 1-2 business days. EU orders typically arrive within 5-7 business days.

What is your return policy?

We accept returns within 30 days of delivery. Items must be unworn, unwashed, and in their original packaging with all tags attached.

Do you ship internationally?

Yes! We ship to all EU countries as well as the US, UK, Canada, and Australia.

How can I track my order?

Once your order has been shipped, you will receive an email with a tracking number. You can use this number to track your package on the carrier\'s website.

', + ]); + + Page::factory()->published()->create([ + 'store_id' => $fashion->id, + 'title' => 'Shipping & Returns', + 'handle' => 'shipping-returns', + 'published_at' => $publishedAt, + 'body_html' => '

Shipping

Shipping Rates

  • Germany Standard: 4.99 EUR (2-4 business days)
  • Germany Express: 9.99 EUR (1-2 business days)
  • EU Standard: 8.99 EUR (5-7 business days)
  • International: 14.99 EUR (7-14 business days)

Returns

We offer a 30-day return policy on all items. Items must be in their original condition with tags attached. Customer pays return shipping unless the item is defective.

', + ]); + + Page::factory()->published()->create([ + 'store_id' => $fashion->id, + 'title' => 'Privacy Policy', + 'handle' => 'privacy-policy', + 'published_at' => $publishedAt, + 'body_html' => '

Privacy Policy

Information We Collect

We collect information you provide directly, including name, email, shipping address, and payment details when placing an order.

How We Use Your Information

Your information is used to process orders, send shipping updates, and improve our services.

Cookies

We use cookies to enhance your browsing experience and analyze site traffic.

Contact

For privacy-related inquiries, please contact privacy@acme-fashion.test.

', + ]); + + Page::factory()->published()->create([ + 'store_id' => $fashion->id, + 'title' => 'Terms of Service', + 'handle' => 'terms', + 'published_at' => $publishedAt, + 'body_html' => '

Terms of Service

Orders and Payments

All prices are displayed in EUR and include applicable taxes. We accept credit cards, PayPal, and bank transfers.

Product Descriptions

We strive for accuracy in our product descriptions. Actual colors may vary slightly due to monitor settings.

Limitation of Liability

Acme Fashion shall not be liable for any indirect, incidental, or consequential damages arising from the use of our products or services.

Governing Law

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

', + ]); + } +} diff --git a/database/seeders/ProductSeeder.php b/database/seeders/ProductSeeder.php new file mode 100644 index 00000000..412551ca --- /dev/null +++ b/database/seeders/ProductSeeder.php @@ -0,0 +1,770 @@ +first(); + $electronics = Store::where('handle', 'acme-electronics')->first(); + + app()->instance('current_store', $fashion); + + $this->seedAcmeFashion($fashion); + $this->seedAcmeElectronics($electronics); + } + + private function seedAcmeFashion(Store $store): void + { + // Fetch collections for assignments + $newArrivals = Collection::where('store_id', $store->id)->where('handle', 'new-arrivals')->first(); + $tShirts = Collection::where('store_id', $store->id)->where('handle', 't-shirts')->first(); + $pantsJeans = Collection::where('store_id', $store->id)->where('handle', 'pants-jeans')->first(); + $sale = Collection::where('store_id', $store->id)->where('handle', 'sale')->first(); + + // Product definitions: [handle, title, vendor, type, tags, desc, status, published_at, price, compare_at, options, collections, inventory_qty, inventory_policy] + $products = $this->getFashionProducts(); + + foreach ($products as $i => $p) { + $product = Product::factory()->create([ + 'store_id' => $store->id, + 'title' => $p['title'], + 'handle' => $p['handle'], + 'status' => $p['status'], + 'vendor' => $p['vendor'], + 'product_type' => $p['product_type'], + 'tags' => $p['tags'], + 'description_html' => '

'.$p['description'].'

', + 'published_at' => $p['published_at'], + ]); + + // Create options and values + $optionValues = []; + foreach ($p['options'] as $optIdx => $option) { + $optionModel = ProductOption::factory()->create([ + 'product_id' => $product->id, + 'name' => $option['name'], + 'position' => $optIdx, + ]); + + $values = []; + foreach ($option['values'] as $valIdx => $val) { + $values[] = ProductOptionValue::factory()->create([ + 'product_option_id' => $optionModel->id, + 'value' => $val, + 'position' => $valIdx, + ]); + } + $optionValues[] = $values; + } + + // Generate variants from option combinations + $combinations = $this->generateCombinations($optionValues); + $position = 0; + + foreach ($combinations as $combo) { + $comboValues = is_array($combo) ? $combo : [$combo]; + $skuParts = [$p['sku_prefix'] ?? 'SKU']; + foreach ($comboValues as $v) { + $skuParts[] = strtoupper(substr(preg_replace('/[^a-zA-Z0-9]/', '', $v->value), 0, 3)); + } + + $variantPrice = isset($p['custom_variant_prices']) + ? ($p['custom_variant_prices'][$position] ?? $p['price']) + : $p['price']; + + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'sku' => implode('-', $skuParts).'-'.str_pad((string) $position, 2, '0', STR_PAD_LEFT), + 'price_amount' => $variantPrice, + 'compare_at_amount' => $p['compare_at'] ?? null, + 'currency' => 'EUR', + 'weight_g' => $p['weight_g'], + 'requires_shipping' => $p['requires_shipping'] ?? true, + 'is_default' => $position === 0, + 'position' => $position, + 'status' => 'active', + ]); + + // Attach option values to variant + foreach ($comboValues as $v) { + $variant->optionValues()->attach($v->id); + } + + InventoryItem::factory()->create([ + 'store_id' => $store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => $p['inventory_qty'], + 'quantity_reserved' => 0, + 'policy' => $p['inventory_policy'], + ]); + + $position++; + } + + // Assign to collections + $collectionMap = [ + 'new-arrivals' => $newArrivals, + 't-shirts' => $tShirts, + 'pants-jeans' => $pantsJeans, + 'sale' => $sale, + ]; + + foreach ($p['collections'] as $colIdx => $colHandle) { + $col = $collectionMap[$colHandle] ?? null; + if ($col) { + $col->products()->attach($product->id, ['position' => $colIdx]); + } + } + } + } + + /** + * @return list> + */ + private function getFashionProducts(): array + { + return [ + // Product 1 + [ + 'title' => 'Classic Cotton T-Shirt', + 'handle' => 'classic-cotton-t-shirt', + 'vendor' => 'Acme Basics', + 'product_type' => 'T-Shirts', + 'tags' => ['new', 'popular'], + 'description' => 'A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.', + 'status' => 'active', + 'published_at' => now()->toIso8601String(), + 'price' => 2499, + 'compare_at' => null, + 'weight_g' => 200, + 'inventory_qty' => 15, + 'inventory_policy' => 'deny', + 'sku_prefix' => 'ACME-CTSH', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ['name' => 'Color', 'values' => ['White', 'Black', 'Navy']], + ], + 'collections' => ['new-arrivals', 't-shirts'], + ], + // Product 2 + [ + 'title' => 'Premium Slim Fit Jeans', + 'handle' => 'premium-slim-fit-jeans', + 'vendor' => 'Acme Denim', + 'product_type' => 'Pants', + 'tags' => ['new', 'sale'], + 'description' => 'Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.', + 'status' => 'active', + 'published_at' => now()->toIso8601String(), + 'price' => 7999, + 'compare_at' => 9999, + 'weight_g' => 800, + 'inventory_qty' => 8, + 'inventory_policy' => 'deny', + 'sku_prefix' => 'ACME-JEANS', + 'options' => [ + ['name' => 'Size', 'values' => ['28', '30', '32', '34', '36']], + ['name' => 'Color', 'values' => ['Blue', 'Black']], + ], + 'collections' => ['new-arrivals', 'pants-jeans', 'sale'], + ], + // Product 3 + [ + 'title' => 'Organic Hoodie', + 'handle' => 'organic-hoodie', + 'vendor' => 'Acme Basics', + 'product_type' => 'Hoodies', + 'tags' => ['new', 'trending'], + 'description' => 'Made from 100% organic cotton. Warm, soft, and sustainably produced.', + 'status' => 'active', + 'published_at' => now()->toIso8601String(), + 'price' => 5999, + 'compare_at' => null, + 'weight_g' => 500, + 'inventory_qty' => 20, + 'inventory_policy' => 'deny', + 'sku_prefix' => 'ACME-HOOD', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ], + 'collections' => ['new-arrivals'], + ], + // Product 4 + [ + 'title' => 'Leather Belt', + 'handle' => 'leather-belt', + 'vendor' => 'Acme Accessories', + 'product_type' => 'Accessories', + 'tags' => ['popular'], + 'description' => 'Genuine leather belt with brushed metal buckle. A wardrobe essential.', + 'status' => 'active', + 'published_at' => now()->toIso8601String(), + 'price' => 3499, + 'compare_at' => null, + 'weight_g' => 150, + 'inventory_qty' => 25, + 'inventory_policy' => 'deny', + 'sku_prefix' => 'ACME-BELT', + 'options' => [ + ['name' => 'Size', 'values' => ['S/M', 'L/XL']], + ['name' => 'Color', 'values' => ['Brown', 'Black']], + ], + 'collections' => [], + ], + // Product 5 + [ + 'title' => 'Running Sneakers', + 'handle' => 'running-sneakers', + 'vendor' => 'Acme Sport', + 'product_type' => 'Shoes', + 'tags' => ['trending'], + 'description' => 'Lightweight running sneakers with responsive cushioning and breathable mesh upper.', + 'status' => 'active', + 'published_at' => now()->toIso8601String(), + 'price' => 11999, + 'compare_at' => null, + 'weight_g' => 600, + 'inventory_qty' => 5, + 'inventory_policy' => 'deny', + 'sku_prefix' => 'ACME-RUN', + 'options' => [ + ['name' => 'Size', 'values' => ['EU 38', 'EU 39', 'EU 40', 'EU 41', 'EU 42', 'EU 43', 'EU 44']], + ['name' => 'Color', 'values' => ['White', 'Black']], + ], + 'collections' => ['new-arrivals'], + ], + // Product 6 + [ + 'title' => 'Graphic Print Tee', + 'handle' => 'graphic-print-tee', + 'vendor' => 'Acme Basics', + 'product_type' => 'T-Shirts', + 'tags' => ['new'], + 'description' => 'Bold graphic print on soft cotton. Express yourself with this statement piece.', + 'status' => 'active', + 'published_at' => now()->toIso8601String(), + 'price' => 2999, + 'compare_at' => null, + 'weight_g' => 210, + 'inventory_qty' => 18, + 'inventory_policy' => 'deny', + 'sku_prefix' => 'ACME-GPT', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ], + 'collections' => ['t-shirts'], + ], + // Product 7 + [ + 'title' => 'V-Neck Linen Tee', + 'handle' => 'v-neck-linen-tee', + 'vendor' => 'Acme Basics', + 'product_type' => 'T-Shirts', + 'tags' => ['popular'], + 'description' => 'Lightweight linen blend v-neck. Perfect for warm summer days.', + 'status' => 'active', + 'published_at' => now()->toIso8601String(), + 'price' => 3499, + 'compare_at' => null, + 'weight_g' => 180, + 'inventory_qty' => 12, + 'inventory_policy' => 'deny', + 'sku_prefix' => 'ACME-VNT', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L']], + ['name' => 'Color', 'values' => ['Beige', 'Olive', 'Sky Blue']], + ], + 'collections' => ['t-shirts'], + ], + // Product 8 + [ + 'title' => 'Striped Polo Shirt', + 'handle' => 'striped-polo-shirt', + 'vendor' => 'Acme Basics', + 'product_type' => 'T-Shirts', + 'tags' => ['sale'], + 'description' => 'Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.', + 'status' => 'active', + 'published_at' => now()->toIso8601String(), + 'price' => 2799, + 'compare_at' => 3999, + 'weight_g' => 250, + 'inventory_qty' => 10, + 'inventory_policy' => 'deny', + 'sku_prefix' => 'ACME-POLO', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ], + 'collections' => ['t-shirts', 'sale'], + ], + // Product 9 + [ + 'title' => 'Cargo Pants', + 'handle' => 'cargo-pants', + 'vendor' => 'Acme Workwear', + 'product_type' => 'Pants', + 'tags' => ['popular'], + 'description' => 'Utility cargo pants with multiple pockets. Durable cotton twill construction.', + 'status' => 'active', + 'published_at' => now()->toIso8601String(), + 'price' => 5499, + 'compare_at' => null, + 'weight_g' => 700, + 'inventory_qty' => 14, + 'inventory_policy' => 'deny', + 'sku_prefix' => 'ACME-CARGO', + 'options' => [ + ['name' => 'Size', 'values' => ['30', '32', '34', '36']], + ['name' => 'Color', 'values' => ['Khaki', 'Olive', 'Black']], + ], + 'collections' => ['pants-jeans'], + ], + // Product 10 + [ + 'title' => 'Chino Shorts', + 'handle' => 'chino-shorts', + 'vendor' => 'Acme Basics', + 'product_type' => 'Pants', + 'tags' => ['new', 'trending'], + 'description' => 'Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.', + 'status' => 'active', + 'published_at' => now()->toIso8601String(), + 'price' => 3999, + 'compare_at' => null, + 'weight_g' => 350, + 'inventory_qty' => 16, + 'inventory_policy' => 'deny', + 'sku_prefix' => 'ACME-CHIN', + 'options' => [ + ['name' => 'Size', 'values' => ['30', '32', '34', '36']], + ['name' => 'Color', 'values' => ['Navy', 'Sand']], + ], + 'collections' => ['pants-jeans', 'new-arrivals'], + ], + // Product 11 + [ + 'title' => 'Wide Leg Trousers', + 'handle' => 'wide-leg-trousers', + 'vendor' => 'Acme Denim', + 'product_type' => 'Pants', + 'tags' => ['sale'], + 'description' => 'Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.', + 'status' => 'active', + 'published_at' => now()->toIso8601String(), + 'price' => 4999, + 'compare_at' => 6999, + 'weight_g' => 550, + 'inventory_qty' => 7, + 'inventory_policy' => 'deny', + 'sku_prefix' => 'ACME-WLT', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L']], + ], + 'collections' => ['pants-jeans', 'sale'], + ], + // Product 12 + [ + 'title' => 'Wool Scarf', + 'handle' => 'wool-scarf', + 'vendor' => 'Acme Accessories', + 'product_type' => 'Accessories', + 'tags' => ['popular'], + 'description' => 'Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.', + 'status' => 'active', + 'published_at' => now()->toIso8601String(), + 'price' => 2999, + 'compare_at' => null, + 'weight_g' => 120, + 'inventory_qty' => 30, + 'inventory_policy' => 'deny', + 'sku_prefix' => 'ACME-SCRF', + 'options' => [ + ['name' => 'Color', 'values' => ['Grey', 'Burgundy', 'Navy']], + ], + 'collections' => [], + ], + // Product 13 + [ + 'title' => 'Canvas Tote Bag', + 'handle' => 'canvas-tote-bag', + 'vendor' => 'Acme Accessories', + 'product_type' => 'Accessories', + 'tags' => ['trending'], + 'description' => 'Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.', + 'status' => 'active', + 'published_at' => now()->toIso8601String(), + 'price' => 1999, + 'compare_at' => null, + 'weight_g' => 300, + 'inventory_qty' => 40, + 'inventory_policy' => 'deny', + 'sku_prefix' => 'ACME-TOTE', + 'options' => [ + ['name' => 'Color', 'values' => ['Natural', 'Black']], + ], + 'collections' => [], + ], + // Product 14 + [ + 'title' => 'Bucket Hat', + 'handle' => 'bucket-hat', + 'vendor' => 'Acme Accessories', + 'product_type' => 'Accessories', + 'tags' => ['new', 'trending'], + 'description' => 'Lightweight bucket hat for sun protection. Packable design, washed cotton twill.', + 'status' => 'active', + 'published_at' => now()->toIso8601String(), + 'price' => 2499, + 'compare_at' => null, + 'weight_g' => 80, + 'inventory_qty' => 22, + 'inventory_policy' => 'deny', + 'sku_prefix' => 'ACME-BHAT', + 'options' => [ + ['name' => 'Size', 'values' => ['S/M', 'L/XL']], + ['name' => 'Color', 'values' => ['Beige', 'Black', 'Olive']], + ], + 'collections' => ['new-arrivals'], + ], + // Product 15 - DRAFT + [ + 'title' => 'Unreleased Winter Jacket', + 'handle' => 'unreleased-winter-jacket', + 'vendor' => 'Acme Outerwear', + 'product_type' => 'Jackets', + 'tags' => ['limited'], + 'description' => 'Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.', + 'status' => 'draft', + 'published_at' => null, + 'price' => 14999, + 'compare_at' => null, + 'weight_g' => 900, + 'inventory_qty' => 0, + 'inventory_policy' => 'deny', + 'sku_prefix' => 'ACME-WJKT', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ], + 'collections' => [], + ], + // Product 16 - ARCHIVED + [ + 'title' => 'Discontinued Raincoat', + 'handle' => 'discontinued-raincoat', + 'vendor' => 'Acme Outerwear', + 'product_type' => 'Jackets', + 'tags' => [], + 'description' => 'Lightweight waterproof raincoat. This product has been discontinued.', + 'status' => 'archived', + 'published_at' => now()->subMonths(6)->toIso8601String(), + 'price' => 8999, + 'compare_at' => null, + 'weight_g' => 400, + 'inventory_qty' => 3, + 'inventory_policy' => 'deny', + 'sku_prefix' => 'ACME-RAIN', + 'options' => [ + ['name' => 'Size', 'values' => ['M', 'L']], + ], + 'collections' => [], + ], + // Product 17 - SOLD OUT (deny) + [ + 'title' => 'Limited Edition Sneakers', + 'handle' => 'limited-edition-sneakers', + 'vendor' => 'Acme Sport', + 'product_type' => 'Shoes', + 'tags' => ['limited'], + 'description' => 'Limited edition collaboration sneakers. Once they are gone, they are gone.', + 'status' => 'active', + 'published_at' => now()->toIso8601String(), + 'price' => 15999, + 'compare_at' => null, + 'weight_g' => 650, + 'inventory_qty' => 0, + 'inventory_policy' => 'deny', + 'sku_prefix' => 'ACME-LSNK', + 'options' => [ + ['name' => 'Size', 'values' => ['EU 40', 'EU 42', 'EU 44']], + ], + 'collections' => [], + ], + // Product 18 - BACKORDER (continue) + [ + 'title' => 'Backorder Denim Jacket', + 'handle' => 'backorder-denim-jacket', + 'vendor' => 'Acme Denim', + 'product_type' => 'Jackets', + 'tags' => ['popular'], + 'description' => 'Classic denim jacket. Currently on backorder - ships within 2-3 weeks.', + 'status' => 'active', + 'published_at' => now()->toIso8601String(), + 'price' => 9999, + 'compare_at' => null, + 'weight_g' => 750, + 'inventory_qty' => 0, + 'inventory_policy' => 'continue', + 'sku_prefix' => 'ACME-DJKT', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ], + 'collections' => [], + ], + // Product 19 - DIGITAL (gift card) + [ + 'title' => 'Gift Card', + 'handle' => 'gift-card', + 'vendor' => 'Acme Fashion', + 'product_type' => 'Gift Cards', + 'tags' => ['popular'], + 'description' => 'Digital gift card delivered via email. The perfect gift when you are not sure what to choose.', + 'status' => 'active', + 'published_at' => now()->toIso8601String(), + 'price' => 2500, // will be overridden per variant + 'compare_at' => null, + 'weight_g' => 0, + 'requires_shipping' => false, + 'inventory_qty' => 9999, + 'inventory_policy' => 'deny', + 'sku_prefix' => 'ACME-GIFT', + 'options' => [ + ['name' => 'Amount', 'values' => ['25 EUR', '50 EUR', '100 EUR']], + ], + 'collections' => [], + 'custom_variant_prices' => [2500, 5000, 10000], + ], + // Product 20 + [ + 'title' => 'Cashmere Overcoat', + 'handle' => 'cashmere-overcoat', + 'vendor' => 'Acme Premium', + 'product_type' => 'Jackets', + 'tags' => ['limited', 'new'], + 'description' => 'Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.', + 'status' => 'active', + 'published_at' => now()->toIso8601String(), + 'price' => 49999, + 'compare_at' => null, + 'weight_g' => 1200, + 'inventory_qty' => 3, + 'inventory_policy' => 'deny', + 'sku_prefix' => 'ACME-COAT', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L']], + ['name' => 'Color', 'values' => ['Camel', 'Charcoal']], + ], + 'collections' => ['new-arrivals'], + ], + ]; + } + + private function seedAcmeElectronics(Store $store): void + { + app()->instance('current_store', $store); + + $featured = Collection::where('store_id', $store->id)->where('handle', 'featured')->first(); + $accessories = Collection::where('store_id', $store->id)->where('handle', 'accessories')->first(); + + $products = [ + [ + 'title' => 'Pro Laptop 15', + 'handle' => 'pro-laptop-15', + 'vendor' => 'TechCorp', + 'product_type' => 'Laptops', + 'tags' => ['popular'], + 'description' => 'High-performance laptop for professionals.', + 'options' => [['name' => 'Storage', 'values' => ['256GB', '512GB', '1TB']]], + 'custom_variant_prices' => [99999, 119999, 149999], + 'weight_g' => 1800, + 'inventory_qty' => 10, + 'collections' => [$featured], + ], + [ + 'title' => 'Wireless Headphones', + 'handle' => 'wireless-headphones', + 'vendor' => 'AudioMax', + 'product_type' => 'Audio', + 'tags' => ['trending'], + 'description' => 'Premium wireless headphones with noise cancellation.', + 'options' => [['name' => 'Color', 'values' => ['Black', 'Silver']]], + 'custom_variant_prices' => [14999, 14999], + 'weight_g' => 250, + 'inventory_qty' => 25, + 'collections' => [$featured], + ], + [ + 'title' => 'USB-C Cable 2m', + 'handle' => 'usb-c-cable-2m', + 'vendor' => 'CablePro', + 'product_type' => 'Cables', + 'tags' => [], + 'description' => 'Durable USB-C cable, 2 meters.', + 'options' => [], + 'custom_variant_prices' => [1299], + 'weight_g' => 50, + 'inventory_qty' => 200, + 'collections' => [$accessories], + ], + [ + 'title' => 'Mechanical Keyboard', + 'handle' => 'mechanical-keyboard', + 'vendor' => 'KeyTech', + 'product_type' => 'Peripherals', + 'tags' => ['popular'], + 'description' => 'Premium mechanical keyboard with hot-swap switches.', + 'options' => [['name' => 'Switch Type', 'values' => ['Red', 'Blue', 'Brown']]], + 'custom_variant_prices' => [12999, 12999, 12999], + 'weight_g' => 1100, + 'inventory_qty' => 15, + 'collections' => [$featured], + ], + [ + 'title' => 'Monitor Stand', + 'handle' => 'monitor-stand', + 'vendor' => 'DeskGear', + 'product_type' => 'Accessories', + 'tags' => [], + 'description' => 'Ergonomic monitor stand with cable management.', + 'options' => [], + 'custom_variant_prices' => [4999], + 'weight_g' => 2500, + 'inventory_qty' => 30, + 'collections' => [$accessories], + ], + ]; + + foreach ($products as $p) { + $product = Product::factory()->create([ + 'store_id' => $store->id, + 'title' => $p['title'], + 'handle' => $p['handle'], + 'status' => 'active', + 'vendor' => $p['vendor'], + 'product_type' => $p['product_type'], + 'tags' => $p['tags'], + 'description_html' => '

'.$p['description'].'

', + 'published_at' => now()->toIso8601String(), + ]); + + if (empty($p['options'])) { + // Single default variant + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'sku' => strtoupper(str_replace(' ', '-', $p['title'])).'-DEFAULT', + 'price_amount' => $p['custom_variant_prices'][0], + 'currency' => 'EUR', + 'weight_g' => $p['weight_g'], + 'is_default' => true, + 'position' => 0, + 'status' => 'active', + ]); + + InventoryItem::factory()->create([ + 'store_id' => $store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => $p['inventory_qty'], + 'policy' => 'deny', + ]); + } else { + $optionValues = []; + foreach ($p['options'] as $optIdx => $option) { + $optionModel = ProductOption::factory()->create([ + 'product_id' => $product->id, + 'name' => $option['name'], + 'position' => $optIdx, + ]); + + $values = []; + foreach ($option['values'] as $valIdx => $val) { + $values[] = ProductOptionValue::factory()->create([ + 'product_option_id' => $optionModel->id, + 'value' => $val, + 'position' => $valIdx, + ]); + } + $optionValues[] = $values; + } + + $combinations = $this->generateCombinations($optionValues); + $priceIdx = 0; + + foreach ($combinations as $combo) { + $comboValues = is_array($combo) ? $combo : [$combo]; + + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'sku' => strtoupper(str_replace(' ', '-', $p['title'])).'-'.str_pad((string) $priceIdx, 2, '0', STR_PAD_LEFT), + 'price_amount' => $p['custom_variant_prices'][$priceIdx] ?? $p['custom_variant_prices'][0], + 'currency' => 'EUR', + 'weight_g' => $p['weight_g'], + 'is_default' => $priceIdx === 0, + 'position' => $priceIdx, + 'status' => 'active', + ]); + + foreach ($comboValues as $v) { + $variant->optionValues()->attach($v->id); + } + + InventoryItem::factory()->create([ + 'store_id' => $store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => $p['inventory_qty'], + 'policy' => 'deny', + ]); + + $priceIdx++; + } + } + + foreach ($p['collections'] as $colIdx => $col) { + if ($col) { + $col->products()->attach($product->id, ['position' => $colIdx]); + } + } + } + } + + /** + * Generate all combinations of option values (cartesian product). + * + * @param list> $optionValues + * @return list|ProductOptionValue> + */ + private function generateCombinations(array $optionValues): array + { + if (empty($optionValues)) { + return []; + } + + if (count($optionValues) === 1) { + return $optionValues[0]; + } + + $result = [[]]; + foreach ($optionValues as $values) { + $newResult = []; + foreach ($result as $existing) { + foreach ($values as $value) { + $combo = is_array($existing) ? $existing : [$existing]; + $combo[] = $value; + $newResult[] = $combo; + } + } + $result = $newResult; + } + + return $result; + } +} diff --git a/database/seeders/SearchSettingsSeeder.php b/database/seeders/SearchSettingsSeeder.php new file mode 100644 index 00000000..00a03e16 --- /dev/null +++ b/database/seeders/SearchSettingsSeeder.php @@ -0,0 +1,37 @@ +first(); + $electronics = Store::where('handle', 'acme-electronics')->first(); + + SearchSettings::create([ + 'store_id' => $fashion->id, + 'synonyms_json' => [ + ['tee', 't-shirt', 'tshirt'], + ['pants', 'trousers', 'jeans'], + ['sneakers', 'trainers', 'shoes'], + ['hoodie', 'sweatshirt'], + ], + 'stop_words_json' => ['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'is'], + ]); + + SearchSettings::create([ + 'store_id' => $electronics->id, + 'synonyms_json' => [ + ['laptop', 'notebook', 'computer'], + ['headphones', 'earphones', 'earbuds'], + ['cable', 'cord', 'wire'], + ], + 'stop_words_json' => ['the', 'a', 'an', 'and', 'or'], + ]); + } +} diff --git a/database/seeders/ShippingZoneSeeder.php b/database/seeders/ShippingZoneSeeder.php new file mode 100644 index 00000000..8a940afe --- /dev/null +++ b/database/seeders/ShippingZoneSeeder.php @@ -0,0 +1,91 @@ +first(); + $electronics = Store::where('handle', 'acme-electronics')->first(); + + // Acme Fashion zones + $domestic = ShippingZone::factory()->create([ + 'store_id' => $fashion->id, + 'name' => 'Domestic', + 'countries_json' => ['DE'], + 'regions_json' => [], + 'is_active' => true, + ]); + + ShippingRate::factory()->create([ + 'zone_id' => $domestic->id, + 'name' => 'Standard Shipping', + 'type' => 'flat', + 'config_json' => ['amount' => 499], + 'is_active' => true, + ]); + + ShippingRate::factory()->create([ + 'zone_id' => $domestic->id, + 'name' => 'Express Shipping', + 'type' => 'flat', + 'config_json' => ['amount' => 999], + 'is_active' => true, + ]); + + $eu = ShippingZone::factory()->create([ + 'store_id' => $fashion->id, + 'name' => 'EU', + 'countries_json' => ['AT', 'FR', 'IT', 'ES', 'NL', 'BE', 'PL'], + 'regions_json' => [], + 'is_active' => true, + ]); + + ShippingRate::factory()->create([ + 'zone_id' => $eu->id, + 'name' => 'EU Standard', + 'type' => 'flat', + 'config_json' => ['amount' => 899], + 'is_active' => true, + ]); + + $row = ShippingZone::factory()->create([ + 'store_id' => $fashion->id, + 'name' => 'Rest of World', + 'countries_json' => ['US', 'GB', 'CA', 'AU'], + 'regions_json' => [], + 'is_active' => true, + ]); + + ShippingRate::factory()->create([ + 'zone_id' => $row->id, + 'name' => 'International', + 'type' => 'flat', + 'config_json' => ['amount' => 1499], + 'is_active' => true, + ]); + + // Acme Electronics - one zone, free shipping + $deZone = ShippingZone::factory()->create([ + 'store_id' => $electronics->id, + 'name' => 'Germany', + 'countries_json' => ['DE'], + 'regions_json' => [], + 'is_active' => true, + ]); + + ShippingRate::factory()->create([ + 'zone_id' => $deZone->id, + 'name' => 'Standard', + 'type' => 'flat', + 'config_json' => ['amount' => 0], + 'is_active' => true, + ]); + } +} diff --git a/database/seeders/StoreDomainSeeder.php b/database/seeders/StoreDomainSeeder.php new file mode 100644 index 00000000..2c89607a --- /dev/null +++ b/database/seeders/StoreDomainSeeder.php @@ -0,0 +1,48 @@ +first(); + $electronics = Store::where('handle', 'acme-electronics')->first(); + + StoreDomain::factory()->create([ + 'store_id' => $fashion->id, + 'hostname' => 'acme-fashion.test', + 'type' => 'storefront', + 'is_primary' => true, + 'tls_mode' => 'managed', + ]); + + StoreDomain::factory()->create([ + 'store_id' => $fashion->id, + 'hostname' => 'shop.test', + 'type' => 'storefront', + 'is_primary' => false, + 'tls_mode' => 'managed', + ]); + + StoreDomain::factory()->create([ + 'store_id' => $fashion->id, + 'hostname' => 'admin.acme-fashion.test', + 'type' => 'admin', + 'is_primary' => false, + 'tls_mode' => 'managed', + ]); + + StoreDomain::factory()->create([ + 'store_id' => $electronics->id, + 'hostname' => 'acme-electronics.test', + 'type' => 'storefront', + 'is_primary' => true, + 'tls_mode' => 'managed', + ]); + } +} diff --git a/database/seeders/StoreSeeder.php b/database/seeders/StoreSeeder.php new file mode 100644 index 00000000..ae30e200 --- /dev/null +++ b/database/seeders/StoreSeeder.php @@ -0,0 +1,35 @@ +first(); + + Store::factory()->create([ + 'organization_id' => $org->id, + 'name' => 'Acme Fashion', + 'handle' => 'acme-fashion', + 'status' => 'active', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ]); + + Store::factory()->create([ + 'organization_id' => $org->id, + 'name' => 'Acme Electronics', + 'handle' => 'acme-electronics', + 'status' => 'active', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ]); + } +} diff --git a/database/seeders/StoreSettingsSeeder.php b/database/seeders/StoreSettingsSeeder.php new file mode 100644 index 00000000..695ddbc4 --- /dev/null +++ b/database/seeders/StoreSettingsSeeder.php @@ -0,0 +1,36 @@ +first(); + $electronics = Store::where('handle', 'acme-electronics')->first(); + + StoreSettings::factory()->create([ + 'store_id' => $fashion->id, + 'settings_json' => [ + 'store_name' => 'Acme Fashion', + 'contact_email' => 'hello@acme-fashion.test', + 'order_number_prefix' => '#', + 'order_number_start' => 1001, + ], + ]); + + StoreSettings::factory()->create([ + 'store_id' => $electronics->id, + 'settings_json' => [ + 'store_name' => 'Acme Electronics', + 'contact_email' => 'hello@acme-electronics.test', + 'order_number_prefix' => '#', + 'order_number_start' => 5001, + ], + ]); + } +} diff --git a/database/seeders/StoreUserSeeder.php b/database/seeders/StoreUserSeeder.php new file mode 100644 index 00000000..192e55ff --- /dev/null +++ b/database/seeders/StoreUserSeeder.php @@ -0,0 +1,29 @@ +first(); + $electronics = Store::where('handle', 'acme-electronics')->first(); + + $assignments = [ + ['email' => 'admin@acme.test', 'store' => $fashion, 'role' => 'owner'], + ['email' => 'staff@acme.test', 'store' => $fashion, 'role' => 'staff'], + ['email' => 'support@acme.test', 'store' => $fashion, 'role' => 'support'], + ['email' => 'manager@acme.test', 'store' => $fashion, 'role' => 'admin'], + ['email' => 'admin2@acme.test', 'store' => $electronics, 'role' => 'owner'], + ]; + + foreach ($assignments as $a) { + $user = User::where('email', $a['email'])->first(); + $a['store']->users()->attach($user->id, ['role' => $a['role']]); + } + } +} diff --git a/database/seeders/TaxSettingsSeeder.php b/database/seeders/TaxSettingsSeeder.php new file mode 100644 index 00000000..98f7d4a3 --- /dev/null +++ b/database/seeders/TaxSettingsSeeder.php @@ -0,0 +1,27 @@ +create([ + 'store_id' => $store->id, + 'mode' => 'manual', + 'provider' => 'none', + 'rate' => 1900, + 'prices_include_tax' => true, + 'tax_name' => 'VAT', + 'is_active' => true, + ]); + } + } +} diff --git a/database/seeders/ThemeSeeder.php b/database/seeders/ThemeSeeder.php new file mode 100644 index 00000000..3138b16f --- /dev/null +++ b/database/seeders/ThemeSeeder.php @@ -0,0 +1,121 @@ +first(); + $electronics = Store::where('handle', 'acme-electronics')->first(); + + // Acme Fashion theme + $fashionTheme = Theme::factory()->published()->create([ + 'store_id' => $fashion->id, + 'name' => 'Default Theme', + 'version' => '1.0.0', + ]); + + ThemeFile::factory()->create(['theme_id' => $fashionTheme->id, 'path' => 'templates/index.html', 'byte_size' => 1024]); + ThemeFile::factory()->create(['theme_id' => $fashionTheme->id, 'path' => 'templates/product.html', 'byte_size' => 2048]); + ThemeFile::factory()->create(['theme_id' => $fashionTheme->id, 'path' => 'assets/theme.css', 'byte_size' => 4096]); + + ThemeSettings::factory()->create([ + 'theme_id' => $fashionTheme->id, + 'settings_json' => [ + 'primary_color' => '#1a1a2e', + 'secondary_color' => '#e94560', + 'font_family' => 'Inter, sans-serif', + 'announcement_bar' => [ + 'enabled' => true, + 'text' => 'Free shipping on orders over 50 EUR - Use code FREESHIP', + 'link' => '/collections/sale', + ], + 'header' => [ + 'sticky' => true, + 'logo_url' => null, + ], + 'footer' => [ + 'social_links' => [ + ['platform' => 'facebook', 'url' => 'https://facebook.com/acme'], + ['platform' => 'instagram', 'url' => 'https://instagram.com/acme'], + ], + 'footer_text' => '2025 Acme Fashion. All rights reserved.', + ], + 'dark_mode' => 'system', + 'sections' => [ + 'hero' => [ + 'enabled' => true, + 'heading' => 'Welcome to Acme Fashion', + 'subheading' => 'Discover our curated collection of modern essentials', + 'cta_text' => 'Shop New Arrivals', + 'cta_link' => '/collections/new-arrivals', + 'background_image' => null, + ], + 'featured_collections' => [ + 'enabled' => true, + 'collection_handles' => ['new-arrivals', 't-shirts', 'sale'], + ], + 'featured_products' => [ + 'enabled' => true, + 'product_ids' => [], + ], + 'newsletter' => [ + 'enabled' => true, + ], + 'rich_text' => [ + 'enabled' => false, + 'content' => '', + ], + ], + 'section_order' => ['hero', 'featured_collections', 'featured_products', 'newsletter', 'rich_text'], + 'products_per_page' => 12, + 'show_vendor' => true, + 'show_quantity_selector' => true, + ], + ]); + + // Acme Electronics theme + $electronicsTheme = Theme::factory()->published()->create([ + 'store_id' => $electronics->id, + 'name' => 'Default Theme', + 'version' => '1.0.0', + ]); + + ThemeSettings::factory()->create([ + 'theme_id' => $electronicsTheme->id, + 'settings_json' => [ + 'primary_color' => '#0f172a', + 'secondary_color' => '#3b82f6', + 'font_family' => 'Inter, sans-serif', + 'announcement_bar' => ['enabled' => false], + 'header' => ['sticky' => true, 'logo_url' => null], + 'footer' => [ + 'social_links' => [], + 'footer_text' => '2025 Acme Electronics. All rights reserved.', + ], + 'dark_mode' => 'system', + 'sections' => [ + 'hero' => [ + 'enabled' => true, + 'heading' => 'Acme Electronics', + 'subheading' => 'Premium tech for professionals', + 'cta_text' => 'Shop Featured', + 'cta_link' => '/collections/featured', + ], + 'featured_collections' => [ + 'enabled' => true, + 'collection_handles' => ['featured'], + ], + ], + 'section_order' => ['hero', 'featured_collections'], + ], + ]); + } +} diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php new file mode 100644 index 00000000..1f0933ee --- /dev/null +++ b/database/seeders/UserSeeder.php @@ -0,0 +1,30 @@ + 'admin@acme.test', 'name' => 'Admin User', 'last_login_at' => now()], + ['email' => 'staff@acme.test', 'name' => 'Staff User', 'last_login_at' => now()->subDays(2)], + ['email' => 'support@acme.test', 'name' => 'Support User', 'last_login_at' => now()->subDay()], + ['email' => 'manager@acme.test', 'name' => 'Store Manager', 'last_login_at' => now()->subDay()], + ['email' => 'admin2@acme.test', 'name' => 'Admin Two', 'last_login_at' => now()->subDay()], + ]; + + foreach ($users as $data) { + User::factory()->create([ + 'email' => $data['email'], + 'name' => $data['name'], + 'password' => 'password', + 'status' => 'active', + 'last_login_at' => $data['last_login_at'], + ]); + } + } +} diff --git a/phpunit.xml b/phpunit.xml index d7032415..d5e8cb18 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,6 +18,7 @@ + diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php new file mode 100644 index 00000000..26059856 --- /dev/null +++ b/resources/views/layouts/admin.blade.php @@ -0,0 +1,204 @@ + + + + @include('partials.head') + + + {{-- Skip to content --}} + + Skip to main content + + + + + + + + + + + Dashboard + + + + + Products + + + Collections + + + Inventory + + + + + + Orders + + + + + + Customers + + + + + + Discounts + + + + + + Pages + + + Navigation + + + Themes + + + + + + + + + Analytics + + + Settings + + + Apps + + + Developers + + + + + + + @if(isset($currentStore)) + {{ $currentStore->name }} + + @endif + Settings + +
+ @csrf + + Log out + +
+
+
+
+ + {{-- Mobile header --}} + + + + + + @if(isset($currentStore)) + {{ $currentStore->name }} + + @endif + + + + + Settings + +
+ @csrf + + Log out + +
+
+
+
+ + + {{-- Breadcrumbs --}} + @if(isset($breadcrumbs) && count($breadcrumbs) > 0) + + + @foreach($breadcrumbs as $crumb) + @if(isset($crumb['url'])) + {{ $crumb['label'] }} + @else + {{ $crumb['label'] }} + @endif + @endforeach + + @endif + + {{ $slot }} + + + {{-- Toast notifications --}} +
+ +
+ + @fluxScripts + + diff --git a/resources/views/layouts/guest.blade.php b/resources/views/layouts/guest.blade.php new file mode 100644 index 00000000..14258d70 --- /dev/null +++ b/resources/views/layouts/guest.blade.php @@ -0,0 +1,16 @@ + + + + @include('partials.head') + + +
+
+
+ {{ $slot }} +
+
+
+ @fluxScripts + + diff --git a/resources/views/layouts/storefront.blade.php b/resources/views/layouts/storefront.blade.php new file mode 100644 index 00000000..2996365f --- /dev/null +++ b/resources/views/layouts/storefront.blade.php @@ -0,0 +1,244 @@ + + + + + + + {{ $title ?? ($currentStore->name ?? config('app.name')) }} + + @if(isset($metaDescription)) + + @endif + + + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + @fluxAppearance + @livewireStyles + + + {{-- Skip to content --}} + + Skip to main content + + + @php + $themeService = app(\App\Services\ThemeSettingsService::class); + $store = $currentStore ?? null; + $settings = $store ? $themeService->getSettings($store) : $themeService->defaults(); + $navService = app(\App\Services\NavigationService::class); + @endphp + + {{-- Announcement Bar --}} + @if(data_get($settings, 'announcement_bar.enabled', false)) +
+ @if(data_get($settings, 'announcement_bar.link')) + + {{ data_get($settings, 'announcement_bar.text', '') }} + + @else + {{ data_get($settings, 'announcement_bar.text', '') }} + @endif + +
+ @endif + + {{-- Desktop Header --}} +
data_get($settings, 'header.sticky', false), + ])> +
+ {{-- Logo --}} + + @if(data_get($settings, 'header.logo_url')) + {{ $currentStore->name ?? config('app.name') }} + @else + {{ $currentStore->name ?? config('app.name') }} + @endif + + + {{-- Desktop Navigation --}} + @php + $mainMenu = $store ? \App\Models\NavigationMenu::withoutGlobalScopes()->where('store_id', $store->id)->where('handle', 'main-menu')->first() : null; + $navItems = $mainMenu ? $navService->buildTree($mainMenu) : []; + @endphp + + + {{-- Action Icons --}} +
+ {{-- Search --}} + + + {{-- Account --}} + + + {{-- Cart --}} + + + + + {{-- Mobile hamburger --}} + +
+
+
+ + {{-- Mobile Navigation Drawer --}} +
+ {{-- Overlay --}} +
+ + {{-- Drawer --}} + +
+ + {{-- Main Content --}} +
+ {{ $slot }} +
+ + {{-- Footer --}} +
+
+
+ {{-- Store Info --}} +
+

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

+ @if($store && $store->settings) +

+ {{ data_get($store->settings->settings_json ?? [], 'contact_email', '') }} +

+ @endif + {{-- Social Links --}} + @php $socialLinks = data_get($settings, 'footer.social_links', []); @endphp + @if(!empty($socialLinks)) +
+ @foreach($socialLinks as $social) + + + + @endforeach +
+ @endif +
+ + {{-- Footer Navigation --}} + @php + $footerMenu = $store ? \App\Models\NavigationMenu::withoutGlobalScopes()->where('store_id', $store->id)->where('handle', 'footer-menu')->first() : null; + $footerItems = $footerMenu ? $navService->buildTree($footerMenu) : []; + @endphp + @if(!empty($footerItems)) +
+

Links

+ +
+ @endif +
+ + {{-- Copyright --}} +
+

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

+
+
+
+ + {{-- Search Modal --}} + + + {{-- Cart Drawer --}} + + + @fluxScripts + @livewireScripts + + 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..57e2a6b5 --- /dev/null +++ b/resources/views/livewire/admin/analytics/index.blade.php @@ -0,0 +1,88 @@ +
+
+ Analytics + + + Today + Last 7 days + Last 30 days + Custom range + +
+ + @if($dateRange === 'custom') +
+ + +
+ @endif + + {{-- KPI Tiles --}} + @php $kpis = $this->kpis; @endphp +
+
+ Total Sales + {{ $this->formatCurrency($kpis['totalSales']) }} +
+ +
+ Orders + {{ number_format($kpis['ordersCount']) }} +
+ +
+ Avg Order Value + {{ $this->formatCurrency($kpis['averageOrderValue']) }} +
+ +
+ Conversion Rate + {{ number_format($kpis['conversionRate'], 1) }}% +
+
+ + {{-- Sales Chart --}} + @php $chartData = $this->salesChartData; @endphp +
+ Daily Revenue + @if(count($chartData['labels']) > 0) +
+ @foreach($chartData['labels'] as $index => $label) + @php + $revenue = $chartData['revenue'][$index] ?? 0; + $maxRevenue = max(1, max($chartData['revenue'])); + $widthPercent = ($revenue / $maxRevenue) * 100; + @endphp +
+ {{ $label }} +
+
+
+ {{ $this->formatCurrency($revenue) }} +
+ @endforeach +
+ @else + No data for this period. + @endif +
+ + {{-- Metrics Summary --}} +
+ Funnel +
+
+ Visits + {{ number_format($kpis['visitsCount']) }} +
+
+ Orders + {{ number_format($kpis['ordersCount']) }} +
+
+ Conversion Rate + {{ number_format($kpis['conversionRate'], 1) }}% +
+
+
+
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..9f49fc2a --- /dev/null +++ b/resources/views/livewire/admin/apps/index.blade.php @@ -0,0 +1,30 @@ +
+ Apps + + @if($this->installedApps->isEmpty()) +
+ + No apps installed + Apps extend the functionality of your store. Install apps to add new features. +
+ @else +
+ @foreach($this->installedApps as $installation) +
+
+ +
+
+ {{ $installation->app->name }} + + Installed {{ $installation->installed_at ? \Carbon\Carbon::parse($installation->installed_at)->diffForHumans() : 'recently' }} + +
+ + {{ ucfirst($installation->status->value) }} + +
+ @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..87b61918 --- /dev/null +++ b/resources/views/livewire/admin/auth/login.blade.php @@ -0,0 +1,16 @@ +
+
+ Sign in + Sign in to the admin panel. +
+ +
+ + + + + + + Sign in + +
diff --git a/resources/views/livewire/admin/auth/logout.blade.php b/resources/views/livewire/admin/auth/logout.blade.php new file mode 100644 index 00000000..81b251c2 --- /dev/null +++ b/resources/views/livewire/admin/auth/logout.blade.php @@ -0,0 +1,3 @@ +
+ +
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..674a9139 --- /dev/null +++ b/resources/views/livewire/admin/collections/form.blade.php @@ -0,0 +1,61 @@ +
+
+ {{ $this->isEditing ? $collection->title : 'Add collection' }} +
+ +
+
+
+
+ + + +
+ + {{-- Products --}} +
+ Products + + + @if($this->searchResults->count() > 0) +
+ @foreach($this->searchResults as $product) +
+ {{ $product->title }} + Add +
+ @endforeach +
+ @endif + + @if($this->assignedProducts->count() > 0) +
+ @foreach($this->assignedProducts as $product) +
+ {{ $product->title }} + +
+ @endforeach +
+ @else + No products assigned yet. + @endif +
+
+ +
+
+ + Active + Archived + +
+
+
+ +
+ Discard + 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..ec6320a5 --- /dev/null +++ b/resources/views/livewire/admin/collections/index.blade.php @@ -0,0 +1,50 @@ +
+
+ Collections + Add collection +
+ +
+ + + All statuses + Active + Archived + +
+ + @if($this->collections->total() > 0) + + + Title + Products + Status + Updated + + + + @foreach($this->collections as $collection) + + + {{ $collection->title }} + + {{ $collection->products_count }} + + {{ ucfirst($collection->status->value) }} + + {{ $collection->updated_at->diffForHumans() }} + + + + + @endforeach + + + @else +
+ Create your first collection + Organize your products into curated groups. + Add collection +
+ @endif +
diff --git a/resources/views/livewire/admin/customers/index.blade.php b/resources/views/livewire/admin/customers/index.blade.php new file mode 100644 index 00000000..044ddf03 --- /dev/null +++ b/resources/views/livewire/admin/customers/index.blade.php @@ -0,0 +1,38 @@ +
+
+ Customers +
+ +
+ +
+ + + + Name + Email + Orders + Total spent + Created + + + @forelse($this->customers as $customer) + + + {{ $customer->name }} + + {{ $customer->email }} + {{ $customer->orders_count }} + {{ $this->formatCurrency((int) ($customer->orders_sum_total_amount ?? 0)) }} + {{ $customer->created_at->format('M d, Y') }} + + @empty + + + No customers found. + + + @endforelse + + +
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..75ec9ceb --- /dev/null +++ b/resources/views/livewire/admin/customers/show.blade.php @@ -0,0 +1,113 @@ +
+
+ {{ $customer->name }} +
+ +
+
+ {{-- Info --}} +
+
+
+ {{ $customer->name }} + {{ $customer->email }} + Since {{ $customer->created_at->format('M d, Y') }} +
+
+ + {{ $customer->marketing_opt_in ? 'Opted in' : 'Not opted in' }} + + Total spent: {{ $this->formatCurrency($this->totalSpent) }} +
+
+
+ + {{-- Orders --}} +
+ Order history + @if($customer->orders->count() > 0) + + + Order # + Date + Status + Total + + + @foreach($customer->orders as $order) + + + {{ $order->order_number }} + + {{ $order->placed_at ? \Carbon\Carbon::parse($order->placed_at)->format('M d, Y') : '-' }} + + + {{ ucfirst(str_replace('_', ' ', $order->financial_status->value)) }} + + + {{ $this->formatCurrency($order->total_amount) }} + + @endforeach + + + @else + No orders yet. + @endif +
+
+ + {{-- Right Column --}} +
+
+
+ Addresses + Add +
+ @forelse($customer->addresses as $address) +
+
+ {{ $address->label }} + @if($address->is_default) + Default + @endif +
+ @php $a = $address->address_json ?? []; @endphp +
+ {{ data_get($a, 'address1') }}, {{ data_get($a, 'city') }} {{ data_get($a, 'zip') }}, {{ data_get($a, 'country') }} +
+
+ Edit + Delete + @if(!$address->is_default) + Set as default + @endif +
+
+ @empty + No addresses. + @endforelse +
+
+
+ + {{-- Address Modal --}} + +
+ {{ $editAddressId ? 'Edit address' : 'Add address' }} + + +
+ + +
+ + +
+ + Cancel + + Save +
+
+
+
diff --git a/resources/views/livewire/admin/dashboard.blade.php b/resources/views/livewire/admin/dashboard.blade.php new file mode 100644 index 00000000..dc0e5ba3 --- /dev/null +++ b/resources/views/livewire/admin/dashboard.blade.php @@ -0,0 +1,124 @@ +
+
+ Dashboard + + + Today + Last 7 days + Last 30 days + Custom range + +
+ + @if($dateRange === 'custom') +
+ + +
+ @endif + + {{-- KPI Tiles --}} +
+ @php $kpis = $this->kpis; @endphp +
+ Total Sales + {{ $this->formatCurrency($kpis['totalSales']) }} +
+ + {{ $kpis['salesChange'] >= 0 ? '+' : '' }}{{ $kpis['salesChange'] }}% + +
+
+ +
+ Orders + {{ number_format($kpis['ordersCount']) }} +
+ + {{ $kpis['ordersChange'] >= 0 ? '+' : '' }}{{ $kpis['ordersChange'] }}% + +
+
+ +
+ Avg Order + {{ $this->formatCurrency($kpis['averageOrderValue']) }} +
+ + {{ $kpis['aovChange'] >= 0 ? '+' : '' }}{{ $kpis['aovChange'] }}% + +
+
+ +
+ Visitors + 0 +
+ 0% +
+
+
+ + {{-- Top Products + Recent Orders --}} +
+ {{-- Top Products --}} +
+ Top products + @if(count($this->topProducts) > 0) + + + Product + Sold + Revenue + + + @foreach($this->topProducts as $product) + + {{ $product['title'] }} + {{ $product['units_sold'] }} + {{ $this->formatCurrency($product['revenue']) }} + + @endforeach + + + @else + No sales data for this period. + @endif +
+ + {{-- Recent Orders --}} +
+ Recent orders + @if($this->recentOrders->count() > 0) + + + Order + Customer + Total + Status + + + @foreach($this->recentOrders as $order) + + + + {{ $order->order_number }} + + + {{ $order->customer?->name ?? 'Guest' }} + {{ $this->formatCurrency($order->total_amount) }} + + + {{ ucfirst(str_replace('_', ' ', $order->financial_status->value)) }} + + + + @endforeach + + + @else + No orders yet. + @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..a3fe7658 --- /dev/null +++ b/resources/views/livewire/admin/developers/index.blade.php @@ -0,0 +1,54 @@ +
+ Developers + + {{-- API Tokens Section --}} +
+ API Tokens + Manage personal access tokens for the Admin API. + +
+ API token management coming soon. +
+
+ + + + {{-- Webhooks Section --}} +
+ Webhooks + Manage webhook subscriptions for real-time event notifications. + + @if($this->webhooks->isEmpty()) +
+ + No webhook subscriptions + Add webhooks to receive real-time notifications about events in your store. +
+ @else +
+ + + + + + + + + + @foreach($this->webhooks as $webhook) + + + + + + @endforeach + +
Event TypeURLStatus
{{ $webhook->event_type }}{{ $webhook->target_url }} + + {{ ucfirst($webhook->status->value) }} + +
+
+ @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..4775a636 --- /dev/null +++ b/resources/views/livewire/admin/discounts/form.blade.php @@ -0,0 +1,82 @@ +
+
+ {{ $this->isEditing ? 'Edit discount' : 'Create discount' }} +
+ +
+
+
+ {{-- Type --}} +
+ + Discount code + Automatic discount + + + @if($type === 'code') +
+
+ + @error('code') {{ $message }} @enderror +
+
+ Generate +
+
+ @endif +
+ + {{-- Value --}} +
+ + Percentage + Fixed amount + Free shipping + + + @if($valueType !== 'free_shipping') + + @endif +
+ + {{-- Conditions --}} +
+ Conditions +
+ + Leave empty for no minimum +
+
+ + Leave empty for unlimited usage +
+
+ + {{-- Dates --}} +
+ Active dates + +
+ + Leave empty for no end date +
+
+
+ +
+
+ + Active + Disabled + Draft + +
+
+
+ +
+ Discard + 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..919a9242 --- /dev/null +++ b/resources/views/livewire/admin/discounts/index.blade.php @@ -0,0 +1,64 @@ +
+
+ Discounts + Create discount +
+ +
+ + + All statuses + Active + Expired + Disabled + +
+ + + + Code + Type + Value + Usage + Status + Dates + + + @forelse($this->discounts as $discount) + + + + {{ $discount->code ?? 'Automatic' }} + + + {{ ucfirst($discount->type->value ?? $discount->type) }} + + @if(($discount->value_type->value ?? $discount->value_type) === 'percent') + {{ $discount->value_amount }}% + @elseif(($discount->value_type->value ?? $discount->value_type) === 'free_shipping') + Free shipping + @else + ${{ number_format($discount->value_amount / 100, 2) }} + @endif + + {{ $discount->usage_count }}{{ $discount->usage_limit ? '/'.$discount->usage_limit : '' }} + + + {{ ucfirst($discount->status->value ?? $discount->status) }} + + + + {{ $discount->starts_at ? \Carbon\Carbon::parse($discount->starts_at)->format('M d') : '-' }} + {{ $discount->ends_at ? ' - '.\Carbon\Carbon::parse($discount->ends_at)->format('M d') : '' }} + + + @empty + + + No discounts found. + + + @endforelse + + +
diff --git a/resources/views/livewire/admin/inventory/index.blade.php b/resources/views/livewire/admin/inventory/index.blade.php new file mode 100644 index 00000000..d3802557 --- /dev/null +++ b/resources/views/livewire/admin/inventory/index.blade.php @@ -0,0 +1,8 @@ +
+ Inventory +
+ + Inventory management + Inventory tracking and management coming soon. +
+
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..2c627243 --- /dev/null +++ b/resources/views/livewire/admin/navigation/index.blade.php @@ -0,0 +1,8 @@ +
+ Navigation +
+ + Menu management + Navigation menu management coming soon. +
+
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..0ba75def --- /dev/null +++ b/resources/views/livewire/admin/orders/index.blade.php @@ -0,0 +1,68 @@ +
+
+ Orders +
+ + {{-- Status Tabs --}} +
+ @foreach(['all' => 'All', 'pending' => 'Pending', 'paid' => 'Paid', 'fulfilled' => 'Fulfilled', 'voided' => 'Cancelled', 'refunded' => 'Refunded'] as $value => $label) + + @endforeach +
+ + {{-- Search --}} +
+ +
+ + {{-- Orders Table --}} + + + Order # + Date + Customer + Payment + Fulfill + Total + + + @forelse($this->orders as $order) + + + + {{ $order->order_number }} + + + {{ $order->placed_at ? \Carbon\Carbon::parse($order->placed_at)->format('M d, Y') : '-' }} + {{ $order->customer?->name ?? $order->email }} + + + {{ ucfirst(str_replace('_', ' ', $order->financial_status->value)) }} + + + + + {{ ucfirst($order->fulfillment_status->value) }} + + + {{ $this->formatCurrency($order->total_amount) }} + + @empty + + + No orders found. + + + @endforelse + + +
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..75fb6a92 --- /dev/null +++ b/resources/views/livewire/admin/orders/show.blade.php @@ -0,0 +1,269 @@ +
+ {{-- Header --}} +
+
+ {{ $order->order_number }} + + {{ ucfirst(str_replace('_', ' ', $order->financial_status->value)) }} + + + {{ ucfirst($order->fulfillment_status->value) }} + +
+
+ @if($order->payment_method === \App\Enums\PaymentMethod::BankTransfer && $order->financial_status === \App\Enums\FinancialStatus::Pending) + Confirm payment + @endif + @if($order->financial_status === \App\Enums\FinancialStatus::Paid && $order->fulfillment_status !== \App\Enums\FulfillmentStatus::Fulfilled) + + Create fulfillment + + @endif + @if(in_array($order->financial_status, [\App\Enums\FinancialStatus::Paid, \App\Enums\FinancialStatus::PartiallyRefunded])) + + Refund + + @endif +
+
+ + @if($order->financial_status === \App\Enums\FinancialStatus::Pending && $order->payment_method !== \App\Enums\PaymentMethod::BankTransfer) + + Cannot create fulfillment. Payment must be confirmed before items can be fulfilled. + + @endif + + + Placed {{ $order->placed_at ? \Carbon\Carbon::parse($order->placed_at)->format('M d, Y \a\t h:i A') : '-' }} + + +
+ {{-- Left Column (2/3) --}} +
+ {{-- Line Items --}} +
+ Items + + + Product + SKU + Qty + Unit Price + Total + + + @foreach($order->lines as $line) + + {{ $line->title_snapshot }} + {{ $line->sku_snapshot ?? '-' }} + {{ $line->quantity }} + {{ $this->formatCurrency($line->unit_price_amount) }} + {{ $this->formatCurrency($line->total_amount) }} + + @endforeach + + + + {{-- Summary --}} +
+
Subtotal{{ $this->formatCurrency($order->subtotal_amount) }}
+ @if($order->discount_amount > 0) +
Discount-{{ $this->formatCurrency($order->discount_amount) }}
+ @endif +
Shipping{{ $this->formatCurrency($order->shipping_amount) }}
+
Tax{{ $this->formatCurrency($order->tax_amount) }}
+
Total{{ $this->formatCurrency($order->total_amount) }}
+
+
+ + {{-- Fulfillments --}} + @foreach($order->fulfillments as $fulfillment) +
+
+
+ Fulfillment #{{ $fulfillment->id }} + + {{ ucfirst($fulfillment->status->value) }} + +
+
+ @if($fulfillment->status->value === 'pending') + Mark as shipped + @elseif($fulfillment->status->value === 'shipped') + Mark as delivered + @endif +
+
+ @if($fulfillment->tracking_company || $fulfillment->tracking_number) +
+ @if($fulfillment->tracking_company){{ $fulfillment->tracking_company }}@endif + @if($fulfillment->tracking_number) - {{ $fulfillment->tracking_number }}@endif + @if($fulfillment->tracking_url) Track@endif +
+ @endif +
+ @foreach($fulfillment->fulfillmentLines as $fl) + @php $ol = $order->lines->firstWhere('id', $fl->order_line_id); @endphp +
{{ $ol?->title_snapshot }} x {{ $fl->quantity }}
+ @endforeach +
+
+ @endforeach + + {{-- Refunds --}} + @if($order->refunds->count() > 0) +
+ Refunds + @foreach($order->refunds as $refund) +
+
+ {{ $this->formatCurrency($refund->amount) }} + @if($refund->reason) - {{ $refund->reason }} @endif +
+ {{ ucfirst($refund->status instanceof \App\Enums\RefundStatus ? $refund->status->value : $refund->status) }} +
+ @endforeach +
+ @endif + + {{-- Timeline --}} +
+ Timeline +
+ @if($order->placed_at) +
+
+
+ Order placed + {{ \Carbon\Carbon::parse($order->placed_at)->format('M d, Y h:i A') }} +
+
+ @endif + @foreach($order->payments as $payment) + @if($payment->status === \App\Enums\PaymentStatus::Captured) +
+
+
+ Payment received + {{ $payment->created_at ? \Carbon\Carbon::parse($payment->created_at)->format('M d, Y h:i A') : '' }} +
+
+ @endif + @endforeach + @foreach($order->fulfillments as $f) +
+
+
+ Fulfillment created + {{ $f->created_at ? \Carbon\Carbon::parse($f->created_at)->format('M d, Y h:i A') : '' }} +
+
+ @endforeach +
+
+
+ + {{-- Right Column (1/3) --}} +
+ {{-- Customer --}} +
+ Customer + @if($order->customer) + {{ $order->customer->name }} + {{ $order->customer->email }} + View customer + @else + Guest + @endif +
+ + {{-- Payment --}} +
+ Payment +
+
Method{{ ucfirst(str_replace('_', ' ', $order->payment_method?->value ?? '-')) }}
+ @if($order->payments->first()) +
Status{{ ucfirst($order->payments->first()->status->value) }}
+
Amount{{ $this->formatCurrency($order->payments->first()->amount) }}
+ @if($order->payments->first()->provider_payment_id) +
Reference{{ $order->payments->first()->provider_payment_id }}
+ @endif + @endif +
+
+ + {{-- Shipping Address --}} + @if($order->shipping_address_json) +
+ Shipping address + @php $addr = $order->shipping_address_json; @endphp +
+
{{ data_get($addr, 'first_name') }} {{ data_get($addr, 'last_name') }}
+
{{ data_get($addr, 'address1') }}
+
{{ data_get($addr, 'city') }}, {{ data_get($addr, 'zip') }}
+
{{ data_get($addr, 'country') }}
+
+
+ @endif + + {{-- Billing Address --}} + @if($order->billing_address_json) +
+ Billing address + @php $addr = $order->billing_address_json; @endphp +
+
{{ data_get($addr, 'first_name') }} {{ data_get($addr, 'last_name') }}
+
{{ data_get($addr, 'address1') }}
+
{{ data_get($addr, 'city') }}, {{ data_get($addr, 'zip') }}
+
{{ data_get($addr, 'country') }}
+
+
+ @endif +
+
+ + {{-- Fulfillment Modal --}} + +
+ Create fulfillment +
+ @foreach($order->lines as $line) + @php + $fulfilled = $line->fulfillmentLines()->sum('quantity'); + $remaining = $line->quantity - $fulfilled; + @endphp + @if($remaining > 0) +
+ {{ $line->title_snapshot }} ({{ $remaining }} unfulfilled) + +
+ @endif + @endforeach +
+ + + +
+ + Cancel + + Create fulfillment +
+
+
+ + {{-- Refund Modal --}} + +
+ Refund order + + +
+ + Cancel + + Create 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..cd80558b --- /dev/null +++ b/resources/views/livewire/admin/pages/form.blade.php @@ -0,0 +1,30 @@ +
+
+ {{ $this->isEditing ? $page->title : 'Add page' }} +
+ +
+
+
+
+ + + +
+
+
+
+ + Draft + Published + +
+
+
+ +
+ Discard + 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..4d02f5f0 --- /dev/null +++ b/resources/views/livewire/admin/pages/index.blade.php @@ -0,0 +1,43 @@ +
+
+ Pages + Add page +
+ +
+ +
+ + + + Title + Handle + Status + Updated + + + + @forelse($this->pages as $page) + + + {{ $page->title }} + + {{ $page->handle }} + + {{ ucfirst($page->status->value ?? $page->status) }} + + {{ $page->updated_at->diffForHumans() }} + + + + + @empty + + + No pages found. + + + @endforelse + + +
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..f8f21509 --- /dev/null +++ b/resources/views/livewire/admin/products/form.blade.php @@ -0,0 +1,209 @@ +
+
+ {{ $this->isEditing ? $product->title : 'Add product' }} + @if($this->isEditing) + + Delete + + @endif +
+ +
+
+ {{-- Left Column (2/3) --}} +
+ {{-- Title --}} +
+ + @error('title') {{ $message }} @enderror +
+ + {{-- Description --}} +
+ +
+ + {{-- Media --}} +
+ Media + + @if($this->existingMedia->count() > 0) +
+ @foreach($this->existingMedia as $media) +
+ {{ $media->alt_text }} + +
+ @endforeach +
+ @endif + +
+ + Drag and drop images or click to upload + +
+
+ + {{-- Variants --}} +
+ Variants + + {{-- Options --}} + @foreach($options as $optIdx => $option) +
+
+ +
+ @foreach($option['values'] as $valIdx => $val) +
+ + +
+ @endforeach + + Value +
+
+ +
+ @endforeach + +
+ + Add another option + @if(count($options) > 0) + Generate variants + @endif +
+ + {{-- Variant Table --}} + @if(count($variants) > 0) +
+ + + + + + + + + + + + + @foreach($variants as $vIdx => $variant) + + + + + + + + + @endforeach + +
VariantSKUPriceCompare atQtyShip
+ {{ implode(' / ', $variant['optionValues'] ?? ['Default']) }} + + + + + @error("variants.{$vIdx}.price") {{ $message }} @enderror + + + + + + +
+
+ @endif +
+ + {{-- SEO --}} +
+ + @if($showSeo) +
+ + @error('handle') {{ $message }} @enderror +
+ @endif +
+
+ + {{-- Right Column (1/3) --}} +
+ {{-- Status --}} +
+ + Draft + Active + Archived + +
+ + {{-- Publishing --}} +
+ +
+ + {{-- Organization --}} +
+
+ + +
+ + Separate tags with commas +
+
+
+ + {{-- Collections --}} + @if($this->availableCollections->count() > 0) +
+ Collections + @foreach($this->availableCollections as $collection) +
+ +
+ @endforeach +
+ @endif +
+
+ + {{-- Sticky Save Bar --}} +
+ Discard + + Save + Saving... + +
+
+ + {{-- Delete Confirmation Modal --}} + @if($this->isEditing) + +
+
+ Delete this product? + This will archive the product. It can be restored later. +
+
+ + Cancel + + Confirm +
+
+
+ @endif +
diff --git a/resources/views/livewire/admin/products/index.blade.php b/resources/views/livewire/admin/products/index.blade.php new file mode 100644 index 00000000..164628a8 --- /dev/null +++ b/resources/views/livewire/admin/products/index.blade.php @@ -0,0 +1,123 @@ +
+
+ Products + + Add product + +
+ + {{-- Filters --}} +
+ + + + All statuses + Draft + Active + Archived + + + @if(count($this->productTypes) > 0) + + All types + @foreach($this->productTypes as $type) + {{ $type }} + @endforeach + + @endif +
+ + {{-- Bulk Actions --}} + @if(count($selectedIds) > 0) +
+ {{ count($selectedIds) }} products selected + Set Active + Archive + + Delete + +
+ @endif + + {{-- Product Table --}} + @if($this->products->total() > 0 || $search !== '' || $statusFilter !== 'all' || $typeFilter !== '') + + + + + + Image + Title + Status + Variants + Type + Vendor + Updated + + + @forelse($this->products as $product) + + + + + + @if($product->media->first()) + + @else +
+ +
+ @endif +
+ + + {{ $product->title }} + + + + + {{ ucfirst($product->status->value) }} + + + {{ $product->variants_count }} + {{ $product->product_type ?? '-' }} + {{ $product->vendor ?? '-' }} + {{ $product->updated_at->diffForHumans() }} +
+ @empty + + + No products match your filters + + + @endforelse +
+
+ @else + {{-- Empty State --}} +
+ + Add your first product + Start building your catalog by adding products. + + Add product + +
+ @endif + + {{-- Delete Confirmation Modal --}} + +
+
+ Delete products? + This will archive {{ count($selectedIds) }} product(s). Products with orders cannot be permanently deleted. +
+
+ + Cancel + + Confirm +
+
+
+
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..d241acc9 --- /dev/null +++ b/resources/views/livewire/admin/settings/index.blade.php @@ -0,0 +1,183 @@ +
+
+ Settings +
+ + {{-- Tabs --}} +
+ @foreach(['general' => 'General', 'domains' => 'Domains', 'shipping' => 'Shipping', 'taxes' => 'Taxes'] as $key => $label) + + @endforeach +
+ + {{-- General --}} + @if($tab === 'general') +
+
+ Store details + +
+ + The store handle cannot be changed after creation. +
+
+
+ Defaults + + USD + EUR + GBP + + + English + German + French + + + UTC + America/New_York + Europe/London + Europe/Berlin + Asia/Tokyo + +
+ Save +
+ @endif + + {{-- Domains --}} + @if($tab === 'domains') +
+
+ + Add domain + +
+ + + Hostname + Type + Primary + + + + @foreach($this->domains as $domain) + + {{ $domain->hostname }} + {{ ucfirst($domain->type instanceof \App\Enums\StoreDomainType ? $domain->type->value : $domain->type) }} + + @if($domain->is_primary) + Primary + @else + Set Primary + @endif + + + @if(!$domain->is_primary) + + @endif + + + @endforeach + + +
+ + +
+ Add domain + + + Storefront + Admin + +
+ Cancel + Add domain +
+
+
+ @endif + + {{-- Shipping --}} + @if($tab === 'shipping') +
+
+ Add zone +
+ @foreach($this->shippingZones as $zone) +
+
+
+ {{ $zone->name }} + {{ implode(', ', $zone->countries_json ?? []) }} +
+
+ Edit + +
+
+ @foreach($zone->rates as $rate) +
+ {{ $rate->name }} - ${{ number_format((data_get($rate->config_json, 'price', 0)) / 100, 2) }} + +
+ @endforeach + Add rate +
+ @endforeach +
+ + +
+ {{ $editZoneId ? 'Edit' : 'Add' }} shipping zone + + +
+ Cancel + Save zone +
+
+
+ + +
+ Add shipping rate + + + Flat rate + Weight-based + Price-based + + +
+ Cancel + Save rate +
+
+
+ @endif + + {{-- Taxes --}} + @if($tab === 'taxes') +
+
+ Tax configuration + + Manual tax rates + Tax provider + + + + +
+ Save +
+ @endif +
diff --git a/resources/views/livewire/admin/themes/index.blade.php b/resources/views/livewire/admin/themes/index.blade.php new file mode 100644 index 00000000..3e13e516 --- /dev/null +++ b/resources/views/livewire/admin/themes/index.blade.php @@ -0,0 +1,8 @@ +
+ Themes +
+ + Theme management + Theme customization coming soon. +
+
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..20ae0169 --- /dev/null +++ b/resources/views/livewire/storefront/account/addresses/index.blade.php @@ -0,0 +1,131 @@ +
+
+ + Account + Addresses + +
+ +
+ Your Addresses + Add new address +
+ + @if($addresses->isEmpty()) +

You have no saved addresses yet.

+ @else +
+ @foreach($addresses as $address) +
$address->is_default, + 'border-zinc-200 dark:border-zinc-700' => ! $address->is_default, + ])> +
+
+ @if($address->label) +

{{ $address->label }}

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

{{ data_get($address->address_json, 'first_name') }} {{ data_get($address->address_json, 'last_name') }}

+

{{ data_get($address->address_json, 'address1') }}

+ @if(data_get($address->address_json, 'address2')) +

{{ data_get($address->address_json, 'address2') }}

+ @endif +

{{ data_get($address->address_json, 'city') }}, {{ data_get($address->address_json, 'zip') }}

+

{{ data_get($address->address_json, 'country') }}

+
+
+ Edit + Delete + @if(! $address->is_default) + Set as default + @endif +
+
+ @endforeach +
+ @endif + + {{-- Address Form Modal --}} + + {{ $editingAddressId ? 'Edit Address' : 'Add New Address' }} + +
+ + Label (optional) + + + +
+ + First name + + + + + Last name + + + +
+ + + Company (optional) + + + + + Address + + + + + + Apartment, suite, etc. (optional) + + + +
+ + City + + + + + Province / State (optional) + + +
+ +
+ + Postal code + + + + + Country + + + +
+ + + Phone (optional) + + + +
+ Cancel + Save +
+
+
+
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..844e704d --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/login.blade.php @@ -0,0 +1,21 @@ +
+
+ Log in + Sign in to your account to view orders and manage your profile. +
+ +
+ + + + + + + Log in + + + + Don't have an account? + Create one + +
diff --git a/resources/views/livewire/storefront/account/auth/register.blade.php b/resources/views/livewire/storefront/account/auth/register.blade.php new file mode 100644 index 00000000..f89594af --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/register.blade.php @@ -0,0 +1,25 @@ +
+
+ Create account + Register for an account to track orders and save addresses. +
+ +
+ + + + + + + + + + + Register + + + + Already have an account? + Log 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..ecee854f --- /dev/null +++ b/resources/views/livewire/storefront/account/dashboard.blade.php @@ -0,0 +1,99 @@ +
+ My Account +

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

+ + {{-- Quick Links --}} +
+ +

Order history

+

View past orders

+
+ + +

Addresses

+

Manage shipping addresses

+
+ +
+ @csrf + +
+
+ + {{-- Profile --}} +
+ Profile +
+ + Name + + + + + + Email + + + + + + Save changes + +
+ + {{-- Recent Orders --}} +
+ Recent Orders + + @if($recentOrders->isEmpty()) +

You have not placed any orders yet.

+ @else +
+ + + + + + + + + + + + @foreach($recentOrders as $order) + + + + + + + + @endforeach + +
OrderDateStatusTotal
{{ $order->order_number }}{{ \Carbon\Carbon::parse($order->placed_at)->format('M d, Y') }} + @php + $statusColor = match($order->status) { + \App\Enums\OrderStatus::Pending => 'yellow', + \App\Enums\OrderStatus::Paid => 'green', + \App\Enums\OrderStatus::Fulfilled => 'blue', + \App\Enums\OrderStatus::Cancelled => 'zinc', + \App\Enums\OrderStatus::Refunded => 'red', + }; + @endphp + {{ ucfirst($order->status->value) }} + ${{ number_format($order->total_amount / 100, 2) }} + + 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..898813b2 --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/index.blade.php @@ -0,0 +1,59 @@ +
+
+ + Account + Orders + +
+ + Order History + + @if($orders->isEmpty()) +

You have not placed any orders yet.

+ @else +
+ + + + + + + + + + + + @foreach($orders as $order) + + + + + + + + @endforeach + +
OrderDateStatusTotalAction
{{ $order->order_number }}{{ \Carbon\Carbon::parse($order->placed_at)->format('M d, Y') }} + @php + $statusColor = match($order->status) { + \App\Enums\OrderStatus::Pending => 'yellow', + \App\Enums\OrderStatus::Paid => 'green', + \App\Enums\OrderStatus::Fulfilled => 'blue', + \App\Enums\OrderStatus::Cancelled => 'zinc', + \App\Enums\OrderStatus::Refunded => 'red', + }; + @endphp + {{ ucfirst($order->status->value) }} + ${{ number_format($order->total_amount / 100, 2) }} + + View + +
+
+ +
+ {{ $orders->links() }} +
+ @endif +
diff --git a/resources/views/livewire/storefront/account/orders/show.blade.php b/resources/views/livewire/storefront/account/orders/show.blade.php new file mode 100644 index 00000000..e628fd67 --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/show.blade.php @@ -0,0 +1,139 @@ +
+
+ + Account + Orders + {{ $order->order_number }} + +
+ + {{-- Header --}} +
+ Order {{ $order->order_number }} + @php + $statusColor = match($order->status) { + \App\Enums\OrderStatus::Pending => 'yellow', + \App\Enums\OrderStatus::Paid => 'green', + \App\Enums\OrderStatus::Fulfilled => 'blue', + \App\Enums\OrderStatus::Cancelled => 'zinc', + \App\Enums\OrderStatus::Refunded => 'red', + }; + $financialColor = match($order->financial_status) { + \App\Enums\FinancialStatus::Pending => 'yellow', + \App\Enums\FinancialStatus::Authorized => 'blue', + \App\Enums\FinancialStatus::Paid => 'green', + \App\Enums\FinancialStatus::PartiallyRefunded => 'yellow', + \App\Enums\FinancialStatus::Refunded => 'red', + \App\Enums\FinancialStatus::Voided => 'zinc', + }; + @endphp + {{ ucfirst($order->status->value) }} + {{ ucfirst(str_replace('_', ' ', $order->financial_status->value)) }} +
+

+ Placed on {{ \Carbon\Carbon::parse($order->placed_at)->format('F j, Y') }} +

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

{{ $line->title_snapshot }}

+ @if($line->sku_snapshot) +

SKU: {{ $line->sku_snapshot }}

+ @endif +

Qty: {{ $line->quantity }}

+
+

${{ number_format($line->total_amount / 100, 2) }}

+
+ @endforeach +
+
+ + {{-- Price Breakdown --}} +
+
+
+ Subtotal + ${{ number_format($order->subtotal_amount / 100, 2) }} +
+ @if($order->discount_amount > 0) +
+ Discount + -${{ number_format($order->discount_amount / 100, 2) }} +
+ @endif +
+ Shipping + ${{ number_format($order->shipping_amount / 100, 2) }} +
+
+ Tax + ${{ number_format($order->tax_amount / 100, 2) }} +
+
+ Total + ${{ number_format($order->total_amount / 100, 2) }} +
+
+
+ + {{-- Shipping & Payment --}} +
+ {{-- Shipping Address --}} + @if($order->shipping_address_json) +
+ Shipping Address +
+

{{ data_get($order->shipping_address_json, 'first_name') }} {{ data_get($order->shipping_address_json, 'last_name') }}

+

{{ data_get($order->shipping_address_json, 'address1') }}

+ @if(data_get($order->shipping_address_json, 'address2')) +

{{ data_get($order->shipping_address_json, 'address2') }}

+ @endif +

{{ data_get($order->shipping_address_json, 'city') }}, {{ data_get($order->shipping_address_json, 'zip') }}

+

{{ data_get($order->shipping_address_json, 'country') }}

+
+
+ @endif + + {{-- Payment Info --}} +
+ Payment +
+

{{ ucfirst(str_replace('_', ' ', $order->payment_method->value)) }}

+
+
+
+ + {{-- Fulfillments --}} + @if($order->fulfillments->isNotEmpty()) +
+ Shipments +
+ @foreach($order->fulfillments as $fulfillment) +
+ @if($fulfillment->tracking_company) +

Shipped via {{ $fulfillment->tracking_company }}

+ @endif + @if($fulfillment->tracking_number) +

+ Tracking: {{ $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..72d6a2e8 --- /dev/null +++ b/resources/views/livewire/storefront/cart-drawer.blade.php @@ -0,0 +1,68 @@ +
+ {{-- Overlay --}} +
+ + {{-- Drawer --}} +
+
+

Cart

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

Your cart is empty.

+ + Continue Shopping + +
+ @else +
+ @foreach($lines as $line) +
+
+

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

+

${{ number_format($line->unit_price_amount / 100, 2) }}

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

${{ number_format($line->line_total_amount / 100, 2) }}

+ +
+
+ @endforeach +
+ +
+
+ Subtotal + ${{ number_format($subtotal / 100, 2) }} +
+ + View Cart + +
+ @endif +
+
diff --git a/resources/views/livewire/storefront/cart/show.blade.php b/resources/views/livewire/storefront/cart/show.blade.php new file mode 100644 index 00000000..6bd390b0 --- /dev/null +++ b/resources/views/livewire/storefront/cart/show.blade.php @@ -0,0 +1,67 @@ +
+
+

Shopping Cart

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

Your cart is empty

+

Browse our products and add items to your cart.

+ + Continue Shopping + +
+ @else +
+
+
+ @foreach($lines as $line) +
+
+

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

+

${{ number_format($line->unit_price_amount / 100, 2) }} each

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

${{ number_format($line->line_total_amount / 100, 2) }}

+
+ +
+ @endforeach +
+
+ +
+
+

Order Summary

+
+
+ Subtotal + ${{ number_format($subtotal / 100, 2) }} +
+
+ Shipping + Calculated at checkout +
+
+
+
+ Total + ${{ number_format($subtotal / 100, 2) }} +
+
+ +
+
+
+ @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..b3466d30 --- /dev/null +++ b/resources/views/livewire/storefront/checkout/confirmation.blade.php @@ -0,0 +1,85 @@ +
+
+
+ + + +

Order Confirmed

+

Thank you for your purchase!

+ @if($order) +

Order {{ $order->order_number }}

+ @endif +
+ + @if($bankTransferDetails) +
+

Bank Transfer Instructions

+

Please transfer the total amount to the following bank account:

+
+ @if(!empty($bankTransferDetails['bank_name'])) +
+ Bank + {{ $bankTransferDetails['bank_name'] }} +
+ @endif + @if(!empty($bankTransferDetails['iban'])) +
+ IBAN + {{ $bankTransferDetails['iban'] }} +
+ @endif + @if(!empty($bankTransferDetails['bic'])) +
+ BIC + {{ $bankTransferDetails['bic'] }} +
+ @endif + @if($order) +
+ Reference + {{ $order->order_number }} +
+ @endif +
+
+ @endif + +
+

Order Details

+
+
+ Email + {{ $checkout->email }} +
+
+ Subtotal + ${{ number_format(($totals['subtotal'] ?? 0) / 100, 2) }} +
+ @if(($totals['discount'] ?? 0) > 0) +
+ Discount + -${{ number_format(($totals['discount'] ?? 0) / 100, 2) }} +
+ @endif +
+ Shipping + ${{ number_format(($totals['shipping'] ?? 0) / 100, 2) }} +
+
+ Tax + ${{ number_format(($totals['tax_total'] ?? 0) / 100, 2) }} +
+
+ Total + ${{ number_format(($totals['total'] ?? 0) / 100, 2) }} +
+
+
+ + +
+
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..979c37d9 --- /dev/null +++ b/resources/views/livewire/storefront/checkout/show.blade.php @@ -0,0 +1,180 @@ +
+
+

Checkout

+ + @if($error) +
+ {{ $error }} +
+ @endif + +
+
+ {{-- Step: Contact / Address --}} + @if($step === 'contact') +
+

Contact & Shipping Address

+
+
+ + + @error('email') {{ $message }} @enderror +
+
+
+ + + @error('firstName') {{ $message }} @enderror +
+
+ + + @error('lastName') {{ $message }} @enderror +
+
+
+ + + @error('address1') {{ $message }} @enderror +
+
+ + +
+
+
+ + + @error('city') {{ $message }} @enderror +
+
+ + + @error('postalCode') {{ $message }} @enderror +
+
+
+ + + @error('country') {{ $message }} @enderror +
+ +
+
+ @endif + + {{-- Step: Shipping --}} + @if($step === 'shipping') +
+

Shipping Method

+
+ @forelse($availableRates as $rate) + + @empty +

No shipping methods available for your address.

+ @endforelse +
+ +
+ @endif + + {{-- Step: Payment --}} + @if($step === 'payment') +
+

Payment Method

+
+ + + +
+ + @if($paymentMethod === 'credit_card') +
+ + +
+ @endif + + +
+ @endif +
+ + {{-- Order Summary Sidebar --}} +
+
+

Order Summary

+
+
+ Subtotal + ${{ number_format(($totals['subtotal'] ?? 0) / 100, 2) }} +
+ @if(($totals['discount'] ?? 0) > 0) +
+ Discount + -${{ number_format(($totals['discount'] ?? 0) / 100, 2) }} +
+ @endif +
+ Shipping + ${{ number_format(($totals['shipping'] ?? 0) / 100, 2) }} +
+
+ Tax + ${{ number_format(($totals['tax_total'] ?? 0) / 100, 2) }} +
+
+
+
+ Total + ${{ number_format(($totals['total'] ?? 0) / 100, 2) }} +
+
+ + {{-- Discount Code --}} +
+ @if($checkout->discount_code) +
+ Code: {{ $checkout->discount_code }} + +
+ @else +
+ + +
+ @if($discountError) +

{{ $discountError }}

+ @endif + @endif +
+
+
+
+
+
diff --git a/resources/views/livewire/storefront/collections/index.blade.php b/resources/views/livewire/storefront/collections/index.blade.php new file mode 100644 index 00000000..56d77d73 --- /dev/null +++ b/resources/views/livewire/storefront/collections/index.blade.php @@ -0,0 +1,18 @@ +
+
+

Collections

+
+ @forelse($this->collections as $collection) + +
+
+

{{ $collection->title }}

+
+
+
+ @empty +

No collections found.

+ @endforelse +
+
+
diff --git a/resources/views/livewire/storefront/collections/show.blade.php b/resources/views/livewire/storefront/collections/show.blade.php new file mode 100644 index 00000000..9813940c --- /dev/null +++ b/resources/views/livewire/storefront/collections/show.blade.php @@ -0,0 +1,77 @@ +
+
+ {{-- Breadcrumbs --}} + @include('storefront.components.breadcrumbs', ['items' => [ + ['label' => 'Home', 'url' => '/'], + ['label' => 'Collections', 'url' => '/collections'], + ['label' => $collection->title], + ]]) + + {{-- Header --}} +
+

{{ $collection->title }}

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

{{ $products->total() }} {{ Str::plural('product', $products->total()) }}

+ +
+ +
+ {{-- Filter Sidebar (desktop) --}} + + + {{-- Product Grid --}} +
+ @if($products->isEmpty()) +
+

No products found

+

Try adjusting your filters or browse our full collection.

+ +
+ @else +
+ @foreach($products as $product) + @include('storefront.components.product-card', ['product' => $product]) + @endforeach +
+ +
+ {{ $products->links() }} +
+ @endif +
+
+
+
diff --git a/resources/views/livewire/storefront/home.blade.php b/resources/views/livewire/storefront/home.blade.php new file mode 100644 index 00000000..bfb84241 --- /dev/null +++ b/resources/views/livewire/storefront/home.blade.php @@ -0,0 +1,106 @@ +
+ @foreach($sectionOrder as $section) + @php $sectionSettings = data_get($themeSettings, "sections.{$section}", []); @endphp + + @if(data_get($sectionSettings, 'enabled', false)) + @switch($section) + @case('hero') + {{-- Hero Banner --}} +
+ @if(data_get($sectionSettings, 'background_image')) + + @endif +
+
+

+ {{ data_get($sectionSettings, 'heading', 'Welcome') }} +

+ @if(data_get($sectionSettings, 'subheading')) +

+ {{ data_get($sectionSettings, 'subheading') }} +

+ @endif + @if(data_get($sectionSettings, 'cta_text')) + + {{ data_get($sectionSettings, 'cta_text') }} + + @endif +
+
+ @break + + @case('featured_collections') + {{-- Featured Collections --}} +
+

Collections

+
+ @foreach($this->featuredCollections as $collection) + +
+
+
+

{{ $collection->title }}

+ Shop now +
+
+
+
+ @endforeach +
+
+ @break + + @case('featured_products') + {{-- Featured Products --}} +
+

Featured Products

+
+ @foreach($this->featuredProducts as $product) + @include('storefront.components.product-card', ['product' => $product]) + @endforeach +
+
+ @break + + @case('newsletter') + {{-- Newsletter --}} +
+
+

Stay in the loop

+

Subscribe for exclusive offers and updates.

+
+ + +
+
+
+ @break + + @case('rich_text') + {{-- Rich Text --}} + @if(data_get($sectionSettings, 'content')) +
+
+ {!! data_get($sectionSettings, 'content') !!} +
+
+ @endif + @break + @endswitch + @endif + @endforeach +
diff --git a/resources/views/livewire/storefront/pages/show.blade.php b/resources/views/livewire/storefront/pages/show.blade.php new file mode 100644 index 00000000..4b95828b --- /dev/null +++ b/resources/views/livewire/storefront/pages/show.blade.php @@ -0,0 +1,17 @@ +
+
+ {{-- Breadcrumbs --}} + @include('storefront.components.breadcrumbs', ['items' => [ + ['label' => 'Home', 'url' => '/'], + ['label' => $page->title], + ]]) + +

{{ $page->title }}

+ + @if($page->body_html) +
+ {!! $page->body_html !!} +
+ @endif +
+
diff --git a/resources/views/livewire/storefront/products/show.blade.php b/resources/views/livewire/storefront/products/show.blade.php new file mode 100644 index 00000000..b2818cdc --- /dev/null +++ b/resources/views/livewire/storefront/products/show.blade.php @@ -0,0 +1,128 @@ +
+
+ {{-- Breadcrumbs --}} + @include('storefront.components.breadcrumbs', ['items' => [ + ['label' => 'Home', 'url' => '/'], + ['label' => $product->title], + ]]) + +
+ {{-- Image Gallery --}} +
+ @if($product->media->isNotEmpty()) +
+ {{ $product->title }} +
+ @if($product->media->count() > 1) +
+ @foreach($product->media as $media) +
+ {{ $product->title }} +
+ @endforeach +
+ @endif + @else +
+ + + +
+ @endif +
+ + {{-- Product Info --}} +
+

{{ $product->title }}

+ + {{-- Price --}} + @if($this->selectedVariant) +
+ @include('storefront.components.price', [ + 'amount' => $this->selectedVariant->price_amount, + 'currency' => $currentStore->default_currency ?? 'EUR', + 'compareAtAmount' => $this->selectedVariant->compare_at_amount, + ]) +
+ @endif + + {{-- Variant Selection --}} + @if($product->variants->count() > 1) +
+ Options +
+ @foreach($product->variants as $variant) + + @endforeach +
+
+ @endif + + {{-- Stock Status --}} + @if($this->isSoldOut) +
+ Sold out +
+ @elseif($this->isBackorder) +
+ Available on backorder +
+ @endif + + {{-- Quantity & Add to Cart --}} +
+ @unless($this->isSoldOut) + @include('storefront.components.quantity-selector', [ + 'value' => $quantity, + 'min' => 1, + 'max' => 99, + 'wireModel' => 'quantity', + ]) + @endunless + + +
+ + {{-- Description --}} + @if($product->description_html) +
+ {!! $product->description_html !!} +
+ @endif + + {{-- Tags --}} + @if(!empty($product->tags)) +
+ @foreach($product->tags as $tag) + + {{ $tag }} + + @endforeach +
+ @endif +
+
+
+
diff --git a/resources/views/livewire/storefront/search/index.blade.php b/resources/views/livewire/storefront/search/index.blade.php new file mode 100644 index 00000000..9712cab2 --- /dev/null +++ b/resources/views/livewire/storefront/search/index.blade.php @@ -0,0 +1,121 @@ +
+
+ @include('storefront.components.breadcrumbs', ['items' => [ + ['label' => 'Home', 'url' => '/'], + ['label' => 'Search'], + ]]) + +
+

Search

+
+ + {{-- Search Input --}} +
+
+ +
+
+ + @if($query) + @if($results && $results->total() > 0) + {{-- Toolbar --}} +
+

+ {{ $results->total() }} {{ Str::plural('result', $results->total()) }} for "{{ $query }}" +

+ +
+ +
+ {{-- Filter Sidebar --}} + + + {{-- Results Grid --}} +
+
+ @foreach($results as $product) + @include('storefront.components.product-card', ['product' => $product]) + @endforeach +
+ +
+ {{ $results->links() }} +
+
+
+ @else +
+ + + +

No results found

+

No products match "{{ $query }}". Try a different search term.

+
+ @endif + @else +
+ + + +

Search our products

+

Enter a search term above to find products.

+
+ @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..dbf3af91 --- /dev/null +++ b/resources/views/livewire/storefront/search/modal.blade.php @@ -0,0 +1,46 @@ +
+ @if($open) +
+
+
+
+ +
+ + @if($suggestions->isNotEmpty()) + + @elseif(mb_strlen($query) >= 2) +

No results found for "{{ $query }}"

+ @endif + + @if(mb_strlen($query) >= 2) + + @endif +
+
+ @endif +
diff --git a/resources/views/storefront/components/badge.blade.php b/resources/views/storefront/components/badge.blade.php new file mode 100644 index 00000000..dfb198e6 --- /dev/null +++ b/resources/views/storefront/components/badge.blade.php @@ -0,0 +1,13 @@ +@php + $variant = $variant ?? 'default'; + $classes = match($variant) { + 'sale' => 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300', + 'sold-out' => 'bg-zinc-200 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-300', + 'new' => 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300', + default => 'bg-zinc-100 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-300', + }; +@endphp + + + {{ $text }} + diff --git a/resources/views/storefront/components/breadcrumbs.blade.php b/resources/views/storefront/components/breadcrumbs.blade.php new file mode 100644 index 00000000..bf635906 --- /dev/null +++ b/resources/views/storefront/components/breadcrumbs.blade.php @@ -0,0 +1,18 @@ +@php $items = $items ?? []; @endphp + + diff --git a/resources/views/storefront/components/pagination.blade.php b/resources/views/storefront/components/pagination.blade.php new file mode 100644 index 00000000..9f99e8f2 --- /dev/null +++ b/resources/views/storefront/components/pagination.blade.php @@ -0,0 +1,43 @@ +{{-- Uses Laravel's default pagination, customizable via vendor:publish --}} +@if($paginator->hasPages()) + +@endif diff --git a/resources/views/storefront/components/price.blade.php b/resources/views/storefront/components/price.blade.php new file mode 100644 index 00000000..dca1782e --- /dev/null +++ b/resources/views/storefront/components/price.blade.php @@ -0,0 +1,31 @@ +@php + $formattedPrice = number_format($amount / 100, 2, '.', ',') . ' ' . ($currency ?? 'EUR'); + $hasCompare = isset($compareAtAmount) && $compareAtAmount && $compareAtAmount > $amount; + $formattedCompare = $hasCompare ? number_format($compareAtAmount / 100, 2, '.', ',') . ' ' . ($currency ?? 'EUR') : null; + $isCompact = isset($compact) && $compact; +@endphp + +
+ $isCompact, + 'text-lg' => !$isCompact, + 'text-red-600 dark:text-red-400' => $hasCompare, + 'text-zinc-900 dark:text-white' => !$hasCompare, + ])> + {{ $formattedPrice }} + + + @if($hasCompare) + $isCompact, + 'text-sm' => !$isCompact, + ])> + {{ $formattedCompare }} + + @if(!$isCompact) + @include('storefront.components.badge', ['text' => 'Sale', 'variant' => 'sale']) + @endif + @endif +
diff --git a/resources/views/storefront/components/product-card.blade.php b/resources/views/storefront/components/product-card.blade.php new file mode 100644 index 00000000..2cf7e44e --- /dev/null +++ b/resources/views/storefront/components/product-card.blade.php @@ -0,0 +1,48 @@ +@php + $variant = $product->variants->first(); + $image = $product->media->first(); + $hasMultipleVariants = $product->variants->count() > 1; + $priceAmount = $variant?->price_amount ?? 0; + $compareAtAmount = $variant?->compare_at_amount; + $isOnSale = $compareAtAmount && $compareAtAmount > $priceAmount; + $currency = $currentStore->default_currency ?? 'EUR'; +@endphp + + +
+ @if($image) + {{ $product->title }} + @else +
+ + + +
+ @endif + + {{-- Badges --}} + @if($isOnSale) + @include('storefront.components.badge', ['text' => 'Sale', 'variant' => 'sale']) + @endif +
+ +
+

{{ $product->title }}

+ +
+ @include('storefront.components.price', [ + 'amount' => $priceAmount, + 'currency' => $currency, + 'compareAtAmount' => $isOnSale ? $compareAtAmount : null, + 'compact' => true, + ]) +
+ +

+ {{ $hasMultipleVariants ? 'Choose options' : 'Add to cart' }} +

+
+
diff --git a/resources/views/storefront/components/quantity-selector.blade.php b/resources/views/storefront/components/quantity-selector.blade.php new file mode 100644 index 00000000..bcc78ea5 --- /dev/null +++ b/resources/views/storefront/components/quantity-selector.blade.php @@ -0,0 +1,38 @@ +@php + $value = $value ?? 1; + $min = $min ?? 1; + $max = $max ?? 99; + $wireModel = $wireModel ?? null; + $isCompact = isset($compact) && $compact; + $btnSize = $isCompact ? 'h-8 w-8' : 'h-10 w-10'; +@endphp + +
+ + + + + +
diff --git a/resources/views/storefront/errors/404.blade.php b/resources/views/storefront/errors/404.blade.php new file mode 100644 index 00000000..1823e74e --- /dev/null +++ b/resources/views/storefront/errors/404.blade.php @@ -0,0 +1,25 @@ + + + + + + Page not found + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+ 404 +
+

Page not found

+

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

+
+ +
+ + Go to home page + +
+
+ + diff --git a/resources/views/storefront/errors/503.blade.php b/resources/views/storefront/errors/503.blade.php new file mode 100644 index 00000000..bbed710f --- /dev/null +++ b/resources/views/storefront/errors/503.blade.php @@ -0,0 +1,19 @@ + + + + + + Maintenance + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+
{{ config('app.name') }}
+

We'll be back soon

+

We are performing scheduled maintenance. Please check back shortly.

+ + Go to home page + +
+ + diff --git a/routes/console.php b/routes/console.php index 3c9adf1a..13533199 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,12 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::job(new \App\Jobs\ExpireAbandonedCheckouts)->everyFifteenMinutes(); +Schedule::job(new \App\Jobs\CleanupAbandonedCarts)->daily(); +Schedule::job(new \App\Jobs\AggregateAnalytics)->dailyAt('01:00'); diff --git a/routes/web.php b/routes/web.php index f755f111..d1638981 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,12 +2,110 @@ use Illuminate\Support\Facades\Route; -Route::get('/', function () { - return view('welcome'); -})->name('home'); +Route::middleware(['storefront'])->group(function () { + Route::get('/', \App\Livewire\Storefront\Home::class)->name('home'); + Route::get('/collections', \App\Livewire\Storefront\Collections\Index::class)->name('storefront.collections.index'); + Route::get('/collections/{handle}', \App\Livewire\Storefront\Collections\Show::class)->name('storefront.collections.show'); + Route::get('/products/{handle}', \App\Livewire\Storefront\Products\Show::class)->name('storefront.products.show'); + Route::get('/cart', \App\Livewire\Storefront\Cart\Show::class)->name('storefront.cart'); + Route::get('/checkout', \App\Livewire\Storefront\Checkout\Show::class)->name('storefront.checkout'); + Route::get('/checkout/confirmation/{checkout}', \App\Livewire\Storefront\Checkout\Confirmation::class)->name('storefront.checkout.confirmation'); + Route::get('/search', \App\Livewire\Storefront\Search\Index::class)->name('storefront.search'); + Route::get('/pages/{handle}', \App\Livewire\Storefront\Pages\Show::class)->name('storefront.pages.show'); +}); Route::view('dashboard', 'dashboard') ->middleware(['auth', 'verified']) ->name('dashboard'); +Route::prefix('admin')->group(function () { + Route::get('login', \App\Livewire\Admin\Auth\Login::class) + ->name('admin.login'); + + Route::post('logout', function () { + auth()->guard('web')->logout(); + session()->invalidate(); + session()->regenerateToken(); + + return redirect()->route('admin.login'); + })->name('admin.logout'); + + Route::middleware(['auth', 'admin'])->group(function () { + Route::get('/', \App\Livewire\Admin\Dashboard::class)->name('admin.dashboard'); + + // Products + Route::get('products', \App\Livewire\Admin\Products\Index::class)->name('admin.products.index'); + Route::get('products/create', \App\Livewire\Admin\Products\Form::class)->name('admin.products.create'); + Route::get('products/{product}/edit', \App\Livewire\Admin\Products\Form::class)->name('admin.products.edit'); + + // Collections + Route::get('collections', \App\Livewire\Admin\Collections\Index::class)->name('admin.collections.index'); + Route::get('collections/create', \App\Livewire\Admin\Collections\Form::class)->name('admin.collections.create'); + Route::get('collections/{collection}/edit', \App\Livewire\Admin\Collections\Form::class)->name('admin.collections.edit'); + + // Inventory + Route::get('inventory', \App\Livewire\Admin\Inventory\Index::class)->name('admin.inventory.index'); + + // Orders + Route::get('orders', \App\Livewire\Admin\Orders\Index::class)->name('admin.orders.index'); + Route::get('orders/{order}', \App\Livewire\Admin\Orders\Show::class)->name('admin.orders.show'); + + // Customers + Route::get('customers', \App\Livewire\Admin\Customers\Index::class)->name('admin.customers.index'); + Route::get('customers/{customer}', \App\Livewire\Admin\Customers\Show::class)->name('admin.customers.show'); + + // Discounts + Route::get('discounts', \App\Livewire\Admin\Discounts\Index::class)->name('admin.discounts.index'); + Route::get('discounts/create', \App\Livewire\Admin\Discounts\Form::class)->name('admin.discounts.create'); + Route::get('discounts/{discount}/edit', \App\Livewire\Admin\Discounts\Form::class)->name('admin.discounts.edit'); + + // Content + Route::get('pages', \App\Livewire\Admin\Pages\Index::class)->name('admin.pages.index'); + Route::get('pages/create', \App\Livewire\Admin\Pages\Form::class)->name('admin.pages.create'); + Route::get('pages/{page}/edit', \App\Livewire\Admin\Pages\Form::class)->name('admin.pages.edit'); + Route::get('navigation', \App\Livewire\Admin\Navigation\Index::class)->name('admin.navigation.index'); + Route::get('themes', \App\Livewire\Admin\Themes\Index::class)->name('admin.themes.index'); + + // Analytics + Route::get('analytics', \App\Livewire\Admin\Analytics\Index::class)->name('admin.analytics.index'); + + // Apps + Route::get('apps', \App\Livewire\Admin\Apps\Index::class)->name('admin.apps.index'); + + // Developers + Route::get('developers', \App\Livewire\Admin\Developers\Index::class)->name('admin.developers.index'); + + // Settings + Route::get('settings', \App\Livewire\Admin\Settings\Index::class)->name('admin.settings.index'); + }); +}); + +Route::prefix('account')->middleware(['storefront'])->group(function () { + Route::get('login', \App\Livewire\Storefront\Account\Auth\Login::class) + ->name('storefront.login'); + + Route::get('register', \App\Livewire\Storefront\Account\Auth\Register::class) + ->name('storefront.register'); + + Route::get('/', \App\Livewire\Storefront\Account\Dashboard::class) + ->middleware(['auth.customer'])->name('storefront.account'); + + Route::get('orders', \App\Livewire\Storefront\Account\Orders\Index::class) + ->middleware(['auth.customer'])->name('storefront.account.orders'); + + Route::get('orders/{orderNumber}', \App\Livewire\Storefront\Account\Orders\Show::class) + ->middleware(['auth.customer'])->name('storefront.account.orders.show'); + + Route::get('addresses', \App\Livewire\Storefront\Account\Addresses\Index::class) + ->middleware(['auth.customer'])->name('storefront.account.addresses'); + + Route::post('logout', function () { + auth()->guard('customer')->logout(); + session()->invalidate(); + session()->regenerateToken(); + + return redirect()->route('storefront.login'); + })->middleware(['auth.customer'])->name('storefront.logout'); +}); + require __DIR__.'/settings.php'; diff --git a/tests/Feature/Admin/AnalyticsTest.php b/tests/Feature/Admin/AnalyticsTest.php new file mode 100644 index 00000000..00535e6a --- /dev/null +++ b/tests/Feature/Admin/AnalyticsTest.php @@ -0,0 +1,87 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; + $this->actingAs($this->user); + session()->put('current_store_id', $this->store->id); +}); + +it('renders the analytics page for an authenticated admin', function () { + $response = $this->get('/admin/analytics'); + + $response->assertSuccessful(); + $response->assertSee('Analytics'); +}); + +it('displays KPI tiles from analytics_daily', function () { + AnalyticsDaily::withoutGlobalScopes()->create([ + 'store_id' => $this->store->id, + 'date' => now()->format('Y-m-d'), + 'orders_count' => 10, + 'revenue_amount' => 50000, + 'aov_amount' => 5000, + 'visits_count' => 200, + 'add_to_cart_count' => 30, + 'checkout_started_count' => 15, + 'checkout_completed_count' => 10, + ]); + + Livewire::test(Index::class) + ->assertSee('Total Sales') + ->assertSee('Orders') + ->assertSee('Avg Order Value') + ->assertSee('Conversion Rate') + ->assertSee('$500.00') + ->assertSee('10') + ->assertSee('5.0%'); +}); + +it('displays sales chart data', function () { + AnalyticsDaily::withoutGlobalScopes()->create([ + 'store_id' => $this->store->id, + 'date' => now()->format('Y-m-d'), + 'orders_count' => 5, + 'revenue_amount' => 25000, + 'aov_amount' => 5000, + 'visits_count' => 100, + 'add_to_cart_count' => 20, + 'checkout_started_count' => 10, + 'checkout_completed_count' => 5, + ]); + + Livewire::test(Index::class) + ->assertSee('Daily Revenue') + ->assertSee('$250.00'); +}); + +it('filters by date range', function () { + AnalyticsDaily::withoutGlobalScopes()->create([ + 'store_id' => $this->store->id, + 'date' => now()->format('Y-m-d'), + 'orders_count' => 3, + 'revenue_amount' => 15000, + 'aov_amount' => 5000, + 'visits_count' => 50, + 'add_to_cart_count' => 10, + 'checkout_started_count' => 5, + 'checkout_completed_count' => 3, + ]); + + $component = Livewire::test(Index::class); + + $component->set('dateRange', 'today') + ->assertHasNoErrors() + ->assertSee('$150.00'); + + $component->set('dateRange', 'last_7_days') + ->assertHasNoErrors(); + + $component->set('dateRange', 'last_30_days') + ->assertHasNoErrors(); +}); diff --git a/tests/Feature/Admin/DashboardTest.php b/tests/Feature/Admin/DashboardTest.php new file mode 100644 index 00000000..1ac440ec --- /dev/null +++ b/tests/Feature/Admin/DashboardTest.php @@ -0,0 +1,58 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; + $this->actingAs($this->user); + session()->put('current_store_id', $this->store->id); +}); + +it('renders dashboard for authenticated admin user', function () { + $response = $this->get('/admin'); + + $response->assertSuccessful(); + $response->assertSee('Dashboard'); +}); + +it('redirects unauthenticated users to admin login', function () { + auth()->logout(); + + $response = $this->get('/admin'); + + $response->assertRedirect('/admin/login'); +}); + +it('displays KPI tiles with order data', function () { + Order::factory()->count(3)->create([ + 'store_id' => $this->store->id, + 'total_amount' => 5000, + 'placed_at' => now()->toIso8601String(), + ]); + + Livewire::test(\App\Livewire\Admin\Dashboard::class) + ->assertSee('Total Sales') + ->assertSee('Orders'); +}); + +it('filters dashboard by date range', function () { + Order::factory()->create([ + 'store_id' => $this->store->id, + 'total_amount' => 10000, + 'placed_at' => now()->toIso8601String(), + ]); + + $component = Livewire::test(\App\Livewire\Admin\Dashboard::class); + + $component->set('dateRange', 'today') + ->assertHasNoErrors(); + + $component->set('dateRange', 'last_7_days') + ->assertHasNoErrors(); + + $component->set('dateRange', 'last_30_days') + ->assertHasNoErrors(); +}); diff --git a/tests/Feature/Admin/DiscountManagementTest.php b/tests/Feature/Admin/DiscountManagementTest.php new file mode 100644 index 00000000..827704d2 --- /dev/null +++ b/tests/Feature/Admin/DiscountManagementTest.php @@ -0,0 +1,73 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; + $this->actingAs($this->user); + session()->put('current_store_id', $this->store->id); +}); + +it('renders discount list page', function () { + $response = $this->get('/admin/discounts'); + + $response->assertSuccessful(); + $response->assertSee('Discounts'); +}); + +it('lists discounts belonging to the store', function () { + Discount::factory()->count(3)->create([ + 'store_id' => $this->store->id, + ]); + + Livewire::test(\App\Livewire\Admin\Discounts\Index::class) + ->assertSuccessful(); +}); + +it('searches discounts by code', function () { + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'SUMMER20', + ]); + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'WINTER10', + ]); + + Livewire::test(\App\Livewire\Admin\Discounts\Index::class) + ->set('search', 'SUMMER') + ->assertSee('SUMMER20') + ->assertDontSee('WINTER10'); +}); + +it('renders discount create form', function () { + $response = $this->get('/admin/discounts/create'); + + $response->assertSuccessful(); + $response->assertSee('Create discount'); +}); + +it('creates a percentage discount', function () { + Livewire::test(\App\Livewire\Admin\Discounts\Form::class) + ->set('type', 'code') + ->set('code', 'NEWCODE25') + ->set('valueType', 'percent') + ->set('valueAmount', 25) + ->set('status', 'active') + ->set('startsAt', now()->subDay()->toDateTimeString()) + ->set('endsAt', now()->addMonth()->toDateTimeString()) + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('toast'); + + expect(Discount::withoutGlobalScopes()->where('code', 'NEWCODE25')->exists())->toBeTrue(); +}); + +it('generates a random discount code', function () { + Livewire::test(\App\Livewire\Admin\Discounts\Form::class) + ->call('generateCode') + ->assertHasNoErrors(); +}); diff --git a/tests/Feature/Admin/OrderManagementTest.php b/tests/Feature/Admin/OrderManagementTest.php new file mode 100644 index 00000000..b9f9a744 --- /dev/null +++ b/tests/Feature/Admin/OrderManagementTest.php @@ -0,0 +1,95 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; + $this->actingAs($this->user); + session()->put('current_store_id', $this->store->id); +}); + +it('renders order list page', function () { + $response = $this->get('/admin/orders'); + + $response->assertSuccessful(); + $response->assertSee('Orders'); +}); + +it('lists orders belonging to the store', function () { + $orders = Order::factory()->count(3)->create([ + 'store_id' => $this->store->id, + ]); + + Livewire::test(\App\Livewire\Admin\Orders\Index::class) + ->assertSee($orders->first()->order_number); +}); + +it('filters orders by financial status', function () { + Order::factory()->paid()->create([ + 'store_id' => $this->store->id, + 'order_number' => '#PAID-001', + ]); + Order::factory()->pending()->create([ + 'store_id' => $this->store->id, + 'order_number' => '#PEND-001', + ]); + + Livewire::test(\App\Livewire\Admin\Orders\Index::class) + ->set('statusFilter', 'paid') + ->assertSee('#PAID-001') + ->assertDontSee('#PEND-001'); +}); + +it('renders order detail page', function () { + $order = Order::factory()->create([ + 'store_id' => $this->store->id, + 'order_number' => '#TEST-100', + ]); + + OrderLine::factory()->create([ + 'order_id' => $order->id, + 'title_snapshot' => 'Test Line Item', + 'quantity' => 2, + 'unit_price_amount' => 2500, + 'total_amount' => 5000, + ]); + + $response = $this->get("/admin/orders/{$order->id}"); + + $response->assertSuccessful(); + $response->assertSee('#TEST-100'); + $response->assertSee('Test Line Item'); +}); + +it('confirms bank transfer payment on order', function () { + $order = Order::factory()->pending()->create([ + 'store_id' => $this->store->id, + 'payment_method' => PaymentMethod::BankTransfer, + ]); + + Payment::create([ + 'order_id' => $order->id, + 'store_id' => $this->store->id, + 'method' => PaymentMethod::BankTransfer->value, + 'status' => PaymentStatus::Pending->value, + 'amount' => $order->total_amount, + 'currency' => 'USD', + 'provider_ref' => 'BT-'.uniqid(), + 'provider_data_json' => [], + ]); + + Livewire::test(\App\Livewire\Admin\Orders\Show::class, ['order' => $order]) + ->call('confirmPayment') + ->assertDispatched('toast'); + + $order->refresh(); + expect($order->financial_status)->toBe(FinancialStatus::Paid); +}); diff --git a/tests/Feature/Admin/ProductManagementTest.php b/tests/Feature/Admin/ProductManagementTest.php new file mode 100644 index 00000000..c231e234 --- /dev/null +++ b/tests/Feature/Admin/ProductManagementTest.php @@ -0,0 +1,119 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; + $this->actingAs($this->user); + session()->put('current_store_id', $this->store->id); +}); + +it('renders product list page', function () { + $response = $this->get('/admin/products'); + + $response->assertSuccessful(); + $response->assertSee('Products'); +}); + +it('lists products belonging to the store', function () { + Product::factory()->count(3)->create([ + 'store_id' => $this->store->id, + 'status' => 'active', + 'published_at' => now()->toIso8601String(), + ]); + + Livewire::test(\App\Livewire\Admin\Products\Index::class) + ->assertSee('Products'); +}); + +it('filters products by status', function () { + Product::factory()->active()->create([ + 'store_id' => $this->store->id, + 'title' => 'Active Shirt', + ]); + Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => 'Draft Pants', + 'status' => 'draft', + ]); + + Livewire::test(\App\Livewire\Admin\Products\Index::class) + ->set('statusFilter', 'active') + ->assertSee('Active Shirt') + ->assertDontSee('Draft Pants'); +}); + +it('searches products by title', function () { + Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => 'Organic Cotton Hoodie', + ]); + Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => 'Silk Blouse', + ]); + + Livewire::test(\App\Livewire\Admin\Products\Index::class) + ->set('search', 'Cotton') + ->assertSee('Organic Cotton Hoodie') + ->assertDontSee('Silk Blouse'); +}); + +it('renders product create form', function () { + $response = $this->get('/admin/products/create'); + + $response->assertSuccessful(); + $response->assertSee('Add product'); +}); + +it('creates a product with a default variant', function () { + Livewire::test(\App\Livewire\Admin\Products\Form::class) + ->set('title', 'Test Product') + ->set('handle', 'test-product') + ->set('status', 'draft') + ->set('variants', [ + ['sku' => 'TP-001', 'price' => 2999, 'compareAtPrice' => null, 'quantity' => 10, 'requiresShipping' => true, 'optionValues' => []], + ]) + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('toast'); + + expect(Product::withoutGlobalScopes()->where('title', 'Test Product')->exists())->toBeTrue(); +}); + +it('bulk archives selected products', function () { + $products = Product::factory()->active()->count(2)->create([ + 'store_id' => $this->store->id, + ]); + + Livewire::test(\App\Livewire\Admin\Products\Index::class) + ->set('selectedIds', $products->pluck('id')->toArray()) + ->call('bulkArchive') + ->assertDispatched('toast'); + + foreach ($products as $product) { + $product->refresh(); + expect($product->status->value)->toBe('archived'); + } +}); + +it('renders product edit form with existing data', function () { + $product = Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => 'Editable Product', + ]); + ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'price_amount' => 1500, + 'is_default' => true, + ]); + + $response = $this->get("/admin/products/{$product->id}/edit"); + + $response->assertSuccessful(); + $response->assertSee('Editable Product'); +}); diff --git a/tests/Feature/Admin/SettingsTest.php b/tests/Feature/Admin/SettingsTest.php new file mode 100644 index 00000000..aad90632 --- /dev/null +++ b/tests/Feature/Admin/SettingsTest.php @@ -0,0 +1,92 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->user = $this->context['user']; + $this->actingAs($this->user); + session()->put('current_store_id', $this->store->id); +}); + +it('renders settings page for owner', function () { + $response = $this->get('/admin/settings'); + + $response->assertSuccessful(); + $response->assertSee('Settings'); +}); + +it('denies settings access for staff role', function () { + $staffUser = \App\Models\User::factory()->create(); + $this->store->users()->attach($staffUser, ['role' => 'staff']); + $this->actingAs($staffUser); + + $response = $this->get('/admin/settings'); + + $response->assertForbidden(); +}); + +it('saves general store settings', function () { + Livewire::test(\App\Livewire\Admin\Settings\Index::class) + ->set('storeName', 'Updated Store Name') + ->set('defaultCurrency', 'EUR') + ->set('defaultLocale', 'de') + ->set('timezone', 'Europe/Berlin') + ->call('saveGeneral') + ->assertHasNoErrors() + ->assertDispatched('toast'); + + $this->store->refresh(); + expect($this->store->name)->toBe('Updated Store Name') + ->and($this->store->default_currency)->toBe('EUR') + ->and($this->store->default_locale)->toBe('de') + ->and($this->store->timezone)->toBe('Europe/Berlin'); +}); + +it('adds a new domain', function () { + Livewire::test(\App\Livewire\Admin\Settings\Index::class) + ->set('newHostname', 'custom.example.com') + ->set('newDomainType', 'storefront') + ->call('addDomain') + ->assertHasNoErrors() + ->assertDispatched('toast'); + + expect(StoreDomain::where('hostname', 'custom.example.com')->exists())->toBeTrue(); +}); + +it('creates a shipping zone with rates', function () { + Livewire::test(\App\Livewire\Admin\Settings\Index::class) + ->set('zoneName', 'US Domestic') + ->set('zoneCountries', 'US, CA') + ->call('saveZone') + ->assertHasNoErrors() + ->assertDispatched('toast'); + + $zone = ShippingZone::where('store_id', $this->store->id) + ->where('name', 'US Domestic') + ->first(); + expect($zone)->not->toBeNull() + ->and($zone->countries_json)->toContain('US'); +}); + +it('saves tax settings', function () { + Livewire::test(\App\Livewire\Admin\Settings\Index::class) + ->set('tab', 'taxes') + ->set('taxMode', 'manual') + ->set('taxRate', 19) + ->set('taxName', 'VAT') + ->set('pricesIncludeTax', true) + ->call('saveTax') + ->assertHasNoErrors() + ->assertDispatched('toast'); + + $tax = TaxSettings::where('store_id', $this->store->id)->first(); + expect($tax)->not->toBeNull() + ->and($tax->rate)->toBe(19) + ->and($tax->tax_name)->toBe('VAT') + ->and($tax->prices_include_tax)->toBeTrue(); +}); diff --git a/tests/Feature/Analytics/AggregationTest.php b/tests/Feature/Analytics/AggregationTest.php new file mode 100644 index 00000000..3c02e2ab --- /dev/null +++ b/tests/Feature/Analytics/AggregationTest.php @@ -0,0 +1,132 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->yesterday = now()->subDay()->format('Y-m-d'); + $this->yesterdayTimestamp = $this->yesterday.'T12:00:00'; +}); + +it('aggregates visits from unique sessions', function () { + AnalyticsEvent::factory()->pageView()->count(3)->create([ + 'store_id' => $this->store->id, + 'session_id' => 'sess-a', + 'created_at' => $this->yesterdayTimestamp, + ]); + AnalyticsEvent::factory()->pageView()->count(2)->create([ + 'store_id' => $this->store->id, + 'session_id' => 'sess-b', + 'created_at' => $this->yesterdayTimestamp, + ]); + + (new AggregateAnalytics($this->yesterday))->handle(); + + $daily = AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $this->store->id) + ->where('date', $this->yesterday) + ->first(); + + expect($daily)->not->toBeNull() + ->and($daily->visits_count)->toBe(2); +}); + +it('aggregates add_to_cart events', function () { + AnalyticsEvent::factory()->addToCart()->count(4)->create([ + 'store_id' => $this->store->id, + 'created_at' => $this->yesterdayTimestamp, + ]); + + (new AggregateAnalytics($this->yesterday))->handle(); + + $daily = AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $this->store->id) + ->where('date', $this->yesterday) + ->first(); + + expect($daily->add_to_cart_count)->toBe(4); +}); + +it('aggregates checkout_started events', function () { + AnalyticsEvent::factory()->checkoutStarted()->count(2)->create([ + 'store_id' => $this->store->id, + 'created_at' => $this->yesterdayTimestamp, + ]); + + (new AggregateAnalytics($this->yesterday))->handle(); + + $daily = AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $this->store->id) + ->where('date', $this->yesterday) + ->first(); + + expect($daily->checkout_started_count)->toBe(2); +}); + +it('aggregates orders revenue and aov', function () { + AnalyticsEvent::factory()->checkoutCompleted(5000)->create([ + 'store_id' => $this->store->id, + 'created_at' => $this->yesterdayTimestamp, + ]); + AnalyticsEvent::factory()->checkoutCompleted(3000)->create([ + 'store_id' => $this->store->id, + 'created_at' => $this->yesterdayTimestamp, + ]); + + (new AggregateAnalytics($this->yesterday))->handle(); + + $daily = AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $this->store->id) + ->where('date', $this->yesterday) + ->first(); + + expect($daily->orders_count)->toBe(2) + ->and($daily->revenue_amount)->toBe(8000) + ->and($daily->aov_amount)->toBe(4000); +}); + +it('handles zero orders gracefully', function () { + AnalyticsEvent::factory()->pageView()->count(3)->create([ + 'store_id' => $this->store->id, + 'created_at' => $this->yesterdayTimestamp, + ]); + + (new AggregateAnalytics($this->yesterday))->handle(); + + $daily = AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $this->store->id) + ->where('date', $this->yesterday) + ->first(); + + expect($daily->orders_count)->toBe(0) + ->and($daily->revenue_amount)->toBe(0) + ->and($daily->aov_amount)->toBe(0); +}); + +it('retrieves daily metrics for a date range', function () { + $service = new AnalyticsService; + + for ($i = 1; $i <= 7; $i++) { + AnalyticsDaily::withoutGlobalScopes()->create([ + 'store_id' => $this->store->id, + 'date' => "2026-03-0{$i}", + 'orders_count' => $i, + 'revenue_amount' => $i * 1000, + 'aov_amount' => 1000, + 'visits_count' => $i * 10, + 'add_to_cart_count' => $i * 2, + 'checkout_started_count' => $i, + 'checkout_completed_count' => $i, + ]); + } + + $metrics = $service->getDailyMetrics($this->store, '2026-03-01', '2026-03-07'); + + expect($metrics)->toHaveCount(7) + ->and($metrics->first()->date)->toBe('2026-03-01') + ->and($metrics->last()->date)->toBe('2026-03-07'); +}); diff --git a/tests/Feature/Analytics/EventIngestionTest.php b/tests/Feature/Analytics/EventIngestionTest.php new file mode 100644 index 00000000..23b493ab --- /dev/null +++ b/tests/Feature/Analytics/EventIngestionTest.php @@ -0,0 +1,67 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->service = new AnalyticsService; +}); + +it('tracks a page view event', function () { + $this->service->track($this->store, 'page_view', [], 'sess-1'); + + $event = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $this->store->id)->first(); + + expect($event)->not->toBeNull() + ->and($event->type)->toBe('page_view') + ->and($event->session_id)->toBe('sess-1') + ->and($event->customer_id)->toBeNull(); +}); + +it('tracks an event with customer association', function () { + $customer = Customer::factory()->create(['store_id' => $this->store->id]); + + $this->service->track($this->store, 'product_view', [], 'sess-1', $customer->id); + + $event = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $this->store->id)->first(); + + expect($event->customer_id)->toBe($customer->id); +}); + +it('stores event properties as JSON', function () { + $this->service->track($this->store, 'product_view', ['product_id' => 42], 'sess-1'); + + $event = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $this->store->id)->first(); + + expect($event->properties_json)->toBe(['product_id' => 42]); +}); + +it('scopes events to the correct store', function () { + $otherContext = createStoreContext(); + $otherStore = $otherContext['store']; + + $this->service->track($this->store, 'page_view', [], 'sess-1'); + + expect(AnalyticsEvent::withoutGlobalScopes()->where('store_id', $this->store->id)->count())->toBe(1) + ->and(AnalyticsEvent::withoutGlobalScopes()->where('store_id', $otherStore->id)->count())->toBe(0); +}); + +it('tracks all supported event types', function () { + $types = ['page_view', 'add_to_cart', 'checkout_started', 'checkout_completed']; + + foreach ($types as $type) { + $this->service->track($this->store, $type, [], 'sess-1'); + } + + expect(AnalyticsEvent::withoutGlobalScopes()->where('store_id', $this->store->id)->count())->toBe(4); + + foreach ($types as $type) { + expect(AnalyticsEvent::withoutGlobalScopes() + ->where('store_id', $this->store->id) + ->where('type', $type) + ->exists())->toBeTrue(); + } +}); diff --git a/tests/Feature/Auth/AdminAuthTest.php b/tests/Feature/Auth/AdminAuthTest.php new file mode 100644 index 00000000..26b3330a --- /dev/null +++ b/tests/Feature/Auth/AdminAuthTest.php @@ -0,0 +1,98 @@ +create([ + 'email' => 'admin@example.com', + 'password' => bcrypt('password123'), + ]); + + Livewire::test(\App\Livewire\Admin\Auth\Login::class) + ->set('email', 'admin@example.com') + ->set('password', 'password123') + ->call('login') + ->assertRedirect('/admin'); + + expect(Auth::guard('web')->check())->toBeTrue(); +}); + +it('admin login with invalid credentials fails', function () { + User::factory()->create([ + 'email' => 'admin@example.com', + 'password' => bcrypt('password123'), + ]); + + Livewire::test(\App\Livewire\Admin\Auth\Login::class) + ->set('email', 'admin@example.com') + ->set('password', 'wrong-password') + ->call('login') + ->assertHasErrors('email'); + + expect(Auth::guard('web')->check())->toBeFalse(); +}); + +it('admin login with non-existent email fails with generic message', function () { + Livewire::test(\App\Livewire\Admin\Auth\Login::class) + ->set('email', 'nobody@example.com') + ->set('password', 'anything') + ->call('login') + ->assertHasErrors('email'); + + expect(Auth::guard('web')->check())->toBeFalse(); +}); + +it('admin login is rate-limited to 5 attempts per minute', function () { + User::factory()->create([ + 'email' => 'admin@example.com', + 'password' => bcrypt('password123'), + ]); + + for ($i = 0; $i < 5; $i++) { + Livewire::test(\App\Livewire\Admin\Auth\Login::class) + ->set('email', 'admin@example.com') + ->set('password', 'wrong') + ->call('login'); + } + + Livewire::test(\App\Livewire\Admin\Auth\Login::class) + ->set('email', 'admin@example.com') + ->set('password', 'wrong') + ->call('login') + ->assertHasErrors('email'); +}); + +it('admin login with remember me sets a token', function () { + $user = User::factory()->create([ + 'email' => 'admin@example.com', + 'password' => bcrypt('password123'), + ]); + + Livewire::test(\App\Livewire\Admin\Auth\Login::class) + ->set('email', 'admin@example.com') + ->set('password', 'password123') + ->set('remember', true) + ->call('login') + ->assertRedirect('/admin'); + + expect(Auth::guard('web')->check())->toBeTrue(); +}); + +it('admin logout invalidates the session', function () { + $user = User::factory()->create(); + + $this->actingAs($user); + + $response = $this->post('/admin/logout'); + $response->assertRedirect('/admin/login'); + + expect(Auth::guard('web')->check())->toBeFalse(); +}); + +it('login rate limiter is registered', function () { + $limiter = RateLimiter::limiter('login'); + expect($limiter)->not->toBeNull(); +}); diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index fff11fd7..6863270b 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -66,4 +66,4 @@ $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..35e12918 --- /dev/null +++ b/tests/Feature/Auth/CustomerAuthTest.php @@ -0,0 +1,216 @@ +store = Store::factory()->create(); + StoreDomain::factory()->create([ + 'store_id' => $this->store->id, + 'hostname' => 'test-store.test', + 'type' => 'storefront', + ]); + app()->instance('current_store', $this->store); +}); + +it('customer guard uses session driver with customers provider', function () { + expect(config('auth.guards.customer.driver'))->toBe('session'); + expect(config('auth.guards.customer.provider'))->toBe('customers'); +}); + +it('CustomerUserProvider scopes credential queries by store_id', function () { + $storeB = Store::factory()->create(); + + Customer::withoutGlobalScopes()->create([ + 'store_id' => $this->store->id, + 'email' => 'customer@example.com', + 'password' => bcrypt('secret123'), + 'name' => 'Customer A', + ]); + + Customer::withoutGlobalScopes()->create([ + 'store_id' => $storeB->id, + 'email' => 'customer@example.com', + 'password' => bcrypt('different'), + 'name' => 'Customer B', + ]); + + $result = Auth::guard('customer')->attempt([ + 'email' => 'customer@example.com', + 'password' => 'secret123', + ]); + + expect($result)->toBeTrue(); + $authedCustomer = Auth::guard('customer')->user(); + expect($authedCustomer->store_id)->toBe($this->store->id); +}); + +it('customer login with valid credentials succeeds', function () { + Customer::withoutGlobalScopes()->create([ + 'store_id' => $this->store->id, + 'email' => 'buyer@example.com', + 'password' => bcrypt('secret123'), + 'name' => 'Buyer', + ]); + + Livewire::test(\App\Livewire\Storefront\Account\Auth\Login::class) + ->set('email', 'buyer@example.com') + ->set('password', 'secret123') + ->call('login') + ->assertRedirect('/account'); + + expect(Auth::guard('customer')->check())->toBeTrue(); +}); + +it('customer login with invalid credentials fails', function () { + Customer::withoutGlobalScopes()->create([ + 'store_id' => $this->store->id, + 'email' => 'buyer@example.com', + 'password' => bcrypt('secret123'), + 'name' => 'Buyer', + ]); + + Livewire::test(\App\Livewire\Storefront\Account\Auth\Login::class) + ->set('email', 'buyer@example.com') + ->set('password', 'wrong') + ->call('login') + ->assertHasErrors('email'); + + expect(Auth::guard('customer')->check())->toBeFalse(); +}); + +it('customer login is rate-limited to 5 attempts per minute', function () { + Customer::withoutGlobalScopes()->create([ + 'store_id' => $this->store->id, + 'email' => 'buyer@example.com', + 'password' => bcrypt('secret123'), + 'name' => 'Buyer', + ]); + + for ($i = 0; $i < 5; $i++) { + Livewire::test(\App\Livewire\Storefront\Account\Auth\Login::class) + ->set('email', 'buyer@example.com') + ->set('password', 'wrong') + ->call('login'); + } + + Livewire::test(\App\Livewire\Storefront\Account\Auth\Login::class) + ->set('email', 'buyer@example.com') + ->set('password', 'wrong') + ->call('login') + ->assertHasErrors('email'); +}); + +it('customer registration with valid data succeeds', function () { + Livewire::test(\App\Livewire\Storefront\Account\Auth\Register::class) + ->set('name', 'Jane Doe') + ->set('email', 'jane@example.com') + ->set('password', 'securepass1') + ->set('password_confirmation', 'securepass1') + ->call('register') + ->assertRedirect('/account'); + + expect(Auth::guard('customer')->check())->toBeTrue(); + + $customer = Customer::withoutGlobalScopes()->where('email', 'jane@example.com')->first(); + expect($customer)->not->toBeNull(); + expect($customer->store_id)->toBe($this->store->id); +}); + +it('customer registration requires name, email, password, and password_confirmation', function () { + Livewire::test(\App\Livewire\Storefront\Account\Auth\Register::class) + ->set('name', '') + ->set('email', '') + ->set('password', '') + ->call('register') + ->assertHasErrors(['name', 'email', 'password']); +}); + +it('customer registration enforces minimum password length of 8', function () { + Livewire::test(\App\Livewire\Storefront\Account\Auth\Register::class) + ->set('name', 'Jane') + ->set('email', 'jane@example.com') + ->set('password', 'short') + ->set('password_confirmation', 'short') + ->call('register') + ->assertHasErrors('password'); +}); + +it('customer registration enforces password confirmation match', function () { + Livewire::test(\App\Livewire\Storefront\Account\Auth\Register::class) + ->set('name', 'Jane') + ->set('email', 'jane@example.com') + ->set('password', 'securepass1') + ->set('password_confirmation', 'different') + ->call('register') + ->assertHasErrors('password'); +}); + +it('customer email must be unique per store', function () { + Customer::withoutGlobalScopes()->create([ + 'store_id' => $this->store->id, + 'email' => 'existing@example.com', + 'name' => 'Existing', + 'password' => bcrypt('password'), + ]); + + Livewire::test(\App\Livewire\Storefront\Account\Auth\Register::class) + ->set('name', 'Jane') + ->set('email', 'existing@example.com') + ->set('password', 'securepass1') + ->set('password_confirmation', 'securepass1') + ->call('register') + ->assertHasErrors('email'); +}); + +it('same email can register in different stores', function () { + $storeB = Store::factory()->create(); + + Customer::withoutGlobalScopes()->create([ + 'store_id' => $storeB->id, + 'email' => 'shared@example.com', + 'name' => 'Shared', + 'password' => bcrypt('password'), + ]); + + Livewire::test(\App\Livewire\Storefront\Account\Auth\Register::class) + ->set('name', 'Jane') + ->set('email', 'shared@example.com') + ->set('password', 'securepass1') + ->set('password_confirmation', 'securepass1') + ->call('register') + ->assertRedirect('/account'); + + $count = Customer::withoutGlobalScopes()->where('email', 'shared@example.com')->count(); + expect($count)->toBe(2); +}); + +it('customer registration supports optional marketing_opt_in', function () { + Livewire::test(\App\Livewire\Storefront\Account\Auth\Register::class) + ->set('name', 'Jane') + ->set('email', 'jane@example.com') + ->set('password', 'securepass1') + ->set('password_confirmation', 'securepass1') + ->set('marketing_opt_in', true) + ->call('register') + ->assertRedirect('/account'); + + $customer = Customer::withoutGlobalScopes()->where('email', 'jane@example.com')->first(); + expect($customer->marketing_opt_in)->toBeTrue(); +}); + +it('customer registration defaults marketing_opt_in to false', function () { + Livewire::test(\App\Livewire\Storefront\Account\Auth\Register::class) + ->set('name', 'Jane') + ->set('email', 'jane@example.com') + ->set('password', 'securepass1') + ->set('password_confirmation', 'securepass1') + ->call('register') + ->assertRedirect('/account'); + + $customer = Customer::withoutGlobalScopes()->where('email', 'jane@example.com')->first(); + expect($customer->marketing_opt_in)->toBeFalse(); +}); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php index 66f58e36..c8ea4ecf 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -66,4 +66,4 @@ 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 index f42a259e..997196f9 100644 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -10,4 +10,4 @@ $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 index bea78251..99721180 100644 --- a/tests/Feature/Auth/PasswordResetTest.php +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -58,4 +58,4 @@ return true; }); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index c22ea5e1..144036c7 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -20,4 +20,4 @@ ->assertRedirect(route('dashboard', absolute: false)); $this->assertAuthenticated(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php index cda794f2..a2ce0cd5 100644 --- a/tests/Feature/Auth/TwoFactorChallengeTest.php +++ b/tests/Feature/Auth/TwoFactorChallengeTest.php @@ -31,4 +31,4 @@ 'email' => $user->email, 'password' => 'password', ])->assertRedirect(route('two-factor.login')); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Authorization/GatesTest.php b/tests/Feature/Authorization/GatesTest.php new file mode 100644 index 00000000..dc2f528c --- /dev/null +++ b/tests/Feature/Authorization/GatesTest.php @@ -0,0 +1,114 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); +}); + +function createGateUser(Store $store, string $role): User +{ + $user = User::factory()->create(); + $store->users()->attach($user->id, ['role' => $role]); + + return $user; +} + +it('manage-store-settings gate allows only owner and admin', function (string $role, bool $expected) { + $user = createGateUser($this->store, $role); + + $this->actingAs($user); + expect(Gate::allows('manage-store-settings'))->toBe($expected); +})->with([ + ['owner', true], + ['admin', true], + ['staff', false], + ['support', false], +]); + +it('manage-staff gate allows only owner and admin', function (string $role, bool $expected) { + $user = createGateUser($this->store, $role); + + $this->actingAs($user); + expect(Gate::allows('manage-staff'))->toBe($expected); +})->with([ + ['owner', true], + ['admin', true], + ['staff', false], + ['support', false], +]); + +it('manage-developers gate allows only owner and admin', function (string $role, bool $expected) { + $user = createGateUser($this->store, $role); + + $this->actingAs($user); + expect(Gate::allows('manage-developers'))->toBe($expected); +})->with([ + ['owner', true], + ['admin', true], + ['staff', false], + ['support', false], +]); + +it('view-analytics gate allows owner, admin, and staff', function (string $role, bool $expected) { + $user = createGateUser($this->store, $role); + + $this->actingAs($user); + expect(Gate::allows('view-analytics'))->toBe($expected); +})->with([ + ['owner', true], + ['admin', true], + ['staff', true], + ['support', false], +]); + +it('manage-shipping gate allows only owner and admin', function (string $role, bool $expected) { + $user = createGateUser($this->store, $role); + + $this->actingAs($user); + expect(Gate::allows('manage-shipping'))->toBe($expected); +})->with([ + ['owner', true], + ['admin', true], + ['staff', false], + ['support', false], +]); + +it('manage-taxes gate allows only owner and admin', function (string $role, bool $expected) { + $user = createGateUser($this->store, $role); + + $this->actingAs($user); + expect(Gate::allows('manage-taxes'))->toBe($expected); +})->with([ + ['owner', true], + ['admin', true], + ['staff', false], + ['support', false], +]); + +it('manage-search-settings gate allows only owner and admin', function (string $role, bool $expected) { + $user = createGateUser($this->store, $role); + + $this->actingAs($user); + expect(Gate::allows('manage-search-settings'))->toBe($expected); +})->with([ + ['owner', true], + ['admin', true], + ['staff', false], + ['support', false], +]); + +it('manage-navigation gate allows only owner and admin', function (string $role, bool $expected) { + $user = createGateUser($this->store, $role); + + $this->actingAs($user); + expect(Gate::allows('manage-navigation'))->toBe($expected); +})->with([ + ['owner', true], + ['admin', true], + ['staff', false], + ['support', false], +]); diff --git a/tests/Feature/Authorization/PolicyTest.php b/tests/Feature/Authorization/PolicyTest.php new file mode 100644 index 00000000..22995b3e --- /dev/null +++ b/tests/Feature/Authorization/PolicyTest.php @@ -0,0 +1,243 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); +}); + +function createUserWithRole(Store $store, string $role): User +{ + $user = User::factory()->create(); + $store->users()->attach($user->id, ['role' => $role]); + + return $user; +} + +function makeModel(int $storeId): object +{ + return (object) ['store_id' => $storeId]; +} + +// ProductPolicy +it('ProductPolicy viewAny - any role can list', function (string $role) { + $user = createUserWithRole($this->store, $role); + $policy = new ProductPolicy; + expect($policy->viewAny($user))->toBeTrue(); +})->with(['owner', 'admin', 'staff', 'support']); + +it('ProductPolicy viewAny - no role is denied', function () { + $user = User::factory()->create(); + $policy = new ProductPolicy; + expect($policy->viewAny($user))->toBeFalse(); +}); + +it('ProductPolicy create - owner, admin, staff can create', function (string $role, bool $expected) { + $user = createUserWithRole($this->store, $role); + $policy = new ProductPolicy; + expect($policy->create($user))->toBe($expected); +})->with([ + ['owner', true], + ['admin', true], + ['staff', true], + ['support', false], +]); + +it('ProductPolicy delete - only owner and admin', function (string $role, bool $expected) { + $user = createUserWithRole($this->store, $role); + $policy = new ProductPolicy; + expect($policy->delete($user, makeModel($this->store->id)))->toBe($expected); +})->with([ + ['owner', true], + ['admin', true], + ['staff', false], + ['support', false], +]); + +// OrderPolicy +it('OrderPolicy viewAny - any role can list', function (string $role) { + $user = createUserWithRole($this->store, $role); + $policy = new OrderPolicy; + expect($policy->viewAny($user))->toBeTrue(); +})->with(['owner', 'admin', 'staff', 'support']); + +it('OrderPolicy update - owner, admin, staff can update', function (string $role, bool $expected) { + $user = createUserWithRole($this->store, $role); + $policy = new OrderPolicy; + expect($policy->update($user, makeModel($this->store->id)))->toBe($expected); +})->with([ + ['owner', true], + ['admin', true], + ['staff', true], + ['support', false], +]); + +it('OrderPolicy cancel - only owner and admin', function (string $role, bool $expected) { + $user = createUserWithRole($this->store, $role); + $policy = new OrderPolicy; + expect($policy->cancel($user, makeModel($this->store->id)))->toBe($expected); +})->with([ + ['owner', true], + ['admin', true], + ['staff', false], + ['support', false], +]); + +it('OrderPolicy createRefund - only owner and admin', function (string $role, bool $expected) { + $user = createUserWithRole($this->store, $role); + $policy = new OrderPolicy; + expect($policy->createRefund($user, makeModel($this->store->id)))->toBe($expected); +})->with([ + ['owner', true], + ['admin', true], + ['staff', false], + ['support', false], +]); + +// StorePolicy +it('StorePolicy viewSettings - only owner and admin', function (string $role, bool $expected) { + $user = createUserWithRole($this->store, $role); + $policy = new StorePolicy; + expect($policy->viewSettings($user, $this->store))->toBe($expected); +})->with([ + ['owner', true], + ['admin', true], + ['staff', false], + ['support', false], +]); + +it('StorePolicy delete - only owner', function (string $role, bool $expected) { + $user = createUserWithRole($this->store, $role); + $policy = new StorePolicy; + expect($policy->delete($user, $this->store))->toBe($expected); +})->with([ + ['owner', true], + ['admin', false], + ['staff', false], + ['support', false], +]); + +// ThemePolicy +it('ThemePolicy viewAny - only owner and admin', function (string $role, bool $expected) { + $user = createUserWithRole($this->store, $role); + $policy = new ThemePolicy; + expect($policy->viewAny($user))->toBe($expected); +})->with([ + ['owner', true], + ['admin', true], + ['staff', false], + ['support', false], +]); + +// PagePolicy +it('PagePolicy viewAny - owner, admin, staff', function (string $role, bool $expected) { + $user = createUserWithRole($this->store, $role); + $policy = new PagePolicy; + expect($policy->viewAny($user))->toBe($expected); +})->with([ + ['owner', true], + ['admin', true], + ['staff', true], + ['support', false], +]); + +it('PagePolicy delete - only owner and admin', function (string $role, bool $expected) { + $user = createUserWithRole($this->store, $role); + $policy = new PagePolicy; + expect($policy->delete($user, makeModel($this->store->id)))->toBe($expected); +})->with([ + ['owner', true], + ['admin', true], + ['staff', false], + ['support', false], +]); + +// CollectionPolicy +it('CollectionPolicy viewAny - any role can list', function (string $role) { + $user = createUserWithRole($this->store, $role); + $policy = new CollectionPolicy; + expect($policy->viewAny($user))->toBeTrue(); +})->with(['owner', 'admin', 'staff', 'support']); + +it('CollectionPolicy delete - only owner and admin', function (string $role, bool $expected) { + $user = createUserWithRole($this->store, $role); + $policy = new CollectionPolicy; + expect($policy->delete($user, makeModel($this->store->id)))->toBe($expected); +})->with([ + ['owner', true], + ['admin', true], + ['staff', false], + ['support', false], +]); + +// DiscountPolicy +it('DiscountPolicy viewAny - any role can list', function (string $role) { + $user = createUserWithRole($this->store, $role); + $policy = new DiscountPolicy; + expect($policy->viewAny($user))->toBeTrue(); +})->with(['owner', 'admin', 'staff', 'support']); + +it('DiscountPolicy delete - only owner and admin', function (string $role, bool $expected) { + $user = createUserWithRole($this->store, $role); + $policy = new DiscountPolicy; + expect($policy->delete($user, makeModel($this->store->id)))->toBe($expected); +})->with([ + ['owner', true], + ['admin', true], + ['staff', false], + ['support', false], +]); + +// CustomerPolicy +it('CustomerPolicy viewAny - any role can list', function (string $role) { + $user = createUserWithRole($this->store, $role); + $policy = new CustomerPolicy; + expect($policy->viewAny($user))->toBeTrue(); +})->with(['owner', 'admin', 'staff', 'support']); + +it('CustomerPolicy update - owner, admin, staff can update', function (string $role, bool $expected) { + $user = createUserWithRole($this->store, $role); + $policy = new CustomerPolicy; + expect($policy->update($user, makeModel($this->store->id)))->toBe($expected); +})->with([ + ['owner', true], + ['admin', true], + ['staff', true], + ['support', false], +]); + +// RefundPolicy +it('RefundPolicy create - only owner and admin', function (string $role, bool $expected) { + $user = createUserWithRole($this->store, $role); + $policy = new RefundPolicy; + expect($policy->create($user, makeModel($this->store->id)))->toBe($expected); +})->with([ + ['owner', true], + ['admin', true], + ['staff', false], + ['support', false], +]); + +// FulfillmentPolicy +it('FulfillmentPolicy create - owner, admin, staff', function (string $role, bool $expected) { + $user = createUserWithRole($this->store, $role); + $policy = new FulfillmentPolicy; + expect($policy->create($user, makeModel($this->store->id)))->toBe($expected); +})->with([ + ['owner', true], + ['admin', true], + ['staff', true], + ['support', false], +]); diff --git a/tests/Feature/Authorization/RoleCheckingTest.php b/tests/Feature/Authorization/RoleCheckingTest.php new file mode 100644 index 00000000..b4748a79 --- /dev/null +++ b/tests/Feature/Authorization/RoleCheckingTest.php @@ -0,0 +1,131 @@ +checker = new class + { + use ChecksStoreRole; + + public function publicGetStoreRole(User $user, int $storeId): ?StoreUserRole + { + return $this->getStoreRole($user, $storeId); + } + + public function publicHasRole(User $user, int $storeId, array $roles): bool + { + return $this->hasRole($user, $storeId, $roles); + } + + public function publicIsOwnerOrAdmin(User $user, int $storeId): bool + { + return $this->isOwnerOrAdmin($user, $storeId); + } + + public function publicIsOwnerAdminOrStaff(User $user, int $storeId): bool + { + return $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function publicIsAnyRole(User $user, int $storeId): bool + { + return $this->isAnyRole($user, $storeId); + } + }; +}); + +it('getStoreRole returns the user role for a store', function () { + $user = User::factory()->create(); + $store = Store::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'admin']); + + expect($this->checker->publicGetStoreRole($user, $store->id))->toBe(StoreUserRole::Admin); +}); + +it('getStoreRole returns null when user has no role', function () { + $user = User::factory()->create(); + $store = Store::factory()->create(); + + expect($this->checker->publicGetStoreRole($user, $store->id))->toBeNull(); +}); + +it('hasRole returns true when role matches', function () { + $user = User::factory()->create(); + $store = Store::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'staff']); + + expect($this->checker->publicHasRole($user, $store->id, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]))->toBeTrue(); +}); + +it('hasRole returns false when role does not match', function () { + $user = User::factory()->create(); + $store = Store::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'support']); + + expect($this->checker->publicHasRole($user, $store->id, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]))->toBeFalse(); +}); + +it('hasRole returns false when user has no role', function () { + $user = User::factory()->create(); + $store = Store::factory()->create(); + + expect($this->checker->publicHasRole($user, $store->id, [StoreUserRole::Owner]))->toBeFalse(); +}); + +it('isOwnerOrAdmin returns true for Owner', function () { + $user = User::factory()->create(); + $store = Store::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + + expect($this->checker->publicIsOwnerOrAdmin($user, $store->id))->toBeTrue(); +}); + +it('isOwnerOrAdmin returns true for Admin', function () { + $user = User::factory()->create(); + $store = Store::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'admin']); + + expect($this->checker->publicIsOwnerOrAdmin($user, $store->id))->toBeTrue(); +}); + +it('isOwnerOrAdmin returns false for Staff', function () { + $user = User::factory()->create(); + $store = Store::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'staff']); + + expect($this->checker->publicIsOwnerOrAdmin($user, $store->id))->toBeFalse(); +}); + +it('isOwnerAdminOrStaff returns true for Staff', function () { + $user = User::factory()->create(); + $store = Store::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'staff']); + + expect($this->checker->publicIsOwnerAdminOrStaff($user, $store->id))->toBeTrue(); +}); + +it('isOwnerAdminOrStaff returns false for Support', function () { + $user = User::factory()->create(); + $store = Store::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'support']); + + expect($this->checker->publicIsOwnerAdminOrStaff($user, $store->id))->toBeFalse(); +}); + +it('isAnyRole returns true for any valid role', function () { + $user = User::factory()->create(); + $store = Store::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'support']); + + expect($this->checker->publicIsAnyRole($user, $store->id))->toBeTrue(); +}); + +it('isAnyRole returns false when user has no role', function () { + $user = User::factory()->create(); + $store = Store::factory()->create(); + + expect($this->checker->publicIsAnyRole($user, $store->id))->toBeFalse(); +}); diff --git a/tests/Feature/Cart/CartApiTest.php b/tests/Feature/Cart/CartApiTest.php new file mode 100644 index 00000000..ce87ee8c --- /dev/null +++ b/tests/Feature/Cart/CartApiTest.php @@ -0,0 +1,147 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->domain = $this->context['domain']; +}); + +it('creates a cart via session-based flow', function () { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 2500]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 10, + 'policy' => 'deny', + ]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + + expect($cart)->toBeInstanceOf(Cart::class) + ->and($cart->cart_version)->toBe(1); +}); + +it('retrieves a cart with lines', function () { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 2500]); + + $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' => 2500, + 'line_subtotal_amount' => 5000, + 'line_total_amount' => 5000, + ]); + + $cart->load('lines'); + expect($cart->lines)->toHaveCount(1) + ->and($cart->lines->first()->line_subtotal_amount)->toBe(5000); +}); + +it('validates variant exists on add', function () { + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + $cartService = app(\App\Services\CartService::class); + + expect(fn () => $cartService->addLine($cart, 999999, 1)) + ->toThrow(\App\Exceptions\InvalidCartException::class); +}); + +it('validates quantity is positive', function () { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 2500]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 10, + 'policy' => 'deny', + ]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + $cartService = app(\App\Services\CartService::class); + $line = $cartService->addLine($cart, $variant->id, 2); + + // Setting quantity to 0 removes the line + $cartService->updateLineQuantity($cart, $line->id, 0); + + $cart->refresh(); + expect($cart->lines)->toHaveCount(0); +}); + +it('updates line quantity via service', function () { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 2500]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 10, + 'policy' => 'deny', + ]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + $cartService = app(\App\Services\CartService::class); + $line = $cartService->addLine($cart, $variant->id, 1); + $updated = $cartService->updateLineQuantity($cart, $line->id, 5); + + expect($updated->quantity)->toBe(5) + ->and($updated->line_subtotal_amount)->toBe(12500); +}); + +it('removes a line via service', function () { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 2500]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 10, + 'policy' => 'deny', + ]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + $cartService = app(\App\Services\CartService::class); + $line = $cartService->addLine($cart, $variant->id, 1); + $cartService->removeLine($cart, $line->id); + + $cart->refresh(); + expect($cart->lines)->toHaveCount(0); +}); + +it('rejects variant from different store', function () { + $otherContext = createStoreContext(); + $otherStore = $otherContext['store']; + + $product = Product::factory()->create(['store_id' => $otherStore->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 1000]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + $cartService = app(\App\Services\CartService::class); + + expect(fn () => $cartService->addLine($cart, $variant->id, 1)) + ->toThrow(\App\Exceptions\InvalidCartException::class, 'Variant does not belong to this store.'); +}); + +it('rejects update when new quantity exceeds inventory with deny policy', function () { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 1000]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 3, + 'policy' => 'deny', + ]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + $cartService = app(\App\Services\CartService::class); + $line = $cartService->addLine($cart, $variant->id, 2); + + expect(fn () => $cartService->updateLineQuantity($cart, $line->id, 10)) + ->toThrow(\App\Exceptions\InsufficientInventoryException::class); +}); diff --git a/tests/Feature/Cart/CartServiceTest.php b/tests/Feature/Cart/CartServiceTest.php new file mode 100644 index 00000000..77f999ec --- /dev/null +++ b/tests/Feature/Cart/CartServiceTest.php @@ -0,0 +1,232 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->cartService = app(CartService::class); +}); + +it('creates a cart for the store', function () { + $cart = $this->cartService->create($this->store); + + expect($cart)->toBeInstanceOf(Cart::class) + ->and($cart->store_id)->toBe($this->store->id) + ->and($cart->currency)->toBe('USD') + ->and($cart->cart_version)->toBe(1) + ->and($cart->status)->toBe(CartStatus::Active); +}); + +it('creates a cart for an authenticated customer', function () { + $customer = Customer::factory()->create(['store_id' => $this->store->id]); + $cart = $this->cartService->create($this->store, $customer); + + expect($cart->customer_id)->toBe($customer->id); +}); + +it('creates a guest cart with null customer', function () { + $cart = $this->cartService->create($this->store); + expect($cart->customer_id)->toBeNull(); +}); + +it('adds a line item to the cart', function () { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 2500]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 10, + 'policy' => 'deny', + ]); + + $cart = $this->cartService->create($this->store); + $line = $this->cartService->addLine($cart, $variant->id, 2); + + expect($line->variant_id)->toBe($variant->id) + ->and($line->quantity)->toBe(2) + ->and($line->unit_price_amount)->toBe(2500) + ->and($line->line_subtotal_amount)->toBe(5000) + ->and($line->line_discount_amount)->toBe(0) + ->and($line->line_total_amount)->toBe(5000); +}); + +it('increments quantity when adding existing variant', function () { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 2500]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 10, + 'policy' => 'deny', + ]); + + $cart = $this->cartService->create($this->store); + $this->cartService->addLine($cart, $variant->id, 1); + $this->cartService->addLine($cart, $variant->id, 2); + + $cart->refresh(); + expect($cart->lines)->toHaveCount(1) + ->and($cart->lines->first()->quantity)->toBe(3) + ->and($cart->lines->first()->line_subtotal_amount)->toBe(7500); +}); + +it('rejects add when product is not active', function () { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'draft']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 1000]); + + $cart = $this->cartService->create($this->store); + + expect(fn () => $this->cartService->addLine($cart, $variant->id, 1)) + ->toThrow(InvalidCartException::class, 'Product is not active.'); +}); + +it('rejects add when variant is not active', function () { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'status' => 'archived']); + + $cart = $this->cartService->create($this->store); + + expect(fn () => $this->cartService->addLine($cart, $variant->id, 1)) + ->toThrow(InvalidCartException::class, 'Variant is not active.'); +}); + +it('rejects add when inventory is insufficient with deny policy', function () { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 1000]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 2, + 'policy' => 'deny', + ]); + + $cart = $this->cartService->create($this->store); + + expect(fn () => $this->cartService->addLine($cart, $variant->id, 5)) + ->toThrow(InsufficientInventoryException::class); +}); + +it('allows add when inventory is insufficient with continue policy', function () { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 1000]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 2, + 'policy' => 'continue', + ]); + + $cart = $this->cartService->create($this->store); + $line = $this->cartService->addLine($cart, $variant->id, 5); + + expect($line->quantity)->toBe(5); +}); + +it('updates line quantity', function () { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 2500]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 10, + 'policy' => 'deny', + ]); + + $cart = $this->cartService->create($this->store); + $line = $this->cartService->addLine($cart, $variant->id, 2); + $updated = $this->cartService->updateLineQuantity($cart, $line->id, 5); + + expect($updated->quantity)->toBe(5) + ->and($updated->line_subtotal_amount)->toBe(12500); +}); + +it('removes line when quantity is set to zero', function () { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 1000]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 10, + 'policy' => 'deny', + ]); + + $cart = $this->cartService->create($this->store); + $line = $this->cartService->addLine($cart, $variant->id, 2); + $this->cartService->updateLineQuantity($cart, $line->id, 0); + + $cart->refresh(); + expect($cart->lines)->toHaveCount(0); +}); + +it('rejects negative quantity in addLine', function () { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 1000]); + + $cart = $this->cartService->create($this->store); + + expect(fn () => $this->cartService->addLine($cart, $variant->id, -3)) + ->toThrow(InvalidCartException::class, 'Quantity must be greater than zero.'); +}); + +it('rejects zero quantity in addLine', function () { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 1000]); + + $cart = $this->cartService->create($this->store); + + expect(fn () => $this->cartService->addLine($cart, $variant->id, 0)) + ->toThrow(InvalidCartException::class, 'Quantity must be greater than zero.'); +}); + +it('rejects negative quantity in updateLineQuantity', function () { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 1000]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 10, + 'policy' => 'deny', + ]); + + $cart = $this->cartService->create($this->store); + $line = $this->cartService->addLine($cart, $variant->id, 2); + + expect(fn () => $this->cartService->updateLineQuantity($cart, $line->id, -1)) + ->toThrow(InvalidCartException::class, 'Quantity must not be negative.'); +}); + +it('merges guest cart into customer cart using MAX quantity', function () { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 2500]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 10, + 'policy' => 'deny', + ]); + + $guestCart = $this->cartService->create($this->store); + $this->cartService->addLine($guestCart, $variant->id, 3); + + $customer = Customer::factory()->create(['store_id' => $this->store->id]); + $customerCart = $this->cartService->create($this->store, $customer); + $this->cartService->addLine($customerCart, $variant->id, 1); + + $merged = $this->cartService->mergeOnLogin($guestCart, $customerCart); + + $merged->refresh(); + expect($merged->lines)->toHaveCount(1) + ->and($merged->lines->first()->quantity)->toBe(3); + + $guestCart->refresh(); + expect($guestCart->status)->toBe(CartStatus::Abandoned); +}); diff --git a/tests/Feature/Cart/CartVersionTest.php b/tests/Feature/Cart/CartVersionTest.php new file mode 100644 index 00000000..2984c0ba --- /dev/null +++ b/tests/Feature/Cart/CartVersionTest.php @@ -0,0 +1,92 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->cartService = app(CartService::class); +}); + +it('starts with cart version 1', function () { + $cart = $this->cartService->create($this->store); + expect($cart->cart_version)->toBe(1); +}); + +it('increments version on add line', function () { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 1000]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 10, + 'policy' => 'deny', + ]); + + $cart = $this->cartService->create($this->store); + $this->cartService->addLine($cart, $variant->id, 1); + + $cart->refresh(); + expect($cart->cart_version)->toBe(2); +}); + +it('increments version on update quantity', function () { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 1000]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 10, + 'policy' => 'deny', + ]); + + $cart = $this->cartService->create($this->store); + $line = $this->cartService->addLine($cart, $variant->id, 1); + + $this->cartService->updateLineQuantity($cart, $line->id, 3); + + $cart->refresh(); + expect($cart->cart_version)->toBe(3); +}); + +it('increments version on remove line', function () { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 1000]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 10, + 'policy' => 'deny', + ]); + + $cart = $this->cartService->create($this->store); + $line = $this->cartService->addLine($cart, $variant->id, 1); + $this->cartService->removeLine($cart, $line->id); + + $cart->refresh(); + expect($cart->cart_version)->toBe(3); +}); + +it('increments version on merge', function () { + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 1000]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 10, + 'policy' => 'deny', + ]); + + $guestCart = $this->cartService->create($this->store); + $this->cartService->addLine($guestCart, $variant->id, 2); + + $customerCart = $this->cartService->create($this->store); + $this->cartService->addLine($customerCart, $variant->id, 1); + + $merged = $this->cartService->mergeOnLogin($guestCart, $customerCart); + + expect($merged->cart_version)->toBe(3); +}); diff --git a/tests/Feature/Checkout/CheckoutFlowTest.php b/tests/Feature/Checkout/CheckoutFlowTest.php new file mode 100644 index 00000000..edaadde3 --- /dev/null +++ b/tests/Feature/Checkout/CheckoutFlowTest.php @@ -0,0 +1,165 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->checkoutService = app(CheckoutService::class); + + TaxSettings::factory()->create(['store_id' => $this->store->id, 'rate' => 1900]); + + $this->zone = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'countries_json' => ['DE'], + ]); + $this->rate = ShippingRate::factory()->create([ + 'zone_id' => $this->zone->id, + 'config_json' => ['amount' => 499], + ]); + + $this->product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $this->variant = ProductVariant::factory()->create([ + 'product_id' => $this->product->id, + 'price_amount' => 2500, + ]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $this->variant->id, + 'quantity_on_hand' => 10, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]); + + $this->cart = Cart::factory()->create(['store_id' => $this->store->id]); + CartLine::factory()->create([ + 'cart_id' => $this->cart->id, + 'variant_id' => $this->variant->id, + 'quantity' => 2, + 'unit_price_amount' => 2500, + 'line_subtotal_amount' => 5000, + 'line_total_amount' => 5000, + ]); + + $this->addressData = [ + 'email' => 'customer@example.com', + 'shipping_address' => [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'address1' => '123 Main St', + 'city' => 'Berlin', + 'country' => 'DE', + 'postal_code' => '10115', + ], + ]; +}); + +it('completes full checkout happy path', function () { + $checkout = $this->checkoutService->createFromCart($this->store, $this->cart); + expect($checkout->status)->toBe(CheckoutStatus::Started); + + $checkout = $this->checkoutService->setAddress($checkout, $this->addressData); + expect($checkout->status)->toBe(CheckoutStatus::Addressed); + + $checkout = $this->checkoutService->setShippingMethod($checkout, $this->rate->id); + expect($checkout->status)->toBe(CheckoutStatus::ShippingSelected); + + $checkout = $this->checkoutService->selectPaymentMethod($checkout, 'credit_card'); + expect($checkout->status)->toBe(CheckoutStatus::PaymentSelected); + + $this->variant->inventoryItem->refresh(); + expect($this->variant->inventoryItem->quantity_reserved)->toBe(2); + + $order = $this->checkoutService->completeCheckout($checkout, ['card_number' => '4242424242424242']); + expect($order)->toBeInstanceOf(\App\Models\Order::class) + ->and($order->status)->toBe(\App\Enums\OrderStatus::Paid); + + $checkout->refresh(); + expect($checkout->status)->toBe(CheckoutStatus::Completed); + + $this->cart->refresh(); + expect($this->cart->status)->toBe(CartStatus::Converted); + + $this->variant->inventoryItem->refresh(); + expect($this->variant->inventoryItem->quantity_on_hand)->toBe(8) + ->and($this->variant->inventoryItem->quantity_reserved)->toBe(0); +}); + +it('completes checkout with bank transfer keeping inventory reserved', function () { + $checkout = $this->checkoutService->createFromCart($this->store, $this->cart); + $checkout = $this->checkoutService->setAddress($checkout, $this->addressData); + $checkout = $this->checkoutService->setShippingMethod($checkout, $this->rate->id); + $checkout = $this->checkoutService->selectPaymentMethod($checkout, 'bank_transfer'); + $order = $this->checkoutService->completeCheckout($checkout); + + expect($order->status)->toBe(\App\Enums\OrderStatus::Pending); + + $checkout->refresh(); + expect($checkout->status)->toBe(CheckoutStatus::Completed); + + $this->variant->inventoryItem->refresh(); + // bank_transfer keeps inventory reserved, not committed + expect($this->variant->inventoryItem->quantity_on_hand)->toBe(10) + ->and($this->variant->inventoryItem->quantity_reserved)->toBe(2); +}); + +it('prevents duplicate orders (idempotency)', function () { + $checkout = $this->checkoutService->createFromCart($this->store, $this->cart); + $checkout = $this->checkoutService->setAddress($checkout, $this->addressData); + $checkout = $this->checkoutService->setShippingMethod($checkout, $this->rate->id); + $checkout = $this->checkoutService->selectPaymentMethod($checkout, 'credit_card'); + $order1 = $this->checkoutService->completeCheckout($checkout, ['card_number' => '4242424242424242']); + + // Calling again on the now-completed checkout should return the same order + $checkout->refresh(); + $order2 = $this->checkoutService->completeCheckout($checkout); + + expect($order2->id)->toBe($order1->id); +}); + +it('applies discount in checkout flow', function () { + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'SAVE10', + 'value_type' => 'percent', + 'value_amount' => 10, + ]); + + $checkout = $this->checkoutService->createFromCart($this->store, $this->cart); + $checkout = $this->checkoutService->setAddress($checkout, $this->addressData); + $checkout = $this->checkoutService->applyDiscount($checkout, 'SAVE10'); + + expect($checkout->discount_code)->toBe('SAVE10') + ->and($checkout->totals_json['discount'])->toBe(500); +}); + +it('removes discount when cleared', function () { + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'SAVE10', + 'value_type' => 'percent', + 'value_amount' => 10, + ]); + + $checkout = $this->checkoutService->createFromCart($this->store, $this->cart); + $checkout = $this->checkoutService->setAddress($checkout, $this->addressData); + $checkout = $this->checkoutService->applyDiscount($checkout, 'SAVE10'); + + expect($checkout->totals_json['discount'])->toBe(500); + + $checkout = $this->checkoutService->removeDiscount($checkout); + + expect($checkout->discount_code)->toBeNull() + ->and($checkout->totals_json['discount'])->toBe(0); +}); diff --git a/tests/Feature/Checkout/CheckoutStateTest.php b/tests/Feature/Checkout/CheckoutStateTest.php new file mode 100644 index 00000000..14e81a4f --- /dev/null +++ b/tests/Feature/Checkout/CheckoutStateTest.php @@ -0,0 +1,225 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->checkoutService = app(CheckoutService::class); + + TaxSettings::factory()->create(['store_id' => $this->store->id, 'rate' => 1900]); + + $this->product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $this->variant = ProductVariant::factory()->create([ + 'product_id' => $this->product->id, + 'price_amount' => 2500, + ]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $this->variant->id, + 'quantity_on_hand' => 10, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]); + + $this->cart = Cart::factory()->create(['store_id' => $this->store->id]); + CartLine::factory()->create([ + 'cart_id' => $this->cart->id, + 'variant_id' => $this->variant->id, + 'quantity' => 2, + 'unit_price_amount' => 2500, + 'line_subtotal_amount' => 5000, + 'line_total_amount' => 5000, + ]); +}); + +it('creates checkout from cart', function () { + $checkout = $this->checkoutService->createFromCart($this->store, $this->cart); + + expect($checkout->status)->toBe(CheckoutStatus::Started) + ->and($checkout->store_id)->toBe($this->store->id) + ->and($checkout->cart_id)->toBe($this->cart->id); +}); + +it('rejects checkout from empty cart', function () { + $emptyCart = Cart::factory()->create(['store_id' => $this->store->id]); + + expect(fn () => $this->checkoutService->createFromCart($this->store, $emptyCart)) + ->toThrow(\App\Exceptions\InvalidCartException::class); +}); + +it('transitions from started to addressed', function () { + $checkout = $this->checkoutService->createFromCart($this->store, $this->cart); + + $checkout = $this->checkoutService->setAddress($checkout, [ + 'email' => 'test@example.com', + 'shipping_address' => [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'address1' => '123 Main St', + 'city' => 'Berlin', + 'country' => 'DE', + 'postal_code' => '10115', + ], + ]); + + expect($checkout->status)->toBe(CheckoutStatus::Addressed) + ->and($checkout->email)->toBe('test@example.com') + ->and($checkout->shipping_address_json)->toBeArray() + ->and($checkout->billing_address_json)->toBeArray() + ->and($checkout->totals_json)->not->toBeNull(); +}); + +it('transitions from addressed to shipping_selected', function () { + $zone = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'countries_json' => ['DE'], + ]); + $rate = ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'config_json' => ['amount' => 499], + ]); + + $checkout = $this->checkoutService->createFromCart($this->store, $this->cart); + $checkout = $this->checkoutService->setAddress($checkout, [ + 'email' => 'test@example.com', + 'shipping_address' => [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'address1' => '123 Main St', + 'city' => 'Berlin', + 'country' => 'DE', + 'postal_code' => '10115', + ], + ]); + + $checkout = $this->checkoutService->setShippingMethod($checkout, $rate->id); + + expect($checkout->status)->toBe(CheckoutStatus::ShippingSelected) + ->and($checkout->shipping_method_id)->toBe($rate->id); +}); + +it('transitions from shipping_selected to payment_selected and reserves inventory', function () { + $zone = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'countries_json' => ['DE'], + ]); + $rate = ShippingRate::factory()->create(['zone_id' => $zone->id]); + + $checkout = $this->checkoutService->createFromCart($this->store, $this->cart); + $checkout = $this->checkoutService->setAddress($checkout, [ + 'email' => 'test@example.com', + 'shipping_address' => [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'address1' => '123 Main St', + 'city' => 'Berlin', + 'country' => 'DE', + 'postal_code' => '10115', + ], + ]); + $checkout = $this->checkoutService->setShippingMethod($checkout, $rate->id); + $checkout = $this->checkoutService->selectPaymentMethod($checkout, 'credit_card'); + + expect($checkout->status)->toBe(CheckoutStatus::PaymentSelected) + ->and($checkout->expires_at)->not->toBeNull(); + + $this->variant->inventoryItem->refresh(); + expect($this->variant->inventoryItem->quantity_reserved)->toBe(2); +}); + +it('rejects invalid payment method', function () { + $zone = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'countries_json' => ['DE'], + ]); + $rate = ShippingRate::factory()->create(['zone_id' => $zone->id]); + + $checkout = $this->checkoutService->createFromCart($this->store, $this->cart); + $checkout = $this->checkoutService->setAddress($checkout, [ + 'email' => 'test@example.com', + 'shipping_address' => [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'address1' => '123 Main St', + 'city' => 'Berlin', + 'country' => 'DE', + 'postal_code' => '10115', + ], + ]); + $checkout = $this->checkoutService->setShippingMethod($checkout, $rate->id); + + expect(fn () => $this->checkoutService->selectPaymentMethod($checkout, 'bitcoin')) + ->toThrow(InvalidCheckoutTransitionException::class); +}); + +it('rejects invalid state transitions', function () { + $checkout = $this->checkoutService->createFromCart($this->store, $this->cart); + + // Cannot go from started to completed + expect(fn () => $this->checkoutService->completeCheckout($checkout)) + ->toThrow(InvalidCheckoutTransitionException::class); +}); + +it('rejects skipping shipping step', function () { + $checkout = $this->checkoutService->createFromCart($this->store, $this->cart); + + // Cannot go from started to shipping + expect(fn () => $this->checkoutService->setShippingMethod($checkout, 1)) + ->toThrow(InvalidCheckoutTransitionException::class); +}); + +it('expires checkout and releases inventory', function () { + $zone = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'countries_json' => ['DE'], + ]); + $rate = ShippingRate::factory()->create(['zone_id' => $zone->id]); + + $checkout = $this->checkoutService->createFromCart($this->store, $this->cart); + $checkout = $this->checkoutService->setAddress($checkout, [ + 'email' => 'test@example.com', + 'shipping_address' => [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'address1' => '123 Main St', + 'city' => 'Berlin', + 'country' => 'DE', + 'postal_code' => '10115', + ], + ]); + $checkout = $this->checkoutService->setShippingMethod($checkout, $rate->id); + $checkout = $this->checkoutService->selectPaymentMethod($checkout, 'credit_card'); + + $this->variant->inventoryItem->refresh(); + expect($this->variant->inventoryItem->quantity_reserved)->toBe(2); + + $checkout = $this->checkoutService->expireCheckout($checkout); + + expect($checkout->status)->toBe(CheckoutStatus::Expired); + + $this->variant->inventoryItem->refresh(); + expect($this->variant->inventoryItem->quantity_reserved)->toBe(0); +}); + +it('does not expire completed checkouts', function () { + $checkout = Checkout::factory()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $this->cart->id, + 'status' => 'completed', + ]); + + $result = $this->checkoutService->expireCheckout($checkout); + expect($result->status)->toBe(CheckoutStatus::Completed); +}); diff --git a/tests/Feature/Checkout/DiscountCalculatorTest.php b/tests/Feature/Checkout/DiscountCalculatorTest.php new file mode 100644 index 00000000..9a7bf6e5 --- /dev/null +++ b/tests/Feature/Checkout/DiscountCalculatorTest.php @@ -0,0 +1,261 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->discountService = app(DiscountService::class); +}); + +it('validates a valid percent discount code', function () { + $discount = Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'SAVE10', + 'value_type' => 'percent', + 'value_amount' => 10, + ]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 5000]); + CartLine::factory()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => 2, + 'unit_price_amount' => 5000, + 'line_subtotal_amount' => 10000, + 'line_total_amount' => 10000, + ]); + + $result = $this->discountService->validate('SAVE10', $this->store, $cart); + expect($result->id)->toBe($discount->id); +}); + +it('validates case-insensitively', function () { + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'SAVE10', + ]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + CartLine::factory()->create(['cart_id' => $cart->id, 'variant_id' => $variant->id, 'line_subtotal_amount' => 5000]); + + $result = $this->discountService->validate('save10', $this->store, $cart); + expect($result->code)->toBe('SAVE10'); +}); + +it('rejects nonexistent code', function () { + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + + expect(fn () => $this->discountService->validate('NOSUCHCODE', $this->store, $cart)) + ->toThrow(InvalidDiscountException::class); +}); + +it('rejects disabled discount', function () { + Discount::factory()->disabled()->create([ + 'store_id' => $this->store->id, + 'code' => 'DISABLED1', + ]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + + try { + $this->discountService->validate('DISABLED1', $this->store, $cart); + $this->fail('Expected InvalidDiscountException'); + } catch (InvalidDiscountException $e) { + expect($e->reason)->toBe('discount_expired'); + } +}); + +it('rejects discount not yet active', function () { + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'FUTURE10', + 'starts_at' => now()->addMonth()->toIso8601String(), + ]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + + try { + $this->discountService->validate('FUTURE10', $this->store, $cart); + $this->fail('Expected InvalidDiscountException'); + } catch (InvalidDiscountException $e) { + expect($e->reason)->toBe('discount_not_yet_active'); + } +}); + +it('rejects expired discount', function () { + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'OLD10', + 'starts_at' => now()->subMonth()->toIso8601String(), + 'ends_at' => now()->subDay()->toIso8601String(), + ]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + + try { + $this->discountService->validate('OLD10', $this->store, $cart); + $this->fail('Expected InvalidDiscountException'); + } catch (InvalidDiscountException $e) { + expect($e->reason)->toBe('discount_expired'); + } +}); + +it('rejects discount with usage limit reached', function () { + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'LIMITED', + 'usage_limit' => 5, + 'usage_count' => 5, + ]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + + try { + $this->discountService->validate('LIMITED', $this->store, $cart); + $this->fail('Expected InvalidDiscountException'); + } catch (InvalidDiscountException $e) { + expect($e->reason)->toBe('discount_usage_limit_reached'); + } +}); + +it('rejects discount when minimum purchase not met', function () { + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'MIN50', + 'minimum_purchase_amount' => 20000, + ]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + CartLine::factory()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'line_subtotal_amount' => 10000, + ]); + + try { + $this->discountService->validate('MIN50', $this->store, $cart); + $this->fail('Expected InvalidDiscountException'); + } catch (InvalidDiscountException $e) { + expect($e->reason)->toBe('discount_min_purchase_not_met'); + } +}); + +it('calculates percent discount correctly', function () { + $discount = Discount::factory()->create([ + 'store_id' => $this->store->id, + 'value_type' => 'percent', + 'value_amount' => 10, + ]); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + $line = CartLine::factory()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'line_subtotal_amount' => 10000, + ]); + + $result = $this->discountService->calculate($discount, 10000, collect([$line])); + expect($result->totalDiscount)->toBe(1000); +}); + +it('calculates fixed discount correctly', function () { + $discount = Discount::factory()->fixed(500)->create([ + 'store_id' => $this->store->id, + ]); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + $line = CartLine::factory()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'line_subtotal_amount' => 10000, + ]); + + $result = $this->discountService->calculate($discount, 10000, collect([$line])); + expect($result->totalDiscount)->toBe(500); +}); + +it('caps fixed discount at qualifying subtotal', function () { + $discount = Discount::factory()->fixed(15000)->create([ + 'store_id' => $this->store->id, + ]); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + $line = CartLine::factory()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'line_subtotal_amount' => 10000, + ]); + + $result = $this->discountService->calculate($discount, 10000, collect([$line])); + expect($result->totalDiscount)->toBe(10000); +}); + +it('free shipping discount does not reduce item amounts', function () { + $discount = Discount::factory()->freeShipping()->create([ + 'store_id' => $this->store->id, + ]); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + + $line = CartLine::factory()->create([ + 'variant_id' => $variant->id, + 'line_subtotal_amount' => 10000, + ]); + + $result = $this->discountService->calculate($discount, 10000, collect([$line])); + expect($result->totalDiscount)->toBe(0) + ->and($result->isFreeShipping)->toBeTrue(); +}); + +it('allocates discount proportionally across lines', function () { + $discount = Discount::factory()->create([ + 'store_id' => $this->store->id, + 'value_type' => 'percent', + 'value_amount' => 10, + ]); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant1 = ProductVariant::factory()->create(['product_id' => $product->id]); + $variant2 = ProductVariant::factory()->create(['product_id' => $product->id]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + $lineA = CartLine::factory()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant1->id, + 'line_subtotal_amount' => 6000, + ]); + $lineB = CartLine::factory()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant2->id, + 'line_subtotal_amount' => 4000, + ]); + + $result = $this->discountService->calculate($discount, 10000, collect([$lineA, $lineB])); + + expect($result->totalDiscount)->toBe(1000) + ->and($result->lineAllocations[$lineA->id])->toBe(600) + ->and($result->lineAllocations[$lineB->id])->toBe(400); +}); diff --git a/tests/Feature/Checkout/DiscountTest.php b/tests/Feature/Checkout/DiscountTest.php new file mode 100644 index 00000000..c7de4b58 --- /dev/null +++ b/tests/Feature/Checkout/DiscountTest.php @@ -0,0 +1,176 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->checkoutService = app(CheckoutService::class); + + TaxSettings::factory()->create(['store_id' => $this->store->id, 'rate' => 0, 'is_active' => false]); + + $this->product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $this->variant = ProductVariant::factory()->create([ + 'product_id' => $this->product->id, + 'price_amount' => 5000, + ]); + + $this->cart = Cart::factory()->create(['store_id' => $this->store->id]); + CartLine::factory()->create([ + 'cart_id' => $this->cart->id, + 'variant_id' => $this->variant->id, + 'quantity' => 2, + 'unit_price_amount' => 5000, + 'line_subtotal_amount' => 10000, + 'line_total_amount' => 10000, + ]); + + $this->zone = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'countries_json' => ['DE'], + ]); + $this->rate = ShippingRate::factory()->create([ + 'zone_id' => $this->zone->id, + 'config_json' => ['amount' => 499], + ]); + + $this->addressData = [ + 'email' => 'customer@example.com', + 'shipping_address' => [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'address1' => '123 Main St', + 'city' => 'Berlin', + 'country' => 'DE', + 'postal_code' => '10115', + ], + ]; +}); + +it('applies percent discount to checkout totals', function () { + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'SAVE10', + 'value_type' => 'percent', + 'value_amount' => 10, + ]); + + $checkout = $this->checkoutService->createFromCart($this->store, $this->cart); + $checkout = $this->checkoutService->setAddress($checkout, $this->addressData); + $checkout = $this->checkoutService->applyDiscount($checkout, 'SAVE10'); + + expect($checkout->discount_code)->toBe('SAVE10') + ->and($checkout->totals_json['discount'])->toBe(1000) + ->and($checkout->totals_json['subtotal'])->toBe(10000); +}); + +it('applies fixed discount to checkout totals', function () { + Discount::factory()->fixed(500)->create([ + 'store_id' => $this->store->id, + 'code' => 'FLAT5', + ]); + + $checkout = $this->checkoutService->createFromCart($this->store, $this->cart); + $checkout = $this->checkoutService->setAddress($checkout, $this->addressData); + $checkout = $this->checkoutService->applyDiscount($checkout, 'FLAT5'); + + expect($checkout->totals_json['discount'])->toBe(500); +}); + +it('replaces existing discount when new one is applied', function () { + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'SAVE10', + 'value_type' => 'percent', + 'value_amount' => 10, + ]); + + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'SAVE20', + 'value_type' => 'percent', + 'value_amount' => 20, + ]); + + $checkout = $this->checkoutService->createFromCart($this->store, $this->cart); + $checkout = $this->checkoutService->setAddress($checkout, $this->addressData); + + $checkout = $this->checkoutService->applyDiscount($checkout, 'SAVE10'); + expect($checkout->totals_json['discount'])->toBe(1000); + + $checkout = $this->checkoutService->applyDiscount($checkout, 'SAVE20'); + expect($checkout->discount_code)->toBe('SAVE20') + ->and($checkout->totals_json['discount'])->toBe(2000); +}); + +it('removes discount and recalculates totals', function () { + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'SAVE10', + 'value_type' => 'percent', + 'value_amount' => 10, + ]); + + $checkout = $this->checkoutService->createFromCart($this->store, $this->cart); + $checkout = $this->checkoutService->setAddress($checkout, $this->addressData); + $checkout = $this->checkoutService->applyDiscount($checkout, 'SAVE10'); + + expect($checkout->totals_json['discount'])->toBe(1000); + + $checkout = $this->checkoutService->removeDiscount($checkout); + + expect($checkout->discount_code)->toBeNull() + ->and($checkout->totals_json['discount'])->toBe(0); +}); + +it('rejects invalid discount code with exception', function () { + $checkout = $this->checkoutService->createFromCart($this->store, $this->cart); + $checkout = $this->checkoutService->setAddress($checkout, $this->addressData); + + expect(fn () => $this->checkoutService->applyDiscount($checkout, 'INVALID_CODE')) + ->toThrow(\App\Exceptions\InvalidDiscountException::class); + + // Discount code should not be stored + $checkout->refresh(); + expect($checkout->discount_code)->toBeNull(); +}); + +it('increments discount usage count on checkout completion', function () { + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'TRACK', + 'value_type' => 'percent', + 'value_amount' => 5, + 'usage_count' => 0, + ]); + + \App\Models\InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $this->variant->id, + 'quantity_on_hand' => 10, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]); + + $checkout = $this->checkoutService->createFromCart($this->store, $this->cart); + $checkout = $this->checkoutService->setAddress($checkout, $this->addressData); + $checkout = $this->checkoutService->applyDiscount($checkout, 'TRACK'); + $checkout = $this->checkoutService->setShippingMethod($checkout, $this->rate->id); + $checkout = $this->checkoutService->selectPaymentMethod($checkout, 'credit_card'); + $checkout = $this->checkoutService->completeCheckout($checkout); + + $discount = Discount::withoutGlobalScopes() + ->where('store_id', $this->store->id) + ->where('code', 'TRACK') + ->first(); + + expect($discount->usage_count)->toBe(1); +}); diff --git a/tests/Feature/Checkout/PricingEngineTest.php b/tests/Feature/Checkout/PricingEngineTest.php new file mode 100644 index 00000000..9a68c9fd --- /dev/null +++ b/tests/Feature/Checkout/PricingEngineTest.php @@ -0,0 +1,265 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->pricingEngine = app(PricingEngine::class); +}); + +it('calculates correct totals for a simple checkout', function () { + TaxSettings::factory()->create([ + 'store_id' => $this->store->id, + 'rate' => 1900, + 'prices_include_tax' => false, + ]); + + $zone = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'countries_json' => ['DE'], + ]); + + $rate = ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'config_json' => ['amount' => 499], + ]); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 2500]); + + $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' => 2500, + 'line_subtotal_amount' => 5000, + 'line_total_amount' => 5000, + ]); + + $checkout = Checkout::factory()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $cart->id, + 'status' => 'addressed', + 'shipping_method_id' => $rate->id, + 'shipping_address_json' => ['country' => 'DE'], + ]); + + $result = $this->pricingEngine->calculate($checkout); + + expect($result->subtotal)->toBe(5000) + ->and($result->discount)->toBe(0) + ->and($result->shipping)->toBe(499) + ->and($result->taxTotal)->toBe(1045) + ->and($result->total)->toBe(6544); +}); + +it('applies discount code and recalculates', function () { + TaxSettings::factory()->create([ + 'store_id' => $this->store->id, + 'rate' => 0, + 'is_active' => false, + ]); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 5000]); + + $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' => 5000, + 'line_subtotal_amount' => 10000, + 'line_total_amount' => 10000, + ]); + + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'SAVE10', + 'value_type' => 'percent', + 'value_amount' => 10, + ]); + + $checkout = Checkout::factory()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $cart->id, + 'status' => 'addressed', + 'discount_code' => 'SAVE10', + ]); + + $result = $this->pricingEngine->calculate($checkout); + + expect($result->subtotal)->toBe(10000) + ->and($result->discount)->toBe(1000); +}); + +it('stores pricing snapshot in totals_json', function () { + TaxSettings::factory()->create(['store_id' => $this->store->id, 'rate' => 1900]); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + CartLine::factory()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => 1, + 'unit_price_amount' => 1000, + 'line_subtotal_amount' => 1000, + 'line_total_amount' => 1000, + ]); + + $checkout = Checkout::factory()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $cart->id, + 'status' => 'started', + ]); + + $result = $this->pricingEngine->calculate($checkout); + $array = $result->toArray(); + + expect($array)->toHaveKeys(['subtotal', 'discount', 'shipping', 'tax_lines', 'tax_total', 'total', 'currency']); +}); + +it('handles prices-include-tax correctly', function () { + TaxSettings::factory()->inclusive()->create([ + 'store_id' => $this->store->id, + 'rate' => 1900, + ]); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 11900]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + CartLine::factory()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => 1, + 'unit_price_amount' => 11900, + 'line_subtotal_amount' => 11900, + 'line_total_amount' => 11900, + ]); + + $checkout = Checkout::factory()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $cart->id, + 'status' => 'started', + ]); + + $result = $this->pricingEngine->calculate($checkout); + + expect($result->taxTotal)->toBe(1900) + ->and($result->total)->toBe(11900); +}); + +it('discount is applied before tax', function () { + TaxSettings::factory()->create([ + 'store_id' => $this->store->id, + 'rate' => 1900, + 'prices_include_tax' => false, + ]); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + CartLine::factory()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => 1, + 'unit_price_amount' => 10000, + 'line_subtotal_amount' => 10000, + 'line_total_amount' => 10000, + ]); + + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'TEN', + 'value_type' => 'percent', + 'value_amount' => 10, + ]); + + $checkout = Checkout::factory()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $cart->id, + 'status' => 'addressed', + 'discount_code' => 'TEN', + ]); + + $result = $this->pricingEngine->calculate($checkout); + + // Subtotal: 10000, discount: 1000, discounted: 9000, tax on 9000 = 1710, total = 10710 + expect($result->discount)->toBe(1000) + ->and($result->taxTotal)->toBe(1710); +}); + +it('same inputs produce same outputs (determinism)', function () { + TaxSettings::factory()->create(['store_id' => $this->store->id, 'rate' => 1900]); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + + $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' => 3000, + 'line_subtotal_amount' => 6000, + 'line_total_amount' => 6000, + ]); + + $checkout = Checkout::factory()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $cart->id, + 'status' => 'started', + ]); + + $result1 = $this->pricingEngine->calculate($checkout); + $result2 = $this->pricingEngine->calculate($checkout); + + expect($result1->toArray())->toBe($result2->toArray()); +}); + +it('PricingResult contains all required fields', function () { + TaxSettings::factory()->create(['store_id' => $this->store->id, 'rate' => 1900]); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + CartLine::factory()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => 1, + 'unit_price_amount' => 1000, + 'line_subtotal_amount' => 1000, + 'line_total_amount' => 1000, + ]); + + $checkout = Checkout::factory()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $cart->id, + ]); + + $result = $this->pricingEngine->calculate($checkout); + + expect($result->subtotal)->toBeInt() + ->and($result->discount)->toBeInt() + ->and($result->shipping)->toBeInt() + ->and($result->taxLines)->toBeArray() + ->and($result->taxTotal)->toBeInt() + ->and($result->total)->toBeInt() + ->and($result->currency)->toBeString(); +}); diff --git a/tests/Feature/Checkout/PricingIntegrationTest.php b/tests/Feature/Checkout/PricingIntegrationTest.php new file mode 100644 index 00000000..7e18edea --- /dev/null +++ b/tests/Feature/Checkout/PricingIntegrationTest.php @@ -0,0 +1,237 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->pricingEngine = app(PricingEngine::class); +}); + +it('calculates full pricing pipeline correctly', function () { + TaxSettings::factory()->create([ + 'store_id' => $this->store->id, + 'rate' => 1900, + 'prices_include_tax' => false, + ]); + + $zone = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'countries_json' => ['DE'], + ]); + $rate = ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'config_json' => ['amount' => 499], + ]); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 5000]); + + $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' => 5000, + 'line_subtotal_amount' => 10000, + 'line_total_amount' => 10000, + ]); + + Discount::factory()->create([ + 'store_id' => $this->store->id, + 'code' => 'TEN', + 'value_type' => 'percent', + 'value_amount' => 10, + ]); + + $checkout = Checkout::factory()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $cart->id, + 'status' => 'addressed', + 'discount_code' => 'TEN', + 'shipping_method_id' => $rate->id, + 'shipping_address_json' => ['country' => 'DE'], + ]); + + $result = $this->pricingEngine->calculate($checkout); + + // Subtotal: 10000, Discount: 1000, Discounted: 9000, Shipping: 499, Tax on 9499: 1805, Total: 11304 + expect($result->subtotal)->toBe(10000) + ->and($result->discount)->toBe(1000) + ->and($result->shipping)->toBe(499) + ->and($result->taxTotal)->toBe(1805) + ->and($result->total)->toBe(11304); +}); + +it('handles free shipping discount in pricing', function () { + TaxSettings::factory()->create([ + 'store_id' => $this->store->id, + 'rate' => 0, + 'is_active' => false, + ]); + + $zone = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'countries_json' => ['DE'], + ]); + $rate = ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'config_json' => ['amount' => 499], + ]); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 5000]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + CartLine::factory()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => 1, + 'unit_price_amount' => 5000, + 'line_subtotal_amount' => 5000, + 'line_total_amount' => 5000, + ]); + + Discount::factory()->freeShipping()->create([ + 'store_id' => $this->store->id, + 'code' => 'FREESHIP', + ]); + + $checkout = Checkout::factory()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $cart->id, + 'status' => 'addressed', + 'discount_code' => 'FREESHIP', + 'shipping_method_id' => $rate->id, + 'shipping_address_json' => ['country' => 'DE'], + ]); + + $result = $this->pricingEngine->calculate($checkout); + + expect($result->shipping)->toBe(0) + ->and($result->discount)->toBe(0); +}); + +it('recalculates when shipping method changes', function () { + TaxSettings::factory()->create([ + 'store_id' => $this->store->id, + 'rate' => 1900, + 'prices_include_tax' => false, + ]); + + $zone = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'countries_json' => ['DE'], + ]); + $rate1 = ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'name' => 'Standard', + 'config_json' => ['amount' => 499], + ]); + $rate2 = ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'name' => 'Express', + 'config_json' => ['amount' => 899], + ]); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + CartLine::factory()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => 1, + 'unit_price_amount' => 1000, + 'line_subtotal_amount' => 1000, + 'line_total_amount' => 1000, + ]); + + $checkout = Checkout::factory()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $cart->id, + 'status' => 'addressed', + 'shipping_method_id' => $rate1->id, + 'shipping_address_json' => ['country' => 'DE'], + ]); + + $result1 = $this->pricingEngine->calculate($checkout); + + $checkout->update(['shipping_method_id' => $rate2->id]); + $result2 = $this->pricingEngine->calculate($checkout->fresh()); + + expect($result1->shipping)->toBe(499) + ->and($result2->shipping)->toBe(899); +}); + +it('calculates without discount code', function () { + TaxSettings::factory()->create([ + 'store_id' => $this->store->id, + 'rate' => 0, + 'is_active' => false, + ]); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + CartLine::factory()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => 1, + 'unit_price_amount' => 5000, + 'line_subtotal_amount' => 5000, + 'line_total_amount' => 5000, + ]); + + $checkout = Checkout::factory()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $cart->id, + ]); + + $result = $this->pricingEngine->calculate($checkout); + + expect($result->subtotal)->toBe(5000) + ->and($result->discount)->toBe(0) + ->and($result->total)->toBe(5000); +}); + +it('handles tax-inclusive pricing in full pipeline', function () { + TaxSettings::factory()->inclusive()->create([ + 'store_id' => $this->store->id, + 'rate' => 1900, + ]); + + $product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => 11900]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + CartLine::factory()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => 1, + 'unit_price_amount' => 11900, + 'line_subtotal_amount' => 11900, + 'line_total_amount' => 11900, + ]); + + $checkout = Checkout::factory()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $cart->id, + ]); + + $result = $this->pricingEngine->calculate($checkout); + + expect($result->subtotal)->toBe(11900) + ->and($result->taxTotal)->toBe(1900) + ->and($result->total)->toBe(11900); +}); diff --git a/tests/Feature/Checkout/ShippingCalculatorTest.php b/tests/Feature/Checkout/ShippingCalculatorTest.php new file mode 100644 index 00000000..71efdda6 --- /dev/null +++ b/tests/Feature/Checkout/ShippingCalculatorTest.php @@ -0,0 +1,202 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->calculator = app(ShippingCalculator::class); +}); + +it('returns available rates for matching address', function () { + $zone = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'countries_json' => ['DE'], + ]); + + ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'name' => 'Standard', + 'config_json' => ['amount' => 499], + ]); + + $rates = $this->calculator->getAvailableRates($this->store, ['country' => 'DE']); + + expect($rates)->toHaveCount(1) + ->and($rates->first()->name)->toBe('Standard'); +}); + +it('returns empty when no zone matches', function () { + ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'countries_json' => ['DE'], + ]); + + $rates = $this->calculator->getAvailableRates($this->store, ['country' => 'FR']); + expect($rates)->toBeEmpty(); +}); + +it('prefers more specific zone match', function () { + $broadZone = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'name' => 'Germany', + 'countries_json' => ['DE'], + 'regions_json' => [], + ]); + + $specificZone = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'name' => 'Bavaria', + 'countries_json' => ['DE'], + 'regions_json' => ['DE-BY'], + ]); + + ShippingRate::factory()->create(['zone_id' => $broadZone->id, 'name' => 'Standard']); + ShippingRate::factory()->create(['zone_id' => $specificZone->id, 'name' => 'Bavaria Express']); + + $zone = $this->calculator->getMatchingZone($this->store, ['country' => 'DE', 'province_code' => 'DE-BY']); + + expect($zone->id)->toBe($specificZone->id); +}); + +it('calculates flat rate correctly', function () { + $rate = new ShippingRate([ + 'type' => 'flat', + 'config_json' => ['amount' => 499], + ]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + $result = $this->calculator->calculate($rate, $cart); + + expect($result)->toBe(499); +}); + +it('calculates weight-based rate correctly', function () { + $product = \App\Models\Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'weight_g' => 250, + 'requires_shipping' => true, + ]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + CartLine::factory()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => 3, + ]); + + $rate = new ShippingRate([ + 'type' => 'weight', + 'config_json' => [ + 'ranges' => [ + ['min_g' => 0, 'max_g' => 500, 'amount' => 499], + ['min_g' => 501, 'max_g' => 2000, 'amount' => 899], + ], + ], + ]); + + // 250g * 3 = 750g, falls in 501-2000 range + $result = $this->calculator->calculate($rate, $cart); + expect($result)->toBe(899); +}); + +it('returns null for weight exceeding all ranges', function () { + $product = \App\Models\Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'weight_g' => 1000, + 'requires_shipping' => true, + ]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + CartLine::factory()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => 5, + ]); + + $rate = new ShippingRate([ + 'type' => 'weight', + 'config_json' => [ + 'ranges' => [ + ['min_g' => 0, 'max_g' => 2000, 'amount' => 899], + ], + ], + ]); + + // 1000g * 5 = 5000g, exceeds max 2000g + $result = $this->calculator->calculate($rate, $cart); + expect($result)->toBeNull(); +}); + +it('calculates price-based rate correctly', function () { + $product = \App\Models\Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + 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, + ]); + + $rate = new ShippingRate([ + 'type' => 'price', + 'config_json' => [ + 'ranges' => [ + ['min_amount' => 0, 'max_amount' => 5000, 'amount' => 799], + ['min_amount' => 5001, 'amount' => 0], + ], + ], + ]); + + $result = $this->calculator->calculate($rate, $cart); + expect($result)->toBe(0); +}); + +it('excludes digital items from weight calculation', function () { + $product = \App\Models\Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $physical = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'weight_g' => 300, + 'requires_shipping' => true, + ]); + $digital = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'weight_g' => 500, + 'requires_shipping' => false, + ]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id]); + CartLine::factory()->create(['cart_id' => $cart->id, 'variant_id' => $physical->id, 'quantity' => 1]); + CartLine::factory()->create(['cart_id' => $cart->id, 'variant_id' => $digital->id, 'quantity' => 1]); + + $weight = $this->calculator->getTotalShippingWeight($cart); + expect($weight)->toBe(300); +}); + +it('breaks ties by lowest zone ID', function () { + $zone1 = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'name' => 'Zone A', + 'countries_json' => ['US'], + ]); + + $zone2 = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'name' => 'Zone B', + 'countries_json' => ['US'], + ]); + + $matched = $this->calculator->getMatchingZone($this->store, ['country' => 'US']); + expect($matched->id)->toBe($zone1->id); +}); diff --git a/tests/Feature/Checkout/ShippingTest.php b/tests/Feature/Checkout/ShippingTest.php new file mode 100644 index 00000000..c11e6afd --- /dev/null +++ b/tests/Feature/Checkout/ShippingTest.php @@ -0,0 +1,168 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->checkoutService = app(CheckoutService::class); + $this->pricingEngine = app(PricingEngine::class); + + TaxSettings::factory()->create(['store_id' => $this->store->id, 'rate' => 0, 'is_active' => false]); + + $this->product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $this->variant = ProductVariant::factory()->create([ + 'product_id' => $this->product->id, + 'price_amount' => 5000, + 'requires_shipping' => true, + ]); + + $this->cart = Cart::factory()->create(['store_id' => $this->store->id]); + CartLine::factory()->create([ + 'cart_id' => $this->cart->id, + 'variant_id' => $this->variant->id, + 'quantity' => 1, + 'unit_price_amount' => 5000, + 'line_subtotal_amount' => 5000, + 'line_total_amount' => 5000, + ]); +}); + +it('includes flat shipping in checkout totals', function () { + $zone = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'countries_json' => ['DE'], + ]); + $rate = ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'config_json' => ['amount' => 499], + ]); + + $checkout = Checkout::factory()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $this->cart->id, + 'status' => 'addressed', + 'shipping_method_id' => $rate->id, + 'shipping_address_json' => ['country' => 'DE'], + ]); + + $result = $this->pricingEngine->calculate($checkout); + + expect($result->shipping)->toBe(499) + ->and($result->total)->toBe(5499); +}); + +it('returns zero shipping when no shipping method selected', function () { + $checkout = Checkout::factory()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $this->cart->id, + 'status' => 'addressed', + 'shipping_method_id' => null, + ]); + + $result = $this->pricingEngine->calculate($checkout); + + expect($result->shipping)->toBe(0); +}); + +it('validates shipping rate belongs to matching zone', function () { + $zone = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'countries_json' => ['US'], + ]); + $rate = ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'config_json' => ['amount' => 999], + ]); + + $checkout = $this->checkoutService->createFromCart($this->store, $this->cart); + $checkout = $this->checkoutService->setAddress($checkout, [ + 'email' => 'test@example.com', + 'shipping_address' => [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'address1' => '123 Main St', + 'city' => 'Berlin', + 'country' => 'DE', + 'postal_code' => '10115', + ], + ]); + + // Rate belongs to US zone but address is DE - should be rejected + expect(fn () => $this->checkoutService->setShippingMethod($checkout, $rate->id)) + ->toThrow(\App\Exceptions\InvalidCheckoutTransitionException::class); +}); + +it('applies free shipping discount overriding rate amount', function () { + $zone = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'countries_json' => ['DE'], + ]); + $rate = ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'config_json' => ['amount' => 499], + ]); + + \App\Models\Discount::factory()->freeShipping()->create([ + 'store_id' => $this->store->id, + 'code' => 'FREESHIP', + ]); + + $checkout = Checkout::factory()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $this->cart->id, + 'status' => 'addressed', + 'discount_code' => 'FREESHIP', + 'shipping_method_id' => $rate->id, + 'shipping_address_json' => ['country' => 'DE'], + ]); + + $result = $this->pricingEngine->calculate($checkout); + + expect($result->shipping)->toBe(0) + ->and($result->subtotal)->toBe(5000) + ->and($result->total)->toBe(5000); +}); + +it('updates totals when switching shipping methods', function () { + $zone = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'countries_json' => ['DE'], + ]); + $standard = ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'name' => 'Standard', + 'config_json' => ['amount' => 499], + ]); + $express = ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'name' => 'Express', + 'config_json' => ['amount' => 999], + ]); + + $checkout = Checkout::factory()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $this->cart->id, + 'status' => 'addressed', + 'shipping_method_id' => $standard->id, + 'shipping_address_json' => ['country' => 'DE'], + ]); + + $result1 = $this->pricingEngine->calculate($checkout); + + $checkout->update(['shipping_method_id' => $express->id]); + $result2 = $this->pricingEngine->calculate($checkout->fresh()); + + expect($result1->shipping)->toBe(499) + ->and($result2->shipping)->toBe(999) + ->and($result2->total)->toBe($result1->total + 500); +}); diff --git a/tests/Feature/Checkout/TaxTest.php b/tests/Feature/Checkout/TaxTest.php new file mode 100644 index 00000000..5f16857d --- /dev/null +++ b/tests/Feature/Checkout/TaxTest.php @@ -0,0 +1,126 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->pricingEngine = app(PricingEngine::class); + + $this->product = Product::factory()->create(['store_id' => $this->store->id, 'status' => 'active']); + $this->variant = ProductVariant::factory()->create([ + 'product_id' => $this->product->id, + 'price_amount' => 5000, + ]); + + $this->cart = Cart::factory()->create(['store_id' => $this->store->id]); + CartLine::factory()->create([ + 'cart_id' => $this->cart->id, + 'variant_id' => $this->variant->id, + 'quantity' => 2, + 'unit_price_amount' => 5000, + 'line_subtotal_amount' => 10000, + 'line_total_amount' => 10000, + ]); +}); + +it('adds exclusive tax to total', function () { + TaxSettings::factory()->create([ + 'store_id' => $this->store->id, + 'rate' => 1900, + 'prices_include_tax' => false, + ]); + + $zone = ShippingZone::factory()->create([ + 'store_id' => $this->store->id, + 'countries_json' => ['DE'], + ]); + $rate = ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'config_json' => ['amount' => 500], + ]); + + $checkout = Checkout::factory()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $this->cart->id, + 'status' => 'addressed', + 'shipping_method_id' => $rate->id, + 'shipping_address_json' => ['country' => 'DE'], + ]); + + $result = $this->pricingEngine->calculate($checkout); + + // Taxable = 10000 + 500 = 10500, tax = round(10500 * 1900 / 10000) = 1995 + expect($result->taxTotal)->toBe(1995) + ->and($result->total)->toBe(10000 + 500 + 1995); +}); + +it('extracts inclusive tax without increasing total', function () { + TaxSettings::factory()->inclusive()->create([ + 'store_id' => $this->store->id, + 'rate' => 1900, + ]); + + $checkout = Checkout::factory()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $this->cart->id, + 'status' => 'addressed', + ]); + + $result = $this->pricingEngine->calculate($checkout); + + // Inclusive: total = subtotal (tax is extracted, not added) + expect($result->subtotal)->toBe(10000) + ->and($result->taxTotal)->toBeGreaterThan(0) + ->and($result->total)->toBe(10000); +}); + +it('returns zero tax when tax settings are inactive', function () { + TaxSettings::factory()->create([ + 'store_id' => $this->store->id, + 'rate' => 1900, + 'is_active' => false, + ]); + + $checkout = Checkout::factory()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $this->cart->id, + 'status' => 'addressed', + ]); + + $result = $this->pricingEngine->calculate($checkout); + + expect($result->taxTotal)->toBe(0) + ->and($result->total)->toBe(10000); +}); + +it('stores tax lines in totals json', function () { + TaxSettings::factory()->create([ + 'store_id' => $this->store->id, + 'rate' => 1900, + 'prices_include_tax' => false, + 'tax_name' => 'VAT', + ]); + + $checkout = Checkout::factory()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $this->cart->id, + 'status' => 'addressed', + ]); + + $result = $this->pricingEngine->calculate($checkout); + $resultArray = $result->toArray(); + + expect($resultArray['tax_lines'])->toHaveCount(1) + ->and($resultArray['tax_lines'][0]['name'])->toBe('VAT') + ->and($resultArray['tax_lines'][0]['rate'])->toBe(1900) + ->and($resultArray['tax_lines'][0]['amount'])->toBeGreaterThan(0); +}); diff --git a/tests/Feature/Config/EnvironmentConfigTest.php b/tests/Feature/Config/EnvironmentConfigTest.php new file mode 100644 index 00000000..bd7c10fa --- /dev/null +++ b/tests/Feature/Config/EnvironmentConfigTest.php @@ -0,0 +1,81 @@ +toBe('sqlite'); +}); + +it('has WAL mode configured for SQLite', function () { + expect(config('database.connections.sqlite.journal_mode'))->toBe('WAL'); +}); + +it('has foreign keys enabled for SQLite', function () { + expect(config('database.connections.sqlite.foreign_key_constraints'))->toBeTrue(); +}); + +it('has busy_timeout set to 5000 for SQLite', function () { + expect(config('database.connections.sqlite.busy_timeout'))->toBe(5000); +}); + +it('has synchronous mode set to normal for SQLite', function () { + expect(config('database.connections.sqlite.synchronous'))->toBe('NORMAL'); +}); + +it('env file specifies file-based cache', function () { + $envContent = file_get_contents(base_path('.env')); + expect($envContent)->toContain('CACHE_STORE=file'); +}); + +it('env file specifies file-based sessions', function () { + $envContent = file_get_contents(base_path('.env')); + expect($envContent)->toContain('SESSION_DRIVER=file'); +}); + +it('session lifetime is 120 minutes', function () { + expect(config('session.lifetime'))->toBe(120); +}); + +it('uses synchronous queue', function () { + expect(config('queue.default'))->toBe('sync'); +}); + +it('env file specifies log-based mail', function () { + $envContent = file_get_contents(base_path('.env')); + expect($envContent)->toContain('MAIL_MAILER=log'); +}); + +it('has customer auth guard configured', function () { + expect(config('auth.guards.customer'))->toBe([ + 'driver' => 'session', + 'provider' => 'customers', + ]); +}); + +it('has customers auth provider configured', function () { + $provider = config('auth.providers.customers'); + expect($provider['driver'])->toBe('customer'); + expect($provider['model'])->toBe(App\Models\Customer::class); +}); + +it('has customers password broker configured', function () { + $broker = config('auth.passwords.customers'); + expect($broker['provider'])->toBe('customers'); + expect($broker['table'])->toBe('customer_password_reset_tokens'); + expect($broker['expire'])->toBe(60); + expect($broker['throttle'])->toBe(60); +}); + +it('has structured JSON logging channel configured', function () { + $channel = config('logging.channels.structured'); + expect($channel)->not->toBeNull(); + expect($channel['driver'])->toBe('single'); + expect($channel['formatter'])->toBe(Monolog\Formatter\JsonFormatter::class); +}); + +it('has local filesystem as default disk', function () { + expect(config('filesystems.default'))->toBe('local'); +}); + +it('has public disk configured for local storage', function () { + $public = config('filesystems.disks.public'); + expect($public['driver'])->toBe('local'); +}); diff --git a/tests/Feature/Customers/AddressManagementTest.php b/tests/Feature/Customers/AddressManagementTest.php new file mode 100644 index 00000000..0d8b224e --- /dev/null +++ b/tests/Feature/Customers/AddressManagementTest.php @@ -0,0 +1,150 @@ +store = Store::factory()->create(['name' => 'Test Store']); + StoreDomain::factory()->create([ + 'store_id' => $this->store->id, + 'hostname' => 'test-store.test', + 'type' => 'storefront', + ]); + $theme = Theme::factory()->published()->create(['store_id' => $this->store->id]); + ThemeSettings::factory()->create(['theme_id' => $theme->id]); + app()->instance('current_store', $this->store); + + $this->customer = Customer::withoutGlobalScopes()->create([ + 'store_id' => $this->store->id, + 'email' => 'customer@test.com', + 'password' => bcrypt('password'), + 'name' => 'John Doe', + ]); +}); + +it('lists saved addresses', function () { + Auth::guard('customer')->login($this->customer); + + $addr1 = CustomerAddress::factory()->create([ + 'customer_id' => $this->customer->id, + 'address_json' => ['first_name' => 'John', 'last_name' => 'Doe', 'address1' => '123 Main St', 'city' => 'New York', 'zip' => '10001', 'country' => 'US'], + ]); + $addr2 = CustomerAddress::factory()->create([ + 'customer_id' => $this->customer->id, + 'address_json' => ['first_name' => 'John', 'last_name' => 'Doe', 'address1' => '456 Oak Ave', 'city' => 'Chicago', 'zip' => '60601', 'country' => 'US'], + ]); + + $response = $this->get('https://test-store.test/account/addresses'); + + $response->assertOk(); + $response->assertSee('123 Main St'); + $response->assertSee('456 Oak Ave'); +}); + +it('creates a new address', function () { + Auth::guard('customer')->login($this->customer); + + Livewire::test(\App\Livewire\Storefront\Account\Addresses\Index::class) + ->set('form.first_name', 'John') + ->set('form.last_name', 'Doe') + ->set('form.address1', 'New Street 42') + ->set('form.city', 'Hamburg') + ->set('form.zip', '20095') + ->set('form.country', 'DE') + ->call('saveAddress') + ->assertHasNoErrors(); + + expect(CustomerAddress::where('customer_id', $this->customer->id)->count())->toBe(1); + $address = CustomerAddress::where('customer_id', $this->customer->id)->first(); + expect($address->address_json['address1'])->toBe('New Street 42'); + expect($address->address_json['city'])->toBe('Hamburg'); +}); + +it('updates an existing address', function () { + Auth::guard('customer')->login($this->customer); + + $address = CustomerAddress::factory()->create([ + 'customer_id' => $this->customer->id, + 'address_json' => ['first_name' => 'John', 'last_name' => 'Doe', 'address1' => '123 Main St', 'city' => 'New York', 'zip' => '10001', 'country' => 'US'], + ]); + + Livewire::test(\App\Livewire\Storefront\Account\Addresses\Index::class) + ->call('editAddress', $address->id) + ->set('form.city', 'Frankfurt') + ->call('saveAddress') + ->assertHasNoErrors(); + + $address->refresh(); + expect($address->address_json['city'])->toBe('Frankfurt'); +}); + +it('deletes an address', function () { + Auth::guard('customer')->login($this->customer); + + $address = CustomerAddress::factory()->create([ + 'customer_id' => $this->customer->id, + ]); + + Livewire::test(\App\Livewire\Storefront\Account\Addresses\Index::class) + ->call('deleteAddress', $address->id); + + expect(CustomerAddress::find($address->id))->toBeNull(); +}); + +it('sets a default address', function () { + Auth::guard('customer')->login($this->customer); + + $addr1 = CustomerAddress::factory()->default()->create([ + 'customer_id' => $this->customer->id, + ]); + $addr2 = CustomerAddress::factory()->create([ + 'customer_id' => $this->customer->id, + ]); + + Livewire::test(\App\Livewire\Storefront\Account\Addresses\Index::class) + ->call('setDefault', $addr2->id); + + $addr1->refresh(); + $addr2->refresh(); + expect($addr2->is_default)->toBeTrue(); + expect($addr1->is_default)->toBeFalse(); +}); + +it('validates required address fields', function () { + Auth::guard('customer')->login($this->customer); + + Livewire::test(\App\Livewire\Storefront\Account\Addresses\Index::class) + ->set('form.first_name', '') + ->set('form.last_name', '') + ->set('form.address1', '') + ->set('form.city', '') + ->set('form.zip', '') + ->set('form.country', '') + ->call('saveAddress') + ->assertHasErrors(['form.first_name', 'form.last_name', 'form.address1', 'form.city', 'form.zip', 'form.country']); +}); + +it('prevents managing another customers addresses', function () { + Auth::guard('customer')->login($this->customer); + + $otherCustomer = Customer::withoutGlobalScopes()->create([ + 'store_id' => $this->store->id, + 'email' => 'other@test.com', + 'password' => bcrypt('password'), + 'name' => 'Other Customer', + ]); + + $otherAddress = CustomerAddress::factory()->create([ + 'customer_id' => $otherCustomer->id, + ]); + + Livewire::test(\App\Livewire\Storefront\Account\Addresses\Index::class) + ->call('editAddress', $otherAddress->id) + ->assertForbidden(); +}); diff --git a/tests/Feature/Customers/CustomerAccountTest.php b/tests/Feature/Customers/CustomerAccountTest.php new file mode 100644 index 00000000..d2b644ac --- /dev/null +++ b/tests/Feature/Customers/CustomerAccountTest.php @@ -0,0 +1,127 @@ +store = Store::factory()->create(['name' => 'Test Store']); + StoreDomain::factory()->create([ + 'store_id' => $this->store->id, + 'hostname' => 'test-store.test', + 'type' => 'storefront', + ]); + $theme = Theme::factory()->published()->create(['store_id' => $this->store->id]); + ThemeSettings::factory()->create(['theme_id' => $theme->id]); + app()->instance('current_store', $this->store); + + $this->customer = Customer::withoutGlobalScopes()->create([ + 'store_id' => $this->store->id, + 'email' => 'customer@test.com', + 'password' => bcrypt('password'), + 'name' => 'John Doe', + ]); +}); + +it('renders customer dashboard with customer name', function () { + Auth::guard('customer')->login($this->customer); + + $response = $this->get('https://test-store.test/account'); + + $response->assertOk(); + $response->assertSee('John'); +}); + +it('lists customer orders on orders index', function () { + Auth::guard('customer')->login($this->customer); + + $orders = Order::factory()->count(3)->create([ + 'store_id' => $this->store->id, + 'customer_id' => $this->customer->id, + ]); + + $response = $this->get('https://test-store.test/account/orders'); + + $response->assertOk(); + foreach ($orders as $order) { + $response->assertSee($order->order_number); + } +}); + +it('shows order detail with items and totals', function () { + Auth::guard('customer')->login($this->customer); + + $order = Order::factory()->create([ + 'store_id' => $this->store->id, + 'customer_id' => $this->customer->id, + 'order_number' => '#1001', + 'subtotal_amount' => 5000, + 'total_amount' => 5975, + ]); + + OrderLine::factory()->create([ + 'order_id' => $order->id, + 'title_snapshot' => 'Classic Cotton Tee - Size: M', + 'quantity' => 2, + 'unit_price_amount' => 2500, + 'total_amount' => 5000, + ]); + + $response = $this->get('https://test-store.test/account/orders/1001'); + + $response->assertOk(); + $response->assertSee('#1001'); + $response->assertSee('Classic Cotton Tee'); +}); + +it('prevents accessing another customers order', function () { + Auth::guard('customer')->login($this->customer); + + $otherCustomer = Customer::withoutGlobalScopes()->create([ + 'store_id' => $this->store->id, + 'email' => 'other@test.com', + 'password' => bcrypt('password'), + 'name' => 'Other Customer', + ]); + + Order::factory()->create([ + 'store_id' => $this->store->id, + 'customer_id' => $otherCustomer->id, + 'order_number' => '#9999', + ]); + + $response = $this->get('https://test-store.test/account/orders/9999'); + + $response->assertNotFound(); +}); + +it('redirects unauthenticated users to login', function () { + $response = $this->get('https://test-store.test/account'); + $response->assertRedirect('/account/login'); + + $response = $this->get('https://test-store.test/account/orders'); + $response->assertRedirect('/account/login'); + + $response = $this->get('https://test-store.test/account/addresses'); + $response->assertRedirect('/account/login'); +}); + +it('updates customer profile', function () { + Auth::guard('customer')->login($this->customer); + + Livewire::test(\App\Livewire\Storefront\Account\Dashboard::class) + ->set('name', 'Jane Doe') + ->set('marketingOptIn', true) + ->call('updateProfile') + ->assertHasNoErrors(); + + $this->customer->refresh(); + expect($this->customer->name)->toBe('Jane Doe'); + expect($this->customer->marketing_opt_in)->toBeTrue(); +}); diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php index fcd0258d..412a103c 100644 --- a/tests/Feature/DashboardTest.php +++ b/tests/Feature/DashboardTest.php @@ -15,4 +15,4 @@ $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 index 8b5843f4..fc0aa8df 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -1,7 +1,21 @@ get('/'); + $store = Store::factory()->create(); + StoreDomain::factory()->create([ + 'store_id' => $store->id, + 'hostname' => 'example-store.test', + 'type' => 'storefront', + ]); + $theme = Theme::factory()->published()->create(['store_id' => $store->id]); + ThemeSettings::factory()->create(['theme_id' => $theme->id]); + + $response = $this->get('https://example-store.test/'); $response->assertStatus(200); }); diff --git a/tests/Feature/HandleGeneratorTest.php b/tests/Feature/HandleGeneratorTest.php new file mode 100644 index 00000000..469a96e0 --- /dev/null +++ b/tests/Feature/HandleGeneratorTest.php @@ -0,0 +1,83 @@ +generate('My Amazing Product', 'products', $context['store']->id); + + expect($handle)->toBe('my-amazing-product'); +}); + +it('appends suffix on collision', function () { + $context = createStoreContext(); + $generator = new HandleGenerator; + + \App\Models\Product::factory()->create([ + 'store_id' => $context['store']->id, + 'handle' => 't-shirt', + ]); + + $handle = $generator->generate('T-Shirt', 'products', $context['store']->id); + + expect($handle)->toBe('t-shirt-1'); +}); + +it('increments suffix on multiple collisions', function () { + $context = createStoreContext(); + $generator = new HandleGenerator; + + \App\Models\Product::factory()->create([ + 'store_id' => $context['store']->id, + 'handle' => 't-shirt', + ]); + \App\Models\Product::factory()->create([ + 'store_id' => $context['store']->id, + 'handle' => 't-shirt-1', + ]); + + $handle = $generator->generate('T-Shirt', 'products', $context['store']->id); + + expect($handle)->toBe('t-shirt-2'); +}); + +it('handles special characters', function () { + $context = createStoreContext(); + $generator = new HandleGenerator; + + $handle = $generator->generate("Loewe's Fall/Winter 2026", 'products', $context['store']->id); + + expect($handle)->toMatch('/^[a-z0-9\-]+$/'); +}); + +it('excludes current record id from collision check', function () { + $context = createStoreContext(); + $generator = new HandleGenerator; + + $product = \App\Models\Product::factory()->create([ + 'store_id' => $context['store']->id, + 'handle' => 't-shirt', + ]); + + $handle = $generator->generate('T-Shirt', 'products', $context['store']->id, $product->id); + + expect($handle)->toBe('t-shirt'); +}); + +it('scopes uniqueness check to store', function () { + $contextA = createStoreContext(); + + \App\Models\Product::factory()->create([ + 'store_id' => $contextA['store']->id, + 'handle' => 't-shirt', + ]); + + $contextB = createStoreContext(); + $generator = new HandleGenerator; + + $handle = $generator->generate('T-Shirt', 'products', $contextB['store']->id); + + expect($handle)->toBe('t-shirt'); +}); diff --git a/tests/Feature/Models/MigrationSchemaTest.php b/tests/Feature/Models/MigrationSchemaTest.php new file mode 100644 index 00000000..8421b2b7 --- /dev/null +++ b/tests/Feature/Models/MigrationSchemaTest.php @@ -0,0 +1,74 @@ +toBeTrue(); +}); + +it('stores table has correct columns', function () { + expect(Schema::hasColumns('stores', [ + 'id', 'organization_id', 'name', 'handle', 'status', + 'default_currency', 'default_locale', 'timezone', 'created_at', 'updated_at', + ]))->toBeTrue(); +}); + +it('store_domains table has correct columns', function () { + expect(Schema::hasColumns('store_domains', [ + 'id', 'store_id', 'hostname', 'type', 'is_primary', 'tls_mode', 'created_at', + ]))->toBeTrue(); +}); + +it('users table has status and last_login_at columns', function () { + expect(Schema::hasColumns('users', ['status', 'last_login_at']))->toBeTrue(); +}); + +it('store_users table has composite primary key columns', function () { + expect(Schema::hasColumns('store_users', ['store_id', 'user_id', 'role', 'created_at']))->toBeTrue(); +}); + +it('store_settings table has store_id as primary key', function () { + expect(Schema::hasColumns('store_settings', ['store_id', 'settings_json', 'updated_at']))->toBeTrue(); +}); + +it('customers table has correct columns', function () { + expect(Schema::hasColumns('customers', [ + 'id', 'store_id', 'email', 'password', 'name', 'marketing_opt_in', 'created_at', 'updated_at', + ]))->toBeTrue(); +}); + +it('customer_password_reset_tokens table exists', function () { + expect(Schema::hasTable('customer_password_reset_tokens'))->toBeTrue(); + expect(Schema::hasColumns('customer_password_reset_tokens', ['email', 'store_id', 'token', 'created_at']))->toBeTrue(); +}); + +it('cascading deletes work correctly for organizations', function () { + $organization = Organization::factory()->create(); + $store = Store::factory()->create(['organization_id' => $organization->id]); + StoreDomain::factory()->create(['store_id' => $store->id]); + StoreSettings::factory()->create(['store_id' => $store->id]); + $user = User::factory()->create(); + $store->users()->attach($user->id, ['role' => 'owner']); + + $organization->delete(); + + expect(Store::count())->toBe(0); + expect(StoreDomain::count())->toBe(0); + expect(StoreSettings::count())->toBe(0); + expect(\Illuminate\Support\Facades\DB::table('store_users')->count())->toBe(0); +}); + +it('seeder creates sample foundation data', function () { + $this->seed(); + + expect(Organization::count())->toBeGreaterThanOrEqual(1); + expect(Store::count())->toBeGreaterThanOrEqual(1); + expect(StoreDomain::count())->toBeGreaterThanOrEqual(1); + expect(\Illuminate\Support\Facades\DB::table('store_users')->count())->toBeGreaterThanOrEqual(1); + expect(StoreSettings::count())->toBeGreaterThanOrEqual(1); +}); diff --git a/tests/Feature/Models/NavigationItemTest.php b/tests/Feature/Models/NavigationItemTest.php new file mode 100644 index 00000000..fe870a38 --- /dev/null +++ b/tests/Feature/Models/NavigationItemTest.php @@ -0,0 +1,61 @@ +create(); + + expect($item->menu)->toBeInstanceOf(NavigationMenu::class); +}); + +it('casts type to NavigationItemType enum', function () { + $item = NavigationItem::factory()->create(['type' => 'collection']); + + expect($item->type)->toBeInstanceOf(NavigationItemType::class); + expect($item->type)->toBe(NavigationItemType::Collection); +}); + +it('link type uses url directly', function () { + $item = NavigationItem::factory()->create([ + 'type' => 'link', + 'url' => 'https://example.com', + 'resource_id' => null, + ]); + + expect($item->url)->toBe('https://example.com'); + expect($item->resource_id)->toBeNull(); +}); + +it('page type uses resource_id', function () { + $item = NavigationItem::factory()->create([ + 'type' => 'page', + 'url' => null, + 'resource_id' => 5, + ]); + + expect($item->resource_id)->toBe(5); + expect($item->url)->toBeNull(); +}); + +it('items are ordered by position', function () { + $menu = NavigationMenu::factory()->create(); + NavigationItem::factory()->create(['menu_id' => $menu->id, 'position' => 2, 'label' => 'Third']); + NavigationItem::factory()->create(['menu_id' => $menu->id, 'position' => 0, 'label' => 'First']); + NavigationItem::factory()->create(['menu_id' => $menu->id, 'position' => 1, 'label' => 'Second']); + + $items = $menu->items; + + expect($items[0]->label)->toBe('First'); + expect($items[1]->label)->toBe('Second'); + expect($items[2]->label)->toBe('Third'); +}); + +it('factory creates valid item', function () { + $item = NavigationItem::factory()->create(); + + expect($item->type)->toBeInstanceOf(NavigationItemType::class); + expect($item->label)->not->toBeEmpty(); + expect($item->position)->toBeGreaterThanOrEqual(0); +}); diff --git a/tests/Feature/Models/NavigationMenuTest.php b/tests/Feature/Models/NavigationMenuTest.php new file mode 100644 index 00000000..e6de4796 --- /dev/null +++ b/tests/Feature/Models/NavigationMenuTest.php @@ -0,0 +1,51 @@ +create(); + + expect($menu->store)->toBeInstanceOf(Store::class); +}); + +it('has many navigation items', function () { + $menu = NavigationMenu::factory()->create(); + NavigationItem::factory()->count(5)->create(['menu_id' => $menu->id]); + + expect($menu->items)->toHaveCount(5); +}); + +it('enforces unique handle per store', function () { + $store = Store::factory()->create(); + NavigationMenu::factory()->create(['store_id' => $store->id, 'handle' => 'main-menu']); + + NavigationMenu::factory()->create(['store_id' => $store->id, 'handle' => 'main-menu']); +})->throws(QueryException::class); + +it('factory creates valid menu', function () { + $menu = NavigationMenu::factory()->create(); + + expect($menu->handle)->not->toBeEmpty(); + expect($menu->title)->not->toBeEmpty(); +}); + +it('cascades delete to menus when store is deleted', function () { + $store = Store::factory()->create(); + NavigationMenu::factory()->create(['store_id' => $store->id]); + + $store->delete(); + + expect(NavigationMenu::where('store_id', $store->id)->count())->toBe(0); +}); + +it('cascades delete to items when menu is deleted', function () { + $menu = NavigationMenu::factory()->create(); + NavigationItem::factory()->count(3)->create(['menu_id' => $menu->id]); + + $menu->delete(); + + expect(NavigationItem::where('menu_id', $menu->id)->count())->toBe(0); +}); diff --git a/tests/Feature/Models/OrganizationTest.php b/tests/Feature/Models/OrganizationTest.php new file mode 100644 index 00000000..c00a97e8 --- /dev/null +++ b/tests/Feature/Models/OrganizationTest.php @@ -0,0 +1,19 @@ +create(); + Store::factory()->count(2)->create(['organization_id' => $organization->id]); + + expect($organization->stores)->toHaveCount(2); +}); + +it('factory creates valid records', function () { + $organization = Organization::factory()->create(); + + expect($organization->name)->not->toBeEmpty(); + expect($organization->billing_email)->not->toBeEmpty(); + expect(filter_var($organization->billing_email, FILTER_VALIDATE_EMAIL))->not->toBeFalse(); +}); diff --git a/tests/Feature/Models/PageTest.php b/tests/Feature/Models/PageTest.php new file mode 100644 index 00000000..6212451f --- /dev/null +++ b/tests/Feature/Models/PageTest.php @@ -0,0 +1,53 @@ +create(); + + expect($page->store)->toBeInstanceOf(Store::class); +}); + +it('casts status to PageStatus enum', function () { + $page = Page::factory()->published()->create(); + + expect($page->status)->toBeInstanceOf(PageStatus::class); + expect($page->status)->toBe(PageStatus::Published); +}); + +it('enforces unique handle per store', function () { + $store = Store::factory()->create(); + Page::factory()->create(['store_id' => $store->id, 'handle' => 'about-us']); + + Page::factory()->create(['store_id' => $store->id, 'handle' => 'about-us']); +})->throws(QueryException::class); + +it('allows same handle for different stores', function () { + $storeA = Store::factory()->create(); + $storeB = Store::factory()->create(); + + Page::factory()->create(['store_id' => $storeA->id, 'handle' => 'about-us']); + $pageB = Page::factory()->create(['store_id' => $storeB->id, 'handle' => 'about-us']); + + expect($pageB->exists)->toBeTrue(); +}); + +it('factory creates valid page', function () { + $page = Page::factory()->create(); + + expect($page->title)->not->toBeEmpty(); + expect($page->handle)->not->toBeEmpty(); + expect($page->status)->toBe(PageStatus::Draft); +}); + +it('cascades delete to pages when store is deleted', function () { + $store = Store::factory()->create(); + Page::factory()->count(2)->create(['store_id' => $store->id]); + + $store->delete(); + + expect(Page::where('store_id', $store->id)->count())->toBe(0); +}); diff --git a/tests/Feature/Models/StoreDomainTest.php b/tests/Feature/Models/StoreDomainTest.php new file mode 100644 index 00000000..40918dfd --- /dev/null +++ b/tests/Feature/Models/StoreDomainTest.php @@ -0,0 +1,25 @@ +create(); + + expect($domain->store)->toBeInstanceOf(Store::class); +}); + +it('factory creates valid records', function () { + $domain = StoreDomain::factory()->create(); + + expect($domain->hostname)->not->toBeEmpty(); + expect($domain->type)->toBeInstanceOf(StoreDomainType::class); +}); + +it('casts type to StoreDomainType enum', function () { + $domain = StoreDomain::factory()->create(['type' => 'storefront']); + + expect($domain->type)->toBeInstanceOf(StoreDomainType::class); + expect($domain->type)->toBe(StoreDomainType::Storefront); +}); diff --git a/tests/Feature/Models/StoreSettingsTest.php b/tests/Feature/Models/StoreSettingsTest.php new file mode 100644 index 00000000..dbb26768 --- /dev/null +++ b/tests/Feature/Models/StoreSettingsTest.php @@ -0,0 +1,28 @@ +create(); + + expect($settings->store)->toBeInstanceOf(Store::class); +}); + +it('casts settings_json to array', function () { + $settings = StoreSettings::factory()->create([ + 'settings_json' => ['key' => 'value'], + ]); + + $settings->refresh(); + + expect($settings->settings_json)->toBeArray(); + expect($settings->settings_json['key'])->toBe('value'); +}); + +it('factory creates valid records', function () { + $settings = StoreSettings::factory()->create(); + + expect($settings->settings_json)->toBeArray(); + expect($settings->settings_json)->not->toBeEmpty(); +}); diff --git a/tests/Feature/Models/StoreTest.php b/tests/Feature/Models/StoreTest.php new file mode 100644 index 00000000..86b4c668 --- /dev/null +++ b/tests/Feature/Models/StoreTest.php @@ -0,0 +1,62 @@ +create(); + + expect($store->organization)->toBeInstanceOf(Organization::class); +}); + +it('has many store domains', function () { + $store = Store::factory()->create(); + StoreDomain::factory()->count(2)->create(['store_id' => $store->id]); + + expect($store->domains)->toHaveCount(2); +}); + +it('belongs to many users through store_users', function () { + $store = Store::factory()->create(); + $users = User::factory()->count(2)->create(); + + $store->users()->attach($users[0]->id, ['role' => 'owner']); + $store->users()->attach($users[1]->id, ['role' => 'admin']); + + $store->refresh(); + expect($store->users)->toHaveCount(2); + + $pivot = $store->users->first()->pivot; + expect($pivot)->toBeInstanceOf(StoreUser::class); + expect($pivot->role)->not->toBeNull(); +}); + +it('has one store settings record', function () { + $store = Store::factory()->create(); + StoreSettings::factory()->create(['store_id' => $store->id]); + + expect($store->settings)->toBeInstanceOf(StoreSettings::class); +}); + +it('factory creates valid records with all defaults', function () { + $store = Store::factory()->create(); + + expect($store->name)->not->toBeEmpty(); + expect($store->handle)->not->toBeEmpty(); + expect($store->status)->toBe(StoreStatus::Active); + expect($store->default_currency)->toBe('USD'); + expect($store->default_locale)->toBe('en'); + expect($store->timezone)->toBe('UTC'); +}); + +it('casts status to StoreStatus enum', function () { + $store = Store::factory()->create(['status' => 'active']); + + expect($store->status)->toBeInstanceOf(StoreStatus::class); + expect($store->status)->toBe(StoreStatus::Active); +}); diff --git a/tests/Feature/Models/StoreUserTest.php b/tests/Feature/Models/StoreUserTest.php new file mode 100644 index 00000000..0aa6478c --- /dev/null +++ b/tests/Feature/Models/StoreUserTest.php @@ -0,0 +1,18 @@ +create(); + $user = User::factory()->create(); + + $store->users()->attach($user->id, ['role' => 'admin']); + + $pivot = $user->stores->first()->pivot; + + expect($pivot)->toBeInstanceOf(StoreUser::class); + expect($pivot->role)->toBe(StoreUserRole::Admin); +}); diff --git a/tests/Feature/Models/ThemeFileTest.php b/tests/Feature/Models/ThemeFileTest.php new file mode 100644 index 00000000..0939a56b --- /dev/null +++ b/tests/Feature/Models/ThemeFileTest.php @@ -0,0 +1,27 @@ +create(); + + expect($file->theme)->toBeInstanceOf(Theme::class); +}); + +it('enforces unique path per theme', function () { + $theme = Theme::factory()->create(); + ThemeFile::factory()->create(['theme_id' => $theme->id, 'path' => 'templates/index.html']); + + ThemeFile::factory()->create(['theme_id' => $theme->id, 'path' => 'templates/index.html']); +})->throws(QueryException::class); + +it('factory creates valid theme file', function () { + $file = ThemeFile::factory()->create(); + + expect($file->path)->not->toBeEmpty(); + expect($file->storage_key)->not->toBeEmpty(); + expect($file->sha256)->not->toBeEmpty(); + expect($file->byte_size)->toBeGreaterThanOrEqual(0); +}); diff --git a/tests/Feature/Models/ThemeSettingsTest.php b/tests/Feature/Models/ThemeSettingsTest.php new file mode 100644 index 00000000..be9b87d3 --- /dev/null +++ b/tests/Feature/Models/ThemeSettingsTest.php @@ -0,0 +1,36 @@ +create(); + + expect($settings->theme)->toBeInstanceOf(Theme::class); +}); + +it('casts settings_json to array', function () { + $settings = ThemeSettings::factory()->create([ + 'settings_json' => ['announcement_bar' => ['enabled' => true, 'text' => 'Hello']], + ]); + + $settings->refresh(); + + expect($settings->settings_json)->toBeArray(); + expect($settings->settings_json['announcement_bar']['enabled'])->toBeTrue(); +}); + +it('uses theme_id as primary key', function () { + $theme = Theme::factory()->create(); + $settings = ThemeSettings::factory()->create(['theme_id' => $theme->id]); + + expect($settings->getKeyName())->toBe('theme_id'); + expect($settings->getKey())->toBe($theme->id); +}); + +it('factory creates valid settings', function () { + $settings = ThemeSettings::factory()->create(); + + expect($settings->settings_json)->toBeArray(); + expect($settings->theme_id)->not->toBeNull(); +}); diff --git a/tests/Feature/Models/ThemeTest.php b/tests/Feature/Models/ThemeTest.php new file mode 100644 index 00000000..c5405a84 --- /dev/null +++ b/tests/Feature/Models/ThemeTest.php @@ -0,0 +1,63 @@ +create(); + + expect($theme->store)->toBeInstanceOf(Store::class); +}); + +it('has many theme files', function () { + $theme = Theme::factory()->create(); + ThemeFile::factory()->count(3)->create(['theme_id' => $theme->id]); + + expect($theme->files)->toHaveCount(3); +}); + +it('has one theme settings', function () { + $theme = Theme::factory()->create(); + ThemeSettings::factory()->create(['theme_id' => $theme->id]); + + expect($theme->settings)->toBeInstanceOf(ThemeSettings::class); +}); + +it('casts status to ThemeStatus enum', function () { + $theme = Theme::factory()->published()->create(); + + expect($theme->status)->toBeInstanceOf(ThemeStatus::class); + expect($theme->status)->toBe(ThemeStatus::Published); +}); + +it('factory creates valid theme', function () { + $theme = Theme::factory()->create(); + + expect($theme->name)->not->toBeEmpty(); + expect($theme->status)->toBe(ThemeStatus::Draft); + expect($theme->store_id)->not->toBeNull(); +}); + +it('cascades delete to theme when store is deleted', function () { + $store = Store::factory()->create(); + $theme = Theme::factory()->create(['store_id' => $store->id]); + + $store->delete(); + + expect(Theme::find($theme->id))->toBeNull(); +}); + +it('cascades delete to files and settings when theme is deleted', function () { + $theme = Theme::factory()->create(); + ThemeFile::factory()->count(3)->create(['theme_id' => $theme->id]); + ThemeSettings::factory()->create(['theme_id' => $theme->id]); + + $themeId = $theme->id; + $theme->delete(); + + expect(ThemeFile::where('theme_id', $themeId)->count())->toBe(0); + expect(ThemeSettings::where('theme_id', $themeId)->count())->toBe(0); +}); diff --git a/tests/Feature/Models/UserTest.php b/tests/Feature/Models/UserTest.php new file mode 100644 index 00000000..75b813ca --- /dev/null +++ b/tests/Feature/Models/UserTest.php @@ -0,0 +1,44 @@ +create(); + $stores = Store::factory()->count(2)->create(); + + $user->stores()->attach($stores[0]->id, ['role' => 'owner']); + $user->stores()->attach($stores[1]->id, ['role' => 'staff']); + + $user->refresh(); + expect($user->stores)->toHaveCount(2); +}); + +it('can get role for a specific store', function () { + $user = User::factory()->create(); + $store = Store::factory()->create(); + + $user->stores()->attach($store->id, ['role' => 'admin']); + + expect($user->roleForStore($store))->toBe(StoreUserRole::Admin); +}); + +it('returns null when user has no role in store', function () { + $user = User::factory()->create(); + $store = Store::factory()->create(); + + expect($user->roleForStore($store))->toBeNull(); +}); + +it('can have different roles in different stores', function () { + $user = User::factory()->create(); + $store1 = Store::factory()->create(); + $store2 = Store::factory()->create(); + + $user->stores()->attach($store1->id, ['role' => 'owner']); + $user->stores()->attach($store2->id, ['role' => 'staff']); + + expect($user->roleForStore($store1))->toBe(StoreUserRole::Owner); + expect($user->roleForStore($store2))->toBe(StoreUserRole::Staff); +}); diff --git a/tests/Feature/Orders/FulfillmentTest.php b/tests/Feature/Orders/FulfillmentTest.php new file mode 100644 index 00000000..8387a3d8 --- /dev/null +++ b/tests/Feature/Orders/FulfillmentTest.php @@ -0,0 +1,233 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->fulfillmentService = app(FulfillmentService::class); + + $customer = Customer::factory()->create(['store_id' => $this->store->id]); + $this->product = Product::factory()->create(['store_id' => $this->store->id]); + $this->variant = ProductVariant::factory()->create([ + 'product_id' => $this->product->id, + 'requires_shipping' => true, + ]); + + $this->order = Order::factory()->paid()->create([ + 'store_id' => $this->store->id, + 'customer_id' => $customer->id, + 'total_amount' => 5000, + ]); + + $this->line1 = OrderLine::factory()->create([ + 'order_id' => $this->order->id, + 'product_id' => $this->product->id, + 'variant_id' => $this->variant->id, + 'quantity' => 3, + 'unit_price_amount' => 1000, + 'total_amount' => 3000, + ]); + + $this->line2 = OrderLine::factory()->create([ + 'order_id' => $this->order->id, + 'product_id' => $this->product->id, + 'variant_id' => $this->variant->id, + 'quantity' => 2, + 'unit_price_amount' => 1000, + 'total_amount' => 2000, + ]); + + Payment::factory()->create([ + 'order_id' => $this->order->id, + 'amount' => 5000, + ]); +}); + +it('creates a fulfillment for all order lines', function () { + Event::fake(); + + $fulfillment = $this->fulfillmentService->create( + $this->order, + [$this->line1->id => 3, $this->line2->id => 2], + ['tracking_company' => 'DHL', 'tracking_number' => 'TRACK123'], + ); + + expect($fulfillment->status)->toBe(FulfillmentShipmentStatus::Pending) + ->and($fulfillment->tracking_company)->toBe('DHL') + ->and($fulfillment->tracking_number)->toBe('TRACK123') + ->and($fulfillment->fulfillmentLines)->toHaveCount(2); + + $this->order->refresh(); + expect($this->order->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled) + ->and($this->order->status)->toBe(OrderStatus::Fulfilled); + + Event::assertDispatched(FulfillmentCreated::class); + Event::assertDispatched(OrderFulfilled::class); +}); + +it('creates a partial fulfillment', function () { + $fulfillment = $this->fulfillmentService->create( + $this->order, + [$this->line1->id => 2], + ); + + expect($fulfillment->fulfillmentLines)->toHaveCount(1); + + $this->order->refresh(); + expect($this->order->fulfillment_status)->toBe(FulfillmentStatus::Partial); +}); + +it('prevents fulfillment when financial status is pending', function () { + $this->order->update(['financial_status' => FinancialStatus::Pending]); + + expect(fn () => $this->fulfillmentService->create( + $this->order->fresh(), + [$this->line1->id => 1], + ))->toThrow(FulfillmentGuardException::class); +}); + +it('prevents fulfillment when financial status is voided', function () { + $this->order->update([ + 'financial_status' => FinancialStatus::Voided, + 'status' => OrderStatus::Cancelled, + ]); + + expect(fn () => $this->fulfillmentService->create( + $this->order->fresh(), + [$this->line1->id => 1], + ))->toThrow(FulfillmentGuardException::class); +}); + +it('allows fulfillment when financial status is partially_refunded', function () { + $this->order->update(['financial_status' => FinancialStatus::PartiallyRefunded]); + + $fulfillment = $this->fulfillmentService->create( + $this->order->fresh(), + [$this->line1->id => 1], + ); + + expect($fulfillment)->not->toBeNull(); +}); + +it('prevents over-fulfillment of order lines', function () { + // Fulfill 2 of 3 units from line1 + $this->fulfillmentService->create( + $this->order, + [$this->line1->id => 2], + ); + + // Attempt to fulfill 2 more (only 1 remaining) + expect(fn () => $this->fulfillmentService->create( + $this->order->fresh(), + [$this->line1->id => 2], + ))->toThrow(RuntimeException::class, 'Only 1 remaining'); +}); + +it('allows multiple partial fulfillments until fully fulfilled', function () { + // First fulfillment: 2 of line1 + $this->fulfillmentService->create( + $this->order, + [$this->line1->id => 2], + ); + + $this->order->refresh(); + expect($this->order->fulfillment_status)->toBe(FulfillmentStatus::Partial); + + // Second fulfillment: remaining line1 + all line2 + $this->fulfillmentService->create( + $this->order->fresh(), + [$this->line1->id => 1, $this->line2->id => 2], + ); + + $this->order->refresh(); + expect($this->order->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled) + ->and($this->order->status)->toBe(OrderStatus::Fulfilled); +}); + +it('marks fulfillment as shipped', function () { + Event::fake(); + + $fulfillment = $this->fulfillmentService->create( + $this->order, + [$this->line1->id => 3, $this->line2->id => 2], + ); + + $this->fulfillmentService->markAsShipped($fulfillment, [ + 'tracking_company' => 'UPS', + 'tracking_number' => 'UPS-789', + ]); + + $fulfillment->refresh(); + + expect($fulfillment->status)->toBe(FulfillmentShipmentStatus::Shipped) + ->and($fulfillment->tracking_company)->toBe('UPS') + ->and($fulfillment->tracking_number)->toBe('UPS-789') + ->and($fulfillment->shipped_at)->not->toBeNull(); + + Event::assertDispatched(FulfillmentShipped::class); +}); + +it('marks fulfillment as delivered', function () { + $fulfillment = $this->fulfillmentService->create( + $this->order, + [$this->line1->id => 3, $this->line2->id => 2], + ); + + $this->fulfillmentService->markAsShipped($fulfillment); + $this->fulfillmentService->markAsDelivered($fulfillment->fresh()); + + $fulfillment->refresh(); + expect($fulfillment->status)->toBe(FulfillmentShipmentStatus::Delivered) + ->and($fulfillment->delivered_at)->not->toBeNull(); +}); + +it('prevents shipping a non-pending fulfillment', function () { + $fulfillment = $this->fulfillmentService->create( + $this->order, + [$this->line1->id => 1], + ); + + $this->fulfillmentService->markAsShipped($fulfillment); + + expect(fn () => $this->fulfillmentService->markAsShipped($fulfillment->fresh())) + ->toThrow(RuntimeException::class, 'Only pending fulfillments'); +}); + +it('prevents delivering a non-shipped fulfillment', function () { + $fulfillment = $this->fulfillmentService->create( + $this->order, + [$this->line1->id => 1], + ); + + expect(fn () => $this->fulfillmentService->markAsDelivered($fulfillment)) + ->toThrow(RuntimeException::class, 'Only shipped fulfillments'); +}); + +it('prevents fulfilling an order line that does not belong to the order', function () { + $otherOrder = Order::factory()->paid()->create(['store_id' => $this->store->id]); + $otherLine = OrderLine::factory()->create([ + 'order_id' => $otherOrder->id, + 'quantity' => 1, + ]); + + expect(fn () => $this->fulfillmentService->create( + $this->order, + [$otherLine->id => 1], + ))->toThrow(RuntimeException::class, 'does not belong to this order'); +}); diff --git a/tests/Feature/Orders/OrderCreationTest.php b/tests/Feature/Orders/OrderCreationTest.php new file mode 100644 index 00000000..db2758d5 --- /dev/null +++ b/tests/Feature/Orders/OrderCreationTest.php @@ -0,0 +1,209 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->orderService = app(OrderService::class); + + $this->product = Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => 'Test Product', + 'status' => 'active', + ]); + $this->variant = ProductVariant::factory()->create([ + 'product_id' => $this->product->id, + 'price_amount' => 2500, + 'sku' => 'TEST-SKU-001', + 'requires_shipping' => true, + ]); + $this->inventory = InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $this->variant->id, + 'quantity_on_hand' => 10, + 'quantity_reserved' => 2, + 'policy' => 'deny', + ]); + + $this->customer = Customer::factory()->create(['store_id' => $this->store->id]); + + $this->cart = Cart::factory()->create(['store_id' => $this->store->id]); + CartLine::factory()->create([ + 'cart_id' => $this->cart->id, + 'variant_id' => $this->variant->id, + 'quantity' => 2, + 'unit_price_amount' => 2500, + 'line_subtotal_amount' => 5000, + 'line_total_amount' => 5000, + ]); + + $this->checkout = Checkout::factory()->paymentSelected()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $this->cart->id, + 'customer_id' => $this->customer->id, + 'email' => 'customer@example.com', + 'payment_method' => PaymentMethod::CreditCard, + 'totals_json' => [ + 'subtotal' => 5000, + 'discount' => 0, + 'shipping' => 499, + 'tax_total' => 1045, + 'total' => 6544, + 'currency' => 'USD', + ], + ]); + + $this->successResult = new PaymentResult( + success: true, + status: 'captured', + providerPaymentId: 'mock_test_123', + rawResponse: ['provider' => 'mock'], + ); +}); + +it('creates an order from a completed checkout', function () { + Event::fake(); + + $order = $this->orderService->createFromCheckout($this->checkout, $this->successResult); + + expect($order)->toBeInstanceOf(Order::class) + ->and($order->store_id)->toBe($this->store->id) + ->and($order->customer_id)->toBe($this->customer->id) + ->and($order->order_number)->toBe('#1001') + ->and($order->status)->toBe(OrderStatus::Paid) + ->and($order->financial_status)->toBe(FinancialStatus::Paid) + ->and($order->fulfillment_status)->toBe(FulfillmentStatus::Unfulfilled) + ->and($order->email)->toBe('customer@example.com') + ->and($order->subtotal_amount)->toBe(5000) + ->and($order->total_amount)->toBe(6544); + + Event::assertDispatched(OrderCreated::class); +}); + +it('creates order lines with snapshot data', function () { + $order = $this->orderService->createFromCheckout($this->checkout, $this->successResult); + + $order->load('lines'); + + expect($order->lines)->toHaveCount(1); + + $line = $order->lines->first(); + expect($line->title_snapshot)->toBe('Test Product') + ->and($line->sku_snapshot)->toBe('TEST-SKU-001') + ->and($line->quantity)->toBe(2) + ->and($line->unit_price_amount)->toBe(2500); +}); + +it('creates a payment record linked to the order', function () { + $order = $this->orderService->createFromCheckout($this->checkout, $this->successResult); + + $order->load('payments'); + + expect($order->payments)->toHaveCount(1); + + $payment = $order->payments->first(); + expect($payment->provider)->toBe('mock') + ->and($payment->method)->toBe(PaymentMethod::CreditCard) + ->and($payment->status)->toBe(PaymentStatus::Captured) + ->and($payment->amount)->toBe(6544) + ->and($payment->provider_payment_id)->toBe('mock_test_123'); +}); + +it('commits inventory for credit card payment', function () { + $this->orderService->createFromCheckout($this->checkout, $this->successResult); + + $this->inventory->refresh(); + + // Was: on_hand=10, reserved=2. After commit of qty 2: on_hand=8, reserved=0 + expect($this->inventory->quantity_on_hand)->toBe(8) + ->and($this->inventory->quantity_reserved)->toBe(0); +}); + +it('converts the cart to converted status', function () { + $this->orderService->createFromCheckout($this->checkout, $this->successResult); + + $this->cart->refresh(); + $this->checkout->refresh(); + + expect($this->cart->status)->toBe(CartStatus::Converted) + ->and($this->checkout->status)->toBe(CheckoutStatus::Completed); +}); + +it('creates pending order for bank transfer', function () { + $this->checkout->update(['payment_method' => PaymentMethod::BankTransfer]); + + $pendingResult = new PaymentResult( + success: true, + status: 'pending', + providerPaymentId: 'mock_bank_456', + rawResponse: ['provider' => 'mock', 'method' => 'bank_transfer'], + ); + + $order = $this->orderService->createFromCheckout($this->checkout->fresh(), $pendingResult); + + expect($order->status)->toBe(OrderStatus::Pending) + ->and($order->financial_status)->toBe(FinancialStatus::Pending); + + // Inventory should NOT be committed for bank transfer - stays reserved + $this->inventory->refresh(); + expect($this->inventory->quantity_on_hand)->toBe(10) + ->and($this->inventory->quantity_reserved)->toBe(2); +}); + +it('generates sequential order numbers starting at 1001', function () { + $orderNumber = $this->orderService->generateOrderNumber($this->store); + expect($orderNumber)->toBe('#1001'); + + // Create an order to advance the counter + Order::factory()->create([ + 'store_id' => $this->store->id, + 'order_number' => '#1001', + ]); + + $orderNumber = $this->orderService->generateOrderNumber($this->store); + expect($orderNumber)->toBe('#1002'); +}); + +it('cancels an unfulfilled order', function () { + Event::fake(); + + $order = $this->orderService->createFromCheckout($this->checkout, $this->successResult); + + $this->orderService->cancel($order, 'Customer requested cancellation'); + + $order->refresh(); + $this->inventory->refresh(); + + expect($order->status)->toBe(OrderStatus::Cancelled) + ->and($this->inventory->quantity_on_hand)->toBe(10); + + Event::assertDispatched(OrderCancelled::class); +}); + +it('prevents cancellation of a fulfilled order', function () { + $order = $this->orderService->createFromCheckout($this->checkout, $this->successResult); + $order->update(['fulfillment_status' => FulfillmentStatus::Fulfilled]); + + expect(fn () => $this->orderService->cancel($order->fresh(), 'test')) + ->toThrow(RuntimeException::class); +}); diff --git a/tests/Feature/Orders/RefundTest.php b/tests/Feature/Orders/RefundTest.php new file mode 100644 index 00000000..1f7b76e8 --- /dev/null +++ b/tests/Feature/Orders/RefundTest.php @@ -0,0 +1,161 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->refundService = app(RefundService::class); + + $this->product = Product::factory()->create(['store_id' => $this->store->id]); + $this->variant = ProductVariant::factory()->create([ + 'product_id' => $this->product->id, + 'price_amount' => 2500, + ]); + $this->inventory = InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $this->variant->id, + 'quantity_on_hand' => 8, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]); + + $customer = Customer::factory()->create(['store_id' => $this->store->id]); + $this->order = Order::factory()->paid()->create([ + 'store_id' => $this->store->id, + 'customer_id' => $customer->id, + 'total_amount' => 5000, + ]); + $this->orderLine = OrderLine::factory()->create([ + 'order_id' => $this->order->id, + 'product_id' => $this->product->id, + 'variant_id' => $this->variant->id, + 'quantity' => 2, + 'unit_price_amount' => 2500, + 'total_amount' => 5000, + ]); + $this->payment = Payment::factory()->create([ + 'order_id' => $this->order->id, + 'amount' => 5000, + 'status' => PaymentStatus::Captured, + ]); +}); + +it('creates a full refund successfully', function () { + Event::fake(); + + $refund = $this->refundService->create( + order: $this->order, + payment: $this->payment, + amount: 5000, + reason: 'Customer unhappy', + ); + + expect($refund->status)->toBe(RefundStatus::Processed) + ->and($refund->amount)->toBe(5000) + ->and($refund->reason)->toBe('Customer unhappy') + ->and($refund->provider_refund_id)->toStartWith('mock_refund_'); + + $this->order->refresh(); + expect($this->order->financial_status)->toBe(FinancialStatus::Refunded) + ->and($this->order->status)->toBe(OrderStatus::Refunded); + + Event::assertDispatched(OrderRefunded::class); +}); + +it('creates a partial refund and updates to partially_refunded', function () { + $refund = $this->refundService->create( + order: $this->order, + payment: $this->payment, + amount: 2000, + ); + + expect($refund->amount)->toBe(2000); + + $this->order->refresh(); + expect($this->order->financial_status)->toBe(FinancialStatus::PartiallyRefunded); +}); + +it('prevents refund exceeding remaining refundable amount', function () { + // Create a prior refund of 3000 + \App\Models\Refund::create([ + 'order_id' => $this->order->id, + 'payment_id' => $this->payment->id, + 'amount' => 3000, + 'status' => RefundStatus::Processed, + 'created_at' => now()->toIso8601String(), + ]); + + expect(fn () => $this->refundService->create( + order: $this->order, + payment: $this->payment, + amount: 3000, + ))->toThrow(RuntimeException::class, 'exceeds remaining refundable amount'); +}); + +it('prevents zero amount refunds', function () { + expect(fn () => $this->refundService->create( + order: $this->order, + payment: $this->payment, + amount: 0, + ))->toThrow(RuntimeException::class, 'greater than zero'); +}); + +it('restocks inventory when restock flag is true', function () { + $this->refundService->create( + order: $this->order, + payment: $this->payment, + amount: 5000, + restock: true, + ); + + $this->inventory->refresh(); + // quantity was 8, restock qty 2 -> 10 + expect($this->inventory->quantity_on_hand)->toBe(10); +}); + +it('does not restock inventory when restock flag is false', function () { + $this->refundService->create( + order: $this->order, + payment: $this->payment, + amount: 5000, + restock: false, + ); + + $this->inventory->refresh(); + expect($this->inventory->quantity_on_hand)->toBe(8); +}); + +it('transitions to fully refunded after multiple partial refunds', function () { + $this->refundService->create( + order: $this->order, + payment: $this->payment, + amount: 2000, + ); + + $this->order->refresh(); + expect($this->order->financial_status)->toBe(FinancialStatus::PartiallyRefunded); + + $this->refundService->create( + order: $this->order, + payment: $this->payment, + amount: 3000, + ); + + $this->order->refresh(); + expect($this->order->financial_status)->toBe(FinancialStatus::Refunded) + ->and($this->order->status)->toBe(OrderStatus::Refunded); +}); diff --git a/tests/Feature/Payments/BankTransferConfirmationTest.php b/tests/Feature/Payments/BankTransferConfirmationTest.php new file mode 100644 index 00000000..01ab6d59 --- /dev/null +++ b/tests/Feature/Payments/BankTransferConfirmationTest.php @@ -0,0 +1,140 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->paymentService = app(PaymentService::class); + + $this->product = Product::factory()->create(['store_id' => $this->store->id]); + $this->variant = ProductVariant::factory()->create([ + 'product_id' => $this->product->id, + 'price_amount' => 2500, + 'requires_shipping' => true, + ]); + $this->inventory = InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $this->variant->id, + 'quantity_on_hand' => 10, + 'quantity_reserved' => 2, + 'policy' => 'deny', + ]); + + $customer = Customer::factory()->create(['store_id' => $this->store->id]); + $this->order = Order::factory()->pending()->create([ + 'store_id' => $this->store->id, + 'customer_id' => $customer->id, + 'total_amount' => 5000, + ]); + $this->orderLine = OrderLine::factory()->create([ + 'order_id' => $this->order->id, + 'product_id' => $this->product->id, + 'variant_id' => $this->variant->id, + 'quantity' => 2, + 'unit_price_amount' => 2500, + 'total_amount' => 5000, + ]); + $this->payment = Payment::factory()->pending()->create([ + 'order_id' => $this->order->id, + 'amount' => 5000, + ]); +}); + +it('confirms bank transfer payment and updates statuses', function () { + Event::fake(); + + $this->paymentService->confirmBankTransfer($this->order); + + $this->order->refresh(); + $this->payment->refresh(); + + expect($this->order->financial_status)->toBe(FinancialStatus::Paid) + ->and($this->order->status)->toBe(OrderStatus::Paid) + ->and($this->payment->status)->toBe(PaymentStatus::Captured); + + Event::assertDispatched(OrderPaid::class); +}); + +it('commits inventory on bank transfer confirmation', function () { + $this->paymentService->confirmBankTransfer($this->order); + + $this->inventory->refresh(); + + expect($this->inventory->quantity_on_hand)->toBe(8) + ->and($this->inventory->quantity_reserved)->toBe(0); +}); + +it('rejects confirmation if order is not bank transfer', function () { + $order = Order::factory()->create([ + 'store_id' => $this->store->id, + 'payment_method' => PaymentMethod::CreditCard, + 'financial_status' => FinancialStatus::Paid, + ]); + + expect(fn () => $this->paymentService->confirmBankTransfer($order)) + ->toThrow(RuntimeException::class, 'Order is not a bank transfer order.'); +}); + +it('rejects confirmation if financial status is not pending', function () { + $this->order->update([ + 'financial_status' => FinancialStatus::Paid, + 'status' => OrderStatus::Paid, + ]); + + expect(fn () => $this->paymentService->confirmBankTransfer($this->order->fresh())) + ->toThrow(RuntimeException::class, 'Order financial status is not pending.'); +}); + +it('auto-fulfills digital products on bank transfer confirmation', function () { + // Make the variant digital + $this->variant->update(['requires_shipping' => false]); + + $this->paymentService->confirmBankTransfer($this->order); + + $this->order->refresh(); + + expect($this->order->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled) + ->and($this->order->status)->toBe(OrderStatus::Fulfilled) + ->and($this->order->fulfillments)->toHaveCount(1) + ->and($this->order->fulfillments->first()->status)->toBe(FulfillmentShipmentStatus::Delivered); +}); + +it('cancels unpaid bank transfer orders after timeout', function () { + Event::fake(); + + // Set placed_at to 8 days ago (past the 7-day default) + $this->order->update(['placed_at' => now()->subDays(8)->toIso8601String()]); + + $job = new CancelUnpaidBankTransferOrders; + $job->handle(app(\App\Services\InventoryService::class)); + + $this->order->refresh(); + $this->payment->refresh(); + $this->inventory->refresh(); + + expect($this->order->status)->toBe(OrderStatus::Cancelled) + ->and($this->order->financial_status)->toBe(FinancialStatus::Voided) + ->and($this->payment->status)->toBe(PaymentStatus::Failed) + ->and($this->inventory->quantity_reserved)->toBe(0) + ->and($this->inventory->quantity_on_hand)->toBe(10); + + Event::assertDispatched(OrderCancelled::class); +}); diff --git a/tests/Feature/Payments/MockPaymentProviderTest.php b/tests/Feature/Payments/MockPaymentProviderTest.php new file mode 100644 index 00000000..ba5053cd --- /dev/null +++ b/tests/Feature/Payments/MockPaymentProviderTest.php @@ -0,0 +1,128 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->provider = new MockPaymentProvider; + + $this->product = Product::factory()->create(['store_id' => $this->store->id]); + $this->variant = ProductVariant::factory()->create([ + 'product_id' => $this->product->id, + 'price_amount' => 2500, + ]); + + $this->cart = Cart::factory()->create(['store_id' => $this->store->id]); + CartLine::factory()->create([ + 'cart_id' => $this->cart->id, + 'variant_id' => $this->variant->id, + 'quantity' => 2, + 'unit_price_amount' => 2500, + 'line_subtotal_amount' => 5000, + 'line_total_amount' => 5000, + ]); +}); + +it('captures a credit card payment with success card', function () { + $checkout = Checkout::factory()->paymentSelected()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $this->cart->id, + 'payment_method' => PaymentMethod::CreditCard, + 'totals_json' => ['total_amount' => 5000], + ]); + + $result = $this->provider->charge($checkout, ['card_number' => '4242424242424242']); + + expect($result->success)->toBeTrue() + ->and($result->status)->toBe('captured') + ->and($result->providerPaymentId)->toStartWith('mock_') + ->and($result->errorCode)->toBeNull(); +}); + +it('declines a credit card with decline card number', function () { + $checkout = Checkout::factory()->paymentSelected()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $this->cart->id, + 'payment_method' => PaymentMethod::CreditCard, + 'totals_json' => ['total_amount' => 5000], + ]); + + $result = $this->provider->charge($checkout, ['card_number' => '4000000000000002']); + + expect($result->success)->toBeFalse() + ->and($result->status)->toBe('failed') + ->and($result->errorCode)->toBe('card_declined'); +}); + +it('declines a credit card with insufficient funds', function () { + $checkout = Checkout::factory()->paymentSelected()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $this->cart->id, + 'payment_method' => PaymentMethod::CreditCard, + 'totals_json' => ['total_amount' => 5000], + ]); + + $result = $this->provider->charge($checkout, ['card_number' => '4000000000009995']); + + expect($result->success)->toBeFalse() + ->and($result->status)->toBe('failed') + ->and($result->errorCode)->toBe('insufficient_funds'); +}); + +it('always succeeds for PayPal payments', function () { + $checkout = Checkout::factory()->paymentSelected()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $this->cart->id, + 'payment_method' => PaymentMethod::Paypal, + 'totals_json' => ['total_amount' => 5000], + ]); + + $result = $this->provider->charge($checkout, []); + + expect($result->success)->toBeTrue() + ->and($result->status)->toBe('captured') + ->and($result->providerPaymentId)->toStartWith('mock_'); +}); + +it('returns pending status for bank transfer payments', function () { + $checkout = Checkout::factory()->paymentSelected()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $this->cart->id, + 'payment_method' => PaymentMethod::BankTransfer, + 'totals_json' => ['total_amount' => 5000], + ]); + + $result = $this->provider->charge($checkout, []); + + expect($result->success)->toBeTrue() + ->and($result->status)->toBe('pending') + ->and($result->providerPaymentId)->toStartWith('mock_') + ->and($result->rawResponse)->toHaveKey('iban'); +}); + +it('processes a refund successfully', function () { + $order = \App\Models\Order::factory()->create([ + 'store_id' => $this->store->id, + 'total_amount' => 5000, + ]); + + $payment = \App\Models\Payment::factory()->create([ + 'order_id' => $order->id, + 'amount' => 5000, + 'status' => PaymentStatus::Captured, + ]); + + $result = $this->provider->refund($payment, 2500); + + expect($result->success)->toBeTrue() + ->and($result->status)->toBe('processed') + ->and($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..9bd94457 --- /dev/null +++ b/tests/Feature/Payments/PaymentServiceTest.php @@ -0,0 +1,178 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->paymentService = app(PaymentService::class); +}); + +it('resolves PaymentProvider from the container', function () { + $provider = app(PaymentProvider::class); + + expect($provider)->toBeInstanceOf(MockPaymentProvider::class); +}); + +it('does not auto-fulfill when order has physical products', function () { + $product = Product::factory()->create(['store_id' => $this->store->id]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'requires_shipping' => true, + ]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 10, + 'quantity_reserved' => 1, + 'policy' => 'deny', + ]); + + $customer = Customer::factory()->create(['store_id' => $this->store->id]); + $order = Order::factory()->pending()->create([ + 'store_id' => $this->store->id, + 'customer_id' => $customer->id, + ]); + OrderLine::factory()->create([ + 'order_id' => $order->id, + 'product_id' => $product->id, + 'variant_id' => $variant->id, + 'quantity' => 1, + ]); + Payment::factory()->pending()->create(['order_id' => $order->id]); + + $this->paymentService->confirmBankTransfer($order); + + $order->refresh(); + + expect($order->fulfillment_status)->toBe(FulfillmentStatus::Unfulfilled) + ->and($order->fulfillments)->toHaveCount(0); +}); + +it('does not auto-fulfill mixed physical and digital order', function () { + $product = Product::factory()->create(['store_id' => $this->store->id]); + $physicalVariant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'requires_shipping' => true, + ]); + $digitalVariant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'requires_shipping' => false, + ]); + + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $physicalVariant->id, + 'quantity_on_hand' => 10, + 'quantity_reserved' => 1, + 'policy' => 'deny', + ]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $digitalVariant->id, + 'quantity_on_hand' => 100, + 'quantity_reserved' => 1, + 'policy' => 'deny', + ]); + + $customer = Customer::factory()->create(['store_id' => $this->store->id]); + $order = Order::factory()->pending()->create([ + 'store_id' => $this->store->id, + 'customer_id' => $customer->id, + ]); + OrderLine::factory()->create([ + 'order_id' => $order->id, + 'variant_id' => $physicalVariant->id, + 'product_id' => $product->id, + 'quantity' => 1, + ]); + OrderLine::factory()->create([ + 'order_id' => $order->id, + 'variant_id' => $digitalVariant->id, + 'product_id' => $product->id, + 'quantity' => 1, + ]); + Payment::factory()->pending()->create(['order_id' => $order->id]); + + $this->paymentService->confirmBankTransfer($order); + + $order->refresh(); + + expect($order->fulfillment_status)->toBe(FulfillmentStatus::Unfulfilled) + ->and($order->fulfillments)->toHaveCount(0); +}); + +it('auto-fulfills all-digital order on bank transfer confirmation', function () { + $product = Product::factory()->create(['store_id' => $this->store->id]); + $digitalVariant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'requires_shipping' => false, + ]); + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $digitalVariant->id, + 'quantity_on_hand' => 100, + 'quantity_reserved' => 2, + 'policy' => 'deny', + ]); + + $customer = Customer::factory()->create(['store_id' => $this->store->id]); + $order = Order::factory()->pending()->create([ + 'store_id' => $this->store->id, + 'customer_id' => $customer->id, + ]); + OrderLine::factory()->create([ + 'order_id' => $order->id, + 'variant_id' => $digitalVariant->id, + 'product_id' => $product->id, + 'quantity' => 2, + ]); + Payment::factory()->pending()->create(['order_id' => $order->id]); + + $this->paymentService->confirmBankTransfer($order); + + $order->refresh(); + + expect($order->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled) + ->and($order->status)->toBe(OrderStatus::Fulfilled) + ->and($order->fulfillments)->toHaveCount(1) + ->and($order->fulfillments->first()->status)->toBe(FulfillmentShipmentStatus::Delivered) + ->and($order->fulfillments->first()->fulfillmentLines)->toHaveCount(1); +}); + +it('does not cancel orders within the timeout period', function () { + $product = Product::factory()->create(['store_id' => $this->store->id]); + $variant = ProductVariant::factory()->create(['product_id' => $product->id]); + + $customer = Customer::factory()->create(['store_id' => $this->store->id]); + $order = Order::factory()->pending()->create([ + 'store_id' => $this->store->id, + 'customer_id' => $customer->id, + 'placed_at' => now()->subDays(3)->toIso8601String(), + ]); + OrderLine::factory()->create([ + 'order_id' => $order->id, + 'variant_id' => $variant->id, + 'product_id' => $product->id, + ]); + Payment::factory()->pending()->create(['order_id' => $order->id]); + + $job = new \App\Jobs\CancelUnpaidBankTransferOrders; + $job->handle(app(\App\Services\InventoryService::class)); + + $order->refresh(); + + expect($order->status)->toBe(OrderStatus::Pending); +}); diff --git a/tests/Feature/Products/CollectionTest.php b/tests/Feature/Products/CollectionTest.php new file mode 100644 index 00000000..a76c413d --- /dev/null +++ b/tests/Feature/Products/CollectionTest.php @@ -0,0 +1,146 @@ +context = createStoreContext(); + $this->store = $this->context['store']; +}); + +it('creates a collection with a unique handle', function () { + $generator = app(HandleGenerator::class); + $handle = $generator->generate('Summer Sale', 'collections', $this->store->id); + + $collection = Collection::query()->create([ + 'store_id' => $this->store->id, + 'title' => 'Summer Sale', + 'handle' => $handle, + ]); + + expect($collection->handle)->toBe('summer-sale') + ->and($collection)->toBeInstanceOf(Collection::class); +}); + +it('adds products to a collection', function () { + $collection = Collection::factory()->create([ + 'store_id' => $this->store->id, + 'handle' => 'test-collection', + ]); + + $products = Product::factory()->count(3)->create([ + 'store_id' => $this->store->id, + ]); + + foreach ($products as $i => $product) { + $collection->products()->attach($product->id, ['position' => $i]); + } + + expect($collection->products)->toHaveCount(3); +}); + +it('removes products from a collection', function () { + $collection = Collection::factory()->create([ + 'store_id' => $this->store->id, + 'handle' => 'test-collection', + ]); + + $products = Product::factory()->count(3)->create([ + 'store_id' => $this->store->id, + ]); + + foreach ($products as $i => $product) { + $collection->products()->attach($product->id, ['position' => $i]); + } + + $collection->products()->detach($products->first()->id); + + expect($collection->products()->count())->toBe(2); +}); + +it('reorders products within a collection', function () { + $collection = Collection::factory()->create([ + 'store_id' => $this->store->id, + 'handle' => 'test-collection', + ]); + + $products = Product::factory()->count(3)->create([ + 'store_id' => $this->store->id, + ]); + + foreach ($products as $i => $product) { + $collection->products()->attach($product->id, ['position' => $i]); + } + + // Reorder: move last to first + $collection->products()->updateExistingPivot($products[0]->id, ['position' => 2]); + $collection->products()->updateExistingPivot($products[1]->id, ['position' => 0]); + $collection->products()->updateExistingPivot($products[2]->id, ['position' => 1]); + + $ordered = $collection->products()->orderByPivot('position')->get(); + expect($ordered->first()->id)->toBe($products[1]->id) + ->and($ordered->last()->id)->toBe($products[0]->id); +}); + +it('transitions collection from draft to active', function () { + $collection = Collection::factory()->create([ + 'store_id' => $this->store->id, + 'handle' => 'draft-collection', + 'status' => 'draft', + ]); + + $collection->update(['status' => CollectionStatus::Active]); + + $collection->refresh(); + expect($collection->status)->toBe(CollectionStatus::Active); +}); + +it('lists collections with product count', function () { + $collectionA = Collection::factory()->create([ + 'store_id' => $this->store->id, + 'handle' => 'collection-a', + ]); + + $collectionB = Collection::factory()->create([ + 'store_id' => $this->store->id, + 'handle' => 'collection-b', + ]); + + $productsA = Product::factory()->count(5)->create(['store_id' => $this->store->id]); + foreach ($productsA as $i => $p) { + $collectionA->products()->attach($p->id, ['position' => $i]); + } + + $productsB = Product::factory()->count(3)->create(['store_id' => $this->store->id]); + foreach ($productsB as $i => $p) { + $collectionB->products()->attach($p->id, ['position' => $i]); + } + + $collections = Collection::withCount('products')->get(); + $a = $collections->firstWhere('id', $collectionA->id); + $b = $collections->firstWhere('id', $collectionB->id); + + expect($a->products_count)->toBe(5) + ->and($b->products_count)->toBe(3); +}); + +it('scopes collections to current store', function () { + Collection::factory()->count(2)->create([ + 'store_id' => $this->store->id, + ]); + + $contextB = createStoreContext(); + $storeB = $contextB['store']; + + Collection::factory()->count(4)->create([ + 'store_id' => $storeB->id, + ]); + + app()->instance('current_store', $this->store); + expect(Collection::count())->toBe(2); + + app()->instance('current_store', $storeB); + expect(Collection::count())->toBe(4); +}); diff --git a/tests/Feature/Products/InventoryTest.php b/tests/Feature/Products/InventoryTest.php new file mode 100644 index 00000000..5cd147b6 --- /dev/null +++ b/tests/Feature/Products/InventoryTest.php @@ -0,0 +1,125 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->inventoryService = app(InventoryService::class); +}); + +it('creates inventory item when variant is created', function () { + $productService = app(ProductService::class); + $product = $productService->create($this->store, ['title' => 'Test Product']); + + $variant = $product->variants()->first(); + $inventoryItem = $variant->inventoryItem; + + expect($inventoryItem)->not->toBeNull() + ->and($inventoryItem->quantity_on_hand)->toBe(0) + ->and($inventoryItem->quantity_reserved)->toBe(0); +}); + +it('checks availability correctly', function () { + $item = InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'quantity_on_hand' => 10, + 'quantity_reserved' => 3, + 'policy' => 'deny', + ]); + + expect($this->inventoryService->checkAvailability($item, 7))->toBeTrue() + ->and($this->inventoryService->checkAvailability($item, 8))->toBeFalse(); +}); + +it('reserves inventory', function () { + $item = InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'quantity_on_hand' => 10, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]); + + $this->inventoryService->reserve($item, 3); + + $item->refresh(); + expect($item->quantity_reserved)->toBe(3) + ->and($item->quantity_on_hand)->toBe(10); +}); + +it('throws InsufficientInventoryException when reserving more than available with deny policy', function () { + $item = InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'quantity_on_hand' => 5, + 'quantity_reserved' => 3, + 'policy' => 'deny', + ]); + + expect(fn () => $this->inventoryService->reserve($item, 3)) + ->toThrow(InsufficientInventoryException::class); + + $item->refresh(); + expect($item->quantity_reserved)->toBe(3); +}); + +it('allows overselling with continue policy', function () { + $item = InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'quantity_on_hand' => 2, + 'quantity_reserved' => 0, + 'policy' => 'continue', + ]); + + $this->inventoryService->reserve($item, 5); + + $item->refresh(); + expect($item->quantity_reserved)->toBe(5); +}); + +it('releases reserved inventory', function () { + $item = InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'quantity_on_hand' => 10, + 'quantity_reserved' => 5, + 'policy' => 'deny', + ]); + + $this->inventoryService->release($item, 3); + + $item->refresh(); + expect($item->quantity_reserved)->toBe(2) + ->and($item->quantity_on_hand)->toBe(10); +}); + +it('commits inventory on order completion', function () { + $item = InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'quantity_on_hand' => 10, + 'quantity_reserved' => 3, + 'policy' => 'deny', + ]); + + $this->inventoryService->commit($item, 3); + + $item->refresh(); + expect($item->quantity_on_hand)->toBe(7) + ->and($item->quantity_reserved)->toBe(0); +}); + +it('restocks inventory', function () { + $item = InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'quantity_on_hand' => 5, + 'quantity_reserved' => 2, + 'policy' => 'deny', + ]); + + $this->inventoryService->restock($item, 10); + + $item->refresh(); + expect($item->quantity_on_hand)->toBe(15) + ->and($item->quantity_reserved)->toBe(2); +}); diff --git a/tests/Feature/Products/MediaUploadTest.php b/tests/Feature/Products/MediaUploadTest.php new file mode 100644 index 00000000..788682da --- /dev/null +++ b/tests/Feature/Products/MediaUploadTest.php @@ -0,0 +1,145 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + Storage::fake('public'); +}); + +it('uploads an image for a product', function () { + $product = Product::factory()->create([ + 'store_id' => $this->store->id, + ]); + + $file = UploadedFile::fake()->image('product.jpg', 800, 600); + $path = $file->store('products', 'public'); + + $media = ProductMedia::query()->create([ + 'product_id' => $product->id, + 'type' => 'image', + 'storage_key' => $path, + 'status' => 'processing', + 'position' => 0, + ]); + + expect($media->status)->toBe(MediaStatus::Processing) + ->and($media->type->value)->toBe('image') + ->and(Storage::disk('public')->exists($path))->toBeTrue(); +}); + +it('processes uploaded image and generates variants', function () { + $product = Product::factory()->create([ + 'store_id' => $this->store->id, + ]); + + $file = UploadedFile::fake()->image('product.jpg', 2000, 1500); + $path = $file->store('products', 'public'); + + $media = ProductMedia::query()->create([ + 'product_id' => $product->id, + 'type' => 'image', + 'storage_key' => $path, + 'status' => 'processing', + 'position' => 0, + ]); + + // Check if intervention/image is available + if (! class_exists(\Intervention\Image\ImageManager::class)) { + // Just verify the job can be dispatched + Queue::fake(); + ProcessMediaUpload::dispatch($media); + Queue::assertPushed(ProcessMediaUpload::class); + + return; + } + + $job = new ProcessMediaUpload($media); + $job->handle(); + + $media->refresh(); + expect($media->status)->toBe(MediaStatus::Ready) + ->and($media->width)->not->toBeNull() + ->and($media->height)->not->toBeNull(); +}); + +it('rejects non-image file types', function () { + $file = UploadedFile::fake()->create('document.txt', 100, 'text/plain'); + + $validator = \Illuminate\Support\Facades\Validator::make( + ['file' => $file], + ['file' => 'image|mimes:jpeg,png,gif,webp'] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('sets alt text on media', function () { + $product = Product::factory()->create([ + 'store_id' => $this->store->id, + ]); + + $media = ProductMedia::factory()->create([ + 'product_id' => $product->id, + ]); + + $media->update(['alt_text' => 'Product front view']); + + $media->refresh(); + expect($media->alt_text)->toBe('Product front view'); +}); + +it('reorders media positions', function () { + $product = Product::factory()->create([ + 'store_id' => $this->store->id, + ]); + + $mediaItems = []; + for ($i = 0; $i < 3; $i++) { + $mediaItems[] = ProductMedia::factory()->create([ + 'product_id' => $product->id, + 'position' => $i, + ]); + } + + $mediaItems[0]->update(['position' => 2]); + $mediaItems[1]->update(['position' => 0]); + $mediaItems[2]->update(['position' => 1]); + + $ordered = $product->media()->orderBy('position')->get(); + expect($ordered[0]->id)->toBe($mediaItems[1]->id) + ->and($ordered[1]->id)->toBe($mediaItems[2]->id) + ->and($ordered[2]->id)->toBe($mediaItems[0]->id); +}); + +it('deletes media and removes file from storage', function () { + $product = Product::factory()->create([ + 'store_id' => $this->store->id, + ]); + + $file = UploadedFile::fake()->image('product.jpg', 800, 600); + $path = $file->store('products', 'public'); + + $media = ProductMedia::query()->create([ + 'product_id' => $product->id, + 'type' => 'image', + 'storage_key' => $path, + 'status' => 'ready', + 'position' => 0, + ]); + + expect(Storage::disk('public')->exists($path))->toBeTrue(); + + Storage::disk('public')->delete($media->storage_key); + $media->delete(); + + expect(ProductMedia::find($media->id))->toBeNull() + ->and(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..be4cd462 --- /dev/null +++ b/tests/Feature/Products/ProductCrudTest.php @@ -0,0 +1,188 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->service = app(ProductService::class); +}); + +it('creates a product with a default variant', function () { + $product = $this->service->create($this->store, [ + 'title' => 'Summer T-Shirt', + 'description_html' => '

A cool shirt

', + ]); + + expect($product)->toBeInstanceOf(Product::class) + ->and($product->title)->toBe('Summer T-Shirt') + ->and($product->status)->toBe(ProductStatus::Draft) + ->and($product->variants)->toHaveCount(1) + ->and($product->variants->first()->is_default)->toBeTrue() + ->and($product->variants->first()->inventoryItem)->not->toBeNull() + ->and($product->variants->first()->inventoryItem->quantity_on_hand)->toBe(0) + ->and($product->variants->first()->inventoryItem->quantity_reserved)->toBe(0); +}); + +it('generates a unique handle from the title', function () { + $product = $this->service->create($this->store, [ + 'title' => 'Summer T-Shirt', + ]); + + expect($product->handle)->toBe('summer-t-shirt'); +}); + +it('appends suffix when handle collides', function () { + $this->service->create($this->store, ['title' => 'T-Shirt']); + $product2 = $this->service->create($this->store, ['title' => 'T-Shirt']); + + expect($product2->handle)->toBe('t-shirt-1'); +}); + +it('updates a product', function () { + $product = $this->service->create($this->store, ['title' => 'Old Title']); + + $updated = $this->service->update($product, [ + 'title' => 'New Title', + 'description_html' => 'New description', + ]); + + expect($updated->title)->toBe('New Title') + ->and($updated->description_html)->toBe('New description'); +}); + +it('transitions product from draft to active', function () { + $product = $this->service->create($this->store, ['title' => 'Test Product']); + $product->variants()->first()->update(['price_amount' => 2999]); + + $this->service->transitionStatus($product, ProductStatus::Active); + + $product->refresh(); + expect($product->status)->toBe(ProductStatus::Active) + ->and($product->published_at)->not->toBeNull(); +}); + +it('rejects draft to active without a priced variant', function () { + $product = $this->service->create($this->store, ['title' => 'Test Product']); + + expect(fn () => $this->service->transitionStatus($product, ProductStatus::Active)) + ->toThrow(InvalidProductTransitionException::class); + + $product->refresh(); + expect($product->status)->toBe(ProductStatus::Draft); +}); + +it('transitions product from active to archived', function () { + $product = $this->service->create($this->store, ['title' => 'Test Product']); + $product->variants()->first()->update(['price_amount' => 2999]); + $this->service->transitionStatus($product, ProductStatus::Active); + + $this->service->transitionStatus($product, ProductStatus::Archived); + + $product->refresh(); + expect($product->status)->toBe(ProductStatus::Archived); +}); + +it('prevents active to draft when order lines exist', function () { + $product = $this->service->create($this->store, ['title' => 'Test Product']); + $product->variants()->first()->update(['price_amount' => 2999]); + $this->service->transitionStatus($product, ProductStatus::Active); + + if (\Illuminate\Support\Facades\Schema::hasTable('order_lines')) { + $variant = $product->variants()->first(); + $order = \App\Models\Order::factory()->create(['store_id' => $this->store->id]); + \App\Models\OrderLine::factory()->create([ + 'order_id' => $order->id, + 'variant_id' => $variant->id, + 'product_id' => $product->id, + 'title_snapshot' => 'Test', + 'quantity' => 1, + 'unit_price_amount' => 2999, + 'total_amount' => 2999, + ]); + + expect(fn () => $this->service->transitionStatus($product, ProductStatus::Draft)) + ->toThrow(InvalidProductTransitionException::class); + + $product->refresh(); + expect($product->status)->toBe(ProductStatus::Active); + } else { + // order_lines table not yet created; transition should succeed + $this->service->transitionStatus($product, ProductStatus::Draft); + $product->refresh(); + expect($product->status)->toBe(ProductStatus::Draft); + } +}); + +it('hard deletes a draft product with no order references', function () { + $product = $this->service->create($this->store, ['title' => 'Test Product']); + + $this->service->delete($product); + + expect(Product::withoutGlobalScopes()->find($product->id))->toBeNull(); +}); + +it('prevents deletion of product with order references', function () { + $product = $this->service->create($this->store, ['title' => 'Test Product']); + + if (\Illuminate\Support\Facades\Schema::hasTable('order_lines')) { + $variant = $product->variants()->first(); + $order = \App\Models\Order::factory()->create(['store_id' => $this->store->id]); + \App\Models\OrderLine::factory()->create([ + 'order_id' => $order->id, + 'variant_id' => $variant->id, + 'product_id' => $product->id, + 'title_snapshot' => 'Test', + 'quantity' => 1, + 'unit_price_amount' => 2999, + 'total_amount' => 2999, + ]); + + expect(fn () => $this->service->delete($product)) + ->toThrow(InvalidProductTransitionException::class); + + expect(Product::withoutGlobalScopes()->find($product->id))->not->toBeNull(); + } else { + // Without order_lines table, deletion should succeed + $this->service->delete($product); + expect(Product::withoutGlobalScopes()->find($product->id))->toBeNull(); + } +}); + +it('prevents deletion of non-draft products', function () { + $product = $this->service->create($this->store, ['title' => 'Test Product']); + $product->variants()->first()->update(['price_amount' => 2999]); + $this->service->transitionStatus($product, ProductStatus::Active); + + expect(fn () => $this->service->delete($product)) + ->toThrow(InvalidProductTransitionException::class); + + expect(Product::withoutGlobalScopes()->find($product->id))->not->toBeNull(); +}); + +it('filters products by status', function () { + for ($i = 0; $i < 3; $i++) { + $p = $this->service->create($this->store, ['title' => "Active Product $i"]); + $p->variants()->first()->update(['price_amount' => 2999]); + $this->service->transitionStatus($p, ProductStatus::Active); + } + + for ($i = 0; $i < 2; $i++) { + $this->service->create($this->store, ['title' => "Draft Product $i"]); + } + + $activeProducts = Product::where('status', ProductStatus::Active)->get(); + expect($activeProducts)->toHaveCount(3); +}); + +it('searches products by title', function () { + $this->service->create($this->store, ['title' => 'Organic Cotton Hoodie']); + $this->service->create($this->store, ['title' => 'Silk Blouse']); + + $results = Product::where('title', 'like', '%cotton%')->get(); + expect($results)->toHaveCount(1) + ->and($results->first()->title)->toBe('Organic Cotton Hoodie'); +}); diff --git a/tests/Feature/Products/VariantTest.php b/tests/Feature/Products/VariantTest.php new file mode 100644 index 00000000..c1898a8c --- /dev/null +++ b/tests/Feature/Products/VariantTest.php @@ -0,0 +1,235 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->productService = app(ProductService::class); + $this->matrixService = app(VariantMatrixService::class); +}); + +it('creates variants from option matrix', function () { + $product = $this->productService->create($this->store, ['title' => 'Test Product']); + + $sizeOption = ProductOption::factory()->create([ + 'product_id' => $product->id, + 'name' => 'Size', + 'position' => 0, + ]); + foreach (['S', 'M', 'L'] as $i => $size) { + ProductOptionValue::factory()->create([ + 'product_option_id' => $sizeOption->id, + 'value' => $size, + 'position' => $i, + ]); + } + + $colorOption = ProductOption::factory()->create([ + 'product_id' => $product->id, + 'name' => 'Color', + 'position' => 1, + ]); + foreach (['Red', 'Blue'] as $i => $color) { + ProductOptionValue::factory()->create([ + 'product_option_id' => $colorOption->id, + 'value' => $color, + 'position' => $i, + ]); + } + + $this->matrixService->rebuildMatrix($product); + + $activeVariants = $product->variants()->where('status', 'active')->count(); + expect($activeVariants)->toBe(6); +}); + +it('preserves existing variants when adding an option value', function () { + $product = $this->productService->create($this->store, ['title' => 'Test Product']); + + $sizeOption = ProductOption::factory()->create([ + 'product_id' => $product->id, + 'name' => 'Size', + 'position' => 0, + ]); + $valueS = ProductOptionValue::factory()->create([ + 'product_option_id' => $sizeOption->id, + 'value' => 'S', + 'position' => 0, + ]); + $valueM = ProductOptionValue::factory()->create([ + 'product_option_id' => $sizeOption->id, + 'value' => 'M', + 'position' => 1, + ]); + + $this->matrixService->rebuildMatrix($product); + + $product->variants()->where('status', 'active')->get()->each(function ($v) { + $v->update(['price_amount' => 1999]); + }); + + ProductOptionValue::factory()->create([ + 'product_option_id' => $sizeOption->id, + 'value' => 'L', + 'position' => 2, + ]); + + $this->matrixService->rebuildMatrix($product); + + $activeVariants = $product->variants()->where('status', 'active')->get(); + expect($activeVariants)->toHaveCount(3); + + // All variants should have price 1999 (new one inherits from first existing) + $pricedVariants = $activeVariants->filter(fn ($v) => $v->price_amount === 1999); + expect($pricedVariants)->toHaveCount(3); +}); + +it('archives orphaned variants with order references', function () { + if (! \Illuminate\Support\Facades\Schema::hasTable('order_lines')) { + // Create a temporary order_lines table for this test + \Illuminate\Support\Facades\Schema::create('order_lines', function ($table) { + $table->id(); + $table->integer('order_id'); + $table->integer('variant_id')->nullable(); + $table->integer('product_id')->nullable(); + $table->text('title'); + $table->integer('quantity'); + $table->integer('unit_price_amount'); + $table->integer('subtotal_amount'); + $table->integer('total_amount'); + }); + } + + $product = $this->productService->create($this->store, ['title' => 'Test Product']); + + $sizeOption = ProductOption::factory()->create([ + 'product_id' => $product->id, + 'name' => 'Size', + 'position' => 0, + ]); + foreach (['S', 'M', 'L'] as $i => $size) { + ProductOptionValue::factory()->create([ + 'product_option_id' => $sizeOption->id, + 'value' => $size, + 'position' => $i, + ]); + } + + $this->matrixService->rebuildMatrix($product); + + $variantL = $product->variants()->whereHas('optionValues', function ($q) { + $q->where('value', 'L'); + })->first(); + + $order = \App\Models\Order::factory()->create(['store_id' => $this->store->id]); + \App\Models\OrderLine::factory()->create([ + 'order_id' => $order->id, + 'variant_id' => $variantL->id, + 'product_id' => $product->id, + 'title_snapshot' => 'Test', + 'quantity' => 1, + 'unit_price_amount' => 2999, + 'total_amount' => 2999, + ]); + + $lValue = ProductOptionValue::where('product_option_id', $sizeOption->id) + ->where('value', 'L') + ->first(); + $lValue->delete(); + + $this->matrixService->rebuildMatrix($product); + + $activeVariants = $product->variants()->where('status', 'active')->count(); + expect($activeVariants)->toBe(2); + + $variantL->refresh(); + expect($variantL->status->value)->toBe('archived'); +}); + +it('deletes orphaned variants without order references', function () { + $product = $this->productService->create($this->store, ['title' => 'Test Product']); + + $sizeOption = ProductOption::factory()->create([ + 'product_id' => $product->id, + 'name' => 'Size', + 'position' => 0, + ]); + foreach (['S', 'M', 'L'] as $i => $size) { + ProductOptionValue::factory()->create([ + 'product_option_id' => $sizeOption->id, + 'value' => $size, + 'position' => $i, + ]); + } + + $this->matrixService->rebuildMatrix($product); + + $variantLId = $product->variants()->whereHas('optionValues', function ($q) { + $q->where('value', 'L'); + })->first()->id; + + $lValue = ProductOptionValue::where('product_option_id', $sizeOption->id) + ->where('value', 'L') + ->first(); + $lValue->delete(); + + $this->matrixService->rebuildMatrix($product); + + expect(ProductVariant::find($variantLId))->toBeNull(); + expect($product->variants()->where('status', 'active')->count())->toBe(2); +}); + +it('auto-creates default variant for products without options', function () { + $product = Product::factory()->create([ + 'store_id' => $this->store->id, + 'handle' => 'no-options-product', + ]); + + $this->matrixService->rebuildMatrix($product); + + $variants = $product->variants()->get(); + expect($variants)->toHaveCount(1) + ->and($variants->first()->is_default)->toBeTrue(); +}); + +it('validates SKU uniqueness within store', function () { + $product1 = $this->productService->create($this->store, ['title' => 'Product 1']); + $product1->variants()->first()->update(['sku' => 'TSH-001']); + + $product2 = $this->productService->create($this->store, ['title' => 'Product 2']); + + $existingSku = ProductVariant::query() + ->whereHas('product', fn ($q) => $q->withoutGlobalScopes()->where('store_id', $this->store->id)) + ->where('sku', 'TSH-001') + ->exists(); + + expect($existingSku)->toBeTrue(); +}); + +it('allows duplicate SKU across different stores', function () { + $product1 = $this->productService->create($this->store, ['title' => 'Product 1']); + $product1->variants()->first()->update(['sku' => 'TSH-001']); + + $contextB = createStoreContext(); + $storeB = $contextB['store']; + app()->instance('current_store', $storeB); + + $product2 = $this->productService->create($storeB, ['title' => 'Product 2']); + $product2->variants()->first()->update(['sku' => 'TSH-001']); + + expect($product2->variants()->first()->sku)->toBe('TSH-001'); +}); + +it('allows null SKUs', function () { + $product = $this->productService->create($this->store, ['title' => 'Product 1']); + $product2 = $this->productService->create($this->store, ['title' => 'Product 2']); + + expect($product->variants()->first()->sku)->toBeNull() + ->and($product2->variants()->first()->sku)->toBeNull(); +}); diff --git a/tests/Feature/Search/AutocompleteTest.php b/tests/Feature/Search/AutocompleteTest.php new file mode 100644 index 00000000..019ed292 --- /dev/null +++ b/tests/Feature/Search/AutocompleteTest.php @@ -0,0 +1,60 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->service = app(SearchService::class); +}); + +it('returns prefix matches for autocomplete', function () { + $summer1 = Product::factory()->active()->create([ + 'store_id' => $this->store->id, + 'title' => 'Summer Dress', + ]); + $this->service->syncProduct($summer1); + + $summer2 = Product::factory()->active()->create([ + 'store_id' => $this->store->id, + 'title' => 'Summer Shorts', + ]); + $this->service->syncProduct($summer2); + + $winter = Product::factory()->active()->create([ + 'store_id' => $this->store->id, + 'title' => 'Winter Coat', + ]); + $this->service->syncProduct($winter); + + $results = $this->service->autocomplete($this->store, 'sum'); + + expect($results)->toHaveCount(2) + ->and($results->pluck('title')->toArray())->each->toContain('Summer'); +}); + +it('respects the limit parameter', function () { + foreach (range(1, 10) as $i) { + $product = Product::factory()->active()->create([ + 'store_id' => $this->store->id, + 'title' => "Alpha Product {$i}", + ]); + $this->service->syncProduct($product); + } + + $results = $this->service->autocomplete($this->store, 'alpha', 5); + + expect($results)->toHaveCount(5); +}); + +it('returns empty for very short prefixes', function () { + Product::factory()->active()->create([ + 'store_id' => $this->store->id, + 'title' => 'Apple Watch', + ]); + + $results = $this->service->autocomplete($this->store, 'a'); + + expect($results)->toBeEmpty(); +}); diff --git a/tests/Feature/Search/SearchTest.php b/tests/Feature/Search/SearchTest.php new file mode 100644 index 00000000..ff45fdfa --- /dev/null +++ b/tests/Feature/Search/SearchTest.php @@ -0,0 +1,97 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->service = app(SearchService::class); +}); + +it('returns matching products for a search query', function () { + $product = Product::factory()->active()->create([ + 'store_id' => $this->store->id, + 'title' => 'Red Running Shoes', + 'vendor' => 'Nike', + ]); + $this->service->syncProduct($product); + + $other = Product::factory()->active()->create([ + 'store_id' => $this->store->id, + 'title' => 'Blue Hiking Boots', + 'vendor' => 'Adidas', + ]); + $this->service->syncProduct($other); + + $results = $this->service->search($this->store, 'running'); + + expect($results->total())->toBe(1) + ->and($results->items()[0]->id)->toBe($product->id); +}); + +it('scopes search results to the current store', function () { + $storeA = $this->store; + $productA = Product::factory()->active()->create([ + 'store_id' => $storeA->id, + 'title' => 'Laptop Stand', + ]); + $this->service->syncProduct($productA); + + $storeB = \App\Models\Store::factory()->create(); + $productB = Product::factory()->active()->create([ + 'store_id' => $storeB->id, + 'title' => 'Laptop Bag', + ]); + $this->service->syncProduct($productB); + + $results = $this->service->search($storeA, 'laptop'); + + expect($results->total())->toBe(1) + ->and($results->items()[0]->id)->toBe($productA->id); +}); + +it('returns empty results when nothing matches', function () { + Product::factory()->active()->create([ + 'store_id' => $this->store->id, + 'title' => 'Red Running Shoes', + ]); + + $results = $this->service->search($this->store, 'xyznonexistent'); + + expect($results->total())->toBe(0); +}); + +it('logs the search query with result count', function () { + $product = Product::factory()->active()->create([ + 'store_id' => $this->store->id, + 'title' => 'Organic Coffee Beans', + ]); + $this->service->syncProduct($product); + + $this->service->search($this->store, 'coffee'); + + $log = \App\Models\SearchQuery::query() + ->withoutGlobalScopes() + ->where('store_id', $this->store->id) + ->first(); + + expect($log)->not->toBeNull() + ->and($log->query)->toBe('coffee') + ->and($log->results_count)->toBe(1); +}); + +it('paginates search results', function () { + foreach (range(1, 30) as $i) { + $product = Product::factory()->active()->create([ + 'store_id' => $this->store->id, + 'title' => "Widget Model {$i}", + ]); + $this->service->syncProduct($product); + } + + $results = $this->service->search($this->store, 'widget', [], 12); + + expect($results)->toHaveCount(12) + ->and($results->total())->toBe(30); +}); diff --git a/tests/Feature/SeedDataTest.php b/tests/Feature/SeedDataTest.php new file mode 100644 index 00000000..1d70c2aa --- /dev/null +++ b/tests/Feature/SeedDataTest.php @@ -0,0 +1,297 @@ +seed(); +}); + +// -- Stores and Organization -- + +test('seeds two stores under one organization', function () { + expect(Organization::count())->toBe(1); + expect(Store::count())->toBe(2); + + $fashion = Store::where('handle', 'acme-fashion')->first(); + $electronics = Store::where('handle', 'acme-electronics')->first(); + expect($fashion)->not->toBeNull(); + expect($electronics)->not->toBeNull(); +}); + +test('seeds store domains correctly', function () { + expect(StoreDomain::count())->toBe(4); + expect(StoreDomain::where('hostname', 'acme-fashion.test')->where('is_primary', true)->exists())->toBeTrue(); + expect(StoreDomain::where('hostname', 'admin.acme-fashion.test')->where('type', 'admin')->exists())->toBeTrue(); +}); + +// -- Users -- + +test('seeds five users with correct roles', function () { + expect(User::count())->toBe(5); + expect(User::where('email', 'admin@acme.test')->exists())->toBeTrue(); + expect(User::where('email', 'staff@acme.test')->exists())->toBeTrue(); +}); + +// -- Store Settings -- + +test('seeds store settings for both stores', function () { + expect(StoreSettings::count())->toBe(2); + $fashion = Store::where('handle', 'acme-fashion')->first(); + $settings = StoreSettings::where('store_id', $fashion->id)->first(); + expect($settings->settings_json['order_number_prefix'])->toBe('#'); + expect($settings->settings_json['order_number_start'])->toBe(1001); +}); + +// -- Tax Settings -- + +test('seeds tax settings for both stores', function () { + expect(TaxSettings::count())->toBe(2); +}); + +// -- Shipping -- + +test('seeds fashion store with three shipping zones', function () { + $fashion = Store::where('handle', 'acme-fashion')->first(); + app()->instance('current_store', $fashion); + expect(ShippingZone::where('store_id', $fashion->id)->count())->toBe(3); + expect(ShippingRate::count())->toBeGreaterThanOrEqual(4); +}); + +// -- Collections -- + +test('seeds fashion store with four collections', function () { + $fashion = Store::where('handle', 'acme-fashion')->first(); + app()->instance('current_store', $fashion); + expect(Collection::where('store_id', $fashion->id)->count())->toBe(4); + expect(Collection::where('handle', 'new-arrivals')->exists())->toBeTrue(); + expect(Collection::where('handle', 't-shirts')->exists())->toBeTrue(); + expect(Collection::where('handle', 'pants-jeans')->exists())->toBeTrue(); + expect(Collection::where('handle', 'sale')->exists())->toBeTrue(); +}); + +// -- Products -- + +test('seeds twenty fashion products', function () { + $fashion = Store::where('handle', 'acme-fashion')->first(); + app()->instance('current_store', $fashion); + expect(Product::where('store_id', $fashion->id)->withoutGlobalScopes()->count())->toBe(20); +}); + +test('seeds five electronics products', function () { + $electronics = Store::where('handle', 'acme-electronics')->first(); + app()->instance('current_store', $electronics); + expect(Product::where('store_id', $electronics->id)->withoutGlobalScopes()->count())->toBe(5); +}); + +test('classic cotton t-shirt has twelve variants at correct price', function () { + $fashion = Store::where('handle', 'acme-fashion')->first(); + app()->instance('current_store', $fashion); + $product = Product::where('handle', 'classic-cotton-t-shirt')->first(); + expect($product)->not->toBeNull(); + expect($product->variants()->count())->toBe(12); + $defaultVariant = $product->variants()->where('is_default', true)->first(); + expect($defaultVariant->price_amount)->toBe(2499); +}); + +test('premium slim fit jeans has sale price', function () { + $fashion = Store::where('handle', 'acme-fashion')->first(); + app()->instance('current_store', $fashion); + $product = Product::where('handle', 'premium-slim-fit-jeans')->first(); + $defaultVariant = $product->variants()->where('is_default', true)->first(); + expect($defaultVariant->price_amount)->toBe(7999); + expect($defaultVariant->compare_at_amount)->toBe(9999); +}); + +test('draft product is seeded', function () { + $fashion = Store::where('handle', 'acme-fashion')->first(); + $product = Product::withoutGlobalScopes() + ->where('store_id', $fashion->id) + ->where('status', 'draft') + ->first(); + expect($product)->not->toBeNull(); +}); + +test('gift card product is digital', function () { + $fashion = Store::where('handle', 'acme-fashion')->first(); + app()->instance('current_store', $fashion); + $product = Product::where('handle', 'gift-card')->first(); + expect($product)->not->toBeNull(); + expect($product->product_type)->toBe('Gift Cards'); + $variant = $product->variants()->first(); + expect($variant->requires_shipping)->toBeFalsy(); + expect($product->variants()->count())->toBe(3); +}); + +// -- Discounts -- + +test('seeds five discounts with correct codes', function () { + $fashion = Store::where('handle', 'acme-fashion')->first(); + app()->instance('current_store', $fashion); + expect(Discount::where('store_id', $fashion->id)->count())->toBe(5); + expect(Discount::where('code', 'WELCOME10')->exists())->toBeTrue(); + expect(Discount::where('code', 'FLAT5')->exists())->toBeTrue(); + expect(Discount::where('code', 'FREESHIP')->exists())->toBeTrue(); + expect(Discount::where('code', 'EXPIRED20')->exists())->toBeTrue(); + expect(Discount::where('code', 'MAXED')->exists())->toBeTrue(); +}); + +test('expired discount has past dates', function () { + $fashion = Store::where('handle', 'acme-fashion')->first(); + app()->instance('current_store', $fashion); + $expired = Discount::where('code', 'EXPIRED20')->first(); + expect($expired->ends_at)->not->toBeNull(); + expect(now()->isAfter($expired->ends_at))->toBeTrue(); +}); + +test('maxed discount has reached usage limit', function () { + $fashion = Store::where('handle', 'acme-fashion')->first(); + app()->instance('current_store', $fashion); + $maxed = Discount::where('code', 'MAXED')->first(); + expect($maxed->usage_count)->toBe($maxed->usage_limit); +}); + +// -- Customers -- + +test('seeds ten fashion customers', function () { + $fashion = Store::where('handle', 'acme-fashion')->first(); + app()->instance('current_store', $fashion); + expect(Customer::where('store_id', $fashion->id)->count())->toBe(10); +}); + +test('seeds two electronics customers for tenant isolation', function () { + $electronics = Store::where('handle', 'acme-electronics')->first(); + app()->instance('current_store', $electronics); + expect(Customer::where('store_id', $electronics->id)->count())->toBe(2); +}); + +test('john doe has two addresses', function () { + $fashion = Store::where('handle', 'acme-fashion')->first(); + app()->instance('current_store', $fashion); + $john = Customer::where('email', 'customer@acme.test')->first(); + expect($john->addresses()->count())->toBe(2); + expect($john->addresses()->where('is_default', true)->count())->toBe(1); +}); + +// -- Orders -- + +test('seeds fifteen fashion orders', function () { + $fashion = Store::where('handle', 'acme-fashion')->first(); + app()->instance('current_store', $fashion); + expect(Order::where('store_id', $fashion->id)->count())->toBe(15); +}); + +test('seeds three electronics orders', function () { + $electronics = Store::where('handle', 'acme-electronics')->first(); + app()->instance('current_store', $electronics); + expect(Order::where('store_id', $electronics->id)->count())->toBe(3); +}); + +test('order 1001 exists and is unfulfilled', function () { + $fashion = Store::where('handle', 'acme-fashion')->first(); + app()->instance('current_store', $fashion); + $order = Order::where('order_number', '#1001')->first(); + expect($order)->not->toBeNull(); + expect($order->fulfillment_status->value)->toBe('unfulfilled'); +}); + +// -- Themes -- + +test('seeds themes for both stores', function () { + expect(Theme::withoutGlobalScopes()->count())->toBe(2); +}); + +// -- Pages -- + +test('seeds five fashion pages', function () { + $fashion = Store::where('handle', 'acme-fashion')->first(); + app()->instance('current_store', $fashion); + expect(Page::where('store_id', $fashion->id)->count())->toBe(5); + expect(Page::where('handle', 'about')->exists())->toBeTrue(); + expect(Page::where('handle', 'faq')->exists())->toBeTrue(); +}); + +// -- Navigation -- + +test('seeds fashion main and footer menus', function () { + $fashion = Store::where('handle', 'acme-fashion')->first(); + app()->instance('current_store', $fashion); + $mainMenu = NavigationMenu::where('store_id', $fashion->id)->where('handle', 'main-menu')->first(); + $footerMenu = NavigationMenu::where('store_id', $fashion->id)->where('handle', 'footer-menu')->first(); + expect($mainMenu)->not->toBeNull(); + expect($footerMenu)->not->toBeNull(); + expect(NavigationItem::where('menu_id', $mainMenu->id)->count())->toBe(5); + expect(NavigationItem::where('menu_id', $footerMenu->id)->count())->toBe(5); +}); + +// -- Analytics -- + +test('seeds thirty-one days of daily analytics', function () { + $fashion = Store::where('handle', 'acme-fashion')->first(); + app()->instance('current_store', $fashion); + expect(AnalyticsDaily::where('store_id', $fashion->id)->count())->toBe(31); +}); + +test('seeds analytics events', function () { + $fashion = Store::where('handle', 'acme-fashion')->first(); + app()->instance('current_store', $fashion); + expect(AnalyticsEvent::where('store_id', $fashion->id)->count())->toBeGreaterThan(200); +}); + +// -- Search Settings -- + +test('seeds search settings for both stores', function () { + expect(SearchSettings::count())->toBe(2); +}); + +// -- Error Pages -- + +test('404 page renders for nonexistent routes', function () { + $fashion = Store::where('handle', 'acme-fashion')->first(); + $domain = StoreDomain::where('store_id', $fashion->id)->where('is_primary', true)->first(); + + $this->withServerVariables(['HTTP_HOST' => $domain->hostname]) + ->get('/nonexistent-page-xyz') + ->assertNotFound(); +}); + +// -- Accessibility -- + +test('storefront layout includes skip-to-content link', function () { + $fashion = Store::where('handle', 'acme-fashion')->first(); + $domain = StoreDomain::where('store_id', $fashion->id)->where('is_primary', true)->first(); + + $response = $this->withServerVariables(['HTTP_HOST' => $domain->hostname])->get('/'); + $response->assertSuccessful(); + $response->assertSee('Skip to main content'); + $response->assertSee('id="main-content"', false); +}); + +test('admin layout includes skip-to-content link', function () { + $admin = User::where('email', 'admin@acme.test')->first(); + $fashion = Store::where('handle', 'acme-fashion')->first(); + $adminDomain = StoreDomain::where('store_id', $fashion->id)->where('type', 'admin')->first(); + + $this->actingAs($admin); + $response = $this->withServerVariables(['HTTP_HOST' => $adminDomain->hostname]) + ->get('/'); + $response->assertSee('Skip to main content'); +}); diff --git a/tests/Feature/Services/NavigationServiceTest.php b/tests/Feature/Services/NavigationServiceTest.php new file mode 100644 index 00000000..bd4caa7e --- /dev/null +++ b/tests/Feature/Services/NavigationServiceTest.php @@ -0,0 +1,91 @@ +create(); + NavigationItem::factory()->create(['menu_id' => $menu->id, 'label' => 'Home', 'url' => '/', 'position' => 0]); + NavigationItem::factory()->create(['menu_id' => $menu->id, 'label' => 'About', 'url' => '/about', 'position' => 1]); + + $service = app(NavigationService::class); + $tree = $service->buildTree($menu); + + expect($tree)->toHaveCount(2); + expect($tree[0]['label'])->toBe('Home'); + expect($tree[0]['url'])->toBe('/'); + expect($tree[1]['label'])->toBe('About'); +}); + +it('resolves url for link type', function () { + $item = NavigationItem::factory()->create([ + 'type' => 'link', + 'url' => 'https://example.com', + ]); + + $service = app(NavigationService::class); + + expect($service->resolveUrl($item))->toBe('https://example.com'); +}); + +it('resolves url for page type', function () { + $store = Store::factory()->create(); + $page = Page::factory()->create(['store_id' => $store->id, 'handle' => 'about-us']); + + $item = NavigationItem::factory()->create([ + 'type' => 'page', + 'url' => null, + 'resource_id' => $page->id, + ]); + + $service = app(NavigationService::class); + + expect($service->resolveUrl($item))->toBe('/pages/about-us'); +}); + +it('resolves url for collection type', function () { + $store = Store::factory()->create(); + $collection = Collection::factory()->create(['store_id' => $store->id, 'handle' => 'summer']); + + $item = NavigationItem::factory()->create([ + 'type' => 'collection', + 'url' => null, + 'resource_id' => $collection->id, + ]); + + $service = app(NavigationService::class); + + expect($service->resolveUrl($item))->toBe('/collections/summer'); +}); + +it('resolves url for product type', function () { + $store = Store::factory()->create(); + $product = Product::factory()->create(['store_id' => $store->id, 'handle' => 't-shirt']); + + $item = NavigationItem::factory()->create([ + 'type' => 'product', + 'url' => null, + 'resource_id' => $product->id, + ]); + + $service = app(NavigationService::class); + + expect($service->resolveUrl($item))->toBe('/products/t-shirt'); +}); + +it('returns hash when resource is not found', function () { + $item = NavigationItem::factory()->create([ + 'type' => 'page', + 'url' => null, + 'resource_id' => 9999, + ]); + + $service = app(NavigationService::class); + + expect($service->resolveUrl($item))->toBe('#'); +}); diff --git a/tests/Feature/Services/ThemeSettingsServiceTest.php b/tests/Feature/Services/ThemeSettingsServiceTest.php new file mode 100644 index 00000000..9a6537cd --- /dev/null +++ b/tests/Feature/Services/ThemeSettingsServiceTest.php @@ -0,0 +1,72 @@ +toBe($instance2); +}); + +it('loads published theme settings for a store', function () { + $store = Store::factory()->create(); + $theme = Theme::factory()->published()->create(['store_id' => $store->id]); + ThemeSettings::factory()->create([ + 'theme_id' => $theme->id, + 'settings_json' => [ + 'announcement_bar' => ['enabled' => true, 'text' => 'Free shipping over 50 EUR'], + ], + ]); + + $service = app(ThemeSettingsService::class); + $settings = $service->getSettings($store); + + expect($settings['announcement_bar']['enabled'])->toBeTrue(); + expect($settings['announcement_bar']['text'])->toBe('Free shipping over 50 EUR'); +}); + +it('returns defaults when no published theme exists', function () { + $store = Store::factory()->create(); + Theme::factory()->create(['store_id' => $store->id, 'status' => 'draft']); + + $service = app(ThemeSettingsService::class); + $settings = $service->getSettings($store); + + expect($settings['announcement_bar']['enabled'])->toBeFalse(); +}); + +it('caches loaded settings in memory', function () { + $store = Store::factory()->create(); + $theme = Theme::factory()->published()->create(['store_id' => $store->id]); + ThemeSettings::factory()->create([ + 'theme_id' => $theme->id, + 'settings_json' => ['dark_mode' => 'toggle'], + ]); + + $service = app(ThemeSettingsService::class); + $settings1 = $service->getSettings($store); + $settings2 = $service->getSettings($store); + + expect($settings1)->toBe($settings2); +}); + +it('retrieves nested setting via get method', function () { + $store = Store::factory()->create(); + $theme = Theme::factory()->published()->create(['store_id' => $store->id]); + ThemeSettings::factory()->create([ + 'theme_id' => $theme->id, + 'settings_json' => [ + 'header' => ['sticky' => true, 'logo_url' => null], + ], + ]); + + $service = app(ThemeSettingsService::class); + + expect($service->get($store, 'header.sticky'))->toBeTrue(); + expect($service->get($store, 'header.logo_url'))->toBeNull(); + expect($service->get($store, 'nonexistent', 'fallback'))->toBe('fallback'); +}); diff --git a/tests/Feature/Settings/PasswordUpdateTest.php b/tests/Feature/Settings/PasswordUpdateTest.php index a6379b2b..759e3b26 100644 --- a/tests/Feature/Settings/PasswordUpdateTest.php +++ b/tests/Feature/Settings/PasswordUpdateTest.php @@ -39,4 +39,4 @@ ->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 index 276e9fef..fa5f185e 100644 --- a/tests/Feature/Settings/ProfileUpdateTest.php +++ b/tests/Feature/Settings/ProfileUpdateTest.php @@ -75,4 +75,4 @@ $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 index e2d530fb..b57a3202 100644 --- a/tests/Feature/Settings/TwoFactorAuthenticationTest.php +++ b/tests/Feature/Settings/TwoFactorAuthenticationTest.php @@ -69,4 +69,4 @@ 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, ]); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Storefront/RouteAccessibilityTest.php b/tests/Feature/Storefront/RouteAccessibilityTest.php new file mode 100644 index 00000000..be963f53 --- /dev/null +++ b/tests/Feature/Storefront/RouteAccessibilityTest.php @@ -0,0 +1,100 @@ +store = Store::factory()->create(['name' => 'Test Store']); + StoreDomain::factory()->create([ + 'store_id' => $this->store->id, + 'hostname' => 'test-store.test', + 'type' => 'storefront', + ]); + + $theme = Theme::factory()->published()->create(['store_id' => $this->store->id]); + ThemeSettings::factory()->create(['theme_id' => $theme->id]); +}); + +it('home page returns 200', function () { + $response = $this->get('https://test-store.test/'); + $response->assertOk(); +}); + +it('collections index returns 200', function () { + $response = $this->get('https://test-store.test/collections'); + $response->assertOk(); +}); + +it('collection show returns 200 for valid collection', function () { + Collection::factory()->create([ + 'store_id' => $this->store->id, + 'handle' => 'summer', + 'status' => CollectionStatus::Active, + ]); + + $response = $this->get('https://test-store.test/collections/summer'); + $response->assertOk(); +}); + +it('collection show returns 404 for missing collection', function () { + $response = $this->get('https://test-store.test/collections/nonexistent'); + $response->assertNotFound(); +}); + +it('product show returns 200 for valid product', function () { + Product::factory()->create([ + 'store_id' => $this->store->id, + 'handle' => 't-shirt', + 'status' => 'active', + ]); + + $response = $this->get('https://test-store.test/products/t-shirt'); + $response->assertOk(); +}); + +it('product show returns 404 for missing product', function () { + $response = $this->get('https://test-store.test/products/nonexistent'); + $response->assertNotFound(); +}); + +it('cart page returns 200', function () { + $response = $this->get('https://test-store.test/cart'); + $response->assertOk(); +}); + +it('search page returns 200', function () { + $response = $this->get('https://test-store.test/search?q=test'); + $response->assertOk(); +}); + +it('pages show returns 200 for published page', function () { + Page::factory()->published()->create([ + 'store_id' => $this->store->id, + 'handle' => 'about', + ]); + + $response = $this->get('https://test-store.test/pages/about'); + $response->assertOk(); +}); + +it('pages show returns 404 for draft page', function () { + Page::factory()->create([ + 'store_id' => $this->store->id, + 'handle' => 'draft-page', + 'status' => 'draft', + ]); + + $response = $this->get('https://test-store.test/pages/draft-page'); + $response->assertNotFound(); +}); + +it('pages show returns 404 for missing page', function () { + $response = $this->get('https://test-store.test/pages/nonexistent'); + $response->assertNotFound(); +}); diff --git a/tests/Feature/Tenancy/StoreIsolationTest.php b/tests/Feature/Tenancy/StoreIsolationTest.php new file mode 100644 index 00000000..f1014805 --- /dev/null +++ b/tests/Feature/Tenancy/StoreIsolationTest.php @@ -0,0 +1,59 @@ +create(); + $storeB = Store::factory()->create(); + + Customer::withoutGlobalScopes()->create([ + 'store_id' => $storeA->id, + 'email' => 'a@example.com', + 'name' => 'Customer A', + ]); + Customer::withoutGlobalScopes()->create([ + 'store_id' => $storeB->id, + 'email' => 'b@example.com', + 'name' => 'Customer B', + ]); + + app()->instance('current_store', $storeA); + + $customers = Customer::all(); + expect($customers)->toHaveCount(1); + expect($customers->first()->email)->toBe('a@example.com'); +}); + +it('auto-sets store_id on creating when current_store is bound', function () { + $store = Store::factory()->create(); + app()->instance('current_store', $store); + + $customer = Customer::create([ + 'email' => 'test@example.com', + 'name' => 'Test', + 'password' => 'password', + ]); + + expect($customer->store_id)->toBe($store->id); +}); + +it('does not filter by store_id when no current store is bound', function () { + $storeA = Store::factory()->create(); + $storeB = Store::factory()->create(); + + Customer::withoutGlobalScopes()->create([ + 'store_id' => $storeA->id, + 'email' => 'a@example.com', + 'name' => 'A', + ]); + Customer::withoutGlobalScopes()->create([ + 'store_id' => $storeB->id, + 'email' => 'b@example.com', + 'name' => 'B', + ]); + + // No current_store bound + $customers = Customer::withoutGlobalScopes()->get(); + expect($customers)->toHaveCount(2); +}); diff --git a/tests/Feature/Tenancy/TenantResolutionTest.php b/tests/Feature/Tenancy/TenantResolutionTest.php new file mode 100644 index 00000000..5276c3d5 --- /dev/null +++ b/tests/Feature/Tenancy/TenantResolutionTest.php @@ -0,0 +1,92 @@ +get('/test-storefront', function () { + $store = app('current_store'); + + return response()->json(['store' => $store->name]); + }); + + Route::middleware(['web', 'auth', ResolveStore::class.':admin']) + ->get('/test-admin', function () { + $store = app('current_store'); + + return response()->json(['store' => $store->name]); + }); +}); + +it('resolves store from hostname on storefront requests', function () { + $store = Store::factory()->create(['name' => 'Acme Fashion']); + StoreDomain::factory()->create([ + 'store_id' => $store->id, + 'hostname' => 'acme-fashion.test', + 'type' => 'storefront', + ]); + + $response = $this->get('https://acme-fashion.test/test-storefront'); + + $response->assertOk(); + $response->assertJson(['store' => 'Acme Fashion']); +}); + +it('returns 404 for unknown hostname', function () { + $response = $this->get('https://unknown-shop.test/test-storefront'); + + $response->assertNotFound(); +}); + +it('returns 503 for suspended store', function () { + $store = Store::factory()->suspended()->create(); + StoreDomain::factory()->create([ + 'store_id' => $store->id, + 'hostname' => 'suspended-shop.test', + ]); + + $response = $this->get('https://suspended-shop.test/test-storefront'); + + $response->assertStatus(503); +}); + +it('caches hostname-to-store mapping', function () { + $store = Store::factory()->create(); + StoreDomain::factory()->create([ + 'store_id' => $store->id, + 'hostname' => 'cached-shop.test', + ]); + + $this->get('https://cached-shop.test/test-storefront'); + + expect(Cache::has('store_domain:cached-shop.test'))->toBeTrue(); +}); + +it('resolves store from session for admin requests', function () { + $store = Store::factory()->create(['name' => 'Acme Fashion']); + $user = User::factory()->create(); + $store->users()->attach($user->id, ['role' => 'owner']); + + $response = $this->actingAs($user) + ->withSession(['current_store_id' => $store->id]) + ->get('/test-admin'); + + $response->assertOk(); + $response->assertJson(['store' => 'Acme Fashion']); +}); + +it('returns 403 for admin request without store_users record', function () { + $store = Store::factory()->create(); + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->withSession(['current_store_id' => $store->id]) + ->get('/test-admin'); + + $response->assertForbidden(); +}); diff --git a/tests/Feature/Webhooks/WebhookDeliveryTest.php b/tests/Feature/Webhooks/WebhookDeliveryTest.php new file mode 100644 index 00000000..d9a21ca6 --- /dev/null +++ b/tests/Feature/Webhooks/WebhookDeliveryTest.php @@ -0,0 +1,180 @@ +context = createStoreContext(); + $this->store = $this->context['store']; + $this->service = new WebhookService; +}); + +it('delivers a webhook to a subscribed URL', function () { + Http::fake([ + 'https://example.com/hook' => Http::response('OK', 200), + ]); + + $subscription = WebhookSubscription::withoutGlobalScopes()->create([ + 'store_id' => $this->store->id, + 'event_type' => 'order.created', + 'target_url' => 'https://example.com/hook', + 'signing_secret_encrypted' => 'test-secret', + 'status' => 'active', + ]); + + $payload = ['order_id' => 1]; + $delivery = WebhookDelivery::create([ + 'subscription_id' => $subscription->id, + 'event_id' => 'test-event-id', + 'attempt_count' => 0, + 'status' => 'pending', + ]); + + $job = new DeliverWebhook($delivery, 'order.created', $payload); + $job->handle($this->service); + + $delivery->refresh(); + expect($delivery->status)->toBe(WebhookDeliveryStatus::Success) + ->and($delivery->response_code)->toBe(200) + ->and($delivery->attempt_count)->toBe(1); + + Http::assertSent(function ($request) { + return $request->url() === 'https://example.com/hook' + && $request->hasHeader('X-Platform-Signature') + && $request->hasHeader('X-Platform-Event') + && $request->hasHeader('X-Platform-Delivery-Id') + && $request->hasHeader('X-Platform-Timestamp'); + }); +}); + +it('signs the payload with HMAC', function () { + Http::fake([ + '*' => Http::response('OK', 200), + ]); + + $secret = 'my-signing-secret'; + $subscription = WebhookSubscription::withoutGlobalScopes()->create([ + 'store_id' => $this->store->id, + 'event_type' => 'order.created', + 'target_url' => 'https://example.com/hook', + 'signing_secret_encrypted' => $secret, + 'status' => 'active', + ]); + + $payload = ['order_id' => 42]; + $delivery = WebhookDelivery::create([ + 'subscription_id' => $subscription->id, + 'event_id' => 'test-event-id', + 'attempt_count' => 0, + 'status' => 'pending', + ]); + + $job = new DeliverWebhook($delivery, 'order.created', $payload); + $job->handle($this->service); + + Http::assertSent(function ($request) use ($payload, $secret) { + $expectedSignature = hash_hmac('sha256', json_encode($payload), $secret); + + return $request->header('X-Platform-Signature')[0] === $expectedSignature + && $request->header('X-Platform-Event')[0] === 'order.created'; + }); +}); + +it('retries failed deliveries with exponential backoff', function () { + $job = new DeliverWebhook( + WebhookDelivery::factory()->create(), + 'order.created', + ['order_id' => 1] + ); + + expect($job->tries)->toBe(6) + ->and($job->backoff())->toBe([60, 300, 1800, 7200, 43200]); +}); + +it('marks delivery as failed after max retries', function () { + $subscription = WebhookSubscription::withoutGlobalScopes()->create([ + 'store_id' => $this->store->id, + 'event_type' => 'order.created', + 'target_url' => 'https://example.com/hook', + 'signing_secret_encrypted' => 'test-secret', + 'status' => 'active', + ]); + + $delivery = WebhookDelivery::create([ + 'subscription_id' => $subscription->id, + 'event_id' => 'test-event-id', + 'attempt_count' => 5, + 'status' => 'pending', + ]); + + $job = new DeliverWebhook($delivery, 'order.created', ['order_id' => 1]); + $job->failed(new \RuntimeException('Connection failed')); + + $delivery->refresh(); + expect($delivery->status)->toBe(WebhookDeliveryStatus::Failed); +}); + +it('pauses subscription after circuit breaker threshold', function () { + $subscription = WebhookSubscription::withoutGlobalScopes()->create([ + 'store_id' => $this->store->id, + 'event_type' => 'order.created', + 'target_url' => 'https://example.com/hook', + 'signing_secret_encrypted' => 'test-secret', + 'status' => 'active', + ]); + + // Create 4 prior failed deliveries + for ($i = 0; $i < 4; $i++) { + WebhookDelivery::create([ + 'subscription_id' => $subscription->id, + 'event_id' => "failed-event-{$i}", + 'attempt_count' => 6, + 'status' => 'failed', + 'last_attempt_at' => now()->toIso8601String(), + ]); + } + + // Create the 5th delivery that will fail + $delivery = WebhookDelivery::create([ + 'subscription_id' => $subscription->id, + 'event_id' => 'failing-event-5', + 'attempt_count' => 5, + 'status' => 'pending', + ]); + + $job = new DeliverWebhook($delivery, 'order.created', ['order_id' => 1]); + $job->failed(new \RuntimeException('Connection failed')); + + $subscription->refresh(); + expect($subscription->status)->toBe(WebhookSubscriptionStatus::Paused); +}); + +it('dispatches jobs only for active subscriptions', function () { + Queue::fake(); + + WebhookSubscription::withoutGlobalScopes()->create([ + 'store_id' => $this->store->id, + 'event_type' => 'order.created', + 'target_url' => 'https://active.com/hook', + 'signing_secret_encrypted' => 'secret', + 'status' => 'active', + ]); + + WebhookSubscription::withoutGlobalScopes()->create([ + 'store_id' => $this->store->id, + 'event_type' => 'order.created', + 'target_url' => 'https://paused.com/hook', + 'signing_secret_encrypted' => 'secret', + 'status' => 'paused', + ]); + + $this->service->dispatch($this->store, 'order.created', ['order_id' => 1]); + + Queue::assertPushed(DeliverWebhook::class, 1); +}); diff --git a/tests/Feature/Webhooks/WebhookSignatureTest.php b/tests/Feature/Webhooks/WebhookSignatureTest.php new file mode 100644 index 00000000..c128ac83 --- /dev/null +++ b/tests/Feature/Webhooks/WebhookSignatureTest.php @@ -0,0 +1,47 @@ +service = new WebhookService; +}); + +it('generates a valid HMAC-SHA256 signature', function () { + $payload = '{"event":"order.created"}'; + $secret = 'test-secret'; + + $signature = $this->service->sign($payload, $secret); + + $expected = hash_hmac('sha256', $payload, $secret); + expect($signature)->toBe($expected) + ->and(strlen($signature))->toBe(64); +}); + +it('verifies a valid signature', function () { + $payload = '{"event":"order.created"}'; + $secret = 'test-secret'; + + $signature = $this->service->sign($payload, $secret); + $result = $this->service->verify($payload, $signature, $secret); + + expect($result)->toBeTrue(); +}); + +it('rejects a tampered payload', function () { + $payload = '{"event":"order.created"}'; + $secret = 'test-secret'; + + $signature = $this->service->sign($payload, $secret); + $result = $this->service->verify('{"event":"order.TAMPERED"}', $signature, $secret); + + expect($result)->toBeFalse(); +}); + +it('rejects an incorrect secret', function () { + $payload = '{"event":"order.created"}'; + + $signature = $this->service->sign($payload, 'secret-a'); + $result = $this->service->verify($payload, $signature, 'secret-b'); + + expect($result)->toBeFalse(); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 60f04a45..41ae76f0 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -12,7 +12,7 @@ */ pest()->extend(Tests\TestCase::class) - // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) + ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) ->in('Feature'); /* @@ -41,7 +41,21 @@ | */ -function something() +/** + * Creates a full store context for testing: Organization, Store, StoreDomain, User with Owner role. + * Binds 'current_store' in the container. + * + * @return array{store: \App\Models\Store, user: \App\Models\User, organization: \App\Models\Organization, domain: \App\Models\StoreDomain} + */ +function createStoreContext(): array { - // .. + $organization = \App\Models\Organization::factory()->create(); + $store = \App\Models\Store::factory()->create(['organization_id' => $organization->id]); + $domain = \App\Models\StoreDomain::factory()->create(['store_id' => $store->id]); + $user = \App\Models\User::factory()->create(); + $store->users()->attach($user, ['role' => 'owner']); + + app()->instance('current_store', $store); + + return compact('store', 'user', 'organization', 'domain'); } diff --git a/tests/Unit/Enums/EnumTest.php b/tests/Unit/Enums/EnumTest.php new file mode 100644 index 00000000..da629eb1 --- /dev/null +++ b/tests/Unit/Enums/EnumTest.php @@ -0,0 +1,26 @@ +value)->toBe('active'); + expect(StoreStatus::Suspended->value)->toBe('suspended'); + expect(StoreStatus::cases())->toHaveCount(2); +}); + +it('StoreUserRole is a backed string enum with correct cases', function () { + expect(StoreUserRole::Owner->value)->toBe('owner'); + expect(StoreUserRole::Admin->value)->toBe('admin'); + expect(StoreUserRole::Staff->value)->toBe('staff'); + expect(StoreUserRole::Support->value)->toBe('support'); + expect(StoreUserRole::cases())->toHaveCount(4); +}); + +it('StoreDomainType is a backed string enum with correct cases', function () { + expect(StoreDomainType::Storefront->value)->toBe('storefront'); + expect(StoreDomainType::Admin->value)->toBe('admin'); + expect(StoreDomainType::Api->value)->toBe('api'); + expect(StoreDomainType::cases())->toHaveCount(3); +}); diff --git a/tests/Unit/TaxCalculatorTest.php b/tests/Unit/TaxCalculatorTest.php new file mode 100644 index 00000000..bfe9c969 --- /dev/null +++ b/tests/Unit/TaxCalculatorTest.php @@ -0,0 +1,76 @@ +calculator = new TaxCalculator; +}); + +it('calculates exclusive tax correctly', function () { + $settings = new TaxSettings([ + 'mode' => 'manual', + 'rate' => 1900, + 'prices_include_tax' => false, + 'tax_name' => 'VAT', + 'is_active' => true, + ]); + + $result = $this->calculator->calculate(5499, $settings); + + expect($result->taxAmount)->toBe(1045) + ->and($result->taxLines)->toHaveCount(1) + ->and($result->taxLines[0]->name)->toBe('VAT') + ->and($result->taxLines[0]->rate)->toBe(1900); +}); + +it('adds exclusive tax to net amount', function () { + $tax = $this->calculator->addExclusive(1000, 1900); + expect($tax)->toBe(190); +}); + +it('extracts inclusive tax correctly', function () { + $tax = $this->calculator->extractInclusive(11900, 1900); + expect($tax)->toBe(1900); +}); + +it('handles inclusive tax with integer division', function () { + $tax = $this->calculator->extractInclusive(1190, 1900); + expect($tax)->toBe(190); +}); + +it('returns zero tax when settings are null', function () { + $result = $this->calculator->calculate(5000, null); + + expect($result->taxAmount)->toBe(0) + ->and($result->taxLines)->toBeEmpty(); +}); + +it('returns zero tax when rate is zero', function () { + $settings = new TaxSettings([ + 'mode' => 'manual', + 'rate' => 0, + 'prices_include_tax' => false, + 'tax_name' => 'VAT', + 'is_active' => true, + ]); + + $result = $this->calculator->calculate(5000, $settings); + expect($result->taxAmount)->toBe(0); +}); + +it('calculates tax on discounted amounts', function () { + $settings = new TaxSettings([ + 'mode' => 'manual', + 'rate' => 1900, + 'prices_include_tax' => false, + 'tax_name' => 'VAT', + 'is_active' => true, + ]); + + // Subtotal 10000, discount 1000, shipping 500 => taxable = 9500 + $taxableAmount = 9500; + $result = $this->calculator->calculate($taxableAmount, $settings); + + expect($result->taxAmount)->toBe(1805); +}); diff --git a/work/adversarial-qa.md b/work/adversarial-qa.md new file mode 100644 index 00000000..8b9ba40c --- /dev/null +++ b/work/adversarial-qa.md @@ -0,0 +1,170 @@ +# Adversarial QA Report + +**Date:** 2026-03-20 +**Tester:** Adversarial QA Analyst (Claude) +**Baseline:** 143 passing E2E test cases + +--- + +## Summary + +Found **5 FAIL** results and **1 security concern**, against **12 PASS** results. The most critical issue is that negative quantities can be added to cart and successfully checked out, creating orders with negative totals. One issue (FAIL-5) was fixed during this QA session. + +--- + +## FAIL Results + +### FAIL-1: Negative quantity accepted in cart (CRITICAL) + +- **Extends baseline:** Cart add-to-cart tests +- **What was tried:** Added a product to cart with quantity -3 via the quantity input field +- **Expected behavior:** Validation should reject quantities <= 0 +- **Actual behavior:** The negative quantity was accepted. Cart showed quantity -3, subtotal $-74.97, total $-74.97. The entire checkout flow completed successfully, creating Order #1016 with total_amount = -7497 in the database, financial_status = "paid". +- **Severity:** CRITICAL - Financial vulnerability. An attacker could place orders with negative totals, potentially gaming the payment system. +- **Root cause:** `CartService::addLine()` (app/Services/CartService.php:34) does not validate that `$quantity > 0`. The `checkAvailability` inventory check likely passes because a negative requested quantity is always "available." +- **Database proof:** `SELECT total_amount, financial_status FROM orders WHERE order_number = '#1016'` returns `{total_amount: -7497, financial_status: "paid"}` + +### FAIL-2: Zero quantity accepted in cart + +- **Extends baseline:** Cart add-to-cart tests +- **What was tried:** Added a product to cart with quantity 0 +- **Expected behavior:** Validation should reject quantity 0 +- **Actual behavior:** A cart line was created with quantity 0 and line total $0.00 +- **Severity:** MEDIUM - Allows meaningless cart entries and could cause downstream issues +- **Root cause:** Same as FAIL-1 - no `quantity > 0` validation in `CartService::addLine()` + +### FAIL-3: Checkout confirmation pages are publicly accessible (IDOR) + +- **Extends baseline:** Checkout confirmation tests +- **What was tried:** Visited `/checkout/confirmation/1`, `/checkout/confirmation/5` without authentication +- **Expected behavior:** Should require authentication or use non-guessable tokens (UUIDs) +- **Actual behavior:** Order confirmation pages are accessible to anyone. They expose: customer email, order subtotal, shipping, tax, and total amounts. Checkout IDs are sequential integers, making enumeration trivial. +- **Severity:** MEDIUM - Information disclosure. An attacker can enumerate all checkout confirmations and see customer emails and order totals. +- **Recommended fix:** Use UUIDs instead of sequential IDs for checkout confirmation URLs, or verify that the visitor's session matches the checkout session. + +### FAIL-4: Credit card number stored as public Livewire property + +- **Extends baseline:** Checkout payment tests +- **What was tried:** Inspected the Livewire component source and DOM during checkout +- **Expected behavior:** Sensitive payment data should not be exposed in the HTML source +- **Actual behavior:** `cardNumber` is defined as `public string $cardNumber = ''` in `App\Livewire\Storefront\Checkout\Show` (line 43). This means: + - The card number appears in the `wire:snapshot` JSON attribute in the page HTML source + - It is transmitted as plain text in every Livewire POST request after the user types it + - Any browser extension, XSS attack, or DOM inspection tool can read it +- **Severity:** HIGH - PCI compliance violation. Credit card numbers should never be stored in public DOM attributes. +- **Recommended fix:** Use JavaScript to collect card data and send it directly to the payment provider (tokenization), or at minimum make this a non-public property that's only set during the form submission action. + +### FAIL-5: Search button in header non-functional (FIXED) + +- **Extends baseline:** Storefront header/navigation tests +- **What was tried:** Clicked the search icon (magnifying glass) in the desktop header +- **Expected behavior:** A search modal should open with an active search input +- **Actual behavior:** Nothing happened. The `