From e05431ece57ac10a4cc56e22e999676c28f62ba5 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sat, 25 Apr 2026 08:53:42 +0200 Subject: [PATCH 01/35] Prompt --- .../skills/developing-with-fortify/SKILL.md | 116 +++++++++ .agents/skills/fluxui-development/SKILL.md | 81 +++++++ .../skills/laravel-best-practices/SKILL.md | 190 +++++++++++++++ .../rules/advanced-queries.md | 106 +++++++++ .../rules/architecture.md | 202 ++++++++++++++++ .../rules/blade-views.md | 36 +++ .../laravel-best-practices/rules/caching.md | 70 ++++++ .../rules/collections.md | 44 ++++ .../laravel-best-practices/rules/config.md | 73 ++++++ .../rules/db-performance.md | 192 +++++++++++++++ .../laravel-best-practices/rules/eloquent.md | 148 ++++++++++++ .../rules/error-handling.md | 72 ++++++ .../rules/events-notifications.md | 52 ++++ .../rules/http-client.md | 160 +++++++++++++ .../laravel-best-practices/rules/mail.md | 27 +++ .../rules/migrations.md | 121 ++++++++++ .../rules/queue-jobs.md | 144 +++++++++++ .../laravel-best-practices/rules/routing.md | 99 ++++++++ .../rules/scheduling.md | 39 +++ .../laravel-best-practices/rules/security.md | 198 ++++++++++++++++ .../laravel-best-practices/rules/style.md | 125 ++++++++++ .../laravel-best-practices/rules/testing.md | 43 ++++ .../rules/validation.md | 75 ++++++ .agents/skills/livewire-development/SKILL.md | 175 ++++++++++++++ .../reference/javascript-hooks.md | 39 +++ .agents/skills/pest-testing/SKILL.md | 159 +++++++++++++ .../skills/tailwindcss-development/SKILL.md | 119 ++++++++++ .codex/config.toml | 4 + .../console-2026-04-18T07-42-21-973Z.log | 2 + .../console-2026-04-18T07-42-29-697Z.log | 1 + .../console-2026-04-18T07-42-33-348Z.log | 1 + .../console-2026-04-18T07-42-37-846Z.log | 6 + .../console-2026-04-18T07-43-07-589Z.log | 2 + .../console-2026-04-18T07-43-15-408Z.log | 1 + .../console-2026-04-18T07-43-17-415Z.log | 1 + .../console-2026-04-18T07-43-21-810Z.log | 1 + .../console-2026-04-18T07-43-24-813Z.log | 2 + .../console-2026-04-18T08-04-27-959Z.log | 2 + .../console-2026-04-18T08-04-43-558Z.log | 1 + .../console-2026-04-18T08-04-48-155Z.log | 1 + .../console-2026-04-18T08-04-54-091Z.log | 4 + .../console-2026-04-18T08-06-02-803Z.log | 4 + .../console-2026-04-18T08-06-32-813Z.log | 4 + .../console-2026-04-18T08-07-33-682Z.log | 3 + .../console-2026-04-18T08-07-51-938Z.log | 1 + .../console-2026-04-18T08-07-58-376Z.log | 1 + .../console-2026-04-18T08-18-52-998Z.log | 3 + .../console-2026-04-18T08-26-52-212Z.log | 6 + .../console-2026-04-18T08-27-08-697Z.log | 4 + .../console-2026-04-18T08-27-49-983Z.log | 1 + .../console-2026-04-18T08-27-52-992Z.log | 1 + .../console-2026-04-18T08-39-15-591Z.log | 5 + .../console-2026-04-18T08-41-52-531Z.log | 3 + .../console-2026-04-18T08-42-02-646Z.log | 3 + .../console-2026-04-18T08-43-55-862Z.log | 1 + .../console-2026-04-18T08-44-17-066Z.log | 2 + .../console-2026-04-18T08-44-45-330Z.log | 1 + .../console-2026-04-18T08-44-53-484Z.log | 2 + .../console-2026-04-18T08-45-34-419Z.log | 1 + .../console-2026-04-18T08-45-49-887Z.log | 2 + .../console-2026-04-18T08-46-24-044Z.log | 1 + .../console-2026-04-18T08-46-55-981Z.log | 1 + .../console-2026-04-18T08-47-07-970Z.log | 1 + .../console-2026-04-18T08-47-18-512Z.log | 1 + .../console-2026-04-18T08-47-22-997Z.log | 1 + .../page-2026-04-18T07-42-22-458Z.yml | 42 ++++ .../page-2026-04-18T07-42-29-798Z.yml | 33 +++ .../page-2026-04-18T07-42-33-517Z.yml | 51 ++++ .../page-2026-04-18T07-42-38-652Z.yml | 212 +++++++++++++++++ .../page-2026-04-18T07-43-07-708Z.yml | 56 +++++ .../page-2026-04-18T07-43-15-507Z.yml | 32 +++ .../page-2026-04-18T07-43-17-578Z.yml | 35 +++ .../page-2026-04-18T07-43-21-920Z.yml | 37 +++ .../page-2026-04-18T07-43-24-950Z.yml | 6 + .../page-2026-04-18T08-04-28-270Z.yml | 54 +++++ .../page-2026-04-18T08-04-43-675Z.yml | 50 ++++ .../page-2026-04-18T08-04-48-300Z.yml | 68 ++++++ .../page-2026-04-18T08-04-54-210Z.yml | 73 ++++++ .../page-2026-04-18T08-05-12-101Z.yml | 81 +++++++ .../page-2026-04-18T08-06-02-997Z.yml | 73 ++++++ .../page-2026-04-18T08-06-09-558Z.yml | 81 +++++++ .../page-2026-04-18T08-06-32-938Z.yml | 73 ++++++ .../page-2026-04-18T08-06-42-016Z.yml | 81 +++++++ .../page-2026-04-18T08-07-33-859Z.yml | 73 ++++++ .../page-2026-04-18T08-07-43-009Z.yml | 94 ++++++++ .../page-2026-04-18T08-07-52-099Z.yml | 117 +++++++++ .../page-2026-04-18T08-07-58-525Z.yml | 148 ++++++++++++ .../page-2026-04-18T08-18-53-378Z.yml | 73 ++++++ .../page-2026-04-18T08-26-52-503Z.yml | 6 + .../page-2026-04-18T08-27-08-803Z.yml | 56 +++++ .../page-2026-04-18T08-27-41-204Z.yml | 52 ++++ .../page-2026-04-18T08-27-50-110Z.yml | 58 +++++ .../page-2026-04-18T08-27-53-108Z.yml | 58 +++++ .../page-2026-04-18T08-39-15-903Z.yml | 59 +++++ .../page-2026-04-18T08-39-24-839Z.yml | 73 ++++++ .../page-2026-04-18T08-39-31-590Z.yml | 94 ++++++++ .../page-2026-04-18T08-39-45-003Z.yml | 148 ++++++++++++ .../page-2026-04-18T08-40-17-855Z.yml | 148 ++++++++++++ .../page-2026-04-18T08-41-52-664Z.yml | 73 ++++++ .../page-2026-04-18T08-41-59-687Z.yml | 94 ++++++++ .../page-2026-04-18T08-42-02-779Z.yml | 151 ++++++++++++ .../page-2026-04-18T08-42-25-068Z.yml | 151 ++++++++++++ .../page-2026-04-18T08-42-33-505Z.yml | 155 ++++++++++++ .../page-2026-04-18T08-42-45-519Z.yml | 155 ++++++++++++ .../page-2026-04-18T08-43-11-472Z.yml | 155 ++++++++++++ .../page-2026-04-18T08-43-21-769Z.yml | 155 ++++++++++++ .../page-2026-04-18T08-43-55-970Z.yml | 44 ++++ .../page-2026-04-18T08-44-17-187Z.yml | 75 ++++++ .../page-2026-04-18T08-44-45-492Z.yml | 75 ++++++ .../page-2026-04-18T08-44-53-597Z.yml | 56 +++++ .../page-2026-04-18T08-45-20-872Z.yml | 52 ++++ .../page-2026-04-18T08-45-29-103Z.yml | 52 ++++ .../page-2026-04-18T08-45-34-547Z.yml | 58 +++++ .../page-2026-04-18T08-45-49-992Z.yml | 16 ++ .../page-2026-04-18T08-46-01-203Z.yml | 16 ++ .../page-2026-04-18T08-46-07-518Z.yml | 16 ++ .../page-2026-04-18T08-46-19-920Z.yml | 83 +++++++ .../page-2026-04-18T08-46-24-184Z.yml | 99 ++++++++ .../page-2026-04-18T08-46-40-422Z.yml | 99 ++++++++ .../page-2026-04-18T08-46-56-113Z.yml | 90 +++++++ .../page-2026-04-18T08-47-08-093Z.yml | 104 ++++++++ .../page-2026-04-18T08-47-18-629Z.yml | 60 +++++ .../page-2026-04-18T08-47-23-095Z.yml | 69 ++++++ .../page-2026-04-18T10-59-02-335Z.yml | 59 +++++ AGENTS.md | 224 ++++++++++++++++++ README.md | 7 + boost.json | 17 ++ composer.json | 2 +- composer.lock | 115 +++++---- 129 files changed, 7906 insertions(+), 53 deletions(-) create mode 100644 .agents/skills/developing-with-fortify/SKILL.md create mode 100644 .agents/skills/fluxui-development/SKILL.md create mode 100644 .agents/skills/laravel-best-practices/SKILL.md create mode 100644 .agents/skills/laravel-best-practices/rules/advanced-queries.md create mode 100644 .agents/skills/laravel-best-practices/rules/architecture.md create mode 100644 .agents/skills/laravel-best-practices/rules/blade-views.md create mode 100644 .agents/skills/laravel-best-practices/rules/caching.md create mode 100644 .agents/skills/laravel-best-practices/rules/collections.md create mode 100644 .agents/skills/laravel-best-practices/rules/config.md create mode 100644 .agents/skills/laravel-best-practices/rules/db-performance.md create mode 100644 .agents/skills/laravel-best-practices/rules/eloquent.md create mode 100644 .agents/skills/laravel-best-practices/rules/error-handling.md create mode 100644 .agents/skills/laravel-best-practices/rules/events-notifications.md create mode 100644 .agents/skills/laravel-best-practices/rules/http-client.md create mode 100644 .agents/skills/laravel-best-practices/rules/mail.md create mode 100644 .agents/skills/laravel-best-practices/rules/migrations.md create mode 100644 .agents/skills/laravel-best-practices/rules/queue-jobs.md create mode 100644 .agents/skills/laravel-best-practices/rules/routing.md create mode 100644 .agents/skills/laravel-best-practices/rules/scheduling.md create mode 100644 .agents/skills/laravel-best-practices/rules/security.md create mode 100644 .agents/skills/laravel-best-practices/rules/style.md create mode 100644 .agents/skills/laravel-best-practices/rules/testing.md create mode 100644 .agents/skills/laravel-best-practices/rules/validation.md create mode 100644 .agents/skills/livewire-development/SKILL.md create mode 100644 .agents/skills/livewire-development/reference/javascript-hooks.md create mode 100644 .agents/skills/pest-testing/SKILL.md create mode 100644 .agents/skills/tailwindcss-development/SKILL.md create mode 100644 .codex/config.toml create mode 100644 .playwright-mcp/console-2026-04-18T07-42-21-973Z.log create mode 100644 .playwright-mcp/console-2026-04-18T07-42-29-697Z.log create mode 100644 .playwright-mcp/console-2026-04-18T07-42-33-348Z.log create mode 100644 .playwright-mcp/console-2026-04-18T07-42-37-846Z.log create mode 100644 .playwright-mcp/console-2026-04-18T07-43-07-589Z.log create mode 100644 .playwright-mcp/console-2026-04-18T07-43-15-408Z.log create mode 100644 .playwright-mcp/console-2026-04-18T07-43-17-415Z.log create mode 100644 .playwright-mcp/console-2026-04-18T07-43-21-810Z.log create mode 100644 .playwright-mcp/console-2026-04-18T07-43-24-813Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-04-27-959Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-04-43-558Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-04-48-155Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-04-54-091Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-06-02-803Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-06-32-813Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-07-33-682Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-07-51-938Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-07-58-376Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-18-52-998Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-26-52-212Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-27-08-697Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-27-49-983Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-27-52-992Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-39-15-591Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-41-52-531Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-42-02-646Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-43-55-862Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-44-17-066Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-44-45-330Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-44-53-484Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-45-34-419Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-45-49-887Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-46-24-044Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-46-55-981Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-47-07-970Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-47-18-512Z.log create mode 100644 .playwright-mcp/console-2026-04-18T08-47-22-997Z.log create mode 100644 .playwright-mcp/page-2026-04-18T07-42-22-458Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T07-42-29-798Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T07-42-33-517Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T07-42-38-652Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T07-43-07-708Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T07-43-15-507Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T07-43-17-578Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T07-43-21-920Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T07-43-24-950Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-04-28-270Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-04-43-675Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-04-48-300Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-04-54-210Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-05-12-101Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-06-02-997Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-06-09-558Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-06-32-938Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-06-42-016Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-07-33-859Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-07-43-009Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-07-52-099Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-07-58-525Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-18-53-378Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-26-52-503Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-27-08-803Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-27-41-204Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-27-50-110Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-27-53-108Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-39-15-903Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-39-24-839Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-39-31-590Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-39-45-003Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-40-17-855Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-41-52-664Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-41-59-687Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-42-02-779Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-42-25-068Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-42-33-505Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-42-45-519Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-43-11-472Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-43-21-769Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-43-55-970Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-44-17-187Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-44-45-492Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-44-53-597Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-45-20-872Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-45-29-103Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-45-34-547Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-45-49-992Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-46-01-203Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-46-07-518Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-46-19-920Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-46-24-184Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-46-40-422Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-46-56-113Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-47-08-093Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-47-18-629Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T08-47-23-095Z.yml create mode 100644 .playwright-mcp/page-2026-04-18T10-59-02-335Z.yml create mode 100644 README.md create mode 100644 boost.json diff --git a/.agents/skills/developing-with-fortify/SKILL.md b/.agents/skills/developing-with-fortify/SKILL.md new file mode 100644 index 00000000..2ff71a4b --- /dev/null +++ b/.agents/skills/developing-with-fortify/SKILL.md @@ -0,0 +1,116 @@ +--- +name: developing-with-fortify +description: Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications. +--- + +# Laravel Fortify Development + +Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications. + +## Documentation + +Use `search-docs` for detailed Laravel Fortify patterns and documentation. + +## Usage + +- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints +- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.) +- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field +- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.) +- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc. + +## Available Features + +Enable in `config/fortify.php` features array: + +- `Features::registration()` - User registration +- `Features::resetPasswords()` - Password reset via email +- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail` +- `Features::updateProfileInformation()` - Profile updates +- `Features::updatePasswords()` - Password changes +- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes + +> Use `search-docs` for feature configuration options and customization patterns. + +## Setup Workflows + +### Two-Factor Authentication Setup + +``` +- [ ] Add TwoFactorAuthenticatable trait to User model +- [ ] Enable feature in config/fortify.php +- [ ] Run migrations for 2FA columns +- [ ] Set up view callbacks in FortifyServiceProvider +- [ ] Create 2FA management UI +- [ ] Test QR code and recovery codes +``` + +> Use `search-docs` for TOTP implementation and recovery code handling patterns. + +### Email Verification Setup + +``` +- [ ] Enable emailVerification feature in config +- [ ] Implement MustVerifyEmail interface on User model +- [ ] Set up verifyEmailView callback +- [ ] Add verified middleware to protected routes +- [ ] Test verification email flow +``` + +> Use `search-docs` for MustVerifyEmail implementation patterns. + +### Password Reset Setup + +``` +- [ ] Enable resetPasswords feature in config +- [ ] Set up requestPasswordResetLinkView callback +- [ ] Set up resetPasswordView callback +- [ ] Define password.reset named route (if views disabled) +- [ ] Test reset email and link flow +``` + +> Use `search-docs` for custom password reset flow patterns. + +### SPA Authentication Setup + +``` +- [ ] Set 'views' => false in config/fortify.php +- [ ] Install and configure Laravel Sanctum +- [ ] Use 'web' guard in fortify config +- [ ] Set up CSRF token handling +- [ ] Test XHR authentication flows +``` + +> Use `search-docs` for integration and SPA authentication patterns. + +## Best Practices + +### Custom Authentication Logic + +Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects. + +### Registration Customization + +Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields. + +### Rate Limiting + +Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination. + +## Key Endpoints + +| Feature | Method | Endpoint | +|------------------------|----------|---------------------------------------------| +| Login | POST | `/login` | +| Logout | POST | `/logout` | +| Register | POST | `/register` | +| Password Reset Request | POST | `/forgot-password` | +| Password Reset | POST | `/reset-password` | +| Email Verify Notice | GET | `/email/verify` | +| Resend Verification | POST | `/email/verification-notification` | +| Password Confirm | POST | `/user/confirm-password` | +| Enable 2FA | POST | `/user/two-factor-authentication` | +| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` | +| 2FA Challenge | POST | `/two-factor-challenge` | +| Get QR Code | GET | `/user/two-factor-qr-code` | +| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` | \ No newline at end of file diff --git a/.agents/skills/fluxui-development/SKILL.md b/.agents/skills/fluxui-development/SKILL.md new file mode 100644 index 00000000..4b5aabb1 --- /dev/null +++ b/.agents/skills/fluxui-development/SKILL.md @@ -0,0 +1,81 @@ +--- +name: fluxui-development +description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling." +license: MIT +metadata: + author: laravel +--- + +# Flux UI Development + +## Documentation + +Use `search-docs` for detailed Flux UI patterns and documentation. + +## Basic Usage + +This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components. + +Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize. + +Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs. + + +```blade +Click me +``` + +## Available Components (Free Edition) + +Available: avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, profile, radio, select, separator, skeleton, switch, text, textarea, tooltip + +## Icons + +Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names. + + +```blade +Export +``` + +For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command: + +```bash +php artisan flux:icon crown grip-vertical github +``` + +## Common Patterns + +### Form Fields + + +```blade + + Email + + + +``` + +### Modals + + +```blade + + Title +

Content

+
+``` + +## Verification + +1. Check component renders correctly +2. Test interactive states +3. Verify mobile responsiveness + +## Common Pitfalls + +- Trying to use Pro-only components in the free edition +- Not checking if a Flux component exists before creating custom implementations +- Forgetting to use the `search-docs` tool for component-specific documentation +- Not following existing project patterns for Flux usage \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/SKILL.md b/.agents/skills/laravel-best-practices/SKILL.md new file mode 100644 index 00000000..aca32c9c --- /dev/null +++ b/.agents/skills/laravel-best-practices/SKILL.md @@ -0,0 +1,190 @@ +--- +name: laravel-best-practices +description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns." +license: MIT +metadata: + author: laravel +--- + +# Laravel Best Practices + +Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`. + +## Consistency First + +Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. + +Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides. + +## Quick Reference + +### 1. Database Performance → `rules/db-performance.md` + +- Eager load with `with()` to prevent N+1 queries +- Enable `Model::preventLazyLoading()` in development +- Select only needed columns, avoid `SELECT *` +- `chunk()` / `chunkById()` for large datasets +- Index columns used in `WHERE`, `ORDER BY`, `JOIN` +- `withCount()` instead of loading relations to count +- `cursor()` for memory-efficient read-only iteration +- Never query in Blade templates + +### 2. Advanced Query Patterns → `rules/advanced-queries.md` + +- `addSelect()` subqueries over eager-loading entire has-many for a single value +- Dynamic relationships via subquery FK + `belongsTo` +- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries +- `setRelation()` to prevent circular N+1 queries +- `whereIn` + `pluck()` over `whereHas` for better index usage +- Two simple queries can beat one complex query +- Compound indexes matching `orderBy` column order +- Correlated subqueries in `orderBy` for has-many sorting (avoid joins) + +### 3. Security → `rules/security.md` + +- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates +- No raw SQL with user input — use Eloquent or query builder +- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes +- Validate MIME type, extension, and size for file uploads +- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields + +### 4. Caching → `rules/caching.md` + +- `Cache::remember()` over manual get/put +- `Cache::flexible()` for stale-while-revalidate on high-traffic data +- `Cache::memo()` to avoid redundant cache hits within a request +- Cache tags to invalidate related groups +- `Cache::add()` for atomic conditional writes +- `once()` to memoize per-request or per-object lifetime +- `Cache::lock()` / `lockForUpdate()` for race conditions +- Failover cache stores in production + +### 5. Eloquent Patterns → `rules/eloquent.md` + +- Correct relationship types with return type hints +- Local scopes for reusable query constraints +- Global scopes sparingly — document their existence +- Attribute casts in the `casts()` method +- Cast date columns, use Carbon instances in templates +- `whereBelongsTo($model)` for cleaner queries +- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries + +### 6. Validation & Forms → `rules/validation.md` + +- Form Request classes, not inline validation +- Array notation `['required', 'email']` for new code; follow existing convention +- `$request->validated()` only — never `$request->all()` +- `Rule::when()` for conditional validation +- `after()` instead of `withValidator()` + +### 7. Configuration → `rules/config.md` + +- `env()` only inside config files +- `App::environment()` or `app()->isProduction()` +- Config, lang files, and constants over hardcoded text + +### 8. Testing Patterns → `rules/testing.md` + +- `LazilyRefreshDatabase` over `RefreshDatabase` for speed +- `assertModelExists()` over raw `assertDatabaseHas()` +- Factory states and sequences over manual overrides +- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before +- `recycle()` to share relationship instances across factories + +### 9. Queue & Job Patterns → `rules/queue-jobs.md` + +- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]` +- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release +- Always implement `failed()`; with `retryUntil()`, set `$tries = 0` +- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs +- Horizon for complex multi-queue scenarios + +### 10. Routing & Controllers → `rules/routing.md` + +- Implicit route model binding +- Scoped bindings for nested resources +- `Route::resource()` or `apiResource()` +- Methods under 10 lines — extract to actions/services +- Type-hint Form Requests for auto-validation + +### 11. HTTP Client → `rules/http-client.md` + +- Explicit `timeout` and `connectTimeout` on every request +- `retry()` with exponential backoff for external APIs +- Check response status or use `throw()` +- `Http::pool()` for concurrent independent requests +- `Http::fake()` and `preventStrayRequests()` in tests + +### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md` + +- Event discovery over manual registration; `event:cache` in production +- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions +- Queue notifications and mailables with `ShouldQueue` +- On-demand notifications for non-user recipients +- `HasLocalePreference` on notifiable models +- `assertQueued()` not `assertSent()` for queued mailables +- Markdown mailables for transactional emails + +### 13. Error Handling → `rules/error-handling.md` + +- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern +- `ShouldntReport` for exceptions that should never log +- Throttle high-volume exceptions to protect log sinks +- `dontReportDuplicates()` for multi-catch scenarios +- Force JSON rendering for API routes +- Structured context via `context()` on exception classes + +### 14. Task Scheduling → `rules/scheduling.md` + +- `withoutOverlapping()` on variable-duration tasks +- `onOneServer()` on multi-server deployments +- `runInBackground()` for concurrent long tasks +- `environments()` to restrict to appropriate environments +- `takeUntilTimeout()` for time-bounded processing +- Schedule groups for shared configuration + +### 15. Architecture → `rules/architecture.md` + +- Single-purpose Action classes; dependency injection over `app()` helper +- Prefer official Laravel packages and follow conventions, don't override defaults +- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety +- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution + +### 16. Migrations → `rules/migrations.md` + +- Generate migrations with `php artisan make:migration` +- `constrained()` for foreign keys +- Never modify migrations that have run in production +- Add indexes in the migration, not as an afterthought +- Mirror column defaults in model `$attributes` +- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes +- One concern per migration — never mix DDL and DML + +### 17. Collections → `rules/collections.md` + +- Higher-order messages for simple collection operations +- `cursor()` vs. `lazy()` — choose based on relationship needs +- `lazyById()` when updating records while iterating +- `toQuery()` for bulk operations on collections + +### 18. Blade & Views → `rules/blade-views.md` + +- `$attributes->merge()` in component templates +- Blade components over `@include`; `@pushOnce` for per-component scripts +- View Composers for shared view data +- `@aware` for deeply nested component props + +### 19. Conventions & Style → `rules/style.md` + +- Follow Laravel naming conventions for all entities +- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions +- No JS/CSS in Blade, no HTML in PHP classes +- Code should be readable; comments only for config files + +## How to Apply + +Always use a sub-agent to read rule files and explore this skill's content. + +1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10) +2. Check sibling files for existing patterns — follow those first per Consistency First +3. Verify API syntax with `search-docs` for the installed Laravel version \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/advanced-queries.md b/.agents/skills/laravel-best-practices/rules/advanced-queries.md new file mode 100644 index 00000000..920714a1 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/advanced-queries.md @@ -0,0 +1,106 @@ +# Advanced Query Patterns + +## Use `addSelect()` Subqueries for Single Values from Has-Many + +Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries. + +```php +public function scopeWithLastLoginAt($query): void +{ + $query->addSelect([ + 'last_login_at' => Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->withCasts(['last_login_at' => 'datetime']); +} +``` + +## Create Dynamic Relationships via Subquery FK + +Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection. + +```php +public function lastLogin(): BelongsTo +{ + return $this->belongsTo(Login::class); +} + +public function scopeWithLastLogin($query): void +{ + $query->addSelect([ + 'last_login_id' => Login::select('id') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->with('lastLogin'); +} +``` + +## Use Conditional Aggregates Instead of Multiple Count Queries + +Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values. + +```php +$statuses = Feature::toBase() + ->selectRaw("count(case when status = 'Requested' then 1 end) as requested") + ->selectRaw("count(case when status = 'Planned' then 1 end) as planned") + ->selectRaw("count(case when status = 'Completed' then 1 end) as completed") + ->first(); +``` + +## Use `setRelation()` to Prevent Circular N+1 + +When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries. + +```php +$feature->load('comments.user'); +$feature->comments->each->setRelation('feature', $feature); +``` + +## Prefer `whereIn` + Subquery Over `whereHas` + +`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory. + +Incorrect (correlated EXISTS re-executes per row): + +```php +$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term)); +``` + +Correct (index-friendly subquery, no PHP memory overhead): + +```php +$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id')); +``` + +## Sometimes Two Simple Queries Beat One Complex Query + +Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index. + +## Use Compound Indexes Matching `orderBy` Column Order + +When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index. + +```php +// Migration +$table->index(['last_name', 'first_name']); + +// Query — column order must match the index +User::query()->orderBy('last_name')->orderBy('first_name')->paginate(); +``` + +## Use Correlated Subqueries for Has-Many Ordering + +When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading. + +```php +public function scopeOrderByLastLogin($query): void +{ + $query->orderByDesc(Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1) + ); +} +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/architecture.md b/.agents/skills/laravel-best-practices/rules/architecture.md new file mode 100644 index 00000000..6112a635 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/architecture.md @@ -0,0 +1,202 @@ +# Architecture Best Practices + +## Single-Purpose Action Classes + +Extract discrete business operations into invokable Action classes. + +```php +class CreateOrderAction +{ + public function __construct(private InventoryService $inventory) {} + + public function execute(array $data): Order + { + $order = Order::create($data); + $this->inventory->reserve($order); + + return $order; + } +} +``` + +## Use Dependency Injection + +Always use constructor injection. Avoid `app()` or `resolve()` inside classes. + +Incorrect: +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request) + { + $service = app(OrderService::class); + + return $service->create($request->validated()); + } +} +``` + +Correct: +```php +class OrderController extends Controller +{ + public function __construct(private OrderService $service) {} + + public function store(StoreOrderRequest $request) + { + return $this->service->create($request->validated()); + } +} +``` + +## Code to Interfaces + +Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability. + +Incorrect (concrete dependency): +```php +class OrderService +{ + public function __construct(private StripeGateway $gateway) {} +} +``` + +Correct (interface dependency): +```php +interface PaymentGateway +{ + public function charge(int $amount, string $customerId): PaymentResult; +} + +class OrderService +{ + public function __construct(private PaymentGateway $gateway) {} +} +``` + +Bind in a service provider: + +```php +$this->app->bind(PaymentGateway::class, StripeGateway::class); +``` + +## Default Sort by Descending + +When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined. + +Incorrect: +```php +$posts = Post::paginate(); +``` + +Correct: +```php +$posts = Post::latest()->paginate(); +``` + +## Use Atomic Locks for Race Conditions + +Prevent race conditions with `Cache::lock()` or `lockForUpdate()`. + +```php +Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) { + $order->process(); +}); + +// Or at query level +$product = Product::where('id', $id)->lockForUpdate()->first(); +``` + +## Use `mb_*` String Functions + +When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters. + +Incorrect: +```php +strlen('José'); // 5 (bytes, not characters) +strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte +``` + +Correct: +```php +mb_strlen('José'); // 4 (characters) +mb_strtolower('MÜNCHEN'); // 'münchen' + +// Prefer Laravel's Str helpers when available +Str::length('José'); // 4 +Str::lower('MÜNCHEN'); // 'münchen' +``` + +## Use `defer()` for Post-Response Work + +For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead. + +Incorrect (job overhead for trivial work): +```php +dispatch(new LogPageView($page)); +``` + +Correct (runs after response, same process): +```php +defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()])); +``` + +Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work. + +## Use `Context` for Request-Scoped Data + +The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually. + +```php +// In middleware +Context::add('tenant_id', $request->header('X-Tenant-ID')); + +// Anywhere later — controllers, jobs, log context +$tenantId = Context::get('tenant_id'); +``` + +Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`. + +## Use `Concurrency::run()` for Parallel Execution + +Run independent operations in parallel using child processes — no async libraries needed. + +```php +use Illuminate\Support\Facades\Concurrency; + +[$users, $orders] = Concurrency::run([ + fn () => User::count(), + fn () => Order::where('status', 'pending')->count(), +]); +``` + +Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially. + +## Convention Over Configuration + +Follow Laravel conventions. Don't override defaults unnecessarily. + +Incorrect: +```php +class Customer extends Model +{ + protected $table = 'Customer'; + protected $primaryKey = 'customer_id'; + + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id'); + } +} +``` + +Correct: +```php +class Customer extends Model +{ + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class); + } +} +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/blade-views.md b/.agents/skills/laravel-best-practices/rules/blade-views.md new file mode 100644 index 00000000..c6f8aaf1 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/blade-views.md @@ -0,0 +1,36 @@ +# Blade & Views Best Practices + +## Use `$attributes->merge()` in Component Templates + +Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly. + +```blade +
merge(['class' => 'alert alert-'.$type]) }}> + {{ $message }} +
+``` + +## Use `@pushOnce` for Per-Component Scripts + +If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once. + +## Prefer Blade Components Over `@include` + +`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots. + +## Use View Composers for Shared View Data + +If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it. + +## Use Blade Fragments for Partial Re-Renders (htmx/Turbo) + +A single view can return either the full page or just a fragment, keeping routing clean. + +```php +return view('dashboard', compact('users')) + ->fragmentIf($request->hasHeader('HX-Request'), 'user-list'); +``` + +## Use `@aware` for Deeply Nested Component Props + +Avoids re-passing parent props through every level of nested components. \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/caching.md b/.agents/skills/laravel-best-practices/rules/caching.md new file mode 100644 index 00000000..e65146dc --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/caching.md @@ -0,0 +1,70 @@ +# Caching Best Practices + +## Use `Cache::remember()` Instead of Manual Get/Put + +Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions. + +Incorrect: +```php +$val = Cache::get('stats'); +if (! $val) { + $val = $this->computeStats(); + Cache::put('stats', $val, 60); +} +``` + +Correct: +```php +$val = Cache::remember('stats', 60, fn () => $this->computeStats()); +``` + +## Use `Cache::flexible()` for Stale-While-Revalidate + +On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background. + +Incorrect: `Cache::remember('users', 300, fn () => User::all());` + +Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function. + +## Use `Cache::memo()` to Avoid Redundant Hits Within a Request + +If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory. + +`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5. + +## Use Cache Tags to Invalidate Related Groups + +Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`. + +```php +Cache::tags(['user-1'])->flush(); +``` + +## Use `Cache::add()` for Atomic Conditional Writes + +`add()` only writes if the key does not exist — atomic, no race condition between checking and writing. + +Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }` + +Correct: `Cache::add('lock', true, 10);` + +## Use `once()` for Per-Request Memoization + +`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory. + +```php +public function roles(): Collection +{ + return once(fn () => $this->loadRoles()); +} +``` + +Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching. + +## Configure Failover Cache Stores in Production + +If Redis goes down, the app falls back to a secondary store automatically. + +```php +'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/collections.md b/.agents/skills/laravel-best-practices/rules/collections.md new file mode 100644 index 00000000..14f683d3 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/collections.md @@ -0,0 +1,44 @@ +# Collection Best Practices + +## Use Higher-Order Messages for Simple Operations + +Incorrect: +```php +$users->each(function (User $user) { + $user->markAsVip(); +}); +``` + +Correct: `$users->each->markAsVip();` + +Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc. + +## Choose `cursor()` vs. `lazy()` Correctly + +- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk). +- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading. + +Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored. + +Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work. + +## Use `lazyById()` When Updating Records While Iterating + +`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation. + +## Use `toQuery()` for Bulk Operations on Collections + +Avoids manual `whereIn` construction. + +Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);` + +Correct: `$users->toQuery()->update([...]);` + +## Use `#[CollectedBy]` for Custom Collection Classes + +More declarative than overriding `newCollection()`. + +```php +#[CollectedBy(UserCollection::class)] +class User extends Model {} +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/config.md b/.agents/skills/laravel-best-practices/rules/config.md new file mode 100644 index 00000000..193155d6 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/config.md @@ -0,0 +1,73 @@ +# Configuration Best Practices + +## `env()` Only in Config Files + +Direct `env()` calls may return `null` when config is cached. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'key' => env('API_KEY'), + +// Application code +$key = config('services.key'); +``` + +## Use Encrypted Env or External Secrets + +Never store production secrets in plain `.env` files in version control. + +Incorrect: +```bash + +# .env committed to repo or shared in Slack + +STRIPE_SECRET=sk_live_abc123 +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI +``` + +Correct: +```bash +php artisan env:encrypt --env=production --readable +php artisan env:decrypt --env=production +``` + +For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime. + +## Use `App::environment()` for Environment Checks + +Incorrect: +```php +if (env('APP_ENV') === 'production') { +``` + +Correct: +```php +if (app()->isProduction()) { +// or +if (App::environment('production')) { +``` + +## Use Constants and Language Files + +Use class constants instead of hardcoded magic strings for model states, types, and statuses. + +```php +// Incorrect +return $this->type === 'normal'; + +// Correct +return $this->type === self::TYPE_NORMAL; +``` + +If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there. + +```php +// Only when lang files already exist in the project +return back()->with('message', __('app.article_added')); +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/db-performance.md b/.agents/skills/laravel-best-practices/rules/db-performance.md new file mode 100644 index 00000000..8fb71937 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/db-performance.md @@ -0,0 +1,192 @@ +# Database Performance Best Practices + +## Always Eager Load Relationships + +Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront. + +Incorrect (N+1 — executes 1 + N queries): +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Correct (2 queries total): +```php +$posts = Post::with('author')->get(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Constrain eager loads to select only needed columns (always include the foreign key): + +```php +$users = User::with(['posts' => function ($query) { + $query->select('id', 'user_id', 'title') + ->where('published', true) + ->latest() + ->limit(10); +}])->get(); +``` + +## Prevent Lazy Loading in Development + +Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development. + +```php +public function boot(): void +{ + Model::preventLazyLoading(! app()->isProduction()); +} +``` + +Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded. + +## Select Only Needed Columns + +Avoid `SELECT *` — especially when tables have large text or JSON columns. + +Incorrect: +```php +$posts = Post::with('author')->get(); +``` + +Correct: +```php +$posts = Post::select('id', 'title', 'user_id', 'created_at') + ->with(['author:id,name,avatar']) + ->get(); +``` + +When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match. + +## Chunk Large Datasets + +Never load thousands of records at once. Use chunking for batch processing. + +Incorrect: +```php +$users = User::all(); +foreach ($users as $user) { + $user->notify(new WeeklyDigest); +} +``` + +Correct: +```php +User::where('subscribed', true)->chunk(200, function ($users) { + foreach ($users as $user) { + $user->notify(new WeeklyDigest); + } +}); +``` + +Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change: + +```php +User::where('active', false)->chunkById(200, function ($users) { + $users->each->delete(); +}); +``` + +## Add Database Indexes + +Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->index()->constrained(); + $table->string('status')->index(); + $table->timestamps(); + $table->index(['status', 'created_at']); +}); +``` + +Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`). + +## Use `withCount()` for Counting Relations + +Never load entire collections just to count them. + +Incorrect: +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->comments->count(); +} +``` + +Correct: +```php +$posts = Post::withCount('comments')->get(); +foreach ($posts as $post) { + echo $post->comments_count; +} +``` + +Conditional counting: + +```php +$posts = Post::withCount([ + 'comments', + 'comments as approved_comments_count' => function ($query) { + $query->where('approved', true); + }, +])->get(); +``` + +## Use `cursor()` for Memory-Efficient Iteration + +For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator. + +Incorrect: +```php +$users = User::where('active', true)->get(); +``` + +Correct: +```php +foreach (User::where('active', true)->cursor() as $user) { + ProcessUser::dispatch($user->id); +} +``` + +Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records. + +## No Queries in Blade Templates + +Never execute queries in Blade templates. Pass data from controllers. + +Incorrect: +```blade +@foreach (User::all() as $user) + {{ $user->profile->name }} +@endforeach +``` + +Correct: +```php +// Controller +$users = User::with('profile')->get(); +return view('users.index', compact('users')); +``` + +```blade +@foreach ($users as $user) + {{ $user->profile->name }} +@endforeach +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/eloquent.md b/.agents/skills/laravel-best-practices/rules/eloquent.md new file mode 100644 index 00000000..09cd66a0 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/eloquent.md @@ -0,0 +1,148 @@ +# Eloquent Best Practices + +## Use Correct Relationship Types + +Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints. + +```php +public function comments(): HasMany +{ + return $this->hasMany(Comment::class); +} + +public function author(): BelongsTo +{ + return $this->belongsTo(User::class, 'user_id'); +} +``` + +## Use Local Scopes for Reusable Queries + +Extract reusable query constraints into local scopes to avoid duplication. + +Incorrect: +```php +$active = User::where('verified', true)->whereNotNull('activated_at')->get(); +$articles = Article::whereHas('user', function ($q) { + $q->where('verified', true)->whereNotNull('activated_at'); +})->get(); +``` + +Correct: +```php +public function scopeActive(Builder $query): Builder +{ + return $query->where('verified', true)->whereNotNull('activated_at'); +} + +// Usage +$active = User::active()->get(); +$articles = Article::whereHas('user', fn ($q) => $q->active())->get(); +``` + +## Apply Global Scopes Sparingly + +Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy. + +Incorrect (global scope for a conditional filter): +```php +class PublishedScope implements Scope +{ + public function apply(Builder $builder, Model $model): void + { + $builder->where('published', true); + } +} +// Now admin panels, reports, and background jobs all silently skip drafts +``` + +Correct (local scope you opt into): +```php +public function scopePublished(Builder $query): Builder +{ + return $query->where('published', true); +} + +Post::published()->paginate(); // Explicit +Post::paginate(); // Admin sees all +``` + +## Define Attribute Casts + +Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion. + +```php +protected function casts(): array +{ + return [ + 'is_active' => 'boolean', + 'metadata' => 'array', + 'total' => 'decimal:2', + ]; +} +``` + +## Cast Date Columns Properly + +Always cast date columns. Use Carbon instances in templates instead of formatting strings manually. + +Incorrect: +```blade +{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }} +``` + +Correct: +```php +protected function casts(): array +{ + return [ + 'ordered_at' => 'datetime', + ]; +} +``` + +```blade +{{ $order->ordered_at->toDateString() }} +{{ $order->ordered_at->format('m-d') }} +``` + +## Use `whereBelongsTo()` for Relationship Queries + +Cleaner than manually specifying foreign keys. + +Incorrect: +```php +Post::where('user_id', $user->id)->get(); +``` + +Correct: +```php +Post::whereBelongsTo($user)->get(); +Post::whereBelongsTo($user, 'author')->get(); +``` + +## Avoid Hardcoded Table Names in Queries + +Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string). + +Incorrect: +```php +DB::table('users')->where('active', true)->get(); + +$query->join('companies', 'companies.id', '=', 'users.company_id'); + +DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); +``` + +Correct — reference the model's table: +```php +DB::table((new User)->getTable())->where('active', true)->get(); + +// Even better — use Eloquent or the query builder instead of raw SQL +User::where('active', true)->get(); +Order::where('status', 'pending')->get(); +``` + +Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable. + +**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration. \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/error-handling.md b/.agents/skills/laravel-best-practices/rules/error-handling.md new file mode 100644 index 00000000..bb8e7a38 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/error-handling.md @@ -0,0 +1,72 @@ +# Error Handling Best Practices + +## Exception Reporting and Rendering + +There are two valid approaches — choose one and apply it consistently across the project. + +**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find: + +```php +class InvalidOrderException extends Exception +{ + public function report(): void { /* custom reporting */ } + + public function render(Request $request): Response + { + return response()->view('errors.invalid-order', status: 422); + } +} +``` + +**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture: + +```php +->withExceptions(function (Exceptions $exceptions) { + $exceptions->report(function (InvalidOrderException $e) { /* ... */ }); + $exceptions->render(function (InvalidOrderException $e, Request $request) { + return response()->view('errors.invalid-order', status: 422); + }); +}) +``` + +Check the existing codebase and follow whichever pattern is already established. + +## Use `ShouldntReport` for Exceptions That Should Never Log + +More discoverable than listing classes in `dontReport()`. + +```php +class PodcastProcessingException extends Exception implements ShouldntReport {} +``` + +## Throttle High-Volume Exceptions + +A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type. + +## Enable `dontReportDuplicates()` + +Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks. + +## Force JSON Error Rendering for API Routes + +Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes. + +```php +$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { + return $request->is('api/*') || $request->expectsJson(); +}); +``` + +## Add Context to Exception Classes + +Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry. + +```php +class InvalidOrderException extends Exception +{ + public function context(): array + { + return ['order_id' => $this->orderId]; + } +} +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/events-notifications.md b/.agents/skills/laravel-best-practices/rules/events-notifications.md new file mode 100644 index 00000000..47fcf324 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/events-notifications.md @@ -0,0 +1,52 @@ +# Events & Notifications Best Practices + +## Rely on Event Discovery + +Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`. + +## Run `event:cache` in Production Deploy + +Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`. + +## Use `ShouldDispatchAfterCommit` Inside Transactions + +Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet. + +```php +class OrderShipped implements ShouldDispatchAfterCommit {} +``` + +## Always Queue Notifications + +Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response. + +```php +class InvoicePaid extends Notification implements ShouldQueue +{ + use Queueable; +} +``` + +## Use `afterCommit()` on Notifications in Transactions + +Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits. + +```php +$user->notify((new InvoicePaid($invoice))->afterCommit()); +``` + +## Route Notification Channels to Dedicated Queues + +Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues. + +## Use On-Demand Notifications for Non-User Recipients + +Avoid creating dummy models to send notifications to arbitrary addresses. + +```php +Notification::route('mail', 'admin@example.com')->notify(new SystemAlert()); +``` + +## Implement `HasLocalePreference` on Notifiable Models + +Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/http-client.md b/.agents/skills/laravel-best-practices/rules/http-client.md new file mode 100644 index 00000000..fd37ddb9 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/http-client.md @@ -0,0 +1,160 @@ +# HTTP Client Best Practices + +## Always Set Explicit Timeouts + +The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users'); +``` + +Correct: +```php +$response = Http::timeout(5) + ->connectTimeout(3) + ->get('https://api.example.com/users'); +``` + +For service-specific clients, define timeouts in a macro: + +```php +Http::macro('github', function () { + return Http::baseUrl('https://api.github.com') + ->timeout(10) + ->connectTimeout(3) + ->withToken(config('services.github.token')); +}); + +$response = Http::github()->get('/repos/laravel/framework'); +``` + +## Use Retry with Backoff for External APIs + +External APIs have transient failures. Use `retry()` with increasing delays. + +Incorrect: +```php +$response = Http::post('https://api.stripe.com/v1/charges', $data); + +if ($response->failed()) { + throw new PaymentFailedException('Charge failed'); +} +``` + +Correct: +```php +$response = Http::retry([100, 500, 1000]) + ->timeout(10) + ->post('https://api.stripe.com/v1/charges', $data); +``` + +Only retry on specific errors: + +```php +$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException && $exception->response->serverError()); +})->post('https://api.example.com/data'); +``` + +## Handle Errors Explicitly + +The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users/1'); +$user = $response->json(); // Could be an error body +``` + +Correct: +```php +$response = Http::timeout(5) + ->get('https://api.example.com/users/1') + ->throw(); + +$user = $response->json(); +``` + +For graceful degradation: + +```php +$response = Http::get('https://api.example.com/users/1'); + +if ($response->successful()) { + return $response->json(); +} + +if ($response->notFound()) { + return null; +} + +$response->throw(); +``` + +## Use Request Pooling for Concurrent Requests + +When making multiple independent API calls, use `Http::pool()` instead of sequential calls. + +Incorrect: +```php +$users = Http::get('https://api.example.com/users')->json(); +$posts = Http::get('https://api.example.com/posts')->json(); +$comments = Http::get('https://api.example.com/comments')->json(); +``` + +Correct: +```php +use Illuminate\Http\Client\Pool; + +$responses = Http::pool(fn (Pool $pool) => [ + $pool->as('users')->get('https://api.example.com/users'), + $pool->as('posts')->get('https://api.example.com/posts'), + $pool->as('comments')->get('https://api.example.com/comments'), +]); + +$users = $responses['users']->json(); +$posts = $responses['posts']->json(); +``` + +## Fake HTTP Calls in Tests + +Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`. + +Incorrect: +```php +it('syncs user from API', function () { + $service = new UserSyncService; + $service->sync(1); // Hits the real API +}); +``` + +Correct: +```php +it('syncs user from API', function () { + Http::preventStrayRequests(); + + Http::fake([ + 'api.example.com/users/1' => Http::response([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]), + ]); + + $service = new UserSyncService; + $service->sync(1); + + Http::assertSent(function (Request $request) { + return $request->url() === 'https://api.example.com/users/1'; + }); +}); +``` + +Test failure scenarios too: + +```php +Http::fake([ + 'api.example.com/*' => Http::failedConnection(), +]); +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/mail.md b/.agents/skills/laravel-best-practices/rules/mail.md new file mode 100644 index 00000000..2435d9cc --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/mail.md @@ -0,0 +1,27 @@ +# Mail Best Practices + +## Implement `ShouldQueue` on the Mailable Class + +Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it. + +## Use `afterCommit()` on Mailables Inside Transactions + +A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor. + +## Use `assertQueued()` Not `assertSent()` for Queued Mailables + +`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint. + +Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. + +Correct: `Mail::assertQueued(OrderShipped::class);` + +## Use Markdown Mailables for Transactional Emails + +Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag. + +## Separate Content Tests from Sending Tests + +Content tests: instantiate the mailable directly, call `assertSeeInHtml()`. +Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`. +Don't mix them — it conflates concerns and makes tests brittle. \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/migrations.md b/.agents/skills/laravel-best-practices/rules/migrations.md new file mode 100644 index 00000000..de25aa39 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/migrations.md @@ -0,0 +1,121 @@ +# Migration Best Practices + +## Generate Migrations with Artisan + +Always use `php artisan make:migration` for consistent naming and timestamps. + +Incorrect (manually created file): +```php +// database/migrations/posts_migration.php ← wrong naming, no timestamp +``` + +Correct (Artisan-generated): +```bash +php artisan make:migration create_posts_table +php artisan make:migration add_slug_to_posts_table +``` + +## Use `constrained()` for Foreign Keys + +Automatic naming and referential integrity. + +```php +$table->foreignId('user_id')->constrained()->cascadeOnDelete(); + +// Non-standard names +$table->foreignId('author_id')->constrained('users'); +``` + +## Never Modify Deployed Migrations + +Once a migration has run in production, treat it as immutable. Create a new migration to change the table. + +Incorrect (editing a deployed migration): +```php +// 2024_01_01_create_posts_table.php — already in production +$table->string('slug')->unique(); // ← added after deployment +``` + +Correct (new migration to alter): +```php +// 2024_03_15_add_slug_to_posts_table.php +Schema::table('posts', function (Blueprint $table) { + $table->string('slug')->unique()->after('title'); +}); +``` + +## Add Indexes in the Migration + +Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->index(); + $table->string('status')->index(); + $table->timestamp('shipped_at')->nullable()->index(); + $table->timestamps(); +}); +``` + +## Mirror Defaults in Model `$attributes` + +When a column has a database default, mirror it in the model so new instances have correct values before saving. + +```php +// Migration +$table->string('status')->default('pending'); + +// Model +protected $attributes = [ + 'status' => 'pending', +]; +``` + +## Write Reversible `down()` Methods by Default + +Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments. + +```php +public function down(): void +{ + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn('slug'); + }); +} +``` + +For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported. + +## Keep Migrations Focused + +One concern per migration. Never mix DDL (schema changes) and DML (data manipulation). + +Incorrect (partial failure creates unrecoverable state): +```php +public function up(): void +{ + Schema::create('settings', function (Blueprint $table) { ... }); + DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +} +``` + +Correct (separate migrations): +```php +// Migration 1: create_settings_table +Schema::create('settings', function (Blueprint $table) { ... }); + +// Migration 2: seed_default_settings +DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/queue-jobs.md b/.agents/skills/laravel-best-practices/rules/queue-jobs.md new file mode 100644 index 00000000..f7aa548b --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/queue-jobs.md @@ -0,0 +1,144 @@ +# Queue & Job Best Practices + +## Set `retry_after` Greater Than `timeout` + +If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution. + +Incorrect (`retry_after` ≤ `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 90 ← job retried while still running! +``` + +Correct (`retry_after` > `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 180 ← safely longer than any job timeout +``` + +## Use Exponential Backoff + +Use progressively longer delays between retries to avoid hammering failing services. + +Incorrect (fixed retry interval): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + // Default: retries immediately, overwhelming the API +} +``` + +Correct (exponential backoff): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + public $backoff = [1, 5, 10]; +} +``` + +## Implement `ShouldBeUnique` + +Prevent duplicate job processing. + +```php +class GenerateInvoice implements ShouldQueue, ShouldBeUnique +{ + public function uniqueId(): string + { + return $this->order->id; + } + + public $uniqueFor = 3600; +} +``` + +## Always Implement `failed()` + +Handle errors explicitly — don't rely on silent failure. + +```php +public function failed(?Throwable $exception): void +{ + $this->podcast->update(['status' => 'failed']); + Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]); +} +``` + +## Rate Limit External API Calls in Jobs + +Use `RateLimited` middleware to throttle jobs calling third-party APIs. + +```php +public function middleware(): array +{ + return [new RateLimited('external-api')]; +} +``` + +## Batch Related Jobs + +Use `Bus::batch()` when jobs should succeed or fail together. + +```php +Bus::batch([ + new ImportCsvChunk($chunk1), + new ImportCsvChunk($chunk2), +]) +->then(fn (Batch $batch) => Notification::send($user, new ImportComplete)) +->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed')) +->dispatch(); +``` + +## `retryUntil()` Needs `$tries = 0` + +When using time-based retry limits, set `$tries = 0` to avoid premature failure. + +```php +public $tries = 0; + +public function retryUntil(): \DateTimeInterface +{ + return now()->addHours(4); +} +``` + +## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release + +`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue. + +```php +class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing +{ + // Lock releases when processing begins, not when it finishes +} +``` + +## Use Horizon for Complex Queue Scenarios + +Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities. + +```php +// config/horizon.php +'environments' => [ + 'production' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['high', 'default', 'low'], + 'balance' => 'auto', + 'minProcesses' => 1, + 'maxProcesses' => 10, + 'tries' => 3, + ], + ], +], +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/routing.md b/.agents/skills/laravel-best-practices/rules/routing.md new file mode 100644 index 00000000..977d136e --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/routing.md @@ -0,0 +1,99 @@ +# Routing & Controllers Best Practices + +## Use Implicit Route Model Binding + +Let Laravel resolve models automatically from route parameters. + +Incorrect: +```php +public function show(int $id) +{ + $post = Post::findOrFail($id); +} +``` + +Correct: +```php +public function show(Post $post) +{ + return view('posts.show', ['post' => $post]); +} +``` + +## Use Scoped Bindings for Nested Resources + +Enforce parent-child relationships automatically. + +```php +Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { + // $post is automatically scoped to $user +})->scopeBindings(); +``` + +## Use Resource Controllers + +Use `Route::resource()` or `apiResource()` for RESTful endpoints. + +```php +Route::resource('posts', PostController::class); +// In routes/api.php — the /api prefix is applied automatically +Route::apiResource('posts', Api\PostController::class); +``` + +## Keep Controllers Thin + +Aim for under 10 lines per method. Extract business logic to action or service classes. + +Incorrect: +```php +public function store(Request $request) +{ + $validated = $request->validate([...]); + if ($request->hasFile('image')) { + $request->file('image')->move(public_path('images')); + } + $post = Post::create($validated); + $post->tags()->sync($validated['tags']); + event(new PostCreated($post)); + return redirect()->route('posts.show', $post); +} +``` + +Correct: +```php +public function store(StorePostRequest $request, CreatePostAction $create) +{ + $post = $create->execute($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +## Type-Hint Form Requests + +Type-hinting Form Requests triggers automatic validation and authorization before the method executes. + +Incorrect: +```php +public function store(Request $request): RedirectResponse +{ + $validated = $request->validate([ + 'title' => ['required', 'max:255'], + 'body' => ['required'], + ]); + + Post::create($validated); + + return redirect()->route('posts.index'); +} +``` + +Correct: +```php +public function store(StorePostRequest $request): RedirectResponse +{ + Post::create($request->validated()); + + return redirect()->route('posts.index'); +} +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/scheduling.md b/.agents/skills/laravel-best-practices/rules/scheduling.md new file mode 100644 index 00000000..dfaefa26 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/scheduling.md @@ -0,0 +1,39 @@ +# Task Scheduling Best Practices + +## Use `withoutOverlapping()` on Variable-Duration Tasks + +Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion. + +## Use `onOneServer()` on Multi-Server Deployments + +Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached). + +## Use `runInBackground()` for Concurrent Long Tasks + +By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes. + +## Use `environments()` to Restrict Tasks + +Prevent accidental execution of production-only tasks (billing, reporting) on staging. + +```php +Schedule::command('billing:charge')->monthly()->environments(['production']); +``` + +## Use `takeUntilTimeout()` for Time-Bounded Processing + +A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time. + +## Use Schedule Groups for Shared Configuration + +Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks. + +```php +Schedule::daily() + ->onOneServer() + ->timezone('America/New_York') + ->group(function () { + Schedule::command('emails:send --force'); + Schedule::command('emails:prune'); + }); +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/security.md b/.agents/skills/laravel-best-practices/rules/security.md new file mode 100644 index 00000000..909ff91a --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/security.md @@ -0,0 +1,198 @@ +# Security Best Practices + +## Mass Assignment Protection + +Every model must define `$fillable` (whitelist) or `$guarded` (blacklist). + +Incorrect: +```php +class User extends Model +{ + protected $guarded = []; // All fields are mass assignable +} +``` + +Correct: +```php +class User extends Model +{ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; +} +``` + +Never use `$guarded = []` on models that accept user input. + +## Authorize Every Action + +Use policies or gates in controllers. Never skip authorization. + +Incorrect: +```php +public function update(UpdatePostRequest $request, Post $post) +{ + $post->update($request->validated()); +} +``` + +Correct: +```php +public function update(UpdatePostRequest $request, Post $post) +{ + Gate::authorize('update', $post); + + $post->update($request->validated()); +} +``` + +Or via Form Request: + +```php +public function authorize(): bool +{ + return $this->user()->can('update', $this->route('post')); +} +``` + +## Prevent SQL Injection + +Always use parameter binding. Never interpolate user input into queries. + +Incorrect: +```php +DB::select("SELECT * FROM users WHERE name = '{$request->name}'"); +``` + +Correct: +```php +User::where('name', $request->name)->get(); + +// Raw expressions with bindings +User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get(); +``` + +## Escape Output to Prevent XSS + +Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content. + +Incorrect: +```blade +{!! $user->bio !!} +``` + +Correct: +```blade +{{ $user->bio }} +``` + +## CSRF Protection + +Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied. + +Incorrect: +```blade +
+ +
+``` + +Correct: +```blade +
+ @csrf + +
+``` + +## Rate Limit Auth and API Routes + +Apply `throttle` middleware to authentication and API routes. + +```php +RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by($request->ip()); +}); + +Route::post('/login', LoginController::class)->middleware('throttle:login'); +``` + +## Validate File Uploads + +Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames. + +```php +public function rules(): array +{ + return [ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + ]; +} +``` + +Store with generated filenames: + +```php +$path = $request->file('avatar')->store('avatars', 'public'); +``` + +## Keep Secrets Out of Code + +Never commit `.env`. Access secrets via `config()` only. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'api_key' => env('API_KEY'), + +// In application code +$key = config('services.api_key'); +``` + +## Audit Dependencies + +Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment. + +```bash +composer audit +``` + +## Encrypt Sensitive Database Fields + +Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`. + +Incorrect: +```php +class Integration extends Model +{ + protected function casts(): array + { + return [ + 'api_key' => 'string', + ]; + } +} +``` + +Correct: +```php +class Integration extends Model +{ + protected $hidden = ['api_key', 'api_secret']; + + protected function casts(): array + { + return [ + 'api_key' => 'encrypted', + 'api_secret' => 'encrypted', + ]; + } +} +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/style.md b/.agents/skills/laravel-best-practices/rules/style.md new file mode 100644 index 00000000..67af9891 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/style.md @@ -0,0 +1,125 @@ +# Conventions & Style + +## Follow Laravel Naming Conventions + +| What | Convention | Good | Bad | +|------|-----------|------|-----| +| Controller | singular | `ArticleController` | `ArticlesController` | +| Model | singular | `User` | `Users` | +| Table | plural, snake_case | `article_comments` | `articleComments` | +| Pivot table | singular alphabetical | `article_user` | `user_article` | +| Column | snake_case, no model name | `meta_title` | `article_meta_title` | +| Foreign key | singular model + `_id` | `article_id` | `articles_id` | +| Route | plural | `articles/1` | `article/1` | +| Route name | snake_case with dots | `users.show_active` | `users.show-active` | +| Method | camelCase | `getAll` | `get_all` | +| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` | +| Collection | descriptive, plural | `$activeUsers` | `$data` | +| Object | descriptive, singular | `$activeUser` | `$users` | +| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` | +| Config | snake_case | `google_calendar.php` | `googleCalendar.php` | +| Enum | singular | `UserType` | `UserTypes` | + +## Prefer Shorter Readable Syntax + +| Verbose | Shorter | +|---------|---------| +| `Session::get('cart')` | `session('cart')` | +| `$request->session()->get('cart')` | `session('cart')` | +| `$request->input('name')` | `$request->name` | +| `return Redirect::back()` | `return back()` | +| `Carbon::now()` | `now()` | +| `App::make('Class')` | `app('Class')` | +| `->where('column', '=', 1)` | `->where('column', 1)` | +| `->orderBy('created_at', 'desc')` | `->latest()` | +| `->orderBy('created_at', 'asc')` | `->oldest()` | +| `->first()->name` | `->value('name')` | + +## Use Laravel String & Array Helpers + +Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them. + +Strings — use `Str` and fluent `Str::of()` over raw PHP: +```php +// Incorrect +$slug = strtolower(str_replace(' ', '-', $title)); +$short = substr($text, 0, 100) . '...'; +$class = substr(strrchr('App\Models\User', '\'), 1); + +// Correct +$slug = Str::slug($title); +$short = Str::limit($text, 100); +$class = class_basename('App\Models\User'); +``` + +Fluent strings — chain operations for complex transformations: +```php +// Incorrect +$result = strtolower(trim(str_replace('_', '-', $input))); + +// Correct +$result = Str::of($input)->trim()->replace('_', '-')->lower(); +``` + +Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`. + +Arrays — use `Arr` over raw PHP: +```php +// Incorrect +$name = isset($array['user']['name']) ? $array['user']['name'] : 'default'; + +// Correct +$name = Arr::get($array, 'user.name', 'default'); +``` + +Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`. + +Numbers — use `Number` for display formatting: +```php +Number::format(1000000); // "1,000,000" +Number::currency(1500, 'USD'); // "$1,500.00" +Number::abbreviate(1000000); // "1M" +Number::fileSize(1024 * 1024); // "1 MB" +Number::percentage(75.5); // "75.5%" +``` + +URIs — use `Uri` for URL manipulation: +```php +$uri = Uri::of('https://example.com/search') + ->withQuery(['q' => 'laravel', 'page' => 1]); +``` + +Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining. + +Use `search-docs` for the full list of available methods — these helpers are extensive. + +## No Inline JS/CSS in Blade + +Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes. + +Incorrect: +```blade +let article = `{{ json_encode($article) }}`; +``` + +Correct: +```blade + +``` + +Pass data to JS via data attributes or use a dedicated PHP-to-JS package. + +## No Unnecessary Comments + +Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected. + +Incorrect: +```php +// Check if there are any joins +if (count((array) $builder->getQuery()->joins) > 0) +``` + +Correct: +```php +if ($this->hasJoins()) +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/testing.md b/.agents/skills/laravel-best-practices/rules/testing.md new file mode 100644 index 00000000..287b083b --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/testing.md @@ -0,0 +1,43 @@ +# Testing Best Practices + +## Use `LazilyRefreshDatabase` Over `RefreshDatabase` + +`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date. + +## Use Model Assertions Over Raw Database Assertions + +Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);` + +Correct: `$this->assertModelExists($user);` + +More expressive, type-safe, and fails with clearer messages. + +## Use Factory States and Sequences + +Named states make tests self-documenting. Sequences eliminate repetitive setup. + +Incorrect: `User::factory()->create(['email_verified_at' => null]);` + +Correct: `User::factory()->unverified()->create();` + +## Use `Exceptions::fake()` to Assert Exception Reporting + +Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally. + +## Call `Event::fake()` After Factory Setup + +Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models. + +Incorrect: `Event::fake(); $user = User::factory()->create();` + +Correct: `$user = User::factory()->create(); Event::fake();` + +## Use `recycle()` to Share Relationship Instances Across Factories + +Without `recycle()`, nested factories create separate instances of the same conceptual entity. + +```php +Ticket::factory() + ->recycle(Airline::factory()->create()) + ->create(); +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/validation.md b/.agents/skills/laravel-best-practices/rules/validation.md new file mode 100644 index 00000000..a20202ff --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/validation.md @@ -0,0 +1,75 @@ +# Validation & Forms Best Practices + +## Use Form Request Classes + +Extract validation from controllers into dedicated Form Request classes. + +Incorrect: +```php +public function store(Request $request) +{ + $request->validate([ + 'title' => 'required|max:255', + 'body' => 'required', + ]); +} +``` + +Correct: +```php +public function store(StorePostRequest $request) +{ + Post::create($request->validated()); +} +``` + +## Array vs. String Notation for Rules + +Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses. + +```php +// Preferred for new code +'email' => ['required', 'email', Rule::unique('users')], + +// Follow existing convention if the project uses string notation +'email' => 'required|email|unique:users', +``` + +## Always Use `validated()` + +Get only validated data. Never use `$request->all()` for mass operations. + +Incorrect: +```php +Post::create($request->all()); +``` + +Correct: +```php +Post::create($request->validated()); +``` + +## Use `Rule::when()` for Conditional Validation + +```php +'company_name' => [ + Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']), +], +``` + +## Use the `after()` Method for Custom Validation + +Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields. + +```php +public function after(): array +{ + return [ + function (Validator $validator) { + if ($this->quantity > Product::find($this->product_id)?->stock) { + $validator->errors()->add('quantity', 'Not enough stock.'); + } + }, + ]; +} +``` \ No newline at end of file diff --git a/.agents/skills/livewire-development/SKILL.md b/.agents/skills/livewire-development/SKILL.md new file mode 100644 index 00000000..62d032dd --- /dev/null +++ b/.agents/skills/livewire-development/SKILL.md @@ -0,0 +1,175 @@ +--- +name: livewire-development +description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire." +license: MIT +metadata: + author: laravel +--- + +# Livewire Development + +## Documentation + +Use `search-docs` for detailed Livewire 4 patterns and documentation. + +## Basic Usage + +### Creating Components + +```bash + +# Single-file component (SFC - default in v4) + +# Creates: resources/views/components/⚡create-post.blade.php + +php artisan make:livewire create-post + +# Page component (SFC - Full Page in v4) + +# Creates: resources/views/pages/⚡create-post.blade.php + +php artisan make:livewire pages::create-post + +# Multi-file component (MFC) + +# Creates: resources/views/components/⚡create-post/create-post.php + +# resources/views/components/⚡create-post/create-post.blade.php + +php artisan make:livewire create-post --mfc + +# Class-based component (v3 style) + +# Creates: app/Livewire/CreatePost.php AND resources/views/livewire/create-post.blade.php + +php artisan make:livewire create-post --class + +# With namespace + +php artisan make:livewire Posts/CreatePost +``` + +### Converting Between Formats + +Use `php artisan livewire:convert create-post` to convert between single-file, multi-file, and class-based formats. + +### Choosing a Component Format + +> **Always follow the project's existing conventions first.** Before creating any component, inspect the project's existing Livewire components to determine the established format (SFC, MFC, or class-based) and directory structure. Check `app/Livewire/`, `resources/views/components/`, and `resources/views/livewire/` for existing components. If the project already uses a consistent format, **use that same format** — even if it differs from the Livewire v4 defaults below. Only fall back to the v4 defaults (SFC in `resources/views/components/`) when no existing convention is established. + +Also check `config/livewire.php` for `make_command.type`, `make_command.emoji`, `component_locations`, and `component_namespaces` overrides, which change the default format and where files are stored. + +### Component Format Reference + +| Format | Flag | Class Path | View Path | +|--------|------|------------|-----------| +| Single-file (SFC) | default | — | `resources/views/components/⚡create-post.blade.php` (PHP + Blade in one file) | +| Full Page SFC | `pages::name` | — | `resources/views/pages/⚡create-post.blade.php` | +| Multi-file (MFC) | `--mfc` | `resources/views/components/⚡create-post/create-post.php` | `resources/views/components/⚡create-post/create-post.blade.php` | +| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` | +| View-based | default (Blade-only) | — | `resources/views/components/⚡create-post.blade.php` (Blade-only with functional state) | + +> **Important:** The ⚡ prefix shown above is the **default** behavior in Livewire v4 — it is **configurable**. Check `config/livewire.php` for the `make_command.emoji` setting. When `true` (default), always include the ⚡ prefix in filenames you create. When `false`, omit the ⚡ prefix from all paths above. + +Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates `resources/views/components/posts/⚡create-post.blade.php` (single-file by default). Use `make:livewire Posts/CreatePost --mfc` for multi-file output at `resources/views/components/posts/⚡create-post/create-post.php` and `resources/views/components/posts/⚡create-post/create-post.blade.php`. + +### Single-File Component Example + + +```php +count++; + } +}; +?> + +
+ +
+``` + +## Livewire 4 Specifics + +### Key Changes From Livewire 3 + +These things changed in Livewire 4, but may not have been updated in this application. Verify this application's setup to ensure you follow existing conventions. + +- Use `Route::livewire()` for full-page components (e.g., `Route::livewire('/posts/create', CreatePost::class)`); config keys renamed: `layout` → `component_layout`, `lazy_placeholder` → `component_placeholder`. +- `wire:model` now ignores child events by default (use `wire:model.deep` for old behavior); `wire:scroll` renamed to `wire:navigate:scroll`. +- Component tags must be properly closed; `wire:transition` now uses View Transitions API (modifiers removed). +- JavaScript: `$wire.$js('name', fn)` → `$wire.$js.name = fn`; `commit`/`request` hooks → `interceptMessage()`/`interceptRequest()`. + +### New Features + +- Component formats: single-file (SFC), multi-file (MFC), view-based components. +- Islands (`@island`) for isolated updates; async actions (`wire:click.async`, `#[Async]`) for parallel execution. +- Deferred/bundled loading: `defer`, `lazy.bundle` for optimized component loading. + +| Feature | Usage | Purpose | +|---------|-------|---------| +| Islands | `@island(name: 'stats')` | Isolated update regions | +| Async | `wire:click.async` or `#[Async]` | Non-blocking actions | +| Deferred | `defer` attribute | Load after page render | +| Bundled | `lazy.bundle` | Load multiple together | + +### New Directives + +- `wire:sort`, `wire:intersect`, `wire:ref`, `.renderless`, `.preserve-scroll` are available for use. +- `data-loading` attribute automatically added to elements triggering network requests. + +| Directive | Purpose | +|-----------|---------| +| `wire:sort` | Drag-and-drop sorting | +| `wire:intersect` | Viewport intersection detection | +| `wire:ref` | Element references for JS | +| `.renderless` | Component without rendering | +| `.preserve-scroll` | Preserve scroll position | + +## Best Practices + +- Always use `wire:key` in loops +- Use `wire:loading` for loading states +- Use `wire:model.live` for instant updates (default is debounced) +- Validate and authorize in actions (treat like HTTP requests) + +## Configuration + +- `smart_wire_keys` defaults to `true`; new configs: `component_locations`, `component_namespaces`, `make_command`, `csp_safe`. + +## Alpine & JavaScript + +- `wire:transition` uses browser View Transitions API; `$errors` and `$intercept` magic properties available. +- Non-blocking `wire:poll` and parallel `wire:model.live` updates improve performance. + +For interceptors and hooks, see [reference/javascript-hooks.md](reference/javascript-hooks.md). + +## Testing + + +```php +Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1); +``` + +## Verification + +1. Browser console: Check for JS errors +2. Network tab: Verify Livewire requests return 200 +3. Ensure `wire:key` on all `@foreach` loops + +## Common Pitfalls + +- Missing `wire:key` in loops → unexpected re-rendering +- Expecting `wire:model` real-time → use `wire:model.live` +- Unclosed component tags → syntax errors in v4 +- Using deprecated config keys or JS hooks +- Including Alpine.js separately (already bundled in Livewire 4) \ No newline at end of file diff --git a/.agents/skills/livewire-development/reference/javascript-hooks.md b/.agents/skills/livewire-development/reference/javascript-hooks.md new file mode 100644 index 00000000..d6a44170 --- /dev/null +++ b/.agents/skills/livewire-development/reference/javascript-hooks.md @@ -0,0 +1,39 @@ +# Livewire 4 JavaScript Integration + +## Interceptor System (v4) + +### Intercept Messages + +```js +Livewire.interceptMessage(({ component, message, onFinish, onSuccess, onError }) => { + onFinish(() => { /* After response, before processing */ }); + onSuccess(({ payload }) => { /* payload.snapshot, payload.effects */ }); + onError(() => { /* Server errors */ }); +}); +``` + +### Intercept Requests + +```js +Livewire.interceptRequest(({ request, onResponse, onSuccess, onError, onFailure }) => { + onResponse(({ response }) => { /* When received */ }); + onSuccess(({ response, responseJson }) => { /* Success */ }); + onError(({ response, responseBody, preventDefault }) => { /* 4xx/5xx */ }); + onFailure(({ error }) => { /* Network failures */ }); +}); +``` + +### Component-Scoped Interceptors + +```blade + +``` + +## Magic Properties + +- `$errors` - Access validation errors from JavaScript +- `$intercept` - Component-scoped interceptors \ No newline at end of file diff --git a/.agents/skills/pest-testing/SKILL.md b/.agents/skills/pest-testing/SKILL.md new file mode 100644 index 00000000..323d4723 --- /dev/null +++ b/.agents/skills/pest-testing/SKILL.md @@ -0,0 +1,159 @@ +--- +name: pest-testing +description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code." +license: MIT +metadata: + author: laravel +--- + +# Pest Testing 4 + +## Documentation + +Use `search-docs` for detailed Pest 4 patterns and documentation. + +## Basic Usage + +### Creating Tests + +All tests must be written using Pest. Use `php artisan make:test --pest {name}`. + +### Test Organization + +- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. +- Browser tests: `tests/Browser/` directory. +- Do NOT remove tests without approval - these are core application code. + +### Basic Test Structure + +Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`. + + +```php +it('is true', function () { + expect(true)->toBeTrue(); +}); +``` + +### Running Tests + +- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`. +- Run all tests: `php artisan test --compact`. +- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`. + +## Assertions + +Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: + + +```php +it('returns all', function () { + $this->postJson('/api/docs', [])->assertSuccessful(); +}); +``` + +| Use | Instead of | +|-----|------------| +| `assertSuccessful()` | `assertStatus(200)` | +| `assertNotFound()` | `assertStatus(404)` | +| `assertForbidden()` | `assertStatus(403)` | + +## Mocking + +Import mock function before use: `use function Pest\Laravel\mock;` + +## Datasets + +Use datasets for repetitive tests (validation rules, etc.): + + +```php +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); +``` + +## Pest 4 Features + +| Feature | Purpose | +|---------|---------| +| Browser Testing | Full integration tests in real browsers | +| Smoke Testing | Validate multiple pages quickly | +| Visual Regression | Compare screenshots for visual changes | +| Test Sharding | Parallel CI runs | +| Architecture Testing | Enforce code conventions | + +### Browser Test Example + +Browser tests run in real browsers for full integration testing: + +- Browser tests live in `tests/Browser/`. +- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories. +- Use `RefreshDatabase` for clean state per test. +- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures. +- Test on multiple browsers (Chrome, Firefox, Safari) if requested. +- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested. +- Switch color schemes (light/dark mode) when appropriate. +- Take screenshots or pause tests for debugging. + + +```php +it('may reset the password', function () { + Notification::fake(); + + $this->actingAs(User::factory()->create()); + + $page = visit('/sign-in'); + + $page->assertSee('Sign In') + ->assertNoJavaScriptErrors() + ->click('Forgot Password?') + ->fill('email', 'nuno@laravel.com') + ->click('Send Reset Link') + ->assertSee('We have emailed your password reset link!'); + + Notification::assertSent(ResetPassword::class); +}); +``` + +### Smoke Testing + +Quickly validate multiple pages have no JavaScript errors: + + +```php +$pages = visit(['/', '/about', '/contact']); + +$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs(); +``` + +### Visual Regression Testing + +Capture and compare screenshots to detect visual changes. + +### Test Sharding + +Split tests across parallel processes for faster CI runs. + +### Architecture Testing + +Pest 4 includes architecture testing (from Pest 3): + + +```php +arch('controllers') + ->expect('App\Http\Controllers') + ->toExtendNothing() + ->toHaveSuffix('Controller'); +``` + +## Common Pitfalls + +- Not importing `use function Pest\Laravel\mock;` before using mock +- Using `assertStatus(200)` instead of `assertSuccessful()` +- Forgetting datasets for repetitive validation tests +- Deleting tests without approval +- Forgetting `assertNoJavaScriptErrors()` in browser tests \ No newline at end of file diff --git a/.agents/skills/tailwindcss-development/SKILL.md b/.agents/skills/tailwindcss-development/SKILL.md new file mode 100644 index 00000000..7c8e295e --- /dev/null +++ b/.agents/skills/tailwindcss-development/SKILL.md @@ -0,0 +1,119 @@ +--- +name: tailwindcss-development +description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS." +license: MIT +metadata: + author: laravel +--- + +# Tailwind CSS Development + +## Documentation + +Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. + +## Basic Usage + +- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. +- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). +- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. + +## Tailwind CSS v4 Specifics + +- Always use Tailwind CSS v4 and avoid deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. + +### CSS-First Configuration + +In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: + + +```css +@theme { + --color-brand: oklch(0.72 0.11 178); +} +``` + +### Import Syntax + +In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: + + +```diff +- @tailwind base; +- @tailwind components; +- @tailwind utilities; ++ @import "tailwindcss"; +``` + +### Replaced Utilities + +Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. + +| Deprecated | Replacement | +|------------|-------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + +## Spacing + +Use `gap` utilities instead of margins for spacing between siblings: + + +```html +
+
Item 1
+
Item 2
+
+``` + +## Dark Mode + +If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: + + +```html +
+ Content adapts to color scheme +
+``` + +## Common Patterns + +### Flexbox Layout + + +```html +
+
Left content
+
Right content
+
+``` + +### Grid Layout + + +```html +
+
Card 1
+
Card 2
+
Card 3
+
+``` + +## Common Pitfalls + +- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.) +- Using `@tailwind` directives instead of `@import "tailwindcss"` +- Trying to use `tailwind.config.js` instead of CSS `@theme` directive +- Using margins for spacing between siblings instead of gap utilities +- Forgetting to add dark mode variants when the project uses dark mode \ No newline at end of file diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 00000000..2a2fdc87 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,4 @@ +[mcp_servers.laravel-boost] +command = "php" +args = ["artisan", "boost:mcp"] +cwd = "/Users/fabianwesner/Herd/shop" diff --git a/.playwright-mcp/console-2026-04-18T07-42-21-973Z.log b/.playwright-mcp/console-2026-04-18T07-42-21-973Z.log new file mode 100644 index 00000000..107e16b8 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T07-42-21-973Z.log @@ -0,0 +1,2 @@ +[ 304ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/:49 +[ 469ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-04-18T07-42-29-697Z.log b/.playwright-mcp/console-2026-04-18T07-42-29-697Z.log new file mode 100644 index 00000000..44e42766 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T07-42-29-697Z.log @@ -0,0 +1 @@ +[ 76ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/collections:49 diff --git a/.playwright-mcp/console-2026-04-18T07-42-33-348Z.log b/.playwright-mcp/console-2026-04-18T07-42-33-348Z.log new file mode 100644 index 00000000..6a4230e2 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T07-42-33-348Z.log @@ -0,0 +1 @@ +[ 115ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/collections/featured:49 diff --git a/.playwright-mcp/console-2026-04-18T07-42-37-846Z.log b/.playwright-mcp/console-2026-04-18T07-42-37-846Z.log new file mode 100644 index 00000000..78dff563 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T07-42-37-846Z.log @@ -0,0 +1,6 @@ +[ 233ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://shop.test/products/organic-cotton-t-shirt:0 +[ 237ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:21 +[ 19454ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://shop.test/products/organic-cotton-t-shirt:0 +[ 19461ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:21 +[ 27391ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 +[ 27465ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:49 diff --git a/.playwright-mcp/console-2026-04-18T07-43-07-589Z.log b/.playwright-mcp/console-2026-04-18T07-43-07-589Z.log new file mode 100644 index 00000000..d3b8d65f --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T07-43-07-589Z.log @@ -0,0 +1,2 @@ +[ 91ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:49 +[ 100ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 diff --git a/.playwright-mcp/console-2026-04-18T07-43-15-408Z.log b/.playwright-mcp/console-2026-04-18T07-43-15-408Z.log new file mode 100644 index 00000000..d445da32 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T07-43-15-408Z.log @@ -0,0 +1 @@ +[ 75ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/pages/about:49 diff --git a/.playwright-mcp/console-2026-04-18T07-43-17-415Z.log b/.playwright-mcp/console-2026-04-18T07-43-17-415Z.log new file mode 100644 index 00000000..452802f7 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T07-43-17-415Z.log @@ -0,0 +1 @@ +[ 82ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/search?q=cotton:49 diff --git a/.playwright-mcp/console-2026-04-18T07-43-21-810Z.log b/.playwright-mcp/console-2026-04-18T07-43-21-810Z.log new file mode 100644 index 00000000..8806e7a7 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T07-43-21-810Z.log @@ -0,0 +1 @@ +[ 87ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/cart:49 diff --git a/.playwright-mcp/console-2026-04-18T07-43-24-813Z.log b/.playwright-mcp/console-2026-04-18T07-43-24-813Z.log new file mode 100644 index 00000000..c4f5c291 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T07-43-24-813Z.log @@ -0,0 +1,2 @@ +[ 106ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/products/does-not-exist:0 +[ 115ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/does-not-exist:43 diff --git a/.playwright-mcp/console-2026-04-18T08-04-27-959Z.log b/.playwright-mcp/console-2026-04-18T08-04-27-959Z.log new file mode 100644 index 00000000..3686f109 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-04-27-959Z.log @@ -0,0 +1,2 @@ +[ 237ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/cart:49 +[ 309ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-04-18T08-04-43-558Z.log b/.playwright-mcp/console-2026-04-18T08-04-43-558Z.log new file mode 100644 index 00000000..a6889113 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-04-43-558Z.log @@ -0,0 +1 @@ +[ 91ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/collections:49 diff --git a/.playwright-mcp/console-2026-04-18T08-04-48-155Z.log b/.playwright-mcp/console-2026-04-18T08-04-48-155Z.log new file mode 100644 index 00000000..d33854c2 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-04-48-155Z.log @@ -0,0 +1 @@ +[ 87ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/collections/featured:49 diff --git a/.playwright-mcp/console-2026-04-18T08-04-54-091Z.log b/.playwright-mcp/console-2026-04-18T08-04-54-091Z.log new file mode 100644 index 00000000..da422c1a --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-04-54-091Z.log @@ -0,0 +1,4 @@ +[ 87ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:49 +[ 99ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 +[ 15992ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/livewire-6701cc17/update:0 +[ 16008ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:6 diff --git a/.playwright-mcp/console-2026-04-18T08-06-02-803Z.log b/.playwright-mcp/console-2026-04-18T08-06-02-803Z.log new file mode 100644 index 00000000..bb2753ae --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-06-02-803Z.log @@ -0,0 +1,4 @@ +[ 138ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:49 +[ 174ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 +[ 4756ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/livewire-6701cc17/update:0 +[ 4767ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:6 diff --git a/.playwright-mcp/console-2026-04-18T08-06-32-813Z.log b/.playwright-mcp/console-2026-04-18T08-06-32-813Z.log new file mode 100644 index 00000000..3a9094d3 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-06-32-813Z.log @@ -0,0 +1,4 @@ +[ 93ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt?_=1:49 +[ 103ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 +[ 7228ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/livewire-6701cc17/update:0 +[ 7243ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt?_=1:6 diff --git a/.playwright-mcp/console-2026-04-18T08-07-33-682Z.log b/.playwright-mcp/console-2026-04-18T08-07-33-682Z.log new file mode 100644 index 00000000..97c691f2 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-07-33-682Z.log @@ -0,0 +1,3 @@ +[ 140ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:49 +[ 152ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 +[ 7388ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 diff --git a/.playwright-mcp/console-2026-04-18T08-07-51-938Z.log b/.playwright-mcp/console-2026-04-18T08-07-51-938Z.log new file mode 100644 index 00000000..1288fed6 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-07-51-938Z.log @@ -0,0 +1 @@ +[ 121ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/cart:49 diff --git a/.playwright-mcp/console-2026-04-18T08-07-58-376Z.log b/.playwright-mcp/console-2026-04-18T08-07-58-376Z.log new file mode 100644 index 00000000..437a4484 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-07-58-376Z.log @@ -0,0 +1 @@ +[ 111ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout:49 diff --git a/.playwright-mcp/console-2026-04-18T08-18-52-998Z.log b/.playwright-mcp/console-2026-04-18T08-18-52-998Z.log new file mode 100644 index 00000000..e2dac582 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-18-52-998Z.log @@ -0,0 +1,3 @@ +[ 168ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 +[ 282ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:49 +[ 378ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-04-18T08-26-52-212Z.log b/.playwright-mcp/console-2026-04-18T08-26-52-212Z.log new file mode 100644 index 00000000..16be4546 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-26-52-212Z.log @@ -0,0 +1,6 @@ +[ 110ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/account/login:0 +[ 239ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/login:43 +[ 282ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 +[ 3623ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/account/login:0 +[ 3627ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/login:43 +[ 14000ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/login:49 diff --git a/.playwright-mcp/console-2026-04-18T08-27-08-697Z.log b/.playwright-mcp/console-2026-04-18T08-27-08-697Z.log new file mode 100644 index 00000000..d14e2eae --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-27-08-697Z.log @@ -0,0 +1,4 @@ +[ 73ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/login:49 +[ 14324ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/login:49 +[ 31486ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account:49 +[ 39743ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account:49 diff --git a/.playwright-mcp/console-2026-04-18T08-27-49-983Z.log b/.playwright-mcp/console-2026-04-18T08-27-49-983Z.log new file mode 100644 index 00000000..a41c459c --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-27-49-983Z.log @@ -0,0 +1 @@ +[ 81ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/orders:49 diff --git a/.playwright-mcp/console-2026-04-18T08-27-52-992Z.log b/.playwright-mcp/console-2026-04-18T08-27-52-992Z.log new file mode 100644 index 00000000..578c0c3d --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-27-52-992Z.log @@ -0,0 +1 @@ +[ 91ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/addresses:49 diff --git a/.playwright-mcp/console-2026-04-18T08-39-15-591Z.log b/.playwright-mcp/console-2026-04-18T08-39-15-591Z.log new file mode 100644 index 00000000..cb2ea445 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-39-15-591Z.log @@ -0,0 +1,5 @@ +[ 230ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/:49 +[ 293ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 +[ 8201ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:49 +[ 8214ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 +[ 14052ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 diff --git a/.playwright-mcp/console-2026-04-18T08-41-52-531Z.log b/.playwright-mcp/console-2026-04-18T08-41-52-531Z.log new file mode 100644 index 00000000..33f7401d --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-41-52-531Z.log @@ -0,0 +1,3 @@ +[ 94ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/organic-cotton-t-shirt:49 +[ 112ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 +[ 5209ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://shop.test/storage/products/tshirt-front.jpg:0 diff --git a/.playwright-mcp/console-2026-04-18T08-42-02-646Z.log b/.playwright-mcp/console-2026-04-18T08-42-02-646Z.log new file mode 100644 index 00000000..c3cc0575 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-42-02-646Z.log @@ -0,0 +1,3 @@ +[ 98ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout:49 +[ 86800ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout/confirmation/1001:49 +[ 101025ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout/confirmation/1001:49 diff --git a/.playwright-mcp/console-2026-04-18T08-43-55-862Z.log b/.playwright-mcp/console-2026-04-18T08-43-55-862Z.log new file mode 100644 index 00000000..1e17718c --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-43-55-862Z.log @@ -0,0 +1 @@ +[ 87ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout/confirmation/1001:49 diff --git a/.playwright-mcp/console-2026-04-18T08-44-17-066Z.log b/.playwright-mcp/console-2026-04-18T08-44-17-066Z.log new file mode 100644 index 00000000..09506d99 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-44-17-066Z.log @@ -0,0 +1,2 @@ +[ 91ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout/confirmation/1001:49 +[ 280ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout/confirmation/1001:49 diff --git a/.playwright-mcp/console-2026-04-18T08-44-45-330Z.log b/.playwright-mcp/console-2026-04-18T08-44-45-330Z.log new file mode 100644 index 00000000..542a1b0a --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-44-45-330Z.log @@ -0,0 +1 @@ +[ 136ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout/confirmation/1001:49 diff --git a/.playwright-mcp/console-2026-04-18T08-44-53-484Z.log b/.playwright-mcp/console-2026-04-18T08-44-53-484Z.log new file mode 100644 index 00000000..6b44a145 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-44-53-484Z.log @@ -0,0 +1,2 @@ +[ 82ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/login:49 +[ 26367ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account:49 diff --git a/.playwright-mcp/console-2026-04-18T08-45-34-419Z.log b/.playwright-mcp/console-2026-04-18T08-45-34-419Z.log new file mode 100644 index 00000000..932c191b --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-45-34-419Z.log @@ -0,0 +1 @@ +[ 83ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/orders:49 diff --git a/.playwright-mcp/console-2026-04-18T08-45-49-887Z.log b/.playwright-mcp/console-2026-04-18T08-45-49-887Z.log new file mode 100644 index 00000000..5d505e67 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-45-49-887Z.log @@ -0,0 +1,2 @@ +[ 79ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/login:46 +[ 20950ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin:46 diff --git a/.playwright-mcp/console-2026-04-18T08-46-24-044Z.log b/.playwright-mcp/console-2026-04-18T08-46-24-044Z.log new file mode 100644 index 00000000..a0bcda23 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-46-24-044Z.log @@ -0,0 +1 @@ +[ 102ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/orders/1:46 diff --git a/.playwright-mcp/console-2026-04-18T08-46-55-981Z.log b/.playwright-mcp/console-2026-04-18T08-46-55-981Z.log new file mode 100644 index 00000000..f191f5df --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-46-55-981Z.log @@ -0,0 +1 @@ +[ 82ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/products:46 diff --git a/.playwright-mcp/console-2026-04-18T08-47-07-970Z.log b/.playwright-mcp/console-2026-04-18T08-47-07-970Z.log new file mode 100644 index 00000000..d7239594 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-47-07-970Z.log @@ -0,0 +1 @@ +[ 88ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/settings/shipping:46 diff --git a/.playwright-mcp/console-2026-04-18T08-47-18-512Z.log b/.playwright-mcp/console-2026-04-18T08-47-18-512Z.log new file mode 100644 index 00000000..6dbb4c54 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-47-18-512Z.log @@ -0,0 +1 @@ +[ 71ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/discounts:46 diff --git a/.playwright-mcp/console-2026-04-18T08-47-22-997Z.log b/.playwright-mcp/console-2026-04-18T08-47-22-997Z.log new file mode 100644 index 00000000..e4aaf3d4 --- /dev/null +++ b/.playwright-mcp/console-2026-04-18T08-47-22-997Z.log @@ -0,0 +1 @@ +[ 70ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/analytics:46 diff --git a/.playwright-mcp/page-2026-04-18T07-42-22-458Z.yml b/.playwright-mcp/page-2026-04-18T07-42-22-458Z.yml new file mode 100644 index 00000000..42a7487c --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T07-42-22-458Z.yml @@ -0,0 +1,42 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - generic [ref=e14]: + - generic [ref=e15]: Elevated essentials + - paragraph [ref=e16]: Timeless pieces for modern wardrobes. + - link "Shop the collection" [ref=e18] [cursor=pointer]: + - /url: /collections + - generic [ref=e19]: + - generic [ref=e20]: Featured collections + - link "Featured" [ref=e22] [cursor=pointer]: + - /url: http://shop.test/collections/featured + - generic [ref=e23]: Featured + - generic [ref=e24]: + - generic [ref=e25]: Featured products + - generic [ref=e26]: + - link "Organic Cotton T-Shirt" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/products/organic-cotton-t-shirt + - img [ref=e31] + - generic [ref=e34]: Organic Cotton T-Shirt + - link "Classic Pullover Hoodie" [ref=e36] [cursor=pointer]: + - /url: http://shop.test/products/classic-pullover-hoodie + - img [ref=e39] + - generic [ref=e42]: Classic Pullover Hoodie + - contentinfo [ref=e43]: + - generic [ref=e44]: © 2026 Acme Fashion. All rights reserved. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T07-42-29-798Z.yml b/.playwright-mcp/page-2026-04-18T07-42-29-798Z.yml new file mode 100644 index 00000000..7b401b88 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T07-42-29-798Z.yml @@ -0,0 +1,33 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Collections + - generic [ref=e22]: Collections + - link "Featured" [ref=e24] [cursor=pointer]: + - /url: http://shop.test/collections/featured + - generic [ref=e25]: Featured + - contentinfo [ref=e26]: + - generic [ref=e27]: © 2026 Acme Fashion. All rights reserved. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T07-42-33-517Z.yml b/.playwright-mcp/page-2026-04-18T07-42-33-517Z.yml new file mode 100644 index 00000000..fa468a0f --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T07-42-33-517Z.yml @@ -0,0 +1,51 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Collections" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/collections + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Featured + - generic [ref=e26]: Featured + - paragraph [ref=e28]: Our picks for the season. + - generic [ref=e29]: + - textbox "Search products..." [ref=e31] + - combobox [ref=e33]: + - 'option "Sort: default" [selected]' + - option "Title A-Z" + - option "Title Z-A" + - option "Newest" + - generic [ref=e34]: + - link "Organic Cotton T-Shirt" [ref=e36] [cursor=pointer]: + - /url: http://shop.test/products/organic-cotton-t-shirt + - img [ref=e39] + - generic [ref=e42]: Organic Cotton T-Shirt + - link "Classic Pullover Hoodie" [ref=e44] [cursor=pointer]: + - /url: http://shop.test/products/classic-pullover-hoodie + - img [ref=e47] + - generic [ref=e50]: Classic Pullover Hoodie + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T07-42-38-652Z.yml b/.playwright-mcp/page-2026-04-18T07-42-38-652Z.yml new file mode 100644 index 00000000..c678a6f1 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T07-42-38-652Z.yml @@ -0,0 +1,212 @@ +- generic [ref=e2]: + - generic [ref=e4]: + - generic [ref=e5]: + - img [ref=e7] + - generic [ref=e10]: Internal Server Error + - button "Copy as Markdown" [ref=e11] [cursor=pointer]: + - img [ref=e12] + - generic [ref=e15]: Copy as Markdown + - generic [ref=e18]: + - generic [ref=e19]: + - heading "ErrorException" [level=1] [ref=e20] + - generic [ref=e22]: resources/views/livewire/storefront/products/show.blade.php:17 + - paragraph [ref=e23]: "Undefined property: stdClass::$url" + - generic [ref=e24]: + - generic [ref=e25]: + - generic [ref=e26]: + - generic [ref=e27]: LARAVEL + - generic [ref=e28]: 12.51.0 + - generic [ref=e29]: + - generic [ref=e30]: PHP + - generic [ref=e31]: 8.4.17 + - generic [ref=e32]: + - img [ref=e33] + - text: UNHANDLED + - generic [ref=e36]: CODE 0 + - generic [ref=e38]: + - generic [ref=e39]: + - img [ref=e40] + - text: "500" + - generic [ref=e43]: + - img [ref=e44] + - text: GET + - generic [ref=e47]: http://shop.test/products/organic-cotton-t-shirt + - button [ref=e48] [cursor=pointer]: + - img [ref=e49] + - generic [ref=e53]: + - generic [ref=e54]: + - generic [ref=e55]: + - img [ref=e57] + - heading "Exception trace" [level=3] [ref=e60] + - generic [ref=e61]: + - generic [ref=e62]: + - generic [ref=e63] [cursor=pointer]: + - generic [ref=e66]: + - code [ref=e70]: + - generic [ref=e71]: resources/views/livewire/storefront/products/show.blade.php + - generic [ref=e73]: resources/views/livewire/storefront/products/show.blade.php:17 + - button [ref=e75]: + - img [ref=e76] + - code [ref=e84]: + - generic [ref=e85]: "12" + - generic [ref=e86]: 13
+ - generic [ref=e87]: 14
+ - generic [ref=e88]: 15 @if (! empty($media)) + - generic [ref=e89]: 16
+ - generic [ref=e90]: "17 url }}\" alt=\"{{ $media[0]->alt_text ?? $product->title }}\" class=\"h-full w-full object-cover\" />" + - generic [ref=e91]: 18
+ - generic [ref=e92]: 19 @if (count($media) > 1) + - generic [ref=e93]: 20
+ - generic [ref=e94]: 21 @foreach (array_slice($media, 1, 7) as $item) + - generic [ref=e95]: "22
id }}\" class=\"aspect-square overflow-hidden rounded-md bg-zinc-100 dark:bg-zinc-800\">" + - generic [ref=e96]: "23 url }}\" alt=\"{{ $item->alt_text ?? '' }}\" class=\"h-full w-full object-cover\" loading=\"lazy\" />" + - generic [ref=e97]: 24
+ - generic [ref=e98]: 25 @endforeach + - generic [ref=e99]: 26
+ - generic [ref=e100]: 27 @endif + - generic [ref=e101]: 28 @else + - generic [ref=e102]: "29" + - generic [ref=e104] [cursor=pointer]: + - img [ref=e105] + - generic [ref=e109]: 20 vendor frames + - button [ref=e110]: + - img [ref=e111] + - generic [ref=e116] [cursor=pointer]: + - generic [ref=e119]: + - code [ref=e123]: + - generic [ref=e124]: app/Http/Middleware/ResolveStore.php + - generic [ref=e126]: app/Http/Middleware/ResolveStore.php:36 + - button [ref=e128]: + - img [ref=e129] + - generic [ref=e134] [cursor=pointer]: + - img [ref=e135] + - generic [ref=e139]: 49 vendor frames + - button [ref=e140]: + - img [ref=e141] + - generic [ref=e146] [cursor=pointer]: + - generic [ref=e149]: + - code [ref=e153]: + - generic [ref=e154]: public/index.php + - generic [ref=e156]: public/index.php:20 + - button [ref=e158]: + - img [ref=e159] + - generic [ref=e164] [cursor=pointer]: + - img [ref=e165] + - generic [ref=e169]: 1 vendor frame + - button [ref=e170]: + - img [ref=e171] + - generic [ref=e175]: + - generic [ref=e176]: + - generic [ref=e177]: + - img [ref=e179] + - heading "Queries" [level=3] [ref=e181] + - generic [ref=e183]: 1-7 of 7 + - generic [ref=e184]: + - generic [ref=e185]: + - generic [ref=e186]: + - generic [ref=e187]: + - img [ref=e188] + - generic [ref=e190]: sqlite + - code [ref=e194]: + - generic [ref=e195]: select * from "stores" where "stores"."id" = 1 limit 1 + - generic [ref=e196]: 1.77ms + - generic [ref=e197]: + - generic [ref=e198]: + - generic [ref=e199]: + - img [ref=e200] + - generic [ref=e202]: sqlite + - code [ref=e206]: + - generic [ref=e207]: select exists (select 1 from "main".sqlite_master where name = 'products' and type = 'table') as "exists" + - generic [ref=e208]: 0.04ms + - generic [ref=e209]: + - generic [ref=e210]: + - generic [ref=e211]: + - img [ref=e212] + - generic [ref=e214]: sqlite + - code [ref=e218]: + - generic [ref=e219]: select "id", "title", "handle", "description_html", "tags" from "products" where "store_id" = 1 and "handle" = 'organic-cotton-t-shirt' and "status" = 'active' limit 1 + - generic [ref=e220]: 0.04ms + - generic [ref=e221]: + - generic [ref=e222]: + - generic [ref=e223]: + - img [ref=e224] + - generic [ref=e226]: sqlite + - code [ref=e230]: + - generic [ref=e231]: select exists (select 1 from "main".sqlite_master where name = 'product_variants' and type = 'table') as "exists" + - generic [ref=e232]: 0.02ms + - generic [ref=e233]: + - generic [ref=e234]: + - generic [ref=e235]: + - img [ref=e236] + - generic [ref=e238]: sqlite + - code [ref=e242]: + - generic [ref=e243]: select "id", "sku", "price_amount", "compare_at_amount", "currency", "is_default", "status" from "product_variants" where "product_id" = 1 order by "position" asc + - generic [ref=e244]: 0.1ms + - generic [ref=e245]: + - generic [ref=e246]: + - generic [ref=e247]: + - img [ref=e248] + - generic [ref=e250]: sqlite + - code [ref=e254]: + - generic [ref=e255]: select exists (select 1 from "main".sqlite_master where name = 'product_media' and type = 'table') as "exists" + - generic [ref=e256]: 0.02ms + - generic [ref=e257]: + - generic [ref=e258]: + - generic [ref=e259]: + - img [ref=e260] + - generic [ref=e262]: sqlite + - code [ref=e266]: + - generic [ref=e267]: select "id", "url", "alt_text" from "product_media" where "product_id" = 1 order by "position" asc + - generic [ref=e268]: 0.03ms + - generic [ref=e270]: + - generic [ref=e271]: + - heading "Headers" [level=2] [ref=e272] + - generic [ref=e273]: + - generic [ref=e274]: + - generic [ref=e275]: cookie + - generic [ref=e277]: XSRF-TOKEN=eyJpdiI6IlJINnRyMXFuWFpGby80QjVuclVHRmc9PSIsInZhbHVlIjoiOTJyZGVNY0s5TnVWZDlOWThzNDBTTXFUMXhRd0lyZlNZYytoTTFjQ1lubXI3RHhtZkpwangzRDBTY1JyNlRCd0xORnRiYUlrNnRRV0lNZmgwTU1pakRqOVJ6UHN4UWpJdnBvNzE0SUxGWUxIUThpOUZiWnZPL1FSNUtXakhub1ciLCJtYWMiOiJiOWRmMWMyN2Q2YmFlNzI1MWM4MGYyNzU4YjM3ZTE1NTdjZDdmMmFjMzgxY2UxMDIyN2E5NTJkZTUzZGZiN2MxIiwidGFnIjoiIn0%3D; shop_session=eyJpdiI6Imk5MDRCRnp6Q1FSckhVU1FQZmhpeXc9PSIsInZhbHVlIjoiNnIyMUdHdHozQk5GcHUvNkFMN0FMS2FPMDdTcnA0cnptV1VjTm0ySFdndDlySSt1R2pDbzJZN09VdXhGUHZsQi83R09Udnpiby95NmFrclEyUVd6Vk5ETlo0ai9TOHd4UjhyWlc1RlVLNXArcW9udmljMDU4TTloTUtwbjZQNzkiLCJtYWMiOiI4YmU4OWI0OGI5N2ZhOGVkNDgyMmIzYjE2YWY0ZTc3Zjg1ZjFlYzM4YWYzZTE5NmNlNTUzYmI1MGZjNzMyYWY5IiwidGFnIjoiIn0%3D + - generic [ref=e278]: + - generic [ref=e279]: accept-language + - generic [ref=e281]: en-GB,en-US;q=0.9,en;q=0.8 + - generic [ref=e282]: + - generic [ref=e283]: accept-encoding + - generic [ref=e285]: gzip, deflate + - generic [ref=e286]: + - generic [ref=e287]: accept + - generic [ref=e289]: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 + - generic [ref=e290]: + - generic [ref=e291]: user-agent + - generic [ref=e293]: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 + - generic [ref=e294]: + - generic [ref=e295]: upgrade-insecure-requests + - generic [ref=e297]: "1" + - generic [ref=e298]: + - generic [ref=e299]: connection + - generic [ref=e301]: keep-alive + - generic [ref=e302]: + - generic [ref=e303]: host + - generic [ref=e305]: shop.test + - generic [ref=e306]: + - heading "Body" [level=2] [ref=e307] + - generic [ref=e308]: // No request body + - generic [ref=e309]: + - heading "Routing" [level=2] [ref=e310] + - generic [ref=e311]: + - generic [ref=e312]: + - generic [ref=e313]: controller + - generic [ref=e315]: App\Livewire\Storefront\Products\Show + - generic [ref=e316]: + - generic [ref=e317]: route name + - generic [ref=e319]: storefront.products.show + - generic [ref=e320]: + - generic [ref=e321]: middleware + - generic [ref=e323]: web, storefront + - generic [ref=e324]: + - heading "Routing parameters" [level=2] [ref=e325] + - code [ref=e330]: + - generic [ref=e331]: "{" + - generic [ref=e332]: "\"handle\": \"organic-cotton-t-shirt\"" + - generic [ref=e333]: "}" + - generic [ref=e336]: + - img [ref=e338] + - img [ref=e3376] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T07-43-07-708Z.yml b/.playwright-mcp/page-2026-04-18T07-43-07-708Z.yml new file mode 100644 index 00000000..1d3e83b5 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T07-43-07-708Z.yml @@ -0,0 +1,56 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T07-43-15-507Z.yml b/.playwright-mcp/page-2026-04-18T07-43-15-507Z.yml new file mode 100644 index 00000000..49ca496f --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T07-43-15-507Z.yml @@ -0,0 +1,32 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: About Us + - article [ref=e22]: + - heading "About Us" [level=1] [ref=e23] + - paragraph [ref=e24]: Acme Fashion is a demo store created to showcase the platform. + - contentinfo [ref=e25]: + - generic [ref=e26]: © 2026 Acme Fashion. All rights reserved. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T07-43-17-578Z.yml b/.playwright-mcp/page-2026-04-18T07-43-17-578Z.yml new file mode 100644 index 00000000..ca255515 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T07-43-17-578Z.yml @@ -0,0 +1,35 @@ +- generic [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Search + - generic [ref=e22]: Search + - textbox "What are you looking for?" [active] [ref=e25]: cotton + - link "Organic Cotton T-Shirt" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/products/organic-cotton-t-shirt + - img [ref=e32] + - generic [ref=e35]: Organic Cotton T-Shirt + - contentinfo [ref=e36]: + - generic [ref=e37]: © 2026 Acme Fashion. All rights reserved. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T07-43-21-920Z.yml b/.playwright-mcp/page-2026-04-18T07-43-21-920Z.yml new file mode 100644 index 00000000..66d04376 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T07-43-21-920Z.yml @@ -0,0 +1,37 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Cart + - generic [ref=e22]: Your cart + - generic [ref=e23]: + - img [ref=e25] + - generic [ref=e28]: + - text: Your cart is empty. Browse + - link "our collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/collections + - text: to get started. + - contentinfo [ref=e30]: + - generic [ref=e31]: © 2026 Acme Fashion. All rights reserved. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T07-43-24-950Z.yml b/.playwright-mcp/page-2026-04-18T07-43-24-950Z.yml new file mode 100644 index 00000000..82c607c2 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T07-43-24-950Z.yml @@ -0,0 +1,6 @@ +- generic [ref=e2]: + - paragraph [ref=e3]: "404" + - heading "Page not found" [level=1] [ref=e4] + - paragraph [ref=e5]: The page you are looking for does not exist or has been moved. + - link "Back to home" [ref=e7] [cursor=pointer]: + - /url: / \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-04-28-270Z.yml b/.playwright-mcp/page-2026-04-18T08-04-28-270Z.yml new file mode 100644 index 00000000..7b4850c1 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-04-28-270Z.yml @@ -0,0 +1,54 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Cart + - generic [ref=e22]: Your cart + - generic [ref=e23]: + - img [ref=e25] + - generic [ref=e28]: + - text: Your cart is empty. Browse + - link "our collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/collections + - text: to get started. + - contentinfo [ref=e30]: + - generic [ref=e31]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-04-43-675Z.yml b/.playwright-mcp/page-2026-04-18T08-04-43-675Z.yml new file mode 100644 index 00000000..2e5ed39e --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-04-43-675Z.yml @@ -0,0 +1,50 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Collections + - generic [ref=e22]: Collections + - link "Featured" [ref=e24] [cursor=pointer]: + - /url: http://shop.test/collections/featured + - generic [ref=e25]: Featured + - contentinfo [ref=e26]: + - generic [ref=e27]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-04-48-300Z.yml b/.playwright-mcp/page-2026-04-18T08-04-48-300Z.yml new file mode 100644 index 00000000..3c6206c4 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-04-48-300Z.yml @@ -0,0 +1,68 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Collections" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/collections + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Featured + - generic [ref=e26]: Featured + - paragraph [ref=e28]: Our picks for the season. + - generic [ref=e29]: + - textbox "Search products..." [ref=e31] + - combobox [ref=e33]: + - 'option "Sort: default" [selected]' + - option "Title A-Z" + - option "Title Z-A" + - option "Newest" + - generic [ref=e34]: + - link "Organic Cotton T-Shirt" [ref=e36] [cursor=pointer]: + - /url: http://shop.test/products/organic-cotton-t-shirt + - img [ref=e39] + - generic [ref=e42]: Organic Cotton T-Shirt + - link "Classic Pullover Hoodie" [ref=e44] [cursor=pointer]: + - /url: http://shop.test/products/classic-pullover-hoodie + - img [ref=e47] + - generic [ref=e50]: Classic Pullover Hoodie + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-04-54-210Z.yml b/.playwright-mcp/page-2026-04-18T08-04-54-210Z.yml new file mode 100644 index 00000000..345c45d7 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-04-54-210Z.yml @@ -0,0 +1,73 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-05-12-101Z.yml b/.playwright-mcp/page-2026-04-18T08-05-12-101Z.yml new file mode 100644 index 00000000..eb0e953c --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-05-12-101Z.yml @@ -0,0 +1,81 @@ +- generic [active] [ref=e1]: + - dialog [ref=e53]: + - iframe [ref=e54]: + - generic [ref=f1e2]: + - paragraph [ref=f1e3]: "404" + - heading "Page not found" [level=1] [ref=f1e4] + - paragraph [ref=f1e5]: The page you are looking for does not exist or has been moved. + - link "Back to home" [ref=f1e7] [cursor=pointer]: + - /url: / + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-06-02-997Z.yml b/.playwright-mcp/page-2026-04-18T08-06-02-997Z.yml new file mode 100644 index 00000000..345c45d7 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-06-02-997Z.yml @@ -0,0 +1,73 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-06-09-558Z.yml b/.playwright-mcp/page-2026-04-18T08-06-09-558Z.yml new file mode 100644 index 00000000..ca489fa6 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-06-09-558Z.yml @@ -0,0 +1,81 @@ +- generic [active] [ref=e1]: + - dialog [ref=e53]: + - iframe [ref=e54]: + - generic [ref=f2e2]: + - paragraph [ref=f2e3]: "404" + - heading "Page not found" [level=1] [ref=f2e4] + - paragraph [ref=f2e5]: The page you are looking for does not exist or has been moved. + - link "Back to home" [ref=f2e7] [cursor=pointer]: + - /url: / + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-06-32-938Z.yml b/.playwright-mcp/page-2026-04-18T08-06-32-938Z.yml new file mode 100644 index 00000000..345c45d7 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-06-32-938Z.yml @@ -0,0 +1,73 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-06-42-016Z.yml b/.playwright-mcp/page-2026-04-18T08-06-42-016Z.yml new file mode 100644 index 00000000..5a528416 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-06-42-016Z.yml @@ -0,0 +1,81 @@ +- generic [active] [ref=e1]: + - dialog [ref=e53]: + - iframe [ref=e54]: + - generic [ref=f3e2]: + - paragraph [ref=f3e3]: "404" + - heading "Page not found" [level=1] [ref=f3e4] + - paragraph [ref=f3e5]: The page you are looking for does not exist or has been moved. + - link "Back to home" [ref=f3e7] [cursor=pointer]: + - /url: / + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-07-33-859Z.yml b/.playwright-mcp/page-2026-04-18T08-07-33-859Z.yml new file mode 100644 index 00000000..345c45d7 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-07-33-859Z.yml @@ -0,0 +1,73 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-07-43-009Z.yml b/.playwright-mcp/page-2026-04-18T08-07-43-009Z.yml new file mode 100644 index 00000000..a09982c6 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-07-43-009Z.yml @@ -0,0 +1,94 @@ +- generic [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [active] [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - dialog "Shopping cart" [ref=e55]: + - banner [ref=e56]: + - generic [ref=e57]: Your Cart (1) + - button "Close cart" [ref=e58]: + - img [ref=e60] + - img [ref=e63] + - list [ref=e66]: + - listitem [ref=e67]: + - generic [ref=e69]: + - paragraph [ref=e70]: Organic Cotton T-Shirt + - paragraph [ref=e71]: TSH-S-BLA + - generic [ref=e72]: + - button "Decrease quantity" [ref=e73]: + - img [ref=e75] + - generic [ref=e78]: "-" + - generic [ref=e79]: "1" + - button "Increase quantity" [ref=e80]: + - img [ref=e82] + - generic [ref=e85]: + + - generic [ref=e86]: + - paragraph [ref=e87]: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart" [ref=e88]: + - img [ref=e90] + - img [ref=e93] + - contentinfo [ref=e95]: + - generic [ref=e96]: + - generic [ref=e97]: + - term [ref=e98]: Subtotal + - definition [ref=e99]: 25.00 EUR + - generic [ref=e100]: + - term [ref=e101]: Estimated total + - definition [ref=e102]: 25.00 EUR + - paragraph [ref=e103]: Shipping and taxes calculated at checkout + - link "Checkout" [ref=e104] [cursor=pointer]: + - /url: http://shop.test/checkout + - button "Continue shopping" [ref=e105]: + - img [ref=e107] + - generic [ref=e110]: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-07-52-099Z.yml b/.playwright-mcp/page-2026-04-18T08-07-52-099Z.yml new file mode 100644 index 00000000..a422bcd1 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-07-52-099Z.yml @@ -0,0 +1,117 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Cart + - generic [ref=e22]: Your cart + - generic [ref=e23]: + - list [ref=e25]: + - listitem [ref=e26]: + - generic [ref=e28]: + - paragraph [ref=e29]: Organic Cotton T-Shirt + - paragraph [ref=e30]: TSH-S-BLA + - paragraph [ref=e31]: 25.00 EUR + - generic [ref=e32]: + - button "-" [ref=e33]: + - img [ref=e35] + - generic [ref=e38]: "-" + - generic [ref=e39]: "1" + - button "+" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: + + - button "Remove" [ref=e46]: + - img [ref=e48] + - generic [ref=e51]: Remove + - paragraph [ref=e53]: 25.00 EUR + - complementary [ref=e54]: + - generic [ref=e55]: + - generic [ref=e56]: Discount code + - generic [ref=e57]: + - textbox "Discount code" [ref=e59] + - button "Apply" [ref=e60]: + - img [ref=e62] + - generic [ref=e65]: Apply + - generic [ref=e66]: + - generic [ref=e67]: Summary + - generic [ref=e68]: + - generic [ref=e69]: + - term [ref=e70]: Subtotal + - definition [ref=e71]: 25.00 EUR + - generic [ref=e72]: + - term [ref=e73]: Estimated total + - definition [ref=e74]: 25.00 EUR + - button "Checkout" [ref=e75]: + - img [ref=e77] + - generic [ref=e80]: Checkout + - link "Continue shopping" [ref=e81] [cursor=pointer]: + - /url: http://shop.test/collections + - contentinfo [ref=e82]: + - generic [ref=e83]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (1) + - button "Close cart": + - generic: + - img + - img + - generic: + - list: + - listitem: + - generic: + - paragraph: Organic Cotton T-Shirt + - paragraph: TSH-S-BLA + - generic: + - button "Decrease quantity": + - generic: + - img + - generic: "-" + - generic: "1" + - button "Increase quantity": + - generic: + - img + - generic: + + - generic: + - paragraph: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart": + - generic: + - img + - img + - contentinfo: + - generic: + - generic: + - term: Subtotal + - definition: 25.00 EUR + - generic: + - term: Estimated total + - definition: 25.00 EUR + - paragraph: Shipping and taxes calculated at checkout + - link "Checkout": + - /url: http://shop.test/checkout + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-07-58-525Z.yml b/.playwright-mcp/page-2026-04-18T08-07-58-525Z.yml new file mode 100644 index 00000000..325e4224 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-07-58-525Z.yml @@ -0,0 +1,148 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Cart" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Checkout + - generic [ref=e26]: Checkout + - generic [ref=e27]: + - generic [ref=e28]: + - generic [ref=e29]: + - generic [ref=e30]: 1. Contact & shipping + - generic [ref=e31]: + - generic [ref=e32]: + - generic [ref=e33]: Email + - textbox "Email" [ref=e35] + - generic [ref=e36]: + - generic [ref=e37]: First name + - textbox "First name" [ref=e39] + - generic [ref=e40]: + - generic [ref=e41]: Last name + - textbox "Last name" [ref=e43] + - generic [ref=e44]: + - generic [ref=e45]: Address + - textbox "Address" [ref=e47] + - generic [ref=e48]: + - generic [ref=e49]: Apt / Suite + - textbox "Apt / Suite" [ref=e51] + - generic [ref=e52]: + - generic [ref=e53]: City + - textbox "City" [ref=e55] + - generic [ref=e56]: + - generic [ref=e57]: State / Province + - textbox "State / Province" [ref=e59] + - generic [ref=e60]: + - generic [ref=e61]: Postal code + - textbox "Postal code" [ref=e63] + - generic [ref=e64]: + - generic [ref=e65]: Country code + - textbox "Country code" [ref=e67]: US + - button "Continue" [ref=e68]: + - img [ref=e70] + - generic [ref=e73]: Continue + - generic [ref=e74]: + - generic [ref=e75]: 2. Shipping method + - generic [ref=e76]: + - img [ref=e78] + - generic [ref=e82]: No shipping methods available yet. Continue past step 1 first. + - generic [ref=e83]: + - generic [ref=e84]: 3. Payment + - generic [ref=e85]: + - generic [ref=e86]: + - radio "Credit Card" [checked] [ref=e87] + - text: Credit Card + - generic [ref=e88]: + - radio "PayPal" [ref=e89] + - text: PayPal + - generic [ref=e90]: + - radio "Bank Transfer" [ref=e91] + - text: Bank Transfer + - button "Place order - 25.00 EUR" [ref=e92]: + - img [ref=e94] + - generic [ref=e97]: Place order - 25.00 EUR + - complementary [ref=e98]: + - generic [ref=e99]: + - generic [ref=e100]: Order summary + - generic [ref=e101]: + - generic [ref=e102]: + - term [ref=e103]: Subtotal + - definition [ref=e104]: 25.00 EUR + - generic [ref=e105]: + - term [ref=e106]: Shipping + - definition [ref=e107]: 0.00 EUR + - generic [ref=e108]: + - term [ref=e109]: Total + - definition [ref=e110]: 25.00 EUR + - contentinfo [ref=e111]: + - generic [ref=e112]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (1) + - button "Close cart": + - generic: + - img + - img + - generic: + - list: + - listitem: + - generic: + - paragraph: Organic Cotton T-Shirt + - paragraph: TSH-S-BLA + - generic: + - button "Decrease quantity": + - generic: + - img + - generic: "-" + - generic: "1" + - button "Increase quantity": + - generic: + - img + - generic: + + - generic: + - paragraph: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart": + - generic: + - img + - img + - contentinfo: + - generic: + - generic: + - term: Subtotal + - definition: 25.00 EUR + - generic: + - term: Estimated total + - definition: 25.00 EUR + - paragraph: Shipping and taxes calculated at checkout + - link "Checkout": + - /url: http://shop.test/checkout + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-18-53-378Z.yml b/.playwright-mcp/page-2026-04-18T08-18-53-378Z.yml new file mode 100644 index 00000000..345c45d7 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-18-53-378Z.yml @@ -0,0 +1,73 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-26-52-503Z.yml b/.playwright-mcp/page-2026-04-18T08-26-52-503Z.yml new file mode 100644 index 00000000..82c607c2 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-26-52-503Z.yml @@ -0,0 +1,6 @@ +- generic [ref=e2]: + - paragraph [ref=e3]: "404" + - heading "Page not found" [level=1] [ref=e4] + - paragraph [ref=e5]: The page you are looking for does not exist or has been moved. + - link "Back to home" [ref=e7] [cursor=pointer]: + - /url: / \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-27-08-803Z.yml b/.playwright-mcp/page-2026-04-18T08-27-08-803Z.yml new file mode 100644 index 00000000..ec9315b0 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-27-08-803Z.yml @@ -0,0 +1,56 @@ +- generic [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - generic [ref=e14]: Sign in + - generic [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: Email + - textbox "Email" [active] [ref=e19] + - generic [ref=e20]: + - generic [ref=e21]: Password + - textbox "Password" [ref=e23] + - generic [ref=e24]: + - checkbox "Remember me" [ref=e25] + - generic [ref=e27]: Remember me + - button "Sign in" [ref=e28]: + - img [ref=e30] + - generic [ref=e33]: Sign in + - paragraph [ref=e34]: + - text: No account yet? + - link "Create one" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/account/register + - contentinfo [ref=e36]: + - generic [ref=e37]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-27-41-204Z.yml b/.playwright-mcp/page-2026-04-18T08-27-41-204Z.yml new file mode 100644 index 00000000..3223af0a --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-27-41-204Z.yml @@ -0,0 +1,52 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - generic [ref=e14]: Account dashboard + - paragraph [ref=e15]: Welcome back, Billy. + - generic [ref=e16]: + - link "Your orders Track and manage previous purchases" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/account/orders + - generic [ref=e18]: Your orders + - paragraph [ref=e19]: Track and manage previous purchases + - link "Addresses Manage shipping and billing addresses" [ref=e20] [cursor=pointer]: + - /url: http://shop.test/account/addresses + - generic [ref=e21]: Addresses + - paragraph [ref=e22]: Manage shipping and billing addresses + - button "Sign out End your current session" [ref=e24]: + - generic [ref=e25]: Sign out + - paragraph [ref=e26]: End your current session + - contentinfo [ref=e27]: + - generic [ref=e28]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-27-50-110Z.yml b/.playwright-mcp/page-2026-04-18T08-27-50-110Z.yml new file mode 100644 index 00000000..0aad325c --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-27-50-110Z.yml @@ -0,0 +1,58 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Account" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/account + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Orders + - generic [ref=e26]: Order history + - generic [ref=e27]: + - img [ref=e29] + - generic [ref=e32]: + - text: You have no orders yet. + - link "Start shopping" [ref=e33] [cursor=pointer]: + - /url: http://shop.test/collections + - text: . + - contentinfo [ref=e34]: + - generic [ref=e35]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-27-53-108Z.yml b/.playwright-mcp/page-2026-04-18T08-27-53-108Z.yml new file mode 100644 index 00000000..14af7ee2 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-27-53-108Z.yml @@ -0,0 +1,58 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Account" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/account + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Addresses + - generic [ref=e26]: + - generic [ref=e27]: Your addresses + - button "Add address" [ref=e28]: + - img [ref=e30] + - generic [ref=e33]: Add address + - generic [ref=e34]: + - img [ref=e36] + - generic [ref=e39]: No addresses yet. Add one to speed up checkout. + - contentinfo [ref=e40]: + - generic [ref=e41]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-39-15-903Z.yml b/.playwright-mcp/page-2026-04-18T08-39-15-903Z.yml new file mode 100644 index 00000000..54f77f4a --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-39-15-903Z.yml @@ -0,0 +1,59 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - generic [ref=e14]: + - generic [ref=e15]: Elevated essentials + - paragraph [ref=e16]: Timeless pieces for modern wardrobes. + - link "Shop the collection" [ref=e18] [cursor=pointer]: + - /url: /collections + - generic [ref=e19]: + - generic [ref=e20]: Featured collections + - link "Featured" [ref=e22] [cursor=pointer]: + - /url: http://shop.test/collections/featured + - generic [ref=e23]: Featured + - generic [ref=e24]: + - generic [ref=e25]: Featured products + - generic [ref=e26]: + - link "Organic Cotton T-Shirt" [ref=e28] [cursor=pointer]: + - /url: http://shop.test/products/organic-cotton-t-shirt + - img [ref=e31] + - generic [ref=e34]: Organic Cotton T-Shirt + - link "Classic Pullover Hoodie" [ref=e36] [cursor=pointer]: + - /url: http://shop.test/products/classic-pullover-hoodie + - img [ref=e39] + - generic [ref=e42]: Classic Pullover Hoodie + - contentinfo [ref=e43]: + - generic [ref=e44]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-39-24-839Z.yml b/.playwright-mcp/page-2026-04-18T08-39-24-839Z.yml new file mode 100644 index 00000000..e4c711b6 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-39-24-839Z.yml @@ -0,0 +1,73 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-39-31-590Z.yml b/.playwright-mcp/page-2026-04-18T08-39-31-590Z.yml new file mode 100644 index 00000000..10e6e10c --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-39-31-590Z.yml @@ -0,0 +1,94 @@ +- generic [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [active] [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - dialog "Shopping cart" [ref=e55]: + - banner [ref=e56]: + - generic [ref=e57]: Your Cart (1) + - button "Close cart" [ref=e58]: + - img [ref=e60] + - img [ref=e63] + - list [ref=e66]: + - listitem [ref=e67]: + - generic [ref=e69]: + - paragraph [ref=e70]: Organic Cotton T-Shirt + - paragraph [ref=e71]: TSH-S-BLA + - generic [ref=e72]: + - button "Decrease quantity" [ref=e73]: + - img [ref=e75] + - generic [ref=e78]: "-" + - generic [ref=e79]: "1" + - button "Increase quantity" [ref=e80]: + - img [ref=e82] + - generic [ref=e85]: + + - generic [ref=e86]: + - paragraph [ref=e87]: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart" [ref=e88]: + - img [ref=e90] + - img [ref=e93] + - contentinfo [ref=e95]: + - generic [ref=e96]: + - generic [ref=e97]: + - term [ref=e98]: Subtotal + - definition [ref=e99]: 25.00 EUR + - generic [ref=e100]: + - term [ref=e101]: Estimated total + - definition [ref=e102]: 25.00 EUR + - paragraph [ref=e103]: Shipping and taxes calculated at checkout + - link "Checkout" [ref=e104] [cursor=pointer]: + - /url: http://shop.test/checkout + - button "Continue shopping" [ref=e105]: + - img [ref=e107] + - generic [ref=e110]: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-39-45-003Z.yml b/.playwright-mcp/page-2026-04-18T08-39-45-003Z.yml new file mode 100644 index 00000000..55c62ff6 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-39-45-003Z.yml @@ -0,0 +1,148 @@ +- generic [active] [ref=e111]: + - link "Skip to content" [ref=e112] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e113]: Free shipping on orders over 50 + - banner [ref=e114]: + - generic [ref=e115]: + - link "Acme Fashion" [ref=e116] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e117]: + - link "Collections" [ref=e118] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e119] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e120] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e121] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e122]: + - generic [ref=e123]: + - navigation "Breadcrumb" [ref=e124]: + - list [ref=e125]: + - listitem [ref=e126]: + - link "Home" [ref=e127] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e128] + - listitem [ref=e130]: + - link "Cart" [ref=e131] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e132] + - listitem [ref=e134]: + - generic [ref=e135]: Checkout + - generic [ref=e136]: Checkout + - generic [ref=e137]: + - generic [ref=e138]: + - generic [ref=e139]: + - generic [ref=e140]: 1. Contact & shipping + - generic [ref=e141]: + - generic [ref=e142]: + - generic [ref=e143]: Email + - textbox "Email" [ref=e145] + - generic [ref=e146]: + - generic [ref=e147]: First name + - textbox "First name" [ref=e149] + - generic [ref=e150]: + - generic [ref=e151]: Last name + - textbox "Last name" [ref=e153] + - generic [ref=e154]: + - generic [ref=e155]: Address + - textbox "Address" [ref=e157] + - generic [ref=e158]: + - generic [ref=e159]: Apt / Suite + - textbox "Apt / Suite" [ref=e161] + - generic [ref=e162]: + - generic [ref=e163]: City + - textbox "City" [ref=e165] + - generic [ref=e166]: + - generic [ref=e167]: State / Province + - textbox "State / Province" [ref=e169] + - generic [ref=e170]: + - generic [ref=e171]: Postal code + - textbox "Postal code" [ref=e173] + - generic [ref=e174]: + - generic [ref=e175]: Country code + - textbox "Country code" [ref=e177]: US + - button "Continue" [ref=e178]: + - img [ref=e180] + - generic [ref=e183]: Continue + - generic [ref=e184]: + - generic [ref=e185]: 2. Shipping method + - generic [ref=e186]: + - img [ref=e188] + - generic [ref=e192]: No shipping methods available yet. Continue past step 1 first. + - generic [ref=e193]: + - generic [ref=e194]: 3. Payment + - generic [ref=e195]: + - generic [ref=e196]: + - radio "Credit Card" [checked] [ref=e197] + - text: Credit Card + - generic [ref=e198]: + - radio "PayPal" [ref=e199] + - text: PayPal + - generic [ref=e200]: + - radio "Bank Transfer" [ref=e201] + - text: Bank Transfer + - button "Place order - 25.00 EUR" [ref=e202]: + - img [ref=e204] + - generic [ref=e207]: Place order - 25.00 EUR + - complementary [ref=e208]: + - generic [ref=e209]: + - generic [ref=e210]: Order summary + - generic [ref=e211]: + - generic [ref=e212]: + - term [ref=e213]: Subtotal + - definition [ref=e214]: 25.00 EUR + - generic [ref=e215]: + - term [ref=e216]: Shipping + - definition [ref=e217]: 0.00 EUR + - generic [ref=e218]: + - term [ref=e219]: Total + - definition [ref=e220]: 25.00 EUR + - contentinfo [ref=e221]: + - generic [ref=e222]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (1) + - button "Close cart": + - generic: + - img + - img + - generic: + - list: + - listitem: + - generic: + - paragraph: Organic Cotton T-Shirt + - paragraph: TSH-S-BLA + - generic: + - button "Decrease quantity": + - generic: + - img + - generic: "-" + - generic: "1" + - button "Increase quantity": + - generic: + - img + - generic: + + - generic: + - paragraph: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart": + - generic: + - img + - img + - contentinfo: + - generic: + - generic: + - term: Subtotal + - definition: 25.00 EUR + - generic: + - term: Estimated total + - definition: 25.00 EUR + - paragraph: Shipping and taxes calculated at checkout + - link "Checkout": + - /url: http://shop.test/checkout + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-40-17-855Z.yml b/.playwright-mcp/page-2026-04-18T08-40-17-855Z.yml new file mode 100644 index 00000000..b07ab0a9 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-40-17-855Z.yml @@ -0,0 +1,148 @@ +- generic [ref=e111]: + - link "Skip to content" [ref=e112] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e113]: Free shipping on orders over 50 + - banner [ref=e114]: + - generic [ref=e115]: + - link "Acme Fashion" [ref=e116] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e117]: + - link "Collections" [ref=e118] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e119] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e120] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e121] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e122]: + - generic [ref=e123]: + - navigation "Breadcrumb" [ref=e124]: + - list [ref=e125]: + - listitem [ref=e126]: + - link "Home" [ref=e127] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e128] + - listitem [ref=e130]: + - link "Cart" [ref=e131] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e132] + - listitem [ref=e134]: + - generic [ref=e135]: Checkout + - generic [ref=e136]: Checkout + - generic [ref=e137]: + - generic [ref=e138]: + - generic [ref=e139]: + - generic [ref=e140]: 1. Contact & shipping + - generic [ref=e141]: + - generic [ref=e142]: + - generic [ref=e143]: Email + - textbox "Email" [ref=e145]: buyer@example.com + - generic [ref=e146]: + - generic [ref=e147]: First name + - textbox "First name" [ref=e149]: Billy + - generic [ref=e150]: + - generic [ref=e151]: Last name + - textbox "Last name" [ref=e153]: Buyer + - generic [ref=e154]: + - generic [ref=e155]: Address + - textbox "Address" [ref=e157]: 1 Shop Street + - generic [ref=e158]: + - generic [ref=e159]: Apt / Suite + - textbox "Apt / Suite" [ref=e161] + - generic [ref=e162]: + - generic [ref=e163]: City + - textbox "City" [ref=e165]: Berlin + - generic [ref=e166]: + - generic [ref=e167]: State / Province + - textbox "State / Province" [ref=e169]: BE + - generic [ref=e170]: + - generic [ref=e171]: Postal code + - textbox "Postal code" [ref=e173]: "10115" + - generic [ref=e174]: + - generic [ref=e175]: Country code + - textbox "Country code" [ref=e177]: US + - button "Continue" [active] [ref=e178]: + - img [ref=e180] + - generic [ref=e183]: Continue + - generic [ref=e184]: + - generic [ref=e185]: 2. Shipping method + - generic [ref=e186]: + - img [ref=e188] + - generic [ref=e192]: No shipping methods available yet. Continue past step 1 first. + - generic [ref=e193]: + - generic [ref=e194]: 3. Payment + - generic [ref=e195]: + - generic [ref=e196]: + - radio "Credit Card" [checked] [ref=e197] + - text: Credit Card + - generic [ref=e198]: + - radio "PayPal" [ref=e199] + - text: PayPal + - generic [ref=e200]: + - radio "Bank Transfer" [ref=e201] + - text: Bank Transfer + - button "Place order - 25.00 EUR" [ref=e202]: + - img [ref=e204] + - generic [ref=e207]: Place order - 25.00 EUR + - complementary [ref=e208]: + - generic [ref=e209]: + - generic [ref=e210]: Order summary + - generic [ref=e211]: + - generic [ref=e212]: + - term [ref=e213]: Subtotal + - definition [ref=e214]: 25.00 EUR + - generic [ref=e215]: + - term [ref=e216]: Shipping + - definition [ref=e217]: 0.00 EUR + - generic [ref=e218]: + - term [ref=e219]: Total + - definition [ref=e220]: 25.00 EUR + - contentinfo [ref=e221]: + - generic [ref=e222]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (1) + - button "Close cart": + - generic: + - img + - img + - generic: + - list: + - listitem: + - generic: + - paragraph: Organic Cotton T-Shirt + - paragraph: TSH-S-BLA + - generic: + - button "Decrease quantity": + - generic: + - img + - generic: "-" + - generic: "1" + - button "Increase quantity": + - generic: + - img + - generic: + + - generic: + - paragraph: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart": + - generic: + - img + - img + - contentinfo: + - generic: + - generic: + - term: Subtotal + - definition: 25.00 EUR + - generic: + - term: Estimated total + - definition: 25.00 EUR + - paragraph: Shipping and taxes calculated at checkout + - link "Checkout": + - /url: http://shop.test/checkout + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-41-52-664Z.yml b/.playwright-mcp/page-2026-04-18T08-41-52-664Z.yml new file mode 100644 index 00000000..e4c711b6 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-41-52-664Z.yml @@ -0,0 +1,73 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-41-59-687Z.yml b/.playwright-mcp/page-2026-04-18T08-41-59-687Z.yml new file mode 100644 index 00000000..10e6e10c --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-41-59-687Z.yml @@ -0,0 +1,94 @@ +- generic [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - generic [ref=e21]: Products + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Organic Cotton T-Shirt + - generic [ref=e26]: + - img "Organic Cotton T-Shirt front" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Organic Cotton T-Shirt + - generic [ref=e33]: 25.00 EUR + - generic [ref=e34]: + - generic [ref=e35]: Variant + - combobox "Variant" [ref=e36]: + - option "TSH-S-BLA" [selected] + - option "TSH-S-WHI" + - option "TSH-M-BLA" + - option "TSH-M-WHI" + - option "TSH-L-BLA" + - option "TSH-L-WHI" + - generic [ref=e37]: + - generic [ref=e38]: Quantity + - spinbutton "Quantity" [ref=e39]: "1" + - button "Add to cart" [active] [ref=e40]: + - img [ref=e42] + - generic [ref=e45]: Add to cart + - paragraph [ref=e47]: Soft, breathable, sustainably sourced. + - generic [ref=e48]: + - generic [ref=e49]: summer + - generic [ref=e50]: bestseller + - contentinfo [ref=e51]: + - generic [ref=e52]: © 2026 Acme Fashion. All rights reserved. + - dialog "Shopping cart" [ref=e55]: + - banner [ref=e56]: + - generic [ref=e57]: Your Cart (1) + - button "Close cart" [ref=e58]: + - img [ref=e60] + - img [ref=e63] + - list [ref=e66]: + - listitem [ref=e67]: + - generic [ref=e69]: + - paragraph [ref=e70]: Organic Cotton T-Shirt + - paragraph [ref=e71]: TSH-S-BLA + - generic [ref=e72]: + - button "Decrease quantity" [ref=e73]: + - img [ref=e75] + - generic [ref=e78]: "-" + - generic [ref=e79]: "1" + - button "Increase quantity" [ref=e80]: + - img [ref=e82] + - generic [ref=e85]: + + - generic [ref=e86]: + - paragraph [ref=e87]: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart" [ref=e88]: + - img [ref=e90] + - img [ref=e93] + - contentinfo [ref=e95]: + - generic [ref=e96]: + - generic [ref=e97]: + - term [ref=e98]: Subtotal + - definition [ref=e99]: 25.00 EUR + - generic [ref=e100]: + - term [ref=e101]: Estimated total + - definition [ref=e102]: 25.00 EUR + - paragraph [ref=e103]: Shipping and taxes calculated at checkout + - link "Checkout" [ref=e104] [cursor=pointer]: + - /url: http://shop.test/checkout + - button "Continue shopping" [ref=e105]: + - img [ref=e107] + - generic [ref=e110]: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-42-02-779Z.yml b/.playwright-mcp/page-2026-04-18T08-42-02-779Z.yml new file mode 100644 index 00000000..1673f5d7 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-42-02-779Z.yml @@ -0,0 +1,151 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Cart" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Checkout + - generic [ref=e26]: Checkout + - generic [ref=e27]: + - generic [ref=e28]: + - generic [ref=e29]: + - generic [ref=e30]: 1. Contact & shipping + - generic [ref=e31]: + - generic [ref=e32]: + - generic [ref=e33]: Email + - textbox "Email" [ref=e35] + - generic [ref=e36]: + - generic [ref=e37]: First name + - textbox "First name" [ref=e39] + - generic [ref=e40]: + - generic [ref=e41]: Last name + - textbox "Last name" [ref=e43] + - generic [ref=e44]: + - generic [ref=e45]: Address + - textbox "Address" [ref=e47] + - generic [ref=e48]: + - generic [ref=e49]: Apt / Suite + - textbox "Apt / Suite" [ref=e51] + - generic [ref=e52]: + - generic [ref=e53]: City + - textbox "City" [ref=e55] + - generic [ref=e56]: + - generic [ref=e57]: State / Province + - textbox "State / Province" [ref=e59] + - generic [ref=e60]: + - generic [ref=e61]: Postal code + - textbox "Postal code" [ref=e63] + - generic [ref=e64]: + - generic [ref=e65]: Country code + - textbox "Country code" [ref=e67]: US + - button "Continue" [ref=e68]: + - img [ref=e70] + - generic [ref=e73]: Continue + - generic [ref=e74]: + - generic [ref=e75]: 2. Shipping method + - generic [ref=e76]: + - img [ref=e78] + - generic [ref=e82]: No shipping methods available yet. Continue past step 1 first. + - generic [ref=e83]: + - generic [ref=e84]: 3. Payment + - generic [ref=e85]: + - generic [ref=e86]: + - radio "Credit Card" [checked] [ref=e87] + - text: Credit Card + - generic [ref=e88]: + - radio "PayPal" [ref=e89] + - text: PayPal + - generic [ref=e90]: + - radio "Bank Transfer" [ref=e91] + - text: Bank Transfer + - button "Place order - 29.75 EUR" [ref=e92]: + - img [ref=e94] + - generic [ref=e97]: Place order - 29.75 EUR + - complementary [ref=e98]: + - generic [ref=e99]: + - generic [ref=e100]: Order summary + - generic [ref=e101]: + - generic [ref=e102]: + - term [ref=e103]: Subtotal + - definition [ref=e104]: 25.00 EUR + - generic [ref=e105]: + - term [ref=e106]: Shipping + - definition [ref=e107]: 0.00 EUR + - generic [ref=e108]: + - term [ref=e109]: Tax + - definition [ref=e110]: 4.75 EUR + - generic [ref=e111]: + - term [ref=e112]: Total + - definition [ref=e113]: 29.75 EUR + - contentinfo [ref=e114]: + - generic [ref=e115]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (1) + - button "Close cart": + - generic: + - img + - img + - generic: + - list: + - listitem: + - generic: + - paragraph: Organic Cotton T-Shirt + - paragraph: TSH-S-BLA + - generic: + - button "Decrease quantity": + - generic: + - img + - generic: "-" + - generic: "1" + - button "Increase quantity": + - generic: + - img + - generic: + + - generic: + - paragraph: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart": + - generic: + - img + - img + - contentinfo: + - generic: + - generic: + - term: Subtotal + - definition: 25.00 EUR + - generic: + - term: Estimated total + - definition: 25.00 EUR + - paragraph: Shipping and taxes calculated at checkout + - link "Checkout": + - /url: http://shop.test/checkout + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-42-25-068Z.yml b/.playwright-mcp/page-2026-04-18T08-42-25-068Z.yml new file mode 100644 index 00000000..79691711 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-42-25-068Z.yml @@ -0,0 +1,151 @@ +- generic [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Cart" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Checkout + - generic [ref=e26]: Checkout + - generic [ref=e27]: + - generic [ref=e28]: + - generic [ref=e29]: + - generic [ref=e30]: 1. Contact & shipping + - generic [ref=e31]: + - generic [ref=e32]: + - generic [ref=e33]: Email + - textbox "Email" [ref=e35]: buyer@example.com + - generic [ref=e36]: + - generic [ref=e37]: First name + - textbox "First name" [ref=e39]: Billy + - generic [ref=e40]: + - generic [ref=e41]: Last name + - textbox "Last name" [ref=e43]: Buyer + - generic [ref=e44]: + - generic [ref=e45]: Address + - textbox "Address" [ref=e47]: 1 Shop Street + - generic [ref=e48]: + - generic [ref=e49]: Apt / Suite + - textbox "Apt / Suite" [ref=e51] + - generic [ref=e52]: + - generic [ref=e53]: City + - textbox "City" [ref=e55]: Berlin + - generic [ref=e56]: + - generic [ref=e57]: State / Province + - textbox "State / Province" [ref=e59]: BE + - generic [ref=e60]: + - generic [ref=e61]: Postal code + - textbox "Postal code" [ref=e63]: "10115" + - generic [ref=e64]: + - generic [ref=e65]: Country code + - textbox "Country code" [active] [ref=e67]: US + - button "Continue" [ref=e68]: + - img [ref=e70] + - generic [ref=e73]: Continue + - generic [ref=e74]: + - generic [ref=e75]: 2. Shipping method + - generic [ref=e76]: + - img [ref=e78] + - generic [ref=e82]: No shipping methods available yet. Continue past step 1 first. + - generic [ref=e83]: + - generic [ref=e84]: 3. Payment + - generic [ref=e85]: + - generic [ref=e86]: + - radio "Credit Card" [checked] [ref=e87] + - text: Credit Card + - generic [ref=e88]: + - radio "PayPal" [ref=e89] + - text: PayPal + - generic [ref=e90]: + - radio "Bank Transfer" [ref=e91] + - text: Bank Transfer + - button "Place order - 29.75 EUR" [ref=e92]: + - img [ref=e94] + - generic [ref=e97]: Place order - 29.75 EUR + - complementary [ref=e98]: + - generic [ref=e99]: + - generic [ref=e100]: Order summary + - generic [ref=e101]: + - generic [ref=e102]: + - term [ref=e103]: Subtotal + - definition [ref=e104]: 25.00 EUR + - generic [ref=e105]: + - term [ref=e106]: Shipping + - definition [ref=e107]: 0.00 EUR + - generic [ref=e108]: + - term [ref=e109]: Tax + - definition [ref=e110]: 4.75 EUR + - generic [ref=e111]: + - term [ref=e112]: Total + - definition [ref=e113]: 29.75 EUR + - contentinfo [ref=e114]: + - generic [ref=e115]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (1) + - button "Close cart": + - generic: + - img + - img + - generic: + - list: + - listitem: + - generic: + - paragraph: Organic Cotton T-Shirt + - paragraph: TSH-S-BLA + - generic: + - button "Decrease quantity": + - generic: + - img + - generic: "-" + - generic: "1" + - button "Increase quantity": + - generic: + - img + - generic: + + - generic: + - paragraph: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart": + - generic: + - img + - img + - contentinfo: + - generic: + - generic: + - term: Subtotal + - definition: 25.00 EUR + - generic: + - term: Estimated total + - definition: 25.00 EUR + - paragraph: Shipping and taxes calculated at checkout + - link "Checkout": + - /url: http://shop.test/checkout + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-42-33-505Z.yml b/.playwright-mcp/page-2026-04-18T08-42-33-505Z.yml new file mode 100644 index 00000000..5a98ac28 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-42-33-505Z.yml @@ -0,0 +1,155 @@ +- generic [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Cart" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Checkout + - generic [ref=e26]: Checkout + - generic [ref=e27]: + - generic [ref=e28]: + - generic [ref=e29]: + - generic [ref=e30]: 1. Contact & shipping + - generic [ref=e31]: + - generic [ref=e32]: + - generic [ref=e33]: Email + - textbox "Email" [ref=e35]: buyer@example.com + - generic [ref=e36]: + - generic [ref=e37]: First name + - textbox "First name" [ref=e39]: Billy + - generic [ref=e40]: + - generic [ref=e41]: Last name + - textbox "Last name" [ref=e43]: Buyer + - generic [ref=e44]: + - generic [ref=e45]: Address + - textbox "Address" [ref=e47]: 1 Shop Street + - generic [ref=e48]: + - generic [ref=e49]: Apt / Suite + - textbox "Apt / Suite" [ref=e51] + - generic [ref=e52]: + - generic [ref=e53]: City + - textbox "City" [ref=e55]: Berlin + - generic [ref=e56]: + - generic [ref=e57]: State / Province + - textbox "State / Province" [ref=e59]: BE + - generic [ref=e60]: + - generic [ref=e61]: Postal code + - textbox "Postal code" [ref=e63]: "10115" + - generic [ref=e64]: + - generic [ref=e65]: Country code + - textbox "Country code" [ref=e67]: DE + - button "Continue" [active] [ref=e68]: + - img [ref=e70] + - generic [ref=e73]: Continue + - generic [ref=e74]: + - generic [ref=e75]: 2. Shipping method + - list [ref=e116]: + - listitem [ref=e117]: + - generic [ref=e118] [cursor=pointer]: + - generic [ref=e119]: + - radio "Standard 4.99 EUR" [ref=e120] + - generic [ref=e121]: Standard + - generic [ref=e122]: 4.99 EUR + - generic [ref=e83]: + - generic [ref=e84]: 3. Payment + - generic [ref=e85]: + - generic [ref=e86]: + - radio "Credit Card" [checked] [ref=e87] + - text: Credit Card + - generic [ref=e88]: + - radio "PayPal" [ref=e89] + - text: PayPal + - generic [ref=e90]: + - radio "Bank Transfer" [ref=e91] + - text: Bank Transfer + - button "Place order - 29.75 EUR" [ref=e92]: + - img [ref=e94] + - generic [ref=e97]: Place order - 29.75 EUR + - complementary [ref=e98]: + - generic [ref=e99]: + - generic [ref=e100]: Order summary + - generic [ref=e101]: + - generic [ref=e102]: + - term [ref=e103]: Subtotal + - definition [ref=e104]: 25.00 EUR + - generic [ref=e105]: + - term [ref=e106]: Shipping + - definition [ref=e107]: 0.00 EUR + - generic [ref=e108]: + - term [ref=e109]: Tax + - definition [ref=e110]: 4.75 EUR + - generic [ref=e111]: + - term [ref=e112]: Total + - definition [ref=e113]: 29.75 EUR + - contentinfo [ref=e114]: + - generic [ref=e115]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (1) + - button "Close cart": + - generic: + - img + - img + - generic: + - list: + - listitem: + - generic: + - paragraph: Organic Cotton T-Shirt + - paragraph: TSH-S-BLA + - generic: + - button "Decrease quantity": + - generic: + - img + - generic: "-" + - generic: "1" + - button "Increase quantity": + - generic: + - img + - generic: + + - generic: + - paragraph: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart": + - generic: + - img + - img + - contentinfo: + - generic: + - generic: + - term: Subtotal + - definition: 25.00 EUR + - generic: + - term: Estimated total + - definition: 25.00 EUR + - paragraph: Shipping and taxes calculated at checkout + - link "Checkout": + - /url: http://shop.test/checkout + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-42-45-519Z.yml b/.playwright-mcp/page-2026-04-18T08-42-45-519Z.yml new file mode 100644 index 00000000..76190904 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-42-45-519Z.yml @@ -0,0 +1,155 @@ +- generic [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Cart" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Checkout + - generic [ref=e26]: Checkout + - generic [ref=e27]: + - generic [ref=e28]: + - generic [ref=e29]: + - generic [ref=e30]: 1. Contact & shipping + - generic [ref=e31]: + - generic [ref=e32]: + - generic [ref=e33]: Email + - textbox "Email" [ref=e35]: buyer@example.com + - generic [ref=e36]: + - generic [ref=e37]: First name + - textbox "First name" [ref=e39]: Billy + - generic [ref=e40]: + - generic [ref=e41]: Last name + - textbox "Last name" [ref=e43]: Buyer + - generic [ref=e44]: + - generic [ref=e45]: Address + - textbox "Address" [ref=e47]: 1 Shop Street + - generic [ref=e48]: + - generic [ref=e49]: Apt / Suite + - textbox "Apt / Suite" [ref=e51] + - generic [ref=e52]: + - generic [ref=e53]: City + - textbox "City" [ref=e55]: Berlin + - generic [ref=e56]: + - generic [ref=e57]: State / Province + - textbox "State / Province" [ref=e59]: BE + - generic [ref=e60]: + - generic [ref=e61]: Postal code + - textbox "Postal code" [ref=e63]: "10115" + - generic [ref=e64]: + - generic [ref=e65]: Country code + - textbox "Country code" [ref=e67]: DE + - button "Continue" [ref=e68]: + - img [ref=e70] + - generic [ref=e73]: Continue + - generic [ref=e74]: + - generic [ref=e75]: 2. Shipping method + - list [ref=e116]: + - listitem [ref=e117]: + - generic [ref=e118] [cursor=pointer]: + - generic [ref=e119]: + - radio "Standard 4.99 EUR" [checked] [active] [ref=e120] + - generic [ref=e121]: Standard + - generic [ref=e122]: 4.99 EUR + - generic [ref=e83]: + - generic [ref=e84]: 3. Payment + - generic [ref=e85]: + - generic [ref=e86]: + - radio "Credit Card" [checked] [ref=e87] + - text: Credit Card + - generic [ref=e88]: + - radio "PayPal" [ref=e89] + - text: PayPal + - generic [ref=e90]: + - radio "Bank Transfer" [ref=e91] + - text: Bank Transfer + - button "Place order - 35.69 EUR" [ref=e123]: + - img [ref=e94] + - generic [ref=e97]: Place order - 35.69 EUR + - complementary [ref=e98]: + - generic [ref=e99]: + - generic [ref=e100]: Order summary + - generic [ref=e101]: + - generic [ref=e102]: + - term [ref=e103]: Subtotal + - definition [ref=e104]: 25.00 EUR + - generic [ref=e105]: + - term [ref=e106]: Shipping + - definition [ref=e107]: 4.99 EUR + - generic [ref=e108]: + - term [ref=e109]: Tax + - definition [ref=e110]: 5.70 EUR + - generic [ref=e111]: + - term [ref=e112]: Total + - definition [ref=e113]: 35.69 EUR + - contentinfo [ref=e114]: + - generic [ref=e115]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (1) + - button "Close cart": + - generic: + - img + - img + - generic: + - list: + - listitem: + - generic: + - paragraph: Organic Cotton T-Shirt + - paragraph: TSH-S-BLA + - generic: + - button "Decrease quantity": + - generic: + - img + - generic: "-" + - generic: "1" + - button "Increase quantity": + - generic: + - img + - generic: + + - generic: + - paragraph: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart": + - generic: + - img + - img + - contentinfo: + - generic: + - generic: + - term: Subtotal + - definition: 25.00 EUR + - generic: + - term: Estimated total + - definition: 25.00 EUR + - paragraph: Shipping and taxes calculated at checkout + - link "Checkout": + - /url: http://shop.test/checkout + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-43-11-472Z.yml b/.playwright-mcp/page-2026-04-18T08-43-11-472Z.yml new file mode 100644 index 00000000..fe10ffb9 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-43-11-472Z.yml @@ -0,0 +1,155 @@ +- generic [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Cart" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Checkout + - generic [ref=e26]: Checkout + - generic [ref=e27]: + - generic [ref=e28]: + - generic [ref=e29]: + - generic [ref=e30]: 1. Contact & shipping + - generic [ref=e31]: + - generic [ref=e32]: + - generic [ref=e33]: Email + - textbox "Email" [ref=e35]: buyer@example.com + - generic [ref=e36]: + - generic [ref=e37]: First name + - textbox "First name" [ref=e39]: Billy + - generic [ref=e40]: + - generic [ref=e41]: Last name + - textbox "Last name" [ref=e43]: Buyer + - generic [ref=e44]: + - generic [ref=e45]: Address + - textbox "Address" [ref=e47]: 1 Shop Street + - generic [ref=e48]: + - generic [ref=e49]: Apt / Suite + - textbox "Apt / Suite" [ref=e51] + - generic [ref=e52]: + - generic [ref=e53]: City + - textbox "City" [ref=e55]: Berlin + - generic [ref=e56]: + - generic [ref=e57]: State / Province + - textbox "State / Province" [ref=e59]: BE + - generic [ref=e60]: + - generic [ref=e61]: Postal code + - textbox "Postal code" [ref=e63]: "10115" + - generic [ref=e64]: + - generic [ref=e65]: Country code + - textbox "Country code" [ref=e67]: DE + - button "Continue" [ref=e68]: + - img [ref=e70] + - generic [ref=e73]: Continue + - generic [ref=e74]: + - generic [ref=e75]: 2. Shipping method + - list [ref=e116]: + - listitem [ref=e117]: + - generic [ref=e118] [cursor=pointer]: + - generic [ref=e119]: + - radio "Standard 4.99 EUR" [checked] [ref=e120] + - generic [ref=e121]: Standard + - generic [ref=e122]: 4.99 EUR + - generic [ref=e83]: + - generic [ref=e84]: 3. Payment + - generic [ref=e85]: + - generic [ref=e86]: + - radio "Credit Card" [ref=e87] + - text: Credit Card + - generic [ref=e88]: + - radio "PayPal" [ref=e89] + - text: PayPal + - generic [ref=e90]: + - radio "Bank Transfer" [checked] [active] [ref=e91] + - text: Bank Transfer + - button "Place order - 35.69 EUR" [ref=e123]: + - img [ref=e94] + - generic [ref=e97]: Place order - 35.69 EUR + - complementary [ref=e98]: + - generic [ref=e99]: + - generic [ref=e100]: Order summary + - generic [ref=e101]: + - generic [ref=e102]: + - term [ref=e103]: Subtotal + - definition [ref=e104]: 25.00 EUR + - generic [ref=e105]: + - term [ref=e106]: Shipping + - definition [ref=e107]: 4.99 EUR + - generic [ref=e108]: + - term [ref=e109]: Tax + - definition [ref=e110]: 5.70 EUR + - generic [ref=e111]: + - term [ref=e112]: Total + - definition [ref=e113]: 35.69 EUR + - contentinfo [ref=e114]: + - generic [ref=e115]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (1) + - button "Close cart": + - generic: + - img + - img + - generic: + - list: + - listitem: + - generic: + - paragraph: Organic Cotton T-Shirt + - paragraph: TSH-S-BLA + - generic: + - button "Decrease quantity": + - generic: + - img + - generic: "-" + - generic: "1" + - button "Increase quantity": + - generic: + - img + - generic: + + - generic: + - paragraph: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart": + - generic: + - img + - img + - contentinfo: + - generic: + - generic: + - term: Subtotal + - definition: 25.00 EUR + - generic: + - term: Estimated total + - definition: 25.00 EUR + - paragraph: Shipping and taxes calculated at checkout + - link "Checkout": + - /url: http://shop.test/checkout + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-43-21-769Z.yml b/.playwright-mcp/page-2026-04-18T08-43-21-769Z.yml new file mode 100644 index 00000000..3764bf45 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-43-21-769Z.yml @@ -0,0 +1,155 @@ +- generic [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Cart" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Checkout + - generic [ref=e26]: Checkout + - generic [ref=e27]: + - generic [ref=e28]: + - generic [ref=e29]: + - generic [ref=e30]: 1. Contact & shipping + - generic [ref=e31]: + - generic [ref=e32]: + - generic [ref=e33]: Email + - textbox "Email" [ref=e35]: buyer@example.com + - generic [ref=e36]: + - generic [ref=e37]: First name + - textbox "First name" [ref=e39]: Billy + - generic [ref=e40]: + - generic [ref=e41]: Last name + - textbox "Last name" [ref=e43]: Buyer + - generic [ref=e44]: + - generic [ref=e45]: Address + - textbox "Address" [ref=e47]: 1 Shop Street + - generic [ref=e48]: + - generic [ref=e49]: Apt / Suite + - textbox "Apt / Suite" [ref=e51] + - generic [ref=e52]: + - generic [ref=e53]: City + - textbox "City" [ref=e55]: Berlin + - generic [ref=e56]: + - generic [ref=e57]: State / Province + - textbox "State / Province" [ref=e59]: BE + - generic [ref=e60]: + - generic [ref=e61]: Postal code + - textbox "Postal code" [ref=e63]: "10115" + - generic [ref=e64]: + - generic [ref=e65]: Country code + - textbox "Country code" [ref=e67]: DE + - button "Continue" [ref=e68]: + - img [ref=e70] + - generic [ref=e73]: Continue + - generic [ref=e74]: + - generic [ref=e75]: 2. Shipping method + - list [ref=e116]: + - listitem [ref=e117]: + - generic [ref=e118] [cursor=pointer]: + - generic [ref=e119]: + - radio "Standard 4.99 EUR" [checked] [active] [ref=e120] + - generic [ref=e121]: Standard + - generic [ref=e122]: 4.99 EUR + - generic [ref=e83]: + - generic [ref=e84]: 3. Payment + - generic [ref=e85]: + - generic [ref=e86]: + - radio "Credit Card" [ref=e87] + - text: Credit Card + - generic [ref=e88]: + - radio "PayPal" [ref=e89] + - text: PayPal + - generic [ref=e90]: + - radio "Bank Transfer" [checked] [ref=e91] + - text: Bank Transfer + - button "Place order - 35.69 EUR" [ref=e123]: + - img [ref=e94] + - generic [ref=e97]: Place order - 35.69 EUR + - complementary [ref=e98]: + - generic [ref=e99]: + - generic [ref=e100]: Order summary + - generic [ref=e101]: + - generic [ref=e102]: + - term [ref=e103]: Subtotal + - definition [ref=e104]: 25.00 EUR + - generic [ref=e105]: + - term [ref=e106]: Shipping + - definition [ref=e107]: 4.99 EUR + - generic [ref=e108]: + - term [ref=e109]: Tax + - definition [ref=e110]: 5.70 EUR + - generic [ref=e111]: + - term [ref=e112]: Total + - definition [ref=e113]: 35.69 EUR + - contentinfo [ref=e114]: + - generic [ref=e115]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (1) + - button "Close cart": + - generic: + - img + - img + - generic: + - list: + - listitem: + - generic: + - paragraph: Organic Cotton T-Shirt + - paragraph: TSH-S-BLA + - generic: + - button "Decrease quantity": + - generic: + - img + - generic: "-" + - generic: "1" + - button "Increase quantity": + - generic: + - img + - generic: + + - generic: + - paragraph: 25.00 EUR + - button "Remove Organic Cotton T-Shirt from cart": + - generic: + - img + - img + - contentinfo: + - generic: + - generic: + - term: Subtotal + - definition: 25.00 EUR + - generic: + - term: Estimated total + - definition: 25.00 EUR + - paragraph: Shipping and taxes calculated at checkout + - link "Checkout": + - /url: http://shop.test/checkout + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-43-55-970Z.yml b/.playwright-mcp/page-2026-04-18T08-43-55-970Z.yml new file mode 100644 index 00000000..acf4b153 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-43-55-970Z.yml @@ -0,0 +1,44 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - img [ref=e14] + - generic [ref=e16]: Thank you for your order + - paragraph [ref=e17]: "Order number: #1001" + - paragraph [ref=e18]: Phase 5 will wire the real order and payment data. + - link "Continue shopping" [ref=e19] [cursor=pointer]: + - /url: http://shop.test + - contentinfo [ref=e20]: + - generic [ref=e21]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-44-17-187Z.yml b/.playwright-mcp/page-2026-04-18T08-44-17-187Z.yml new file mode 100644 index 00000000..d63f019d --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-44-17-187Z.yml @@ -0,0 +1,75 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - generic [ref=e14]: + - img [ref=e15] + - generic [ref=e17]: Thank you for your order + - paragraph [ref=e18]: "Order number: #1001" + - generic [ref=e19]: + - generic [ref=e20]: + - generic [ref=e21]: Order summary + - generic [ref=e22]: pending + - table [ref=e23]: + - rowgroup [ref=e24]: + - row "Item Qty Total" [ref=e25]: + - columnheader "Item" [ref=e26] + - columnheader "Qty" [ref=e27] + - columnheader "Total" [ref=e28] + - rowgroup [ref=e29]: + - row "Organic Cotton T-Shirt TSH-S-BLA 1 0.00 EUR" [ref=e30]: + - cell "Organic Cotton T-Shirt TSH-S-BLA" [ref=e31]: + - generic [ref=e32]: Organic Cotton T-Shirt + - paragraph [ref=e33]: TSH-S-BLA + - cell "1" [ref=e34] + - cell "0.00 EUR" [ref=e35] + - rowgroup [ref=e36]: + - row "Subtotal 25.00 EUR" [ref=e37]: + - cell "Subtotal" [ref=e38] + - cell "25.00 EUR" [ref=e39] + - row "Shipping 4.99 EUR" [ref=e40]: + - cell "Shipping" [ref=e41] + - cell "4.99 EUR" [ref=e42] + - row "Tax 5.70 EUR" [ref=e43]: + - cell "Tax" [ref=e44] + - cell "5.70 EUR" [ref=e45] + - row "Total 35.69 EUR" [ref=e46]: + - cell "Total" [ref=e47] + - cell "35.69 EUR" [ref=e48] + - generic [ref=e51]: Your bank transfer is awaiting payment. We'll update you once it has been confirmed. + - link "Continue shopping" [ref=e53] [cursor=pointer]: + - /url: http://shop.test + - contentinfo [ref=e54]: + - generic [ref=e55]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-44-45-492Z.yml b/.playwright-mcp/page-2026-04-18T08-44-45-492Z.yml new file mode 100644 index 00000000..ee50e7e2 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-44-45-492Z.yml @@ -0,0 +1,75 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - generic [ref=e14]: + - img [ref=e15] + - generic [ref=e17]: Thank you for your order + - paragraph [ref=e18]: "Order number: #1001" + - generic [ref=e19]: + - generic [ref=e20]: + - generic [ref=e21]: Order summary + - generic [ref=e22]: pending + - table [ref=e23]: + - rowgroup [ref=e24]: + - row "Item Qty Total" [ref=e25]: + - columnheader "Item" [ref=e26] + - columnheader "Qty" [ref=e27] + - columnheader "Total" [ref=e28] + - rowgroup [ref=e29]: + - row "Organic Cotton T-Shirt TSH-S-BLA 1 25.00 EUR" [ref=e30]: + - cell "Organic Cotton T-Shirt TSH-S-BLA" [ref=e31]: + - generic [ref=e32]: Organic Cotton T-Shirt + - paragraph [ref=e33]: TSH-S-BLA + - cell "1" [ref=e34] + - cell "25.00 EUR" [ref=e35] + - rowgroup [ref=e36]: + - row "Subtotal 25.00 EUR" [ref=e37]: + - cell "Subtotal" [ref=e38] + - cell "25.00 EUR" [ref=e39] + - row "Shipping 4.99 EUR" [ref=e40]: + - cell "Shipping" [ref=e41] + - cell "4.99 EUR" [ref=e42] + - row "Tax 5.70 EUR" [ref=e43]: + - cell "Tax" [ref=e44] + - cell "5.70 EUR" [ref=e45] + - row "Total 35.69 EUR" [ref=e46]: + - cell "Total" [ref=e47] + - cell "35.69 EUR" [ref=e48] + - generic [ref=e51]: Your bank transfer is awaiting payment. We'll update you once it has been confirmed. + - link "Continue shopping" [ref=e53] [cursor=pointer]: + - /url: http://shop.test + - contentinfo [ref=e54]: + - generic [ref=e55]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-44-53-597Z.yml b/.playwright-mcp/page-2026-04-18T08-44-53-597Z.yml new file mode 100644 index 00000000..34a55d87 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-44-53-597Z.yml @@ -0,0 +1,56 @@ +- generic [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - generic [ref=e14]: Sign in + - generic [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: Email + - textbox "Email" [active] [ref=e19] + - generic [ref=e20]: + - generic [ref=e21]: Password + - textbox "Password" [ref=e23] + - generic [ref=e24]: + - checkbox "Remember me" [ref=e25] + - generic [ref=e27]: Remember me + - button "Sign in" [ref=e28]: + - img [ref=e30] + - generic [ref=e33]: Sign in + - paragraph [ref=e34]: + - text: No account yet? + - link "Create one" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/account/register + - contentinfo [ref=e36]: + - generic [ref=e37]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-45-20-872Z.yml b/.playwright-mcp/page-2026-04-18T08-45-20-872Z.yml new file mode 100644 index 00000000..3223af0a --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-45-20-872Z.yml @@ -0,0 +1,52 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - generic [ref=e14]: Account dashboard + - paragraph [ref=e15]: Welcome back, Billy. + - generic [ref=e16]: + - link "Your orders Track and manage previous purchases" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/account/orders + - generic [ref=e18]: Your orders + - paragraph [ref=e19]: Track and manage previous purchases + - link "Addresses Manage shipping and billing addresses" [ref=e20] [cursor=pointer]: + - /url: http://shop.test/account/addresses + - generic [ref=e21]: Addresses + - paragraph [ref=e22]: Manage shipping and billing addresses + - button "Sign out End your current session" [ref=e24]: + - generic [ref=e25]: Sign out + - paragraph [ref=e26]: End your current session + - contentinfo [ref=e27]: + - generic [ref=e28]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-45-29-103Z.yml b/.playwright-mcp/page-2026-04-18T08-45-29-103Z.yml new file mode 100644 index 00000000..3223af0a --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-45-29-103Z.yml @@ -0,0 +1,52 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - generic [ref=e14]: Account dashboard + - paragraph [ref=e15]: Welcome back, Billy. + - generic [ref=e16]: + - link "Your orders Track and manage previous purchases" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/account/orders + - generic [ref=e18]: Your orders + - paragraph [ref=e19]: Track and manage previous purchases + - link "Addresses Manage shipping and billing addresses" [ref=e20] [cursor=pointer]: + - /url: http://shop.test/account/addresses + - generic [ref=e21]: Addresses + - paragraph [ref=e22]: Manage shipping and billing addresses + - button "Sign out End your current session" [ref=e24]: + - generic [ref=e25]: Sign out + - paragraph [ref=e26]: End your current session + - contentinfo [ref=e27]: + - generic [ref=e28]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-45-34-547Z.yml b/.playwright-mcp/page-2026-04-18T08-45-34-547Z.yml new file mode 100644 index 00000000..0aad325c --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-45-34-547Z.yml @@ -0,0 +1,58 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://shop.test + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/search + - link "Account" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/account + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/cart + - main [ref=e12]: + - generic [ref=e13]: + - navigation "Breadcrumb" [ref=e14]: + - list [ref=e15]: + - listitem [ref=e16]: + - link "Home" [ref=e17] [cursor=pointer]: + - /url: http://shop.test + - img [ref=e18] + - listitem [ref=e20]: + - link "Account" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/account + - img [ref=e22] + - listitem [ref=e24]: + - generic [ref=e25]: Orders + - generic [ref=e26]: Order history + - generic [ref=e27]: + - img [ref=e29] + - generic [ref=e32]: + - text: You have no orders yet. + - link "Start shopping" [ref=e33] [cursor=pointer]: + - /url: http://shop.test/collections + - text: . + - contentinfo [ref=e34]: + - generic [ref=e35]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-45-49-992Z.yml b/.playwright-mcp/page-2026-04-18T08-45-49-992Z.yml new file mode 100644 index 00000000..6bc74eca --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-45-49-992Z.yml @@ -0,0 +1,16 @@ +- generic [ref=e3]: + - generic [ref=e4]: Admin Login + - paragraph [ref=e5]: Sign in to manage your store + - generic [ref=e6]: + - generic [ref=e7]: + - generic [ref=e8]: Email + - textbox "Email" [active] [ref=e10] + - generic [ref=e11]: + - generic [ref=e12]: Password + - textbox "Password" [ref=e14] + - generic [ref=e15]: + - checkbox "Remember me" [ref=e16] + - generic [ref=e18]: Remember me + - button "Sign in" [ref=e19]: + - img [ref=e21] + - generic [ref=e24]: Sign in \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-46-01-203Z.yml b/.playwright-mcp/page-2026-04-18T08-46-01-203Z.yml new file mode 100644 index 00000000..9a15b409 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-46-01-203Z.yml @@ -0,0 +1,16 @@ +- generic [ref=e3]: + - generic [ref=e4]: Admin Login + - paragraph [ref=e5]: Sign in to manage your store + - generic [ref=e6]: + - generic [ref=e7]: + - generic [ref=e8]: Email + - textbox "Email" [ref=e10]: owner@acme.test + - generic [ref=e11]: + - generic [ref=e12]: Password + - textbox "Password" [active] [ref=e14]: password + - generic [ref=e15]: + - checkbox "Remember me" [ref=e16] + - generic [ref=e18]: Remember me + - button "Sign in" [ref=e19]: + - img [ref=e21] + - generic [ref=e24]: Sign in \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-46-07-518Z.yml b/.playwright-mcp/page-2026-04-18T08-46-07-518Z.yml new file mode 100644 index 00000000..9a15b409 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-46-07-518Z.yml @@ -0,0 +1,16 @@ +- generic [ref=e3]: + - generic [ref=e4]: Admin Login + - paragraph [ref=e5]: Sign in to manage your store + - generic [ref=e6]: + - generic [ref=e7]: + - generic [ref=e8]: Email + - textbox "Email" [ref=e10]: owner@acme.test + - generic [ref=e11]: + - generic [ref=e12]: Password + - textbox "Password" [active] [ref=e14]: password + - generic [ref=e15]: + - checkbox "Remember me" [ref=e16] + - generic [ref=e18]: Remember me + - button "Sign in" [ref=e19]: + - img [ref=e21] + - generic [ref=e24]: Sign in \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-46-19-920Z.yml b/.playwright-mcp/page-2026-04-18T08-46-19-920Z.yml new file mode 100644 index 00000000..1673ed07 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-46-19-920Z.yml @@ -0,0 +1,83 @@ +- generic [ref=e2]: + - complementary [ref=e3]: + - generic [ref=e4]: Acme Fashion + - navigation [ref=e5]: + - link "Dashboard" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/admin + - link "Orders" [ref=e7] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - link "Products" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/admin/products + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - link "Customers" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - link "Discounts" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - link "Analytics" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - generic [ref=e13]: Content + - link "Pages" [ref=e14] [cursor=pointer]: + - /url: http://shop.test/admin/content/pages + - link "Navigation" [ref=e15] [cursor=pointer]: + - /url: http://shop.test/admin/content/navigation + - link "Themes" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/content/themes + - generic [ref=e17]: System + - link "Settings" [ref=e18] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - link "Search" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/search/settings + - link "Apps" [ref=e20] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - link "Developers" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - generic [ref=e22]: + - banner [ref=e23]: + - generic [ref=e25]: Admin + - generic [ref=e26]: + - paragraph [ref=e27]: owner@acme.test + - button "Sign out" [ref=e29]: + - img [ref=e31] + - generic [ref=e34]: Sign out + - main [ref=e35]: + - generic [ref=e36]: + - generic [ref=e37]: + - generic [ref=e38]: Dashboard + - generic [ref=e39]: + - generic [ref=e40]: + - generic [ref=e41]: From + - textbox "From" [ref=e43]: 2026-03-20 + - generic [ref=e45]: + - generic [ref=e46]: To + - textbox "To" [ref=e48]: 2026-04-18 + - generic [ref=e50]: + - generic [ref=e51]: + - paragraph [ref=e52]: Total sales + - generic [ref=e53]: "0.00" + - generic [ref=e54]: + - paragraph [ref=e55]: Orders + - generic [ref=e56]: "0" + - generic [ref=e57]: + - paragraph [ref=e58]: AOV + - generic [ref=e59]: "0.00" + - generic [ref=e60]: + - paragraph [ref=e61]: Conversion + - generic [ref=e62]: 0.00% + - generic [ref=e63]: + - generic [ref=e64]: Recent orders + - table [ref=e65]: + - rowgroup [ref=e66]: + - row "Order Email Status Total" [ref=e67]: + - columnheader "Order" [ref=e68] + - columnheader "Email" [ref=e69] + - columnheader "Status" [ref=e70] + - columnheader "Total" [ref=e71] + - rowgroup [ref=e72]: + - row "#1001 buyer@example.com pending 35.69" [ref=e73]: + - cell "#1001" [ref=e74]: + - link "#1001" [ref=e75] [cursor=pointer]: + - /url: http://shop.test/admin/orders/1 + - cell "buyer@example.com" [ref=e76] + - cell "pending" [ref=e77] + - cell "35.69" [ref=e78] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-46-24-184Z.yml b/.playwright-mcp/page-2026-04-18T08-46-24-184Z.yml new file mode 100644 index 00000000..97cb8a0a --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-46-24-184Z.yml @@ -0,0 +1,99 @@ +- generic [ref=e2]: + - complementary [ref=e3]: + - generic [ref=e4]: Acme Fashion + - navigation [ref=e5]: + - link "Dashboard" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/admin + - link "Orders" [ref=e7] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - link "Products" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/admin/products + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - link "Customers" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - link "Discounts" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - link "Analytics" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - generic [ref=e13]: Content + - link "Pages" [ref=e14] [cursor=pointer]: + - /url: http://shop.test/admin/content/pages + - link "Navigation" [ref=e15] [cursor=pointer]: + - /url: http://shop.test/admin/content/navigation + - link "Themes" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/content/themes + - generic [ref=e17]: System + - link "Settings" [ref=e18] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - link "Search" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/search/settings + - link "Apps" [ref=e20] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - link "Developers" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - generic [ref=e22]: + - banner [ref=e23]: + - generic [ref=e25]: Admin + - generic [ref=e26]: + - paragraph [ref=e27]: owner@acme.test + - button "Sign out" [ref=e29]: + - img [ref=e31] + - generic [ref=e34]: Sign out + - main [ref=e35]: + - generic [ref=e36]: + - generic [ref=e37]: + - generic [ref=e38]: "Order #1001" + - link "Back" [ref=e39] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - generic [ref=e40]: + - button "Fulfill items" [ref=e41]: + - img [ref=e43] + - generic [ref=e46]: Fulfill items + - button "Confirm payment" [ref=e47]: + - img [ref=e49] + - generic [ref=e52]: Confirm payment + - button "Cancel order" [ref=e53]: + - img [ref=e55] + - generic [ref=e58]: Cancel order + - generic [ref=e59]: + - generic [ref=e60]: + - generic [ref=e61]: + - generic [ref=e62]: Line items + - table [ref=e63]: + - rowgroup [ref=e64]: + - row "Title SKU Qty Price Total" [ref=e65]: + - columnheader "Title" [ref=e66] + - columnheader "SKU" [ref=e67] + - columnheader "Qty" [ref=e68] + - columnheader "Price" [ref=e69] + - columnheader "Total" [ref=e70] + - rowgroup [ref=e71]: + - row "Organic Cotton T-Shirt TSH-S-BLA 1 (0 fulfilled) 25.00 25.00" [ref=e72]: + - cell "Organic Cotton T-Shirt" [ref=e73] + - cell "TSH-S-BLA" [ref=e74] + - cell "1 (0 fulfilled)" [ref=e75] + - cell "25.00" [ref=e76] + - cell "25.00" [ref=e77] + - generic [ref=e79]: + - generic [ref=e80]: "Subtotal: 25.00" + - generic [ref=e81]: "Shipping: 4.99" + - generic [ref=e82]: "Tax: 5.70" + - generic [ref=e83]: "Total: 35.69" + - generic [ref=e84]: + - generic [ref=e85]: Timeline + - list [ref=e86]: + - listitem [ref=e87]: Placed at 2026-04-18 08:43 + - generic [ref=e88]: + - generic [ref=e89]: Payment + - list [ref=e90]: + - listitem [ref=e91]: "Method: bank_transfer" + - listitem [ref=e92]: "Financial status: pending" + - listitem [ref=e93]: "Total paid: 35.69" + - generic [ref=e94]: + - generic [ref=e95]: + - generic [ref=e96]: Customer + - generic [ref=e98]: buyer@example.com + - generic [ref=e99]: + - generic [ref=e100]: Shipping address + - generic [ref=e101]: "{ \"first_name\": \"Billy\", \"last_name\": \"Buyer\", \"address1\": \"1 Shop Street\", \"address2\": \"\", \"city\": \"Berlin\", \"province_code\": \"BE\", \"zip\": \"10115\", \"country_code\": \"DE\", \"phone\": \"\" }" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-46-40-422Z.yml b/.playwright-mcp/page-2026-04-18T08-46-40-422Z.yml new file mode 100644 index 00000000..97cb8a0a --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-46-40-422Z.yml @@ -0,0 +1,99 @@ +- generic [ref=e2]: + - complementary [ref=e3]: + - generic [ref=e4]: Acme Fashion + - navigation [ref=e5]: + - link "Dashboard" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/admin + - link "Orders" [ref=e7] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - link "Products" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/admin/products + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - link "Customers" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - link "Discounts" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - link "Analytics" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - generic [ref=e13]: Content + - link "Pages" [ref=e14] [cursor=pointer]: + - /url: http://shop.test/admin/content/pages + - link "Navigation" [ref=e15] [cursor=pointer]: + - /url: http://shop.test/admin/content/navigation + - link "Themes" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/content/themes + - generic [ref=e17]: System + - link "Settings" [ref=e18] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - link "Search" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/search/settings + - link "Apps" [ref=e20] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - link "Developers" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - generic [ref=e22]: + - banner [ref=e23]: + - generic [ref=e25]: Admin + - generic [ref=e26]: + - paragraph [ref=e27]: owner@acme.test + - button "Sign out" [ref=e29]: + - img [ref=e31] + - generic [ref=e34]: Sign out + - main [ref=e35]: + - generic [ref=e36]: + - generic [ref=e37]: + - generic [ref=e38]: "Order #1001" + - link "Back" [ref=e39] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - generic [ref=e40]: + - button "Fulfill items" [ref=e41]: + - img [ref=e43] + - generic [ref=e46]: Fulfill items + - button "Confirm payment" [ref=e47]: + - img [ref=e49] + - generic [ref=e52]: Confirm payment + - button "Cancel order" [ref=e53]: + - img [ref=e55] + - generic [ref=e58]: Cancel order + - generic [ref=e59]: + - generic [ref=e60]: + - generic [ref=e61]: + - generic [ref=e62]: Line items + - table [ref=e63]: + - rowgroup [ref=e64]: + - row "Title SKU Qty Price Total" [ref=e65]: + - columnheader "Title" [ref=e66] + - columnheader "SKU" [ref=e67] + - columnheader "Qty" [ref=e68] + - columnheader "Price" [ref=e69] + - columnheader "Total" [ref=e70] + - rowgroup [ref=e71]: + - row "Organic Cotton T-Shirt TSH-S-BLA 1 (0 fulfilled) 25.00 25.00" [ref=e72]: + - cell "Organic Cotton T-Shirt" [ref=e73] + - cell "TSH-S-BLA" [ref=e74] + - cell "1 (0 fulfilled)" [ref=e75] + - cell "25.00" [ref=e76] + - cell "25.00" [ref=e77] + - generic [ref=e79]: + - generic [ref=e80]: "Subtotal: 25.00" + - generic [ref=e81]: "Shipping: 4.99" + - generic [ref=e82]: "Tax: 5.70" + - generic [ref=e83]: "Total: 35.69" + - generic [ref=e84]: + - generic [ref=e85]: Timeline + - list [ref=e86]: + - listitem [ref=e87]: Placed at 2026-04-18 08:43 + - generic [ref=e88]: + - generic [ref=e89]: Payment + - list [ref=e90]: + - listitem [ref=e91]: "Method: bank_transfer" + - listitem [ref=e92]: "Financial status: pending" + - listitem [ref=e93]: "Total paid: 35.69" + - generic [ref=e94]: + - generic [ref=e95]: + - generic [ref=e96]: Customer + - generic [ref=e98]: buyer@example.com + - generic [ref=e99]: + - generic [ref=e100]: Shipping address + - generic [ref=e101]: "{ \"first_name\": \"Billy\", \"last_name\": \"Buyer\", \"address1\": \"1 Shop Street\", \"address2\": \"\", \"city\": \"Berlin\", \"province_code\": \"BE\", \"zip\": \"10115\", \"country_code\": \"DE\", \"phone\": \"\" }" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-46-56-113Z.yml b/.playwright-mcp/page-2026-04-18T08-46-56-113Z.yml new file mode 100644 index 00000000..719c9fdb --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-46-56-113Z.yml @@ -0,0 +1,90 @@ +- generic [ref=e2]: + - complementary [ref=e3]: + - generic [ref=e4]: Acme Fashion + - navigation [ref=e5]: + - link "Dashboard" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/admin + - link "Orders" [ref=e7] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - link "Products" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/admin/products + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - link "Customers" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - link "Discounts" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - link "Analytics" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - generic [ref=e13]: Content + - link "Pages" [ref=e14] [cursor=pointer]: + - /url: http://shop.test/admin/content/pages + - link "Navigation" [ref=e15] [cursor=pointer]: + - /url: http://shop.test/admin/content/navigation + - link "Themes" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/content/themes + - generic [ref=e17]: System + - link "Settings" [ref=e18] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - link "Search" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/search/settings + - link "Apps" [ref=e20] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - link "Developers" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - generic [ref=e22]: + - banner [ref=e23]: + - generic [ref=e25]: Admin + - generic [ref=e26]: + - paragraph [ref=e27]: owner@acme.test + - button "Sign out" [ref=e29]: + - img [ref=e31] + - generic [ref=e34]: Sign out + - main [ref=e35]: + - generic [ref=e36]: + - generic [ref=e37]: + - generic [ref=e38]: Products + - link "New product" [ref=e39] [cursor=pointer]: + - /url: http://shop.test/admin/products/new + - generic [ref=e40]: + - textbox "Search products..." [ref=e42] + - combobox [ref=e44]: + - option "All statuses" [disabled] + - option "All statuses" [selected] + - option "Draft" + - option "Active" + - option "Archived" + - table [ref=e46]: + - rowgroup [ref=e47]: + - row "Title Status Vendor Type Actions" [ref=e48]: + - columnheader [ref=e49] + - columnheader "Title" [ref=e50] + - columnheader "Status" [ref=e51] + - columnheader "Vendor" [ref=e52] + - columnheader "Type" [ref=e53] + - columnheader "Actions" [ref=e54] + - rowgroup [ref=e55]: + - row "Classic Pullover Hoodie active Acme Apparel Apparel Edit" [ref=e56]: + - cell [ref=e57]: + - checkbox [ref=e58] + - cell "Classic Pullover Hoodie" [ref=e60]: + - link "Classic Pullover Hoodie" [ref=e61] [cursor=pointer]: + - /url: http://shop.test/admin/products/2 + - cell "active" [ref=e62] + - cell "Acme Apparel" [ref=e63] + - cell "Apparel" [ref=e64] + - cell "Edit" [ref=e65]: + - link "Edit" [ref=e66] [cursor=pointer]: + - /url: http://shop.test/admin/products/2 + - row "Organic Cotton T-Shirt active Acme Apparel Apparel Edit" [ref=e67]: + - cell [ref=e68]: + - checkbox [ref=e69] + - cell "Organic Cotton T-Shirt" [ref=e71]: + - link "Organic Cotton T-Shirt" [ref=e72] [cursor=pointer]: + - /url: http://shop.test/admin/products/1 + - cell "active" [ref=e73] + - cell "Acme Apparel" [ref=e74] + - cell "Apparel" [ref=e75] + - cell "Edit" [ref=e76]: + - link "Edit" [ref=e77] [cursor=pointer]: + - /url: http://shop.test/admin/products/1 \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-47-08-093Z.yml b/.playwright-mcp/page-2026-04-18T08-47-08-093Z.yml new file mode 100644 index 00000000..b55244ff --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-47-08-093Z.yml @@ -0,0 +1,104 @@ +- generic [ref=e2]: + - complementary [ref=e3]: + - generic [ref=e4]: Acme Fashion + - navigation [ref=e5]: + - link "Dashboard" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/admin + - link "Orders" [ref=e7] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - link "Products" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/admin/products + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - link "Customers" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - link "Discounts" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - link "Analytics" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - generic [ref=e13]: Content + - link "Pages" [ref=e14] [cursor=pointer]: + - /url: http://shop.test/admin/content/pages + - link "Navigation" [ref=e15] [cursor=pointer]: + - /url: http://shop.test/admin/content/navigation + - link "Themes" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/content/themes + - generic [ref=e17]: System + - link "Settings" [ref=e18] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - link "Search" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/search/settings + - link "Apps" [ref=e20] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - link "Developers" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - generic [ref=e22]: + - banner [ref=e23]: + - generic [ref=e25]: Admin + - generic [ref=e26]: + - paragraph [ref=e27]: owner@acme.test + - button "Sign out" [ref=e29]: + - img [ref=e31] + - generic [ref=e34]: Sign out + - main [ref=e35]: + - generic [ref=e36]: + - generic [ref=e37]: + - generic [ref=e38]: Shipping + - link "Back" [ref=e39] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - generic [ref=e40]: + - generic [ref=e41]: + - generic [ref=e42]: Zone name + - textbox "Zone name" [ref=e44] + - generic [ref=e45]: + - generic [ref=e46]: Countries (comma ISO-2) + - textbox "Countries (comma ISO-2)" [ref=e48] + - button "Add zone" [ref=e49]: + - img [ref=e51] + - generic [ref=e54]: Add zone + - generic [ref=e55]: + - generic [ref=e56]: + - generic [ref=e57]: Germany DE + - button "Remove zone" [ref=e58]: + - img [ref=e60] + - generic [ref=e63]: Remove zone + - list [ref=e64]: + - listitem [ref=e65]: + - generic [ref=e66]: Standard (flat) - 499 cents + - button "Remove" [ref=e67]: + - img [ref=e69] + - generic [ref=e72]: Remove + - generic [ref=e73]: + - textbox "Rate name" [ref=e75] + - combobox [ref=e76]: + - option "flat" [selected] + - option "weight" + - option "price" + - option "carrier" + - spinbutton [ref=e78] + - button "Add rate" [ref=e79]: + - img [ref=e81] + - generic [ref=e84]: Add rate + - generic [ref=e85]: + - generic [ref=e86]: + - generic [ref=e87]: Rest of World US, GB, FR, AT, CH, IT, ES, NL + - button "Remove zone" [ref=e88]: + - img [ref=e90] + - generic [ref=e93]: Remove zone + - list [ref=e94]: + - listitem [ref=e95]: + - generic [ref=e96]: International (flat) - 1499 cents + - button "Remove" [ref=e97]: + - img [ref=e99] + - generic [ref=e102]: Remove + - generic [ref=e103]: + - textbox "Rate name" [ref=e105] + - combobox [ref=e106]: + - option "flat" [selected] + - option "weight" + - option "price" + - option "carrier" + - spinbutton [ref=e108] + - button "Add rate" [ref=e109]: + - img [ref=e111] + - generic [ref=e114]: Add rate \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-47-18-629Z.yml b/.playwright-mcp/page-2026-04-18T08-47-18-629Z.yml new file mode 100644 index 00000000..916efc2f --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-47-18-629Z.yml @@ -0,0 +1,60 @@ +- generic [ref=e2]: + - complementary [ref=e3]: + - generic [ref=e4]: Acme Fashion + - navigation [ref=e5]: + - link "Dashboard" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/admin + - link "Orders" [ref=e7] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - link "Products" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/admin/products + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - link "Customers" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - link "Discounts" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - link "Analytics" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - generic [ref=e13]: Content + - link "Pages" [ref=e14] [cursor=pointer]: + - /url: http://shop.test/admin/content/pages + - link "Navigation" [ref=e15] [cursor=pointer]: + - /url: http://shop.test/admin/content/navigation + - link "Themes" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/content/themes + - generic [ref=e17]: System + - link "Settings" [ref=e18] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - link "Search" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/search/settings + - link "Apps" [ref=e20] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - link "Developers" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - generic [ref=e22]: + - banner [ref=e23]: + - generic [ref=e25]: Admin + - generic [ref=e26]: + - paragraph [ref=e27]: owner@acme.test + - button "Sign out" [ref=e29]: + - img [ref=e31] + - generic [ref=e34]: Sign out + - main [ref=e35]: + - generic [ref=e36]: + - generic [ref=e37]: + - generic [ref=e38]: Discounts + - link "New discount" [ref=e39] [cursor=pointer]: + - /url: http://shop.test/admin/discounts/new + - textbox "Search by code..." [ref=e41] + - table [ref=e44]: + - rowgroup [ref=e45]: + - row "Code Type Value Status Actions" [ref=e46]: + - columnheader "Code" [ref=e47] + - columnheader "Type" [ref=e48] + - columnheader "Value" [ref=e49] + - columnheader "Status" [ref=e50] + - columnheader "Actions" [ref=e51] + - rowgroup [ref=e52]: + - row "No discounts yet." [ref=e53]: + - cell "No discounts yet." [ref=e54] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T08-47-23-095Z.yml b/.playwright-mcp/page-2026-04-18T08-47-23-095Z.yml new file mode 100644 index 00000000..cf7e4975 --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T08-47-23-095Z.yml @@ -0,0 +1,69 @@ +- generic [ref=e2]: + - complementary [ref=e3]: + - generic [ref=e4]: Acme Fashion + - navigation [ref=e5]: + - link "Dashboard" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/admin + - link "Orders" [ref=e7] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - link "Products" [ref=e8] [cursor=pointer]: + - /url: http://shop.test/admin/products + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - link "Customers" [ref=e10] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - link "Discounts" [ref=e11] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - link "Analytics" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - generic [ref=e13]: Content + - link "Pages" [ref=e14] [cursor=pointer]: + - /url: http://shop.test/admin/content/pages + - link "Navigation" [ref=e15] [cursor=pointer]: + - /url: http://shop.test/admin/content/navigation + - link "Themes" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/admin/content/themes + - generic [ref=e17]: System + - link "Settings" [ref=e18] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - link "Search" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/admin/search/settings + - link "Apps" [ref=e20] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - link "Developers" [ref=e21] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - generic [ref=e22]: + - banner [ref=e23]: + - generic [ref=e25]: Admin + - generic [ref=e26]: + - paragraph [ref=e27]: owner@acme.test + - button "Sign out" [ref=e29]: + - img [ref=e31] + - generic [ref=e34]: Sign out + - main [ref=e35]: + - generic [ref=e36]: + - generic [ref=e37]: + - generic [ref=e38]: Analytics + - generic [ref=e39]: + - generic [ref=e40]: + - generic [ref=e41]: From + - textbox "From" [ref=e43]: 2026-03-20 + - generic [ref=e45]: + - generic [ref=e46]: To + - textbox "To" [ref=e48]: 2026-04-18 + - generic [ref=e50]: + - generic [ref=e51]: + - paragraph [ref=e52]: Revenue + - generic [ref=e53]: "0.00" + - generic [ref=e54]: + - paragraph [ref=e55]: Orders + - generic [ref=e56]: "0" + - generic [ref=e57]: + - paragraph [ref=e58]: Visits + - generic [ref=e59]: "0" + - generic [ref=e60]: + - paragraph [ref=e61]: AOV + - generic [ref=e62]: "0.00" + - generic [ref=e63]: + - generic [ref=e64]: Daily breakdown + - paragraph [ref=e65]: No data yet. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-18T10-59-02-335Z.yml b/.playwright-mcp/page-2026-04-18T10-59-02-335Z.yml new file mode 100644 index 00000000..ddcd336e --- /dev/null +++ b/.playwright-mcp/page-2026-04-18T10-59-02-335Z.yml @@ -0,0 +1,59 @@ +- generic [active] [ref=e1]: + - link "Skip to content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping on orders over 50 + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: https://2026-04-16-claude-code-opus-4-7-xhigh.agentic-engineers.dev + - navigation [ref=e7]: + - link "Collections" [ref=e8] [cursor=pointer]: + - /url: https://2026-04-16-claude-code-opus-4-7-xhigh.agentic-engineers.dev/collections + - link "Search" [ref=e9] [cursor=pointer]: + - /url: https://2026-04-16-claude-code-opus-4-7-xhigh.agentic-engineers.dev/search + - link "Sign in" [ref=e10] [cursor=pointer]: + - /url: https://2026-04-16-claude-code-opus-4-7-xhigh.agentic-engineers.dev/account/login + - link "Cart" [ref=e11] [cursor=pointer]: + - /url: https://2026-04-16-claude-code-opus-4-7-xhigh.agentic-engineers.dev/cart + - main [ref=e12]: + - generic [ref=e13]: + - generic [ref=e14]: + - generic [ref=e15]: Elevated essentials + - paragraph [ref=e16]: Timeless pieces for modern wardrobes. + - link "Shop the collection" [ref=e18] [cursor=pointer]: + - /url: /collections + - generic [ref=e19]: + - generic [ref=e20]: Featured collections + - link "Featured" [ref=e22] [cursor=pointer]: + - /url: https://2026-04-16-claude-code-opus-4-7-xhigh.agentic-engineers.dev/collections/featured + - generic [ref=e23]: Featured + - generic [ref=e24]: + - generic [ref=e25]: Featured products + - generic [ref=e26]: + - link "Organic Cotton T-Shirt" [ref=e28] [cursor=pointer]: + - /url: https://2026-04-16-claude-code-opus-4-7-xhigh.agentic-engineers.dev/products/organic-cotton-t-shirt + - img [ref=e31] + - generic [ref=e34]: Organic Cotton T-Shirt + - link "Classic Pullover Hoodie" [ref=e36] [cursor=pointer]: + - /url: https://2026-04-16-claude-code-opus-4-7-xhigh.agentic-engineers.dev/products/classic-pullover-hoodie + - img [ref=e39] + - generic [ref=e42]: Classic Pullover Hoodie + - contentinfo [ref=e43]: + - generic [ref=e44]: © 2026 Acme Fashion. All rights reserved. + - generic: + - generic: + - dialog "Shopping cart": + - banner: + - generic: Your Cart (0) + - button "Close cart": + - generic: + - img + - img + - generic: + - generic: + - img + - paragraph: Your cart is empty + - button "Continue shopping": + - generic: + - img + - generic: Continue shopping \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 296f2af0..30ccb8ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,3 +23,227 @@ The complete specification is in `specs/`. Start with `specs/09-IMPLEMENTATION-R - `specs/07-SEEDERS-AND-TEST-DATA.md` - Seeders and test data - `specs/08-PLAYWRIGHT-E2E-PLAN.md` - E2E browser tests - `specs/09-IMPLEMENTATION-ROADMAP.md` - Implementation roadmap + +=== + + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications. + +## Foundational Context + +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.4 +- laravel/fortify (FORTIFY) - v1 +- laravel/framework (LARAVEL) - v12 +- laravel/prompts (PROMPTS) - v0 +- livewire/flux (FLUXUI_FREE) - v2 +- livewire/livewire (LIVEWIRE) - v4 +- laravel/boost (BOOST) - v2 +- laravel/mcp (MCP) - v0 +- laravel/pail (PAIL) - v1 +- laravel/pint (PINT) - v1 +- laravel/sail (SAIL) - v1 +- pestphp/pest (PEST) - v4 +- phpunit/phpunit (PHPUNIT) - v12 +- tailwindcss (TAILWINDCSS) - v4 + +## Skills Activation + +This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. + +- `developing-with-fortify` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications. +- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns. +- `fluxui-development` — Use this skill for Flux UI development in Livewire applications only. Trigger when working with components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling. +- `livewire-development` — Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire. +- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code. +- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS. + +## Conventions + +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts + +- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important. + +## Application Structure & Architecture + +- Stick to existing directory structure; don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling + +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Documentation Files + +- You must only create documentation files if explicitly requested by the user. + +## Replies + +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +=== boost rules === + +# Laravel Boost + +## Tools + +- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads. +- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker. +- Use `database-schema` to inspect table structure before writing migrations or models. +- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user. +- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries. + +## Searching Documentation (IMPORTANT) + +- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically. +- Pass a `packages` array to scope results when you know which packages are relevant. +- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first. +- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`. + +### Search Syntax + +1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit". +2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order. +3. Combine words and phrases for mixed queries: `middleware "rate limit"`. +4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`. + +## Artisan + +- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. +- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`. +- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory. +- To check environment variables, read the `.env` file directly. + +## Tinker + +- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code. +- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'` + - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` + +=== php rules === + +# PHP + +- Always use curly braces for control structures, even for single-line bodies. +- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private. +- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool` +- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`. +- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic. +- Use array shape type definitions in PHPDoc blocks. + +=== deployments rules === + +# Deployment + +- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications. + +=== herd rules === + +# Laravel Herd + +- The application is served by Laravel Herd at `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs. Never run commands to serve the site. It is always available. +- Use the `herd` CLI to manage services, PHP versions, and sites (e.g. `herd sites`, `herd services:start `, `herd php:list`). Run `herd list` to discover all available commands. + +=== tests rules === + +# Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. + +=== fortify/core rules === + +# Laravel Fortify + +- Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications. +- IMPORTANT: Always use the `search-docs` tool for detailed Laravel Fortify patterns and documentation. +- IMPORTANT: Activate `developing-with-fortify` skill when working with Fortify authentication features. + +=== laravel/core rules === + +# Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`. +- If you're creating a generic PHP class, use `php artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Model Creation + +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options. + +## APIs & Eloquent Resources + +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +## URL Generation + +- When generating links to other pages, prefer named routes and the `route()` function. + +## Testing + +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +## Vite Error + +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + +=== laravel/v12 rules === + +# Laravel 12 + +- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +## Laravel 12 Structure + +- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`. +- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- The `app/Console/Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. +- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration. + +## Database + +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models + +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + +=== livewire/core rules === + +# Livewire + +- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript. +- You can use Alpine.js for client-side interactions instead of JavaScript frameworks. +- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests. + +=== pint/core rules === + +# Laravel Pint Code Formatter + +- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues. + +=== pest/core rules === + +## Pest + +- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`. +- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`. +- Do NOT delete tests without approval. + + diff --git a/README.md b/README.md new file mode 100644 index 00000000..e19249e8 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +Your mission is to implement an entire shop system based on the specifications im specs/*. You are allowed to decide to use sub-agents. You must do in one go without stopping. You must use team mode! You must test everything via Pest (unit, and functional tests). You must also additional simulate user behaviour using the Playwright MPC and confirm that all acceptance criterias are met. If you find bugs, you must fix them. The result is a perfect shop system. All requirements are perfectly implemented. All acceptance criterias are met, tested and confirmed by you. + +Continuously keep track of the progress in specs/progress.md Commit your progress after every relevant iteration with a meaningful message. + +When implementation is fully done, then make a full review meeting with Playwright in Chrome and showcase all features (customer- and admin-side) to me. In case bugs appear, you must fix them all and restart the review meeting. Shop is running at http://shop.test/. + +Don't re-use any existing implementation in another branch. Build it from scratch. diff --git a/boost.json b/boost.json new file mode 100644 index 00000000..3a9632d5 --- /dev/null +++ b/boost.json @@ -0,0 +1,17 @@ +{ + "agents": [ + "codex" + ], + "guidelines": true, + "mcp": true, + "nightwatch_mcp": false, + "sail": false, + "skills": [ + "developing-with-fortify", + "laravel-best-practices", + "fluxui-development", + "livewire-development", + "pest-testing", + "tailwindcss-development" + ] +} diff --git a/composer.json b/composer.json index 1f848aaf..a578e1d1 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ }, "require-dev": { "fakerphp/faker": "^1.23", - "laravel/boost": "^1.0", + "laravel/boost": "^2.4", "laravel/pail": "^1.2.2", "laravel/pint": "^1.24", "laravel/sail": "^1.41", diff --git a/composer.lock b/composer.lock index e4255dbd..7134a0de 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e4aa7ad38dac6834e5ff6bf65b1cdf23", + "content-hash": "a73f62d24e65543e17c317a1e9b580fa", "packages": [ { "name": "bacon/bacon-qr-code", @@ -6877,35 +6877,36 @@ }, { "name": "laravel/boost", - "version": "v1.0.18", + "version": "v2.4.5", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab" + "reference": "60386c7723ff7cb388b62b6c137597244a9cf2f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", + "url": "https://api.github.com/repos/laravel/boost/zipball/60386c7723ff7cb388b62b6c137597244a9cf2f2", + "reference": "60386c7723ff7cb388b62b6c137597244a9cf2f2", "shasum": "" }, "require": { "guzzlehttp/guzzle": "^7.9", - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "laravel/mcp": "^0.1.0", - "laravel/prompts": "^0.1.9|^0.3", - "laravel/roster": "^0.2", - "php": "^8.1|^8.2" + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "laravel/mcp": "^0.5.1|^0.6.0|^0.7.0", + "laravel/prompts": "^0.3.10", + "laravel/roster": "^0.5.0", + "php": "^8.2" }, "require-dev": { - "laravel/pint": "^1.14|^1.23", - "mockery/mockery": "^1.6", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.0", - "phpstan/phpstan": "^2.0" + "laravel/pint": "^1.27.0", + "mockery/mockery": "^1.6.12", + "orchestra/testbench": "^9.15.0|^10.6|^11.0", + "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.1" }, "type": "library", "extra": { @@ -6927,7 +6928,7 @@ "license": [ "MIT" ], - "description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.", + "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.", "homepage": "https://github.com/laravel/boost", "keywords": [ "ai", @@ -6938,35 +6939,41 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2025-08-16T09:10:03+00:00" + "time": "2026-04-22T13:29:20+00:00" }, { "name": "laravel/mcp", - "version": "v0.1.1", + "version": "v0.7.0", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713" + "reference": "3513b4feca5f1678be4d2261dcfa8e456436d02a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713", + "url": "https://api.github.com/repos/laravel/mcp/zipball/3513b4feca5f1678be4d2261dcfa8e456436d02a", + "reference": "3513b4feca5f1678be4d2261dcfa8e456436d02a", "shasum": "" }, "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/http": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "illuminate/validation": "^10.0|^11.0|^12.0", - "php": "^8.1|^8.2" + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2" }, "require-dev": { - "laravel/pint": "^1.14", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "phpstan/phpstan": "^2.0" + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" }, "type": "library", "extra": { @@ -6982,8 +6989,6 @@ "autoload": { "psr-4": { "Laravel\\Mcp\\": "src/", - "Workbench\\App\\": "workbench/app/", - "Laravel\\Mcp\\Tests\\": "tests/", "Laravel\\Mcp\\Server\\": "src/Server/" } }, @@ -6991,10 +6996,15 @@ "license": [ "MIT" ], - "description": "The easiest way to add MCP servers to your Laravel app.", + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", "homepage": "https://github.com/laravel/mcp", "keywords": [ - "dev", "laravel", "mcp" ], @@ -7002,7 +7012,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2025-08-16T09:50:43+00:00" + "time": "2026-04-21T10:23:03+00:00" }, { "name": "laravel/pail", @@ -7153,30 +7163,31 @@ }, { "name": "laravel/roster", - "version": "v0.2.2", + "version": "v0.5.1", "source": { "type": "git", "url": "https://github.com/laravel/roster.git", - "reference": "67a39bce557a6cb7e7205a2a9d6c464f0e72956f" + "reference": "5089de7615f72f78e831590ff9d0435fed0102bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/roster/zipball/67a39bce557a6cb7e7205a2a9d6c464f0e72956f", - "reference": "67a39bce557a6cb7e7205a2a9d6c464f0e72956f", + "url": "https://api.github.com/repos/laravel/roster/zipball/5089de7615f72f78e831590ff9d0435fed0102bb", + "reference": "5089de7615f72f78e831590ff9d0435fed0102bb", "shasum": "" }, "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "php": "^8.1|^8.2" + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/routing": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/yaml": "^7.2|^8.0" }, "require-dev": { "laravel/pint": "^1.14", "mockery/mockery": "^1.6", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.0", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.1", "phpstan/phpstan": "^2.0" }, "type": "library", @@ -7209,7 +7220,7 @@ "issues": "https://github.com/laravel/roster/issues", "source": "https://github.com/laravel/roster" }, - "time": "2025-07-24T12:31:13+00:00" + "time": "2026-03-05T07:58:43+00:00" }, { "name": "laravel/sail", @@ -9974,5 +9985,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } From 129ed9610b6ba1da5464c320fc6cc68584909dc7 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sat, 25 Apr 2026 10:53:15 +0200 Subject: [PATCH 02/35] Prompt --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e19249e8..75c46f37 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Your mission is to implement an entire shop system based on the specifications im specs/*. You are allowed to decide to use sub-agents. You must do in one go without stopping. You must use team mode! You must test everything via Pest (unit, and functional tests). You must also additional simulate user behaviour using the Playwright MPC and confirm that all acceptance criterias are met. If you find bugs, you must fix them. The result is a perfect shop system. All requirements are perfectly implemented. All acceptance criterias are met, tested and confirmed by you. +Your mission is to implement an entire shop system based on the specifications im specs/*. You must use sub-agents for role play (e.g. frontend-, backend-deveoper, QA Analyst, QA Engineer, etc). You must do in one go without stopping. You must use team mode! You must test everything via Pest (unit, and functional tests). You must also additional simulate user behaviour using the Playwright MPC and confirm that all acceptance criterias are met. If you find bugs, you must fix them. The result is a perfect shop system. All requirements are perfectly implemented. All acceptance criterias are met, tested and confirmed by you. Continuously keep track of the progress in specs/progress.md Commit your progress after every relevant iteration with a meaningful message. From 8c368a5579ca5f2922ad7a48d01b9c9f8761d7de Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sat, 25 Apr 2026 10:54:09 +0200 Subject: [PATCH 03/35] Prompt --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 75c46f37..a5316efa 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Your mission is to implement an entire shop system based on the specifications im specs/*. You must use sub-agents for role play (e.g. frontend-, backend-deveoper, QA Analyst, QA Engineer, etc). You must do in one go without stopping. You must use team mode! You must test everything via Pest (unit, and functional tests). You must also additional simulate user behaviour using the Playwright MPC and confirm that all acceptance criterias are met. If you find bugs, you must fix them. The result is a perfect shop system. All requirements are perfectly implemented. All acceptance criterias are met, tested and confirmed by you. +Your mission is to implement an entire shop system based on the specifications im specs/*. You must use sub-agents for role play (e.g. frontend-, backend-deveoper, QA Analyst, QA Engineer, etc). You must do in one go without stopping. You must test everything via Pest (unit, and functional tests). You must also additional simulate user behaviour using the Playwright MPC and confirm that all acceptance criterias are met. If you find bugs, you must fix them. The result is a perfect shop system. All requirements are perfectly implemented. All acceptance criterias are met, tested and confirmed by you. Continuously keep track of the progress in specs/progress.md Commit your progress after every relevant iteration with a meaningful message. From 525f66722116399615649f825086b75872f94dad Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 3 May 2026 12:59:00 +0200 Subject: [PATCH 04/35] .. --- .codex/config.toml | 3 ++ README.md | 106 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/.codex/config.toml b/.codex/config.toml index 2a2fdc87..6a8ba1bc 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -2,3 +2,6 @@ command = "php" args = ["artisan", "boost:mcp"] cwd = "/Users/fabianwesner/Herd/shop" + +[features] +goals = true diff --git a/README.md b/README.md index a5316efa..3a8f65da 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,105 @@ -Your mission is to implement an entire shop system based on the specifications im specs/*. You must use sub-agents for role play (e.g. frontend-, backend-deveoper, QA Analyst, QA Engineer, etc). You must do in one go without stopping. You must test everything via Pest (unit, and functional tests). You must also additional simulate user behaviour using the Playwright MPC and confirm that all acceptance criterias are met. If you find bugs, you must fix them. The result is a perfect shop system. All requirements are perfectly implemented. All acceptance criterias are met, tested and confirmed by you. +/goal Build the complete shop system from specs/* until all acceptance criteria are satisfied and verified. -Continuously keep track of the progress in specs/progress.md Commit your progress after every relevant iteration with a meaningful message. +You are operating in persistent goal mode. Continue working until the goal is achieved, or a real external blocker prevents progress. -When implementation is fully done, then make a full review meeting with Playwright in Chrome and showcase all features (customer- and admin-side) to me. In case bugs appear, you must fix them all and restart the review meeting. Shop is running at http://shop.test/. +--- -Don't re-use any existing implementation in another branch. Build it from scratch. +GOAL +Deliver a fully working shop system based on specs/* with verified functionality. + +--- + +CONTEXT +- All requirements are in specs/* +- Progress and decisions must be tracked in specs/progress.md +- System runs at http://shop.test/ + +--- + +CONSTRAINTS +- Follow existing architecture and conventions +- Implement in vertical slices (no isolated scaffolding) +- Do not skip verification steps +- Do not stop at partial implementations +- Do not ask for next steps while acceptance criteria remain unmet + +--- + +DONE WHEN +- All specs/* requirements are implemented +- All acceptance criteria are satisfied +- Pest tests pass +- Playwright MCP verifies customer + admin flows +- No critical bugs remain after browser review +- specs/progress.md reflects full implementation and verification + +--- + +PROCESS (MANDATORY LOOP) + +0. PLANNING +- Read specs/* +- Create a phased execution plan in specs/progress.md +- Identify dependencies and risks +- Only proceed once plan is coherent + +1. IMPLEMENT +- Build next vertical slice + +2. VERIFY +- Run tests (Pest) +- Run browser flows (Playwright MCP) + +3. EVALUATE +- Compare results against DONE WHEN criteria +- Identify gaps and failures + +4. FIX +- Resolve issues before continuing + +5. TRACK +- Update specs/progress.md (status, decisions, open gaps) + +6. COMMIT +- Commit meaningful progress + +7. REPEAT until DONE WHEN is satisfied + +--- + +FAILURE HANDLING + +If stuck or looping: +- Re-evaluate plan +- Simplify approach +- Try alternative implementation strategy + +If context becomes too large: +- Compress state into specs/progress.md +- Continue from compressed state + +--- + +AGENT STRATEGY + +Use sub-agents where useful: +- backend +- frontend +- QA analyst +- QA engineer + +Sub-agents may analyze and propose. +You own integration, correctness, and final quality. + +--- + +PRIORITY ORDER + +1. Passing verification (tests + browser) +2. Functional correctness +3. Completeness vs specs +4. Code quality + +--- + +Never stop while DONE WHEN is not satisfied. From 2ebb8ed422448914310589eac5ee7e74959ce57d Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 3 May 2026 13:21:23 +0200 Subject: [PATCH 05/35] Build shop foundation tenancy --- app/Auth/CustomerUserProvider.php | 96 +++++++++++++++++++ app/Enums/StoreDomainType.php | 10 ++ app/Enums/StoreStatus.php | 9 ++ app/Enums/StoreUserRole.php | 11 +++ app/Http/Middleware/ResolveStore.php | 85 ++++++++++++++++ app/Models/Concerns/BelongsToStore.php | 30 ++++++ app/Models/Customer.php | 72 ++++++++++++++ app/Models/Organization.php | 29 ++++++ app/Models/Scopes/StoreScope.php | 25 +++++ app/Models/Store.php | 89 +++++++++++++++++ app/Models/StoreDomain.php | 55 +++++++++++ app/Models/StoreSettings.php | 52 ++++++++++ app/Models/StoreUser.php | 43 +++++++++ app/Models/User.php | 59 +++++++++++- app/Providers/AppServiceProvider.php | 11 +++ app/Providers/FortifyServiceProvider.php | 5 +- app/Providers/ShopAuthServiceProvider.php | 28 ++++++ bootstrap/app.php | 12 ++- bootstrap/providers.php | 1 + config/auth.php | 17 ++++ config/cache.php | 2 +- config/database.php | 6 +- config/logging.php | 8 ++ config/queue.php | 2 +- config/session.php | 2 +- database/factories/OrganizationFactory.php | 24 +++++ database/factories/StoreDomainFactory.php | 50 ++++++++++ database/factories/StoreFactory.php | 41 ++++++++ database/factories/StoreSettingsFactory.php | 32 +++++++ database/factories/UserFactory.php | 9 ++ .../0001_01_01_000000_create_users_table.php | 12 ++- ..._add_two_factor_columns_to_users_table.php | 2 +- ...5_03_110830_create_organizations_table.php | 31 ++++++ .../2026_05_03_110834_create_stores_table.php | 37 +++++++ ...5_03_110837_create_store_domains_table.php | 35 +++++++ ..._05_03_110844_create_store_users_table.php | 33 +++++++ ..._03_110845_create_store_settings_table.php | 28 ++++++ ...e_customer_password_reset_tokens_table.php | 31 ++++++ database/seeders/DatabaseSeeder.php | 14 +-- database/seeders/OrganizationSeeder.php | 20 ++++ database/seeders/StoreDomainSeeder.php | 29 ++++++ database/seeders/StoreSeeder.php | 31 ++++++ database/seeders/StoreSettingsSeeder.php | 32 +++++++ database/seeders/StoreUserSeeder.php | 32 +++++++ database/seeders/UserSeeder.php | 26 +++++ specs/progress.md | 69 +++++++++++++ .../Foundation/FoundationModelTest.php | 47 +++++++++ .../Feature/Tenancy/TenantResolutionTest.php | 76 +++++++++++++++ 48 files changed, 1475 insertions(+), 25 deletions(-) create mode 100644 app/Auth/CustomerUserProvider.php create mode 100644 app/Enums/StoreDomainType.php create mode 100644 app/Enums/StoreStatus.php create mode 100644 app/Enums/StoreUserRole.php create mode 100644 app/Http/Middleware/ResolveStore.php create mode 100644 app/Models/Concerns/BelongsToStore.php create mode 100644 app/Models/Customer.php create mode 100644 app/Models/Organization.php create mode 100644 app/Models/Scopes/StoreScope.php create mode 100644 app/Models/Store.php create mode 100644 app/Models/StoreDomain.php create mode 100644 app/Models/StoreSettings.php create mode 100644 app/Models/StoreUser.php create mode 100644 app/Providers/ShopAuthServiceProvider.php create mode 100644 database/factories/OrganizationFactory.php create mode 100644 database/factories/StoreDomainFactory.php create mode 100644 database/factories/StoreFactory.php create mode 100644 database/factories/StoreSettingsFactory.php create mode 100644 database/migrations/2026_05_03_110830_create_organizations_table.php create mode 100644 database/migrations/2026_05_03_110834_create_stores_table.php create mode 100644 database/migrations/2026_05_03_110837_create_store_domains_table.php create mode 100644 database/migrations/2026_05_03_110844_create_store_users_table.php create mode 100644 database/migrations/2026_05_03_110845_create_store_settings_table.php create mode 100644 database/migrations/2026_05_03_111034_create_customer_password_reset_tokens_table.php create mode 100644 database/seeders/OrganizationSeeder.php create mode 100644 database/seeders/StoreDomainSeeder.php create mode 100644 database/seeders/StoreSeeder.php create mode 100644 database/seeders/StoreSettingsSeeder.php create mode 100644 database/seeders/StoreUserSeeder.php create mode 100644 database/seeders/UserSeeder.php create mode 100644 specs/progress.md create mode 100644 tests/Feature/Foundation/FoundationModelTest.php create mode 100644 tests/Feature/Tenancy/TenantResolutionTest.php diff --git a/app/Auth/CustomerUserProvider.php b/app/Auth/CustomerUserProvider.php new file mode 100644 index 00000000..5c283fb9 --- /dev/null +++ b/app/Auth/CustomerUserProvider.php @@ -0,0 +1,96 @@ +currentStore(); + + if (! $store) { + return null; + } + + $model = $this->createModel(); + + return $this->newModelQuery($model) + ->where($model->getAuthIdentifierName(), $identifier) + ->where('store_id', $store->getKey()) + ->first(); + } + + /** + * @param mixed $identifier + * @param string $token + * @return (\Illuminate\Contracts\Auth\Authenticatable&\Illuminate\Database\Eloquent\Model)|null + */ + public function retrieveByToken($identifier, #[\SensitiveParameter] $token) + { + $store = $this->currentStore(); + + if (! $store) { + return null; + } + + $model = $this->createModel(); + + $retrievedModel = $this->newModelQuery($model) + ->where($model->getAuthIdentifierName(), $identifier) + ->where('store_id', $store->getKey()) + ->first(); + + if (! $retrievedModel) { + return null; + } + + $rememberToken = $retrievedModel->getRememberToken(); + + return $rememberToken && hash_equals($rememberToken, $token) ? $retrievedModel : null; + } + + /** + * @param array $credentials + * @return (\Illuminate\Contracts\Auth\Authenticatable&\Illuminate\Database\Eloquent\Model)|null + */ + public function retrieveByCredentials(#[\SensitiveParameter] array $credentials) + { + $store = $this->currentStore(); + + if (! $store) { + return null; + } + + $credentials['store_id'] = $store->getKey(); + + return parent::retrieveByCredentials($credentials); + } + + public function updateRememberToken(UserContract $user, #[\SensitiveParameter] $token): void + { + if (! $this->currentStore()) { + return; + } + + parent::updateRememberToken($user, $token); + } + + private function currentStore(): ?Store + { + if (! app()->bound('current_store')) { + return null; + } + + $store = app('current_store'); + + return $store instanceof Store ? $store : null; + } +} diff --git a/app/Enums/StoreDomainType.php b/app/Enums/StoreDomainType.php new file mode 100644 index 00000000..8b2b4869 --- /dev/null +++ b/app/Enums/StoreDomainType.php @@ -0,0 +1,10 @@ +isAdminRequest($request) + ? $this->resolveAdminStore($request) + : $this->resolveStorefrontStore($request); + + app()->instance('current_store', $store); + + return $next($request); + } + + private function resolveStorefrontStore(Request $request): Store + { + $hostname = $request->getHost(); + $cacheKey = 'store_domain:'.$hostname; + + $storeId = Cache::remember($cacheKey, now()->addMinutes(5), function () use ($hostname): ?int { + return StoreDomain::query() + ->where('hostname', $hostname) + ->value('store_id'); + }); + + if (! $storeId) { + abort(404); + } + + $store = Store::query()->find($storeId); + + if (! $store) { + Cache::forget($cacheKey); + abort(404); + } + + if ($store->status === StoreStatus::Suspended) { + abort(503); + } + + return $store; + } + + private function resolveAdminStore(Request $request): Store + { + $user = $request->user(); + $storeId = $request->session()->get('current_store_id'); + + if (! $user || ! $storeId) { + abort(403); + } + + $store = Store::query() + ->whereKey($storeId) + ->whereHas('users', fn ($query) => $query->whereKey($user->getKey())) + ->first(); + + if (! $store) { + abort(403); + } + + return $store; + } + + private function isAdminRequest(Request $request): bool + { + return $request->routeIs('admin.*') || $request->is('admin') || $request->is('admin/*'); + } +} diff --git a/app/Models/Concerns/BelongsToStore.php b/app/Models/Concerns/BelongsToStore.php new file mode 100644 index 00000000..28e2ce08 --- /dev/null +++ b/app/Models/Concerns/BelongsToStore.php @@ -0,0 +1,30 @@ +getAttribute('store_id') && app()->bound('current_store')) { + $model->setAttribute('store_id', app('current_store')->getKey()); + } + }); + } + + /** + * @return BelongsTo + */ + 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..ff458aa8 --- /dev/null +++ b/app/Models/Customer.php @@ -0,0 +1,72 @@ + */ + use BelongsToStore, HasFactory, Notifiable; + + protected $authPasswordName = 'password_hash'; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'name', + 'email', + 'password', + 'password_hash', + 'marketing_opt_in', + 'status', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'marketing_opt_in' => false, + 'status' => 'active', + ]; + + /** + * @var list + */ + protected $hidden = [ + 'password_hash', + 'remember_token', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'marketing_opt_in' => 'bool', + 'password_hash' => 'hashed', + ]; + } + + /** + * @return Attribute + */ + protected function password(): Attribute + { + return Attribute::make( + get: fn (mixed $value, array $attributes): ?string => $attributes['password_hash'] ?? null, + set: fn (?string $value): array => [ + 'password_hash' => $value && ! Hash::isHashed($value) ? Hash::make($value) : $value, + ], + ); + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php new file mode 100644 index 00000000..937745f0 --- /dev/null +++ b/app/Models/Organization.php @@ -0,0 +1,29 @@ + */ + use HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'name', + 'billing_email', + ]; + + /** + * @return HasMany + */ + public function stores(): HasMany + { + return $this->hasMany(Store::class); + } +} diff --git a/app/Models/Scopes/StoreScope.php b/app/Models/Scopes/StoreScope.php new file mode 100644 index 00000000..8af676fc --- /dev/null +++ b/app/Models/Scopes/StoreScope.php @@ -0,0 +1,25 @@ + $builder + */ + public function apply(Builder $builder, Model $model): void + { + if (! app()->bound('current_store')) { + return; + } + + $builder->where( + $model->qualifyColumn('store_id'), + app('current_store')->getKey(), + ); + } +} diff --git a/app/Models/Store.php b/app/Models/Store.php new file mode 100644 index 00000000..8c02d406 --- /dev/null +++ b/app/Models/Store.php @@ -0,0 +1,89 @@ + */ + use HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'organization_id', + 'name', + 'handle', + 'status', + 'default_currency', + 'default_locale', + 'timezone', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'status' => StoreStatus::Active->value, + 'default_currency' => 'USD', + 'default_locale' => 'en', + 'timezone' => 'UTC', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => StoreStatus::class, + ]; + } + + /** + * @return BelongsTo + */ + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } + + /** + * @return HasMany + */ + public function domains(): HasMany + { + return $this->hasMany(StoreDomain::class); + } + + /** + * @return BelongsToMany + */ + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'store_users') + ->using(StoreUser::class) + ->withPivot('role', 'created_at'); + } + + /** + * @return HasOne + */ + public function settings(): HasOne + { + return $this->hasOne(StoreSettings::class); + } + + public function isSuspended(): bool + { + return $this->status === StoreStatus::Suspended; + } +} diff --git a/app/Models/StoreDomain.php b/app/Models/StoreDomain.php new file mode 100644 index 00000000..60e7d580 --- /dev/null +++ b/app/Models/StoreDomain.php @@ -0,0 +1,55 @@ + */ + use HasFactory; + + public const UPDATED_AT = null; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'hostname', + 'type', + 'is_primary', + 'tls_mode', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => false, + 'tls_mode' => 'managed', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => StoreDomainType::class, + 'is_primary' => 'bool', + ]; + } + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/StoreSettings.php b/app/Models/StoreSettings.php new file mode 100644 index 00000000..11b71a1e --- /dev/null +++ b/app/Models/StoreSettings.php @@ -0,0 +1,52 @@ + */ + use HasFactory; + + public const CREATED_AT = null; + + protected $primaryKey = 'store_id'; + + public $incrementing = false; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'settings_json', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'settings_json' => '{}', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'settings_json' => 'array', + ]; + } + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/StoreUser.php b/app/Models/StoreUser.php new file mode 100644 index 00000000..a61eba49 --- /dev/null +++ b/app/Models/StoreUser.php @@ -0,0 +1,43 @@ + + */ + protected $fillable = [ + 'store_id', + 'user_id', + 'role', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'role' => StoreUserRole::Staff->value, + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'role' => StoreUserRole::class, + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 214bea4e..a216fa41 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,10 +2,14 @@ namespace App\Models; +use App\Enums\StoreUserRole; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; use Laravel\Fortify\TwoFactorAuthenticatable; @@ -14,6 +18,8 @@ class User extends Authenticatable /** @use HasFactory<\Database\Factories\UserFactory> */ use HasFactory, Notifiable, TwoFactorAuthenticatable; + protected $authPasswordName = 'password_hash'; + /** * The attributes that are mass assignable. * @@ -23,6 +29,16 @@ class User extends Authenticatable 'name', 'email', 'password', + 'password_hash', + 'status', + 'last_login_at', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'status' => 'active', ]; /** @@ -31,7 +47,7 @@ class User extends Authenticatable * @var list */ protected $hidden = [ - 'password', + 'password_hash', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token', @@ -46,10 +62,49 @@ protected function casts(): array { return [ 'email_verified_at' => 'datetime', - 'password' => 'hashed', + 'last_login_at' => 'datetime', + 'password_hash' => 'hashed', ]; } + /** + * @return Attribute + */ + protected function password(): Attribute + { + return Attribute::make( + get: fn (mixed $value, array $attributes): ?string => $attributes['password_hash'] ?? null, + set: fn (?string $value): array => [ + 'password_hash' => $value && ! Hash::isHashed($value) ? Hash::make($value) : $value, + ], + ); + } + + /** + * @return BelongsToMany + */ + public function stores(): BelongsToMany + { + return $this->belongsToMany(Store::class, 'store_users') + ->using(StoreUser::class) + ->withPivot('role', 'created_at'); + } + + public function roleForStore(Store $store): ?StoreUserRole + { + $role = $this->stores() + ->whereKey($store->getKey()) + ->first() + ?->pivot + ?->role; + + if ($role instanceof StoreUserRole) { + return $role; + } + + return is_string($role) ? StoreUserRole::tryFrom($role) : null; + } + /** * Get the user's initials */ diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8a29e6f5..4e200584 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,8 +3,11 @@ namespace App\Providers; use Carbon\CarbonImmutable; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; @@ -24,6 +27,7 @@ public function register(): void public function boot(): void { $this->configureDefaults(); + $this->configureRateLimiting(); } /** @@ -47,4 +51,11 @@ protected function configureDefaults(): void : null ); } + + protected function configureRateLimiting(): void + { + RateLimiter::for('api.storefront', function (Request $request) { + return Limit::perMinute(120)->by($request->ip()); + }); + } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 44e57aa0..e506c96f 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -8,7 +8,6 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; -use Illuminate\Support\Str; use Laravel\Fortify\Fortify; class FortifyServiceProvider extends ServiceProvider @@ -64,9 +63,7 @@ private function configureRateLimiting(): void }); RateLimiter::for('login', function (Request $request) { - $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip()); - - return Limit::perMinute(5)->by($throttleKey); + return Limit::perMinute(5)->by($request->ip()); }); } } diff --git a/app/Providers/ShopAuthServiceProvider.php b/app/Providers/ShopAuthServiceProvider.php new file mode 100644 index 00000000..57a0adf8 --- /dev/null +++ b/app/Providers/ShopAuthServiceProvider.php @@ -0,0 +1,28 @@ +withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'store.resolve' => App\Http\Middleware\ResolveStore::class, + ]); + + $middleware->appendToGroup('storefront', [ + App\Http\Middleware\ResolveStore::class, + ]); + + $middleware->appendToGroup('admin', [ + App\Http\Middleware\ResolveStore::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 0ad9c573..7da69287 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -3,4 +3,5 @@ return [ App\Providers\AppServiceProvider::class, App\Providers\FortifyServiceProvider::class, + App\Providers\ShopAuthServiceProvider::class, ]; diff --git a/config/auth.php b/config/auth.php index 7d1eb0de..33e9ba97 100644 --- a/config/auth.php +++ b/config/auth.php @@ -40,6 +40,11 @@ 'driver' => 'session', 'provider' => 'users', ], + + 'customer' => [ + 'driver' => 'session', + 'provider' => 'customers', + ], ], /* @@ -65,6 +70,11 @@ 'model' => env('AUTH_MODEL', App\Models\User::class), ], + 'customers' => [ + 'driver' => 'store_scoped_eloquent', + 'model' => App\Models\Customer::class, + ], + // 'users' => [ // 'driver' => 'database', // 'table' => 'users', @@ -97,6 +107,13 @@ 'expire' => 60, 'throttle' => 60, ], + + 'customers' => [ + 'provider' => 'customers', + 'table' => 'customer_password_reset_tokens', + 'expire' => 60, + 'throttle' => 60, + ], ], /* diff --git a/config/cache.php b/config/cache.php index b32aead2..9289977f 100644 --- a/config/cache.php +++ b/config/cache.php @@ -15,7 +15,7 @@ | */ - 'default' => env('CACHE_STORE', 'database'), + 'default' => env('CACHE_STORE', 'file'), /* |-------------------------------------------------------------------------- diff --git a/config/database.php b/config/database.php index df933e7f..210e1eac 100644 --- a/config/database.php +++ b/config/database.php @@ -37,9 +37,9 @@ 'database' => env('DB_DATABASE', database_path('database.sqlite')), 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), - 'busy_timeout' => null, - 'journal_mode' => null, - 'synchronous' => null, + 'busy_timeout' => 5000, + 'journal_mode' => 'WAL', + 'synchronous' => 'NORMAL', 'transaction_mode' => 'DEFERRED', ], diff --git a/config/logging.php b/config/logging.php index 9e998a49..e975cedb 100644 --- a/config/logging.php +++ b/config/logging.php @@ -65,6 +65,14 @@ 'replace_placeholders' => true, ], + 'structured' => [ + 'driver' => 'single', + 'path' => storage_path('logs/structured.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'formatter' => Monolog\Formatter\JsonFormatter::class, + 'replace_placeholders' => true, + ], + 'daily' => [ 'driver' => 'daily', 'path' => storage_path('logs/laravel.log'), diff --git a/config/queue.php b/config/queue.php index 79c2c0a2..d0e0f50e 100644 --- a/config/queue.php +++ b/config/queue.php @@ -13,7 +13,7 @@ | */ - 'default' => env('QUEUE_CONNECTION', 'database'), + 'default' => env('QUEUE_CONNECTION', 'sync'), /* |-------------------------------------------------------------------------- diff --git a/config/session.php b/config/session.php index 5b541b75..e6197a0f 100644 --- a/config/session.php +++ b/config/session.php @@ -18,7 +18,7 @@ | */ - 'driver' => env('SESSION_DRIVER', 'database'), + 'driver' => env('SESSION_DRIVER', 'file'), /* |-------------------------------------------------------------------------- diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php new file mode 100644 index 00000000..a991973b --- /dev/null +++ b/database/factories/OrganizationFactory.php @@ -0,0 +1,24 @@ + + */ +class OrganizationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->company(), + 'billing_email' => fake()->unique()->companyEmail(), + ]; + } +} diff --git a/database/factories/StoreDomainFactory.php b/database/factories/StoreDomainFactory.php new file mode 100644 index 00000000..821f9c0a --- /dev/null +++ b/database/factories/StoreDomainFactory.php @@ -0,0 +1,50 @@ + + */ +class StoreDomainFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'hostname' => fake()->unique()->domainName(), + 'type' => StoreDomainType::Storefront, + 'is_primary' => true, + 'tls_mode' => 'managed', + ]; + } + + public function admin(): static + { + return $this->state(fn (array $attributes): array => [ + 'type' => StoreDomainType::Admin, + ]); + } + + public function api(): static + { + return $this->state(fn (array $attributes): array => [ + 'type' => StoreDomainType::Api, + ]); + } + + public function secondary(): static + { + return $this->state(fn (array $attributes): array => [ + 'is_primary' => false, + ]); + } +} diff --git a/database/factories/StoreFactory.php b/database/factories/StoreFactory.php new file mode 100644 index 00000000..14e6ae11 --- /dev/null +++ b/database/factories/StoreFactory.php @@ -0,0 +1,41 @@ + + */ +class StoreFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $name = fake()->company().' Store'; + + return [ + 'organization_id' => Organization::factory(), + 'name' => $name, + 'handle' => Str::slug($name).'-'.fake()->unique()->numerify('###'), + 'status' => StoreStatus::Active, + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ]; + } + + public function suspended(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => StoreStatus::Suspended, + ]); + } +} diff --git a/database/factories/StoreSettingsFactory.php b/database/factories/StoreSettingsFactory.php new file mode 100644 index 00000000..e5951ad8 --- /dev/null +++ b/database/factories/StoreSettingsFactory.php @@ -0,0 +1,32 @@ + + */ +class StoreSettingsFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'settings_json' => [ + 'checkout' => [ + 'guest_checkout_enabled' => true, + ], + 'notifications' => [ + 'order_confirmation' => true, + ], + ], + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 80da5ac7..e12f4f5c 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -28,6 +28,8 @@ public function definition(): array 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), + 'status' => 'active', + 'last_login_at' => fake()->dateTimeBetween('-30 days'), 'remember_token' => Str::random(10), 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, @@ -45,6 +47,13 @@ public function unverified(): static ]); } + public function disabled(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => 'disabled', + ]); + } + /** * Indicate that the model has two-factor authentication configured. */ diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 05fb5d9e..62a0ebc2 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -13,12 +13,16 @@ public function up(): void { Schema::create('users', function (Blueprint $table) { $table->id(); - $table->string('name'); $table->string('email')->unique(); + $table->string('password_hash'); + $table->string('name'); + $table->enum('status', ['active', 'disabled'])->default('active'); $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); + $table->timestamp('last_login_at')->nullable(); $table->rememberToken(); $table->timestamps(); + + $table->index('status'); }); Schema::create('password_reset_tokens', function (Blueprint $table) { @@ -42,8 +46,8 @@ public function up(): void */ public function down(): void { - Schema::dropIfExists('users'); - Schema::dropIfExists('password_reset_tokens'); Schema::dropIfExists('sessions'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('users'); } }; diff --git a/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php b/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php index 187d974d..a008f488 100644 --- a/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php +++ b/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php @@ -12,7 +12,7 @@ public function up(): void { Schema::table('users', function (Blueprint $table) { - $table->text('two_factor_secret')->after('password')->nullable(); + $table->text('two_factor_secret')->after('password_hash')->nullable(); $table->text('two_factor_recovery_codes')->after('two_factor_secret')->nullable(); $table->timestamp('two_factor_confirmed_at')->after('two_factor_recovery_codes')->nullable(); }); diff --git a/database/migrations/2026_05_03_110830_create_organizations_table.php b/database/migrations/2026_05_03_110830_create_organizations_table.php new file mode 100644 index 00000000..1d1bf767 --- /dev/null +++ b/database/migrations/2026_05_03_110830_create_organizations_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name'); + $table->string('billing_email'); + $table->timestamps(); + + $table->index('billing_email'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('organizations'); + } +}; diff --git a/database/migrations/2026_05_03_110834_create_stores_table.php b/database/migrations/2026_05_03_110834_create_stores_table.php new file mode 100644 index 00000000..36c94477 --- /dev/null +++ b/database/migrations/2026_05_03_110834_create_stores_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('organization_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('handle')->unique(); + $table->enum('status', ['active', 'suspended'])->default('active'); + $table->string('default_currency', 3)->default('USD'); + $table->string('default_locale')->default('en'); + $table->string('timezone')->default('UTC'); + $table->timestamps(); + + $table->index('organization_id'); + $table->index('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('stores'); + } +}; diff --git a/database/migrations/2026_05_03_110837_create_store_domains_table.php b/database/migrations/2026_05_03_110837_create_store_domains_table.php new file mode 100644 index 00000000..3548e721 --- /dev/null +++ b/database/migrations/2026_05_03_110837_create_store_domains_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('hostname')->unique(); + $table->enum('type', ['storefront', 'admin', 'api'])->default('storefront'); + $table->boolean('is_primary')->default(false); + $table->enum('tls_mode', ['managed', 'bring_your_own'])->default('managed'); + $table->timestamp('created_at')->nullable(); + + $table->index('store_id'); + $table->index(['store_id', 'is_primary']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('store_domains'); + } +}; diff --git a/database/migrations/2026_05_03_110844_create_store_users_table.php b/database/migrations/2026_05_03_110844_create_store_users_table.php new file mode 100644 index 00000000..8d8e602c --- /dev/null +++ b/database/migrations/2026_05_03_110844_create_store_users_table.php @@ -0,0 +1,33 @@ +foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->enum('role', ['owner', 'admin', 'staff', 'support'])->default('staff'); + $table->timestamp('created_at')->nullable(); + + $table->primary(['store_id', 'user_id']); + $table->index('user_id'); + $table->index(['store_id', 'role']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('store_users'); + } +}; diff --git a/database/migrations/2026_05_03_110845_create_store_settings_table.php b/database/migrations/2026_05_03_110845_create_store_settings_table.php new file mode 100644 index 00000000..33fc0d5f --- /dev/null +++ b/database/migrations/2026_05_03_110845_create_store_settings_table.php @@ -0,0 +1,28 @@ +foreignId('store_id')->primary()->constrained()->cascadeOnDelete(); + $table->text('settings_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('store_settings'); + } +}; diff --git a/database/migrations/2026_05_03_111034_create_customer_password_reset_tokens_table.php b/database/migrations/2026_05_03_111034_create_customer_password_reset_tokens_table.php new file mode 100644 index 00000000..a9ff55e8 --- /dev/null +++ b/database/migrations/2026_05_03_111034_create_customer_password_reset_tokens_table.php @@ -0,0 +1,31 @@ +foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('email'); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + + $table->primary(['store_id', 'email']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('customer_password_reset_tokens'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef2..34aaa57f 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,8 +2,6 @@ namespace Database\Seeders; -use App\Models\User; -// use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder @@ -13,11 +11,13 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', + $this->call([ + OrganizationSeeder::class, + StoreSeeder::class, + StoreDomainSeeder::class, + UserSeeder::class, + StoreUserSeeder::class, + StoreSettingsSeeder::class, ]); } } diff --git a/database/seeders/OrganizationSeeder.php b/database/seeders/OrganizationSeeder.php new file mode 100644 index 00000000..ea361277 --- /dev/null +++ b/database/seeders/OrganizationSeeder.php @@ -0,0 +1,20 @@ +updateOrCreate( + ['billing_email' => 'billing@acme.test'], + ['name' => 'Acme Commerce'], + ); + } +} diff --git a/database/seeders/StoreDomainSeeder.php b/database/seeders/StoreDomainSeeder.php new file mode 100644 index 00000000..2085dcab --- /dev/null +++ b/database/seeders/StoreDomainSeeder.php @@ -0,0 +1,29 @@ +where('handle', 'acme-fashion')->firstOrFail(); + + StoreDomain::query()->updateOrCreate( + ['hostname' => 'shop.test'], + [ + 'store_id' => $store->id, + 'type' => StoreDomainType::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..885d2222 --- /dev/null +++ b/database/seeders/StoreSeeder.php @@ -0,0 +1,31 @@ +where('billing_email', 'billing@acme.test')->firstOrFail(); + + Store::query()->updateOrCreate( + ['handle' => 'acme-fashion'], + [ + 'organization_id' => $organization->id, + 'name' => 'Acme Fashion', + 'status' => StoreStatus::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..fbb569c0 --- /dev/null +++ b/database/seeders/StoreSettingsSeeder.php @@ -0,0 +1,32 @@ +where('handle', 'acme-fashion')->firstOrFail(); + + StoreSettings::query()->updateOrCreate( + ['store_id' => $store->id], + [ + 'settings_json' => [ + 'checkout' => [ + 'guest_checkout_enabled' => true, + ], + 'notifications' => [ + 'order_confirmation' => true, + ], + ], + ], + ); + } +} diff --git a/database/seeders/StoreUserSeeder.php b/database/seeders/StoreUserSeeder.php new file mode 100644 index 00000000..ff5e3ee8 --- /dev/null +++ b/database/seeders/StoreUserSeeder.php @@ -0,0 +1,32 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $user = User::query()->where('email', 'admin@example.com')->firstOrFail(); + + DB::table('store_users')->updateOrInsert( + [ + 'store_id' => $store->id, + 'user_id' => $user->id, + ], + [ + 'role' => StoreUserRole::Owner->value, + 'created_at' => now(), + ], + ); + } +} diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php new file mode 100644 index 00000000..09029cf0 --- /dev/null +++ b/database/seeders/UserSeeder.php @@ -0,0 +1,26 @@ +updateOrCreate( + ['email' => 'admin@example.com'], + [ + 'name' => 'Admin User', + 'password' => Hash::make('password'), + 'status' => 'active', + 'email_verified_at' => now(), + ], + ); + } +} diff --git a/specs/progress.md b/specs/progress.md new file mode 100644 index 00000000..27d65c04 --- /dev/null +++ b/specs/progress.md @@ -0,0 +1,69 @@ +# Shop Implementation Progress + +Last updated: 2026-05-03 + +## Objective + +Build the complete self-contained shop platform from `specs/*` and verify it with Pest plus browser smoke flows at `http://shop.test/`. + +## Current State + +- Repository started from the Laravel Livewire starter kit with Fortify authentication. +- Phase 1 foundation is partially implemented and verified: configuration defaults, core tenancy schema, models, factories, seeders, tenant middleware, customer guard provider registration, store role helper, and password_hash compatibility. +- No catalog, storefront shop UI, admin shop UI, cart, checkout, orders, search, analytics, apps, or webhooks are implemented yet. +- Phase 2 Catalog is the next active vertical slice after committing Phase 1 progress. + +## Execution Plan + +1. **Foundation and tenancy** - In progress + - Configure SQLite/file/sync environment defaults. + - Add organization/store/domain/settings schema, models, enums, factories, seeders, tenant middleware, store-scoped model support, customer guard baseline, and role-aware user helpers. + - Verify with focused tenancy/model/auth tests and `migrate:fresh --seed`. +2. **Catalog** - Pending + - Add products, variants, options, inventory, collections, media, catalog services, and storefront/admin catalog basics. + - Verify product lifecycle, inventory, variants, and collection isolation tests. +3. **Theme and storefront shell** - Pending + - Add themes, pages, navigation, storefront layout, reusable components, and initial storefront pages. + - Verify storefront render, navigation, product detail, accessibility smoke, and responsive browser checks. +4. **Cart, checkout, pricing** - Pending + - Add carts, checkout, discounts, shipping, taxes, pricing engine, state transitions, and REST endpoints. + - Verify cart API, cart UI, checkout state, pricing, discount, shipping, and tax tests. +5. **Payments and orders** - Pending + - Add customers, addresses, orders, payments, refunds, fulfillments, mock PSP, order events, and scheduled cleanup jobs. + - Verify successful card checkout, declined card, bank transfer pending/confirmation, fulfillment guard, refunds, and inventory commits/releases. +6. **Customer accounts** - Pending + - Add store-scoped customer auth, account dashboard, order history, and address book. + - Verify customer registration/login isolation and account browser flows. +7. **Admin panel** - Pending + - Add admin shell, dashboard, resource management pages, settings, themes, pages, navigation, analytics, apps, and developers surfaces. + - Verify admin login, store switching, product/order/discount/settings flows. +8. **Search, analytics, apps, webhooks** - Pending + - Add SQLite FTS5 search, analytics ingestion/aggregation, API token support, app installs, webhook dispatch/signing/delivery. + - Verify API, search, analytics, and webhook tests. +9. **Polish and completion audit** - Pending + - Run full Pest suite, style formatting, fresh migration/seeding, Playwright customer/admin flows, responsive and browser log review. + - Update this file with final evidence and close all gaps. + +## Decisions + +- Implement in vertical slices following `specs/09-IMPLEMENTATION-ROADMAP.md`. +- Use SQLite, file cache/session, sync queue, log mail, and local filesystem as specified. +- Keep all money as integer minor units. +- Prefer Laravel conventions and existing starter-kit patterns unless specs require a different shop-specific behavior. +- Phase 1 customer guard/provider is registered now, but `customers` persistence remains in the customer/order slice because the roadmap places the `customers` table in Phase 5. +- Admin users store credentials in `users.password_hash`; the `User` model keeps a `password` attribute alias so Fortify and starter-kit Livewire settings continue to work. + +## Open Gaps + +- Phase 1 still needs the resource policies listed in the roadmap, but most referenced resources do not exist until later phases. Policies will be added with their models to keep type hints and tests coherent. +- Catalog and all later shop phases remain unimplemented. +- API token requirements mention Sanctum, but the package is not currently installed. This remains an open dependency decision for the API/developers phase because dependencies must not be changed without approval. + +## Verification Log + +- 2026-05-03: `php artisan test --compact tests/Feature/Foundation/FoundationModelTest.php tests/Feature/Tenancy/TenantResolutionTest.php` passed, 7 tests / 14 assertions. +- 2026-05-03: `php artisan test --compact tests/Feature/Auth tests/Feature/Settings/PasswordUpdateTest.php tests/Feature/Settings/ProfileUpdateTest.php` passed, 25 tests / 62 assertions. +- 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed after creating missing `database/database.sqlite`. +- 2026-05-03: `vendor/bin/pint --dirty --format agent` passed and fixed import ordering. +- 2026-05-03: `php artisan test --compact` passed, 40 tests / 89 assertions. +- Pending: Playwright customer and admin browser flows. diff --git a/tests/Feature/Foundation/FoundationModelTest.php b/tests/Feature/Foundation/FoundationModelTest.php new file mode 100644 index 00000000..123035fb --- /dev/null +++ b/tests/Feature/Foundation/FoundationModelTest.php @@ -0,0 +1,47 @@ +create(); + $store = Store::factory()->for($organization)->create([ + 'status' => StoreStatus::Active, + ]); + $domain = StoreDomain::factory()->for($store)->create([ + 'type' => StoreDomainType::Storefront, + ]); + $settings = StoreSettings::factory()->for($store)->create([ + 'settings_json' => ['checkout' => ['guest_checkout_enabled' => true]], + ]); + + expect($organization->stores)->toHaveCount(1) + ->and($store->fresh()->organization->is($organization))->toBeTrue() + ->and($domain->fresh()->type)->toBe(StoreDomainType::Storefront) + ->and($domain->fresh()->is_primary)->toBeTrue() + ->and($settings->fresh()->settings_json)->toBe(['checkout' => ['guest_checkout_enabled' => true]]) + ->and($store->fresh()->status)->toBe(StoreStatus::Active); +}); + +test('users expose their store role through the pivot model', function () { + $store = Store::factory()->create(); + $user = User::factory()->create(); + + DB::table('store_users')->insert([ + 'store_id' => $store->id, + 'user_id' => $user->id, + 'role' => StoreUserRole::Owner->value, + 'created_at' => now(), + ]); + + expect($user->roleForStore($store))->toBe(StoreUserRole::Owner); +}); diff --git a/tests/Feature/Tenancy/TenantResolutionTest.php b/tests/Feature/Tenancy/TenantResolutionTest.php new file mode 100644 index 00000000..832894d5 --- /dev/null +++ b/tests/Feature/Tenancy/TenantResolutionTest.php @@ -0,0 +1,76 @@ +get('/tenant-probe', function () { + return app('current_store')->handle; + }); + + Route::middleware(['web', 'auth', 'admin'])->get('/admin/tenant-probe', function () { + return app('current_store')->handle; + }); +}); + +test('storefront routes resolve the current store from the request hostname', function () { + $store = Store::factory()->create(['handle' => 'acme']); + StoreDomain::factory()->for($store)->create(['hostname' => 'acme.test']); + + $this->get('http://acme.test/tenant-probe') + ->assertOk() + ->assertSee('acme'); +}); + +test('storefront routes return not found for unknown hostnames', function () { + $this->get('http://missing.test/tenant-probe') + ->assertNotFound(); +}); + +test('storefront routes reject suspended stores', function () { + $store = Store::factory()->create([ + 'status' => StoreStatus::Suspended, + ]); + StoreDomain::factory()->for($store)->create(['hostname' => 'suspended.test']); + + $this->get('http://suspended.test/tenant-probe') + ->assertStatus(503); +}); + +test('admin routes resolve the current store from the authenticated session', function () { + $store = Store::factory()->create(['handle' => 'admin-store']); + $user = User::factory()->create(); + + DB::table('store_users')->insert([ + 'store_id' => $store->id, + 'user_id' => $user->id, + 'role' => StoreUserRole::Admin->value, + 'created_at' => now(), + ]); + + $this->actingAs($user) + ->withSession(['current_store_id' => $store->id]) + ->get('/admin/tenant-probe') + ->assertOk() + ->assertSee('admin-store'); +}); + +test('admin routes reject stores outside the authenticated user membership', function () { + $store = Store::factory()->create(); + $user = User::factory()->create(); + + $this->actingAs($user) + ->withSession(['current_store_id' => $store->id]) + ->get('/admin/tenant-probe') + ->assertForbidden(); +}); From ea70780eb3f19f3fd07a8f97e80b6a38f2f9d8f4 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 3 May 2026 13:38:04 +0200 Subject: [PATCH 06/35] Build catalog backend --- app/Enums/CollectionStatus.php | 10 + app/Enums/InventoryPolicy.php | 9 + app/Enums/MediaStatus.php | 10 + app/Enums/MediaType.php | 9 + app/Enums/ProductStatus.php | 10 + app/Enums/VariantStatus.php | 9 + app/Events/ProductStatusChanged.php | 23 ++ .../InsufficientInventoryException.php | 7 + .../InvalidProductTransitionException.php | 7 + app/Jobs/ProcessMediaUpload.php | 53 +++++ app/Models/Collection.php | 54 +++++ app/Models/InventoryItem.php | 60 +++++ app/Models/Product.php | 84 +++++++ app/Models/ProductMedia.php | 61 +++++ app/Models/ProductOption.php | 48 ++++ app/Models/ProductOptionValue.php | 48 ++++ app/Models/ProductVariant.php | 97 ++++++++ app/Models/Store.php | 24 ++ app/Policies/CollectionPolicy.php | 66 ++++++ app/Policies/ProductPolicy.php | 71 ++++++ app/Services/InventoryService.php | 80 +++++++ app/Services/ProductService.php | 217 ++++++++++++++++++ app/Services/VariantMatrixService.php | 147 ++++++++++++ app/Support/HandleGenerator.php | 37 +++ database/factories/CollectionFactory.php | 47 ++++ database/factories/InventoryItemFactory.php | 57 +++++ database/factories/ProductFactory.php | 75 ++++++ database/factories/ProductMediaFactory.php | 51 ++++ database/factories/ProductOptionFactory.php | 26 +++ .../factories/ProductOptionValueFactory.php | 26 +++ database/factories/ProductVariantFactory.php | 65 ++++++ ...026_05_03_112155_create_products_table.php | 43 ++++ ...03_112156_create_product_options_table.php | 32 +++ ...157_create_product_option_values_table.php | 32 +++ ...3_112158_create_product_variants_table.php | 44 ++++ ...159_create_variant_option_values_table.php | 30 +++ ...03_112160_create_inventory_items_table.php | 33 +++ ..._05_03_112161_create_collections_table.php | 37 +++ ...12162_create_collection_products_table.php | 32 +++ ...5_03_112163_create_product_media_table.php | 41 ++++ database/seeders/CollectionSeeder.php | 32 +++ database/seeders/DatabaseSeeder.php | 2 + database/seeders/InventoryItemSeeder.php | 16 ++ database/seeders/ProductMediaSeeder.php | 16 ++ database/seeders/ProductOptionSeeder.php | 16 ++ database/seeders/ProductOptionValueSeeder.php | 16 ++ database/seeders/ProductSeeder.php | 145 ++++++++++++ database/seeders/ProductVariantSeeder.php | 16 ++ specs/progress.md | 19 +- tests/Feature/Products/CatalogModelTest.php | 48 ++++ .../Feature/Products/InventoryServiceTest.php | 53 +++++ tests/Feature/Products/ProductServiceTest.php | 91 ++++++++ 52 files changed, 2376 insertions(+), 6 deletions(-) create mode 100644 app/Enums/CollectionStatus.php create mode 100644 app/Enums/InventoryPolicy.php create mode 100644 app/Enums/MediaStatus.php create mode 100644 app/Enums/MediaType.php create mode 100644 app/Enums/ProductStatus.php create mode 100644 app/Enums/VariantStatus.php create mode 100644 app/Events/ProductStatusChanged.php create mode 100644 app/Exceptions/InsufficientInventoryException.php create mode 100644 app/Exceptions/InvalidProductTransitionException.php create mode 100644 app/Jobs/ProcessMediaUpload.php create mode 100644 app/Models/Collection.php create mode 100644 app/Models/InventoryItem.php create mode 100644 app/Models/Product.php create mode 100644 app/Models/ProductMedia.php create mode 100644 app/Models/ProductOption.php create mode 100644 app/Models/ProductOptionValue.php create mode 100644 app/Models/ProductVariant.php create mode 100644 app/Policies/CollectionPolicy.php create mode 100644 app/Policies/ProductPolicy.php create mode 100644 app/Services/InventoryService.php create mode 100644 app/Services/ProductService.php create mode 100644 app/Services/VariantMatrixService.php create mode 100644 app/Support/HandleGenerator.php create mode 100644 database/factories/CollectionFactory.php create mode 100644 database/factories/InventoryItemFactory.php create mode 100644 database/factories/ProductFactory.php create mode 100644 database/factories/ProductMediaFactory.php create mode 100644 database/factories/ProductOptionFactory.php create mode 100644 database/factories/ProductOptionValueFactory.php create mode 100644 database/factories/ProductVariantFactory.php create mode 100644 database/migrations/2026_05_03_112155_create_products_table.php create mode 100644 database/migrations/2026_05_03_112156_create_product_options_table.php create mode 100644 database/migrations/2026_05_03_112157_create_product_option_values_table.php create mode 100644 database/migrations/2026_05_03_112158_create_product_variants_table.php create mode 100644 database/migrations/2026_05_03_112159_create_variant_option_values_table.php create mode 100644 database/migrations/2026_05_03_112160_create_inventory_items_table.php create mode 100644 database/migrations/2026_05_03_112161_create_collections_table.php create mode 100644 database/migrations/2026_05_03_112162_create_collection_products_table.php create mode 100644 database/migrations/2026_05_03_112163_create_product_media_table.php create mode 100644 database/seeders/CollectionSeeder.php create mode 100644 database/seeders/InventoryItemSeeder.php create mode 100644 database/seeders/ProductMediaSeeder.php create mode 100644 database/seeders/ProductOptionSeeder.php create mode 100644 database/seeders/ProductOptionValueSeeder.php create mode 100644 database/seeders/ProductSeeder.php create mode 100644 database/seeders/ProductVariantSeeder.php create mode 100644 tests/Feature/Products/CatalogModelTest.php create mode 100644 tests/Feature/Products/InventoryServiceTest.php create mode 100644 tests/Feature/Products/ProductServiceTest.php diff --git a/app/Enums/CollectionStatus.php b/app/Enums/CollectionStatus.php new file mode 100644 index 00000000..aa9da513 --- /dev/null +++ b/app/Enums/CollectionStatus.php @@ -0,0 +1,10 @@ +exists($this->media->storage_key)) { + $this->media->forceFill(['status' => MediaStatus::Failed])->save(); + + return; + } + + $path = $disk->path($this->media->storage_key); + $metadata = [ + 'byte_size' => $disk->size($this->media->storage_key), + 'status' => MediaStatus::Ready, + ]; + + if ($this->media->type === MediaType::Image && ($size = @getimagesize($path))) { + $metadata['width'] = $size[0]; + $metadata['height'] = $size[1]; + $metadata['mime_type'] = $size['mime'] ?? $this->media->mime_type; + } + + $this->media->forceFill($metadata)->save(); + } catch (Throwable) { + $this->media->forceFill(['status' => MediaStatus::Failed])->save(); + } + } +} diff --git a/app/Models/Collection.php b/app/Models/Collection.php new file mode 100644 index 00000000..54e82e4a --- /dev/null +++ b/app/Models/Collection.php @@ -0,0 +1,54 @@ + */ + use BelongsToStore, HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'description_html', + 'type', + 'status', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'type' => 'manual', + 'status' => CollectionStatus::Active->value, + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => CollectionStatus::class, + ]; + } + + /** + * @return BelongsToMany + */ + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'collection_products') + ->withPivot('position'); + } +} diff --git a/app/Models/InventoryItem.php b/app/Models/InventoryItem.php new file mode 100644 index 00000000..ac74c211 --- /dev/null +++ b/app/Models/InventoryItem.php @@ -0,0 +1,60 @@ + */ + use BelongsToStore, HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'variant_id', + 'quantity_on_hand', + 'quantity_reserved', + 'policy', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny->value, + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'policy' => InventoryPolicy::class, + ]; + } + + /** + * @return BelongsTo + */ + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + public function availableQuantity(): int + { + return $this->quantity_on_hand - $this->quantity_reserved; + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 00000000..23e1ee76 --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,84 @@ + */ + use BelongsToStore, HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'status', + 'description_html', + 'vendor', + 'product_type', + 'tags', + 'published_at', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'status' => ProductStatus::Draft->value, + 'tags' => '[]', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => ProductStatus::class, + 'tags' => 'array', + 'published_at' => 'datetime', + ]; + } + + /** + * @return HasMany + */ + public function variants(): HasMany + { + return $this->hasMany(ProductVariant::class); + } + + /** + * @return HasMany + */ + public function options(): HasMany + { + return $this->hasMany(ProductOption::class); + } + + /** + * @return HasMany + */ + public function media(): HasMany + { + return $this->hasMany(ProductMedia::class); + } + + /** + * @return BelongsToMany + */ + 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..92244f18 --- /dev/null +++ b/app/Models/ProductMedia.php @@ -0,0 +1,61 @@ + */ + use HasFactory; + + public const UPDATED_AT = null; + + /** + * @var list + */ + protected $fillable = [ + 'product_id', + 'type', + 'storage_key', + 'alt_text', + 'width', + 'height', + 'mime_type', + 'byte_size', + 'position', + 'status', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'type' => MediaType::Image->value, + 'position' => 0, + 'status' => MediaStatus::Processing->value, + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => MediaType::class, + 'status' => MediaStatus::class, + ]; + } + + /** + * @return BelongsTo + */ + 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..986b32c9 --- /dev/null +++ b/app/Models/ProductOption.php @@ -0,0 +1,48 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'product_id', + 'name', + 'position', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'position' => 0, + ]; + + /** + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * @return HasMany + */ + public function values(): HasMany + { + return $this->hasMany(ProductOptionValue::class); + } +} diff --git a/app/Models/ProductOptionValue.php b/app/Models/ProductOptionValue.php new file mode 100644 index 00000000..e4eb3c9e --- /dev/null +++ b/app/Models/ProductOptionValue.php @@ -0,0 +1,48 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'product_option_id', + 'value', + 'position', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'position' => 0, + ]; + + /** + * @return BelongsTo + */ + public function option(): BelongsTo + { + return $this->belongsTo(ProductOption::class, 'product_option_id'); + } + + /** + * @return BelongsToMany + */ + public function variants(): BelongsToMany + { + return $this->belongsToMany(ProductVariant::class, 'variant_option_values', 'product_option_value_id', 'variant_id'); + } +} diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php new file mode 100644 index 00000000..09b35c7f --- /dev/null +++ b/app/Models/ProductVariant.php @@ -0,0 +1,97 @@ + */ + use HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'product_id', + 'sku', + 'barcode', + 'price_amount', + 'compare_at_amount', + 'currency', + 'weight_g', + 'requires_shipping', + 'is_default', + 'position', + 'status', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'price_amount' => 0, + 'currency' => 'USD', + 'requires_shipping' => true, + 'is_default' => false, + 'position' => 0, + 'status' => VariantStatus::Active->value, + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'requires_shipping' => 'bool', + 'is_default' => 'bool', + 'status' => VariantStatus::class, + ]; + } + + protected static function booted(): void + { + static::created(function (ProductVariant $variant): void { + $storeId = $variant->product()->withoutGlobalScopes()->value('store_id'); + + if (! $storeId) { + return; + } + + InventoryItem::withoutGlobalScopes()->firstOrCreate( + ['variant_id' => $variant->id], + ['store_id' => $storeId], + ); + }); + } + + /** + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * @return HasOne + */ + public function inventoryItem(): HasOne + { + return $this->hasOne(InventoryItem::class, 'variant_id'); + } + + /** + * @return BelongsToMany + */ + public function optionValues(): BelongsToMany + { + return $this->belongsToMany(ProductOptionValue::class, 'variant_option_values', 'variant_id', 'product_option_value_id'); + } +} diff --git a/app/Models/Store.php b/app/Models/Store.php index 8c02d406..f001b26a 100644 --- a/app/Models/Store.php +++ b/app/Models/Store.php @@ -82,6 +82,30 @@ public function settings(): HasOne return $this->hasOne(StoreSettings::class); } + /** + * @return HasMany + */ + public function products(): HasMany + { + return $this->hasMany(Product::class); + } + + /** + * @return HasMany + */ + public function collections(): HasMany + { + return $this->hasMany(Collection::class); + } + + /** + * @return HasMany + */ + public function inventoryItems(): HasMany + { + return $this->hasMany(InventoryItem::class); + } + public function isSuspended(): bool { return $this->status === StoreStatus::Suspended; diff --git a/app/Policies/CollectionPolicy.php b/app/Policies/CollectionPolicy.php new file mode 100644 index 00000000..a67952f8 --- /dev/null +++ b/app/Policies/CollectionPolicy.php @@ -0,0 +1,66 @@ +hasAnyRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function view(User $user, Collection $collection): bool + { + return $this->hasRole($user, $collection->store, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function create(User $user): bool + { + return $this->hasAnyRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function update(User $user, Collection $collection): bool + { + return $this->hasRole($user, $collection->store, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function delete(User $user, Collection $collection): bool + { + return $this->hasRole($user, $collection->store, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + public function restore(User $user, Collection $collection): bool + { + return $this->update($user, $collection); + } + + public function forceDelete(User $user, Collection $collection): bool + { + return $this->delete($user, $collection); + } + + /** + * @param list $roles + */ + private function hasRole(User $user, Store $store, array $roles): bool + { + return in_array($user->roleForStore($store), $roles, true); + } + + /** + * @param list $roles + */ + private function hasAnyRole(User $user, array $roles): bool + { + if (! app()->bound('current_store')) { + return false; + } + + return $this->hasRole($user, app('current_store'), $roles); + } +} diff --git a/app/Policies/ProductPolicy.php b/app/Policies/ProductPolicy.php new file mode 100644 index 00000000..62259f7a --- /dev/null +++ b/app/Policies/ProductPolicy.php @@ -0,0 +1,71 @@ +hasAnyRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function view(User $user, Product $product): bool + { + return $this->hasRole($user, $product->store, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function create(User $user): bool + { + return $this->hasAnyRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function update(User $user, Product $product): bool + { + return $this->hasRole($user, $product->store, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function delete(User $user, Product $product): bool + { + return $this->hasRole($user, $product->store, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + public function restore(User $user, Product $product): bool + { + return $this->update($user, $product); + } + + public function forceDelete(User $user, Product $product): bool + { + return $this->delete($user, $product); + } + + public function archive(User $user, Product $product): bool + { + return $this->delete($user, $product); + } + + /** + * @param list $roles + */ + private function hasRole(User $user, Store $store, array $roles): bool + { + return in_array($user->roleForStore($store), $roles, true); + } + + /** + * @param list $roles + */ + private function hasAnyRole(User $user, array $roles): bool + { + if (! app()->bound('current_store')) { + return false; + } + + return $this->hasRole($user, app('current_store'), $roles); + } +} diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php new file mode 100644 index 00000000..0286b36b --- /dev/null +++ b/app/Services/InventoryService.php @@ -0,0 +1,80 @@ +guardPositiveQuantity($quantity); + + return $item->policy === InventoryPolicy::Continue || $item->availableQuantity() >= $quantity; + } + + public function reserve(InventoryItem $item, int $quantity): void + { + $this->mutate($item, $quantity, function (InventoryItem $locked, int $quantity): void { + if (! $this->checkAvailability($locked, $quantity)) { + throw new InsufficientInventoryException('Insufficient inventory available.'); + } + + $locked->increment('quantity_reserved', $quantity); + }); + } + + public function release(InventoryItem $item, int $quantity): void + { + $this->mutate($item, $quantity, function (InventoryItem $locked, int $quantity): void { + $locked->forceFill([ + 'quantity_reserved' => max(0, $locked->quantity_reserved - $quantity), + ])->save(); + }); + } + + public function commit(InventoryItem $item, int $quantity): void + { + $this->mutate($item, $quantity, function (InventoryItem $locked, int $quantity): void { + $locked->forceFill([ + 'quantity_on_hand' => $locked->quantity_on_hand - $quantity, + 'quantity_reserved' => max(0, $locked->quantity_reserved - $quantity), + ])->save(); + }); + } + + public function restock(InventoryItem $item, int $quantity): void + { + $this->mutate($item, $quantity, function (InventoryItem $locked, int $quantity): void { + $locked->increment('quantity_on_hand', $quantity); + }); + } + + /** + * @param callable(InventoryItem, int): void $callback + */ + private function mutate(InventoryItem $item, int $quantity, callable $callback): void + { + $this->guardPositiveQuantity($quantity); + + DB::transaction(function () use ($item, $quantity, $callback): void { + $locked = InventoryItem::withoutGlobalScopes() + ->whereKey($item->getKey()) + ->lockForUpdate() + ->firstOrFail(); + + $callback($locked, $quantity); + }); + } + + private function guardPositiveQuantity(int $quantity): void + { + if ($quantity < 1) { + throw new InvalidArgumentException('Quantity must be greater than zero.'); + } + } +} diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php new file mode 100644 index 00000000..79a606a1 --- /dev/null +++ b/app/Services/ProductService.php @@ -0,0 +1,217 @@ + $data + */ + public function create(Store $store, array $data): Product + { + return DB::transaction(function () use ($store, $data): Product { + $product = Product::query()->create([ + ...Arr::only($data, [ + 'title', + 'status', + 'description_html', + 'vendor', + 'product_type', + 'tags', + 'published_at', + ]), + 'store_id' => $store->id, + 'handle' => $data['handle'] ?? $this->handleGenerator->generate($data['title'], (new Product)->getTable(), $store->id), + ]); + + $this->createOptions($product, $data['options'] ?? []); + $this->createVariants($product, $data['variants'] ?? []); + + if ($product->variants()->count() === 0) { + ProductVariant::query()->create([ + 'product_id' => $product->id, + 'price_amount' => (int) ($data['price_amount'] ?? 0), + 'currency' => $store->default_currency, + 'is_default' => true, + ]); + } + + if ($product->options()->exists()) { + $this->variantMatrixService->rebuildMatrix($product); + } + + return $product->refresh(); + }); + } + + /** + * @param array $data + */ + public function update(Product $product, array $data): Product + { + return DB::transaction(function () use ($product, $data): Product { + $payload = Arr::only($data, [ + 'title', + 'status', + 'description_html', + 'vendor', + 'product_type', + 'tags', + 'published_at', + ]); + + if (array_key_exists('handle', $data)) { + $payload['handle'] = $data['handle'] ?: $this->handleGenerator->generate( + $data['title'] ?? $product->title, + $product->getTable(), + $product->store_id, + $product->id, + ); + } + + $product->update($payload); + + return $product->refresh(); + }); + } + + public function transitionStatus(Product $product, ProductStatus $newStatus): void + { + $from = $product->status; + + if ($from === $newStatus) { + return; + } + + $this->assertTransitionAllowed($product, $newStatus); + + $product->forceFill([ + 'status' => $newStatus, + 'published_at' => $newStatus === ProductStatus::Active && ! $product->published_at + ? now() + : $product->published_at, + ])->save(); + + ProductStatusChanged::dispatch($product->refresh(), $from, $newStatus); + } + + public function delete(Product $product): void + { + if ($product->status !== ProductStatus::Draft || $this->hasOrderReferences($product)) { + throw new InvalidProductTransitionException('Only draft products without order references can be deleted.'); + } + + $product->delete(); + } + + private function assertTransitionAllowed(Product $product, ProductStatus $newStatus): void + { + if ($newStatus === ProductStatus::Active && ! $this->canActivate($product)) { + throw new InvalidProductTransitionException('Products need a title and a priced variant before activation.'); + } + + if ($newStatus === ProductStatus::Draft && $this->hasOrderReferences($product)) { + throw new InvalidProductTransitionException('Products with order references cannot be reverted to draft.'); + } + } + + private function canActivate(Product $product): bool + { + return trim($product->title) !== '' + && $product->variants() + ->where('status', VariantStatus::Active->value) + ->where('price_amount', '>', 0) + ->exists(); + } + + private function hasOrderReferences(Product $product): bool + { + if (! Schema::hasTable('order_lines')) { + return false; + } + + return DB::table('order_lines') + ->whereIn('variant_id', $product->variants()->withoutGlobalScopes()->pluck('id')) + ->orWhere('product_id', $product->id) + ->exists(); + } + + /** + * @param array}> $options + */ + private function createOptions(Product $product, array $options): void + { + foreach (array_values($options) as $optionIndex => $optionData) { + $option = $product->options()->create([ + 'name' => $optionData['name'], + 'position' => $optionIndex, + ]); + + foreach (array_values($optionData['values'] ?? []) as $valueIndex => $value) { + $option->values()->create([ + 'value' => $value, + 'position' => $valueIndex, + ]); + } + } + } + + /** + * @param array> $variants + */ + private function createVariants(Product $product, array $variants): void + { + foreach (array_values($variants) as $index => $variantData) { + $this->assertSkuIsUnique($product, $variantData['sku'] ?? null); + + $product->variants()->create([ + ...Arr::only($variantData, [ + 'sku', + 'barcode', + 'price_amount', + 'compare_at_amount', + 'currency', + 'weight_g', + 'requires_shipping', + 'is_default', + 'status', + ]), + 'position' => $variantData['position'] ?? $index, + 'currency' => $variantData['currency'] ?? $product->store->default_currency, + ]); + } + } + + private function assertSkuIsUnique(Product $product, ?string $sku): void + { + if (! $sku) { + return; + } + + $exists = ProductVariant::query() + ->where('sku', $sku) + ->whereHas('product', fn ($query) => $query->where('store_id', $product->store_id)) + ->exists(); + + if ($exists) { + throw new InvalidProductTransitionException('SKU already exists for this store.'); + } + } +} diff --git a/app/Services/VariantMatrixService.php b/app/Services/VariantMatrixService.php new file mode 100644 index 00000000..a360ef9a --- /dev/null +++ b/app/Services/VariantMatrixService.php @@ -0,0 +1,147 @@ +loadMissing('options.values', 'variants.optionValues', 'store'); + + $optionValueGroups = $product->options + ->sortBy('position') + ->map(fn ($option) => $option->values->sortBy('position')->values()) + ->values(); + + if ($optionValueGroups->isEmpty()) { + $this->ensureDefaultVariant($product); + + return; + } + + if ($optionValueGroups->contains(fn (Collection $values): bool => $values->isEmpty())) { + return; + } + + $desiredCombinations = $this->cartesian($optionValueGroups); + $desiredKeys = collect($desiredCombinations) + ->map(fn (array $combination): string => $this->keyFor($combination)) + ->all(); + + $existingVariants = $product->variants() + ->with('optionValues') + ->orderBy('position') + ->get(); + + $existingByKey = $existingVariants->keyBy( + fn (ProductVariant $variant): string => $this->keyFor($variant->optionValues->pluck('id')->all()) + ); + + foreach ($desiredCombinations as $index => $combination) { + $key = $this->keyFor($combination); + + if ($existingByKey->has($key)) { + continue; + } + + $variant = $this->createVariantForCombination($product, $existingVariants->first(), $index); + $variant->optionValues()->attach($combination); + } + + foreach ($existingVariants as $variant) { + $key = $this->keyFor($variant->optionValues->pluck('id')->all()); + + if (in_array($key, $desiredKeys, true)) { + continue; + } + + if ($this->hasOrderReferences($variant)) { + $variant->forceFill(['status' => VariantStatus::Archived])->save(); + + continue; + } + + $variant->delete(); + } + }); + } + + private function ensureDefaultVariant(Product $product): void + { + if ($product->variants()->exists()) { + return; + } + + $product->variants()->create([ + 'price_amount' => 0, + 'currency' => $product->store->default_currency, + 'is_default' => true, + ]); + } + + /** + * @param Collection> $groups + * @return array> + */ + private function cartesian(Collection $groups): array + { + $result = [[]]; + + foreach ($groups as $group) { + $append = []; + + foreach ($result as $product) { + foreach ($group as $item) { + $append[] = [...$product, $item->id]; + } + } + + $result = $append; + } + + return $result; + } + + /** + * @param array $ids + */ + private function keyFor(array $ids): string + { + sort($ids); + + return implode('-', $ids); + } + + private function createVariantForCombination(Product $product, ?ProductVariant $template, int $position): ProductVariant + { + return $product->variants()->create([ + 'sku' => null, + 'barcode' => null, + 'price_amount' => $template?->price_amount ?? 0, + 'compare_at_amount' => $template?->compare_at_amount, + 'currency' => $template?->currency ?? $product->store->default_currency, + 'weight_g' => $template?->weight_g, + 'requires_shipping' => $template?->requires_shipping ?? true, + 'is_default' => false, + 'position' => $position, + 'status' => VariantStatus::Active, + ]); + } + + private function hasOrderReferences(ProductVariant $variant): bool + { + if (! Schema::hasTable('order_lines')) { + return false; + } + + return DB::table('order_lines')->where('variant_id', $variant->id)->exists(); + } +} diff --git a/app/Support/HandleGenerator.php b/app/Support/HandleGenerator.php new file mode 100644 index 00000000..9ea7db15 --- /dev/null +++ b/app/Support/HandleGenerator.php @@ -0,0 +1,37 @@ +exists($table, $storeId, $candidate, $excludeId)) { + $candidate = $base.'-'.$suffix; + $suffix++; + } + + return $candidate; + } + + private function exists(string $table, int $storeId, string $handle, ?int $excludeId): bool + { + return DB::table($table) + ->where('store_id', $storeId) + ->where('handle', $handle) + ->when($excludeId, fn ($query) => $query->where('id', '!=', $excludeId)) + ->exists(); + } +} diff --git a/database/factories/CollectionFactory.php b/database/factories/CollectionFactory.php new file mode 100644 index 00000000..c5a9b730 --- /dev/null +++ b/database/factories/CollectionFactory.php @@ -0,0 +1,47 @@ + + */ +class CollectionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $title = Str::title(fake()->words(2, true)); + + return [ + 'store_id' => Store::factory(), + 'title' => $title, + 'handle' => Str::slug($title).'-'.fake()->unique()->numerify('####'), + 'description_html' => '

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

', + 'type' => 'manual', + 'status' => CollectionStatus::Active, + ]; + } + + public function draft(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => CollectionStatus::Draft, + ]); + } + + public function archived(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => CollectionStatus::Archived, + ]); + } +} diff --git a/database/factories/InventoryItemFactory.php b/database/factories/InventoryItemFactory.php new file mode 100644 index 00000000..7cbb2341 --- /dev/null +++ b/database/factories/InventoryItemFactory.php @@ -0,0 +1,57 @@ + + */ +class InventoryItemFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $store = Store::factory()->create(); + $product = Product::factory()->for($store)->create(); + $variant = ProductVariant::withoutEvents(fn () => ProductVariant::factory()->for($product)->create()); + + return [ + 'store_id' => $store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => fake()->numberBetween(0, 100), + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ]; + } + + public function outOfStock(): static + { + return $this->state(fn (array $attributes): array => [ + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + ]); + } + + public function continuePolicy(): static + { + return $this->state(fn (array $attributes): array => [ + 'policy' => InventoryPolicy::Continue, + ]); + } + + public function lowStock(): static + { + return $this->state(fn (array $attributes): array => [ + 'quantity_on_hand' => fake()->numberBetween(1, 3), + ]); + } +} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php new file mode 100644 index 00000000..7326e42b --- /dev/null +++ b/database/factories/ProductFactory.php @@ -0,0 +1,75 @@ + + */ +class ProductFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $title = Str::title(fake()->words(3, true)); + + return [ + 'store_id' => Store::factory(), + 'title' => $title, + 'handle' => Str::slug($title).'-'.fake()->unique()->numerify('####'), + 'status' => ProductStatus::Active, + 'description_html' => '

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

', + 'vendor' => fake()->company(), + 'product_type' => fake()->randomElement(['Shirts', 'Pants', 'Shoes', 'Accessories', 'Electronics', 'Books']), + 'tags' => fake()->randomElements(['new', 'sale', 'trending', 'popular', 'limited'], fake()->numberBetween(1, 3)), + 'published_at' => now(), + ]; + } + + public function draft(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => ProductStatus::Draft, + 'published_at' => null, + ]); + } + + public function archived(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => ProductStatus::Archived, + ]); + } + + public function withVariants(int $count = 1): static + { + return $this->afterCreating(function ($product) use ($count): void { + ProductVariant::factory() + ->count($count) + ->for($product) + ->create(['currency' => $product->store->default_currency]); + }); + } + + public function withDefaultVariant(int $price = 2499): static + { + return $this->afterCreating(function ($product) use ($price): void { + ProductVariant::factory() + ->default() + ->for($product) + ->create([ + 'price_amount' => $price, + 'currency' => $product->store->default_currency, + ]); + }); + } +} diff --git a/database/factories/ProductMediaFactory.php b/database/factories/ProductMediaFactory.php new file mode 100644 index 00000000..c5d300d2 --- /dev/null +++ b/database/factories/ProductMediaFactory.php @@ -0,0 +1,51 @@ + + */ +class ProductMediaFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'type' => MediaType::Image, + 'storage_key' => 'products/'.fake()->uuid().'.jpg', + 'alt_text' => fake()->sentence(4), + 'width' => 1200, + 'height' => 1200, + 'mime_type' => 'image/jpeg', + 'byte_size' => fake()->numberBetween(50_000, 500_000), + 'position' => 0, + 'status' => MediaStatus::Ready, + ]; + } + + public function processing(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => MediaStatus::Processing, + ]); + } + + public function video(): static + { + return $this->state(fn (array $attributes): array => [ + 'type' => MediaType::Video, + 'storage_key' => 'products/'.fake()->uuid().'.mp4', + 'mime_type' => 'video/mp4', + ]); + } +} diff --git a/database/factories/ProductOptionFactory.php b/database/factories/ProductOptionFactory.php new file mode 100644 index 00000000..a560784b --- /dev/null +++ b/database/factories/ProductOptionFactory.php @@ -0,0 +1,26 @@ + + */ +class ProductOptionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'name' => fake()->randomElement(['Size', 'Color', 'Material']), + 'position' => 0, + ]; + } +} diff --git a/database/factories/ProductOptionValueFactory.php b/database/factories/ProductOptionValueFactory.php new file mode 100644 index 00000000..be2734b9 --- /dev/null +++ b/database/factories/ProductOptionValueFactory.php @@ -0,0 +1,26 @@ + + */ +class ProductOptionValueFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'product_option_id' => ProductOption::factory(), + 'value' => fake()->randomElement(['Small', 'Medium', 'Large', 'Black', 'White', 'Cotton']), + 'position' => 0, + ]; + } +} diff --git a/database/factories/ProductVariantFactory.php b/database/factories/ProductVariantFactory.php new file mode 100644 index 00000000..c1134e89 --- /dev/null +++ b/database/factories/ProductVariantFactory.php @@ -0,0 +1,65 @@ + + */ +class ProductVariantFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'sku' => fake()->unique()->bothify('SKU-####-???'), + 'barcode' => fake()->ean13(), + 'price_amount' => fake()->numberBetween(999, 19999), + 'compare_at_amount' => null, + 'currency' => 'EUR', + 'weight_g' => fake()->numberBetween(100, 5000), + 'requires_shipping' => true, + 'is_default' => false, + 'position' => 0, + 'status' => VariantStatus::Active, + ]; + } + + public function onSale(): static + { + return $this->state(fn (array $attributes): array => [ + 'compare_at_amount' => fake()->numberBetween(20000, 39999), + 'price_amount' => fake()->numberBetween(9999, 19999), + ]); + } + + public function digital(): static + { + return $this->state(fn (array $attributes): array => [ + 'requires_shipping' => false, + 'weight_g' => 0, + ]); + } + + public function default(): static + { + return $this->state(fn (array $attributes): array => [ + 'is_default' => true, + ]); + } + + public function archived(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => VariantStatus::Archived, + ]); + } +} diff --git a/database/migrations/2026_05_03_112155_create_products_table.php b/database/migrations/2026_05_03_112155_create_products_table.php new file mode 100644 index 00000000..e797c38a --- /dev/null +++ b/database/migrations/2026_05_03_112155_create_products_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->enum('status', ['draft', 'active', 'archived'])->default('draft'); + $table->text('description_html')->nullable(); + $table->string('vendor')->nullable(); + $table->string('product_type')->nullable(); + $table->text('tags')->default('[]'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'handle']); + $table->index('store_id'); + $table->index(['store_id', 'status']); + $table->index(['store_id', 'published_at']); + $table->index(['store_id', 'vendor']); + $table->index(['store_id', 'product_type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('products'); + } +}; diff --git a/database/migrations/2026_05_03_112156_create_product_options_table.php b/database/migrations/2026_05_03_112156_create_product_options_table.php new file mode 100644 index 00000000..91fcbb0a --- /dev/null +++ b/database/migrations/2026_05_03_112156_create_product_options_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->integer('position')->default(0); + + $table->index('product_id'); + $table->unique(['product_id', 'position']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_options'); + } +}; diff --git a/database/migrations/2026_05_03_112157_create_product_option_values_table.php b/database/migrations/2026_05_03_112157_create_product_option_values_table.php new file mode 100644 index 00000000..2ba4217f --- /dev/null +++ b/database/migrations/2026_05_03_112157_create_product_option_values_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('product_option_id')->constrained()->cascadeOnDelete(); + $table->string('value'); + $table->integer('position')->default(0); + + $table->index('product_option_id'); + $table->unique(['product_option_id', 'position']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_option_values'); + } +}; diff --git a/database/migrations/2026_05_03_112158_create_product_variants_table.php b/database/migrations/2026_05_03_112158_create_product_variants_table.php new file mode 100644 index 00000000..fe0e6390 --- /dev/null +++ b/database/migrations/2026_05_03_112158_create_product_variants_table.php @@ -0,0 +1,44 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('sku')->nullable(); + $table->string('barcode')->nullable(); + $table->integer('price_amount')->default(0); + $table->integer('compare_at_amount')->nullable(); + $table->string('currency', 3)->default('USD'); + $table->integer('weight_g')->nullable(); + $table->boolean('requires_shipping')->default(true); + $table->boolean('is_default')->default(false); + $table->integer('position')->default(0); + $table->enum('status', ['active', 'archived'])->default('active'); + $table->timestamps(); + + $table->index('product_id'); + $table->index('sku'); + $table->index('barcode'); + $table->index(['product_id', 'position']); + $table->index(['product_id', 'is_default']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_variants'); + } +}; diff --git a/database/migrations/2026_05_03_112159_create_variant_option_values_table.php b/database/migrations/2026_05_03_112159_create_variant_option_values_table.php new file mode 100644 index 00000000..034422bd --- /dev/null +++ b/database/migrations/2026_05_03_112159_create_variant_option_values_table.php @@ -0,0 +1,30 @@ +foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->foreignId('product_option_value_id')->constrained()->cascadeOnDelete(); + + $table->primary(['variant_id', 'product_option_value_id']); + $table->index('product_option_value_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('variant_option_values'); + } +}; diff --git a/database/migrations/2026_05_03_112160_create_inventory_items_table.php b/database/migrations/2026_05_03_112160_create_inventory_items_table.php new file mode 100644 index 00000000..bbd2d75a --- /dev/null +++ b/database/migrations/2026_05_03_112160_create_inventory_items_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('variant_id')->unique()->constrained('product_variants')->cascadeOnDelete(); + $table->integer('quantity_on_hand')->default(0); + $table->integer('quantity_reserved')->default(0); + $table->enum('policy', ['deny', 'continue'])->default('deny'); + + $table->index('store_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('inventory_items'); + } +}; diff --git a/database/migrations/2026_05_03_112161_create_collections_table.php b/database/migrations/2026_05_03_112161_create_collections_table.php new file mode 100644 index 00000000..db3c3c92 --- /dev/null +++ b/database/migrations/2026_05_03_112161_create_collections_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->text('description_html')->nullable(); + $table->enum('type', ['manual', 'automated'])->default('manual'); + $table->enum('status', ['draft', 'active', 'archived'])->default('active'); + $table->timestamps(); + + $table->unique(['store_id', 'handle']); + $table->index('store_id'); + $table->index(['store_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('collections'); + } +}; diff --git a/database/migrations/2026_05_03_112162_create_collection_products_table.php b/database/migrations/2026_05_03_112162_create_collection_products_table.php new file mode 100644 index 00000000..39714cdf --- /dev/null +++ b/database/migrations/2026_05_03_112162_create_collection_products_table.php @@ -0,0 +1,32 @@ +foreignId('collection_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->integer('position')->default(0); + + $table->primary(['collection_id', 'product_id']); + $table->index('product_id'); + $table->index(['collection_id', 'position']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('collection_products'); + } +}; diff --git a/database/migrations/2026_05_03_112163_create_product_media_table.php b/database/migrations/2026_05_03_112163_create_product_media_table.php new file mode 100644 index 00000000..fb05faec --- /dev/null +++ b/database/migrations/2026_05_03_112163_create_product_media_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->enum('type', ['image', 'video'])->default('image'); + $table->string('storage_key'); + $table->string('alt_text')->nullable(); + $table->integer('width')->nullable(); + $table->integer('height')->nullable(); + $table->string('mime_type')->nullable(); + $table->integer('byte_size')->nullable(); + $table->integer('position')->default(0); + $table->enum('status', ['processing', 'ready', 'failed'])->default('processing'); + $table->timestamp('created_at')->nullable(); + + $table->index('product_id'); + $table->index(['product_id', 'position']); + $table->index('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_media'); + } +}; diff --git a/database/seeders/CollectionSeeder.php b/database/seeders/CollectionSeeder.php new file mode 100644 index 00000000..a278835b --- /dev/null +++ b/database/seeders/CollectionSeeder.php @@ -0,0 +1,32 @@ +where('handle', 'acme-fashion')->firstOrFail(); + + Collection::query()->updateOrCreate( + [ + 'store_id' => $store->id, + 'handle' => 'summer-essentials', + ], + [ + 'title' => 'Summer Essentials', + 'description_html' => '

Lightweight staples for warm days.

', + 'type' => 'manual', + 'status' => CollectionStatus::Active, + ], + ); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 34aaa57f..f6328615 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -18,6 +18,8 @@ public function run(): void UserSeeder::class, StoreUserSeeder::class, StoreSettingsSeeder::class, + CollectionSeeder::class, + ProductSeeder::class, ]); } } diff --git a/database/seeders/InventoryItemSeeder.php b/database/seeders/InventoryItemSeeder.php new file mode 100644 index 00000000..6aa07f65 --- /dev/null +++ b/database/seeders/InventoryItemSeeder.php @@ -0,0 +1,16 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $collection = Collection::query() + ->where('store_id', $store->id) + ->where('handle', 'summer-essentials') + ->firstOrFail(); + + $shirt = Product::query()->updateOrCreate( + [ + 'store_id' => $store->id, + 'handle' => 'linen-shirt', + ], + [ + 'title' => 'Linen Shirt', + 'status' => ProductStatus::Active, + 'description_html' => '

A breathable linen shirt with a relaxed fit.

', + 'vendor' => 'Acme Apparel', + 'product_type' => 'Shirts', + 'tags' => ['new', 'summer'], + 'published_at' => now(), + ], + ); + + $variant = ProductVariant::query()->updateOrCreate( + [ + 'product_id' => $shirt->id, + 'sku' => 'LINEN-SHIRT-DEFAULT', + ], + [ + 'price_amount' => 4999, + 'currency' => $store->default_currency, + 'requires_shipping' => true, + 'is_default' => true, + 'position' => 0, + 'status' => VariantStatus::Active, + ], + ); + + $variant->inventoryItem()->withoutGlobalScopes()->updateOrCreate( + ['variant_id' => $variant->id], + [ + 'store_id' => $store->id, + 'quantity_on_hand' => 50, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ], + ); + + $tee = Product::query()->updateOrCreate( + [ + 'store_id' => $store->id, + 'handle' => 'logo-tee', + ], + [ + 'title' => 'Logo Tee', + 'status' => ProductStatus::Active, + 'description_html' => '

Soft cotton tee with the Acme mark.

', + 'vendor' => 'Acme Apparel', + 'product_type' => 'Shirts', + 'tags' => ['popular'], + 'published_at' => now(), + ], + ); + + $sizeOption = ProductOption::query()->updateOrCreate( + [ + 'product_id' => $tee->id, + 'position' => 0, + ], + ['name' => 'Size'], + ); + + foreach (['S', 'M', 'L'] as $position => $value) { + $sizeOption->values()->updateOrCreate( + ['position' => $position], + ['value' => $value], + ); + } + + ProductVariant::query()->firstOrCreate( + [ + 'product_id' => $tee->id, + 'sku' => 'LOGO-TEE-TEMPLATE', + ], + [ + 'price_amount' => 2999, + 'currency' => $store->default_currency, + 'is_default' => true, + 'position' => 0, + 'status' => VariantStatus::Active, + ], + ); + + app(VariantMatrixService::class)->rebuildMatrix($tee->refresh()); + + $tee->variants()->with('inventoryItem')->get()->each(function (ProductVariant $variant) use ($store): void { + $variant->inventoryItem()->withoutGlobalScopes()->updateOrCreate( + ['variant_id' => $variant->id], + [ + 'store_id' => $store->id, + 'quantity_on_hand' => 30, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ], + ); + }); + + DB::table('collection_products')->updateOrInsert( + [ + 'collection_id' => $collection->id, + 'product_id' => $shirt->id, + ], + ['position' => 0], + ); + + DB::table('collection_products')->updateOrInsert( + [ + 'collection_id' => $collection->id, + 'product_id' => $tee->id, + ], + ['position' => 1], + ); + } +} diff --git a/database/seeders/ProductVariantSeeder.php b/database/seeders/ProductVariantSeeder.php new file mode 100644 index 00000000..da732dd0 --- /dev/null +++ b/database/seeders/ProductVariantSeeder.php @@ -0,0 +1,16 @@ +create(); + $product = Product::factory()->for($store)->create(['tags' => ['summer', 'sale']]); + $option = ProductOption::factory()->for($product)->create(['name' => 'Size']); + $small = $option->values()->create(['value' => 'S', 'position' => 0]); + $variant = ProductVariant::factory()->for($product)->default()->create(['price_amount' => 2499]); + $variant->optionValues()->attach($small); + $collection = Collection::factory()->for($store)->create(); + $media = ProductMedia::factory()->for($product)->processing()->create(); + + DB::table('collection_products')->insert([ + 'collection_id' => $collection->id, + 'product_id' => $product->id, + 'position' => 0, + ]); + + expect($product->fresh()->tags)->toBe(['summer', 'sale']) + ->and($product->variants)->toHaveCount(1) + ->and($variant->fresh()->inventoryItem)->not->toBeNull() + ->and($variant->fresh()->optionValues)->toHaveCount(1) + ->and($collection->fresh()->products)->toHaveCount(1) + ->and($media->fresh()->status)->toBe(MediaStatus::Processing); +}); + +test('store scoped catalog queries only return the current store records', function () { + $currentStore = Store::factory()->create(); + $otherStore = Store::factory()->create(); + + Product::factory()->for($currentStore)->create(['title' => 'Visible']); + Product::factory()->for($otherStore)->create(['title' => 'Hidden']); + + app()->instance('current_store', $currentStore); + + expect(Product::query()->pluck('title')->all())->toBe(['Visible']); +}); diff --git a/tests/Feature/Products/InventoryServiceTest.php b/tests/Feature/Products/InventoryServiceTest.php new file mode 100644 index 00000000..574de617 --- /dev/null +++ b/tests/Feature/Products/InventoryServiceTest.php @@ -0,0 +1,53 @@ +create([ + 'quantity_on_hand' => 10, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ]); + $service = app(InventoryService::class); + + $service->reserve($item, 4); + expect($item->refresh()->quantity_reserved)->toBe(4) + ->and($item->availableQuantity())->toBe(6); + + $service->release($item, 1); + expect($item->refresh()->quantity_reserved)->toBe(3); + + $service->commit($item, 3); + expect($item->refresh()->quantity_on_hand)->toBe(7) + ->and($item->quantity_reserved)->toBe(0); + + $service->restock($item, 2); + expect($item->refresh()->quantity_on_hand)->toBe(9); +}); + +test('deny policy blocks reservations above available inventory', function () { + $item = InventoryItem::factory()->create([ + 'quantity_on_hand' => 1, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ]); + + expect(fn () => app(InventoryService::class)->reserve($item, 2)) + ->toThrow(InsufficientInventoryException::class); +}); + +test('continue policy allows reservations above available inventory', function () { + $item = InventoryItem::factory()->continuePolicy()->create([ + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + ]); + + app(InventoryService::class)->reserve($item, 2); + + expect($item->refresh()->quantity_reserved)->toBe(2); +}); diff --git a/tests/Feature/Products/ProductServiceTest.php b/tests/Feature/Products/ProductServiceTest.php new file mode 100644 index 00000000..855b2e81 --- /dev/null +++ b/tests/Feature/Products/ProductServiceTest.php @@ -0,0 +1,91 @@ +create(['default_currency' => 'EUR']); + + $product = app(ProductService::class)->create($store, [ + 'title' => 'Linen Shirt', + 'price_amount' => 4999, + ]); + + expect($product->handle)->toBe('linen-shirt') + ->and($product->variants)->toHaveCount(1) + ->and($product->variants->first()->price_amount)->toBe(4999) + ->and($product->variants->first()->currency)->toBe('EUR') + ->and($product->variants->first()->inventoryItem)->not->toBeNull(); +}); + +test('product service generates colliding handles with suffixes and rejects duplicate skus per store', function () { + $store = Store::factory()->create(); + + app(ProductService::class)->create($store, [ + 'title' => 'Logo Tee', + 'variants' => [ + ['sku' => 'LOGO-TEE', 'price_amount' => 1000], + ], + ]); + + $second = app(ProductService::class)->create($store, [ + 'title' => 'Logo Tee', + 'price_amount' => 2000, + ]); + + expect($second->handle)->toBe('logo-tee-1'); + + expect(fn () => app(ProductService::class)->create($store, [ + 'title' => 'Another Tee', + 'variants' => [ + ['sku' => 'LOGO-TEE', 'price_amount' => 1500], + ], + ]))->toThrow(InvalidProductTransitionException::class); +}); + +test('product status transitions require an active priced variant', function () { + $product = Product::factory()->draft()->create(['title' => 'Draft Product']); + ProductVariant::factory()->for($product)->default()->create(['price_amount' => 0]); + + expect(fn () => app(ProductService::class)->transitionStatus($product, ProductStatus::Active)) + ->toThrow(InvalidProductTransitionException::class); + + Event::fake(); + + $product->variants()->first()->update(['price_amount' => 1000]); + + app(ProductService::class)->transitionStatus($product->refresh(), ProductStatus::Active); + + expect($product->refresh()->status)->toBe(ProductStatus::Active) + ->and($product->published_at)->not->toBeNull(); + + Event::assertDispatched(ProductStatusChanged::class); +}); + +test('variant matrix rebuild creates combinations and removes orphan variants', function () { + $product = Product::factory()->create(); + $template = ProductVariant::factory()->for($product)->default()->create(['price_amount' => 1999]); + $option = ProductOption::factory()->for($product)->create(['name' => 'Size']); + + $small = $option->values()->create(['value' => 'S', 'position' => 0]); + $medium = $option->values()->create(['value' => 'M', 'position' => 1]); + + app(VariantMatrixService::class)->rebuildMatrix($product); + + $variants = $product->variants()->with('optionValues')->get(); + + expect($variants)->toHaveCount(2) + ->and($variants->pluck('price_amount')->all())->toBe([1999, 1999]) + ->and($variants->flatMap->optionValues->pluck('id')->sort()->values()->all())->toBe([$small->id, $medium->id]) + ->and(ProductVariant::query()->whereKey($template->id)->exists())->toBeFalse(); +}); From 58879dde9350fc91e58b1080e62e3fc0b00932c4 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 3 May 2026 13:51:57 +0200 Subject: [PATCH 07/35] Build storefront theme shell --- app/Enums/NavigationItemType.php | 11 ++ app/Enums/PageStatus.php | 10 ++ app/Enums/ThemeStatus.php | 9 ++ app/Livewire/Storefront/Cart/Show.php | 17 +++ app/Livewire/Storefront/CartDrawer.php | 14 +++ app/Livewire/Storefront/Collections/Index.php | 23 ++++ app/Livewire/Storefront/Collections/Show.php | 38 +++++++ app/Livewire/Storefront/Home.php | 38 +++++++ app/Livewire/Storefront/Pages/Show.php | 32 ++++++ app/Livewire/Storefront/Products/Show.php | 46 ++++++++ app/Livewire/Storefront/Search/Index.php | 41 +++++++ app/Models/NavigationItem.php | 54 +++++++++ app/Models/NavigationMenu.php | 31 +++++ app/Models/Page.php | 44 +++++++ app/Models/Store.php | 24 ++++ app/Models/Theme.php | 61 ++++++++++ app/Models/ThemeFile.php | 41 +++++++ app/Models/ThemeSettings.php | 52 +++++++++ app/Providers/AppServiceProvider.php | 3 +- app/Services/NavigationService.php | 64 +++++++++++ app/Services/ThemeSettingsService.php | 51 +++++++++ database/factories/NavigationItemFactory.php | 30 +++++ database/factories/NavigationMenuFactory.php | 26 +++++ database/factories/PageFactory.php | 41 +++++++ database/factories/ThemeFactory.php | 37 ++++++ database/factories/ThemeFileFactory.php | 30 +++++ database/factories/ThemeSettingsFactory.php | 40 +++++++ .../2026_05_03_113836_create_themes_table.php | 35 ++++++ ..._05_03_113837_create_theme_files_table.php | 34 ++++++ ..._03_113838_create_theme_settings_table.php | 28 +++++ .../2026_05_03_113839_create_pages_table.php | 37 ++++++ ...3_113840_create_navigation_menus_table.php | 33 ++++++ ...3_113841_create_navigation_items_table.php | 35 ++++++ database/seeders/DatabaseSeeder.php | 3 + database/seeders/NavigationItemSeeder.php | 16 +++ database/seeders/NavigationMenuSeeder.php | 16 +++ database/seeders/NavigationSeeder.php | 90 +++++++++++++++ database/seeders/PageSeeder.php | 32 ++++++ database/seeders/ThemeFileSeeder.php | 16 +++ database/seeders/ThemeSeeder.php | 54 +++++++++ database/seeders/ThemeSettingsSeeder.php | 16 +++ .../livewire/storefront/cart-drawer.blade.php | 3 + .../livewire/storefront/cart/show.blade.php | 9 ++ .../storefront/collections/index.blade.php | 11 ++ .../storefront/collections/show.blade.php | 13 +++ .../views/livewire/storefront/home.blade.php | 51 +++++++++ .../livewire/storefront/pages/show.blade.php | 6 + .../storefront/products/show.blade.php | 44 +++++++ .../storefront/search/index.blade.php | 17 +++ .../storefront/components/price.blade.php | 3 + .../components/product-card.blade.php | 28 +++++ .../views/storefront/layouts/app.blade.php | 107 ++++++++++++++++++ routes/web.php | 19 +++- specs/progress.md | 21 +++- tests/Feature/ExampleTest.php | 12 +- .../Storefront/StorefrontRenderTest.php | 41 +++++++ .../Storefront/ThemeNavigationTest.php | 33 ++++++ 57 files changed, 1758 insertions(+), 13 deletions(-) create mode 100644 app/Enums/NavigationItemType.php create mode 100644 app/Enums/PageStatus.php create mode 100644 app/Enums/ThemeStatus.php create mode 100644 app/Livewire/Storefront/Cart/Show.php create mode 100644 app/Livewire/Storefront/CartDrawer.php create mode 100644 app/Livewire/Storefront/Collections/Index.php create mode 100644 app/Livewire/Storefront/Collections/Show.php create mode 100644 app/Livewire/Storefront/Home.php create mode 100644 app/Livewire/Storefront/Pages/Show.php create mode 100644 app/Livewire/Storefront/Products/Show.php create mode 100644 app/Livewire/Storefront/Search/Index.php create mode 100644 app/Models/NavigationItem.php create mode 100644 app/Models/NavigationMenu.php create mode 100644 app/Models/Page.php create mode 100644 app/Models/Theme.php create mode 100644 app/Models/ThemeFile.php create mode 100644 app/Models/ThemeSettings.php create mode 100644 app/Services/NavigationService.php create mode 100644 app/Services/ThemeSettingsService.php create mode 100644 database/factories/NavigationItemFactory.php create mode 100644 database/factories/NavigationMenuFactory.php create mode 100644 database/factories/PageFactory.php create mode 100644 database/factories/ThemeFactory.php create mode 100644 database/factories/ThemeFileFactory.php create mode 100644 database/factories/ThemeSettingsFactory.php create mode 100644 database/migrations/2026_05_03_113836_create_themes_table.php create mode 100644 database/migrations/2026_05_03_113837_create_theme_files_table.php create mode 100644 database/migrations/2026_05_03_113838_create_theme_settings_table.php create mode 100644 database/migrations/2026_05_03_113839_create_pages_table.php create mode 100644 database/migrations/2026_05_03_113840_create_navigation_menus_table.php create mode 100644 database/migrations/2026_05_03_113841_create_navigation_items_table.php create mode 100644 database/seeders/NavigationItemSeeder.php create mode 100644 database/seeders/NavigationMenuSeeder.php create mode 100644 database/seeders/NavigationSeeder.php create mode 100644 database/seeders/PageSeeder.php create mode 100644 database/seeders/ThemeFileSeeder.php create mode 100644 database/seeders/ThemeSeeder.php create mode 100644 database/seeders/ThemeSettingsSeeder.php create mode 100644 resources/views/livewire/storefront/cart-drawer.blade.php create mode 100644 resources/views/livewire/storefront/cart/show.blade.php create mode 100644 resources/views/livewire/storefront/collections/index.blade.php create mode 100644 resources/views/livewire/storefront/collections/show.blade.php create mode 100644 resources/views/livewire/storefront/home.blade.php create mode 100644 resources/views/livewire/storefront/pages/show.blade.php create mode 100644 resources/views/livewire/storefront/products/show.blade.php create mode 100644 resources/views/livewire/storefront/search/index.blade.php create mode 100644 resources/views/storefront/components/price.blade.php create mode 100644 resources/views/storefront/components/product-card.blade.php create mode 100644 resources/views/storefront/layouts/app.blade.php create mode 100644 tests/Feature/Storefront/StorefrontRenderTest.php create mode 100644 tests/Feature/Storefront/ThemeNavigationTest.php diff --git a/app/Enums/NavigationItemType.php b/app/Enums/NavigationItemType.php new file mode 100644 index 00000000..cb39d0b0 --- /dev/null +++ b/app/Enums/NavigationItemType.php @@ -0,0 +1,11 @@ +layout('storefront.layouts.app', [ + 'title' => 'Cart', + ]); + } +} diff --git a/app/Livewire/Storefront/CartDrawer.php b/app/Livewire/Storefront/CartDrawer.php new file mode 100644 index 00000000..d2e4c86e --- /dev/null +++ b/app/Livewire/Storefront/CartDrawer.php @@ -0,0 +1,14 @@ + Collection::query() + ->where('status', CollectionStatus::Active) + ->orderBy('title') + ->get(), + ])->layout('storefront.layouts.app', [ + 'title' => 'Collections', + ]); + } +} diff --git a/app/Livewire/Storefront/Collections/Show.php b/app/Livewire/Storefront/Collections/Show.php new file mode 100644 index 00000000..c9091d3a --- /dev/null +++ b/app/Livewire/Storefront/Collections/Show.php @@ -0,0 +1,38 @@ +handle = $handle; + } + + public function render(): View + { + $collection = Collection::query() + ->with(['products' => fn ($query) => $query + ->with('variants', 'media') + ->where('status', ProductStatus::Active) + ->whereNotNull('published_at') + ->orderBy('collection_products.position')]) + ->where('handle', $this->handle) + ->where('status', CollectionStatus::Active) + ->firstOrFail(); + + return view('livewire.storefront.collections.show', [ + 'collection' => $collection, + ])->layout('storefront.layouts.app', [ + 'title' => $collection->title, + ]); + } +} diff --git a/app/Livewire/Storefront/Home.php b/app/Livewire/Storefront/Home.php new file mode 100644 index 00000000..016270c1 --- /dev/null +++ b/app/Livewire/Storefront/Home.php @@ -0,0 +1,38 @@ + $store, + 'settings' => app(ThemeSettingsService::class)->forStore($store), + 'collections' => Collection::query() + ->where('status', CollectionStatus::Active) + ->latest() + ->limit(3) + ->get(), + 'products' => Product::query() + ->with('variants', 'media') + ->where('status', ProductStatus::Active) + ->whereNotNull('published_at') + ->latest('published_at') + ->limit(6) + ->get(), + ])->layout('storefront.layouts.app', [ + 'title' => $store->name, + ]); + } +} diff --git a/app/Livewire/Storefront/Pages/Show.php b/app/Livewire/Storefront/Pages/Show.php new file mode 100644 index 00000000..f9b47988 --- /dev/null +++ b/app/Livewire/Storefront/Pages/Show.php @@ -0,0 +1,32 @@ +handle = $handle; + } + + public function render(): View + { + $page = Page::query() + ->where('handle', $this->handle) + ->where('status', PageStatus::Published) + ->firstOrFail(); + + return view('livewire.storefront.pages.show', [ + 'page' => $page, + ])->layout('storefront.layouts.app', [ + 'title' => $page->title, + ]); + } +} diff --git a/app/Livewire/Storefront/Products/Show.php b/app/Livewire/Storefront/Products/Show.php new file mode 100644 index 00000000..8aac80fa --- /dev/null +++ b/app/Livewire/Storefront/Products/Show.php @@ -0,0 +1,46 @@ +handle = $handle; + } + + public function selectVariant(int $variantId): void + { + $this->selectedVariantId = $variantId; + } + + public function render(): View + { + $product = Product::query() + ->with('variants.optionValues.option', 'variants.inventoryItem', 'media') + ->where('handle', $this->handle) + ->where('status', ProductStatus::Active) + ->whereNotNull('published_at') + ->firstOrFail(); + + $selectedVariant = $product->variants->firstWhere('id', $this->selectedVariantId) + ?? $product->variants->firstWhere('is_default', true) + ?? $product->variants->first(); + + return view('livewire.storefront.products.show', [ + 'product' => $product, + 'selectedVariant' => $selectedVariant, + ])->layout('storefront.layouts.app', [ + 'title' => $product->title, + ]); + } +} diff --git a/app/Livewire/Storefront/Search/Index.php b/app/Livewire/Storefront/Search/Index.php new file mode 100644 index 00000000..f008c1d2 --- /dev/null +++ b/app/Livewire/Storefront/Search/Index.php @@ -0,0 +1,41 @@ + ['except' => ''], + ]; + + public function render(): View + { + $query = Product::query() + ->with('variants', 'media') + ->where('status', ProductStatus::Active) + ->whereNotNull('published_at'); + + if (trim($this->q) !== '') { + $term = '%'.str_replace('%', '\\%', trim($this->q)).'%'; + + $query->where(function ($query) use ($term): void { + $query->where('title', 'like', $term) + ->orWhere('vendor', 'like', $term) + ->orWhere('product_type', 'like', $term); + }); + } + + return view('livewire.storefront.search.index', [ + 'products' => $query->latest('published_at')->get(), + ])->layout('storefront.layouts.app', [ + 'title' => 'Search', + ]); + } +} diff --git a/app/Models/NavigationItem.php b/app/Models/NavigationItem.php new file mode 100644 index 00000000..938b872f --- /dev/null +++ b/app/Models/NavigationItem.php @@ -0,0 +1,54 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'menu_id', + 'type', + 'label', + 'url', + 'resource_id', + 'position', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'type' => NavigationItemType::Link->value, + 'position' => 0, + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => NavigationItemType::class, + ]; + } + + /** + * @return BelongsTo + */ + 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..edc1f3ee --- /dev/null +++ b/app/Models/NavigationMenu.php @@ -0,0 +1,31 @@ + */ + use BelongsToStore, HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'handle', + 'title', + ]; + + /** + * @return HasMany + */ + public function items(): HasMany + { + return $this->hasMany(NavigationItem::class, 'menu_id'); + } +} diff --git a/app/Models/Page.php b/app/Models/Page.php new file mode 100644 index 00000000..4c6009c6 --- /dev/null +++ b/app/Models/Page.php @@ -0,0 +1,44 @@ + */ + use BelongsToStore, HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'body_html', + 'status', + 'published_at', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'status' => PageStatus::Draft->value, + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => PageStatus::class, + 'published_at' => 'datetime', + ]; + } +} diff --git a/app/Models/Store.php b/app/Models/Store.php index f001b26a..c1d1fa01 100644 --- a/app/Models/Store.php +++ b/app/Models/Store.php @@ -106,6 +106,30 @@ public function inventoryItems(): HasMany return $this->hasMany(InventoryItem::class); } + /** + * @return HasMany + */ + public function themes(): HasMany + { + return $this->hasMany(Theme::class); + } + + /** + * @return HasMany + */ + public function pages(): HasMany + { + return $this->hasMany(Page::class); + } + + /** + * @return HasMany + */ + public function navigationMenus(): HasMany + { + return $this->hasMany(NavigationMenu::class); + } + public function isSuspended(): bool { return $this->status === StoreStatus::Suspended; diff --git a/app/Models/Theme.php b/app/Models/Theme.php new file mode 100644 index 00000000..18c5fda9 --- /dev/null +++ b/app/Models/Theme.php @@ -0,0 +1,61 @@ + */ + use BelongsToStore, HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'name', + 'version', + 'status', + 'published_at', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'status' => ThemeStatus::Draft->value, + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => ThemeStatus::class, + 'published_at' => 'datetime', + ]; + } + + /** + * @return HasMany + */ + public function files(): HasMany + { + return $this->hasMany(ThemeFile::class); + } + + /** + * @return HasOne + */ + 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..a11c9800 --- /dev/null +++ b/app/Models/ThemeFile.php @@ -0,0 +1,41 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'theme_id', + 'path', + 'storage_key', + 'sha256', + 'byte_size', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'byte_size' => 0, + ]; + + /** + * @return BelongsTo + */ + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } +} diff --git a/app/Models/ThemeSettings.php b/app/Models/ThemeSettings.php new file mode 100644 index 00000000..0b4b2ef9 --- /dev/null +++ b/app/Models/ThemeSettings.php @@ -0,0 +1,52 @@ + */ + use HasFactory; + + public const CREATED_AT = null; + + protected $primaryKey = 'theme_id'; + + public $incrementing = false; + + /** + * @var list + */ + protected $fillable = [ + 'theme_id', + 'settings_json', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'settings_json' => '{}', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'settings_json' => 'array', + ]; + } + + /** + * @return BelongsTo + */ + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 4e200584..f0d294ee 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Services\ThemeSettingsService; use Carbon\CarbonImmutable; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; @@ -18,7 +19,7 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->singleton(ThemeSettingsService::class); } /** diff --git a/app/Services/NavigationService.php b/app/Services/NavigationService.php new file mode 100644 index 00000000..6d64511b --- /dev/null +++ b/app/Services/NavigationService.php @@ -0,0 +1,64 @@ +> + */ + public function buildTree(NavigationMenu $menu): array + { + return Cache::remember("navigation_menu:{$menu->store_id}:{$menu->handle}", now()->addMinutes(5), function () use ($menu): array { + return $menu->items() + ->orderBy('position') + ->get() + ->map(fn (NavigationItem $item): array => [ + 'label' => $item->label, + 'url' => $this->resolveUrl($item), + 'type' => $item->type->value, + 'children' => [], + ]) + ->all(); + }); + } + + public function resolveUrl(NavigationItem $item): string + { + return match ($item->type) { + NavigationItemType::Collection => $this->collectionUrl($item), + NavigationItemType::Product => $this->productUrl($item), + NavigationItemType::Page => $this->pageUrl($item), + NavigationItemType::Link => $item->url ?? '#', + }; + } + + private function collectionUrl(NavigationItem $item): string + { + $collection = Collection::withoutGlobalScopes()->find($item->resource_id); + + return $collection ? '/collections/'.$collection->handle : '#'; + } + + private function productUrl(NavigationItem $item): string + { + $product = Product::withoutGlobalScopes()->find($item->resource_id); + + return $product ? '/products/'.$product->handle : '#'; + } + + private function pageUrl(NavigationItem $item): string + { + $page = Page::withoutGlobalScopes()->find($item->resource_id); + + return $page ? '/pages/'.$page->handle : '#'; + } +} diff --git a/app/Services/ThemeSettingsService.php b/app/Services/ThemeSettingsService.php new file mode 100644 index 00000000..181dff87 --- /dev/null +++ b/app/Services/ThemeSettingsService.php @@ -0,0 +1,51 @@ + + */ + public function forStore(Store $store): array + { + return Cache::remember("theme_settings:{$store->id}", now()->addMinutes(5), function () use ($store): array { + $theme = Theme::withoutGlobalScopes() + ->with('settings') + ->where('store_id', $store->id) + ->where('status', ThemeStatus::Published) + ->latest('published_at') + ->first(); + + return array_replace_recursive($this->defaults($store), $theme?->settings?->settings_json ?? []); + }); + } + + /** + * @return array + */ + private function defaults(Store $store): array + { + return [ + 'announcement' => [ + 'enabled' => false, + 'text' => '', + 'link' => null, + ], + 'home' => [ + 'hero_heading' => $store->name, + 'hero_subheading' => 'Curated products from '.$store->name.'.', + 'hero_cta_label' => 'Shop products', + 'hero_cta_url' => '/collections', + ], + 'footer' => [ + 'contact_email' => $store->organization?->billing_email, + ], + ]; + } +} diff --git a/database/factories/NavigationItemFactory.php b/database/factories/NavigationItemFactory.php new file mode 100644 index 00000000..59428d01 --- /dev/null +++ b/database/factories/NavigationItemFactory.php @@ -0,0 +1,30 @@ + + */ +class NavigationItemFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'menu_id' => NavigationMenu::factory(), + 'type' => NavigationItemType::Link, + 'label' => fake()->words(2, true), + 'url' => '/'.fake()->slug(), + 'resource_id' => null, + 'position' => 0, + ]; + } +} diff --git a/database/factories/NavigationMenuFactory.php b/database/factories/NavigationMenuFactory.php new file mode 100644 index 00000000..4d888b3d --- /dev/null +++ b/database/factories/NavigationMenuFactory.php @@ -0,0 +1,26 @@ + + */ +class NavigationMenuFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'handle' => fake()->unique()->slug(2), + 'title' => fake()->words(2, true), + ]; + } +} diff --git a/database/factories/PageFactory.php b/database/factories/PageFactory.php new file mode 100644 index 00000000..d752e0a1 --- /dev/null +++ b/database/factories/PageFactory.php @@ -0,0 +1,41 @@ + + */ +class PageFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $title = Str::title(fake()->words(2, true)); + + return [ + 'store_id' => Store::factory(), + 'title' => $title, + 'handle' => Str::slug($title).'-'.fake()->unique()->numerify('###'), + 'body_html' => '

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

', + 'status' => PageStatus::Published, + 'published_at' => now(), + ]; + } + + public function draft(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => PageStatus::Draft, + 'published_at' => null, + ]); + } +} diff --git a/database/factories/ThemeFactory.php b/database/factories/ThemeFactory.php new file mode 100644 index 00000000..2840b16b --- /dev/null +++ b/database/factories/ThemeFactory.php @@ -0,0 +1,37 @@ + + */ +class ThemeFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'name' => fake()->words(2, true), + 'version' => '1.0.0', + 'status' => ThemeStatus::Draft, + 'published_at' => null, + ]; + } + + public function published(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => ThemeStatus::Published, + 'published_at' => now(), + ]); + } +} diff --git a/database/factories/ThemeFileFactory.php b/database/factories/ThemeFileFactory.php new file mode 100644 index 00000000..4dfa1cd6 --- /dev/null +++ b/database/factories/ThemeFileFactory.php @@ -0,0 +1,30 @@ + + */ +class ThemeFileFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $contents = fake()->sentence(); + + return [ + 'theme_id' => Theme::factory(), + 'path' => 'templates/'.fake()->unique()->slug().'.blade.php', + 'storage_key' => 'themes/'.fake()->uuid().'.blade.php', + 'sha256' => hash('sha256', $contents), + 'byte_size' => strlen($contents), + ]; + } +} diff --git a/database/factories/ThemeSettingsFactory.php b/database/factories/ThemeSettingsFactory.php new file mode 100644 index 00000000..4183dc74 --- /dev/null +++ b/database/factories/ThemeSettingsFactory.php @@ -0,0 +1,40 @@ + + */ +class ThemeSettingsFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'theme_id' => Theme::factory(), + 'settings_json' => [ + 'announcement' => [ + 'enabled' => true, + 'text' => 'Free shipping on orders over 75 EUR', + 'link' => '/collections/summer-essentials', + ], + 'home' => [ + 'hero_heading' => 'Acme Fashion', + 'hero_subheading' => 'Everyday essentials with sharp fits and breathable fabrics.', + 'hero_cta_label' => 'Shop new arrivals', + 'hero_cta_url' => '/collections/summer-essentials', + ], + 'footer' => [ + 'contact_email' => 'support@acme.test', + ], + ], + ]; + } +} diff --git a/database/migrations/2026_05_03_113836_create_themes_table.php b/database/migrations/2026_05_03_113836_create_themes_table.php new file mode 100644 index 00000000..72925274 --- /dev/null +++ b/database/migrations/2026_05_03_113836_create_themes_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('version')->nullable(); + $table->enum('status', ['draft', 'published'])->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->index('store_id'); + $table->index(['store_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('themes'); + } +}; diff --git a/database/migrations/2026_05_03_113837_create_theme_files_table.php b/database/migrations/2026_05_03_113837_create_theme_files_table.php new file mode 100644 index 00000000..ccb7d271 --- /dev/null +++ b/database/migrations/2026_05_03_113837_create_theme_files_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('theme_id')->constrained()->cascadeOnDelete(); + $table->string('path'); + $table->string('storage_key'); + $table->string('sha256'); + $table->integer('byte_size')->default(0); + + $table->unique(['theme_id', 'path']); + $table->index('theme_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('theme_files'); + } +}; diff --git a/database/migrations/2026_05_03_113838_create_theme_settings_table.php b/database/migrations/2026_05_03_113838_create_theme_settings_table.php new file mode 100644 index 00000000..90a84785 --- /dev/null +++ b/database/migrations/2026_05_03_113838_create_theme_settings_table.php @@ -0,0 +1,28 @@ +foreignId('theme_id')->primary()->constrained()->cascadeOnDelete(); + $table->text('settings_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('theme_settings'); + } +}; diff --git a/database/migrations/2026_05_03_113839_create_pages_table.php b/database/migrations/2026_05_03_113839_create_pages_table.php new file mode 100644 index 00000000..2e52d953 --- /dev/null +++ b/database/migrations/2026_05_03_113839_create_pages_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->text('body_html')->nullable(); + $table->enum('status', ['draft', 'published', 'archived'])->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'handle']); + $table->index('store_id'); + $table->index(['store_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('pages'); + } +}; diff --git a/database/migrations/2026_05_03_113840_create_navigation_menus_table.php b/database/migrations/2026_05_03_113840_create_navigation_menus_table.php new file mode 100644 index 00000000..bf2a3af0 --- /dev/null +++ b/database/migrations/2026_05_03_113840_create_navigation_menus_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('handle'); + $table->string('title'); + $table->timestamps(); + + $table->unique(['store_id', 'handle']); + $table->index('store_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('navigation_menus'); + } +}; diff --git a/database/migrations/2026_05_03_113841_create_navigation_items_table.php b/database/migrations/2026_05_03_113841_create_navigation_items_table.php new file mode 100644 index 00000000..c4846814 --- /dev/null +++ b/database/migrations/2026_05_03_113841_create_navigation_items_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('menu_id')->constrained('navigation_menus')->cascadeOnDelete(); + $table->enum('type', ['link', 'page', 'collection', 'product'])->default('link'); + $table->string('label'); + $table->string('url')->nullable(); + $table->unsignedBigInteger('resource_id')->nullable(); + $table->integer('position')->default(0); + + $table->index('menu_id'); + $table->index(['menu_id', 'position']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('navigation_items'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index f6328615..3d0cc57c 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -20,6 +20,9 @@ public function run(): void StoreSettingsSeeder::class, CollectionSeeder::class, ProductSeeder::class, + ThemeSeeder::class, + PageSeeder::class, + NavigationSeeder::class, ]); } } diff --git a/database/seeders/NavigationItemSeeder.php b/database/seeders/NavigationItemSeeder.php new file mode 100644 index 00000000..e8a5a51d --- /dev/null +++ b/database/seeders/NavigationItemSeeder.php @@ -0,0 +1,16 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $collection = Collection::query() + ->where('store_id', $store->id) + ->where('handle', 'summer-essentials') + ->firstOrFail(); + $page = Page::query() + ->where('store_id', $store->id) + ->where('handle', 'about') + ->firstOrFail(); + + $mainMenu = NavigationMenu::query()->updateOrCreate( + [ + 'store_id' => $store->id, + 'handle' => 'main-menu', + ], + ['title' => 'Main menu'], + ); + + $footerMenu = NavigationMenu::query()->updateOrCreate( + [ + 'store_id' => $store->id, + 'handle' => 'footer-menu', + ], + ['title' => 'Footer menu'], + ); + + $mainMenu->items()->updateOrCreate( + ['position' => 0], + [ + 'type' => NavigationItemType::Link, + 'label' => 'Home', + 'url' => '/', + 'resource_id' => null, + ], + ); + + $mainMenu->items()->updateOrCreate( + ['position' => 1], + [ + 'type' => NavigationItemType::Collection, + 'label' => 'Summer Essentials', + 'url' => null, + 'resource_id' => $collection->id, + ], + ); + + $mainMenu->items()->updateOrCreate( + ['position' => 2], + [ + 'type' => NavigationItemType::Page, + 'label' => 'About', + 'url' => null, + 'resource_id' => $page->id, + ], + ); + + foreach ([ + ['label' => 'Search', 'url' => '/search'], + ['label' => 'Account', 'url' => '/account/login'], + ] as $position => $item) { + $footerMenu->items()->updateOrCreate( + ['position' => $position], + [ + 'type' => NavigationItemType::Link, + 'label' => $item['label'], + 'url' => $item['url'], + 'resource_id' => null, + ], + ); + } + } +} diff --git a/database/seeders/PageSeeder.php b/database/seeders/PageSeeder.php new file mode 100644 index 00000000..bc9993bc --- /dev/null +++ b/database/seeders/PageSeeder.php @@ -0,0 +1,32 @@ +where('handle', 'acme-fashion')->firstOrFail(); + + Page::query()->updateOrCreate( + [ + 'store_id' => $store->id, + 'handle' => 'about', + ], + [ + 'title' => 'About Acme', + 'body_html' => '

Acme Fashion designs practical essentials for daily wear.

', + 'status' => PageStatus::Published, + 'published_at' => now(), + ], + ); + } +} diff --git a/database/seeders/ThemeFileSeeder.php b/database/seeders/ThemeFileSeeder.php new file mode 100644 index 00000000..396d0dd4 --- /dev/null +++ b/database/seeders/ThemeFileSeeder.php @@ -0,0 +1,16 @@ +where('handle', 'acme-fashion')->firstOrFail(); + + $theme = Theme::query()->updateOrCreate( + [ + 'store_id' => $store->id, + 'name' => 'Default Storefront', + ], + [ + 'version' => '1.0.0', + 'status' => ThemeStatus::Published, + 'published_at' => now(), + ], + ); + + ThemeSettings::query()->updateOrCreate( + ['theme_id' => $theme->id], + [ + 'settings_json' => [ + 'announcement' => [ + 'enabled' => true, + 'text' => 'Free shipping on orders over 75 EUR', + 'link' => '/collections/summer-essentials', + ], + 'home' => [ + 'hero_heading' => 'Acme Fashion', + 'hero_subheading' => 'Light layers, clean lines, and durable everyday staples.', + 'hero_cta_label' => 'Shop summer essentials', + 'hero_cta_url' => '/collections/summer-essentials', + ], + 'footer' => [ + 'contact_email' => 'support@acme.test', + ], + ], + ], + ); + } +} diff --git a/database/seeders/ThemeSettingsSeeder.php b/database/seeders/ThemeSettingsSeeder.php new file mode 100644 index 00000000..738af9c8 --- /dev/null +++ b/database/seeders/ThemeSettingsSeeder.php @@ -0,0 +1,16 @@ + + Cart drawer +
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..876fbacf --- /dev/null +++ b/resources/views/livewire/storefront/cart/show.blade.php @@ -0,0 +1,9 @@ +
+

Cart

+
+

Your cart is empty.

+ + Continue shopping + +
+
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..9e69c971 --- /dev/null +++ b/resources/views/livewire/storefront/collections/index.blade.php @@ -0,0 +1,11 @@ +
+

Collections

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

{{ $collection->title }}

+

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

+
+ @endforeach +
+
diff --git a/resources/views/livewire/storefront/collections/show.blade.php b/resources/views/livewire/storefront/collections/show.blade.php new file mode 100644 index 00000000..9ba6eed6 --- /dev/null +++ b/resources/views/livewire/storefront/collections/show.blade.php @@ -0,0 +1,13 @@ +
+
+ Collections +

{{ $collection->title }}

+

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

+
+ +
+ @foreach($collection->products as $product) + @include('storefront.components.product-card', ['product' => $product]) + @endforeach +
+
diff --git a/resources/views/livewire/storefront/home.blade.php b/resources/views/livewire/storefront/home.blade.php new file mode 100644 index 00000000..c5f31446 --- /dev/null +++ b/resources/views/livewire/storefront/home.blade.php @@ -0,0 +1,51 @@ +
+
+
+
+

{{ data_get($settings, 'home.hero_heading') }}

+

{{ data_get($settings, 'home.hero_subheading') }}

+ +
+
+
+
Featured edit
+
Summer Essentials
+
+
+
+
+ +
+
+
+

Featured Collections

+

Curated selections from {{ $store->name }}.

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

{{ $collection->title }}

+
{{ strip_tags($collection->description_html) }}
+
+ @endforeach +
+
+ +
+
+

Featured Products

+ Search products +
+
+ @foreach($products as $product) + @include('storefront.components.product-card', ['product' => $product]) + @endforeach +
+
+
diff --git a/resources/views/livewire/storefront/pages/show.blade.php b/resources/views/livewire/storefront/pages/show.blade.php new file mode 100644 index 00000000..cc59a4f9 --- /dev/null +++ b/resources/views/livewire/storefront/pages/show.blade.php @@ -0,0 +1,6 @@ +
+

{{ $page->title }}

+
+ {!! $page->body_html !!} +
+
diff --git a/resources/views/livewire/storefront/products/show.blade.php b/resources/views/livewire/storefront/products/show.blade.php new file mode 100644 index 00000000..5937088b --- /dev/null +++ b/resources/views/livewire/storefront/products/show.blade.php @@ -0,0 +1,44 @@ +
+
+ @if($product->media->first()) + {{ $product->media->first()->alt_text ?: $product->title }} + @else +
+ {{ $product->title }} +
+ @endif +
+ +
+
+ Products +

{{ $product->title }}

+ @if($selectedVariant) +
+ @include('storefront.components.price', ['amount' => $selectedVariant->price_amount, 'currency' => $selectedVariant->currency]) +
+ @endif +
+ + @if($product->variants->count() > 1) +
+
Options
+
+ @foreach($product->variants as $variant) + + @endforeach +
+
+ @endif + +
+ {!! $product->description_html !!} +
+ + +
+
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..8baf69c4 --- /dev/null +++ b/resources/views/livewire/storefront/search/index.blade.php @@ -0,0 +1,17 @@ +
+

Search

+ + +
+ @forelse($products as $product) + @include('storefront.components.product-card', ['product' => $product]) + @empty +
+ No products found. +
+ @endforelse +
+
diff --git a/resources/views/storefront/components/price.blade.php b/resources/views/storefront/components/price.blade.php new file mode 100644 index 00000000..6e1e8dfa --- /dev/null +++ b/resources/views/storefront/components/price.blade.php @@ -0,0 +1,3 @@ +@props(['amount', 'currency']) + +{{ number_format(((int) $amount) / 100, 2, '.', ',') }} {{ $currency }} 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..9858a389 --- /dev/null +++ b/resources/views/storefront/components/product-card.blade.php @@ -0,0 +1,28 @@ +@props(['product']) + +@php + $variant = $product->variants->sortBy('position')->first(); + $media = $product->media->sortBy('position')->first(); +@endphp + + diff --git a/resources/views/storefront/layouts/app.blade.php b/resources/views/storefront/layouts/app.blade.php new file mode 100644 index 00000000..6e030611 --- /dev/null +++ b/resources/views/storefront/layouts/app.blade.php @@ -0,0 +1,107 @@ +@php + use App\Models\NavigationMenu; + use App\Services\NavigationService; + use App\Services\ThemeSettingsService; + + $store = app('current_store'); + $settings = app(ThemeSettingsService::class)->forStore($store); + $navigation = app(NavigationService::class); + $mainMenu = NavigationMenu::query()->where('handle', 'main-menu')->first(); + $footerMenu = NavigationMenu::query()->where('handle', 'footer-menu')->first(); + $mainItems = $mainMenu ? $navigation->buildTree($mainMenu) : []; + $footerItems = $footerMenu ? $navigation->buildTree($footerMenu) : []; +@endphp + + + + + @include('partials.head') + + + + + Skip to main content + + + @if(data_get($settings, 'announcement.enabled')) +
+ @if(data_get($settings, 'announcement.link')) + + {{ data_get($settings, 'announcement.text') }} + + @else + {{ data_get($settings, 'announcement.text') }} + @endif +
+ @endif + +
+
+
+ + Menu + + +
+ + + {{ $store->name }} + + + + +
+ Search + Cart +
+
+
+ +
+ {{ $slot }} +
+ +
+
+
+
{{ $store->name }}
+

+ {{ data_get($settings, 'footer.contact_email') }} +

+
+ +
+
+ © {{ now()->year }} {{ $store->name }}. All rights reserved. +
+
+ + @livewire('storefront.cart-drawer') + @fluxScripts + + diff --git a/routes/web.php b/routes/web.php index f755f111..c673a16d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,10 +1,23 @@ name('home'); +Route::middleware('storefront')->group(function () { + Route::get('/', Home::class)->name('home'); + Route::get('/collections', CollectionsIndex::class)->name('storefront.collections.index'); + Route::get('/collections/{handle}', CollectionShow::class)->name('storefront.collections.show'); + Route::get('/products/{handle}', ProductShow::class)->name('storefront.products.show'); + Route::get('/cart', CartShow::class)->name('storefront.cart.show'); + Route::get('/search', SearchIndex::class)->name('storefront.search.index'); + Route::get('/pages/{handle}', PageShow::class)->name('storefront.pages.show'); +}); Route::view('dashboard', 'dashboard') ->middleware(['auth', 'verified']) diff --git a/specs/progress.md b/specs/progress.md index 540d1941..443a3868 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -10,9 +10,10 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Repository started from the Laravel Livewire starter kit with Fortify authentication. - Phase 1 foundation is implemented and committed: configuration defaults, core tenancy schema, models, factories, seeders, tenant middleware, customer guard provider registration, store role helper, and password_hash compatibility. -- Phase 2 catalog backend is partially implemented and verified: products, options, option values, variants, inventory, collections, collection pivot, media schema/models/factories/seed data, product lifecycle service, variant matrix service, inventory service, handle generator, and product/collection policies. -- Storefront shop UI, admin shop UI, cart, checkout, orders, search, analytics, apps, and webhooks are not implemented yet. -- Phase 3 theme/storefront shell is the next active vertical slice after committing Phase 2 backend progress. +- Phase 2 catalog backend is implemented and committed: products, options, option values, variants, inventory, collections, collection pivot, media schema/models/factories/seed data, product lifecycle service, variant matrix service, inventory service, handle generator, and product/collection policies. +- Phase 3 theme/storefront shell is implemented and verified: theme/page/navigation schema, models, factories, seed data, theme settings service, navigation service, storefront layout, product cards, price rendering, and initial Livewire storefront pages. +- Admin shop UI, functional cart persistence, checkout, orders, customer account flows, search indexing, analytics, apps, and webhooks are not implemented yet. +- Phase 4 cart/checkout/pricing is the next active vertical slice after committing the storefront shell. ## Execution Plan @@ -20,10 +21,10 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Configure SQLite/file/sync environment defaults. - Add organization/store/domain/settings schema, models, enums, factories, seeders, tenant middleware, store-scoped model support, customer guard baseline, and role-aware user helpers. - Verify with focused tenancy/model/auth tests and `migrate:fresh --seed`. -2. **Catalog** - In progress +2. **Catalog** - Committed (`ea70780e`) - Add products, variants, options, inventory, collections, media, catalog services, and storefront/admin catalog basics. - Verify product lifecycle, inventory, variants, and collection isolation tests. -3. **Theme and storefront shell** - Pending +3. **Theme and storefront shell** - Implemented and verified - Add themes, pages, navigation, storefront layout, reusable components, and initial storefront pages. - Verify storefront render, navigation, product detail, accessibility smoke, and responsive browser checks. 4. **Cart, checkout, pricing** - Pending @@ -53,11 +54,14 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Prefer Laravel conventions and existing starter-kit patterns unless specs require a different shop-specific behavior. - Phase 1 customer guard/provider is registered now, but `customers` persistence remains in the customer/order slice because the roadmap places the `customers` table in Phase 5. - Admin users store credentials in `users.password_hash`; the `User` model keeps a `password` attribute alias so Fortify and starter-kit Livewire settings continue to work. +- Storefront pages are class-based Livewire components using the existing starter layout conventions and store resolution middleware. +- Phase 3 includes a cart page/drawer shell only; persistent carts and line-item actions stay in Phase 4 so pricing and checkout state can be implemented coherently. ## Open Gaps - Phase 1 still needs the resource policies listed in the roadmap, but most referenced resources do not exist until later phases. Policies will be added with their models to keep type hints and tests coherent. -- Phase 2 still needs Livewire/admin product management, storefront product/collection pages, and full media resizing variants. The backend catalog data model and core services are implemented. +- Phase 2 still needs Livewire/admin product management and full media resizing variants. Storefront product/collection browsing is covered by the Phase 3 shell. +- Phase 3 still needs the richer theme editor/admin surfaces, search modal autocomplete, checkout/account/error storefront templates, and fully configurable section ordering. These are deferred to the admin, search, checkout, and account slices. - Order-reference guards in product deletion/status logic are present but only become fully meaningful once `order_lines` exists in Phase 5. - Cart and all later shop phases remain unimplemented. - API token requirements mention Sanctum, but the package is not currently installed. This remains an open dependency decision for the API/developers phase because dependencies must not be changed without approval. @@ -73,4 +77,9 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed with catalog migrations and seed data. - 2026-05-03: `vendor/bin/pint --dirty --format agent` passed and fixed generated catalog PHP files. - 2026-05-03: `php artisan test --compact` passed, 49 tests / 119 assertions. +- 2026-05-03: `php artisan test --compact tests/Feature/Storefront tests/Feature/ExampleTest.php` passed, 5 tests / 21 assertions. +- 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed with theme, page, and navigation migrations/seed data. +- 2026-05-03: `php artisan test --compact` passed, 53 tests / 139 assertions. +- 2026-05-03: `npm run build` passed for the storefront Tailwind/Vite assets. +- 2026-05-03: Playwright smoke visited `http://shop.test/` and `http://shop.test/products/linen-shirt`; latest browser console check reported no warnings or errors. - Pending: Playwright customer and admin browser flows. diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 8b5843f4..5435a824 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -1,7 +1,13 @@ get('/'); +use Illuminate\Support\Facades\Cache; - $response->assertStatus(200); +uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); + +it('returns a successful storefront response', function () { + Cache::flush(); + $this->seed(); + + $this->get('http://shop.test/') + ->assertOk(); }); diff --git a/tests/Feature/Storefront/StorefrontRenderTest.php b/tests/Feature/Storefront/StorefrontRenderTest.php new file mode 100644 index 00000000..c13d7249 --- /dev/null +++ b/tests/Feature/Storefront/StorefrontRenderTest.php @@ -0,0 +1,41 @@ +seed(); +}); + +test('storefront home renders seeded theme collections and products', function () { + $this->get('http://shop.test/') + ->assertOk() + ->assertSee('Acme Fashion') + ->assertSee('Summer Essentials') + ->assertSee('Linen Shirt'); +}); + +test('collection product page static page cart and search render', function () { + $this->get('http://shop.test/collections/summer-essentials') + ->assertOk() + ->assertSee('Linen Shirt'); + + $this->get('http://shop.test/products/linen-shirt') + ->assertOk() + ->assertSee('Add to cart') + ->assertSee('49.99 EUR'); + + $this->get('http://shop.test/pages/about') + ->assertOk() + ->assertSee('About Acme'); + + $this->get('http://shop.test/cart') + ->assertOk() + ->assertSee('Your cart is empty.'); + + $this->get('http://shop.test/search?q=linen') + ->assertOk() + ->assertSee('Linen Shirt'); +}); diff --git a/tests/Feature/Storefront/ThemeNavigationTest.php b/tests/Feature/Storefront/ThemeNavigationTest.php new file mode 100644 index 00000000..5051eb7a --- /dev/null +++ b/tests/Feature/Storefront/ThemeNavigationTest.php @@ -0,0 +1,33 @@ +seed(); +}); + +test('theme settings service loads the published theme settings', function () { + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + + $settings = app(ThemeSettingsService::class)->forStore($store); + + expect(data_get($settings, 'announcement.enabled'))->toBeTrue() + ->and(data_get($settings, 'home.hero_heading'))->toBe('Acme Fashion'); +}); + +test('navigation service resolves seeded resource urls', function () { + $menu = NavigationMenu::query()->where('handle', 'main-menu')->firstOrFail(); + + $items = app(NavigationService::class)->buildTree($menu); + + expect($items)->toHaveCount(3) + ->and($items[1]['url'])->toBe('/collections/summer-essentials') + ->and($items[2]['url'])->toBe('/pages/about'); +}); From 5c4239292d534d891e52767bdc0ac618693a7827 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 3 May 2026 14:24:41 +0200 Subject: [PATCH 08/35] Build cart checkout pricing flow --- app/Enums/CartStatus.php | 10 + app/Enums/CheckoutStatus.php | 13 + app/Enums/DiscountStatus.php | 11 + app/Enums/DiscountType.php | 9 + app/Enums/DiscountValueType.php | 10 + app/Enums/PaymentMethod.php | 10 + app/Enums/ShippingRateType.php | 11 + app/Enums/TaxMode.php | 9 + .../CartVersionConflictException.php | 14 + app/Exceptions/CheckoutStateException.php | 10 + .../InvalidCartMutationException.php | 10 + app/Exceptions/InvalidDiscountException.php | 13 + .../UnavailableShippingRateException.php | 10 + .../Api/Storefront/CartController.php | 105 +++++++ .../Api/Storefront/CheckoutController.php | 120 +++++++ .../Storefront/ApplyDiscountRequest.php | 28 ++ .../Storefront/CartVersionRequest.php | 28 ++ .../Storefront/SetCheckoutAddressRequest.php | 41 +++ .../SetCheckoutPaymentMethodRequest.php | 30 ++ .../SetCheckoutShippingMethodRequest.php | 28 ++ .../Storefront/StoreCartLineRequest.php | 30 ++ .../Requests/Storefront/StoreCartRequest.php | 28 ++ .../Storefront/StoreCheckoutRequest.php | 29 ++ .../Storefront/UpdateCartLineRequest.php | 29 ++ .../Resources/Storefront/CartLineResource.php | 42 +++ .../Resources/Storefront/CartResource.php | 39 +++ .../Resources/Storefront/CheckoutResource.php | 52 ++++ app/Jobs/CleanupAbandonedCarts.php | 24 ++ app/Jobs/ExpireAbandonedCheckouts.php | 27 ++ app/Livewire/Storefront/Cart/Show.php | 73 ++++- app/Livewire/Storefront/CartDrawer.php | 24 +- .../Storefront/Checkout/Confirmation.php | 30 ++ app/Livewire/Storefront/Checkout/Show.php | 154 +++++++++ app/Livewire/Storefront/Products/Show.php | 62 +++- app/Models/Cart.php | 98 ++++++ app/Models/CartLine.php | 55 ++++ app/Models/Checkout.php | 96 ++++++ app/Models/Discount.php | 68 ++++ app/Models/ShippingRate.php | 56 ++++ app/Models/ShippingZone.php | 62 ++++ app/Models/Store.php | 40 +++ app/Models/TaxSettings.php | 62 ++++ app/Providers/AppServiceProvider.php | 10 + app/Services/CartService.php | 292 ++++++++++++++++++ app/Services/CheckoutService.php | 251 +++++++++++++++ app/Services/DiscountService.php | 141 +++++++++ app/Services/PricingEngine.php | 159 ++++++++++ app/Services/ShippingCalculator.php | 158 ++++++++++ app/Services/TaxCalculator.php | 58 ++++ app/ValueObjects/DiscountResult.php | 15 + app/ValueObjects/PricingResult.php | 38 +++ app/ValueObjects/ShippingRateQuote.php | 30 ++ app/ValueObjects/TaxLine.php | 24 ++ bootstrap/app.php | 1 + database/factories/CartFactory.php | 43 +++ database/factories/CartLineFactory.php | 43 +++ database/factories/CheckoutFactory.php | 45 +++ database/factories/DiscountFactory.php | 59 ++++ database/factories/ShippingRateFactory.php | 45 +++ database/factories/ShippingZoneFactory.php | 27 ++ database/factories/TaxSettingsFactory.php | 32 ++ .../2026_05_03_115453_create_carts_table.php | 36 +++ ...6_05_03_115454_create_cart_lines_table.php | 36 +++ ...26_05_03_115455_create_checkouts_table.php | 46 +++ ..._03_115456_create_shipping_zones_table.php | 32 ++ ..._03_115457_create_shipping_rates_table.php | 34 ++ ...05_03_115458_create_tax_settings_table.php | 31 ++ ...26_05_03_115459_create_discounts_table.php | 43 +++ database/seeders/CartLineSeeder.php | 16 + database/seeders/CartSeeder.php | 16 + database/seeders/CheckoutSeeder.php | 16 + database/seeders/DatabaseSeeder.php | 3 + database/seeders/DiscountSeeder.php | 44 +++ database/seeders/ShippingRateSeeder.php | 16 + database/seeders/ShippingZoneSeeder.php | 56 ++++ database/seeders/TaxSettingsSeeder.php | 32 ++ .../livewire/storefront/cart-drawer.blade.php | 4 +- .../livewire/storefront/cart/show.blade.php | 54 +++- .../checkout/confirmation.blade.php | 13 + .../storefront/checkout/show.blade.php | 96 ++++++ .../storefront/products/show.blade.php | 16 +- routes/api.php | 25 ++ routes/console.php | 6 + routes/web.php | 4 + specs/progress.md | 23 +- tests/Feature/Api/StorefrontCartApiTest.php | 103 ++++++ tests/Feature/Cart/CartServiceTest.php | 63 ++++ .../Feature/Checkout/CheckoutServiceTest.php | 113 +++++++ .../Storefront/StorefrontCartLivewireTest.php | 36 +++ 89 files changed, 4156 insertions(+), 28 deletions(-) create mode 100644 app/Enums/CartStatus.php create mode 100644 app/Enums/CheckoutStatus.php create mode 100644 app/Enums/DiscountStatus.php create mode 100644 app/Enums/DiscountType.php create mode 100644 app/Enums/DiscountValueType.php create mode 100644 app/Enums/PaymentMethod.php create mode 100644 app/Enums/ShippingRateType.php create mode 100644 app/Enums/TaxMode.php create mode 100644 app/Exceptions/CartVersionConflictException.php create mode 100644 app/Exceptions/CheckoutStateException.php create mode 100644 app/Exceptions/InvalidCartMutationException.php create mode 100644 app/Exceptions/InvalidDiscountException.php create mode 100644 app/Exceptions/UnavailableShippingRateException.php create mode 100644 app/Http/Controllers/Api/Storefront/CartController.php create mode 100644 app/Http/Controllers/Api/Storefront/CheckoutController.php create mode 100644 app/Http/Requests/Storefront/ApplyDiscountRequest.php create mode 100644 app/Http/Requests/Storefront/CartVersionRequest.php create mode 100644 app/Http/Requests/Storefront/SetCheckoutAddressRequest.php create mode 100644 app/Http/Requests/Storefront/SetCheckoutPaymentMethodRequest.php create mode 100644 app/Http/Requests/Storefront/SetCheckoutShippingMethodRequest.php create mode 100644 app/Http/Requests/Storefront/StoreCartLineRequest.php create mode 100644 app/Http/Requests/Storefront/StoreCartRequest.php create mode 100644 app/Http/Requests/Storefront/StoreCheckoutRequest.php create mode 100644 app/Http/Requests/Storefront/UpdateCartLineRequest.php create mode 100644 app/Http/Resources/Storefront/CartLineResource.php create mode 100644 app/Http/Resources/Storefront/CartResource.php create mode 100644 app/Http/Resources/Storefront/CheckoutResource.php create mode 100644 app/Jobs/CleanupAbandonedCarts.php create mode 100644 app/Jobs/ExpireAbandonedCheckouts.php create mode 100644 app/Livewire/Storefront/Checkout/Confirmation.php create mode 100644 app/Livewire/Storefront/Checkout/Show.php create mode 100644 app/Models/Cart.php create mode 100644 app/Models/CartLine.php create mode 100644 app/Models/Checkout.php create mode 100644 app/Models/Discount.php create mode 100644 app/Models/ShippingRate.php create mode 100644 app/Models/ShippingZone.php create mode 100644 app/Models/TaxSettings.php create mode 100644 app/Services/CartService.php create mode 100644 app/Services/CheckoutService.php create mode 100644 app/Services/DiscountService.php create mode 100644 app/Services/PricingEngine.php create mode 100644 app/Services/ShippingCalculator.php create mode 100644 app/Services/TaxCalculator.php create mode 100644 app/ValueObjects/DiscountResult.php create mode 100644 app/ValueObjects/PricingResult.php create mode 100644 app/ValueObjects/ShippingRateQuote.php create mode 100644 app/ValueObjects/TaxLine.php create mode 100644 database/factories/CartFactory.php create mode 100644 database/factories/CartLineFactory.php create mode 100644 database/factories/CheckoutFactory.php create mode 100644 database/factories/DiscountFactory.php create mode 100644 database/factories/ShippingRateFactory.php create mode 100644 database/factories/ShippingZoneFactory.php create mode 100644 database/factories/TaxSettingsFactory.php create mode 100644 database/migrations/2026_05_03_115453_create_carts_table.php create mode 100644 database/migrations/2026_05_03_115454_create_cart_lines_table.php create mode 100644 database/migrations/2026_05_03_115455_create_checkouts_table.php create mode 100644 database/migrations/2026_05_03_115456_create_shipping_zones_table.php create mode 100644 database/migrations/2026_05_03_115457_create_shipping_rates_table.php create mode 100644 database/migrations/2026_05_03_115458_create_tax_settings_table.php create mode 100644 database/migrations/2026_05_03_115459_create_discounts_table.php create mode 100644 database/seeders/CartLineSeeder.php create mode 100644 database/seeders/CartSeeder.php create mode 100644 database/seeders/CheckoutSeeder.php create mode 100644 database/seeders/DiscountSeeder.php create mode 100644 database/seeders/ShippingRateSeeder.php create mode 100644 database/seeders/ShippingZoneSeeder.php create mode 100644 database/seeders/TaxSettingsSeeder.php create mode 100644 resources/views/livewire/storefront/checkout/confirmation.blade.php create mode 100644 resources/views/livewire/storefront/checkout/show.blade.php create mode 100644 routes/api.php create mode 100644 tests/Feature/Api/StorefrontCartApiTest.php create mode 100644 tests/Feature/Cart/CartServiceTest.php create mode 100644 tests/Feature/Checkout/CheckoutServiceTest.php create mode 100644 tests/Feature/Storefront/StorefrontCartLivewireTest.php diff --git a/app/Enums/CartStatus.php b/app/Enums/CartStatus.php new file mode 100644 index 00000000..56a92071 --- /dev/null +++ b/app/Enums/CartStatus.php @@ -0,0 +1,10 @@ +create($this->currentStore()); + + return (new CartResource($carts->loadForDisplay($cart))) + ->response() + ->setStatusCode(201); + } + + public function show(int $cartId, CartService $carts): CartResource + { + return new CartResource($carts->loadForDisplay($carts->findForStore($this->currentStore(), $cartId))); + } + + public function storeLine(StoreCartLineRequest $request, int $cartId, CartService $carts): JsonResponse + { + $cart = $carts->findForStore($this->currentStore(), $cartId); + + try { + $carts->addLine( + $cart, + (int) $request->validated('variant_id'), + (int) $request->validated('quantity'), + $request->integer('cart_version') ?: null, + ); + } catch (CartVersionConflictException $exception) { + return $this->versionConflict($exception); + } catch (InvalidCartMutationException $exception) { + throw ValidationException::withMessages([ + 'variant_id' => [$exception->getMessage()], + ]); + } + + return (new CartResource($carts->loadForDisplay($cart))) + ->response() + ->setStatusCode(201); + } + + public function updateLine(UpdateCartLineRequest $request, int $cartId, int $lineId, CartService $carts): CartResource|JsonResponse + { + $cart = $carts->findForStore($this->currentStore(), $cartId); + + try { + $carts->updateLineQuantity( + $cart, + $lineId, + (int) $request->validated('quantity'), + (int) $request->validated('cart_version'), + ); + } catch (CartVersionConflictException $exception) { + return $this->versionConflict($exception); + } catch (InvalidCartMutationException $exception) { + throw ValidationException::withMessages([ + 'quantity' => [$exception->getMessage()], + ]); + } + + return new CartResource($carts->loadForDisplay($cart)); + } + + public function destroyLine(CartVersionRequest $request, int $cartId, int $lineId, CartService $carts): CartResource|JsonResponse + { + $cart = $carts->findForStore($this->currentStore(), $cartId); + + try { + $carts->removeLine($cart, $lineId, (int) $request->validated('cart_version')); + } catch (CartVersionConflictException $exception) { + return $this->versionConflict($exception); + } + + return new CartResource($carts->loadForDisplay($cart)); + } + + private function currentStore(): Store + { + return app('current_store'); + } + + private function versionConflict(CartVersionConflictException $exception): JsonResponse + { + return response()->json([ + 'message' => $exception->getMessage(), + 'cart' => (new CartResource($exception->cart))->resolve(), + ], 409); + } +} diff --git a/app/Http/Controllers/Api/Storefront/CheckoutController.php b/app/Http/Controllers/Api/Storefront/CheckoutController.php new file mode 100644 index 00000000..ff971d94 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/CheckoutController.php @@ -0,0 +1,120 @@ +findForStore(app('current_store'), (int) $request->validated('cart_id')); + $checkout = $checkouts->createFromCart($cart, (string) $request->validated('email')); + + return (new CheckoutResource($checkout)) + ->response() + ->setStatusCode(201); + } + + public function show(int $checkoutId, CheckoutService $checkouts): CheckoutResource|JsonResponse + { + $checkout = $checkouts->findForStore($checkoutId); + + if ($checkout->isExpired()) { + return response()->json(['message' => 'This checkout has expired.'], 410); + } + + return new CheckoutResource($checkout); + } + + public function address(SetCheckoutAddressRequest $request, int $checkoutId, CheckoutService $checkouts): CheckoutResource|JsonResponse + { + try { + return new CheckoutResource( + $checkouts->setAddress($checkouts->findForStore($checkoutId), $request->validated()), + ); + } catch (CheckoutStateException $exception) { + return $this->checkoutStateError($exception); + } + } + + public function shippingMethod(SetCheckoutShippingMethodRequest $request, int $checkoutId, CheckoutService $checkouts): CheckoutResource|JsonResponse + { + try { + return new CheckoutResource( + $checkouts->setShippingMethod( + $checkouts->findForStore($checkoutId), + (int) $request->validated('shipping_method_id'), + ), + ); + } catch (UnavailableShippingRateException $exception) { + throw ValidationException::withMessages([ + 'shipping_method_id' => [$exception->getMessage()], + ]); + } catch (CheckoutStateException $exception) { + return $this->checkoutStateError($exception); + } + } + + public function paymentMethod(SetCheckoutPaymentMethodRequest $request, int $checkoutId, CheckoutService $checkouts): CheckoutResource|JsonResponse + { + try { + return new CheckoutResource( + $checkouts->selectPaymentMethod( + $checkouts->findForStore($checkoutId), + PaymentMethod::from((string) $request->validated('payment_method')), + ), + ); + } catch (CheckoutStateException $exception) { + return $this->checkoutStateError($exception); + } + } + + public function applyDiscount(ApplyDiscountRequest $request, int $checkoutId, CheckoutService $checkouts): CheckoutResource|JsonResponse + { + try { + return new CheckoutResource( + $checkouts->applyDiscount($checkouts->findForStore($checkoutId), (string) $request->validated('code')), + ); + } catch (InvalidDiscountException $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + 'error_code' => $exception->errorCode, + ], $exception->errorCode === 'discount_not_found' ? 422 : 400); + } catch (CheckoutStateException $exception) { + return $this->checkoutStateError($exception); + } + } + + public function removeDiscount(int $checkoutId, CheckoutService $checkouts): CheckoutResource|JsonResponse + { + try { + return new CheckoutResource( + $checkouts->removeDiscount($checkouts->findForStore($checkoutId)), + ); + } catch (CheckoutStateException $exception) { + return $this->checkoutStateError($exception); + } + } + + private function checkoutStateError(CheckoutStateException $exception): JsonResponse + { + $status = str_contains($exception->getMessage(), 'expired') ? 410 : 422; + + return response()->json(['message' => $exception->getMessage()], $status); + } +} diff --git a/app/Http/Requests/Storefront/ApplyDiscountRequest.php b/app/Http/Requests/Storefront/ApplyDiscountRequest.php new file mode 100644 index 00000000..4375a0eb --- /dev/null +++ b/app/Http/Requests/Storefront/ApplyDiscountRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'code' => ['required', 'string', 'max:50'], + ]; + } +} diff --git a/app/Http/Requests/Storefront/CartVersionRequest.php b/app/Http/Requests/Storefront/CartVersionRequest.php new file mode 100644 index 00000000..e2000172 --- /dev/null +++ b/app/Http/Requests/Storefront/CartVersionRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'cart_version' => ['required', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/Storefront/SetCheckoutAddressRequest.php b/app/Http/Requests/Storefront/SetCheckoutAddressRequest.php new file mode 100644 index 00000000..134afe59 --- /dev/null +++ b/app/Http/Requests/Storefront/SetCheckoutAddressRequest.php @@ -0,0 +1,41 @@ +|string> + */ + public function rules(): array + { + return [ + 'shipping_address' => ['required', 'array'], + 'shipping_address.first_name' => ['required', 'string', 'max:255'], + 'shipping_address.last_name' => ['required', 'string', 'max:255'], + 'shipping_address.address1' => ['required', 'string', 'max:500'], + 'shipping_address.address2' => ['nullable', 'string', 'max:500'], + 'shipping_address.city' => ['required', 'string', 'max:255'], + 'shipping_address.province' => ['nullable', 'string', 'max:255'], + 'shipping_address.province_code' => ['nullable', 'string', 'max:10'], + 'shipping_address.country' => ['required', 'string', 'max:255'], + 'shipping_address.country_code' => ['required', 'string', 'size:2'], + 'shipping_address.postal_code' => ['required', 'string', 'max:20'], + 'shipping_address.phone' => ['nullable', 'string', 'max:50'], + 'billing_address' => ['nullable', 'array'], + 'use_shipping_as_billing' => ['nullable', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/Storefront/SetCheckoutPaymentMethodRequest.php b/app/Http/Requests/Storefront/SetCheckoutPaymentMethodRequest.php new file mode 100644 index 00000000..8a6c8d7a --- /dev/null +++ b/app/Http/Requests/Storefront/SetCheckoutPaymentMethodRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return [ + 'payment_method' => ['required', Rule::enum(PaymentMethod::class)], + ]; + } +} diff --git a/app/Http/Requests/Storefront/SetCheckoutShippingMethodRequest.php b/app/Http/Requests/Storefront/SetCheckoutShippingMethodRequest.php new file mode 100644 index 00000000..1d5e7b05 --- /dev/null +++ b/app/Http/Requests/Storefront/SetCheckoutShippingMethodRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'shipping_method_id' => ['required', 'integer'], + ]; + } +} diff --git a/app/Http/Requests/Storefront/StoreCartLineRequest.php b/app/Http/Requests/Storefront/StoreCartLineRequest.php new file mode 100644 index 00000000..7dc63b87 --- /dev/null +++ b/app/Http/Requests/Storefront/StoreCartLineRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return [ + 'variant_id' => ['required', 'integer'], + 'quantity' => ['required', 'integer', 'min:1', 'max:9999'], + 'cart_version' => ['nullable', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/Storefront/StoreCartRequest.php b/app/Http/Requests/Storefront/StoreCartRequest.php new file mode 100644 index 00000000..94c14b24 --- /dev/null +++ b/app/Http/Requests/Storefront/StoreCartRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'currency' => ['nullable', 'string', 'size:3'], + ]; + } +} diff --git a/app/Http/Requests/Storefront/StoreCheckoutRequest.php b/app/Http/Requests/Storefront/StoreCheckoutRequest.php new file mode 100644 index 00000000..3bf3a625 --- /dev/null +++ b/app/Http/Requests/Storefront/StoreCheckoutRequest.php @@ -0,0 +1,29 @@ +|string> + */ + public function rules(): array + { + return [ + 'cart_id' => ['required', 'integer'], + 'email' => ['required', 'email', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/Storefront/UpdateCartLineRequest.php b/app/Http/Requests/Storefront/UpdateCartLineRequest.php new file mode 100644 index 00000000..9d60511e --- /dev/null +++ b/app/Http/Requests/Storefront/UpdateCartLineRequest.php @@ -0,0 +1,29 @@ +|string> + */ + public function rules(): array + { + return [ + 'quantity' => ['required', 'integer', 'min:0', 'max:9999'], + 'cart_version' => ['required', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Resources/Storefront/CartLineResource.php b/app/Http/Resources/Storefront/CartLineResource.php new file mode 100644 index 00000000..c57c3c7b --- /dev/null +++ b/app/Http/Resources/Storefront/CartLineResource.php @@ -0,0 +1,42 @@ + + */ + public function toArray(Request $request): array + { + /** @var ProductVariant|null $variant */ + $variant = $this->resource->relationLoaded('variant') ? $this->resource->variant : null; + $product = $variant instanceof ProductVariant ? $variant->product : null; + $media = $product?->relationLoaded('media') ? $product->media->first() : null; + + return [ + 'id' => $this->id, + 'variant_id' => $this->variant_id, + 'product_title' => $product?->title, + 'variant_title' => $variant instanceof ProductVariant + ? ($variant->optionValues->pluck('value')->join(' / ') ?: 'Default') + : null, + 'sku' => $variant?->sku, + 'quantity' => $this->quantity, + 'unit_price_amount' => $this->unit_price_amount, + 'line_subtotal_amount' => $this->line_subtotal_amount, + 'line_discount_amount' => $this->line_discount_amount, + 'line_total_amount' => $this->line_total_amount, + 'image_url' => $media instanceof ProductMedia ? asset('storage/'.$media->storage_key) : null, + 'requires_shipping' => (bool) $variant?->requires_shipping, + 'available_quantity' => $variant?->inventoryItem?->availableQuantity(), + ]; + } +} diff --git a/app/Http/Resources/Storefront/CartResource.php b/app/Http/Resources/Storefront/CartResource.php new file mode 100644 index 00000000..b1eb83d6 --- /dev/null +++ b/app/Http/Resources/Storefront/CartResource.php @@ -0,0 +1,39 @@ + + */ + public function toArray(Request $request): array + { + $this->resource->loadMissing('lines.variant.product.media', 'lines.variant.optionValues.option', 'lines.variant.inventoryItem'); + + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'customer_id' => $this->customer_id, + 'currency' => $this->currency, + 'cart_version' => $this->cart_version, + 'status' => $this->status->value, + 'lines' => CartLineResource::collection($this->lines), + 'totals' => [ + 'subtotal' => $this->subtotalAmount(), + 'discount' => $this->discountAmount(), + 'total' => $this->totalAmount(), + 'currency' => $this->currency, + 'line_count' => $this->lines->count(), + 'item_count' => $this->itemCount(), + ], + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/app/Http/Resources/Storefront/CheckoutResource.php b/app/Http/Resources/Storefront/CheckoutResource.php new file mode 100644 index 00000000..7bebf417 --- /dev/null +++ b/app/Http/Resources/Storefront/CheckoutResource.php @@ -0,0 +1,52 @@ + + */ + public function toArray(Request $request): array + { + $this->resource->loadMissing('store', 'cart.lines.variant.product.media', 'cart.lines.variant.optionValues.option', 'cart.lines.variant.inventoryItem', 'shippingRate'); + $shippingAddress = $this->shipping_address_json ?? []; + $shippingMethods = $shippingAddress === [] + ? collect() + : app(ShippingCalculator::class)->getAvailableRateQuotes($this->store, $this->cart, $shippingAddress); + + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'cart_id' => $this->cart_id, + 'customer_id' => $this->customer_id, + 'status' => $this->status->value, + 'payment_method' => $this->payment_method?->value, + 'email' => $this->email, + 'shipping_address_json' => $this->shipping_address_json, + 'billing_address_json' => $this->billing_address_json, + 'shipping_method_id' => $this->shipping_method_id, + 'discount_code' => $this->discount_code, + 'lines' => CartLineResource::collection($this->cart->lines), + 'totals' => $this->totals_json ?? [ + 'subtotal' => 0, + 'discount' => 0, + 'shipping' => 0, + 'tax' => 0, + 'total' => 0, + 'currency' => $this->cart->currency, + ], + 'available_shipping_methods' => $shippingMethods->map->toArray()->all(), + 'tax_provider_snapshot_json' => $this->tax_provider_snapshot_json, + 'expires_at' => $this->expires_at?->toISOString(), + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/app/Jobs/CleanupAbandonedCarts.php b/app/Jobs/CleanupAbandonedCarts.php new file mode 100644 index 00000000..7872c530 --- /dev/null +++ b/app/Jobs/CleanupAbandonedCarts.php @@ -0,0 +1,24 @@ +where('status', CartStatus::Active) + ->where('updated_at', '<', now()->subDays(14)) + ->update(['status' => CartStatus::Abandoned]); + } +} diff --git a/app/Jobs/ExpireAbandonedCheckouts.php b/app/Jobs/ExpireAbandonedCheckouts.php new file mode 100644 index 00000000..b0c72753 --- /dev/null +++ b/app/Jobs/ExpireAbandonedCheckouts.php @@ -0,0 +1,27 @@ +whereNotIn('status', [CheckoutStatus::Completed, CheckoutStatus::Expired]) + ->whereNotNull('expires_at') + ->where('expires_at', '<', now()) + ->get() + ->each(fn (Checkout $checkout): mixed => $checkouts->expireCheckout($checkout)); + } +} diff --git a/app/Livewire/Storefront/Cart/Show.php b/app/Livewire/Storefront/Cart/Show.php index 9abb9971..d0735cbd 100644 --- a/app/Livewire/Storefront/Cart/Show.php +++ b/app/Livewire/Storefront/Cart/Show.php @@ -2,16 +2,87 @@ namespace App\Livewire\Storefront\Cart; +use App\Exceptions\InvalidCartMutationException; +use App\Models\Cart; +use App\Services\CartService; +use App\Services\CheckoutService; use Illuminate\View\View; use Livewire\Component; class Show extends Component { + public string $email = ''; + + public function updateQuantity(int $lineId, int $quantity): void + { + $cart = $this->cart(); + + if (! $cart instanceof Cart) { + return; + } + + try { + app(CartService::class)->updateLineQuantity($cart, $lineId, $quantity, $cart->cart_version); + $this->dispatch('cart-updated'); + } catch (InvalidCartMutationException $exception) { + $this->addError('cart', $exception->getMessage()); + } + } + + public function removeLine(int $lineId): void + { + $cart = $this->cart(); + + if (! $cart instanceof Cart) { + return; + } + + app(CartService::class)->removeLine($cart, $lineId, $cart->cart_version); + $this->dispatch('cart-updated'); + } + + public function startCheckout(): mixed + { + $this->validate([ + 'email' => ['required', 'email', 'max:255'], + ]); + + $cart = $this->cart(); + + if (! $cart instanceof Cart || $cart->lines->isEmpty()) { + $this->addError('cart', 'Add at least one item before checkout.'); + + return null; + } + + $checkout = app(CheckoutService::class)->createFromCart($cart, $this->email); + + return $this->redirect(route('storefront.checkout.show', $checkout), navigate: true); + } + public function render(): View { - return view('livewire.storefront.cart.show') + return view('livewire.storefront.cart.show', [ + 'cart' => $this->cart(), + ]) ->layout('storefront.layouts.app', [ 'title' => 'Cart', ]); } + + private function cart(): ?Cart + { + $cartId = session(CartService::SESSION_KEY); + + if (! $cartId) { + return null; + } + + $cart = Cart::withoutGlobalScopes() + ->where('store_id', app('current_store')->id) + ->whereKey($cartId) + ->first(); + + return $cart instanceof Cart ? app(CartService::class)->loadForDisplay($cart) : null; + } } diff --git a/app/Livewire/Storefront/CartDrawer.php b/app/Livewire/Storefront/CartDrawer.php index d2e4c86e..724f9025 100644 --- a/app/Livewire/Storefront/CartDrawer.php +++ b/app/Livewire/Storefront/CartDrawer.php @@ -2,13 +2,35 @@ namespace App\Livewire\Storefront; +use App\Models\Cart; +use App\Services\CartService; use Illuminate\View\View; use Livewire\Component; class CartDrawer extends Component { + protected $listeners = ['cart-updated' => '$refresh']; + public function render(): View { - return view('livewire.storefront.cart-drawer'); + return view('livewire.storefront.cart-drawer', [ + 'cart' => $this->cart(), + ]); + } + + private function cart(): ?Cart + { + $cartId = session(CartService::SESSION_KEY); + + if (! $cartId || ! app()->bound('current_store')) { + return null; + } + + $cart = Cart::withoutGlobalScopes() + ->where('store_id', app('current_store')->id) + ->whereKey($cartId) + ->first(); + + return $cart instanceof Cart ? app(CartService::class)->loadForDisplay($cart) : null; } } diff --git a/app/Livewire/Storefront/Checkout/Confirmation.php b/app/Livewire/Storefront/Checkout/Confirmation.php new file mode 100644 index 00000000..6f1859e5 --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Confirmation.php @@ -0,0 +1,30 @@ +checkoutId = $checkoutId; + } + + public function render(): View + { + return view('livewire.storefront.checkout.confirmation', [ + 'checkout' => Checkout::withoutGlobalScopes() + ->with('cart.lines.variant.product') + ->where('store_id', app('current_store')->id) + ->whereKey($this->checkoutId) + ->firstOrFail(), + ])->layout('storefront.layouts.app', [ + 'title' => 'Confirmation', + ]); + } +} diff --git a/app/Livewire/Storefront/Checkout/Show.php b/app/Livewire/Storefront/Checkout/Show.php new file mode 100644 index 00000000..cfe906f7 --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Show.php @@ -0,0 +1,154 @@ + + */ + public array $shippingAddress = [ + 'first_name' => '', + 'last_name' => '', + 'address1' => '', + 'address2' => '', + 'city' => '', + 'province' => '', + 'province_code' => '', + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => '', + 'phone' => '', + ]; + + public ?int $selectedShippingRateId = null; + + public string $paymentMethod = 'credit_card'; + + public string $discountCode = ''; + + public function mount(int $checkoutId): void + { + $this->checkoutId = $checkoutId; + $checkout = $this->checkout(); + + if (is_array($checkout->shipping_address_json)) { + $this->shippingAddress = array_merge($this->shippingAddress, $checkout->shipping_address_json); + } + + $this->selectedShippingRateId = $checkout->shipping_method_id; + $this->paymentMethod = $checkout->payment_method?->value ?? PaymentMethod::CreditCard->value; + $this->discountCode = $checkout->discount_code ?? ''; + } + + public function saveAddress(): void + { + $this->validate([ + 'shippingAddress.first_name' => ['required', 'string', 'max:255'], + 'shippingAddress.last_name' => ['required', 'string', 'max:255'], + 'shippingAddress.address1' => ['required', 'string', 'max:500'], + 'shippingAddress.city' => ['required', 'string', 'max:255'], + 'shippingAddress.country_code' => ['required', 'string', 'size:2'], + 'shippingAddress.postal_code' => ['required', 'string', 'max:20'], + ]); + + try { + app(CheckoutService::class)->setAddress($this->checkout(), [ + 'shipping_address' => $this->shippingAddress, + 'use_shipping_as_billing' => true, + ]); + } catch (CheckoutStateException $exception) { + $this->addError('checkout', $exception->getMessage()); + } + } + + public function selectShipping(): void + { + $this->validate([ + 'selectedShippingRateId' => ['required', 'integer'], + ]); + + try { + app(CheckoutService::class)->setShippingMethod($this->checkout(), (int) $this->selectedShippingRateId); + } catch (CheckoutStateException|UnavailableShippingRateException $exception) { + $this->addError('checkout', $exception->getMessage()); + } + } + + public function applyDiscount(): void + { + $this->validate([ + 'discountCode' => ['required', 'string', 'max:50'], + ]); + + try { + app(CheckoutService::class)->applyDiscount($this->checkout(), $this->discountCode); + } catch (CheckoutStateException|InvalidDiscountException $exception) { + $this->addError('discountCode', $exception->getMessage()); + } + } + + public function removeDiscount(): void + { + try { + app(CheckoutService::class)->removeDiscount($this->checkout()); + $this->discountCode = ''; + } catch (CheckoutStateException $exception) { + $this->addError('discountCode', $exception->getMessage()); + } + } + + public function selectPayment(): void + { + $this->validate([ + 'paymentMethod' => ['required', 'in:credit_card,paypal,bank_transfer'], + ]); + + try { + app(CheckoutService::class)->selectPaymentMethod( + $this->checkout(), + PaymentMethod::from($this->paymentMethod), + ); + session()->flash('checkout_status', 'Payment method saved.'); + } catch (CheckoutStateException $exception) { + $this->addError('checkout', $exception->getMessage()); + } + } + + public function render(): View + { + $checkout = $this->checkout(); + $shippingMethods = $checkout->shipping_address_json + ? app(ShippingCalculator::class)->getAvailableRateQuotes(app('current_store'), $checkout->cart, $checkout->shipping_address_json) + : collect(); + + return view('livewire.storefront.checkout.show', [ + 'checkout' => $checkout, + 'shippingMethods' => $shippingMethods, + 'totals' => $checkout->totals_json ?? [], + ])->layout('storefront.layouts.app', [ + 'title' => 'Checkout', + ]); + } + + private function checkout(): Checkout + { + return Checkout::withoutGlobalScopes() + ->with('cart.lines.variant.product.media', 'cart.lines.variant.optionValues.option', 'cart.lines.variant.inventoryItem', 'shippingRate') + ->where('store_id', app('current_store')->id) + ->whereKey($this->checkoutId) + ->firstOrFail(); + } +} diff --git a/app/Livewire/Storefront/Products/Show.php b/app/Livewire/Storefront/Products/Show.php index 8aac80fa..09938181 100644 --- a/app/Livewire/Storefront/Products/Show.php +++ b/app/Livewire/Storefront/Products/Show.php @@ -3,7 +3,11 @@ namespace App\Livewire\Storefront\Products; use App\Enums\ProductStatus; +use App\Exceptions\InvalidCartMutationException; use App\Models\Product; +use App\Models\ProductVariant; +use App\Services\CartService; +use Illuminate\Database\Eloquent\Builder; use Illuminate\View\View; use Livewire\Component; @@ -13,6 +17,8 @@ class Show extends Component public ?int $selectedVariantId = null; + public int $quantity = 1; + public function mount(string $handle): void { $this->handle = $handle; @@ -23,18 +29,38 @@ public function selectVariant(int $variantId): void $this->selectedVariantId = $variantId; } - public function render(): View + public function addToCart(): void { - $product = Product::query() - ->with('variants.optionValues.option', 'variants.inventoryItem', 'media') - ->where('handle', $this->handle) - ->where('status', ProductStatus::Active) - ->whereNotNull('published_at') - ->firstOrFail(); + $this->validate([ + 'quantity' => ['required', 'integer', 'min:1', 'max:9999'], + ]); - $selectedVariant = $product->variants->firstWhere('id', $this->selectedVariantId) - ?? $product->variants->firstWhere('is_default', true) - ?? $product->variants->first(); + $product = $this->productQuery()->firstOrFail(); + $variant = $this->selectedVariant($product); + + if (! $variant instanceof ProductVariant) { + $this->addError('quantity', 'This product is not available.'); + + return; + } + + try { + $cart = app(CartService::class)->getOrCreateForSession(app('current_store')); + app(CartService::class)->addLine($cart, $variant->id, $this->quantity); + } catch (InvalidCartMutationException $exception) { + $this->addError('quantity', $exception->getMessage()); + + return; + } + + $this->dispatch('cart-updated'); + session()->flash('cart_status', 'Added to cart.'); + } + + public function render(): View + { + $product = $this->productQuery()->firstOrFail(); + $selectedVariant = $this->selectedVariant($product); return view('livewire.storefront.products.show', [ 'product' => $product, @@ -43,4 +69,20 @@ public function render(): View 'title' => $product->title, ]); } + + private function productQuery(): Builder + { + return Product::query() + ->with('variants.optionValues.option', 'variants.inventoryItem', 'media') + ->where('handle', $this->handle) + ->where('status', ProductStatus::Active) + ->whereNotNull('published_at'); + } + + private function selectedVariant(Product $product): ?ProductVariant + { + return $product->variants->firstWhere('id', $this->selectedVariantId) + ?? $product->variants->firstWhere('is_default', true) + ?? $product->variants->first(); + } } diff --git a/app/Models/Cart.php b/app/Models/Cart.php new file mode 100644 index 00000000..f473459a --- /dev/null +++ b/app/Models/Cart.php @@ -0,0 +1,98 @@ + */ + use BelongsToStore, HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'customer_id', + 'currency', + 'cart_version', + 'status', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'currency' => 'USD', + 'cart_version' => 1, + 'status' => CartStatus::Active->value, + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => CartStatus::class, + ]; + } + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @return BelongsTo + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** + * @return HasMany + */ + public function lines(): HasMany + { + return $this->hasMany(CartLine::class); + } + + /** + * @return HasMany + */ + public function checkouts(): HasMany + { + return $this->hasMany(Checkout::class); + } + + public function subtotalAmount(): int + { + return (int) $this->lines->sum('line_subtotal_amount'); + } + + public function discountAmount(): int + { + return (int) $this->lines->sum('line_discount_amount'); + } + + public function totalAmount(): int + { + return (int) $this->lines->sum('line_total_amount'); + } + + public function itemCount(): int + { + return (int) $this->lines->sum('quantity'); + } +} diff --git a/app/Models/CartLine.php b/app/Models/CartLine.php new file mode 100644 index 00000000..9a381ad3 --- /dev/null +++ b/app/Models/CartLine.php @@ -0,0 +1,55 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'cart_id', + 'variant_id', + 'quantity', + 'unit_price_amount', + 'line_subtotal_amount', + 'line_discount_amount', + 'line_total_amount', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'quantity' => 1, + 'unit_price_amount' => 0, + 'line_subtotal_amount' => 0, + 'line_discount_amount' => 0, + 'line_total_amount' => 0, + ]; + + /** + * @return BelongsTo + */ + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + /** + * @return BelongsTo + */ + 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..3cd4072b --- /dev/null +++ b/app/Models/Checkout.php @@ -0,0 +1,96 @@ + */ + use BelongsToStore, HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'cart_id', + 'customer_id', + 'status', + 'payment_method', + 'email', + 'shipping_address_json', + 'billing_address_json', + 'shipping_method_id', + 'discount_code', + 'tax_provider_snapshot_json', + 'totals_json', + 'expires_at', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'status' => CheckoutStatus::Started->value, + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => CheckoutStatus::class, + 'payment_method' => PaymentMethod::class, + 'shipping_address_json' => 'array', + 'billing_address_json' => 'array', + 'tax_provider_snapshot_json' => 'array', + 'totals_json' => 'array', + 'expires_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @return BelongsTo + */ + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + /** + * @return BelongsTo + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** + * @return BelongsTo + */ + public function shippingRate(): BelongsTo + { + return $this->belongsTo(ShippingRate::class, 'shipping_method_id'); + } + + public function isExpired(): bool + { + return $this->status === CheckoutStatus::Expired + || ($this->expires_at !== null && $this->expires_at->isPast()); + } +} diff --git a/app/Models/Discount.php b/app/Models/Discount.php new file mode 100644 index 00000000..eb2860ec --- /dev/null +++ b/app/Models/Discount.php @@ -0,0 +1,68 @@ + */ + use BelongsToStore, HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'type', + 'code', + 'value_type', + 'value_amount', + 'starts_at', + 'ends_at', + 'usage_limit', + 'usage_count', + 'rules_json', + 'status', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'type' => DiscountType::Code->value, + 'value_amount' => 0, + 'usage_count' => 0, + 'rules_json' => '{}', + 'status' => DiscountStatus::Active->value, + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => DiscountType::class, + 'value_type' => DiscountValueType::class, + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'rules_json' => 'array', + 'status' => DiscountStatus::class, + ]; + } + + /** + * @return BelongsTo + */ + 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..31c252f7 --- /dev/null +++ b/app/Models/ShippingRate.php @@ -0,0 +1,56 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'zone_id', + 'name', + 'type', + 'config_json', + 'is_active', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'type' => ShippingRateType::Flat->value, + 'config_json' => '{}', + 'is_active' => true, + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => ShippingRateType::class, + 'config_json' => 'array', + 'is_active' => 'bool', + ]; + } + + /** + * @return BelongsTo + */ + 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..e727b9f3 --- /dev/null +++ b/app/Models/ShippingZone.php @@ -0,0 +1,62 @@ + */ + use BelongsToStore, HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'name', + 'countries_json', + 'regions_json', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'countries_json' => '[]', + 'regions_json' => '[]', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'countries_json' => 'array', + 'regions_json' => 'array', + ]; + } + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** + * @return HasMany + */ + public function rates(): HasMany + { + return $this->hasMany(ShippingRate::class, 'zone_id'); + } +} diff --git a/app/Models/Store.php b/app/Models/Store.php index c1d1fa01..722fbc8d 100644 --- a/app/Models/Store.php +++ b/app/Models/Store.php @@ -130,6 +130,46 @@ public function navigationMenus(): HasMany return $this->hasMany(NavigationMenu::class); } + /** + * @return HasMany + */ + public function carts(): HasMany + { + return $this->hasMany(Cart::class); + } + + /** + * @return HasMany + */ + public function checkouts(): HasMany + { + return $this->hasMany(Checkout::class); + } + + /** + * @return HasMany + */ + public function shippingZones(): HasMany + { + return $this->hasMany(ShippingZone::class); + } + + /** + * @return HasOne + */ + public function taxSettings(): HasOne + { + return $this->hasOne(TaxSettings::class); + } + + /** + * @return HasMany + */ + public function discounts(): HasMany + { + return $this->hasMany(Discount::class); + } + public function isSuspended(): bool { return $this->status === StoreStatus::Suspended; diff --git a/app/Models/TaxSettings.php b/app/Models/TaxSettings.php new file mode 100644 index 00000000..94f8dbad --- /dev/null +++ b/app/Models/TaxSettings.php @@ -0,0 +1,62 @@ + */ + use BelongsToStore, HasFactory; + + public const CREATED_AT = null; + + protected $primaryKey = 'store_id'; + + public $incrementing = false; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'mode', + 'provider', + 'prices_include_tax', + 'config_json', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'mode' => TaxMode::Manual->value, + 'provider' => 'none', + 'prices_include_tax' => false, + 'config_json' => '{}', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'mode' => TaxMode::class, + 'prices_include_tax' => 'bool', + 'config_json' => 'array', + ]; + } + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f0d294ee..037b4a86 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Http\Middleware\ResolveStore; use App\Services\ThemeSettingsService; use Carbon\CarbonImmutable; use Illuminate\Cache\RateLimiting\Limit; @@ -11,6 +12,7 @@ use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; +use Livewire\Livewire; class AppServiceProvider extends ServiceProvider { @@ -27,6 +29,10 @@ public function register(): void */ public function boot(): void { + Livewire::addPersistentMiddleware([ + ResolveStore::class, + ]); + $this->configureDefaults(); $this->configureRateLimiting(); } @@ -58,5 +64,9 @@ protected function configureRateLimiting(): void RateLimiter::for('api.storefront', function (Request $request) { return Limit::perMinute(120)->by($request->ip()); }); + + RateLimiter::for('checkout', function (Request $request) { + return Limit::perMinute(10)->by($request->hasSession() ? $request->session()->getId() : $request->ip()); + }); } } diff --git a/app/Services/CartService.php b/app/Services/CartService.php new file mode 100644 index 00000000..ee0452be --- /dev/null +++ b/app/Services/CartService.php @@ -0,0 +1,292 @@ +create([ + 'store_id' => $store->id, + 'customer_id' => $customer?->id, + 'currency' => $store->default_currency, + 'cart_version' => 1, + 'status' => CartStatus::Active, + ]); + } + + public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart + { + $guestCart = $this->cartFromSession($store); + + if ($customer !== null) { + $customerCart = Cart::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('customer_id', $customer->id) + ->where('status', CartStatus::Active) + ->latest() + ->first(); + + if ($guestCart !== null && $customerCart !== null && $guestCart->isNot($customerCart)) { + $merged = $this->mergeOnLogin($guestCart, $customerCart); + session()->forget(self::SESSION_KEY); + + return $merged; + } + + if ($customerCart !== null) { + session()->forget(self::SESSION_KEY); + + return $customerCart; + } + } + + if ($guestCart !== null) { + return $guestCart; + } + + $cart = $this->create($store, $customer); + + if ($customer === null) { + session()->put(self::SESSION_KEY, $cart->id); + } + + return $cart; + } + + public function findForStore(Store $store, int $cartId): Cart + { + return Cart::withoutGlobalScopes() + ->where('store_id', $store->id) + ->whereKey($cartId) + ->firstOrFail(); + } + + public function addLine(Cart $cart, int $variantId, int $quantity, ?int $expectedVersion = null): CartLine + { + $this->guardPositiveQuantity($quantity); + + return DB::transaction(function () use ($cart, $variantId, $quantity, $expectedVersion): CartLine { + $lockedCart = $this->lockCart($cart); + $this->assertVersion($lockedCart, $expectedVersion); + $this->guardActiveCart($lockedCart); + + $variant = $this->validVariantForCart($lockedCart, $variantId); + $line = CartLine::query() + ->where('cart_id', $lockedCart->id) + ->where('variant_id', $variant->id) + ->lockForUpdate() + ->first(); + + $newQuantity = ($line?->quantity ?? 0) + $quantity; + $this->guardAvailable($variant, $newQuantity); + + $line ??= new CartLine([ + 'cart_id' => $lockedCart->id, + 'variant_id' => $variant->id, + ]); + + $this->fillLineAmounts($line, $variant->price_amount, $newQuantity); + $line->save(); + $this->incrementVersion($lockedCart); + + return $line->refresh()->load('variant.product', 'variant.inventoryItem'); + }); + } + + public function updateLineQuantity(Cart $cart, int $lineId, int $quantity, ?int $expectedVersion = null): ?CartLine + { + if ($quantity < 1) { + $this->removeLine($cart, $lineId, $expectedVersion); + + return null; + } + + return DB::transaction(function () use ($cart, $lineId, $quantity, $expectedVersion): CartLine { + $lockedCart = $this->lockCart($cart); + $this->assertVersion($lockedCart, $expectedVersion); + $this->guardActiveCart($lockedCart); + + $line = CartLine::query() + ->where('cart_id', $lockedCart->id) + ->whereKey($lineId) + ->lockForUpdate() + ->firstOrFail(); + + $variant = $this->validVariantForCart($lockedCart, $line->variant_id); + $this->guardAvailable($variant, $quantity); + $this->fillLineAmounts($line, $variant->price_amount, $quantity); + $line->save(); + $this->incrementVersion($lockedCart); + + return $line->refresh()->load('variant.product', 'variant.inventoryItem'); + }); + } + + public function removeLine(Cart $cart, int $lineId, ?int $expectedVersion = null): void + { + DB::transaction(function () use ($cart, $lineId, $expectedVersion): void { + $lockedCart = $this->lockCart($cart); + $this->assertVersion($lockedCart, $expectedVersion); + $this->guardActiveCart($lockedCart); + + CartLine::query() + ->where('cart_id', $lockedCart->id) + ->whereKey($lineId) + ->lockForUpdate() + ->firstOrFail() + ->delete(); + + $this->incrementVersion($lockedCart); + }); + } + + public function mergeOnLogin(Cart $guest, Cart $customer): Cart + { + return DB::transaction(function () use ($guest, $customer): Cart { + $guest = $this->lockCart($guest)->load('lines'); + $customer = $this->lockCart($customer)->load('lines'); + + foreach ($guest->lines as $guestLine) { + $customerLine = $customer->lines->firstWhere('variant_id', $guestLine->variant_id); + + if ($customerLine !== null) { + $quantity = max($customerLine->quantity, $guestLine->quantity); + $variant = $this->validVariantForCart($customer, $customerLine->variant_id); + $this->guardAvailable($variant, $quantity); + $this->fillLineAmounts($customerLine, $variant->price_amount, $quantity); + $customerLine->save(); + $guestLine->delete(); + + continue; + } + + $guestLine->forceFill(['cart_id' => $customer->id])->save(); + } + + $guest->forceFill(['status' => CartStatus::Abandoned])->save(); + $this->incrementVersion($customer); + + return $customer->refresh()->load('lines.variant.product', 'lines.variant.inventoryItem'); + }); + } + + public function loadForDisplay(Cart $cart): Cart + { + return $cart->refresh()->load( + 'lines.variant.product.media', + 'lines.variant.optionValues.option', + 'lines.variant.inventoryItem', + ); + } + + private function cartFromSession(Store $store): ?Cart + { + $cartId = session()->get(self::SESSION_KEY); + + if (! $cartId) { + return null; + } + + return Cart::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('status', CartStatus::Active) + ->whereKey($cartId) + ->first(); + } + + private function lockCart(Cart $cart): Cart + { + return Cart::withoutGlobalScopes() + ->whereKey($cart->id) + ->lockForUpdate() + ->firstOrFail(); + } + + private function assertVersion(Cart $cart, ?int $expectedVersion): void + { + if ($expectedVersion !== null && $cart->cart_version !== $expectedVersion) { + throw new CartVersionConflictException($this->loadForDisplay($cart)); + } + } + + private function guardActiveCart(Cart $cart): void + { + if ($cart->status !== CartStatus::Active) { + throw new InvalidCartMutationException('Only active carts may be changed.'); + } + } + + private function validVariantForCart(Cart $cart, int $variantId): ProductVariant + { + $variant = ProductVariant::query() + ->with([ + 'product' => fn ($query) => $query->withoutGlobalScopes(), + 'inventoryItem' => fn ($query) => $query->withoutGlobalScopes(), + ]) + ->whereKey($variantId) + ->firstOrFail(); + + if ($variant->product->store_id !== $cart->store_id) { + throw new InvalidCartMutationException('The selected variant does not belong to this store.'); + } + + if ($variant->product->status !== ProductStatus::Active || $variant->status !== VariantStatus::Active) { + throw new InvalidCartMutationException('The selected variant is not available.'); + } + + if ($variant->inventoryItem === null) { + throw new InvalidCartMutationException('The selected variant has no inventory record.'); + } + + return $variant; + } + + private function guardAvailable(ProductVariant $variant, int $quantity): void + { + if (! $this->inventory->checkAvailability($variant->inventoryItem, $quantity)) { + throw new InvalidCartMutationException('The selected variant is out of stock.'); + } + } + + private function fillLineAmounts(CartLine $line, int $unitPrice, int $quantity): void + { + $subtotal = $unitPrice * $quantity; + + $line->forceFill([ + 'quantity' => $quantity, + 'unit_price_amount' => $unitPrice, + 'line_subtotal_amount' => $subtotal, + 'line_discount_amount' => 0, + 'line_total_amount' => $subtotal, + ]); + } + + private function incrementVersion(Cart $cart): void + { + $cart->increment('cart_version'); + } + + private function guardPositiveQuantity(int $quantity): void + { + if ($quantity < 1) { + throw new InvalidCartMutationException('Quantity must be greater than zero.'); + } + } +} diff --git a/app/Services/CheckoutService.php b/app/Services/CheckoutService.php new file mode 100644 index 00000000..0a7b3d97 --- /dev/null +++ b/app/Services/CheckoutService.php @@ -0,0 +1,251 @@ +with('lines') + ->whereKey($cart->id) + ->lockForUpdate() + ->firstOrFail(); + + if ($cart->status !== CartStatus::Active) { + throw new CheckoutStateException('Only active carts may be checked out.'); + } + + if ($cart->lines->isEmpty()) { + throw ValidationException::withMessages([ + 'cart_id' => ['The cart must contain at least one line.'], + ]); + } + + $checkout = Checkout::query()->create([ + 'store_id' => $cart->store_id, + 'cart_id' => $cart->id, + 'customer_id' => $cart->customer_id, + 'status' => CheckoutStatus::Started, + 'email' => $email, + 'expires_at' => now()->addDay(), + ]); + + $this->pricing->calculate($checkout); + + return $checkout->refresh()->load('cart.lines.variant.product', 'shippingRate'); + }); + } + + /** + * @param array $data + */ + public function setAddress(Checkout $checkout, array $data): Checkout + { + return DB::transaction(function () use ($checkout, $data): Checkout { + $checkout = $this->lockCheckout($checkout); + $this->guardActiveCheckout($checkout); + + $shippingAddress = $data['shipping_address'] ?? []; + $billingAddress = ($data['use_shipping_as_billing'] ?? true) + ? $shippingAddress + : ($data['billing_address'] ?? $shippingAddress); + + $checkout->forceFill([ + 'shipping_address_json' => $shippingAddress, + 'billing_address_json' => $billingAddress, + 'status' => $this->shipping->requiresShipping($checkout->cart) + ? CheckoutStatus::Addressed + : CheckoutStatus::ShippingSelected, + ])->save(); + + $this->pricing->calculate($checkout); + + return $checkout->refresh()->load('cart.lines.variant.product', 'shippingRate'); + }); + } + + public function setShippingMethod(Checkout $checkout, int $shippingRateId): Checkout + { + return DB::transaction(function () use ($checkout, $shippingRateId): Checkout { + $checkout = $this->lockCheckout($checkout); + $this->guardActiveCheckout($checkout); + + if (! in_array($checkout->status, [CheckoutStatus::Addressed, CheckoutStatus::ShippingSelected], true)) { + throw new CheckoutStateException('The checkout address must be set before selecting shipping.'); + } + + if (! $this->shipping->requiresShipping($checkout->cart)) { + $checkout->forceFill([ + 'shipping_method_id' => null, + 'status' => CheckoutStatus::ShippingSelected, + ])->save(); + $this->pricing->calculate($checkout); + + return $checkout->refresh()->load('cart.lines.variant.product', 'shippingRate'); + } + + $quotes = $this->shipping->getAvailableRateQuotes( + $checkout->store, + $checkout->cart, + $checkout->shipping_address_json ?? [], + ); + + if (! $quotes->contains(fn ($quote): bool => $quote->rate->id === $shippingRateId)) { + throw new UnavailableShippingRateException('The selected shipping rate is not available for this checkout.'); + } + + $checkout->forceFill([ + 'shipping_method_id' => $shippingRateId, + 'status' => CheckoutStatus::ShippingSelected, + ])->save(); + $this->pricing->calculate($checkout); + + return $checkout->refresh()->load('cart.lines.variant.product', 'shippingRate'); + }); + } + + public function selectPaymentMethod(Checkout $checkout, PaymentMethod $method): Checkout + { + return DB::transaction(function () use ($checkout, $method): Checkout { + $checkout = $this->lockCheckout($checkout); + $this->guardActiveCheckout($checkout); + + if (! in_array($checkout->status, [CheckoutStatus::ShippingSelected, CheckoutStatus::PaymentSelected], true)) { + throw new CheckoutStateException('Shipping must be selected before payment.'); + } + + if ($checkout->status !== CheckoutStatus::PaymentSelected) { + $this->reserveInventory($checkout); + } + + $checkout->forceFill([ + 'payment_method' => $method, + 'status' => CheckoutStatus::PaymentSelected, + 'expires_at' => now()->addDay(), + ])->save(); + $this->pricing->calculate($checkout); + + return $checkout->refresh()->load('cart.lines.variant.product', 'shippingRate'); + }); + } + + public function applyDiscount(Checkout $checkout, string $code): Checkout + { + return DB::transaction(function () use ($checkout, $code): Checkout { + $checkout = $this->lockCheckout($checkout); + $this->guardActiveCheckout($checkout); + $discount = $this->discounts->validate($code, $checkout->store, $checkout->cart); + + $checkout->forceFill([ + 'discount_code' => Str::upper((string) $discount->code), + ])->save(); + $this->pricing->calculate($checkout); + + return $checkout->refresh()->load('cart.lines.variant.product', 'shippingRate'); + }); + } + + public function removeDiscount(Checkout $checkout): Checkout + { + return DB::transaction(function () use ($checkout): Checkout { + $checkout = $this->lockCheckout($checkout); + $this->guardActiveCheckout($checkout); + + if ($checkout->discount_code === null) { + throw new CheckoutStateException('No discount is applied to this checkout.'); + } + + $checkout->forceFill(['discount_code' => null])->save(); + $this->pricing->calculate($checkout); + + return $checkout->refresh()->load('cart.lines.variant.product', 'shippingRate'); + }); + } + + public function expireCheckout(Checkout $checkout): void + { + DB::transaction(function () use ($checkout): void { + $checkout = $this->lockCheckout($checkout); + + if (in_array($checkout->status, [CheckoutStatus::Completed, CheckoutStatus::Expired], true)) { + return; + } + + if ($checkout->status === CheckoutStatus::PaymentSelected) { + $this->releaseInventory($checkout); + } + + $checkout->forceFill(['status' => CheckoutStatus::Expired])->save(); + }); + } + + public function findForStore(int $checkoutId): Checkout + { + return Checkout::query() + ->with('cart.lines.variant.product', 'shippingRate') + ->whereKey($checkoutId) + ->firstOrFail(); + } + + private function lockCheckout(Checkout $checkout): Checkout + { + return Checkout::withoutGlobalScopes() + ->with('store', 'cart.lines.variant.inventoryItem', 'cart.lines.variant.product.collections', 'shippingRate') + ->whereKey($checkout->id) + ->lockForUpdate() + ->firstOrFail(); + } + + private function guardActiveCheckout(Checkout $checkout): void + { + if ($checkout->isExpired()) { + throw new CheckoutStateException('This checkout has expired.'); + } + + if ($checkout->status === CheckoutStatus::Completed) { + throw new CheckoutStateException('This checkout is already completed.'); + } + } + + private function reserveInventory(Checkout $checkout): void + { + foreach ($checkout->cart->lines as $line) { + $item = $line->variant?->inventoryItem; + + if ($item !== null) { + $this->inventory->reserve($item, $line->quantity); + } + } + } + + private function releaseInventory(Checkout $checkout): void + { + foreach ($checkout->cart->lines as $line) { + $item = $line->variant?->inventoryItem; + + if ($item !== null) { + $this->inventory->release($item, $line->quantity); + } + } + } +} diff --git a/app/Services/DiscountService.php b/app/Services/DiscountService.php new file mode 100644 index 00000000..7963592e --- /dev/null +++ b/app/Services/DiscountService.php @@ -0,0 +1,141 @@ +where('store_id', $store->id) + ->where('type', DiscountType::Code) + ->whereRaw('lower(code) = ?', [Str::lower($code)]) + ->first(); + + if ($discount === null) { + throw new InvalidDiscountException('discount_not_found', 'This discount code does not exist.'); + } + + if ($discount->status !== DiscountStatus::Active) { + throw new InvalidDiscountException('discount_expired', 'This discount code is not active.'); + } + + if ($discount->starts_at->isFuture()) { + throw new InvalidDiscountException('discount_not_yet_active', 'This discount code is not active yet.'); + } + + if ($discount->ends_at !== null && $discount->ends_at->isPast()) { + throw new InvalidDiscountException('discount_expired', 'This discount code has expired.'); + } + + if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) { + throw new InvalidDiscountException('discount_usage_limit_reached', 'This discount code has reached its usage limit.'); + } + + $cart->loadMissing('lines.variant.product.collections'); + $subtotal = (int) $cart->lines->sum('line_subtotal_amount'); + $rules = $discount->rules_json ?? []; + $minimum = $rules['min_purchase_amount'] ?? null; + + if ($minimum !== null && $subtotal < (int) $minimum) { + throw new InvalidDiscountException('discount_min_purchase_not_met', 'This discount requires a larger cart subtotal.'); + } + + if ($this->qualifyingLines($cart->lines, $rules)->isEmpty()) { + throw new InvalidDiscountException('discount_not_applicable', 'This discount does not apply to the cart items.'); + } + + return $discount; + } + + /** + * @param iterable $lines + */ + public function calculate(Discount $discount, int $subtotal, iterable $lines): DiscountResult + { + if ($discount->value_type === DiscountValueType::FreeShipping) { + return new DiscountResult(0, [], true); + } + + $qualifyingLines = $this->qualifyingLines(collect($lines), $discount->rules_json ?? []); + $qualifyingSubtotal = (int) $qualifyingLines->sum('line_subtotal_amount'); + + if ($qualifyingSubtotal < 1 || $subtotal < 1) { + return new DiscountResult(0, []); + } + + $amount = match ($discount->value_type) { + DiscountValueType::Percent => (int) round($qualifyingSubtotal * $discount->value_amount / 100), + DiscountValueType::Fixed => min($discount->value_amount, $qualifyingSubtotal), + DiscountValueType::FreeShipping => 0, + }; + + if ($amount < 1) { + return new DiscountResult(0, []); + } + + $remaining = $amount; + $allocations = []; + $lastIndex = $qualifyingLines->count() - 1; + + foreach ($qualifyingLines->values() as $index => $line) { + $lineAmount = $index === $lastIndex + ? $remaining + : (int) round($amount * $line->line_subtotal_amount / $qualifyingSubtotal); + + $lineAmount = min($lineAmount, $line->line_subtotal_amount); + $allocations[$line->id] = $lineAmount; + $remaining -= $lineAmount; + } + + return new DiscountResult(array_sum($allocations), $allocations); + } + + /** + * @param Collection $lines + * @param array $rules + * @return Collection + */ + private function qualifyingLines(Collection $lines, array $rules): Collection + { + $productIds = array_filter($rules['applicable_product_ids'] ?? []); + $collectionIds = array_filter($rules['applicable_collection_ids'] ?? []); + + if ($productIds === [] && $collectionIds === []) { + return $lines; + } + + return $lines->filter(function (CartLine $line) use ($productIds, $collectionIds): bool { + $product = $line->variant?->product; + + if ($product === null) { + return false; + } + + if ($productIds !== [] && in_array($product->id, $productIds, true)) { + return true; + } + + if ($collectionIds === []) { + return false; + } + + return $product->collections + ->pluck('id') + ->intersect($collectionIds) + ->isNotEmpty(); + }); + } +} diff --git a/app/Services/PricingEngine.php b/app/Services/PricingEngine.php new file mode 100644 index 00000000..def32b68 --- /dev/null +++ b/app/Services/PricingEngine.php @@ -0,0 +1,159 @@ +loadMissing('store', 'cart.lines.variant.product.collections', 'shippingRate'); + $cart = $checkout->cart; + $cart->loadMissing('lines.variant.product.collections'); + + $subtotal = (int) $cart->lines->sum('line_subtotal_amount'); + $discountAmount = 0; + $freeShipping = false; + + if ($checkout->discount_code !== null) { + $discount = $this->discounts->validate($checkout->discount_code, $checkout->store, $cart); + $discountResult = $this->discounts->calculate($discount, $subtotal, $cart->lines); + $discountAmount = $discountResult->amount; + $freeShipping = $discountResult->freeShipping; + $this->applyLineDiscounts($cart->lines, $discountResult->allocations); + } else { + $this->applyLineDiscounts($cart->lines, []); + } + + $cart->refresh()->load('lines.variant.product.collections'); + $discountedSubtotal = (int) $cart->lines->sum('line_total_amount'); + $shippingAmount = $this->shippingAmount($checkout); + + if ($freeShipping) { + $shippingAmount = 0; + } + + $settings = $this->taxSettings($checkout); + $taxLines = $this->taxLines($checkout, $settings, $discountedSubtotal, $shippingAmount); + $taxTotal = array_sum(array_map(fn (TaxLine $line): int => $line->amount, $taxLines)); + $total = $settings->prices_include_tax + ? $discountedSubtotal + $shippingAmount + : $discountedSubtotal + $shippingAmount + $taxTotal; + + $result = new PricingResult( + subtotal: $subtotal, + discount: $discountAmount, + shipping: $shippingAmount, + taxLines: $taxLines, + taxTotal: $taxTotal, + total: $total, + currency: $cart->currency, + ); + + $checkout->forceFill([ + 'totals_json' => $result->toArray(), + 'tax_provider_snapshot_json' => [ + 'provider' => $settings->mode === TaxMode::Manual ? 'manual' : $settings->provider, + 'calculated_at' => now()->toISOString(), + 'lines' => array_map(fn (TaxLine $line): array => $line->toArray(), $taxLines), + ], + ])->save(); + + return $result; + } + + /** + * @param iterable $lines + * @param array $allocations + */ + private function applyLineDiscounts(iterable $lines, array $allocations): void + { + foreach ($lines as $line) { + $discount = min($allocations[$line->id] ?? 0, $line->line_subtotal_amount); + + $line->forceFill([ + 'line_discount_amount' => $discount, + 'line_total_amount' => $line->line_subtotal_amount - $discount, + ])->save(); + } + } + + private function shippingAmount(Checkout $checkout): int + { + if (! $this->shipping->requiresShipping($checkout->cart)) { + return 0; + } + + if ($checkout->shipping_method_id === null) { + return 0; + } + + $rate = ShippingRate::query()->find($checkout->shipping_method_id); + + if ($rate === null) { + return 0; + } + + return $this->shipping->calculate($rate, $checkout->cart) ?? 0; + } + + private function taxSettings(Checkout $checkout): TaxSettings + { + return TaxSettings::withoutGlobalScopes()->firstOrCreate( + ['store_id' => $checkout->store_id], + [ + 'mode' => TaxMode::Manual, + 'provider' => 'none', + 'prices_include_tax' => false, + 'config_json' => [ + 'default_rate_basis_points' => 0, + 'shipping_taxable' => false, + ], + ], + ); + } + + /** + * @return list + */ + private function taxLines(Checkout $checkout, TaxSettings $settings, int $discountedSubtotal, int $shippingAmount): array + { + $address = $checkout->shipping_address_json ?? []; + + if ($address === []) { + return []; + } + + $taxLines = []; + $itemTax = $this->taxes->calculate($discountedSubtotal, $settings, $address); + + if ($itemTax->amount > 0) { + $taxLines[] = new TaxLine('Item tax', $itemTax->rate, $itemTax->amount); + } + + $shippingTaxable = (bool) (($settings->config_json ?? [])['shipping_taxable'] ?? false); + + if ($shippingTaxable && $shippingAmount > 0) { + $shippingTax = $this->taxes->calculate($shippingAmount, $settings, $address); + + if ($shippingTax->amount > 0) { + $taxLines[] = new TaxLine('Shipping tax', $shippingTax->rate, $shippingTax->amount); + } + } + + return $taxLines; + } +} diff --git a/app/Services/ShippingCalculator.php b/app/Services/ShippingCalculator.php new file mode 100644 index 00000000..a52302ff --- /dev/null +++ b/app/Services/ShippingCalculator.php @@ -0,0 +1,158 @@ + $address + * @return Collection + */ + public function getAvailableRates(Store $store, array $address): Collection + { + $zone = $this->matchingZone($store, $address); + + if ($zone === null) { + return collect(); + } + + return $zone->rates() + ->where('is_active', true) + ->get(); + } + + /** + * @param array $address + * @return Collection + */ + public function getAvailableRateQuotes(Store $store, Cart $cart, array $address): Collection + { + return $this->getAvailableRates($store, $address) + ->map(function (ShippingRate $rate) use ($cart): ?ShippingRateQuote { + $amount = $this->calculate($rate, $cart); + + return $amount === null ? null : new ShippingRateQuote($rate, $amount, $cart->currency); + }) + ->filter() + ->values(); + } + + public function calculate(ShippingRate $rate, Cart $cart): ?int + { + $config = $rate->config_json ?? []; + + return match ($rate->type) { + ShippingRateType::Flat => (int) ($config['amount'] ?? 0), + ShippingRateType::Weight => $this->calculateWeightRate($config, $cart), + ShippingRateType::Price => $this->calculatePriceRate($config, $cart), + ShippingRateType::Carrier => (int) ($config['amount'] ?? 999), + }; + } + + public function requiresShipping(Cart $cart): bool + { + $cart->loadMissing('lines.variant'); + + return $cart->lines->contains( + fn ($line): bool => (bool) $line->variant?->requires_shipping, + ); + } + + /** + * @param array $address + */ + public function matchingZone(Store $store, array $address): ?ShippingZone + { + $country = $address['country_code'] ?? $address['country'] ?? null; + $region = $address['province_code'] ?? null; + $bestZone = null; + $bestSpecificity = -1; + + if (! is_string($country) || $country === '') { + return null; + } + + ShippingZone::withoutGlobalScopes() + ->where('store_id', $store->id) + ->orderBy('id') + ->get() + ->each(function (ShippingZone $zone) use ($country, $region, &$bestZone, &$bestSpecificity): void { + $countries = $zone->countries_json ?? []; + $regions = $zone->regions_json ?? []; + + if (! in_array($country, $countries, true)) { + return; + } + + $specificity = in_array($region, $regions, true) ? 2 : 1; + + if ($specificity > $bestSpecificity) { + $bestZone = $zone; + $bestSpecificity = $specificity; + } + }); + + return $bestZone; + } + + /** + * @param array $config + */ + private function calculateWeightRate(array $config, Cart $cart): ?int + { + $cart->loadMissing('lines.variant'); + + $totalWeight = (int) $cart->lines->sum(function ($line): int { + if (! $line->variant?->requires_shipping) { + return 0; + } + + return (int) $line->variant->weight_g * $line->quantity; + }); + + foreach ($config['ranges'] ?? [] as $range) { + if ($totalWeight < ($range['min_g'] ?? 0)) { + continue; + } + + if (isset($range['max_g']) && $totalWeight > $range['max_g']) { + continue; + } + + return (int) $range['amount']; + } + + return null; + } + + /** + * @param array $config + */ + private function calculatePriceRate(array $config, Cart $cart): ?int + { + $cart->loadMissing('lines'); + $subtotal = (int) $cart->lines->sum('line_subtotal_amount'); + + foreach ($config['ranges'] ?? [] as $range) { + if ($subtotal < ($range['min_amount'] ?? 0)) { + continue; + } + + if (isset($range['max_amount']) && $subtotal > $range['max_amount']) { + continue; + } + + return (int) $range['amount']; + } + + return null; + } +} diff --git a/app/Services/TaxCalculator.php b/app/Services/TaxCalculator.php new file mode 100644 index 00000000..51c9d295 --- /dev/null +++ b/app/Services/TaxCalculator.php @@ -0,0 +1,58 @@ + $address + */ + public function calculate(int $amount, TaxSettings $settings, array $address): TaxLine + { + $rate = $this->rateFor($settings, $address); + $tax = $settings->prices_include_tax + ? $this->extractInclusive($amount, $rate) + : $this->addExclusive($amount, $rate) - $amount; + + return new TaxLine('Tax', $rate, max(0, $tax)); + } + + public function extractInclusive(int $grossAmount, int $rateBasisPoints): int + { + if ($grossAmount < 1 || $rateBasisPoints < 1) { + return 0; + } + + $netAmount = intdiv($grossAmount * 10000, 10000 + $rateBasisPoints); + + return $grossAmount - $netAmount; + } + + public function addExclusive(int $netAmount, int $rateBasisPoints): int + { + if ($netAmount < 1 || $rateBasisPoints < 1) { + return $netAmount; + } + + return $netAmount + (int) round($netAmount * $rateBasisPoints / 10000); + } + + /** + * @param array $address + */ + public function rateFor(TaxSettings $settings, array $address): int + { + $config = $settings->config_json ?? []; + $country = $address['country_code'] ?? $address['country'] ?? null; + $countryRates = $config['country_rates'] ?? []; + + if (is_string($country) && isset($countryRates[$country])) { + return (int) $countryRates[$country]; + } + + return (int) ($config['default_rate_basis_points'] ?? 0); + } +} diff --git a/app/ValueObjects/DiscountResult.php b/app/ValueObjects/DiscountResult.php new file mode 100644 index 00000000..cb1be1a9 --- /dev/null +++ b/app/ValueObjects/DiscountResult.php @@ -0,0 +1,15 @@ + $allocations + */ + public function __construct( + public readonly int $amount, + public readonly array $allocations, + public readonly bool $freeShipping = false, + ) {} +} diff --git a/app/ValueObjects/PricingResult.php b/app/ValueObjects/PricingResult.php new file mode 100644 index 00000000..0ec3e66f --- /dev/null +++ b/app/ValueObjects/PricingResult.php @@ -0,0 +1,38 @@ + $taxLines + */ + public function __construct( + public readonly int $subtotal, + public readonly int $discount, + public readonly int $shipping, + public readonly array $taxLines, + public readonly int $taxTotal, + public readonly int $total, + public readonly string $currency, + ) {} + + /** + * @return array{subtotal: int, discount: int, shipping: int, tax: int, tax_lines: list, total: int, currency: string} + */ + public function toArray(): array + { + return [ + 'subtotal' => $this->subtotal, + 'discount' => $this->discount, + 'shipping' => $this->shipping, + 'tax' => $this->taxTotal, + 'tax_lines' => array_map( + fn (TaxLine $line): array => $line->toArray(), + $this->taxLines, + ), + 'total' => $this->total, + 'currency' => $this->currency, + ]; + } +} diff --git a/app/ValueObjects/ShippingRateQuote.php b/app/ValueObjects/ShippingRateQuote.php new file mode 100644 index 00000000..6f659a43 --- /dev/null +++ b/app/ValueObjects/ShippingRateQuote.php @@ -0,0 +1,30 @@ + $this->rate->id, + 'name' => $this->rate->name, + 'type' => $this->rate->type->value, + 'price_amount' => $this->amount, + 'currency' => $this->currency, + 'estimated_days_min' => $this->rate->config_json['estimated_days_min'] ?? null, + 'estimated_days_max' => $this->rate->config_json['estimated_days_max'] ?? null, + ]; + } +} diff --git a/app/ValueObjects/TaxLine.php b/app/ValueObjects/TaxLine.php new file mode 100644 index 00000000..1bc257d1 --- /dev/null +++ b/app/ValueObjects/TaxLine.php @@ -0,0 +1,24 @@ + $this->name, + 'rate' => $this->rate, + 'amount' => $this->amount, + ]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 9a571858..48c075ac 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -7,6 +7,7 @@ return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) diff --git a/database/factories/CartFactory.php b/database/factories/CartFactory.php new file mode 100644 index 00000000..2e4952b6 --- /dev/null +++ b/database/factories/CartFactory.php @@ -0,0 +1,43 @@ + + */ +class CartFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'customer_id' => null, + 'currency' => 'EUR', + 'cart_version' => 1, + 'status' => CartStatus::Active, + ]; + } + + public function converted(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => CartStatus::Converted, + ]); + } + + public function abandoned(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => CartStatus::Abandoned, + ]); + } +} diff --git a/database/factories/CartLineFactory.php b/database/factories/CartLineFactory.php new file mode 100644 index 00000000..a8f2218b --- /dev/null +++ b/database/factories/CartLineFactory.php @@ -0,0 +1,43 @@ + + */ +class CartLineFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $store = Store::factory()->create(); + $cart = Cart::factory()->for($store)->create(); + $product = Product::factory()->for($store)->create(); + $variant = ProductVariant::factory()->for($product)->create([ + 'price_amount' => fake()->numberBetween(1000, 10000), + 'currency' => $cart->currency, + ]); + $quantity = fake()->numberBetween(1, 4); + $subtotal = $variant->price_amount * $quantity; + + return [ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => $quantity, + 'unit_price_amount' => $variant->price_amount, + 'line_subtotal_amount' => $subtotal, + 'line_discount_amount' => 0, + 'line_total_amount' => $subtotal, + ]; + } +} diff --git a/database/factories/CheckoutFactory.php b/database/factories/CheckoutFactory.php new file mode 100644 index 00000000..b132348a --- /dev/null +++ b/database/factories/CheckoutFactory.php @@ -0,0 +1,45 @@ + + */ +class CheckoutFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $cart = Cart::factory()->create(); + + return [ + 'store_id' => $cart->store_id, + 'cart_id' => $cart->id, + 'customer_id' => null, + 'status' => CheckoutStatus::Started, + 'email' => fake()->safeEmail(), + 'shipping_address_json' => null, + 'billing_address_json' => null, + 'shipping_method_id' => null, + 'discount_code' => null, + 'tax_provider_snapshot_json' => null, + 'totals_json' => [ + 'subtotal' => 0, + 'discount' => 0, + 'shipping' => 0, + 'tax' => 0, + 'total' => 0, + 'currency' => $cart->currency, + ], + 'expires_at' => now()->addDay(), + ]; + } +} diff --git a/database/factories/DiscountFactory.php b/database/factories/DiscountFactory.php new file mode 100644 index 00000000..20901658 --- /dev/null +++ b/database/factories/DiscountFactory.php @@ -0,0 +1,59 @@ + + */ +class DiscountFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'type' => DiscountType::Code, + 'code' => Str::upper(fake()->unique()->bothify('SAVE##')), + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addMonth(), + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [ + 'min_purchase_amount' => null, + 'applicable_product_ids' => null, + 'applicable_collection_ids' => null, + 'customer_eligibility' => 'all', + ], + 'status' => DiscountStatus::Active, + ]; + } + + public function fixed(int $amount): static + { + return $this->state(fn (array $attributes): array => [ + 'value_type' => DiscountValueType::Fixed, + 'value_amount' => $amount, + ]); + } + + public function freeShipping(): static + { + return $this->state(fn (array $attributes): array => [ + 'value_type' => DiscountValueType::FreeShipping, + 'value_amount' => 0, + ]); + } +} diff --git a/database/factories/ShippingRateFactory.php b/database/factories/ShippingRateFactory.php new file mode 100644 index 00000000..02ff9c98 --- /dev/null +++ b/database/factories/ShippingRateFactory.php @@ -0,0 +1,45 @@ + + */ +class ShippingRateFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'zone_id' => ShippingZone::factory(), + 'name' => 'Standard Shipping', + 'type' => ShippingRateType::Flat, + 'config_json' => [ + 'amount' => fake()->numberBetween(499, 1299), + 'estimated_days_min' => 3, + 'estimated_days_max' => 5, + ], + 'is_active' => true, + ]; + } + + public function express(): static + { + return $this->state(fn (array $attributes): array => [ + 'name' => 'Express Shipping', + 'config_json' => [ + 'amount' => 1299, + 'estimated_days_min' => 1, + 'estimated_days_max' => 2, + ], + ]); + } +} diff --git a/database/factories/ShippingZoneFactory.php b/database/factories/ShippingZoneFactory.php new file mode 100644 index 00000000..02d0fcd1 --- /dev/null +++ b/database/factories/ShippingZoneFactory.php @@ -0,0 +1,27 @@ + + */ +class ShippingZoneFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'name' => 'Domestic', + 'countries_json' => ['DE'], + 'regions_json' => [], + ]; + } +} diff --git a/database/factories/TaxSettingsFactory.php b/database/factories/TaxSettingsFactory.php new file mode 100644 index 00000000..d0624550 --- /dev/null +++ b/database/factories/TaxSettingsFactory.php @@ -0,0 +1,32 @@ + + */ +class TaxSettingsFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'mode' => TaxMode::Manual, + 'provider' => 'none', + 'prices_include_tax' => false, + 'config_json' => [ + 'default_rate_basis_points' => 1900, + 'shipping_taxable' => true, + ], + ]; + } +} diff --git a/database/migrations/2026_05_03_115453_create_carts_table.php b/database/migrations/2026_05_03_115453_create_carts_table.php new file mode 100644 index 00000000..9ba48f99 --- /dev/null +++ b/database/migrations/2026_05_03_115453_create_carts_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->unsignedBigInteger('customer_id')->nullable(); + $table->string('currency', 3)->default('USD'); + $table->integer('cart_version')->default(1); + $table->enum('status', ['active', 'converted', 'abandoned'])->default('active'); + $table->timestamps(); + + $table->index('store_id'); + $table->index('customer_id'); + $table->index(['store_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('carts'); + } +}; diff --git a/database/migrations/2026_05_03_115454_create_cart_lines_table.php b/database/migrations/2026_05_03_115454_create_cart_lines_table.php new file mode 100644 index 00000000..4855fab6 --- /dev/null +++ b/database/migrations/2026_05_03_115454_create_cart_lines_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('cart_id')->constrained()->cascadeOnDelete(); + $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->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'); + $table->unique(['cart_id', 'variant_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cart_lines'); + } +}; diff --git a/database/migrations/2026_05_03_115455_create_checkouts_table.php b/database/migrations/2026_05_03_115455_create_checkouts_table.php new file mode 100644 index 00000000..c9a6c735 --- /dev/null +++ b/database/migrations/2026_05_03_115455_create_checkouts_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('cart_id')->constrained()->cascadeOnDelete(); + $table->unsignedBigInteger('customer_id')->nullable(); + $table->enum('status', ['started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired'])->default('started'); + $table->enum('payment_method', ['credit_card', 'paypal', 'bank_transfer'])->nullable(); + $table->string('email')->nullable(); + $table->text('shipping_address_json')->nullable(); + $table->text('billing_address_json')->nullable(); + $table->unsignedBigInteger('shipping_method_id')->nullable(); + $table->string('discount_code')->nullable(); + $table->text('tax_provider_snapshot_json')->nullable(); + $table->text('totals_json')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + + $table->index('store_id'); + $table->index('cart_id'); + $table->index('customer_id'); + $table->index(['store_id', 'status']); + $table->index('expires_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('checkouts'); + } +}; diff --git a/database/migrations/2026_05_03_115456_create_shipping_zones_table.php b/database/migrations/2026_05_03_115456_create_shipping_zones_table.php new file mode 100644 index 00000000..a7a316bd --- /dev/null +++ b/database/migrations/2026_05_03_115456_create_shipping_zones_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->text('countries_json')->default('[]'); + $table->text('regions_json')->default('[]'); + + $table->index('store_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shipping_zones'); + } +}; diff --git a/database/migrations/2026_05_03_115457_create_shipping_rates_table.php b/database/migrations/2026_05_03_115457_create_shipping_rates_table.php new file mode 100644 index 00000000..b757136e --- /dev/null +++ b/database/migrations/2026_05_03_115457_create_shipping_rates_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('zone_id')->constrained('shipping_zones')->cascadeOnDelete(); + $table->string('name'); + $table->enum('type', ['flat', 'weight', 'price', 'carrier'])->default('flat'); + $table->text('config_json')->default('{}'); + $table->boolean('is_active')->default(true); + + $table->index('zone_id'); + $table->index(['zone_id', 'is_active']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shipping_rates'); + } +}; diff --git a/database/migrations/2026_05_03_115458_create_tax_settings_table.php b/database/migrations/2026_05_03_115458_create_tax_settings_table.php new file mode 100644 index 00000000..1086ea01 --- /dev/null +++ b/database/migrations/2026_05_03_115458_create_tax_settings_table.php @@ -0,0 +1,31 @@ +foreignId('store_id')->primary()->constrained()->cascadeOnDelete(); + $table->enum('mode', ['manual', 'provider'])->default('manual'); + $table->enum('provider', ['stripe_tax', 'none'])->default('none'); + $table->boolean('prices_include_tax')->default(false); + $table->text('config_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tax_settings'); + } +}; diff --git a/database/migrations/2026_05_03_115459_create_discounts_table.php b/database/migrations/2026_05_03_115459_create_discounts_table.php new file mode 100644 index 00000000..d27ef0bb --- /dev/null +++ b/database/migrations/2026_05_03_115459_create_discounts_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->enum('type', ['code', 'automatic'])->default('code'); + $table->string('code')->nullable(); + $table->enum('value_type', ['fixed', 'percent', 'free_shipping']); + $table->integer('value_amount')->default(0); + $table->timestamp('starts_at'); + $table->timestamp('ends_at')->nullable(); + $table->integer('usage_limit')->nullable(); + $table->integer('usage_count')->default(0); + $table->text('rules_json')->default('{}'); + $table->enum('status', ['draft', 'active', 'expired', 'disabled'])->default('active'); + $table->timestamps(); + + $table->unique(['store_id', 'code']); + $table->index('store_id'); + $table->index(['store_id', 'status']); + $table->index(['store_id', 'type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('discounts'); + } +}; diff --git a/database/seeders/CartLineSeeder.php b/database/seeders/CartLineSeeder.php new file mode 100644 index 00000000..ec63763f --- /dev/null +++ b/database/seeders/CartLineSeeder.php @@ -0,0 +1,16 @@ +where('handle', 'acme-fashion')->firstOrFail(); + + Discount::query()->updateOrCreate( + [ + 'store_id' => $store->id, + 'code' => 'WELCOME10', + ], + [ + 'type' => DiscountType::Code, + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addYear(), + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [ + 'min_purchase_amount' => 1000, + 'applicable_product_ids' => null, + 'applicable_collection_ids' => null, + 'customer_eligibility' => 'all', + ], + 'status' => DiscountStatus::Active, + ], + ); + } +} diff --git a/database/seeders/ShippingRateSeeder.php b/database/seeders/ShippingRateSeeder.php new file mode 100644 index 00000000..a0ffb8df --- /dev/null +++ b/database/seeders/ShippingRateSeeder.php @@ -0,0 +1,16 @@ +where('handle', 'acme-fashion')->firstOrFail(); + + $zone = ShippingZone::query()->updateOrCreate( + [ + 'store_id' => $store->id, + 'name' => 'Germany', + ], + [ + 'countries_json' => ['DE'], + 'regions_json' => [], + ], + ); + + $zone->rates()->updateOrCreate( + ['name' => 'Standard Shipping'], + [ + 'type' => ShippingRateType::Flat, + 'config_json' => [ + 'amount' => 500, + 'estimated_days_min' => 3, + 'estimated_days_max' => 5, + ], + 'is_active' => true, + ], + ); + + $zone->rates()->updateOrCreate( + ['name' => 'Express Shipping'], + [ + 'type' => ShippingRateType::Flat, + 'config_json' => [ + 'amount' => 1200, + 'estimated_days_min' => 1, + 'estimated_days_max' => 2, + ], + 'is_active' => true, + ], + ); + } +} diff --git a/database/seeders/TaxSettingsSeeder.php b/database/seeders/TaxSettingsSeeder.php new file mode 100644 index 00000000..c5155353 --- /dev/null +++ b/database/seeders/TaxSettingsSeeder.php @@ -0,0 +1,32 @@ +where('handle', 'acme-fashion')->firstOrFail(); + + TaxSettings::query()->updateOrCreate( + ['store_id' => $store->id], + [ + 'mode' => TaxMode::Manual, + 'provider' => 'none', + 'prices_include_tax' => false, + 'config_json' => [ + 'default_rate_basis_points' => 1900, + 'shipping_taxable' => true, + ], + ], + ); + } +} diff --git a/resources/views/livewire/storefront/cart-drawer.blade.php b/resources/views/livewire/storefront/cart-drawer.blade.php index 37f80c56..6b2a6b4a 100644 --- a/resources/views/livewire/storefront/cart-drawer.blade.php +++ b/resources/views/livewire/storefront/cart-drawer.blade.php @@ -1,3 +1,3 @@ - - +
+ + + @error('quantity') +

{{ $message }}

+ @enderror + @if(session('cart_status')) +

{{ session('cart_status') }}

+ @endif + +
diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 00000000..77e04837 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,25 @@ +middleware(['store.resolve', 'throttle:api.storefront']) + ->group(function (): void { + Route::post('/carts', [CartController::class, 'store'])->name('api.storefront.carts.store'); + Route::get('/carts/{cartId}', [CartController::class, 'show'])->name('api.storefront.carts.show'); + Route::post('/carts/{cartId}/lines', [CartController::class, 'storeLine'])->name('api.storefront.carts.lines.store'); + Route::put('/carts/{cartId}/lines/{lineId}', [CartController::class, 'updateLine'])->name('api.storefront.carts.lines.update'); + Route::delete('/carts/{cartId}/lines/{lineId}', [CartController::class, 'destroyLine'])->name('api.storefront.carts.lines.destroy'); + + Route::middleware('throttle:checkout')->group(function (): void { + Route::post('/checkouts', [CheckoutController::class, 'store'])->name('api.storefront.checkouts.store'); + Route::get('/checkouts/{checkoutId}', [CheckoutController::class, 'show'])->name('api.storefront.checkouts.show'); + Route::put('/checkouts/{checkoutId}/address', [CheckoutController::class, 'address'])->name('api.storefront.checkouts.address'); + Route::put('/checkouts/{checkoutId}/shipping-method', [CheckoutController::class, 'shippingMethod'])->name('api.storefront.checkouts.shipping-method'); + Route::put('/checkouts/{checkoutId}/payment-method', [CheckoutController::class, 'paymentMethod'])->name('api.storefront.checkouts.payment-method'); + Route::post('/checkouts/{checkoutId}/apply-discount', [CheckoutController::class, 'applyDiscount'])->name('api.storefront.checkouts.apply-discount'); + Route::delete('/checkouts/{checkoutId}/discount', [CheckoutController::class, 'removeDiscount'])->name('api.storefront.checkouts.remove-discount'); + }); + }); diff --git a/routes/console.php b/routes/console.php index 3c9adf1a..7f514d69 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,14 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::job(new ExpireAbandonedCheckouts)->everyFifteenMinutes(); +Schedule::job(new CleanupAbandonedCarts)->daily(); diff --git a/routes/web.php b/routes/web.php index c673a16d..7f7b67f1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,8 @@ name('storefront.collections.show'); Route::get('/products/{handle}', ProductShow::class)->name('storefront.products.show'); Route::get('/cart', CartShow::class)->name('storefront.cart.show'); + Route::get('/checkout/{checkoutId}', CheckoutShow::class)->name('storefront.checkout.show'); + Route::get('/checkout/{checkoutId}/confirmation', CheckoutConfirmation::class)->name('storefront.checkout.confirmation'); Route::get('/search', SearchIndex::class)->name('storefront.search.index'); Route::get('/pages/{handle}', PageShow::class)->name('storefront.pages.show'); }); diff --git a/specs/progress.md b/specs/progress.md index 443a3868..8da27f1e 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -12,8 +12,9 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Phase 1 foundation is implemented and committed: configuration defaults, core tenancy schema, models, factories, seeders, tenant middleware, customer guard provider registration, store role helper, and password_hash compatibility. - Phase 2 catalog backend is implemented and committed: products, options, option values, variants, inventory, collections, collection pivot, media schema/models/factories/seed data, product lifecycle service, variant matrix service, inventory service, handle generator, and product/collection policies. - Phase 3 theme/storefront shell is implemented and verified: theme/page/navigation schema, models, factories, seed data, theme settings service, navigation service, storefront layout, product cards, price rendering, and initial Livewire storefront pages. -- Admin shop UI, functional cart persistence, checkout, orders, customer account flows, search indexing, analytics, apps, and webhooks are not implemented yet. -- Phase 4 cart/checkout/pricing is the next active vertical slice after committing the storefront shell. +- Phase 4 cart/checkout/pricing is implemented and verified: carts, cart lines, checkouts, shipping zones/rates, tax settings, discounts, cart and checkout services, pricing snapshots, storefront REST endpoints, Livewire cart/checkout UI, and cleanup jobs. +- Admin shop UI, payments, orders, customer account flows, search indexing, analytics, apps, and webhooks are not implemented yet. +- Phase 5 payments/orders/customer persistence is the next active vertical slice after committing cart and checkout progress. ## Execution Plan @@ -24,10 +25,10 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit 2. **Catalog** - Committed (`ea70780e`) - Add products, variants, options, inventory, collections, media, catalog services, and storefront/admin catalog basics. - Verify product lifecycle, inventory, variants, and collection isolation tests. -3. **Theme and storefront shell** - Implemented and verified +3. **Theme and storefront shell** - Committed (`58879dde`) - Add themes, pages, navigation, storefront layout, reusable components, and initial storefront pages. - Verify storefront render, navigation, product detail, accessibility smoke, and responsive browser checks. -4. **Cart, checkout, pricing** - Pending +4. **Cart, checkout, pricing** - Implemented and verified - Add carts, checkout, discounts, shipping, taxes, pricing engine, state transitions, and REST endpoints. - Verify cart API, cart UI, checkout state, pricing, discount, shipping, and tax tests. 5. **Payments and orders** - Pending @@ -56,14 +57,19 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Admin users store credentials in `users.password_hash`; the `User` model keeps a `password` attribute alias so Fortify and starter-kit Livewire settings continue to work. - Storefront pages are class-based Livewire components using the existing starter layout conventions and store resolution middleware. - Phase 3 includes a cart page/drawer shell only; persistent carts and line-item actions stay in Phase 4 so pricing and checkout state can be implemented coherently. +- Livewire update requests persist `ResolveStore` middleware so storefront actions keep tenant context after the initial page load. +- Phase 4 keeps `customer_id` as nullable indexed IDs without foreign keys until the `customers` table lands in Phase 5; SQLite test connections reject foreign keys to future tables. ## Open Gaps - Phase 1 still needs the resource policies listed in the roadmap, but most referenced resources do not exist until later phases. Policies will be added with their models to keep type hints and tests coherent. - Phase 2 still needs Livewire/admin product management and full media resizing variants. Storefront product/collection browsing is covered by the Phase 3 shell. - Phase 3 still needs the richer theme editor/admin surfaces, search modal autocomplete, checkout/account/error storefront templates, and fully configurable section ordering. These are deferred to the admin, search, checkout, and account slices. +- Phase 4 stops at payment-method selection and inventory reservation. Mock payment processing, order creation, bank-transfer confirmation, refunds, and fulfillment are Phase 5. +- Phase 4 has a functional cart page and accessible cart count, but the richer slide-out cart drawer can be expanded during UI polish. +- Discounts, shipping, and tax are implemented for the specified local/manual flows; provider/carrier integrations remain stubs by design. - Order-reference guards in product deletion/status logic are present but only become fully meaningful once `order_lines` exists in Phase 5. -- Cart and all later shop phases remain unimplemented. +- Customer accounts, admin surfaces, payments/orders, and all later shop phases remain unimplemented. - API token requirements mention Sanctum, but the package is not currently installed. This remains an open dependency decision for the API/developers phase because dependencies must not be changed without approval. ## Verification Log @@ -82,4 +88,11 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - 2026-05-03: `php artisan test --compact` passed, 53 tests / 139 assertions. - 2026-05-03: `npm run build` passed for the storefront Tailwind/Vite assets. - 2026-05-03: Playwright smoke visited `http://shop.test/` and `http://shop.test/products/linen-shirt`; latest browser console check reported no warnings or errors. +- 2026-05-03: `php artisan test --compact tests/Feature/Api/StorefrontCartApiTest.php tests/Feature/Cart tests/Feature/Checkout tests/Feature/Storefront/StorefrontCartLivewireTest.php` passed, 7 tests / 44 assertions. +- 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed with cart, checkout, shipping, tax, and discount migrations/seed data. +- 2026-05-03: `vendor/bin/pint --dirty --format agent` passed after Phase 4 changes. +- 2026-05-03: `php artisan test --compact` passed, 60 tests / 183 assertions. +- 2026-05-03: `npm run build` passed for the updated cart/checkout Tailwind/Vite assets. +- 2026-05-03: Playwright smoke completed product add-to-cart, cart checkout start, checkout address save, shipping selection, and payment-method save at `http://shop.test`; latest console check reported no warnings or errors. +- 2026-05-03: `browser_logs` reported no browser log file after the latest smoke check. - Pending: Playwright customer and admin browser flows. diff --git a/tests/Feature/Api/StorefrontCartApiTest.php b/tests/Feature/Api/StorefrontCartApiTest.php new file mode 100644 index 00000000..6a699b5c --- /dev/null +++ b/tests/Feature/Api/StorefrontCartApiTest.php @@ -0,0 +1,103 @@ +seed(); +}); + +test('storefront cart api creates carts and mutates lines with version conflicts', function () { + $variant = Product::query()->where('handle', 'linen-shirt')->firstOrFail()->variants()->firstOrFail(); + + $cartId = $this->postJson('http://shop.test/api/storefront/v1/carts') + ->assertCreated() + ->assertJsonPath('data.currency', 'EUR') + ->json('data.id'); + + $this->postJson("http://shop.test/api/storefront/v1/carts/{$cartId}/lines", [ + 'variant_id' => $variant->id, + 'quantity' => 2, + 'cart_version' => 1, + ]) + ->assertCreated() + ->assertJsonPath('data.cart_version', 2) + ->assertJsonPath('data.totals.item_count', 2); + + $lineId = $this->getJson("http://shop.test/api/storefront/v1/carts/{$cartId}") + ->assertOk() + ->json('data.lines.0.id'); + + $this->putJson("http://shop.test/api/storefront/v1/carts/{$cartId}/lines/{$lineId}", [ + 'quantity' => 3, + 'cart_version' => 1, + ])->assertConflict(); + + $this->putJson("http://shop.test/api/storefront/v1/carts/{$cartId}/lines/{$lineId}", [ + 'quantity' => 3, + 'cart_version' => 2, + ]) + ->assertOk() + ->assertJsonPath('data.cart_version', 3) + ->assertJsonPath('data.totals.item_count', 3); +}); + +test('storefront checkout api advances address shipping discount and payment method', function () { + $variant = Product::query()->where('handle', 'linen-shirt')->firstOrFail()->variants()->firstOrFail(); + $cartId = $this->postJson('http://shop.test/api/storefront/v1/carts')->json('data.id'); + + $this->postJson("http://shop.test/api/storefront/v1/carts/{$cartId}/lines", [ + 'variant_id' => $variant->id, + 'quantity' => 1, + 'cart_version' => 1, + ])->assertCreated(); + + $checkoutId = $this->postJson('http://shop.test/api/storefront/v1/checkouts', [ + 'cart_id' => $cartId, + 'email' => 'buyer@example.com', + ]) + ->assertCreated() + ->assertJsonPath('data.status', 'started') + ->json('data.id'); + + $addressResponse = $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkoutId}/address", [ + 'shipping_address' => [ + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'address1' => 'Street 1', + 'city' => 'Berlin', + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => '10115', + ], + 'use_shipping_as_billing' => true, + ]) + ->assertOk() + ->assertJsonPath('data.status', 'addressed'); + + $shippingRateId = $addressResponse->json('data.available_shipping_methods.0.id'); + + $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkoutId}/shipping-method", [ + 'shipping_method_id' => $shippingRateId, + ]) + ->assertOk() + ->assertJsonPath('data.status', 'shipping_selected') + ->assertJsonPath('data.totals.shipping', 500); + + $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkoutId}/apply-discount", [ + 'code' => 'welcome10', + ]) + ->assertOk() + ->assertJsonPath('data.discount_code', 'WELCOME10') + ->assertJsonPath('data.totals.discount', 500); + + $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkoutId}/payment-method", [ + 'payment_method' => 'credit_card', + ]) + ->assertOk() + ->assertJsonPath('data.status', 'payment_selected') + ->assertJsonPath('data.payment_method', 'credit_card'); +}); diff --git a/tests/Feature/Cart/CartServiceTest.php b/tests/Feature/Cart/CartServiceTest.php new file mode 100644 index 00000000..ee66092d --- /dev/null +++ b/tests/Feature/Cart/CartServiceTest.php @@ -0,0 +1,63 @@ +for($store)->create(); + $variant = ProductVariant::factory()->for($product)->default()->create([ + 'price_amount' => $price, + 'currency' => $store->default_currency, + ]); + + InventoryItem::withoutGlobalScopes() + ->where('variant_id', $variant->id) + ->update([ + 'quantity_on_hand' => $stock, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]); + + return $variant->refresh(); +} + +test('cart service creates and increments lines with optimistic versions', function () { + $store = Store::factory()->create(); + app()->instance('current_store', $store); + $variant = cartVariant($store, price: 1500, stock: 5); + $cart = app(CartService::class)->create($store); + + $line = app(CartService::class)->addLine($cart, $variant->id, 2, expectedVersion: 1); + + expect($line->quantity)->toBe(2) + ->and($line->line_total_amount)->toBe(3000) + ->and($cart->refresh()->cart_version)->toBe(2); + + app(CartService::class)->addLine($cart->refresh(), $variant->id, 1, expectedVersion: 2); + + expect(Cart::withoutGlobalScopes()->find($cart->id)->lines()->first()) + ->quantity->toBe(3) + ->line_total_amount->toBe(4500); +}); + +test('cart service rejects stale versions and unavailable stock', function () { + $store = Store::factory()->create(); + app()->instance('current_store', $store); + $variant = cartVariant($store, stock: 1); + $cart = app(CartService::class)->create($store); + + expect(fn () => app(CartService::class)->addLine($cart, $variant->id, 1, expectedVersion: 99)) + ->toThrow(CartVersionConflictException::class); + + expect(fn () => app(CartService::class)->addLine($cart, $variant->id, 2, expectedVersion: 1)) + ->toThrow(InvalidCartMutationException::class); +}); diff --git a/tests/Feature/Checkout/CheckoutServiceTest.php b/tests/Feature/Checkout/CheckoutServiceTest.php new file mode 100644 index 00000000..ca61050f --- /dev/null +++ b/tests/Feature/Checkout/CheckoutServiceTest.php @@ -0,0 +1,113 @@ +create(['default_currency' => 'EUR']); + app()->instance('current_store', $store); + + $product = Product::factory()->for($store)->create(); + $variant = ProductVariant::factory()->for($product)->default()->create([ + 'price_amount' => 1000, + 'currency' => 'EUR', + 'weight_g' => 250, + ]); + InventoryItem::withoutGlobalScopes() + ->where('variant_id', $variant->id) + ->update(['quantity_on_hand' => 10, 'quantity_reserved' => 0, 'policy' => 'deny']); + + $zone = ShippingZone::factory()->for($store)->create(['countries_json' => ['DE']]); + $rate = $zone->rates()->create([ + 'name' => 'Standard', + 'type' => ShippingRateType::Flat, + 'config_json' => ['amount' => 500], + 'is_active' => true, + ]); + + TaxSettings::factory()->for($store)->create([ + 'config_json' => ['default_rate_basis_points' => 1900, 'shipping_taxable' => true], + ]); + + Discount::factory()->for($store)->create([ + 'code' => 'WELCOME10', + 'type' => DiscountType::Code, + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'rules_json' => ['min_purchase_amount' => null], + ]); + + $cart = app(CartService::class)->create($store); + app(CartService::class)->addLine($cart, $variant->id, 2); + + return [$store, $cart->refresh(), $variant->refresh(), $rate]; +} + +test('checkout service calculates shipping discounts taxes and reserves inventory', function () { + [, $cart, $variant, $rate] = checkoutFixture(); + + $checkout = app(CheckoutService::class)->createFromCart($cart, 'buyer@example.com'); + $checkout = app(CheckoutService::class)->setAddress($checkout, [ + 'shipping_address' => [ + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'address1' => 'Street 1', + 'city' => 'Berlin', + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => '10115', + ], + 'use_shipping_as_billing' => true, + ]); + $checkout = app(CheckoutService::class)->setShippingMethod($checkout, $rate->id); + $checkout = app(CheckoutService::class)->applyDiscount($checkout, 'welcome10'); + $checkout = app(CheckoutService::class)->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + + expect($checkout->status)->toBe(CheckoutStatus::PaymentSelected) + ->and($checkout->totals_json['subtotal'])->toBe(2000) + ->and($checkout->totals_json['discount'])->toBe(200) + ->and($checkout->totals_json['shipping'])->toBe(500) + ->and($checkout->totals_json['tax'])->toBe(437) + ->and($checkout->totals_json['total'])->toBe(2737) + ->and($variant->inventoryItem->refresh()->quantity_reserved)->toBe(2); +}); + +test('expiring a payment selected checkout releases reserved inventory', function () { + [, $cart, $variant, $rate] = checkoutFixture(); + + $checkout = app(CheckoutService::class)->createFromCart($cart, 'buyer@example.com'); + $checkout = app(CheckoutService::class)->setAddress($checkout, [ + 'shipping_address' => [ + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'address1' => 'Street 1', + 'city' => 'Berlin', + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => '10115', + ], + 'use_shipping_as_billing' => true, + ]); + $checkout = app(CheckoutService::class)->setShippingMethod($checkout, $rate->id); + $checkout = app(CheckoutService::class)->selectPaymentMethod($checkout, PaymentMethod::Paypal); + + app(CheckoutService::class)->expireCheckout($checkout); + + expect($checkout->refresh()->status)->toBe(CheckoutStatus::Expired) + ->and($variant->inventoryItem->refresh()->quantity_reserved)->toBe(0); +}); diff --git a/tests/Feature/Storefront/StorefrontCartLivewireTest.php b/tests/Feature/Storefront/StorefrontCartLivewireTest.php new file mode 100644 index 00000000..f2889090 --- /dev/null +++ b/tests/Feature/Storefront/StorefrontCartLivewireTest.php @@ -0,0 +1,36 @@ +seed(); + app()->instance('current_store', \App\Models\Store::query()->where('handle', 'acme-fashion')->firstOrFail()); +}); + +test('product page adds a line to the session cart and cart page starts checkout', function () { + $product = Product::query()->where('handle', 'linen-shirt')->firstOrFail(); + + Livewire::test(ProductShow::class, ['handle' => $product->handle]) + ->set('quantity', 2) + ->call('addToCart') + ->assertHasNoErrors(); + + $cart = Cart::withoutGlobalScopes()->findOrFail(session('cart_id')); + + expect($cart->lines()->first())->quantity->toBe(2); + + Livewire::test(CartShow::class) + ->set('email', 'buyer@example.com') + ->call('startCheckout') + ->assertHasNoErrors(); + + expect($cart->refresh()->checkouts)->toHaveCount(1); +}); From 1249b6a1542262279f81d55f5813603f87fe9c82 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 3 May 2026 14:53:25 +0200 Subject: [PATCH 09/35] Build payments orders fulfillment --- app/Contracts/PaymentProvider.php | 19 + app/Enums/FinancialStatus.php | 13 + app/Enums/FulfillmentShipmentStatus.php | 10 + app/Enums/FulfillmentStatus.php | 10 + app/Enums/OrderStatus.php | 12 + app/Enums/PaymentStatus.php | 11 + app/Enums/RefundStatus.php | 10 + app/Events/OrderCancelled.php | 15 + app/Events/OrderCreated.php | 15 + app/Events/OrderFulfilled.php | 15 + app/Events/OrderPaid.php | 15 + app/Events/OrderRefunded.php | 16 + .../BankTransferConfirmationException.php | 10 + app/Exceptions/FulfillmentGuardException.php | 10 + .../FulfillmentQuantityException.php | 10 + app/Exceptions/PaymentFailedException.php | 13 + app/Exceptions/RefundException.php | 13 + .../Api/Storefront/CheckoutController.php | 63 +++ .../Storefront/PayCheckoutRequest.php | 41 ++ .../Resources/Storefront/OrderResource.php | 63 +++ app/Jobs/CancelUnpaidBankTransferOrders.php | 52 +++ .../Storefront/Checkout/Confirmation.php | 2 +- app/Livewire/Storefront/Checkout/Show.php | 54 ++- app/Models/Checkout.php | 9 + app/Models/Customer.php | 29 +- app/Models/CustomerAddress.php | 52 +++ app/Models/Fulfillment.php | 65 +++ app/Models/FulfillmentLine.php | 47 ++ app/Models/Order.php | 131 ++++++ app/Models/OrderLine.php | 86 ++++ app/Models/Payment.php | 70 +++ app/Models/Refund.php | 62 +++ app/Models/Store.php | 16 + app/Providers/AppServiceProvider.php | 3 + app/Services/FulfillmentService.php | 156 +++++++ app/Services/OrderService.php | 402 ++++++++++++++++++ app/Services/Payments/MockPaymentProvider.php | 57 +++ app/Services/RefundService.php | 97 +++++ app/ValueObjects/PaymentResult.php | 56 +++ app/ValueObjects/RefundResult.php | 42 ++ database/factories/CustomerAddressFactory.php | 40 ++ database/factories/CustomerFactory.php | 36 ++ database/factories/FulfillmentFactory.php | 40 ++ database/factories/FulfillmentLineFactory.php | 29 ++ database/factories/OrderFactory.php | 82 ++++ database/factories/OrderLineFactory.php | 40 ++ database/factories/PaymentFactory.php | 45 ++ database/factories/RefundFactory.php | 33 ++ ...26_05_03_115452_create_customers_table.php | 35 ++ .../2026_05_03_115453_create_carts_table.php | 2 +- ...26_05_03_115455_create_checkouts_table.php | 2 +- ...115460_create_customer_addresses_table.php | 33 ++ .../2026_05_03_115461_create_orders_table.php | 53 +++ ..._05_03_115462_create_order_lines_table.php | 40 ++ ...026_05_03_115463_create_payments_table.php | 40 ++ ...2026_05_03_115464_create_refunds_table.php | 37 ++ ...05_03_115465_create_fulfillments_table.php | 38 ++ ..._115466_create_fulfillment_lines_table.php | 32 ++ database/seeders/CustomerAddressSeeder.php | 16 + database/seeders/CustomerSeeder.php | 62 +++ database/seeders/DatabaseSeeder.php | 2 + database/seeders/FulfillmentLineSeeder.php | 16 + database/seeders/FulfillmentSeeder.php | 16 + database/seeders/OrderLineSeeder.php | 16 + database/seeders/OrderSeeder.php | 158 +++++++ database/seeders/PaymentSeeder.php | 16 + database/seeders/RefundSeeder.php | 16 + database/seeders/StoreSettingsSeeder.php | 2 + .../checkout/confirmation.blade.php | 29 +- .../storefront/checkout/show.blade.php | 48 ++- routes/api.php | 1 + routes/console.php | 2 + specs/progress.md | 22 +- .../Api/StorefrontCheckoutPaymentApiTest.php | 99 +++++ tests/Feature/Orders/OrderServiceTest.php | 185 ++++++++ .../Payments/MockPaymentProviderTest.php | 47 ++ 76 files changed, 3250 insertions(+), 32 deletions(-) create mode 100644 app/Contracts/PaymentProvider.php create mode 100644 app/Enums/FinancialStatus.php create mode 100644 app/Enums/FulfillmentShipmentStatus.php create mode 100644 app/Enums/FulfillmentStatus.php create mode 100644 app/Enums/OrderStatus.php create mode 100644 app/Enums/PaymentStatus.php create mode 100644 app/Enums/RefundStatus.php create mode 100644 app/Events/OrderCancelled.php create mode 100644 app/Events/OrderCreated.php create mode 100644 app/Events/OrderFulfilled.php create mode 100644 app/Events/OrderPaid.php create mode 100644 app/Events/OrderRefunded.php create mode 100644 app/Exceptions/BankTransferConfirmationException.php create mode 100644 app/Exceptions/FulfillmentGuardException.php create mode 100644 app/Exceptions/FulfillmentQuantityException.php create mode 100644 app/Exceptions/PaymentFailedException.php create mode 100644 app/Exceptions/RefundException.php create mode 100644 app/Http/Requests/Storefront/PayCheckoutRequest.php create mode 100644 app/Http/Resources/Storefront/OrderResource.php create mode 100644 app/Jobs/CancelUnpaidBankTransferOrders.php create mode 100644 app/Models/CustomerAddress.php create mode 100644 app/Models/Fulfillment.php create mode 100644 app/Models/FulfillmentLine.php create mode 100644 app/Models/Order.php create mode 100644 app/Models/OrderLine.php create mode 100644 app/Models/Payment.php create mode 100644 app/Models/Refund.php create mode 100644 app/Services/FulfillmentService.php create mode 100644 app/Services/OrderService.php create mode 100644 app/Services/Payments/MockPaymentProvider.php create mode 100644 app/Services/RefundService.php create mode 100644 app/ValueObjects/PaymentResult.php create mode 100644 app/ValueObjects/RefundResult.php create mode 100644 database/factories/CustomerAddressFactory.php create mode 100644 database/factories/CustomerFactory.php create mode 100644 database/factories/FulfillmentFactory.php create mode 100644 database/factories/FulfillmentLineFactory.php create mode 100644 database/factories/OrderFactory.php create mode 100644 database/factories/OrderLineFactory.php create mode 100644 database/factories/PaymentFactory.php create mode 100644 database/factories/RefundFactory.php create mode 100644 database/migrations/2026_05_03_115452_create_customers_table.php create mode 100644 database/migrations/2026_05_03_115460_create_customer_addresses_table.php create mode 100644 database/migrations/2026_05_03_115461_create_orders_table.php create mode 100644 database/migrations/2026_05_03_115462_create_order_lines_table.php create mode 100644 database/migrations/2026_05_03_115463_create_payments_table.php create mode 100644 database/migrations/2026_05_03_115464_create_refunds_table.php create mode 100644 database/migrations/2026_05_03_115465_create_fulfillments_table.php create mode 100644 database/migrations/2026_05_03_115466_create_fulfillment_lines_table.php create mode 100644 database/seeders/CustomerAddressSeeder.php create mode 100644 database/seeders/CustomerSeeder.php create mode 100644 database/seeders/FulfillmentLineSeeder.php create mode 100644 database/seeders/FulfillmentSeeder.php create mode 100644 database/seeders/OrderLineSeeder.php create mode 100644 database/seeders/OrderSeeder.php create mode 100644 database/seeders/PaymentSeeder.php create mode 100644 database/seeders/RefundSeeder.php create mode 100644 tests/Feature/Api/StorefrontCheckoutPaymentApiTest.php create mode 100644 tests/Feature/Orders/OrderServiceTest.php create mode 100644 tests/Feature/Payments/MockPaymentProviderTest.php diff --git a/app/Contracts/PaymentProvider.php b/app/Contracts/PaymentProvider.php new file mode 100644 index 00000000..bb56fc8d --- /dev/null +++ b/app/Contracts/PaymentProvider.php @@ -0,0 +1,19 @@ + $details + */ + public function charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult; + + public function refund(Payment $payment, int $amount): RefundResult; +} diff --git a/app/Enums/FinancialStatus.php b/app/Enums/FinancialStatus.php new file mode 100644 index 00000000..1a56a06c --- /dev/null +++ b/app/Enums/FinancialStatus.php @@ -0,0 +1,13 @@ +findForStore($checkoutId)->load('order.lines', 'order.payments', 'order.fulfillments.lines'); + + if ($checkout->order !== null) { + return $this->orderResponse($checkout->order); + } + + try { + $method = PaymentMethod::from((string) $request->validated('payment_method')); + + if ($checkout->status !== CheckoutStatus::PaymentSelected || $checkout->payment_method !== $method) { + $checkout = $checkouts->selectPaymentMethod($checkout, $method); + } + + return $this->orderResponse($orders->createFromCheckout($checkout, $request->validated())); + } catch (PaymentFailedException $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + 'error_code' => $exception->errorCode, + ], 422); + } catch (CheckoutStateException $exception) { + $status = str_contains($exception->getMessage(), 'expired') ? 410 : 409; + + return response()->json(['message' => $exception->getMessage()], $status); + } + } + private function checkoutStateError(CheckoutStateException $exception): JsonResponse { $status = str_contains($exception->getMessage(), 'expired') ? 410 : 422; return response()->json(['message' => $exception->getMessage()], $status); } + + private function orderResponse(Order $order): JsonResponse + { + $payload = [ + 'checkout_id' => $order->checkout_id, + 'status' => 'completed', + 'order' => (new OrderResource($order))->resolve(), + ]; + + if ($order->payment_method === PaymentMethod::BankTransfer) { + $payload['bank_transfer_instructions'] = $this->bankTransferInstructions($order); + } + + return response()->json($payload); + } + + /** + * @return array{bank_name: string, iban: string, bic: string, reference: string, amount_formatted: string} + */ + private function bankTransferInstructions(Order $order): array + { + return [ + 'bank_name' => 'Mock Bank AG', + 'iban' => 'DE89 3704 0044 0532 0130 00', + 'bic' => 'COBADEFFXXX', + 'reference' => $order->order_number, + 'amount_formatted' => number_format($order->total_amount / 100, 2).' '.$order->currency, + ]; + } } diff --git a/app/Http/Requests/Storefront/PayCheckoutRequest.php b/app/Http/Requests/Storefront/PayCheckoutRequest.php new file mode 100644 index 00000000..06a5ebcf --- /dev/null +++ b/app/Http/Requests/Storefront/PayCheckoutRequest.php @@ -0,0 +1,41 @@ +has('card_number')) { + $this->merge([ + 'card_number' => preg_replace('/\D+/', '', (string) $this->input('card_number')), + ]); + } + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'payment_method' => ['required', 'in:credit_card,paypal,bank_transfer'], + 'card_number' => ['required_if:payment_method,credit_card', 'nullable', 'digits:16'], + 'card_expiry' => ['required_if:payment_method,credit_card', 'nullable', 'regex:/^(0[1-9]|1[0-2])\/\d{2}$/'], + 'card_cvc' => ['required_if:payment_method,credit_card', 'nullable', 'digits_between:3,4'], + 'card_holder' => ['required_if:payment_method,credit_card', 'nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Resources/Storefront/OrderResource.php b/app/Http/Resources/Storefront/OrderResource.php new file mode 100644 index 00000000..5044da29 --- /dev/null +++ b/app/Http/Resources/Storefront/OrderResource.php @@ -0,0 +1,63 @@ + + */ + public function toArray(Request $request): array + { + $this->resource->loadMissing('lines', 'payments', 'fulfillments.lines'); + + return [ + 'id' => $this->id, + 'order_number' => $this->order_number, + 'status' => $this->status->value, + 'financial_status' => $this->financial_status->value, + 'fulfillment_status' => $this->fulfillment_status->value, + 'payment_method' => $this->payment_method->value, + 'email' => $this->email, + 'currency' => $this->currency, + 'subtotal_amount' => $this->subtotal_amount, + 'discount_amount' => $this->discount_amount, + 'shipping_amount' => $this->shipping_amount, + 'tax_amount' => $this->tax_amount, + 'total_amount' => $this->total_amount, + 'placed_at' => $this->placed_at?->toISOString(), + 'lines' => $this->lines->map(fn ($line): array => [ + 'id' => $line->id, + 'title_snapshot' => $line->title_snapshot, + 'sku_snapshot' => $line->sku_snapshot, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'total_amount' => $line->total_amount, + 'tax_lines' => $line->tax_lines_json ?? [], + 'discount_allocations' => $line->discount_allocations_json ?? [], + ])->all(), + 'payments' => $this->payments->map(fn ($payment): array => [ + 'id' => $payment->id, + 'provider' => $payment->provider, + 'method' => $payment->method->value, + 'status' => $payment->status->value, + 'amount' => $payment->amount, + 'currency' => $payment->currency, + ])->all(), + 'fulfillments' => $this->fulfillments->map(fn ($fulfillment): array => [ + 'id' => $fulfillment->id, + 'status' => $fulfillment->status->value, + 'tracking_company' => $fulfillment->tracking_company, + 'tracking_number' => $fulfillment->tracking_number, + 'tracking_url' => $fulfillment->tracking_url, + 'shipped_at' => $fulfillment->shipped_at?->toISOString(), + 'delivered_at' => $fulfillment->delivered_at?->toISOString(), + ])->all(), + ]; + } +} diff --git a/app/Jobs/CancelUnpaidBankTransferOrders.php b/app/Jobs/CancelUnpaidBankTransferOrders.php new file mode 100644 index 00000000..74af0fc9 --- /dev/null +++ b/app/Jobs/CancelUnpaidBankTransferOrders.php @@ -0,0 +1,52 @@ + + */ + public array $backoff = [1, 5, 10]; + + public int $tries = 3; + + /** + * Execute the job. + */ + public function handle(OrderService $orders): void + { + Store::query() + ->select('id') + ->lazyById() + ->each(function (Store $store) use ($orders): void { + $cutoff = now()->subDays($this->cancelDays($store)); + + Order::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('payment_method', PaymentMethod::BankTransfer->value) + ->where('financial_status', FinancialStatus::Pending->value) + ->where('placed_at', '<', $cutoff) + ->lazyById() + ->each(fn (Order $order): mixed => $orders->cancel($order, 'Bank transfer payment was not received before the cutoff.')); + }); + } + + private function cancelDays(Store $store): int + { + $settings = StoreSettings::query()->find($store->id)?->settings_json ?? []; + + return max(1, (int) ($settings['bank_transfer_cancel_days'] ?? 7)); + } +} diff --git a/app/Livewire/Storefront/Checkout/Confirmation.php b/app/Livewire/Storefront/Checkout/Confirmation.php index 6f1859e5..02c429f6 100644 --- a/app/Livewire/Storefront/Checkout/Confirmation.php +++ b/app/Livewire/Storefront/Checkout/Confirmation.php @@ -19,7 +19,7 @@ public function render(): View { return view('livewire.storefront.checkout.confirmation', [ 'checkout' => Checkout::withoutGlobalScopes() - ->with('cart.lines.variant.product') + ->with('cart.lines.variant.product', 'order.lines', 'order.payments') ->where('store_id', app('current_store')->id) ->whereKey($this->checkoutId) ->firstOrFail(), diff --git a/app/Livewire/Storefront/Checkout/Show.php b/app/Livewire/Storefront/Checkout/Show.php index cfe906f7..fbb808cb 100644 --- a/app/Livewire/Storefront/Checkout/Show.php +++ b/app/Livewire/Storefront/Checkout/Show.php @@ -2,12 +2,15 @@ namespace App\Livewire\Storefront\Checkout; +use App\Enums\CheckoutStatus; use App\Enums\PaymentMethod; use App\Exceptions\CheckoutStateException; use App\Exceptions\InvalidDiscountException; +use App\Exceptions\PaymentFailedException; use App\Exceptions\UnavailableShippingRateException; use App\Models\Checkout; use App\Services\CheckoutService; +use App\Services\OrderService; use App\Services\ShippingCalculator; use Illuminate\View\View; use Livewire\Component; @@ -37,6 +40,14 @@ class Show extends Component public string $paymentMethod = 'credit_card'; + public string $cardNumber = '4242424242424242'; + + public string $cardExpiry = '12/28'; + + public string $cardCvc = '123'; + + public string $cardHolder = 'Jane Doe'; + public string $discountCode = ''; public function mount(int $checkoutId): void @@ -127,6 +138,47 @@ public function selectPayment(): void } } + public function pay(): mixed + { + $this->validate([ + 'paymentMethod' => ['required', 'in:credit_card,paypal,bank_transfer'], + 'cardNumber' => ['required_if:paymentMethod,credit_card', 'nullable', 'regex:/^[0-9 ]{16,23}$/'], + 'cardExpiry' => ['required_if:paymentMethod,credit_card', 'nullable', 'regex:/^(0[1-9]|1[0-2])\/\d{2}$/'], + 'cardCvc' => ['required_if:paymentMethod,credit_card', 'nullable', 'digits_between:3,4'], + 'cardHolder' => ['required_if:paymentMethod,credit_card', 'nullable', 'string', 'max:255'], + ]); + + $checkout = $this->checkout(); + + if ($checkout->order !== null) { + return $this->redirect(route('storefront.checkout.confirmation', $checkout), navigate: true); + } + + try { + $method = PaymentMethod::from($this->paymentMethod); + + if ($checkout->status !== CheckoutStatus::PaymentSelected || $checkout->payment_method !== $method) { + $checkout = app(CheckoutService::class)->selectPaymentMethod($checkout, $method); + } + + app(OrderService::class)->createFromCheckout($checkout, [ + 'payment_method' => $this->paymentMethod, + 'card_number' => preg_replace('/\D+/', '', $this->cardNumber) ?? '', + 'card_expiry' => $this->cardExpiry, + 'card_cvc' => $this->cardCvc, + 'card_holder' => $this->cardHolder, + ]); + + return $this->redirect(route('storefront.checkout.confirmation', $checkout), navigate: true); + } catch (PaymentFailedException $exception) { + $this->addError('payment', $exception->getMessage()); + } catch (CheckoutStateException $exception) { + $this->addError('checkout', $exception->getMessage()); + } + + return null; + } + public function render(): View { $checkout = $this->checkout(); @@ -146,7 +198,7 @@ public function render(): View private function checkout(): Checkout { return Checkout::withoutGlobalScopes() - ->with('cart.lines.variant.product.media', 'cart.lines.variant.optionValues.option', 'cart.lines.variant.inventoryItem', 'shippingRate') + ->with('cart.lines.variant.product.media', 'cart.lines.variant.optionValues.option', 'cart.lines.variant.inventoryItem', 'shippingRate', 'order') ->where('store_id', app('current_store')->id) ->whereKey($this->checkoutId) ->firstOrFail(); diff --git a/app/Models/Checkout.php b/app/Models/Checkout.php index 3cd4072b..e5dcc0ca 100644 --- a/app/Models/Checkout.php +++ b/app/Models/Checkout.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOne; class Checkout extends Model { @@ -88,6 +89,14 @@ public function shippingRate(): BelongsTo return $this->belongsTo(ShippingRate::class, 'shipping_method_id'); } + /** + * @return HasOne + */ + public function order(): HasOne + { + return $this->hasOne(Order::class); + } + public function isExpired(): bool { return $this->status === CheckoutStatus::Expired diff --git a/app/Models/Customer.php b/app/Models/Customer.php index ff458aa8..811876f8 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -5,6 +5,7 @@ use App\Models\Concerns\BelongsToStore; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Facades\Hash; @@ -26,7 +27,6 @@ class Customer extends Authenticatable 'password', 'password_hash', 'marketing_opt_in', - 'status', ]; /** @@ -34,7 +34,6 @@ class Customer extends Authenticatable */ protected $attributes = [ 'marketing_opt_in' => false, - 'status' => 'active', ]; /** @@ -42,7 +41,6 @@ class Customer extends Authenticatable */ protected $hidden = [ 'password_hash', - 'remember_token', ]; /** @@ -51,7 +49,6 @@ class Customer extends Authenticatable protected function casts(): array { return [ - 'email_verified_at' => 'datetime', 'marketing_opt_in' => 'bool', 'password_hash' => 'hashed', ]; @@ -69,4 +66,28 @@ protected function password(): Attribute ], ); } + + /** + * @return HasMany + */ + public function addresses(): HasMany + { + return $this->hasMany(CustomerAddress::class); + } + + /** + * @return HasMany + */ + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + /** + * @return HasMany + */ + public function carts(): HasMany + { + return $this->hasMany(Cart::class); + } } diff --git a/app/Models/CustomerAddress.php b/app/Models/CustomerAddress.php new file mode 100644 index 00000000..b120f7e9 --- /dev/null +++ b/app/Models/CustomerAddress.php @@ -0,0 +1,52 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'customer_id', + 'label', + 'address_json', + 'is_default', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'address_json' => '{}', + 'is_default' => false, + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'address_json' => 'array', + 'is_default' => 'bool', + ]; + } + + /** + * @return BelongsTo + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/Fulfillment.php b/app/Models/Fulfillment.php new file mode 100644 index 00000000..f9dcf66d --- /dev/null +++ b/app/Models/Fulfillment.php @@ -0,0 +1,65 @@ + */ + use HasFactory; + + public const UPDATED_AT = null; + + /** + * @var list + */ + protected $fillable = [ + 'order_id', + 'status', + 'tracking_company', + 'tracking_number', + 'tracking_url', + 'shipped_at', + 'delivered_at', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'status' => FulfillmentShipmentStatus::Pending->value, + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => FulfillmentShipmentStatus::class, + 'shipped_at' => 'datetime', + 'delivered_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** + * @return HasMany + */ + public function lines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } +} diff --git a/app/Models/FulfillmentLine.php b/app/Models/FulfillmentLine.php new file mode 100644 index 00000000..537335b1 --- /dev/null +++ b/app/Models/FulfillmentLine.php @@ -0,0 +1,47 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'fulfillment_id', + 'order_line_id', + 'quantity', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'quantity' => 1, + ]; + + /** + * @return BelongsTo + */ + public function fulfillment(): BelongsTo + { + return $this->belongsTo(Fulfillment::class); + } + + /** + * @return BelongsTo + */ + public function orderLine(): BelongsTo + { + return $this->belongsTo(OrderLine::class); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 00000000..104e47f4 --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,131 @@ + */ + use BelongsToStore, HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'customer_id', + 'checkout_id', + 'order_number', + 'payment_method', + 'status', + 'financial_status', + 'fulfillment_status', + 'currency', + 'subtotal_amount', + 'discount_amount', + 'shipping_amount', + 'tax_amount', + 'total_amount', + 'email', + 'billing_address_json', + 'shipping_address_json', + 'placed_at', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'status' => OrderStatus::Pending->value, + 'financial_status' => FinancialStatus::Pending->value, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled->value, + 'currency' => 'USD', + 'subtotal_amount' => 0, + 'discount_amount' => 0, + 'shipping_amount' => 0, + 'tax_amount' => 0, + 'total_amount' => 0, + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'payment_method' => PaymentMethod::class, + 'status' => OrderStatus::class, + 'financial_status' => FinancialStatus::class, + 'fulfillment_status' => FulfillmentStatus::class, + 'billing_address_json' => 'array', + 'shipping_address_json' => 'array', + 'placed_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** + * @return BelongsTo + */ + public function checkout(): BelongsTo + { + return $this->belongsTo(Checkout::class); + } + + /** + * @return HasMany + */ + public function lines(): HasMany + { + return $this->hasMany(OrderLine::class); + } + + /** + * @return HasMany + */ + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } + + /** + * @return HasOne + */ + public function latestPayment(): HasOne + { + return $this->hasOne(Payment::class)->latestOfMany(); + } + + /** + * @return HasMany + */ + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } + + /** + * @return HasMany + */ + 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..40ab5bf4 --- /dev/null +++ b/app/Models/OrderLine.php @@ -0,0 +1,86 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'order_id', + 'product_id', + 'variant_id', + 'title_snapshot', + 'sku_snapshot', + 'quantity', + 'unit_price_amount', + 'total_amount', + 'tax_lines_json', + 'discount_allocations_json', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'quantity' => 1, + 'unit_price_amount' => 0, + 'total_amount' => 0, + 'tax_lines_json' => '[]', + 'discount_allocations_json' => '[]', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'tax_lines_json' => 'array', + 'discount_allocations_json' => 'array', + ]; + } + + /** + * @return BelongsTo + */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * @return BelongsTo + */ + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class); + } + + /** + * @return HasMany + */ + public function fulfillmentLines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } +} diff --git a/app/Models/Payment.php b/app/Models/Payment.php new file mode 100644 index 00000000..4b5e0c85 --- /dev/null +++ b/app/Models/Payment.php @@ -0,0 +1,70 @@ + */ + use HasFactory; + + public const UPDATED_AT = null; + + /** + * @var list + */ + protected $fillable = [ + 'order_id', + 'provider', + 'method', + 'provider_payment_id', + 'status', + 'amount', + 'currency', + 'raw_json_encrypted', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'provider' => 'mock', + 'status' => PaymentStatus::Pending->value, + 'amount' => 0, + 'currency' => 'USD', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'method' => PaymentMethod::class, + 'status' => PaymentStatus::class, + 'raw_json_encrypted' => 'encrypted:array', + ]; + } + + /** + * @return BelongsTo + */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** + * @return HasMany + */ + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } +} diff --git a/app/Models/Refund.php b/app/Models/Refund.php new file mode 100644 index 00000000..f37009f8 --- /dev/null +++ b/app/Models/Refund.php @@ -0,0 +1,62 @@ + */ + use HasFactory; + + public const UPDATED_AT = null; + + /** + * @var list + */ + protected $fillable = [ + 'order_id', + 'payment_id', + 'amount', + 'reason', + 'status', + 'provider_refund_id', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'amount' => 0, + 'status' => RefundStatus::Pending->value, + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => RefundStatus::class, + ]; + } + + /** + * @return BelongsTo + */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** + * @return BelongsTo + */ + public function payment(): BelongsTo + { + return $this->belongsTo(Payment::class); + } +} diff --git a/app/Models/Store.php b/app/Models/Store.php index 722fbc8d..b968682d 100644 --- a/app/Models/Store.php +++ b/app/Models/Store.php @@ -138,6 +138,14 @@ public function carts(): HasMany return $this->hasMany(Cart::class); } + /** + * @return HasMany + */ + public function customers(): HasMany + { + return $this->hasMany(Customer::class); + } + /** * @return HasMany */ @@ -146,6 +154,14 @@ public function checkouts(): HasMany return $this->hasMany(Checkout::class); } + /** + * @return HasMany + */ + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + /** * @return HasMany */ diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 037b4a86..9f4d41e9 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,9 @@ namespace App\Providers; +use App\Contracts\PaymentProvider; use App\Http\Middleware\ResolveStore; +use App\Services\Payments\MockPaymentProvider; use App\Services\ThemeSettingsService; use Carbon\CarbonImmutable; use Illuminate\Cache\RateLimiting\Limit; @@ -22,6 +24,7 @@ class AppServiceProvider extends ServiceProvider public function register(): void { $this->app->singleton(ThemeSettingsService::class); + $this->app->bind(PaymentProvider::class, MockPaymentProvider::class); } /** diff --git a/app/Services/FulfillmentService.php b/app/Services/FulfillmentService.php new file mode 100644 index 00000000..8979ddea --- /dev/null +++ b/app/Services/FulfillmentService.php @@ -0,0 +1,156 @@ + $lines + * @param array|null $tracking + */ + public function create(Order $order, array $lines, ?array $tracking = null): Fulfillment + { + return DB::transaction(function () use ($order, $lines, $tracking): Fulfillment { + $order = $this->lockOrder($order); + $this->guardCanFulfill($order); + + if ($lines === []) { + throw new FulfillmentQuantityException('A fulfillment must contain at least one line.'); + } + + foreach ($lines as $orderLineId => $quantity) { + $this->validateFulfillmentQuantity($order, (int) $orderLineId, (int) $quantity); + } + + $fulfillment = $order->fulfillments()->create([ + 'status' => FulfillmentShipmentStatus::Pending, + 'tracking_company' => $tracking['tracking_company'] ?? null, + 'tracking_number' => $tracking['tracking_number'] ?? null, + 'tracking_url' => $tracking['tracking_url'] ?? null, + ]); + + foreach ($lines as $orderLineId => $quantity) { + $fulfillment->lines()->create([ + 'order_line_id' => (int) $orderLineId, + 'quantity' => (int) $quantity, + ]); + } + + $this->updateOrderFulfillmentStatus($order); + + return $fulfillment->refresh()->load('lines.orderLine'); + }); + } + + /** + * @param array|null $tracking + */ + public function markAsShipped(Fulfillment $fulfillment, ?array $tracking = null): void + { + DB::transaction(function () use ($fulfillment, $tracking): void { + $fulfillment = Fulfillment::query() + ->whereKey($fulfillment->id) + ->lockForUpdate() + ->firstOrFail(); + + $fulfillment->forceFill([ + 'status' => FulfillmentShipmentStatus::Shipped, + 'tracking_company' => $tracking['tracking_company'] ?? $fulfillment->tracking_company, + 'tracking_number' => $tracking['tracking_number'] ?? $fulfillment->tracking_number, + 'tracking_url' => $tracking['tracking_url'] ?? $fulfillment->tracking_url, + 'shipped_at' => now(), + ])->save(); + }); + } + + public function markAsDelivered(Fulfillment $fulfillment): void + { + DB::transaction(function () use ($fulfillment): void { + Fulfillment::query() + ->whereKey($fulfillment->id) + ->lockForUpdate() + ->firstOrFail() + ->forceFill([ + 'status' => FulfillmentShipmentStatus::Delivered, + 'delivered_at' => now(), + ]) + ->save(); + }); + } + + private function guardCanFulfill(Order $order): void + { + if (! in_array($order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true)) { + throw new FulfillmentGuardException('Fulfillment cannot be created until payment is confirmed.'); + } + } + + private function validateFulfillmentQuantity(Order $order, int $orderLineId, int $quantity): void + { + $line = $order->lines->firstWhere('id', $orderLineId); + + if ($line === null || $quantity < 1) { + throw new FulfillmentQuantityException('The fulfillment line quantity is invalid.'); + } + + $fulfilled = (int) FulfillmentLine::query() + ->where('order_line_id', $orderLineId) + ->sum('quantity'); + + if ($quantity > ($line->quantity - $fulfilled)) { + throw new FulfillmentQuantityException('The fulfillment quantity exceeds the unfulfilled quantity.'); + } + } + + private function updateOrderFulfillmentStatus(Order $order): void + { + $order->load('lines'); + $fulfilledLineIds = 0; + + foreach ($order->lines as $line) { + $fulfilled = (int) FulfillmentLine::query() + ->where('order_line_id', $line->id) + ->sum('quantity'); + + if ($fulfilled >= $line->quantity) { + $fulfilledLineIds++; + } + } + + if ($fulfilledLineIds === $order->lines->count()) { + $order->forceFill([ + 'status' => OrderStatus::Fulfilled, + 'fulfillment_status' => FulfillmentStatus::Fulfilled, + ])->save(); + + OrderFulfilled::dispatch($order); + + return; + } + + $order->forceFill([ + 'fulfillment_status' => FulfillmentStatus::Partial, + ])->save(); + } + + private function lockOrder(Order $order): Order + { + return Order::withoutGlobalScopes() + ->with('lines', 'fulfillments.lines') + ->whereKey($order->id) + ->lockForUpdate() + ->firstOrFail(); + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php new file mode 100644 index 00000000..5c5f29b9 --- /dev/null +++ b/app/Services/OrderService.php @@ -0,0 +1,402 @@ + $paymentDetails + */ + public function createFromCheckout(Checkout $checkout, array $paymentDetails = []): Order + { + $outcome = DB::transaction(function () use ($checkout, $paymentDetails): Order|PaymentFailedException { + $checkout = $this->lockCheckout($checkout); + + $existingOrder = Order::withoutGlobalScopes() + ->with('lines.variant.inventoryItem', 'payments', 'fulfillments.lines') + ->where('checkout_id', $checkout->id) + ->first(); + + if ($existingOrder !== null) { + return $existingOrder; + } + + if ($checkout->status !== CheckoutStatus::PaymentSelected || $checkout->payment_method === null) { + throw new CheckoutStateException('The checkout must have a selected payment method before payment.'); + } + + $this->pricing->calculate($checkout); + $checkout = $this->lockCheckout($checkout); + $paymentResult = $this->payments->charge($checkout, $checkout->payment_method, $paymentDetails); + + if (! $paymentResult->success) { + $this->releaseCheckoutInventory($checkout); + $checkout->forceFill([ + 'status' => CheckoutStatus::ShippingSelected, + 'payment_method' => null, + ])->save(); + + return new PaymentFailedException( + $paymentResult->errorCode ?? 'payment_failed', + $paymentResult->message ?? 'Payment failed.', + ); + } + + $customer = $this->customerForCheckout($checkout); + $totals = $checkout->totals_json ?? []; + $order = Order::withoutGlobalScopes()->create([ + 'store_id' => $checkout->store_id, + 'customer_id' => $customer?->id, + 'checkout_id' => $checkout->id, + 'order_number' => $this->generateOrderNumber($checkout->store), + 'payment_method' => $checkout->payment_method, + 'status' => $paymentResult->status === PaymentStatus::Captured ? OrderStatus::Paid : OrderStatus::Pending, + 'financial_status' => $paymentResult->status === PaymentStatus::Captured ? FinancialStatus::Paid : FinancialStatus::Pending, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled, + 'currency' => (string) ($totals['currency'] ?? $checkout->cart->currency), + 'subtotal_amount' => (int) ($totals['subtotal'] ?? 0), + 'discount_amount' => (int) ($totals['discount'] ?? 0), + 'shipping_amount' => (int) ($totals['shipping'] ?? 0), + 'tax_amount' => (int) ($totals['tax'] ?? 0), + 'total_amount' => (int) ($totals['total'] ?? 0), + 'email' => $checkout->email, + 'billing_address_json' => $checkout->billing_address_json, + 'shipping_address_json' => $checkout->shipping_address_json, + 'placed_at' => now(), + ]); + + $this->createLines($order, $checkout); + $order->payments()->create([ + 'provider' => 'mock', + 'method' => $checkout->payment_method, + 'provider_payment_id' => $paymentResult->reference, + 'status' => $paymentResult->status, + 'amount' => $order->total_amount, + 'currency' => $order->currency, + 'raw_json_encrypted' => $paymentResult->raw, + ]); + + if ($paymentResult->status === PaymentStatus::Captured) { + $this->commitOrderInventory($order); + } + + $checkout->cart->forceFill([ + 'customer_id' => $customer?->id, + 'status' => CartStatus::Converted, + ])->save(); + + $checkout->forceFill([ + 'customer_id' => $customer?->id, + 'status' => CheckoutStatus::Completed, + 'expires_at' => null, + ])->save(); + + $this->incrementDiscountUsage($checkout); + + OrderCreated::dispatch($order); + + if ($paymentResult->status === PaymentStatus::Captured) { + OrderPaid::dispatch($order); + $this->autoFulfillDigitalOrder($order); + } + + return $order->refresh()->load('lines.variant.inventoryItem', 'payments', 'fulfillments.lines'); + }); + + if ($outcome instanceof PaymentFailedException) { + throw $outcome; + } + + return $outcome; + } + + public function generateOrderNumber(Store $store): string + { + $prefix = $this->orderNumberPrefix($store); + $latest = Order::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('order_number', 'like', $prefix.'%') + ->lockForUpdate() + ->latest('id') + ->value('order_number'); + + $lastNumber = $latest === null + ? 1000 + : (int) preg_replace('/\D+/', '', $latest); + + return $prefix.($lastNumber + 1); + } + + public function confirmBankTransfer(Order $order): Order + { + return DB::transaction(function () use ($order): Order { + $order = $this->lockOrder($order); + + if ($order->payment_method !== PaymentMethod::BankTransfer) { + throw new BankTransferConfirmationException('Only bank transfer orders can be confirmed manually.'); + } + + if ($order->financial_status !== FinancialStatus::Pending) { + throw new BankTransferConfirmationException('Only pending bank transfer orders can be confirmed.'); + } + + $order->payments() + ->where('status', PaymentStatus::Pending) + ->latest('id') + ->firstOrFail() + ->forceFill(['status' => PaymentStatus::Captured]) + ->save(); + + $order->forceFill([ + 'status' => OrderStatus::Paid, + 'financial_status' => FinancialStatus::Paid, + ])->save(); + + $this->commitOrderInventory($order); + OrderPaid::dispatch($order); + $this->autoFulfillDigitalOrder($order); + + return $order->refresh()->load('lines.variant.inventoryItem', 'payments', 'fulfillments.lines'); + }); + } + + public function cancel(Order $order, string $reason): void + { + DB::transaction(function () use ($order, $reason): void { + $order = $this->lockOrder($order); + + if ($order->fulfillment_status !== FulfillmentStatus::Unfulfilled) { + throw new CheckoutStateException('Fulfilled orders cannot be cancelled.'); + } + + if ($order->financial_status === FinancialStatus::Pending) { + $this->releaseOrderInventory($order); + $order->payments()->update(['status' => PaymentStatus::Failed->value]); + } elseif (in_array($order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true)) { + $this->restockOrderInventory($order); + } + + $order->forceFill([ + 'status' => OrderStatus::Cancelled, + 'financial_status' => $order->financial_status === FinancialStatus::Pending + ? FinancialStatus::Voided + : $order->financial_status, + ])->save(); + + OrderCancelled::dispatch($order, $reason); + }); + } + + private function lockCheckout(Checkout $checkout): Checkout + { + return Checkout::withoutGlobalScopes() + ->with('store', 'cart.lines.variant.product', 'cart.lines.variant.inventoryItem', 'cart.lines.variant.optionValues.option', 'shippingRate') + ->whereKey($checkout->id) + ->lockForUpdate() + ->firstOrFail(); + } + + private function lockOrder(Order $order): Order + { + return Order::withoutGlobalScopes() + ->with('lines.variant.inventoryItem', 'payments', 'fulfillments.lines') + ->whereKey($order->id) + ->lockForUpdate() + ->firstOrFail(); + } + + private function customerForCheckout(Checkout $checkout): ?Customer + { + if ($checkout->email === null) { + return null; + } + + $shippingAddress = $checkout->shipping_address_json ?? []; + $name = trim((string) (($shippingAddress['first_name'] ?? '').' '.($shippingAddress['last_name'] ?? ''))); + $customer = Customer::withoutGlobalScopes()->firstOrCreate( + [ + 'store_id' => $checkout->store_id, + 'email' => Str::lower($checkout->email), + ], + [ + 'name' => $name !== '' ? $name : null, + 'password_hash' => null, + 'marketing_opt_in' => false, + ], + ); + + if ($shippingAddress !== [] && ! $customer->addresses()->exists()) { + CustomerAddress::query()->create([ + 'customer_id' => $customer->id, + 'label' => 'Default', + 'address_json' => $shippingAddress, + 'is_default' => true, + ]); + } + + return $customer; + } + + private function createLines(Order $order, Checkout $checkout): void + { + $discount = $checkout->discount_code === null + ? null + : Discount::withoutGlobalScopes() + ->where('store_id', $checkout->store_id) + ->whereRaw('lower(code) = ?', [Str::lower($checkout->discount_code)]) + ->first(); + + foreach ($checkout->cart->lines as $line) { + $variant = $line->variant; + $product = $variant?->product; + $discountAllocations = $line->line_discount_amount > 0 + ? [[ + 'discount_id' => $discount?->id, + 'amount' => $line->line_discount_amount, + ]] + : []; + + $order->lines()->create([ + 'product_id' => $product?->id, + 'variant_id' => $variant?->id, + 'title_snapshot' => $product?->title ?? 'Unknown item', + 'sku_snapshot' => $variant?->sku, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'total_amount' => $line->line_total_amount, + 'tax_lines_json' => [], + 'discount_allocations_json' => $discountAllocations, + ]); + } + } + + private function incrementDiscountUsage(Checkout $checkout): void + { + if ($checkout->discount_code === null) { + return; + } + + Discount::withoutGlobalScopes() + ->where('store_id', $checkout->store_id) + ->whereRaw('lower(code) = ?', [Str::lower($checkout->discount_code)]) + ->increment('usage_count'); + } + + private function commitOrderInventory(Order $order): void + { + foreach ($order->lines as $line) { + $item = $line->variant?->inventoryItem; + + if ($item !== null) { + $this->inventory->commit($item, $line->quantity); + } + } + } + + private function releaseCheckoutInventory(Checkout $checkout): void + { + foreach ($checkout->cart->lines as $line) { + $item = $line->variant?->inventoryItem; + + if ($item !== null) { + $this->inventory->release($item, $line->quantity); + } + } + } + + private function releaseOrderInventory(Order $order): void + { + foreach ($order->lines as $line) { + $item = $line->variant?->inventoryItem; + + if ($item !== null) { + $this->inventory->release($item, $line->quantity); + } + } + } + + private function restockOrderInventory(Order $order): void + { + foreach ($order->lines as $line) { + $item = $line->variant?->inventoryItem; + + if ($item !== null) { + $this->inventory->restock($item, $line->quantity); + } + } + } + + private function autoFulfillDigitalOrder(Order $order): void + { + $order->loadMissing('lines.variant', 'fulfillments'); + + if ($order->lines->isEmpty() || $order->fulfillments->isNotEmpty()) { + return; + } + + $allDigital = $order->lines->every(fn ($line): bool => $line->variant?->requires_shipping === false); + + if (! $allDigital) { + return; + } + + $fulfillment = $order->fulfillments()->create([ + 'status' => FulfillmentShipmentStatus::Delivered, + 'shipped_at' => now(), + 'delivered_at' => now(), + ]); + + foreach ($order->lines as $line) { + $fulfillment->lines()->create([ + 'order_line_id' => $line->id, + 'quantity' => $line->quantity, + ]); + } + + $order->forceFill([ + 'status' => OrderStatus::Fulfilled, + 'fulfillment_status' => FulfillmentStatus::Fulfilled, + ])->save(); + + OrderFulfilled::dispatch($order); + } + + private function orderNumberPrefix(Store $store): string + { + $settings = StoreSettings::query()->find($store->id)?->settings_json ?? []; + + return (string) ($settings['order_number_prefix'] ?? '#'); + } +} diff --git a/app/Services/Payments/MockPaymentProvider.php b/app/Services/Payments/MockPaymentProvider.php new file mode 100644 index 00000000..79a9cd72 --- /dev/null +++ b/app/Services/Payments/MockPaymentProvider.php @@ -0,0 +1,57 @@ + $details + */ + public function charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult + { + return match ($method) { + PaymentMethod::CreditCard => $this->chargeCard((string) ($details['card_number'] ?? '')), + PaymentMethod::Paypal => PaymentResult::captured($this->reference()), + PaymentMethod::BankTransfer => PaymentResult::pending($this->reference()), + }; + } + + public function refund(Payment $payment, int $amount): RefundResult + { + if ($amount < 1 || $amount > $payment->amount) { + return RefundResult::failed('invalid_refund_amount', 'The refund amount is invalid.'); + } + + return RefundResult::processed('mock_ref_'.Str::random(24)); + } + + private function chargeCard(string $cardNumber): PaymentResult + { + $cardNumber = preg_replace('/\D+/', '', $cardNumber) ?? ''; + + return match ($cardNumber) { + self::DECLINE_CARD => PaymentResult::failed('card_declined', 'Your card was declined.'), + self::INSUFFICIENT_FUNDS_CARD => PaymentResult::failed('insufficient_funds', 'Your card has insufficient funds.'), + default => PaymentResult::captured($this->reference()), + }; + } + + private function reference(): string + { + return 'mock_'.Str::random(24); + } +} diff --git a/app/Services/RefundService.php b/app/Services/RefundService.php new file mode 100644 index 00000000..75a510a7 --- /dev/null +++ b/app/Services/RefundService.php @@ -0,0 +1,97 @@ +with('lines.variant.inventoryItem', 'refunds') + ->whereKey($order->id) + ->lockForUpdate() + ->firstOrFail(); + + $payment = Payment::query() + ->where('order_id', $order->id) + ->whereKey($payment->id) + ->lockForUpdate() + ->firstOrFail(); + + if ($payment->status !== PaymentStatus::Captured) { + throw new RefundException('payment_not_captured', 'Only captured payments can be refunded.'); + } + + $refundable = $order->total_amount - (int) $order->refunds->where('status', RefundStatus::Processed)->sum('amount'); + + if ($amount < 1 || $amount > $refundable) { + throw new RefundException('invalid_refund_amount', 'The refund amount exceeds the remaining refundable amount.'); + } + + $result = $this->payments->refund($payment, $amount); + $refund = $order->refunds()->create([ + 'payment_id' => $payment->id, + 'amount' => $amount, + 'reason' => $reason, + 'status' => $result->status, + 'provider_refund_id' => $result->reference, + ]); + + if (! $result->success) { + throw new RefundException( + $result->errorCode ?? 'refund_failed', + $result->message ?? 'The refund could not be processed.', + ); + } + + $totalRefunded = (int) $order->refunds()->where('status', RefundStatus::Processed)->sum('amount'); + $fullyRefunded = $totalRefunded >= $order->total_amount; + + $order->forceFill([ + 'financial_status' => $fullyRefunded ? FinancialStatus::Refunded : FinancialStatus::PartiallyRefunded, + 'status' => $fullyRefunded ? OrderStatus::Refunded : $order->status, + ])->save(); + + if ($fullyRefunded) { + $payment->forceFill(['status' => PaymentStatus::Refunded])->save(); + } + + if ($restock) { + $this->restockOrder($order); + } + + OrderRefunded::dispatch($order, $refund); + + return $refund->refresh(); + }); + } + + private function restockOrder(Order $order): void + { + foreach ($order->lines as $line) { + $item = $line->variant?->inventoryItem; + + if ($item !== null) { + $this->inventory->restock($item, $line->quantity); + } + } + } +} diff --git a/app/ValueObjects/PaymentResult.php b/app/ValueObjects/PaymentResult.php new file mode 100644 index 00000000..9bfff87e --- /dev/null +++ b/app/ValueObjects/PaymentResult.php @@ -0,0 +1,56 @@ + $raw + */ + public function __construct( + public readonly bool $success, + public readonly PaymentStatus $status, + public readonly ?string $reference, + public readonly ?string $errorCode, + public readonly ?string $message, + public readonly array $raw = [], + ) {} + + public static function captured(string $reference): self + { + return new self( + success: true, + status: PaymentStatus::Captured, + reference: $reference, + errorCode: null, + message: null, + raw: ['provider' => 'mock', 'status' => PaymentStatus::Captured->value, 'reference' => $reference], + ); + } + + public static function pending(string $reference): self + { + return new self( + success: true, + status: PaymentStatus::Pending, + reference: $reference, + errorCode: null, + message: null, + raw: ['provider' => 'mock', 'status' => PaymentStatus::Pending->value, 'reference' => $reference], + ); + } + + public static function failed(string $errorCode, string $message): self + { + return new self( + success: false, + status: PaymentStatus::Failed, + reference: null, + errorCode: $errorCode, + message: $message, + raw: ['provider' => 'mock', 'status' => PaymentStatus::Failed->value, 'error_code' => $errorCode], + ); + } +} diff --git a/app/ValueObjects/RefundResult.php b/app/ValueObjects/RefundResult.php new file mode 100644 index 00000000..1877df11 --- /dev/null +++ b/app/ValueObjects/RefundResult.php @@ -0,0 +1,42 @@ + $raw + */ + public function __construct( + public readonly bool $success, + public readonly RefundStatus $status, + public readonly ?string $reference, + public readonly ?string $errorCode = null, + public readonly ?string $message = null, + public readonly array $raw = [], + ) {} + + public static function processed(string $reference): self + { + return new self( + success: true, + status: RefundStatus::Processed, + reference: $reference, + raw: ['provider' => 'mock', 'status' => RefundStatus::Processed->value, 'reference' => $reference], + ); + } + + public static function failed(string $errorCode, string $message): self + { + return new self( + success: false, + status: RefundStatus::Failed, + reference: null, + errorCode: $errorCode, + message: $message, + raw: ['provider' => 'mock', 'status' => RefundStatus::Failed->value, 'error_code' => $errorCode], + ); + } +} diff --git a/database/factories/CustomerAddressFactory.php b/database/factories/CustomerAddressFactory.php new file mode 100644 index 00000000..178c4083 --- /dev/null +++ b/database/factories/CustomerAddressFactory.php @@ -0,0 +1,40 @@ + + */ +class CustomerAddressFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'customer_id' => Customer::factory(), + 'label' => fake()->randomElement(['Home', 'Work', null]), + 'address_json' => [ + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'company' => fake()->optional()->company(), + 'address1' => fake()->streetAddress(), + 'address2' => fake()->optional()->secondaryAddress(), + 'city' => fake()->city(), + 'province' => fake()->state(), + 'province_code' => fake()->stateAbbr(), + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => fake()->postcode(), + 'phone' => fake()->optional()->phoneNumber(), + ], + 'is_default' => true, + ]; + } +} diff --git a/database/factories/CustomerFactory.php b/database/factories/CustomerFactory.php new file mode 100644 index 00000000..bf95a9e9 --- /dev/null +++ b/database/factories/CustomerFactory.php @@ -0,0 +1,36 @@ + + */ +class CustomerFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'email' => fake()->unique()->safeEmail(), + 'password_hash' => null, + 'name' => fake()->name(), + 'marketing_opt_in' => fake()->boolean(30), + ]; + } + + public function registered(): static + { + return $this->state(fn (array $attributes): array => [ + 'password_hash' => Hash::make('password'), + ]); + } +} diff --git a/database/factories/FulfillmentFactory.php b/database/factories/FulfillmentFactory.php new file mode 100644 index 00000000..faab49eb --- /dev/null +++ b/database/factories/FulfillmentFactory.php @@ -0,0 +1,40 @@ + + */ +class FulfillmentFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => Order::factory()->paid(), + 'status' => FulfillmentShipmentStatus::Pending, + 'tracking_company' => fake()->optional()->randomElement(['DHL', 'UPS', 'FedEx']), + 'tracking_number' => fake()->optional()->bothify('TRK########'), + 'tracking_url' => null, + 'shipped_at' => null, + 'delivered_at' => null, + ]; + } + + public function delivered(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => FulfillmentShipmentStatus::Delivered, + 'shipped_at' => now(), + 'delivered_at' => now(), + ]); + } +} diff --git a/database/factories/FulfillmentLineFactory.php b/database/factories/FulfillmentLineFactory.php new file mode 100644 index 00000000..b4d8ca98 --- /dev/null +++ b/database/factories/FulfillmentLineFactory.php @@ -0,0 +1,29 @@ + + */ +class FulfillmentLineFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $orderLine = OrderLine::factory()->create(); + + return [ + 'fulfillment_id' => Fulfillment::factory()->for($orderLine->order), + 'order_line_id' => $orderLine->id, + 'quantity' => $orderLine->quantity, + ]; + } +} diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php new file mode 100644 index 00000000..4b93f481 --- /dev/null +++ b/database/factories/OrderFactory.php @@ -0,0 +1,82 @@ + + */ +class OrderFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'customer_id' => null, + 'checkout_id' => null, + 'order_number' => '#'.fake()->unique()->numberBetween(1001, 9999), + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled, + 'currency' => 'EUR', + 'subtotal_amount' => 5000, + 'discount_amount' => 0, + 'shipping_amount' => 500, + 'tax_amount' => 1045, + 'total_amount' => 6545, + 'email' => fake()->safeEmail(), + 'billing_address_json' => $this->address(), + 'shipping_address_json' => $this->address(), + 'placed_at' => now(), + ]; + } + + public function paid(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => OrderStatus::Paid, + 'financial_status' => FinancialStatus::Paid, + ]); + } + + public function bankTransferPending(): static + { + return $this->state(fn (array $attributes): array => [ + 'payment_method' => PaymentMethod::BankTransfer, + 'status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + ]); + } + + /** + * @return array + */ + private function address(): array + { + return [ + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'address1' => fake()->streetAddress(), + 'address2' => null, + 'city' => fake()->city(), + 'province' => fake()->state(), + 'province_code' => fake()->stateAbbr(), + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => fake()->postcode(), + 'phone' => null, + ]; + } +} diff --git a/database/factories/OrderLineFactory.php b/database/factories/OrderLineFactory.php new file mode 100644 index 00000000..ff044591 --- /dev/null +++ b/database/factories/OrderLineFactory.php @@ -0,0 +1,40 @@ + + */ +class OrderLineFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->for($product)->default()->create(); + $quantity = fake()->numberBetween(1, 3); + $unitPrice = $variant->price_amount; + + return [ + 'order_id' => Order::factory()->for($product->store), + 'product_id' => $product->id, + 'variant_id' => $variant->id, + 'title_snapshot' => $product->title, + 'sku_snapshot' => $variant->sku, + 'quantity' => $quantity, + 'unit_price_amount' => $unitPrice, + 'total_amount' => $unitPrice * $quantity, + 'tax_lines_json' => [], + 'discount_allocations_json' => [], + ]; + } +} diff --git a/database/factories/PaymentFactory.php b/database/factories/PaymentFactory.php new file mode 100644 index 00000000..cdb36ac0 --- /dev/null +++ b/database/factories/PaymentFactory.php @@ -0,0 +1,45 @@ + + */ +class PaymentFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'provider' => 'mock', + 'method' => PaymentMethod::CreditCard, + 'provider_payment_id' => 'mock_'.Str::random(16), + 'status' => PaymentStatus::Captured, + 'amount' => 6545, + 'currency' => 'EUR', + 'raw_json_encrypted' => [ + 'reference' => 'mock_'.Str::random(16), + 'status' => PaymentStatus::Captured->value, + ], + ]; + } + + public function pendingBankTransfer(): static + { + return $this->state(fn (array $attributes): array => [ + 'method' => PaymentMethod::BankTransfer, + 'status' => PaymentStatus::Pending, + ]); + } +} diff --git a/database/factories/RefundFactory.php b/database/factories/RefundFactory.php new file mode 100644 index 00000000..01da81ab --- /dev/null +++ b/database/factories/RefundFactory.php @@ -0,0 +1,33 @@ + + */ +class RefundFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $payment = Payment::factory()->create(); + + return [ + 'order_id' => $payment->order_id, + 'payment_id' => $payment->id, + 'amount' => fake()->numberBetween(100, $payment->amount), + 'reason' => fake()->optional()->sentence(), + 'status' => RefundStatus::Processed, + 'provider_refund_id' => 'mock_ref_'.Str::random(16), + ]; + } +} diff --git a/database/migrations/2026_05_03_115452_create_customers_table.php b/database/migrations/2026_05_03_115452_create_customers_table.php new file mode 100644 index 00000000..47c0ddfc --- /dev/null +++ b/database/migrations/2026_05_03_115452_create_customers_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('email'); + $table->string('password_hash')->nullable(); + $table->string('name')->nullable(); + $table->boolean('marketing_opt_in')->default(false); + $table->timestamps(); + + $table->unique(['store_id', 'email']); + $table->index('store_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('customers'); + } +}; diff --git a/database/migrations/2026_05_03_115453_create_carts_table.php b/database/migrations/2026_05_03_115453_create_carts_table.php index 9ba48f99..accc54d3 100644 --- a/database/migrations/2026_05_03_115453_create_carts_table.php +++ b/database/migrations/2026_05_03_115453_create_carts_table.php @@ -14,7 +14,7 @@ public function up(): void Schema::create('carts', function (Blueprint $table) { $table->id(); $table->foreignId('store_id')->constrained()->cascadeOnDelete(); - $table->unsignedBigInteger('customer_id')->nullable(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); $table->string('currency', 3)->default('USD'); $table->integer('cart_version')->default(1); $table->enum('status', ['active', 'converted', 'abandoned'])->default('active'); diff --git a/database/migrations/2026_05_03_115455_create_checkouts_table.php b/database/migrations/2026_05_03_115455_create_checkouts_table.php index c9a6c735..7bb912eb 100644 --- a/database/migrations/2026_05_03_115455_create_checkouts_table.php +++ b/database/migrations/2026_05_03_115455_create_checkouts_table.php @@ -15,7 +15,7 @@ public function up(): void $table->id(); $table->foreignId('store_id')->constrained()->cascadeOnDelete(); $table->foreignId('cart_id')->constrained()->cascadeOnDelete(); - $table->unsignedBigInteger('customer_id')->nullable(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); $table->enum('status', ['started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired'])->default('started'); $table->enum('payment_method', ['credit_card', 'paypal', 'bank_transfer'])->nullable(); $table->string('email')->nullable(); diff --git a/database/migrations/2026_05_03_115460_create_customer_addresses_table.php b/database/migrations/2026_05_03_115460_create_customer_addresses_table.php new file mode 100644 index 00000000..56489c98 --- /dev/null +++ b/database/migrations/2026_05_03_115460_create_customer_addresses_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('customer_id')->constrained()->cascadeOnDelete(); + $table->string('label')->nullable(); + $table->text('address_json')->default('{}'); + $table->boolean('is_default')->default(false); + + $table->index('customer_id'); + $table->index(['customer_id', 'is_default']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('customer_addresses'); + } +}; diff --git a/database/migrations/2026_05_03_115461_create_orders_table.php b/database/migrations/2026_05_03_115461_create_orders_table.php new file mode 100644 index 00000000..f589b405 --- /dev/null +++ b/database/migrations/2026_05_03_115461_create_orders_table.php @@ -0,0 +1,53 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('checkout_id')->nullable()->unique()->constrained()->nullOnDelete(); + $table->string('order_number'); + $table->enum('payment_method', ['credit_card', 'paypal', 'bank_transfer']); + $table->enum('status', ['pending', 'paid', 'fulfilled', 'cancelled', 'refunded'])->default('pending'); + $table->enum('financial_status', ['pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided'])->default('pending'); + $table->enum('fulfillment_status', ['unfulfilled', 'partial', 'fulfilled'])->default('unfulfilled'); + $table->string('currency', 3)->default('USD'); + $table->integer('subtotal_amount')->default(0); + $table->integer('discount_amount')->default(0); + $table->integer('shipping_amount')->default(0); + $table->integer('tax_amount')->default(0); + $table->integer('total_amount')->default(0); + $table->string('email')->nullable(); + $table->text('billing_address_json')->nullable(); + $table->text('shipping_address_json')->nullable(); + $table->timestamp('placed_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'order_number']); + $table->index('store_id'); + $table->index('customer_id'); + $table->index(['store_id', 'status']); + $table->index(['store_id', 'financial_status']); + $table->index(['store_id', 'fulfillment_status']); + $table->index(['store_id', 'placed_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('orders'); + } +}; diff --git a/database/migrations/2026_05_03_115462_create_order_lines_table.php b/database/migrations/2026_05_03_115462_create_order_lines_table.php new file mode 100644 index 00000000..50455b8d --- /dev/null +++ b/database/migrations/2026_05_03_115462_create_order_lines_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('variant_id')->nullable()->constrained('product_variants')->nullOnDelete(); + $table->string('title_snapshot'); + $table->string('sku_snapshot')->nullable(); + $table->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'); + $table->index('product_id'); + $table->index('variant_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('order_lines'); + } +}; diff --git a/database/migrations/2026_05_03_115463_create_payments_table.php b/database/migrations/2026_05_03_115463_create_payments_table.php new file mode 100644 index 00000000..c5f927cd --- /dev/null +++ b/database/migrations/2026_05_03_115463_create_payments_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->enum('provider', ['mock'])->default('mock'); + $table->enum('method', ['credit_card', 'paypal', 'bank_transfer']); + $table->string('provider_payment_id')->nullable(); + $table->enum('status', ['pending', 'captured', 'failed', 'refunded'])->default('pending'); + $table->integer('amount')->default(0); + $table->string('currency', 3)->default('USD'); + $table->text('raw_json_encrypted')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('order_id'); + $table->index(['provider', 'provider_payment_id']); + $table->index('method'); + $table->index('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('payments'); + } +}; diff --git a/database/migrations/2026_05_03_115464_create_refunds_table.php b/database/migrations/2026_05_03_115464_create_refunds_table.php new file mode 100644 index 00000000..fc0ebf5e --- /dev/null +++ b/database/migrations/2026_05_03_115464_create_refunds_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('payment_id')->constrained()->cascadeOnDelete(); + $table->integer('amount')->default(0); + $table->string('reason')->nullable(); + $table->enum('status', ['pending', 'processed', 'failed'])->default('pending'); + $table->string('provider_refund_id')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('order_id'); + $table->index('payment_id'); + $table->index('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('refunds'); + } +}; diff --git a/database/migrations/2026_05_03_115465_create_fulfillments_table.php b/database/migrations/2026_05_03_115465_create_fulfillments_table.php new file mode 100644 index 00000000..b6450b9c --- /dev/null +++ b/database/migrations/2026_05_03_115465_create_fulfillments_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->enum('status', ['pending', 'shipped', 'delivered'])->default('pending'); + $table->string('tracking_company')->nullable(); + $table->string('tracking_number')->nullable(); + $table->string('tracking_url')->nullable(); + $table->timestamp('shipped_at')->nullable(); + $table->timestamp('delivered_at')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('order_id'); + $table->index('status'); + $table->index(['tracking_company', 'tracking_number']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('fulfillments'); + } +}; diff --git a/database/migrations/2026_05_03_115466_create_fulfillment_lines_table.php b/database/migrations/2026_05_03_115466_create_fulfillment_lines_table.php new file mode 100644 index 00000000..b288a246 --- /dev/null +++ b/database/migrations/2026_05_03_115466_create_fulfillment_lines_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('fulfillment_id')->constrained()->cascadeOnDelete(); + $table->foreignId('order_line_id')->constrained()->cascadeOnDelete(); + $table->integer('quantity')->default(1); + + $table->index('fulfillment_id'); + $table->unique(['fulfillment_id', 'order_line_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('fulfillment_lines'); + } +}; diff --git a/database/seeders/CustomerAddressSeeder.php b/database/seeders/CustomerAddressSeeder.php new file mode 100644 index 00000000..5169f5ae --- /dev/null +++ b/database/seeders/CustomerAddressSeeder.php @@ -0,0 +1,16 @@ +where('handle', 'acme-fashion')->firstOrFail(); + + foreach ([ + ['email' => 'jane@example.com', 'name' => 'Jane Doe'], + ['email' => 'john@example.com', 'name' => 'John Doe'], + ] as $index => $data) { + $customer = Customer::query()->updateOrCreate( + [ + 'store_id' => $store->id, + 'email' => $data['email'], + ], + [ + 'name' => $data['name'], + 'password_hash' => null, + 'marketing_opt_in' => $index === 0, + ], + ); + + [$firstName, $lastName] = explode(' ', $data['name']); + + CustomerAddress::query()->updateOrCreate( + [ + 'customer_id' => $customer->id, + 'label' => 'Default', + ], + [ + 'address_json' => [ + 'first_name' => $firstName, + 'last_name' => $lastName, + 'company' => null, + 'address1' => 'Musterstrasse 1', + 'address2' => null, + 'city' => 'Berlin', + 'province' => 'Berlin', + 'province_code' => 'BE', + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => '10115', + 'phone' => null, + ], + 'is_default' => true, + ], + ); + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index bc985020..3ef1829a 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -23,6 +23,8 @@ public function run(): void ShippingZoneSeeder::class, TaxSettingsSeeder::class, DiscountSeeder::class, + CustomerSeeder::class, + OrderSeeder::class, ThemeSeeder::class, PageSeeder::class, NavigationSeeder::class, diff --git a/database/seeders/FulfillmentLineSeeder.php b/database/seeders/FulfillmentLineSeeder.php new file mode 100644 index 00000000..28b2f40f --- /dev/null +++ b/database/seeders/FulfillmentLineSeeder.php @@ -0,0 +1,16 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $variant = Product::query() + ->where('store_id', $store->id) + ->where('handle', 'linen-shirt') + ->firstOrFail() + ->variants() + ->with('product') + ->firstOrFail(); + + $jane = Customer::query() + ->where('store_id', $store->id) + ->where('email', 'jane@example.com') + ->firstOrFail(); + + $john = Customer::query() + ->where('store_id', $store->id) + ->where('email', 'john@example.com') + ->firstOrFail(); + + $this->createPaidOrder($store, $jane, $variant); + $this->createPendingBankTransferOrder($store, $john, $variant); + $this->createFulfilledOrder($store, $jane, $variant); + } + + private function createPaidOrder(Store $store, Customer $customer, ProductVariant $variant): void + { + if (Order::withoutGlobalScopes()->where('store_id', $store->id)->where('order_number', '#1001')->exists()) { + return; + } + + $order = $this->createBaseOrder($store, $customer, $variant, '#1001', PaymentMethod::CreditCard, OrderStatus::Paid, FinancialStatus::Paid, FulfillmentStatus::Unfulfilled, now()->subDays(3)); + $this->createPayment($order, PaymentMethod::CreditCard, PaymentStatus::Captured, 'mock_seed_1001'); + $variant->inventoryItem()->withoutGlobalScopes()->decrement('quantity_on_hand'); + } + + private function createPendingBankTransferOrder(Store $store, Customer $customer, ProductVariant $variant): void + { + if (Order::withoutGlobalScopes()->where('store_id', $store->id)->where('order_number', '#1002')->exists()) { + return; + } + + $order = $this->createBaseOrder($store, $customer, $variant, '#1002', PaymentMethod::BankTransfer, OrderStatus::Pending, FinancialStatus::Pending, FulfillmentStatus::Unfulfilled, now()->subDay()); + $this->createPayment($order, PaymentMethod::BankTransfer, PaymentStatus::Pending, 'mock_seed_1002'); + $variant->inventoryItem()->withoutGlobalScopes()->increment('quantity_reserved'); + } + + private function createFulfilledOrder(Store $store, Customer $customer, ProductVariant $variant): void + { + if (Order::withoutGlobalScopes()->where('store_id', $store->id)->where('order_number', '#1003')->exists()) { + return; + } + + $order = $this->createBaseOrder($store, $customer, $variant, '#1003', PaymentMethod::Paypal, OrderStatus::Fulfilled, FinancialStatus::Paid, FulfillmentStatus::Fulfilled, now()->subDays(7)); + $this->createPayment($order, PaymentMethod::Paypal, PaymentStatus::Captured, 'mock_seed_1003'); + + $line = $order->lines()->firstOrFail(); + $fulfillment = $order->fulfillments()->create([ + 'status' => FulfillmentShipmentStatus::Delivered, + 'tracking_company' => 'DHL', + 'tracking_number' => 'DHL1003', + 'tracking_url' => 'https://example.test/tracking/DHL1003', + 'shipped_at' => now()->subDays(6), + 'delivered_at' => now()->subDays(4), + ]); + + $fulfillment->lines()->create([ + 'order_line_id' => $line->id, + 'quantity' => 1, + ]); + + $variant->inventoryItem()->withoutGlobalScopes()->decrement('quantity_on_hand'); + } + + private function createBaseOrder( + Store $store, + Customer $customer, + ProductVariant $variant, + string $orderNumber, + PaymentMethod $method, + OrderStatus $status, + FinancialStatus $financialStatus, + FulfillmentStatus $fulfillmentStatus, + CarbonInterface $placedAt, + ): Order { + $address = $customer->addresses()->firstOrFail()->address_json; + $order = Order::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'customer_id' => $customer->id, + 'order_number' => $orderNumber, + 'payment_method' => $method, + 'status' => $status, + 'financial_status' => $financialStatus, + 'fulfillment_status' => $fulfillmentStatus, + 'currency' => $store->default_currency, + 'subtotal_amount' => 4999, + 'discount_amount' => 0, + 'shipping_amount' => 500, + 'tax_amount' => 1045, + 'total_amount' => 6544, + 'email' => $customer->email, + 'billing_address_json' => $address, + 'shipping_address_json' => $address, + 'placed_at' => $placedAt, + ]); + + $order->lines()->create([ + 'product_id' => $variant->product_id, + 'variant_id' => $variant->id, + 'title_snapshot' => $variant->product->title, + 'sku_snapshot' => $variant->sku, + 'quantity' => 1, + 'unit_price_amount' => 4999, + 'total_amount' => 4999, + 'tax_lines_json' => [['title' => 'Tax', 'rate' => 1900, 'amount' => 950]], + 'discount_allocations_json' => [], + ]); + + return $order; + } + + private function createPayment(Order $order, PaymentMethod $method, PaymentStatus $status, string $reference): void + { + $order->payments()->create([ + 'provider' => 'mock', + 'method' => $method, + 'provider_payment_id' => $reference, + 'status' => $status, + 'amount' => $order->total_amount, + 'currency' => $order->currency, + 'raw_json_encrypted' => ['provider' => 'mock', 'reference' => $reference], + ]); + } +} diff --git a/database/seeders/PaymentSeeder.php b/database/seeders/PaymentSeeder.php new file mode 100644 index 00000000..730a50ae --- /dev/null +++ b/database/seeders/PaymentSeeder.php @@ -0,0 +1,16 @@ + [ 'order_confirmation' => true, ], + 'order_number_prefix' => '#', + 'bank_transfer_cancel_days' => 7, ], ], ); diff --git a/resources/views/livewire/storefront/checkout/confirmation.blade.php b/resources/views/livewire/storefront/checkout/confirmation.blade.php index e2dabaa5..387e0cba 100644 --- a/resources/views/livewire/storefront/checkout/confirmation.blade.php +++ b/resources/views/livewire/storefront/checkout/confirmation.blade.php @@ -1,11 +1,28 @@
-

Checkout saved

+

Order confirmed

-

Checkout #{{ $checkout->id }} is {{ str_replace('_', ' ', $checkout->status->value) }}.

-
-
Email
{{ $checkout->email }}
-
Total
@include('storefront.components.price', ['amount' => $checkout->totals_json['total'] ?? 0, 'currency' => $checkout->totals_json['currency'] ?? $checkout->cart->currency])
-
+ @if($checkout->order) +

Order {{ $checkout->order->order_number }} is {{ str_replace('_', ' ', $checkout->order->financial_status->value) }}.

+
+
Email
{{ $checkout->order->email }}
+
Total
@include('storefront.components.price', ['amount' => $checkout->order->total_amount, 'currency' => $checkout->order->currency])
+
Payment
{{ str_replace('_', ' ', $checkout->order->payment_method->value) }}
+
+ + @if($checkout->order->payment_method === \App\Enums\PaymentMethod::BankTransfer) +
+

Bank transfer

+
+
Bank
Mock Bank AG
+
IBAN
DE89 3704 0044 0532 0130 00
+
BIC
COBADEFFXXX
+
Reference
{{ $checkout->order->order_number }}
+
+
+ @endif + @else +

Checkout #{{ $checkout->id }} is {{ str_replace('_', ' ', $checkout->status->value) }}.

+ @endif Continue shopping diff --git a/resources/views/livewire/storefront/checkout/show.blade.php b/resources/views/livewire/storefront/checkout/show.blade.php index 99982b74..874ffd7c 100644 --- a/resources/views/livewire/storefront/checkout/show.blade.php +++ b/resources/views/livewire/storefront/checkout/show.blade.php @@ -47,19 +47,45 @@

Payment

-
- -
- @if(session('checkout_status')) -

{{ session('checkout_status') }}

- @endif
diff --git a/routes/api.php b/routes/api.php index 77e04837..e6c6fb2d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -19,6 +19,7 @@ Route::put('/checkouts/{checkoutId}/address', [CheckoutController::class, 'address'])->name('api.storefront.checkouts.address'); Route::put('/checkouts/{checkoutId}/shipping-method', [CheckoutController::class, 'shippingMethod'])->name('api.storefront.checkouts.shipping-method'); Route::put('/checkouts/{checkoutId}/payment-method', [CheckoutController::class, 'paymentMethod'])->name('api.storefront.checkouts.payment-method'); + Route::post('/checkouts/{checkoutId}/pay', [CheckoutController::class, 'pay'])->name('api.storefront.checkouts.pay'); Route::post('/checkouts/{checkoutId}/apply-discount', [CheckoutController::class, 'applyDiscount'])->name('api.storefront.checkouts.apply-discount'); Route::delete('/checkouts/{checkoutId}/discount', [CheckoutController::class, 'removeDiscount'])->name('api.storefront.checkouts.remove-discount'); }); diff --git a/routes/console.php b/routes/console.php index 7f514d69..79fd6066 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,5 +1,6 @@ everyFifteenMinutes(); Schedule::job(new CleanupAbandonedCarts)->daily(); +Schedule::job(new CancelUnpaidBankTransferOrders)->daily(); diff --git a/specs/progress.md b/specs/progress.md index 8da27f1e..83fcfa2d 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -13,8 +13,8 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Phase 2 catalog backend is implemented and committed: products, options, option values, variants, inventory, collections, collection pivot, media schema/models/factories/seed data, product lifecycle service, variant matrix service, inventory service, handle generator, and product/collection policies. - Phase 3 theme/storefront shell is implemented and verified: theme/page/navigation schema, models, factories, seed data, theme settings service, navigation service, storefront layout, product cards, price rendering, and initial Livewire storefront pages. - Phase 4 cart/checkout/pricing is implemented and verified: carts, cart lines, checkouts, shipping zones/rates, tax settings, discounts, cart and checkout services, pricing snapshots, storefront REST endpoints, Livewire cart/checkout UI, and cleanup jobs. -- Admin shop UI, payments, orders, customer account flows, search indexing, analytics, apps, and webhooks are not implemented yet. -- Phase 5 payments/orders/customer persistence is the next active vertical slice after committing cart and checkout progress. +- Phase 5 payments/orders/customer persistence is implemented and verified: customers, customer addresses, orders, order lines, payments, refunds, fulfillments, mock PSP, checkout pay endpoint/UI, order confirmation, bank-transfer confirmation/cancellation services, and focused tests. +- Admin shop UI, customer account flows, search indexing, analytics, apps, and webhooks are not implemented yet. ## Execution Plan @@ -31,7 +31,7 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit 4. **Cart, checkout, pricing** - Implemented and verified - Add carts, checkout, discounts, shipping, taxes, pricing engine, state transitions, and REST endpoints. - Verify cart API, cart UI, checkout state, pricing, discount, shipping, and tax tests. -5. **Payments and orders** - Pending +5. **Payments and orders** - Implemented and verified - Add customers, addresses, orders, payments, refunds, fulfillments, mock PSP, order events, and scheduled cleanup jobs. - Verify successful card checkout, declined card, bank transfer pending/confirmation, fulfillment guard, refunds, and inventory commits/releases. 6. **Customer accounts** - Pending @@ -53,23 +53,24 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Use SQLite, file cache/session, sync queue, log mail, and local filesystem as specified. - Keep all money as integer minor units. - Prefer Laravel conventions and existing starter-kit patterns unless specs require a different shop-specific behavior. -- Phase 1 customer guard/provider is registered now, but `customers` persistence remains in the customer/order slice because the roadmap places the `customers` table in Phase 5. +- Phase 1 customer guard/provider is registered now; `customers` persistence landed in Phase 5. - Admin users store credentials in `users.password_hash`; the `User` model keeps a `password` attribute alias so Fortify and starter-kit Livewire settings continue to work. - Storefront pages are class-based Livewire components using the existing starter layout conventions and store resolution middleware. - Phase 3 includes a cart page/drawer shell only; persistent carts and line-item actions stay in Phase 4 so pricing and checkout state can be implemented coherently. - Livewire update requests persist `ResolveStore` middleware so storefront actions keep tenant context after the initial page load. -- Phase 4 keeps `customer_id` as nullable indexed IDs without foreign keys until the `customers` table lands in Phase 5; SQLite test connections reject foreign keys to future tables. +- Phase 5 adds the `customers` table immediately before cart/checkout migrations and converts `carts.customer_id` and `checkouts.customer_id` to nullable foreign keys for fresh installs. +- `orders.checkout_id` is intentionally added beyond the table list so `OrderService::createFromCheckout()` can enforce idempotency with a durable unique key. ## Open Gaps - Phase 1 still needs the resource policies listed in the roadmap, but most referenced resources do not exist until later phases. Policies will be added with their models to keep type hints and tests coherent. - Phase 2 still needs Livewire/admin product management and full media resizing variants. Storefront product/collection browsing is covered by the Phase 3 shell. - Phase 3 still needs the richer theme editor/admin surfaces, search modal autocomplete, checkout/account/error storefront templates, and fully configurable section ordering. These are deferred to the admin, search, checkout, and account slices. -- Phase 4 stops at payment-method selection and inventory reservation. Mock payment processing, order creation, bank-transfer confirmation, refunds, and fulfillment are Phase 5. +- Phase 5 includes backend bank-transfer confirmation/cancellation, refunds, and fulfillment services. Admin UI actions for those services are deferred to the admin panel slice. - Phase 4 has a functional cart page and accessible cart count, but the richer slide-out cart drawer can be expanded during UI polish. - Discounts, shipping, and tax are implemented for the specified local/manual flows; provider/carrier integrations remain stubs by design. - Order-reference guards in product deletion/status logic are present but only become fully meaningful once `order_lines` exists in Phase 5. -- Customer accounts, admin surfaces, payments/orders, and all later shop phases remain unimplemented. +- Customer account pages, admin surfaces, and all later shop phases remain unimplemented. - API token requirements mention Sanctum, but the package is not currently installed. This remains an open dependency decision for the API/developers phase because dependencies must not be changed without approval. ## Verification Log @@ -95,4 +96,11 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - 2026-05-03: `npm run build` passed for the updated cart/checkout Tailwind/Vite assets. - 2026-05-03: Playwright smoke completed product add-to-cart, cart checkout start, checkout address save, shipping selection, and payment-method save at `http://shop.test`; latest console check reported no warnings or errors. - 2026-05-03: `browser_logs` reported no browser log file after the latest smoke check. +- 2026-05-03: `php artisan test --compact tests/Feature/Payments/MockPaymentProviderTest.php tests/Feature/Orders/OrderServiceTest.php tests/Feature/Api/StorefrontCheckoutPaymentApiTest.php` passed, 13 tests / 79 assertions. +- 2026-05-03: `vendor/bin/pint --dirty --format agent` passed and fixed generated seeder imports plus route/controller import ordering. +- 2026-05-03: `php artisan test --compact` passed, 73 tests / 262 assertions. +- 2026-05-03: `npm run build` passed for the updated payment/confirmation checkout UI assets. +- 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed with customers, orders, payments, refunds, and fulfillment migrations/seed data. +- 2026-05-03: Playwright smoke completed product add-to-cart, cart checkout start, checkout address save, shipping selection, credit-card payment, and order confirmation at `http://shop.test`; latest console check reported no warnings or errors. +- 2026-05-03: `browser_logs` reported no browser log file after the latest Phase 5 smoke check. - Pending: Playwright customer and admin browser flows. diff --git a/tests/Feature/Api/StorefrontCheckoutPaymentApiTest.php b/tests/Feature/Api/StorefrontCheckoutPaymentApiTest.php new file mode 100644 index 00000000..c0282d60 --- /dev/null +++ b/tests/Feature/Api/StorefrontCheckoutPaymentApiTest.php @@ -0,0 +1,99 @@ +seed(); +}); + +function apiCheckoutReadyForPayment(TestCase $test): int +{ + $variant = Product::query()->where('handle', 'linen-shirt')->firstOrFail()->variants()->firstOrFail(); + $cartId = $test->postJson('http://shop.test/api/storefront/v1/carts')->json('data.id'); + + $test->postJson("http://shop.test/api/storefront/v1/carts/{$cartId}/lines", [ + 'variant_id' => $variant->id, + 'quantity' => 1, + 'cart_version' => 1, + ])->assertCreated(); + + $checkoutId = $test->postJson('http://shop.test/api/storefront/v1/checkouts', [ + 'cart_id' => $cartId, + 'email' => 'api-buyer@example.com', + ])->assertCreated()->json('data.id'); + + $addressResponse = $test->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkoutId}/address", [ + 'shipping_address' => [ + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'address1' => 'Street 1', + 'city' => 'Berlin', + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => '10115', + ], + 'use_shipping_as_billing' => true, + ])->assertOk(); + + $test->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkoutId}/shipping-method", [ + 'shipping_method_id' => $addressResponse->json('data.available_shipping_methods.0.id'), + ])->assertOk(); + + return $checkoutId; +} + +test('storefront pay endpoint captures credit card payment and returns order', function () { + $checkoutId = apiCheckoutReadyForPayment($this); + + $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkoutId}/pay", [ + 'payment_method' => 'credit_card', + 'card_number' => '4242424242424242', + 'card_expiry' => '12/28', + 'card_cvc' => '123', + 'card_holder' => 'Jane Doe', + ]) + ->assertOk() + ->assertJsonPath('status', 'completed') + ->assertJsonPath('order.status', 'paid') + ->assertJsonPath('order.financial_status', 'paid') + ->assertJsonPath('order.payment_method', 'credit_card'); + + expect(Checkout::withoutGlobalScopes()->findOrFail($checkoutId)->order)->not->toBeNull(); +}); + +test('storefront pay endpoint returns decline code and leaves checkout open', function () { + $checkoutId = apiCheckoutReadyForPayment($this); + + $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkoutId}/pay", [ + 'payment_method' => 'credit_card', + 'card_number' => '4000000000000002', + 'card_expiry' => '12/28', + 'card_cvc' => '123', + 'card_holder' => 'Jane Doe', + ]) + ->assertUnprocessable() + ->assertJsonPath('error_code', 'card_declined'); + + $checkout = Checkout::withoutGlobalScopes()->findOrFail($checkoutId); + + expect($checkout->status)->toBe(\App\Enums\CheckoutStatus::ShippingSelected) + ->and($checkout->order)->toBeNull(); +}); + +test('storefront pay endpoint creates pending bank transfer order with instructions', function () { + $checkoutId = apiCheckoutReadyForPayment($this); + + $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkoutId}/pay", [ + 'payment_method' => 'bank_transfer', + ]) + ->assertOk() + ->assertJsonPath('order.status', 'pending') + ->assertJsonPath('order.financial_status', 'pending') + ->assertJsonPath('bank_transfer_instructions.bank_name', 'Mock Bank AG'); +}); diff --git a/tests/Feature/Orders/OrderServiceTest.php b/tests/Feature/Orders/OrderServiceTest.php new file mode 100644 index 00000000..02fe947d --- /dev/null +++ b/tests/Feature/Orders/OrderServiceTest.php @@ -0,0 +1,185 @@ +create(['default_currency' => 'EUR']); + app()->instance('current_store', $store); + + $product = Product::factory()->for($store)->create(); + $variant = ProductVariant::factory()->for($product)->default()->create([ + 'price_amount' => 1000, + 'currency' => 'EUR', + 'weight_g' => 250, + ]); + InventoryItem::withoutGlobalScopes() + ->where('variant_id', $variant->id) + ->update(['quantity_on_hand' => 10, 'quantity_reserved' => 0, 'policy' => 'deny']); + + $zone = ShippingZone::factory()->for($store)->create(['countries_json' => ['DE']]); + $rate = $zone->rates()->create([ + 'name' => 'Standard', + 'type' => ShippingRateType::Flat, + 'config_json' => ['amount' => 500], + 'is_active' => true, + ]); + + TaxSettings::factory()->for($store)->create([ + 'config_json' => ['default_rate_basis_points' => 1900, 'shipping_taxable' => true], + ]); + + Discount::factory()->for($store)->create([ + 'code' => 'WELCOME10', + 'type' => DiscountType::Code, + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'rules_json' => ['min_purchase_amount' => null], + ]); + + $cart = app(CartService::class)->create($store); + app(CartService::class)->addLine($cart, $variant->id, $quantity); + + $checkout = app(CheckoutService::class)->createFromCart($cart->refresh(), 'buyer@example.com'); + $checkout = app(CheckoutService::class)->setAddress($checkout, [ + 'shipping_address' => [ + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'address1' => 'Street 1', + 'city' => 'Berlin', + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => '10115', + ], + 'use_shipping_as_billing' => true, + ]); + $checkout = app(CheckoutService::class)->setShippingMethod($checkout, $rate->id); + $checkout = app(CheckoutService::class)->selectPaymentMethod($checkout, $method); + + return [$store, $checkout->refresh(), $variant->refresh()]; +} + +test('order service creates a paid order from checkout idempotently and commits inventory', function () { + [, $checkout, $variant] = phaseFiveCheckoutFixture(); + + $order = app(OrderService::class)->createFromCheckout($checkout, [ + 'card_number' => '4242424242424242', + ]); + $again = app(OrderService::class)->createFromCheckout($checkout->refresh(), [ + 'card_number' => '4242424242424242', + ]); + + expect($again->id)->toBe($order->id) + ->and($order->order_number)->toBe('#1001') + ->and($order->status)->toBe(OrderStatus::Paid) + ->and($order->financial_status)->toBe(FinancialStatus::Paid) + ->and($order->payments()->first()->status)->toBe(PaymentStatus::Captured) + ->and($order->lines)->toHaveCount(1) + ->and($checkout->refresh()->status)->toBe(CheckoutStatus::Completed) + ->and($checkout->cart->refresh()->status)->toBe(CartStatus::Converted) + ->and($variant->inventoryItem->refresh()->quantity_on_hand)->toBe(8) + ->and($variant->inventoryItem->refresh()->quantity_reserved)->toBe(0); +}); + +test('declined card releases reserved inventory and leaves checkout retryable', function () { + [, $checkout, $variant] = phaseFiveCheckoutFixture(); + + expect(fn () => app(OrderService::class)->createFromCheckout($checkout, [ + 'card_number' => '4000000000000002', + ]))->toThrow(PaymentFailedException::class, 'Your card was declined.'); + + expect($checkout->refresh()->status)->toBe(CheckoutStatus::ShippingSelected) + ->and($checkout->payment_method)->toBeNull() + ->and($checkout->order)->toBeNull() + ->and($variant->inventoryItem->refresh()->quantity_on_hand)->toBe(10) + ->and($variant->inventoryItem->refresh()->quantity_reserved)->toBe(0); +}); + +test('bank transfer orders stay pending until confirmation commits inventory', function () { + [, $checkout, $variant] = phaseFiveCheckoutFixture(PaymentMethod::BankTransfer); + + $order = app(OrderService::class)->createFromCheckout($checkout); + + expect($order->payment_method)->toBe(PaymentMethod::BankTransfer) + ->and($order->financial_status)->toBe(FinancialStatus::Pending) + ->and($order->payments()->first()->status)->toBe(PaymentStatus::Pending) + ->and($variant->inventoryItem->refresh()->quantity_on_hand)->toBe(10) + ->and($variant->inventoryItem->refresh()->quantity_reserved)->toBe(2); + + $confirmed = app(OrderService::class)->confirmBankTransfer($order); + + expect($confirmed->financial_status)->toBe(FinancialStatus::Paid) + ->and($confirmed->payments()->first()->status)->toBe(PaymentStatus::Captured) + ->and($variant->inventoryItem->refresh()->quantity_on_hand)->toBe(8) + ->and($variant->inventoryItem->refresh()->quantity_reserved)->toBe(0); +}); + +test('fulfillment guard blocks unpaid orders and marks paid orders fulfilled', function () { + [, $checkout] = phaseFiveCheckoutFixture(PaymentMethod::BankTransfer); + $order = app(OrderService::class)->createFromCheckout($checkout); + + expect(fn () => app(FulfillmentService::class)->create($order, [ + $order->lines()->first()->id => 2, + ]))->toThrow(FulfillmentGuardException::class); + + $order = app(OrderService::class)->confirmBankTransfer($order); + app(FulfillmentService::class)->create($order, [ + $order->lines()->first()->id => 2, + ]); + + expect($order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled) + ->and($order->status)->toBe(OrderStatus::Fulfilled); +}); + +test('refund service updates financial status and can restock inventory', function () { + [, $checkout, $variant] = phaseFiveCheckoutFixture(); + $order = app(OrderService::class)->createFromCheckout($checkout, [ + 'card_number' => '4242424242424242', + ]); + + app(RefundService::class)->create($order, $order->payments()->first(), $order->total_amount, 'Customer request', true); + + expect($order->refresh()->financial_status)->toBe(FinancialStatus::Refunded) + ->and($order->status)->toBe(OrderStatus::Refunded) + ->and($order->payments()->first()->status)->toBe(PaymentStatus::Refunded) + ->and($variant->inventoryItem->refresh()->quantity_on_hand)->toBe(10); +}); + +test('bank transfer cancellation job voids stale pending orders and releases reservations', function () { + [, $checkout, $variant] = phaseFiveCheckoutFixture(PaymentMethod::BankTransfer); + $order = app(OrderService::class)->createFromCheckout($checkout); + $order->forceFill(['placed_at' => now()->subDays(8)])->save(); + + app(CancelUnpaidBankTransferOrders::class)->handle(app(OrderService::class)); + + expect($order->refresh()->status)->toBe(OrderStatus::Cancelled) + ->and($order->financial_status)->toBe(FinancialStatus::Voided) + ->and($order->payments()->first()->status)->toBe(PaymentStatus::Failed) + ->and($variant->inventoryItem->refresh()->quantity_reserved)->toBe(0); +}); diff --git a/tests/Feature/Payments/MockPaymentProviderTest.php b/tests/Feature/Payments/MockPaymentProviderTest.php new file mode 100644 index 00000000..7b0b19ae --- /dev/null +++ b/tests/Feature/Payments/MockPaymentProviderTest.php @@ -0,0 +1,47 @@ +charge($checkout, PaymentMethod::CreditCard, [ + 'card_number' => '4242 4242 4242 4242', + ]); + $paypal = $provider->charge($checkout, PaymentMethod::Paypal, []); + + expect($card->success)->toBeTrue() + ->and($card->status)->toBe(PaymentStatus::Captured) + ->and($card->reference)->toStartWith('mock_') + ->and($paypal->success)->toBeTrue() + ->and($paypal->status)->toBe(PaymentStatus::Captured); +}); + +test('mock provider returns expected card decline codes', function (string $cardNumber, string $errorCode, string $message) { + $result = (new MockPaymentProvider)->charge(new Checkout, PaymentMethod::CreditCard, [ + 'card_number' => $cardNumber, + ]); + + expect($result->success)->toBeFalse() + ->and($result->status)->toBe(PaymentStatus::Failed) + ->and($result->errorCode)->toBe($errorCode) + ->and($result->message)->toBe($message); +})->with([ + 'declined' => ['4000000000000002', 'card_declined', 'Your card was declined.'], + 'insufficient funds' => ['4000000000009995', 'insufficient_funds', 'Your card has insufficient funds.'], +]); + +test('mock provider creates pending bank transfer charges and processed refunds', function () { + $provider = new MockPaymentProvider; + $charge = $provider->charge(new Checkout, PaymentMethod::BankTransfer, []); + $refund = $provider->refund(new Payment(['amount' => 1000]), 500); + + expect($charge->success)->toBeTrue() + ->and($charge->status)->toBe(PaymentStatus::Pending) + ->and($refund->success)->toBeTrue(); +}); From 6c3ad9ba89e5d30853b58a5a1f8c8b4980bcc830 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 3 May 2026 15:10:03 +0200 Subject: [PATCH 10/35] Build customer account pages --- app/Http/Middleware/CustomerAuthenticate.php | 27 +++ .../Storefront/Account/Addresses/Index.php | 161 ++++++++++++++++++ .../Storefront/Account/Auth/Login.php | 44 +++++ .../Storefront/Account/Auth/Register.php | 71 ++++++++ app/Livewire/Storefront/Account/Dashboard.php | 42 +++++ .../Storefront/Account/Orders/Index.php | 32 ++++ .../Storefront/Account/Orders/Show.php | 47 +++++ bootstrap/app.php | 1 + database/seeders/CustomerSeeder.php | 3 +- .../account/addresses/index.blade.php | 57 +++++++ .../storefront/account/auth/login.blade.php | 26 +++ .../account/auth/register.blade.php | 43 +++++ .../storefront/account/dashboard.blade.php | 53 ++++++ .../storefront/account/orders/index.blade.php | 53 ++++++ .../storefront/account/orders/show.blade.php | 83 +++++++++ .../storefront/components/address.blade.php | 24 +++ .../views/storefront/layouts/app.blade.php | 1 + routes/web.php | 16 ++ specs/progress.md | 17 +- .../Storefront/CustomerAccountTest.php | 135 +++++++++++++++ 20 files changed, 931 insertions(+), 5 deletions(-) create mode 100644 app/Http/Middleware/CustomerAuthenticate.php create mode 100644 app/Livewire/Storefront/Account/Addresses/Index.php create mode 100644 app/Livewire/Storefront/Account/Auth/Login.php create mode 100644 app/Livewire/Storefront/Account/Auth/Register.php create mode 100644 app/Livewire/Storefront/Account/Dashboard.php create mode 100644 app/Livewire/Storefront/Account/Orders/Index.php create mode 100644 app/Livewire/Storefront/Account/Orders/Show.php create mode 100644 resources/views/livewire/storefront/account/addresses/index.blade.php create mode 100644 resources/views/livewire/storefront/account/auth/login.blade.php create mode 100644 resources/views/livewire/storefront/account/auth/register.blade.php create mode 100644 resources/views/livewire/storefront/account/dashboard.blade.php create mode 100644 resources/views/livewire/storefront/account/orders/index.blade.php create mode 100644 resources/views/livewire/storefront/account/orders/show.blade.php create mode 100644 resources/views/storefront/components/address.blade.php create mode 100644 tests/Feature/Storefront/CustomerAccountTest.php diff --git a/app/Http/Middleware/CustomerAuthenticate.php b/app/Http/Middleware/CustomerAuthenticate.php new file mode 100644 index 00000000..c28fa3ce --- /dev/null +++ b/app/Http/Middleware/CustomerAuthenticate.php @@ -0,0 +1,27 @@ +check()) { + return $next($request); + } + + $request->session()->put('url.intended', $request->fullUrl()); + + return redirect()->route('storefront.account.login'); + } +} diff --git a/app/Livewire/Storefront/Account/Addresses/Index.php b/app/Livewire/Storefront/Account/Addresses/Index.php new file mode 100644 index 00000000..79a87ece --- /dev/null +++ b/app/Livewire/Storefront/Account/Addresses/Index.php @@ -0,0 +1,161 @@ + + */ + public array $address = [ + 'first_name' => '', + 'last_name' => '', + 'company' => '', + 'address1' => '', + 'address2' => '', + 'city' => '', + 'province' => '', + 'province_code' => '', + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => '', + 'phone' => '', + ]; + + public bool $isDefault = false; + + public function startCreating(): void + { + $this->resetForm(); + $this->showForm = true; + } + + public function startEditing(int $addressId): void + { + $address = $this->addressQuery()->whereKey($addressId)->firstOrFail(); + + $this->editingAddressId = $address->id; + $this->label = $address->label ?? ''; + $this->address = array_merge($this->address, $address->address_json ?? []); + $this->isDefault = $address->is_default; + $this->showForm = true; + } + + public function save(): void + { + $this->validate([ + 'label' => ['nullable', 'string', 'max:255'], + 'address.first_name' => ['required', 'string', 'max:255'], + 'address.last_name' => ['required', 'string', 'max:255'], + 'address.address1' => ['required', 'string', 'max:500'], + 'address.city' => ['required', 'string', 'max:255'], + 'address.country_code' => ['required', 'string', 'size:2'], + 'address.postal_code' => ['required', 'string', 'max:20'], + 'isDefault' => ['bool'], + ]); + + $customer = $this->customer(); + $address = $this->editingAddressId === null + ? new CustomerAddress(['customer_id' => $customer->id]) + : $this->addressQuery()->whereKey($this->editingAddressId)->firstOrFail(); + + $address->forceFill([ + 'label' => $this->label !== '' ? $this->label : null, + 'address_json' => $this->address, + 'is_default' => $this->isDefault || ! $customer->addresses()->exists(), + ])->save(); + + if ($address->is_default) { + $this->clearOtherDefaults($address); + } + + $this->resetForm(); + } + + public function setDefault(int $addressId): void + { + $address = $this->addressQuery()->whereKey($addressId)->firstOrFail(); + $address->forceFill(['is_default' => true])->save(); + $this->clearOtherDefaults($address); + } + + public function deleteAddress(int $addressId): void + { + $address = $this->addressQuery()->whereKey($addressId)->firstOrFail(); + $wasDefault = $address->is_default; + $address->delete(); + + if ($wasDefault) { + $this->addressQuery()->oldest('id')->first()?->forceFill(['is_default' => true])->save(); + } + } + + public function cancel(): void + { + $this->resetForm(); + } + + public function render(): View + { + return view('livewire.storefront.account.addresses.index', [ + 'addresses' => $this->addressQuery()->orderByDesc('is_default')->oldest('id')->get(), + ])->layout('storefront.layouts.app', [ + 'title' => 'Addresses', + ]); + } + + private function resetForm(): void + { + $this->showForm = false; + $this->editingAddressId = null; + $this->label = ''; + $this->address = [ + 'first_name' => '', + 'last_name' => '', + 'company' => '', + 'address1' => '', + 'address2' => '', + 'city' => '', + 'province' => '', + 'province_code' => '', + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => '', + 'phone' => '', + ]; + $this->isDefault = false; + } + + private function clearOtherDefaults(CustomerAddress $defaultAddress): void + { + $this->addressQuery() + ->whereKeyNot($defaultAddress->id) + ->update(['is_default' => false]); + } + + /** + * @return Builder + */ + private function addressQuery(): Builder + { + return CustomerAddress::query()->where('customer_id', $this->customer()->id); + } + + private function customer(): Customer + { + return Auth::guard('customer')->user(); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Login.php b/app/Livewire/Storefront/Account/Auth/Login.php new file mode 100644 index 00000000..01d92909 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Login.php @@ -0,0 +1,44 @@ +validate([ + 'email' => ['required', 'email', 'max:255'], + 'password' => ['required', 'string'], + ]); + + if (! Auth::guard('customer')->attempt([ + 'email' => Str::lower($this->email), + 'password' => $this->password, + ])) { + $this->addError('email', 'The provided credentials are invalid.'); + + return null; + } + + session()->regenerate(); + + return $this->redirect(session()->pull('url.intended', route('storefront.account.dashboard')), navigate: true); + } + + public function render(): View + { + return view('livewire.storefront.account.auth.login') + ->layout('storefront.layouts.app', [ + 'title' => 'Account login', + ]); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Register.php b/app/Livewire/Storefront/Account/Auth/Register.php new file mode 100644 index 00000000..e84845bd --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Register.php @@ -0,0 +1,71 @@ +validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255'], + 'password' => ['required', 'string', 'min:8'], + 'passwordConfirmation' => ['required', 'same:password'], + 'marketingOptIn' => ['bool'], + ]); + + $store = app('current_store'); + $email = Str::lower($this->email); + $customer = Customer::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('email', $email) + ->first(); + + if ($customer !== null && $customer->password_hash !== null) { + throw ValidationException::withMessages([ + 'email' => ['An account already exists for this email.'], + ]); + } + + $customer ??= new Customer([ + 'store_id' => $store->id, + 'email' => $email, + ]); + + $customer->forceFill([ + 'name' => $this->name, + 'password' => $this->password, + 'marketing_opt_in' => $this->marketingOptIn, + ])->save(); + + Auth::guard('customer')->login($customer); + session()->regenerate(); + + return $this->redirect(route('storefront.account.dashboard'), navigate: true); + } + + public function render(): View + { + return view('livewire.storefront.account.auth.register') + ->layout('storefront.layouts.app', [ + 'title' => 'Create account', + ]); + } +} diff --git a/app/Livewire/Storefront/Account/Dashboard.php b/app/Livewire/Storefront/Account/Dashboard.php new file mode 100644 index 00000000..e3887116 --- /dev/null +++ b/app/Livewire/Storefront/Account/Dashboard.php @@ -0,0 +1,42 @@ +logout(); + session()->invalidate(); + session()->regenerateToken(); + + return $this->redirect(route('storefront.account.login'), navigate: true); + } + + public function render(): View + { + $customer = $this->customer(); + + return view('livewire.storefront.account.dashboard', [ + 'customer' => $customer, + 'recentOrders' => Order::query() + ->where('customer_id', $customer->id) + ->latest('placed_at') + ->limit(5) + ->get(), + ])->layout('storefront.layouts.app', [ + 'title' => 'Account', + ]); + } + + private function customer(): Customer + { + return Auth::guard('customer')->user(); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Index.php b/app/Livewire/Storefront/Account/Orders/Index.php new file mode 100644 index 00000000..a70eb4b4 --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Index.php @@ -0,0 +1,32 @@ + Order::query() + ->where('customer_id', $this->customer()->id) + ->latest('placed_at') + ->paginate(10), + ])->layout('storefront.layouts.app', [ + 'title' => 'Order history', + ]); + } + + private function customer(): Customer + { + return Auth::guard('customer')->user(); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Show.php b/app/Livewire/Storefront/Account/Orders/Show.php new file mode 100644 index 00000000..a89aba3a --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Show.php @@ -0,0 +1,47 @@ +orderNumber = $orderNumber; + } + + public function render(): View + { + return view('livewire.storefront.account.orders.show', [ + 'order' => $this->order(), + ])->layout('storefront.layouts.app', [ + 'title' => 'Order '.$this->normalizedOrderNumber(), + ]); + } + + private function order(): Order + { + return Order::query() + ->with('lines', 'payments', 'fulfillments.lines') + ->where('customer_id', $this->customer()->id) + ->where('order_number', $this->normalizedOrderNumber()) + ->firstOrFail(); + } + + private function customer(): Customer + { + return Auth::guard('customer')->user(); + } + + private function normalizedOrderNumber(): string + { + return str_starts_with($this->orderNumber, '#') ? $this->orderNumber : '#'.$this->orderNumber; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 48c075ac..0153c7a8 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -13,6 +13,7 @@ ) ->withMiddleware(function (Middleware $middleware): void { $middleware->alias([ + 'customer.auth' => App\Http\Middleware\CustomerAuthenticate::class, 'store.resolve' => App\Http\Middleware\ResolveStore::class, ]); diff --git a/database/seeders/CustomerSeeder.php b/database/seeders/CustomerSeeder.php index e3325a74..087cbce1 100644 --- a/database/seeders/CustomerSeeder.php +++ b/database/seeders/CustomerSeeder.php @@ -6,6 +6,7 @@ use App\Models\CustomerAddress; use App\Models\Store; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\Hash; class CustomerSeeder extends Seeder { @@ -27,7 +28,7 @@ public function run(): void ], [ 'name' => $data['name'], - 'password_hash' => null, + 'password_hash' => $index === 0 ? Hash::make('password') : null, 'marketing_opt_in' => $index === 0, ], ); 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..932aac16 --- /dev/null +++ b/resources/views/livewire/storefront/account/addresses/index.blade.php @@ -0,0 +1,57 @@ +
+
+

Your addresses

+ +
+ + @if($showForm) +
+ + + + + + + + + + + +
+ + +
+
+ @endif + +
+ @forelse($addresses as $customerAddress) + @php($addressData = $customerAddress->address_json ?? []) +
+
+

{{ $customerAddress->label ?? 'Address' }}

+ @if($customerAddress->is_default) + Default + @endif +
+
+ @include('storefront.components.address', ['address' => $addressData]) +
+
+ + + @if(! $customerAddress->is_default) + + @endif +
+
+ @empty +

No saved addresses.

+ @endforelse +
+
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..360c78fe --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/login.blade.php @@ -0,0 +1,26 @@ +
+

Log in

+ +
+
+ + + @error('email')

{{ $message }}

@enderror +
+ +
+ + + @error('password')

{{ $message }}

@enderror +
+ + +
+ +

+ New here? + Create account +

+
diff --git a/resources/views/livewire/storefront/account/auth/register.blade.php b/resources/views/livewire/storefront/account/auth/register.blade.php new file mode 100644 index 00000000..11901a32 --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/register.blade.php @@ -0,0 +1,43 @@ +
+

Create account

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

{{ $message }}

@enderror +
+ +
+ + + @error('email')

{{ $message }}

@enderror +
+ +
+ + + @error('password')

{{ $message }}

@enderror +
+ +
+ + + @error('passwordConfirmation')

{{ $message }}

@enderror +
+ + + + +
+ +

+ 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..077bf2d0 --- /dev/null +++ b/resources/views/livewire/storefront/account/dashboard.blade.php @@ -0,0 +1,53 @@ +
+
+

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

+ +
+ + + +
+

Recent orders

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

No orders yet.

+ @else + + + + + + + + + + + + @foreach($recentOrders as $order) + + + + + + + + @endforeach + +
OrderDateStatusTotalAction
{{ $order->order_number }}{{ $order->placed_at?->format('M j, Y') }}{{ str_replace('_', ' ', $order->status->value) }}@include('storefront.components.price', ['amount' => $order->total_amount, 'currency' => $order->currency]) + 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..d2661b96 --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/index.blade.php @@ -0,0 +1,53 @@ +
+
+

Order history

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

No orders yet.

+ @else + + + + + + + + + + + + @foreach($orders as $order) + + + + + + + + @endforeach + + + + + @endif +
+ +
+ {{ $orders->links() }} +
+
diff --git a/resources/views/livewire/storefront/account/orders/show.blade.php b/resources/views/livewire/storefront/account/orders/show.blade.php new file mode 100644 index 00000000..cde1c7a5 --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/show.blade.php @@ -0,0 +1,83 @@ +
+ + +
+
+

Order {{ $order->order_number }}

+

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

+
+
+ {{ str_replace('_', ' ', $order->status->value) }} + {{ str_replace('_', ' ', $order->fulfillment_status->value) }} +
+
+ +
+

Items

+
+ @foreach($order->lines as $line) +
+
+
{{ $line->title_snapshot }} × {{ $line->quantity }}
+ @if($line->sku_snapshot) +
{{ $line->sku_snapshot }}
+ @endif +
+
@include('storefront.components.price', ['amount' => $line->total_amount, 'currency' => $order->currency])
+
+ @endforeach +
+
+ +
+
+

Shipping address

+ @include('storefront.components.address', ['address' => $order->shipping_address_json ?? []]) +
+
+

Billing address

+ @include('storefront.components.address', ['address' => $order->billing_address_json ?? []]) +
+
+

Payment

+

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

+

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

+
+
+ +
+

Totals

+
+
Subtotal
@include('storefront.components.price', ['amount' => $order->subtotal_amount, 'currency' => $order->currency])
+
Discount
-@include('storefront.components.price', ['amount' => $order->discount_amount, 'currency' => $order->currency])
+
Shipping
@include('storefront.components.price', ['amount' => $order->shipping_amount, 'currency' => $order->currency])
+
Tax
@include('storefront.components.price', ['amount' => $order->tax_amount, 'currency' => $order->currency])
+
Total
@include('storefront.components.price', ['amount' => $order->total_amount, 'currency' => $order->currency])
+
+
+ + @if($order->fulfillments->isNotEmpty()) +
+

Fulfillment

+
+ @foreach($order->fulfillments as $fulfillment) +
+
{{ str_replace('_', ' ', $fulfillment->status->value) }}
+ @if($fulfillment->tracking_number) +
{{ $fulfillment->tracking_company }} · {{ $fulfillment->tracking_number }}
+ @endif + @if($fulfillment->tracking_url) + Track shipment + @endif +
+ @endforeach +
+
+ @endif +
diff --git a/resources/views/storefront/components/address.blade.php b/resources/views/storefront/components/address.blade.php new file mode 100644 index 00000000..5fcb3539 --- /dev/null +++ b/resources/views/storefront/components/address.blade.php @@ -0,0 +1,24 @@ +@php + $address = $address ?? []; +@endphp + +
+ @if(($address['first_name'] ?? null) || ($address['last_name'] ?? null)) +
{{ trim(($address['first_name'] ?? '').' '.($address['last_name'] ?? '')) }}
+ @endif + @if($address['company'] ?? null) +
{{ $address['company'] }}
+ @endif + @if($address['address1'] ?? null) +
{{ $address['address1'] }}
+ @endif + @if($address['address2'] ?? null) +
{{ $address['address2'] }}
+ @endif + @if(($address['postal_code'] ?? null) || ($address['city'] ?? null)) +
{{ trim(($address['postal_code'] ?? '').' '.($address['city'] ?? '')) }}
+ @endif + @if($address['country'] ?? $address['country_code'] ?? null) +
{{ $address['country'] ?? $address['country_code'] }}
+ @endif +
diff --git a/resources/views/storefront/layouts/app.blade.php b/resources/views/storefront/layouts/app.blade.php index 6e030611..00c6571e 100644 --- a/resources/views/storefront/layouts/app.blade.php +++ b/resources/views/storefront/layouts/app.blade.php @@ -71,6 +71,7 @@
diff --git a/routes/web.php b/routes/web.php index 7f7b67f1..15032716 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,11 @@ name('storefront.checkout.confirmation'); Route::get('/search', SearchIndex::class)->name('storefront.search.index'); Route::get('/pages/{handle}', PageShow::class)->name('storefront.pages.show'); + + Route::get('/account/login', AccountLogin::class)->name('storefront.account.login'); + Route::get('/account/register', AccountRegister::class)->name('storefront.account.register'); + + Route::middleware('customer.auth')->group(function (): void { + Route::get('/account', AccountDashboard::class)->name('storefront.account.dashboard'); + Route::get('/account/orders', AccountOrdersIndex::class)->name('storefront.account.orders.index'); + Route::get('/account/orders/{orderNumber}', AccountOrdersShow::class)->name('storefront.account.orders.show'); + Route::get('/account/addresses', AccountAddressesIndex::class)->name('storefront.account.addresses.index'); + }); }); Route::view('dashboard', 'dashboard') diff --git a/specs/progress.md b/specs/progress.md index 83fcfa2d..8822c6d5 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -14,7 +14,8 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Phase 3 theme/storefront shell is implemented and verified: theme/page/navigation schema, models, factories, seed data, theme settings service, navigation service, storefront layout, product cards, price rendering, and initial Livewire storefront pages. - Phase 4 cart/checkout/pricing is implemented and verified: carts, cart lines, checkouts, shipping zones/rates, tax settings, discounts, cart and checkout services, pricing snapshots, storefront REST endpoints, Livewire cart/checkout UI, and cleanup jobs. - Phase 5 payments/orders/customer persistence is implemented and verified: customers, customer addresses, orders, order lines, payments, refunds, fulfillments, mock PSP, checkout pay endpoint/UI, order confirmation, bank-transfer confirmation/cancellation services, and focused tests. -- Admin shop UI, customer account flows, search indexing, analytics, apps, and webhooks are not implemented yet. +- Phase 6 customer accounts are implemented and verified: store-scoped customer login/registration, account dashboard, order history/detail pages, address book CRUD, and seeded customer credentials. +- Admin shop UI, search indexing, analytics, apps, and webhooks are not implemented yet. ## Execution Plan @@ -34,7 +35,7 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit 5. **Payments and orders** - Implemented and verified - Add customers, addresses, orders, payments, refunds, fulfillments, mock PSP, order events, and scheduled cleanup jobs. - Verify successful card checkout, declined card, bank transfer pending/confirmation, fulfillment guard, refunds, and inventory commits/releases. -6. **Customer accounts** - Pending +6. **Customer accounts** - Implemented and verified - Add store-scoped customer auth, account dashboard, order history, and address book. - Verify customer registration/login isolation and account browser flows. 7. **Admin panel** - Pending @@ -70,7 +71,8 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Phase 4 has a functional cart page and accessible cart count, but the richer slide-out cart drawer can be expanded during UI polish. - Discounts, shipping, and tax are implemented for the specified local/manual flows; provider/carrier integrations remain stubs by design. - Order-reference guards in product deletion/status logic are present but only become fully meaningful once `order_lines` exists in Phase 5. -- Customer account pages, admin surfaces, and all later shop phases remain unimplemented. +- Customer account password reset UI and emails remain deferred; login, registration, dashboard, order history/detail, and address book flows are implemented. +- Admin surfaces and all later shop phases remain unimplemented. - API token requirements mention Sanctum, but the package is not currently installed. This remains an open dependency decision for the API/developers phase because dependencies must not be changed without approval. ## Verification Log @@ -103,4 +105,11 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed with customers, orders, payments, refunds, and fulfillment migrations/seed data. - 2026-05-03: Playwright smoke completed product add-to-cart, cart checkout start, checkout address save, shipping selection, credit-card payment, and order confirmation at `http://shop.test`; latest console check reported no warnings or errors. - 2026-05-03: `browser_logs` reported no browser log file after the latest Phase 5 smoke check. -- Pending: Playwright customer and admin browser flows. +- 2026-05-03: `php artisan test --compact tests/Feature/Storefront/CustomerAccountTest.php` passed, 6 tests / 26 assertions. +- 2026-05-03: `vendor/bin/pint --dirty --format agent` passed after Phase 6 account changes. +- 2026-05-03: `php artisan test --compact` passed, 79 tests / 288 assertions. +- 2026-05-03: `npm run build` passed for the updated account UI assets. +- 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed with customer login and account seed data. +- 2026-05-03: Playwright smoke completed customer login, account dashboard, order history, order detail, and address book creation at `http://shop.test`; latest console check reported no warnings or errors. +- 2026-05-03: `browser_logs` reported no browser log file after the latest Phase 6 smoke check. +- Pending: Playwright admin browser flows. diff --git a/tests/Feature/Storefront/CustomerAccountTest.php b/tests/Feature/Storefront/CustomerAccountTest.php new file mode 100644 index 00000000..dbf5c687 --- /dev/null +++ b/tests/Feature/Storefront/CustomerAccountTest.php @@ -0,0 +1,135 @@ +seed(); + app()->instance('current_store', Store::query()->where('handle', 'acme-fashion')->firstOrFail()); +}); + +test('guest customers are redirected to account login', function () { + $this->get('http://shop.test/account') + ->assertRedirect('http://shop.test/account/login'); +}); + +test('customer can register and is authenticated with the customer guard', function () { + Livewire::test(Register::class) + ->set('name', 'New Customer') + ->set('email', 'new@example.com') + ->set('password', 'password') + ->set('passwordConfirmation', 'password') + ->set('marketingOptIn', true) + ->call('register') + ->assertHasNoErrors(); + + $customer = Customer::withoutGlobalScopes() + ->where('store_id', app('current_store')->id) + ->where('email', 'new@example.com') + ->firstOrFail(); + + expect(Auth::guard('customer')->id())->toBe($customer->id) + ->and($customer->marketing_opt_in)->toBeTrue(); +}); + +test('customer can log in with seeded account credentials', function () { + Livewire::test(Login::class) + ->set('email', 'jane@example.com') + ->set('password', 'password') + ->call('login') + ->assertHasNoErrors(); + + expect(Auth::guard('customer')->check())->toBeTrue() + ->and(Auth::guard('customer')->user()->email)->toBe('jane@example.com'); +}); + +test('customer auth provider scopes credentials to current store', function () { + $otherStore = Store::factory()->create(); + $otherCustomer = Customer::factory()->for($otherStore)->registered()->create([ + 'email' => 'shared@example.com', + ]); + + app()->instance('current_store', $otherStore); + + expect(Auth::guard('customer')->attempt([ + 'email' => 'shared@example.com', + 'password' => 'password', + ]))->toBeTrue() + ->and(Auth::guard('customer')->id())->toBe($otherCustomer->id); + + Auth::guard('customer')->logout(); + app()->instance('current_store', Store::query()->where('handle', 'acme-fashion')->firstOrFail()); + + expect(Auth::guard('customer')->attempt([ + 'email' => 'shared@example.com', + 'password' => 'password', + ]))->toBeFalse(); +}); + +test('customer account pages show own orders and hide other customer orders', function () { + $customer = Customer::withoutGlobalScopes()->where('email', 'jane@example.com')->firstOrFail(); + + $this->actingAs($customer, 'customer') + ->get('http://shop.test/account') + ->assertOk() + ->assertSee('#1001') + ->assertSee('Order history'); + + $this->actingAs($customer, 'customer') + ->get('http://shop.test/account/orders') + ->assertOk() + ->assertSee('#1001') + ->assertDontSee('#1002'); + + $this->actingAs($customer, 'customer') + ->get('http://shop.test/account/orders/1001') + ->assertOk() + ->assertSee('Order #1001') + ->assertSee('Linen Shirt'); + + $this->actingAs($customer, 'customer') + ->get('http://shop.test/account/orders/1002') + ->assertNotFound(); +}); + +test('customer can manage address book records', function () { + $customer = Customer::withoutGlobalScopes()->where('email', 'jane@example.com')->firstOrFail(); + $this->actingAs($customer, 'customer'); + + Livewire::test(AddressIndex::class) + ->call('startCreating') + ->set('label', 'Work') + ->set('address.first_name', 'Jane') + ->set('address.last_name', 'Doe') + ->set('address.address1', 'Office Street 2') + ->set('address.city', 'Berlin') + ->set('address.country_code', 'DE') + ->set('address.postal_code', '10117') + ->set('isDefault', true) + ->call('save') + ->assertHasNoErrors(); + + $address = CustomerAddress::query() + ->where('customer_id', $customer->id) + ->where('label', 'Work') + ->firstOrFail(); + + expect($address->is_default)->toBeTrue() + ->and(CustomerAddress::query()->where('customer_id', $customer->id)->where('is_default', true)->count())->toBe(1); + + Livewire::test(AddressIndex::class) + ->call('deleteAddress', $address->id) + ->assertHasNoErrors(); + + expect(CustomerAddress::query()->whereKey($address->id)->exists())->toBeFalse(); +}); From e744c39e1e8462854d0dd17be4f6844f8400b599 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 3 May 2026 15:40:34 +0200 Subject: [PATCH 11/35] Build admin management panel --- app/Http/Middleware/ResolveStore.php | 12 +- app/Livewire/Admin/Analytics/Index.php | 33 ++++ app/Livewire/Admin/Apps/Index.php | 22 +++ app/Livewire/Admin/Apps/Show.php | 23 +++ app/Livewire/Admin/Auth/Login.php | 75 +++++++++ app/Livewire/Admin/Collections/Form.php | 75 +++++++++ app/Livewire/Admin/Collections/Index.php | 33 ++++ .../Admin/Concerns/UsesAdminStore.php | 36 +++++ app/Livewire/Admin/Customers/Index.php | 36 +++++ app/Livewire/Admin/Customers/Show.php | 26 ++++ app/Livewire/Admin/Dashboard.php | 110 ++++++++++++++ app/Livewire/Admin/Developers/Index.php | 16 ++ app/Livewire/Admin/Discounts/Form.php | 99 ++++++++++++ app/Livewire/Admin/Discounts/Index.php | 53 +++++++ app/Livewire/Admin/Inventory/Index.php | 68 +++++++++ app/Livewire/Admin/Layout/Breadcrumbs.php | 57 +++++++ app/Livewire/Admin/Layout/Sidebar.php | 60 ++++++++ app/Livewire/Admin/Layout/TopBar.php | 57 +++++++ app/Livewire/Admin/Navigation/Index.php | 66 ++++++++ app/Livewire/Admin/Orders/Index.php | 46 ++++++ app/Livewire/Admin/Orders/Show.php | 120 +++++++++++++++ app/Livewire/Admin/Pages/Form.php | 76 ++++++++++ app/Livewire/Admin/Pages/Index.php | 32 ++++ app/Livewire/Admin/Products/Form.php | 142 ++++++++++++++++++ app/Livewire/Admin/Products/Index.php | 91 +++++++++++ app/Livewire/Admin/Search/Settings.php | 64 ++++++++ app/Livewire/Admin/Settings/Index.php | 65 ++++++++ app/Livewire/Admin/Settings/Shipping.php | 80 ++++++++++ app/Livewire/Admin/Settings/Taxes.php | 64 ++++++++ app/Livewire/Admin/Themes/Editor.php | 44 ++++++ app/Livewire/Admin/Themes/Index.php | 52 +++++++ app/Policies/Concerns/ChecksStoreRoles.php | 30 ++++ app/Policies/CustomerPolicy.php | 33 ++++ app/Policies/DiscountPolicy.php | 38 +++++ app/Policies/FulfillmentPolicy.php | 28 ++++ app/Policies/OrderPolicy.php | 38 +++++ app/Policies/PagePolicy.php | 38 +++++ app/Policies/RefundPolicy.php | 23 +++ app/Policies/StorePolicy.php | 28 ++++ app/Policies/ThemePolicy.php | 38 +++++ app/Providers/AppServiceProvider.php | 4 + bootstrap/app.php | 5 + .../livewire/admin/analytics/index.blade.php | 35 +++++ .../views/livewire/admin/apps/index.blade.php | 15 ++ .../views/livewire/admin/apps/show.blade.php | 18 +++ .../views/livewire/admin/auth/login.blade.php | 31 ++++ .../livewire/admin/collections/form.blade.php | 26 ++++ .../admin/collections/index.blade.php | 32 ++++ .../livewire/admin/customers/index.blade.php | 46 ++++++ .../livewire/admin/customers/show.blade.php | 61 ++++++++ .../views/livewire/admin/dashboard.blade.php | 108 +++++++++++++ .../livewire/admin/developers/index.blade.php | 22 +++ .../livewire/admin/discounts/form.blade.php | 50 ++++++ .../livewire/admin/discounts/index.blade.php | 64 ++++++++ .../livewire/admin/inventory/index.blade.php | 34 +++++ .../views/livewire/admin/layout/app.blade.php | 31 ++++ .../admin/layout/breadcrumbs.blade.php | 13 ++ .../livewire/admin/layout/sidebar.blade.php | 37 +++++ .../livewire/admin/layout/top-bar.blade.php | 46 ++++++ .../livewire/admin/navigation/index.blade.php | 51 +++++++ .../livewire/admin/orders/index.blade.php | 54 +++++++ .../livewire/admin/orders/show.blade.php | 107 +++++++++++++ .../views/livewire/admin/pages/form.blade.php | 26 ++++ .../livewire/admin/pages/index.blade.php | 32 ++++ .../livewire/admin/products/form.blade.php | 52 +++++++ .../livewire/admin/products/index.blade.php | 74 +++++++++ .../livewire/admin/search/settings.blade.php | 22 +++ .../livewire/admin/settings/index.blade.php | 47 ++++++ .../admin/settings/shipping.blade.php | 61 ++++++++ .../livewire/admin/settings/taxes.blade.php | 25 +++ .../livewire/admin/themes/editor.blade.php | 34 +++++ .../livewire/admin/themes/index.blade.php | 26 ++++ routes/admin.php | 79 ++++++++++ routes/web.php | 1 + specs/progress.md | 24 ++- tests/Feature/Admin/AdminPanelTest.php | 140 +++++++++++++++++ 76 files changed, 3651 insertions(+), 9 deletions(-) create mode 100644 app/Livewire/Admin/Analytics/Index.php create mode 100644 app/Livewire/Admin/Apps/Index.php create mode 100644 app/Livewire/Admin/Apps/Show.php create mode 100644 app/Livewire/Admin/Auth/Login.php create mode 100644 app/Livewire/Admin/Collections/Form.php create mode 100644 app/Livewire/Admin/Collections/Index.php create mode 100644 app/Livewire/Admin/Concerns/UsesAdminStore.php create mode 100644 app/Livewire/Admin/Customers/Index.php create mode 100644 app/Livewire/Admin/Customers/Show.php create mode 100644 app/Livewire/Admin/Dashboard.php create mode 100644 app/Livewire/Admin/Developers/Index.php create mode 100644 app/Livewire/Admin/Discounts/Form.php create mode 100644 app/Livewire/Admin/Discounts/Index.php create mode 100644 app/Livewire/Admin/Inventory/Index.php create mode 100644 app/Livewire/Admin/Layout/Breadcrumbs.php create mode 100644 app/Livewire/Admin/Layout/Sidebar.php create mode 100644 app/Livewire/Admin/Layout/TopBar.php create mode 100644 app/Livewire/Admin/Navigation/Index.php create mode 100644 app/Livewire/Admin/Orders/Index.php create mode 100644 app/Livewire/Admin/Orders/Show.php create mode 100644 app/Livewire/Admin/Pages/Form.php create mode 100644 app/Livewire/Admin/Pages/Index.php create mode 100644 app/Livewire/Admin/Products/Form.php create mode 100644 app/Livewire/Admin/Products/Index.php create mode 100644 app/Livewire/Admin/Search/Settings.php create mode 100644 app/Livewire/Admin/Settings/Index.php create mode 100644 app/Livewire/Admin/Settings/Shipping.php create mode 100644 app/Livewire/Admin/Settings/Taxes.php create mode 100644 app/Livewire/Admin/Themes/Editor.php create mode 100644 app/Livewire/Admin/Themes/Index.php create mode 100644 app/Policies/Concerns/ChecksStoreRoles.php create mode 100644 app/Policies/CustomerPolicy.php create mode 100644 app/Policies/DiscountPolicy.php create mode 100644 app/Policies/FulfillmentPolicy.php create mode 100644 app/Policies/OrderPolicy.php create mode 100644 app/Policies/PagePolicy.php create mode 100644 app/Policies/RefundPolicy.php create mode 100644 app/Policies/StorePolicy.php create mode 100644 app/Policies/ThemePolicy.php create mode 100644 resources/views/livewire/admin/analytics/index.blade.php create mode 100644 resources/views/livewire/admin/apps/index.blade.php create mode 100644 resources/views/livewire/admin/apps/show.blade.php create mode 100644 resources/views/livewire/admin/auth/login.blade.php create mode 100644 resources/views/livewire/admin/collections/form.blade.php create mode 100644 resources/views/livewire/admin/collections/index.blade.php create mode 100644 resources/views/livewire/admin/customers/index.blade.php create mode 100644 resources/views/livewire/admin/customers/show.blade.php create mode 100644 resources/views/livewire/admin/dashboard.blade.php create mode 100644 resources/views/livewire/admin/developers/index.blade.php create mode 100644 resources/views/livewire/admin/discounts/form.blade.php create mode 100644 resources/views/livewire/admin/discounts/index.blade.php create mode 100644 resources/views/livewire/admin/inventory/index.blade.php create mode 100644 resources/views/livewire/admin/layout/app.blade.php create mode 100644 resources/views/livewire/admin/layout/breadcrumbs.blade.php create mode 100644 resources/views/livewire/admin/layout/sidebar.blade.php create mode 100644 resources/views/livewire/admin/layout/top-bar.blade.php create mode 100644 resources/views/livewire/admin/navigation/index.blade.php create mode 100644 resources/views/livewire/admin/orders/index.blade.php create mode 100644 resources/views/livewire/admin/orders/show.blade.php create mode 100644 resources/views/livewire/admin/pages/form.blade.php create mode 100644 resources/views/livewire/admin/pages/index.blade.php create mode 100644 resources/views/livewire/admin/products/form.blade.php create mode 100644 resources/views/livewire/admin/products/index.blade.php create mode 100644 resources/views/livewire/admin/search/settings.blade.php create mode 100644 resources/views/livewire/admin/settings/index.blade.php create mode 100644 resources/views/livewire/admin/settings/shipping.blade.php create mode 100644 resources/views/livewire/admin/settings/taxes.blade.php create mode 100644 resources/views/livewire/admin/themes/editor.blade.php create mode 100644 resources/views/livewire/admin/themes/index.blade.php create mode 100644 routes/admin.php create mode 100644 tests/Feature/Admin/AdminPanelTest.php diff --git a/app/Http/Middleware/ResolveStore.php b/app/Http/Middleware/ResolveStore.php index 0455c95c..8d6b9a1e 100644 --- a/app/Http/Middleware/ResolveStore.php +++ b/app/Http/Middleware/ResolveStore.php @@ -62,10 +62,20 @@ private function resolveAdminStore(Request $request): Store $user = $request->user(); $storeId = $request->session()->get('current_store_id'); - if (! $user || ! $storeId) { + if (! $user) { abort(403); } + if (! $storeId) { + $storeId = $user->stores()->oldest((new Store)->getTable().'.id')->value((new Store)->getTable().'.id'); + + if (! $storeId) { + abort(403); + } + + $request->session()->put('current_store_id', $storeId); + } + $store = Store::query() ->whereKey($storeId) ->whereHas('users', fn ($query) => $query->whereKey($user->getKey())) diff --git a/app/Livewire/Admin/Analytics/Index.php b/app/Livewire/Admin/Analytics/Index.php new file mode 100644 index 00000000..ab3d98b2 --- /dev/null +++ b/app/Livewire/Admin/Analytics/Index.php @@ -0,0 +1,33 @@ +latest('placed_at')->get(); + $paidOrders = $orders->where('financial_status.value', 'paid'); + + return view('livewire.admin.analytics.index', [ + 'totalSales' => $this->money((int) $orders->sum('total_amount')), + 'paidOrders' => $paidOrders->count(), + 'pendingOrders' => $orders->where('financial_status.value', 'pending')->count(), + 'refundedOrders' => $orders->whereIn('financial_status.value', ['refunded', 'partially_refunded'])->count(), + 'dailyOrders' => $orders + ->groupBy(fn (Order $order): string => $order->placed_at?->toDateString() ?? 'unknown') + ->map(fn ($orders, string $date): array => ['date' => $date, 'count' => $orders->count()]) + ->values() + ->take(14), + ])->layout('livewire.admin.layout.app', [ + 'title' => 'Analytics', + ]); + } +} diff --git a/app/Livewire/Admin/Apps/Index.php b/app/Livewire/Admin/Apps/Index.php new file mode 100644 index 00000000..d7139cb3 --- /dev/null +++ b/app/Livewire/Admin/Apps/Index.php @@ -0,0 +1,22 @@ + [ + ['id' => 'reviews', 'name' => 'Product Reviews', 'status' => 'available'], + ['id' => 'email-automation', 'name' => 'Email Automation', 'status' => 'available'], + ['id' => 'warehouse-sync', 'name' => 'Warehouse Sync', 'status' => 'available'], + ], + ])->layout('livewire.admin.layout.app', [ + 'title' => 'Apps', + ]); + } +} diff --git a/app/Livewire/Admin/Apps/Show.php b/app/Livewire/Admin/Apps/Show.php new file mode 100644 index 00000000..9487e61d --- /dev/null +++ b/app/Livewire/Admin/Apps/Show.php @@ -0,0 +1,23 @@ +installation = $installation; + } + + public function render(): View + { + return view('livewire.admin.apps.show')->layout('livewire.admin.layout.app', [ + 'title' => 'App detail', + ]); + } +} diff --git a/app/Livewire/Admin/Auth/Login.php b/app/Livewire/Admin/Auth/Login.php new file mode 100644 index 00000000..6faf2291 --- /dev/null +++ b/app/Livewire/Admin/Auth/Login.php @@ -0,0 +1,75 @@ +validate([ + 'email' => ['required', 'email', 'max:255'], + 'password' => ['required', 'string'], + 'remember' => ['bool'], + ]); + + $key = $this->throttleKey(); + + if (RateLimiter::tooManyAttempts($key, 5)) { + throw ValidationException::withMessages([ + 'email' => ['Too many login attempts. Try again in '.RateLimiter::availableIn($key).' seconds.'], + ]); + } + + if (! Auth::guard('web')->attempt([ + 'email' => Str::lower($this->email), + 'password' => $this->password, + ], $this->remember)) { + RateLimiter::hit($key); + + $this->addError('email', 'Invalid credentials.'); + + return null; + } + + RateLimiter::clear($key); + session()->regenerate(); + + $user = Auth::user(); + $storeId = $user?->stores()->oldest((new Store)->getTable().'.id')->value((new Store)->getTable().'.id'); + + if ($storeId !== null) { + session()->put('current_store_id', $storeId); + } + + $user?->forceFill(['last_login_at' => now()])->save(); + + return $this->redirect(session()->pull('url.intended', route('admin.dashboard')), navigate: true); + } + + public function render(): View + { + return view('livewire.admin.auth.login') + ->layout('layouts.auth', [ + 'title' => 'Admin login', + ]); + } + + private function throttleKey(): string + { + return Str::lower($this->email).'|'.request()->ip(); + } +} diff --git a/app/Livewire/Admin/Collections/Form.php b/app/Livewire/Admin/Collections/Form.php new file mode 100644 index 00000000..89170b8e --- /dev/null +++ b/app/Livewire/Admin/Collections/Form.php @@ -0,0 +1,75 @@ +collection = $collection?->exists ? $collection : null; + + if ($this->collection === null) { + return; + } + + $this->title = $this->collection->title; + $this->handle = $this->collection->handle; + $this->descriptionHtml = $this->collection->description_html ?? ''; + $this->status = $this->collection->status->value; + } + + public function save(HandleGenerator $handles): mixed + { + $validated = $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['nullable', 'string', 'max:255'], + 'descriptionHtml' => ['nullable', 'string'], + 'status' => ['required', Rule::in(array_map(fn (CollectionStatus $status): string => $status->value, CollectionStatus::cases()))], + ]); + + $payload = [ + 'store_id' => $this->currentStore()->id, + 'title' => $validated['title'], + 'handle' => $validated['handle'] ?: $handles->generate($validated['title'], (new ProductCollection)->getTable(), $this->currentStore()->id, $this->collection?->id), + 'description_html' => $validated['descriptionHtml'], + 'status' => $validated['status'], + ]; + + $this->collection = $this->collection === null + ? ProductCollection::query()->create($payload) + : tap($this->collection)->update($payload); + + $this->notify('Collection saved.'); + + return $this->redirect(route('admin.collections.edit', $this->collection), navigate: true); + } + + public function render(): View + { + return view('livewire.admin.collections.form', [ + 'statuses' => CollectionStatus::cases(), + ])->layout('livewire.admin.layout.app', [ + 'title' => $this->collection ? 'Edit collection' : 'Create collection', + ]); + } +} diff --git a/app/Livewire/Admin/Collections/Index.php b/app/Livewire/Admin/Collections/Index.php new file mode 100644 index 00000000..2495ce6b --- /dev/null +++ b/app/Livewire/Admin/Collections/Index.php @@ -0,0 +1,33 @@ +resetPage(); + } + + public function render(): View + { + return view('livewire.admin.collections.index', [ + 'collections' => ProductCollection::query() + ->withCount('products') + ->when($this->search !== '', fn ($query) => $query->where('title', 'like', '%'.$this->search.'%')) + ->latest('updated_at') + ->paginate(10), + ])->layout('livewire.admin.layout.app', [ + 'title' => 'Collections', + ]); + } +} diff --git a/app/Livewire/Admin/Concerns/UsesAdminStore.php b/app/Livewire/Admin/Concerns/UsesAdminStore.php new file mode 100644 index 00000000..b9c3c2fe --- /dev/null +++ b/app/Livewire/Admin/Concerns/UsesAdminStore.php @@ -0,0 +1,36 @@ +currentStore()->default_currency); + } + + protected function notify(string $message, string $type = 'success'): void + { + session()->flash('admin_toast', [ + 'message' => $message, + 'type' => $type, + ]); + + $this->dispatch('toast', message: $message, type: $type); + } +} diff --git a/app/Livewire/Admin/Customers/Index.php b/app/Livewire/Admin/Customers/Index.php new file mode 100644 index 00000000..92ce70b1 --- /dev/null +++ b/app/Livewire/Admin/Customers/Index.php @@ -0,0 +1,36 @@ +resetPage(); + } + + public function render(): View + { + return view('livewire.admin.customers.index', [ + 'customers' => Customer::query() + ->withCount('orders') + ->when($this->search !== '', fn ($query) => $query->where(function ($query): void { + $query->where('name', 'like', '%'.$this->search.'%') + ->orWhere('email', 'like', '%'.$this->search.'%'); + })) + ->latest('updated_at') + ->paginate(10), + ])->layout('livewire.admin.layout.app', [ + 'title' => 'Customers', + ]); + } +} diff --git a/app/Livewire/Admin/Customers/Show.php b/app/Livewire/Admin/Customers/Show.php new file mode 100644 index 00000000..813161df --- /dev/null +++ b/app/Livewire/Admin/Customers/Show.php @@ -0,0 +1,26 @@ +customer = $customer; + } + + public function render(): View + { + $this->customer->load('addresses', 'orders.lines'); + + return view('livewire.admin.customers.show')->layout('livewire.admin.layout.app', [ + 'title' => $this->customer->name ?? $this->customer->email, + ]); + } +} diff --git a/app/Livewire/Admin/Dashboard.php b/app/Livewire/Admin/Dashboard.php new file mode 100644 index 00000000..e6f0c969 --- /dev/null +++ b/app/Livewire/Admin/Dashboard.php @@ -0,0 +1,110 @@ +dateWindow(); + $ordersQuery = Order::query()->whereBetween('placed_at', [$start, $end]); + $ordersCount = (clone $ordersQuery)->count(); + $totalSales = (int) (clone $ordersQuery)->sum('total_amount'); + + return view('livewire.admin.dashboard', [ + 'dateRangeOptions' => $this->dateRangeOptions(), + 'totalSales' => $this->money($totalSales), + 'ordersCount' => $ordersCount, + 'averageOrderValue' => $this->money($ordersCount > 0 ? (int) round($totalSales / $ordersCount) : 0), + 'conversionRate' => $ordersCount > 0 ? '100%' : '0%', + 'chartData' => $this->ordersChartData($start, $end), + 'topProducts' => $this->topProducts($start, $end), + 'recentOrders' => Order::query()->with('customer')->latest('placed_at')->limit(10)->get(), + ])->layout('livewire.admin.layout.app', [ + 'title' => 'Dashboard', + ]); + } + + /** + * @return array + */ + private function dateRangeOptions(): array + { + return [ + 'today' => 'Today', + 'last_7_days' => 'Last 7 days', + 'last_30_days' => 'Last 30 days', + 'custom' => 'Custom range', + ]; + } + + /** + * @return array{0: CarbonImmutable, 1: CarbonImmutable} + */ + private function dateWindow(): array + { + $end = now()->toImmutable()->endOfDay(); + + return match ($this->dateRange) { + 'today' => [$end->startOfDay(), $end], + 'last_7_days' => [$end->subDays(6)->startOfDay(), $end], + 'custom' => [ + CarbonImmutable::parse($this->customStartDate ?: $end->subDays(29)->toDateString())->startOfDay(), + CarbonImmutable::parse($this->customEndDate ?: $end->toDateString())->endOfDay(), + ], + default => [$end->subDays(29)->startOfDay(), $end], + }; + } + + /** + * @return array + */ + private function ordersChartData(CarbonImmutable $start, CarbonImmutable $end): array + { + return Order::query() + ->selectRaw('date(placed_at) as order_date, count(*) as aggregate') + ->whereBetween('placed_at', [$start, $end]) + ->groupBy(DB::raw('date(placed_at)')) + ->orderBy('order_date') + ->get() + ->map(fn (Order $order): array => [ + 'date' => (string) $order->order_date, + 'count' => (int) $order->aggregate, + ]) + ->all(); + } + + /** + * @return Collection + */ + private function topProducts(CarbonImmutable $start, CarbonImmutable $end): Collection + { + return OrderLine::query() + ->select('order_lines.title_snapshot') + ->selectRaw('sum(order_lines.quantity) as units_sold, sum(order_lines.total_amount) as revenue') + ->join('orders', 'orders.id', '=', 'order_lines.order_id') + ->where('orders.store_id', $this->currentStore()->id) + ->whereBetween('orders.placed_at', [$start, $end]) + ->groupBy('order_lines.title_snapshot') + ->orderByDesc('revenue') + ->limit(5) + ->get(); + } +} diff --git a/app/Livewire/Admin/Developers/Index.php b/app/Livewire/Admin/Developers/Index.php new file mode 100644 index 00000000..513bed11 --- /dev/null +++ b/app/Livewire/Admin/Developers/Index.php @@ -0,0 +1,16 @@ +layout('livewire.admin.layout.app', [ + 'title' => 'Developers', + ]); + } +} diff --git a/app/Livewire/Admin/Discounts/Form.php b/app/Livewire/Admin/Discounts/Form.php new file mode 100644 index 00000000..1795d6bb --- /dev/null +++ b/app/Livewire/Admin/Discounts/Form.php @@ -0,0 +1,99 @@ +discount = $discount?->exists ? $discount : null; + + if ($this->discount === null) { + return; + } + + $this->type = $this->discount->type->value; + $this->code = $this->discount->code ?? ''; + $this->valueType = $this->discount->value_type->value; + $this->valueAmount = (int) $this->discount->value_amount; + $this->startsAt = $this->discount->starts_at?->format('Y-m-d\TH:i'); + $this->endsAt = $this->discount->ends_at?->format('Y-m-d\TH:i'); + $this->usageLimit = $this->discount->usage_limit; + $this->status = $this->discount->status->value; + } + + public function save(): mixed + { + $validated = $this->validate([ + 'type' => ['required', Rule::in(array_map(fn (DiscountType $type): string => $type->value, DiscountType::cases()))], + 'code' => ['nullable', 'string', 'max:255'], + 'valueType' => ['required', Rule::in(array_map(fn (DiscountValueType $type): string => $type->value, DiscountValueType::cases()))], + 'valueAmount' => ['required', 'integer', 'min:0'], + 'startsAt' => ['nullable', 'date'], + 'endsAt' => ['nullable', 'date', 'after_or_equal:startsAt'], + 'usageLimit' => ['nullable', 'integer', 'min:1'], + 'status' => ['required', Rule::in(array_map(fn (DiscountStatus $status): string => $status->value, DiscountStatus::cases()))], + ]); + + $payload = [ + 'store_id' => $this->currentStore()->id, + 'type' => $validated['type'], + 'code' => $validated['type'] === DiscountType::Code->value ? strtoupper($validated['code'] ?? '') : null, + 'value_type' => $validated['valueType'], + 'value_amount' => $validated['valueAmount'], + 'starts_at' => $validated['startsAt'] ?: null, + 'ends_at' => $validated['endsAt'] ?: null, + 'usage_limit' => $validated['usageLimit'], + 'rules_json' => [], + 'status' => $validated['status'], + ]; + + $this->discount = $this->discount === null + ? Discount::query()->create($payload) + : tap($this->discount)->update($payload); + + $this->notify('Discount saved.'); + + return $this->redirect(route('admin.discounts.edit', $this->discount), navigate: true); + } + + public function render(): View + { + return view('livewire.admin.discounts.form', [ + 'types' => DiscountType::cases(), + 'valueTypes' => DiscountValueType::cases(), + 'statuses' => DiscountStatus::cases(), + ])->layout('livewire.admin.layout.app', [ + 'title' => $this->discount ? 'Edit discount' : 'Create discount', + ]); + } +} diff --git a/app/Livewire/Admin/Discounts/Index.php b/app/Livewire/Admin/Discounts/Index.php new file mode 100644 index 00000000..69ff7611 --- /dev/null +++ b/app/Livewire/Admin/Discounts/Index.php @@ -0,0 +1,53 @@ +resetPage(); + } + + public function updatedStatus(): void + { + $this->resetPage(); + } + + public function disable(int $discountId): void + { + Discount::query() + ->whereKey($discountId) + ->firstOrFail() + ->forceFill(['status' => DiscountStatus::Disabled]) + ->save(); + + session()->flash('admin_toast', ['message' => 'Discount disabled.', 'type' => 'success']); + } + + public function render(): View + { + return view('livewire.admin.discounts.index', [ + 'discounts' => Discount::query() + ->when($this->search !== '', fn ($query) => $query->where('code', 'like', '%'.$this->search.'%')) + ->when($this->status !== 'all', fn ($query) => $query->where('status', $this->status)) + ->latest('updated_at') + ->paginate(10), + 'statuses' => DiscountStatus::cases(), + ])->layout('livewire.admin.layout.app', [ + 'title' => 'Discounts', + ]); + } +} diff --git a/app/Livewire/Admin/Inventory/Index.php b/app/Livewire/Admin/Inventory/Index.php new file mode 100644 index 00000000..9f2bf666 --- /dev/null +++ b/app/Livewire/Admin/Inventory/Index.php @@ -0,0 +1,68 @@ + + */ + public array $quantities = []; + + /** + * @var array + */ + public array $policies = []; + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function saveItem(int $itemId): void + { + $this->validate([ + "quantities.$itemId" => ['required', 'integer', 'min:0'], + "policies.$itemId" => ['required', Rule::in(array_map(fn (InventoryPolicy $policy): string => $policy->value, InventoryPolicy::cases()))], + ]); + + InventoryItem::query()->whereKey($itemId)->firstOrFail()->forceFill([ + 'quantity_on_hand' => $this->quantities[$itemId], + 'policy' => $this->policies[$itemId], + ])->save(); + + session()->flash('admin_toast', ['message' => 'Inventory updated.', 'type' => 'success']); + } + + public function render(): View + { + $items = InventoryItem::query() + ->with('variant.product') + ->whereHas('variant.product', fn ($query) => $query->when($this->search !== '', fn ($query) => $query->where('title', 'like', '%'.$this->search.'%'))) + ->latest('variant_id') + ->paginate(12); + + foreach ($items as $item) { + $this->quantities[$item->id] ??= (int) $item->quantity_on_hand; + $this->policies[$item->id] ??= $item->policy->value; + } + + return view('livewire.admin.inventory.index', [ + 'items' => $items, + 'policyOptions' => InventoryPolicy::cases(), + ])->layout('livewire.admin.layout.app', [ + 'title' => 'Inventory', + ]); + } +} diff --git a/app/Livewire/Admin/Layout/Breadcrumbs.php b/app/Livewire/Admin/Layout/Breadcrumbs.php new file mode 100644 index 00000000..a99ec691 --- /dev/null +++ b/app/Livewire/Admin/Layout/Breadcrumbs.php @@ -0,0 +1,57 @@ + $this->items(), + ]); + } + + /** + * @return array + */ + private function items(): array + { + $route = request()->route()?->getName() ?? 'admin.dashboard'; + $items = [['label' => 'Home', 'url' => route('admin.dashboard')]]; + + $labels = [ + 'admin.products' => 'Products', + 'admin.collections' => 'Collections', + 'admin.inventory' => 'Inventory', + 'admin.orders' => 'Orders', + 'admin.customers' => 'Customers', + 'admin.discounts' => 'Discounts', + 'admin.settings' => 'Settings', + 'admin.themes' => 'Themes', + 'admin.pages' => 'Pages', + 'admin.navigation' => 'Navigation', + 'admin.analytics' => 'Analytics', + 'admin.search' => 'Search', + 'admin.apps' => 'Apps', + 'admin.developers' => 'Developers', + ]; + + foreach ($labels as $prefix => $label) { + if (str_starts_with($route, $prefix)) { + $items[] = ['label' => $label, 'url' => null]; + break; + } + } + + if ($this->title !== 'Admin' && end($items)['label'] !== $this->title) { + $items[] = ['label' => $this->title, 'url' => null]; + } + + return $items; + } +} diff --git a/app/Livewire/Admin/Layout/Sidebar.php b/app/Livewire/Admin/Layout/Sidebar.php new file mode 100644 index 00000000..4b8eaa6e --- /dev/null +++ b/app/Livewire/Admin/Layout/Sidebar.php @@ -0,0 +1,60 @@ +collapsed = ! $this->collapsed; + } + + public function render(): View + { + return view('livewire.admin.layout.sidebar', [ + 'groups' => $this->groups(), + ]); + } + + /** + * @return array}> + */ + private function groups(): array + { + return [ + ['label' => null, 'items' => [ + ['label' => 'Dashboard', 'route' => 'admin.dashboard', 'icon' => 'chart-bar'], + ]], + ['label' => 'Products', 'items' => [ + ['label' => 'Products', 'route' => 'admin.products.index', 'icon' => 'cube'], + ['label' => 'Collections', 'route' => 'admin.collections.index', 'icon' => 'rectangle-stack'], + ['label' => 'Inventory', 'route' => 'admin.inventory.index', 'icon' => 'archive-box'], + ]], + ['label' => 'Orders', 'items' => [ + ['label' => 'Orders', 'route' => 'admin.orders.index', 'icon' => 'shopping-bag'], + ]], + ['label' => 'Customers', 'items' => [ + ['label' => 'Customers', 'route' => 'admin.customers.index', 'icon' => 'users'], + ]], + ['label' => 'Discounts', 'items' => [ + ['label' => 'Discounts', 'route' => 'admin.discounts.index', 'icon' => 'tag'], + ]], + ['label' => 'Content', 'items' => [ + ['label' => 'Pages', 'route' => 'admin.pages.index', 'icon' => 'document-text'], + ['label' => 'Navigation', 'route' => 'admin.navigation.index', 'icon' => 'bars-3'], + ['label' => 'Themes', 'route' => 'admin.themes.index', 'icon' => 'paint-brush'], + ]], + ['label' => null, 'items' => [ + ['label' => 'Analytics', 'route' => 'admin.analytics.index', 'icon' => 'chart-pie'], + ['label' => 'Settings', 'route' => 'admin.settings.index', 'icon' => 'cog-6-tooth'], + ['label' => 'Apps', 'route' => 'admin.apps.index', 'icon' => 'squares-2x2'], + ['label' => 'Developers', 'route' => 'admin.developers.index', 'icon' => 'code-bracket'], + ]], + ]; + } +} diff --git a/app/Livewire/Admin/Layout/TopBar.php b/app/Livewire/Admin/Layout/TopBar.php new file mode 100644 index 00000000..bb14cd4c --- /dev/null +++ b/app/Livewire/Admin/Layout/TopBar.php @@ -0,0 +1,57 @@ +currentUser() + ->stores() + ->whereKey($storeId) + ->firstOrFail(); + + session()->put('current_store_id', $store->id); + + return $this->redirect(route('admin.dashboard'), navigate: true); + } + + public function logout(): mixed + { + Auth::guard('web')->logout(); + session()->invalidate(); + session()->regenerateToken(); + + return $this->redirect(route('admin.login'), navigate: true); + } + + public function render(): View + { + return view('livewire.admin.layout.top-bar', [ + 'currentStoreName' => $this->currentStore()->name, + 'stores' => $this->stores(), + 'unreadNotificationCount' => 0, + 'user' => $this->currentUser(), + ]); + } + + /** + * @return Collection + */ + private function stores(): Collection + { + return $this->currentUser() + ->stores() + ->orderBy('stores.name') + ->get(); + } +} diff --git a/app/Livewire/Admin/Navigation/Index.php b/app/Livewire/Admin/Navigation/Index.php new file mode 100644 index 00000000..c8e23372 --- /dev/null +++ b/app/Livewire/Admin/Navigation/Index.php @@ -0,0 +1,66 @@ +validate([ + 'menuId' => ['required', 'integer', 'exists:navigation_menus,id'], + 'label' => ['required', 'string', 'max:255'], + 'url' => ['required', 'string', 'max:500'], + 'type' => ['required', Rule::in(array_map(fn (NavigationItemType $type): string => $type->value, NavigationItemType::cases()))], + ]); + + $menu = NavigationMenu::query()->whereKey($validated['menuId'])->firstOrFail(); + + $menu->items()->create([ + 'label' => $validated['label'], + 'url' => $validated['url'], + 'type' => $validated['type'], + 'position' => $menu->items()->max('position') + 1, + ]); + + $this->reset('label'); + $this->url = '/'; + $this->notify('Navigation item added.'); + } + + public function deleteItem(int $itemId): void + { + NavigationItem::query()->whereKey($itemId)->firstOrFail()->delete(); + $this->notify('Navigation item deleted.'); + } + + public function render(): View + { + $menus = NavigationMenu::query()->with(['items' => fn ($query) => $query->orderBy('position')])->orderBy('title')->get(); + $this->menuId ??= $menus->first()?->id; + + return view('livewire.admin.navigation.index', [ + 'menus' => $menus, + 'types' => NavigationItemType::cases(), + ])->layout('livewire.admin.layout.app', [ + 'title' => 'Navigation', + ]); + } +} diff --git a/app/Livewire/Admin/Orders/Index.php b/app/Livewire/Admin/Orders/Index.php new file mode 100644 index 00000000..2fbc3959 --- /dev/null +++ b/app/Livewire/Admin/Orders/Index.php @@ -0,0 +1,46 @@ +resetPage(); + } + + public function updatedFinancialStatus(): void + { + $this->resetPage(); + } + + public function render(): View + { + return view('livewire.admin.orders.index', [ + 'orders' => Order::query() + ->with('customer') + ->when($this->search !== '', fn ($query) => $query->where(function ($query): void { + $query->where('order_number', 'like', '%'.$this->search.'%') + ->orWhere('email', 'like', '%'.$this->search.'%'); + })) + ->when($this->financialStatus !== 'all', fn ($query) => $query->where('financial_status', $this->financialStatus)) + ->latest('placed_at') + ->paginate(10), + 'financialStatuses' => FinancialStatus::cases(), + ])->layout('livewire.admin.layout.app', [ + 'title' => 'Orders', + ]); + } +} diff --git a/app/Livewire/Admin/Orders/Show.php b/app/Livewire/Admin/Orders/Show.php new file mode 100644 index 00000000..7602d104 --- /dev/null +++ b/app/Livewire/Admin/Orders/Show.php @@ -0,0 +1,120 @@ +order = $order; + } + + public function confirmBankTransfer(OrderService $orders): void + { + Gate::authorize('update', $this->order); + + try { + $this->order = $orders->confirmBankTransfer($this->order); + $this->notify('Payment confirmed.'); + } catch (Throwable $exception) { + $this->addError('order', $exception->getMessage()); + } + } + + public function fulfillAll(FulfillmentService $fulfillments): void + { + Gate::authorize('fulfill', $this->order); + + try { + $this->order->load('lines.fulfillmentLines'); + $lines = []; + + foreach ($this->order->lines as $line) { + $fulfilled = (int) $line->fulfillmentLines->sum('quantity'); + $remaining = $line->quantity - $fulfilled; + + if ($remaining > 0) { + $lines[$line->id] = $remaining; + } + } + + $fulfillments->create($this->order, $lines, [ + 'tracking_company' => $this->trackingCompany ?: null, + 'tracking_number' => $this->trackingNumber ?: null, + 'tracking_url' => $this->trackingUrl ?: null, + ]); + + $this->reset('trackingCompany', 'trackingNumber', 'trackingUrl'); + $this->notify('Fulfillment created.'); + } catch (Throwable $exception) { + $this->addError('order', $exception->getMessage()); + } + } + + public function refund(RefundService $refunds): void + { + Gate::authorize('refund', $this->order); + + $this->validate([ + 'refundAmount' => ['required', 'integer', 'min:1'], + 'refundReason' => ['nullable', 'string', 'max:500'], + 'restockRefund' => ['bool'], + ]); + + try { + $payment = $this->order->payments() + ->where('status', PaymentStatus::Captured) + ->latest('id') + ->firstOrFail(); + + $refunds->create($this->order, $payment, $this->refundAmount, $this->refundReason ?: null, $this->restockRefund); + $this->reset('refundAmount', 'refundReason', 'restockRefund'); + $this->notify('Refund processed.'); + } catch (Throwable $exception) { + $this->addError('order', $exception->getMessage()); + } + } + + public function render(): View + { + $this->order->load('customer', 'lines.fulfillmentLines', 'payments.refunds', 'refunds', 'fulfillments.lines.orderLine'); + + return view('livewire.admin.orders.show', [ + 'canConfirmBankTransfer' => $this->order->payment_method->value === 'bank_transfer' + && $this->order->financial_status === FinancialStatus::Pending, + 'canFulfill' => in_array($this->order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true), + ])->layout('livewire.admin.layout.app', [ + 'title' => $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..da069fcf --- /dev/null +++ b/app/Livewire/Admin/Pages/Form.php @@ -0,0 +1,76 @@ +page = $page?->exists ? $page : null; + + if ($this->page === null) { + return; + } + + $this->title = $this->page->title; + $this->handle = $this->page->handle; + $this->bodyHtml = $this->page->body_html ?? ''; + $this->status = $this->page->status->value; + } + + public function save(HandleGenerator $handles): mixed + { + $validated = $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['nullable', 'string', 'max:255'], + 'bodyHtml' => ['nullable', 'string'], + 'status' => ['required', Rule::in(array_map(fn (PageStatus $status): string => $status->value, PageStatus::cases()))], + ]); + + $payload = [ + 'store_id' => $this->currentStore()->id, + 'title' => $validated['title'], + 'handle' => $validated['handle'] ?: $handles->generate($validated['title'], (new Page)->getTable(), $this->currentStore()->id, $this->page?->id), + 'body_html' => $validated['bodyHtml'], + 'status' => $validated['status'], + 'published_at' => $validated['status'] === PageStatus::Published->value ? now() : null, + ]; + + $this->page = $this->page === null + ? Page::query()->create($payload) + : tap($this->page)->update($payload); + + $this->notify('Page saved.'); + + return $this->redirect(route('admin.pages.edit', $this->page), navigate: true); + } + + public function render(): View + { + return view('livewire.admin.pages.form', [ + 'statuses' => PageStatus::cases(), + ])->layout('livewire.admin.layout.app', [ + 'title' => $this->page ? 'Edit page' : 'Create page', + ]); + } +} diff --git a/app/Livewire/Admin/Pages/Index.php b/app/Livewire/Admin/Pages/Index.php new file mode 100644 index 00000000..632c9de8 --- /dev/null +++ b/app/Livewire/Admin/Pages/Index.php @@ -0,0 +1,32 @@ +resetPage(); + } + + public function render(): View + { + return view('livewire.admin.pages.index', [ + 'pages' => Page::query() + ->when($this->search !== '', fn ($query) => $query->where('title', 'like', '%'.$this->search.'%')) + ->latest('updated_at') + ->paginate(10), + ])->layout('livewire.admin.layout.app', [ + 'title' => 'Pages', + ]); + } +} diff --git a/app/Livewire/Admin/Products/Form.php b/app/Livewire/Admin/Products/Form.php new file mode 100644 index 00000000..7a49ab43 --- /dev/null +++ b/app/Livewire/Admin/Products/Form.php @@ -0,0 +1,142 @@ +product = $product?->exists ? $product->load('variants.inventoryItem') : null; + + if ($this->product === null) { + Gate::authorize('create', Product::class); + + return; + } + + Gate::authorize('update', $this->product); + + $variant = $this->product->variants->sortBy('position')->first(); + + $this->title = $this->product->title; + $this->handle = $this->product->handle; + $this->descriptionHtml = $this->product->description_html ?? ''; + $this->status = $this->product->status->value; + $this->vendor = $this->product->vendor ?? ''; + $this->productType = $this->product->product_type ?? ''; + $this->tags = implode(', ', $this->product->tags ?? []); + $this->priceAmount = (int) ($variant?->price_amount ?? 0); + $this->sku = $variant?->sku ?? ''; + $this->quantityOnHand = (int) ($variant?->inventoryItem?->quantity_on_hand ?? 0); + } + + public function save(ProductService $products): mixed + { + $validated = $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['nullable', 'string', 'max:255'], + 'descriptionHtml' => ['nullable', 'string'], + 'status' => ['required', Rule::in(array_map(fn (ProductStatus $status): string => $status->value, ProductStatus::cases()))], + 'vendor' => ['nullable', 'string', 'max:255'], + 'productType' => ['nullable', 'string', 'max:255'], + 'tags' => ['nullable', 'string', 'max:1000'], + 'priceAmount' => ['required', 'integer', 'min:0'], + 'sku' => ['nullable', 'string', 'max:255'], + 'quantityOnHand' => ['required', 'integer', 'min:0'], + ]); + + $payload = [ + 'title' => $validated['title'], + 'handle' => $validated['handle'] ?: null, + 'description_html' => $validated['descriptionHtml'], + 'status' => $validated['status'], + 'vendor' => $validated['vendor'] ?: null, + 'product_type' => $validated['productType'] ?: null, + 'tags' => collect(explode(',', $validated['tags'] ?? '')) + ->map(fn (string $tag): string => trim($tag)) + ->filter() + ->values() + ->all(), + 'price_amount' => $validated['priceAmount'], + ]; + + if ($this->product === null) { + Gate::authorize('create', Product::class); + $this->product = $products->create($this->currentStore(), $payload); + } else { + Gate::authorize('update', $this->product); + $this->product = $products->update($this->product, $payload); + } + + $variant = $this->product->variants()->oldest('position')->first() + ?? $this->product->variants()->create([ + 'price_amount' => $this->priceAmount, + 'currency' => $this->currentStore()->default_currency, + 'is_default' => true, + 'status' => VariantStatus::Active, + ]); + + $variant->forceFill([ + 'sku' => $this->sku ?: null, + 'price_amount' => $this->priceAmount, + 'currency' => $this->currentStore()->default_currency, + 'is_default' => true, + 'status' => VariantStatus::Active, + ])->save(); + + $variant->inventoryItem()->updateOrCreate( + ['variant_id' => $variant->id], + [ + 'store_id' => $this->currentStore()->id, + 'quantity_on_hand' => $this->quantityOnHand, + ], + ); + + $this->notify('Product saved.'); + + return $this->redirect(route('admin.products.edit', $this->product), navigate: true); + } + + public function render(): View + { + return view('livewire.admin.products.form', [ + 'statuses' => ProductStatus::cases(), + ])->layout('livewire.admin.layout.app', [ + 'title' => $this->product ? 'Edit product' : 'Create product', + ]); + } +} diff --git a/app/Livewire/Admin/Products/Index.php b/app/Livewire/Admin/Products/Index.php new file mode 100644 index 00000000..aa025985 --- /dev/null +++ b/app/Livewire/Admin/Products/Index.php @@ -0,0 +1,91 @@ + + */ + public array $selected = []; + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatus(): void + { + $this->resetPage(); + } + + public function archive(int $productId, ProductService $products): void + { + $product = Product::query()->whereKey($productId)->firstOrFail(); + Gate::authorize('archive', $product); + + $products->transitionStatus($product, ProductStatus::Archived); + $this->notify('Product archived.'); + } + + public function delete(int $productId, ProductService $products): void + { + $product = Product::query()->whereKey($productId)->firstOrFail(); + Gate::authorize('delete', $product); + + $products->delete($product); + $this->notify('Product deleted.'); + } + + public function bulkArchive(ProductService $products): void + { + Product::query() + ->whereIn('id', $this->selected) + ->get() + ->each(function (Product $product) use ($products): void { + Gate::authorize('archive', $product); + $products->transitionStatus($product, ProductStatus::Archived); + }); + + $this->selected = []; + $this->notify('Selected products archived.'); + } + + public function render(): View + { + Gate::authorize('viewAny', Product::class); + + return view('livewire.admin.products.index', [ + 'products' => Product::query() + ->with('variants.inventoryItem') + ->withCount('variants') + ->when($this->search !== '', fn ($query) => $query->where(function ($query): void { + $query->where('title', 'like', '%'.$this->search.'%') + ->orWhere('handle', 'like', '%'.$this->search.'%') + ->orWhere('vendor', 'like', '%'.$this->search.'%'); + })) + ->when($this->status !== 'all', fn ($query) => $query->where('status', $this->status)) + ->latest('updated_at') + ->paginate(10), + 'statuses' => ProductStatus::cases(), + ])->layout('livewire.admin.layout.app', [ + 'title' => 'Products', + ]); + } +} diff --git a/app/Livewire/Admin/Search/Settings.php b/app/Livewire/Admin/Search/Settings.php new file mode 100644 index 00000000..589d90ab --- /dev/null +++ b/app/Livewire/Admin/Search/Settings.php @@ -0,0 +1,64 @@ +settings()->settings_json; + $this->synonyms = implode("\n", data_get($settings, 'search.synonyms', [])); + $this->stopWords = implode("\n", data_get($settings, 'search.stop_words', [])); + } + + public function save(): void + { + $settings = $this->settings(); + $payload = $settings->settings_json; + data_set($payload, 'search.synonyms', $this->lines($this->synonyms)); + data_set($payload, 'search.stop_words', $this->lines($this->stopWords)); + $settings->forceFill(['settings_json' => $payload])->save(); + + $this->notify('Search settings saved.'); + } + + public function reindex(): void + { + $this->notify('Search index queued.'); + } + + public function render(): View + { + return view('livewire.admin.search.settings')->layout('livewire.admin.layout.app', [ + 'title' => 'Search settings', + ]); + } + + private function settings(): StoreSettings + { + return StoreSettings::query()->firstOrCreate(['store_id' => $this->currentStore()->id]); + } + + /** + * @return list + */ + private function lines(string $value): array + { + return collect(explode("\n", $value)) + ->map(fn (string $line): string => trim($line)) + ->filter() + ->values() + ->all(); + } +} diff --git a/app/Livewire/Admin/Settings/Index.php b/app/Livewire/Admin/Settings/Index.php new file mode 100644 index 00000000..72ace6ee --- /dev/null +++ b/app/Livewire/Admin/Settings/Index.php @@ -0,0 +1,65 @@ +currentStore(); + $this->name = $store->name; + $this->defaultCurrency = $store->default_currency; + $this->defaultLocale = $store->default_locale; + $this->timezone = $store->timezone; + $this->status = $store->status->value; + } + + public function save(): void + { + $validated = $this->validate([ + 'name' => ['required', 'string', 'max:255'], + 'defaultCurrency' => ['required', 'string', 'size:3'], + 'defaultLocale' => ['required', 'string', 'max:10'], + 'timezone' => ['required', 'string', 'max:255'], + 'status' => ['required', Rule::in(array_map(fn (StoreStatus $status): string => $status->value, StoreStatus::cases()))], + ]); + + $this->currentStore()->forceFill([ + 'name' => $validated['name'], + 'default_currency' => strtoupper($validated['defaultCurrency']), + 'default_locale' => $validated['defaultLocale'], + 'timezone' => $validated['timezone'], + 'status' => $validated['status'], + ])->save(); + + $this->notify('Store settings saved.'); + } + + public function render(): View + { + return view('livewire.admin.settings.index', [ + 'statuses' => StoreStatus::cases(), + 'domains' => $this->currentStore()->domains()->orderBy('hostname')->get(), + ])->layout('livewire.admin.layout.app', [ + 'title' => 'Settings', + ]); + } +} diff --git a/app/Livewire/Admin/Settings/Shipping.php b/app/Livewire/Admin/Settings/Shipping.php new file mode 100644 index 00000000..0e6cbe85 --- /dev/null +++ b/app/Livewire/Admin/Settings/Shipping.php @@ -0,0 +1,80 @@ +validate([ + 'zoneName' => ['required', 'string', 'max:255'], + 'countries' => ['required', 'string', 'max:500'], + ]); + + ShippingZone::query()->create([ + 'store_id' => $this->currentStore()->id, + 'name' => $validated['zoneName'], + 'countries_json' => collect(explode(',', $validated['countries']))->map(fn (string $country): string => strtoupper(trim($country)))->filter()->values()->all(), + 'regions_json' => [], + ]); + + $this->reset('zoneName', 'countries'); + $this->countries = 'DE'; + $this->notify('Shipping zone created.'); + } + + public function createRate(): void + { + $validated = $this->validate([ + 'rateZoneId' => ['required', 'integer', 'exists:shipping_zones,id'], + 'rateName' => ['required', 'string', 'max:255'], + 'rateAmount' => ['required', 'integer', 'min:0'], + ]); + + $zone = ShippingZone::query()->whereKey($validated['rateZoneId'])->firstOrFail(); + + $zone->rates()->create([ + 'name' => $validated['rateName'], + 'type' => ShippingRateType::Flat, + 'config_json' => ['amount' => $validated['rateAmount']], + 'is_active' => true, + ]); + + $this->reset('rateZoneId', 'rateName', 'rateAmount'); + $this->notify('Shipping rate created.'); + } + + public function deleteRate(int $rateId): void + { + ShippingRate::query()->whereKey($rateId)->firstOrFail()->delete(); + $this->notify('Shipping rate deleted.'); + } + + public function render(): View + { + return view('livewire.admin.settings.shipping', [ + 'zones' => ShippingZone::query()->with('rates')->orderBy('name')->get(), + ])->layout('livewire.admin.layout.app', [ + 'title' => 'Shipping', + ]); + } +} diff --git a/app/Livewire/Admin/Settings/Taxes.php b/app/Livewire/Admin/Settings/Taxes.php new file mode 100644 index 00000000..984b53e1 --- /dev/null +++ b/app/Livewire/Admin/Settings/Taxes.php @@ -0,0 +1,64 @@ +settings(); + $this->mode = $settings->mode->value; + $this->pricesIncludeTax = $settings->prices_include_tax; + $this->manualRate = (int) data_get($settings->config_json, 'manual_rate_bps', 1900); + } + + public function save(): void + { + $validated = $this->validate([ + 'mode' => ['required', Rule::in(array_map(fn (TaxMode $mode): string => $mode->value, TaxMode::cases()))], + 'pricesIncludeTax' => ['bool'], + 'manualRate' => ['required', 'integer', 'min:0', 'max:10000'], + ]); + + $this->settings()->forceFill([ + 'mode' => $validated['mode'], + 'provider' => $validated['mode'] === TaxMode::Provider->value ? 'mock' : 'none', + 'prices_include_tax' => $validated['pricesIncludeTax'], + 'config_json' => ['manual_rate_bps' => $validated['manualRate']], + ])->save(); + + $this->notify('Tax settings saved.'); + } + + public function render(): View + { + return view('livewire.admin.settings.taxes', [ + 'modes' => TaxMode::cases(), + ])->layout('livewire.admin.layout.app', [ + 'title' => 'Taxes', + ]); + } + + private function settings(): TaxSettings + { + return TaxSettings::query()->firstOrCreate( + ['store_id' => $this->currentStore()->id], + ['config_json' => ['manual_rate_bps' => 1900]], + ); + } +} diff --git a/app/Livewire/Admin/Themes/Editor.php b/app/Livewire/Admin/Themes/Editor.php new file mode 100644 index 00000000..e7edb3ca --- /dev/null +++ b/app/Livewire/Admin/Themes/Editor.php @@ -0,0 +1,44 @@ +theme = $theme->load('settings'); + $this->settingsJson = json_encode($this->theme->settings?->settings_json ?? [], JSON_PRETTY_PRINT) ?: '{}'; + } + + public function save(): void + { + $validated = $this->validate([ + 'settingsJson' => ['required', 'json'], + ]); + + $this->theme->settings()->updateOrCreate( + ['theme_id' => $this->theme->id], + ['settings_json' => json_decode($validated['settingsJson'], true)], + ); + + $this->notify('Theme settings saved.'); + } + + public function render(): View + { + return view('livewire.admin.themes.editor')->layout('livewire.admin.layout.app', [ + 'title' => 'Theme editor', + ]); + } +} diff --git a/app/Livewire/Admin/Themes/Index.php b/app/Livewire/Admin/Themes/Index.php new file mode 100644 index 00000000..f8349be0 --- /dev/null +++ b/app/Livewire/Admin/Themes/Index.php @@ -0,0 +1,52 @@ +update(['status' => ThemeStatus::Draft->value, 'published_at' => null]); + Theme::query()->whereKey($themeId)->firstOrFail()->forceFill([ + 'status' => ThemeStatus::Published, + 'published_at' => now(), + ])->save(); + + $this->notify('Theme published.'); + } + + public function duplicate(int $themeId): void + { + $theme = Theme::query()->with('settings')->whereKey($themeId)->firstOrFail(); + $copy = $theme->replicate(['status', 'published_at']); + $copy->name = $theme->name.' copy'; + $copy->status = ThemeStatus::Draft; + $copy->published_at = null; + $copy->save(); + + if ($theme->settings) { + $copy->settings()->create([ + 'settings_json' => $theme->settings->settings_json, + ]); + } + + $this->notify('Theme duplicated.'); + } + + public function render(): View + { + return view('livewire.admin.themes.index', [ + 'themes' => Theme::query()->with('settings')->latest('updated_at')->get(), + ])->layout('livewire.admin.layout.app', [ + 'title' => 'Themes', + ]); + } +} diff --git a/app/Policies/Concerns/ChecksStoreRoles.php b/app/Policies/Concerns/ChecksStoreRoles.php new file mode 100644 index 00000000..8f036227 --- /dev/null +++ b/app/Policies/Concerns/ChecksStoreRoles.php @@ -0,0 +1,30 @@ + $roles + */ + protected function hasAnyRole(User $user, array $roles): bool + { + if (! app()->bound('current_store')) { + return false; + } + + return $this->hasRole($user, app('current_store'), $roles); + } + + /** + * @param list $roles + */ + protected function hasRole(User $user, Store $store, array $roles): bool + { + return in_array($user->roleForStore($store), $roles, true); + } +} diff --git a/app/Policies/CustomerPolicy.php b/app/Policies/CustomerPolicy.php new file mode 100644 index 00000000..ea05324a --- /dev/null +++ b/app/Policies/CustomerPolicy.php @@ -0,0 +1,33 @@ +hasAnyRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff, StoreUserRole::Support]); + } + + public function view(User $user, Customer $customer): bool + { + return $this->hasRole($user, $customer->store, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff, StoreUserRole::Support]); + } + + public function update(User $user, Customer $customer): bool + { + return $this->hasRole($user, $customer->store, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function delete(User $user, Customer $customer): bool + { + return $this->hasRole($user, $customer->store, [StoreUserRole::Owner, StoreUserRole::Admin]); + } +} diff --git a/app/Policies/DiscountPolicy.php b/app/Policies/DiscountPolicy.php new file mode 100644 index 00000000..c1e5d512 --- /dev/null +++ b/app/Policies/DiscountPolicy.php @@ -0,0 +1,38 @@ +hasAnyRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function view(User $user, Discount $discount): bool + { + return $this->hasRole($user, $discount->store, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function create(User $user): bool + { + return $this->hasAnyRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function update(User $user, Discount $discount): bool + { + return $this->view($user, $discount); + } + + public function delete(User $user, Discount $discount): bool + { + return $this->hasRole($user, $discount->store, [StoreUserRole::Owner, StoreUserRole::Admin]); + } +} diff --git a/app/Policies/FulfillmentPolicy.php b/app/Policies/FulfillmentPolicy.php new file mode 100644 index 00000000..22995b4e --- /dev/null +++ b/app/Policies/FulfillmentPolicy.php @@ -0,0 +1,28 @@ +hasRole($user, $fulfillment->order->store, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff, StoreUserRole::Support]); + } + + public function create(User $user): bool + { + return $this->hasAnyRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function update(User $user, Fulfillment $fulfillment): bool + { + return $this->hasRole($user, $fulfillment->order->store, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } +} diff --git a/app/Policies/OrderPolicy.php b/app/Policies/OrderPolicy.php new file mode 100644 index 00000000..bce2aebc --- /dev/null +++ b/app/Policies/OrderPolicy.php @@ -0,0 +1,38 @@ +hasAnyRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff, StoreUserRole::Support]); + } + + public function view(User $user, Order $order): bool + { + return $this->hasRole($user, $order->store, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff, StoreUserRole::Support]); + } + + public function update(User $user, Order $order): bool + { + return $this->hasRole($user, $order->store, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function fulfill(User $user, Order $order): bool + { + return $this->update($user, $order); + } + + public function refund(User $user, Order $order): bool + { + return $this->hasRole($user, $order->store, [StoreUserRole::Owner, StoreUserRole::Admin]); + } +} diff --git a/app/Policies/PagePolicy.php b/app/Policies/PagePolicy.php new file mode 100644 index 00000000..6ea0f563 --- /dev/null +++ b/app/Policies/PagePolicy.php @@ -0,0 +1,38 @@ +hasAnyRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function view(User $user, Page $page): bool + { + return $this->hasRole($user, $page->store, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function create(User $user): bool + { + return $this->hasAnyRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function update(User $user, Page $page): bool + { + return $this->view($user, $page); + } + + public function delete(User $user, Page $page): bool + { + return $this->hasRole($user, $page->store, [StoreUserRole::Owner, StoreUserRole::Admin]); + } +} diff --git a/app/Policies/RefundPolicy.php b/app/Policies/RefundPolicy.php new file mode 100644 index 00000000..ac7491cd --- /dev/null +++ b/app/Policies/RefundPolicy.php @@ -0,0 +1,23 @@ +hasRole($user, $refund->order->store, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Support]); + } + + public function create(User $user): bool + { + return $this->hasAnyRole($user, [StoreUserRole::Owner, StoreUserRole::Admin]); + } +} diff --git a/app/Policies/StorePolicy.php b/app/Policies/StorePolicy.php new file mode 100644 index 00000000..e1f2ed16 --- /dev/null +++ b/app/Policies/StorePolicy.php @@ -0,0 +1,28 @@ +hasRole($user, $store, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff, StoreUserRole::Support]); + } + + public function update(User $user, Store $store): bool + { + return $this->hasRole($user, $store, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + public function delete(User $user, Store $store): bool + { + return $this->hasRole($user, $store, [StoreUserRole::Owner]); + } +} diff --git a/app/Policies/ThemePolicy.php b/app/Policies/ThemePolicy.php new file mode 100644 index 00000000..06f2831f --- /dev/null +++ b/app/Policies/ThemePolicy.php @@ -0,0 +1,38 @@ +hasAnyRole($user, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + public function view(User $user, Theme $theme): bool + { + return $this->hasRole($user, $theme->store, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + public function create(User $user): bool + { + return $this->hasAnyRole($user, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + public function update(User $user, Theme $theme): bool + { + return $this->view($user, $theme); + } + + public function delete(User $user, Theme $theme): bool + { + return $this->view($user, $theme); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 9f4d41e9..d2f87377 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -64,6 +64,10 @@ protected function configureDefaults(): void protected function configureRateLimiting(): void { + RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by($request->ip()); + }); + RateLimiter::for('api.storefront', function (Request $request) { return Limit::perMinute(120)->by($request->ip()); }); diff --git a/bootstrap/app.php b/bootstrap/app.php index 0153c7a8..a252758d 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,7 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use Illuminate\Http\Request; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -12,6 +13,10 @@ health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { + $middleware->redirectGuestsTo(fn (Request $request): string => $request->is('admin') || $request->is('admin/*') + ? route('admin.login') + : route('login')); + $middleware->alias([ 'customer.auth' => App\Http\Middleware\CustomerAuthenticate::class, 'store.resolve' => App\Http\Middleware\ResolveStore::class, 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..7454037b --- /dev/null +++ b/resources/views/livewire/admin/analytics/index.blade.php @@ -0,0 +1,35 @@ +
+
+ Analytics + Sales and order movement for the active store. +
+ +
+ @foreach ([ + ['label' => 'Total sales', 'value' => $totalSales], + ['label' => 'Paid orders', 'value' => $paidOrders], + ['label' => 'Pending orders', 'value' => $pendingOrders], + ['label' => 'Refunded orders', 'value' => $refundedOrders], + ] as $tile) +
+
{{ $tile['label'] }}
+
{{ $tile['value'] }}
+
+ @endforeach +
+ +
+ Daily orders +
+ @foreach ($dailyOrders as $point) +
+
{{ $point['date'] }}
+
+
+
+
{{ $point['count'] }}
+
+ @endforeach +
+
+
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..a962b1a4 --- /dev/null +++ b/resources/views/livewire/admin/apps/index.blade.php @@ -0,0 +1,15 @@ +
+
+ Apps + Installable integrations for store operations. +
+ +
+ @foreach ($apps as $app) + + {{ $app['name'] }} + {{ $app['status'] }} + + @endforeach +
+
diff --git a/resources/views/livewire/admin/apps/show.blade.php b/resources/views/livewire/admin/apps/show.blade.php new file mode 100644 index 00000000..39615705 --- /dev/null +++ b/resources/views/livewire/admin/apps/show.blade.php @@ -0,0 +1,18 @@ +
+
+
+ {{ \Illuminate\Support\Str::headline($installation) }} + Scopes, webhooks, and installation state. +
+ + Back to apps +
+ +
+
+
Status
Available
+
Scopes
Read products, read orders
+
Webhooks
Not configured
+
+
+
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..3163fa1d --- /dev/null +++ b/resources/views/livewire/admin/auth/login.blade.php @@ -0,0 +1,31 @@ +
+ + +
+ + + + + + + + Log in + + +
diff --git a/resources/views/livewire/admin/collections/form.blade.php b/resources/views/livewire/admin/collections/form.blade.php new file mode 100644 index 00000000..81189f61 --- /dev/null +++ b/resources/views/livewire/admin/collections/form.blade.php @@ -0,0 +1,26 @@ +
+
+
+ {{ $collection ? 'Edit collection' : 'Create collection' }} + {{ $title ?: 'Collection details' }} +
+ +
+ Cancel + Save collection +
+
+ +
+
+ + + + + @foreach ($statuses as $statusOption) + + @endforeach + +
+
+
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..b79beb37 --- /dev/null +++ b/resources/views/livewire/admin/collections/index.blade.php @@ -0,0 +1,32 @@ +
+
+
+ Collections + Curated product groups for storefront navigation. +
+ + Add collection +
+ +
+
+ +
+ +
+ @forelse ($collections as $collection) +
+
+ {{ $collection->title }} +
{{ $collection->handle }} · {{ $collection->products_count }} products
+
+ {{ $collection->status->value }} +
+ @empty +
No collections match the current filters.
+ @endforelse +
+ +
{{ $collections->links() }}
+
+
diff --git a/resources/views/livewire/admin/customers/index.blade.php b/resources/views/livewire/admin/customers/index.blade.php new file mode 100644 index 00000000..4adb120a --- /dev/null +++ b/resources/views/livewire/admin/customers/index.blade.php @@ -0,0 +1,46 @@ +
+
+ Customers + Customer profiles, order counts, and marketing status. +
+ +
+
+ +
+ +
+ + + + + + + + + + + @forelse ($customers as $customer) + + + + + + + @empty + + + + @endforelse + +
CustomerMarketingOrdersLast update
+ {{ $customer->name ?? 'Guest customer' }} +
{{ $customer->email }}
+
{{ $customer->marketing_opt_in ? 'opted in' : 'not opted in' }}{{ $customer->orders_count }}{{ $customer->updated_at?->format('M j, Y') }}
No customers match the current filters.
+
+ +
+ {{ $customers->links() }} +
+
+
diff --git a/resources/views/livewire/admin/customers/show.blade.php b/resources/views/livewire/admin/customers/show.blade.php new file mode 100644 index 00000000..5b24ff2e --- /dev/null +++ b/resources/views/livewire/admin/customers/show.blade.php @@ -0,0 +1,61 @@ +
+
+
+ {{ $customer->name ?? 'Guest customer' }} + {{ $customer->email }} +
+ + Back to customers +
+ +
+
+
+ Orders +
+ +
+ @forelse ($customer->orders->sortByDesc('placed_at') as $order) +
+
+ {{ $order->order_number }} +
{{ $order->placed_at?->format('M j, Y') }} · {{ $order->financial_status->value }}
+
+
{{ \Illuminate\Support\Number::currency($order->total_amount / 100, $order->currency) }}
+
+ @empty +
No orders yet.
+ @endforelse +
+
+ + +
+
diff --git a/resources/views/livewire/admin/dashboard.blade.php b/resources/views/livewire/admin/dashboard.blade.php new file mode 100644 index 00000000..18021210 --- /dev/null +++ b/resources/views/livewire/admin/dashboard.blade.php @@ -0,0 +1,108 @@ +
+
+
+ Dashboard + Operational snapshot for {{ app('current_store')->name }}. +
+ +
+ + @foreach ($dateRangeOptions as $value => $label) + + @endforeach + + + @if ($dateRange === 'custom') + + + @endif +
+
+ +
+ @foreach ([ + ['label' => 'Total sales', 'value' => $totalSales], + ['label' => 'Orders', 'value' => number_format($ordersCount)], + ['label' => 'Average order value', 'value' => $averageOrderValue], + ['label' => 'Conversion rate', 'value' => $conversionRate], + ] as $tile) +
+
{{ $tile['label'] }}
+
{{ $tile['value'] }}
+ Live +
+ @endforeach +
+ +
+
+
+ Orders over time + {{ count($chartData) }} days with orders +
+ +
+ @forelse ($chartData as $point) + @php($height = max(8, min(100, $point['count'] * 24))) +
+
+
{{ \Illuminate\Support\Str::of($point['date'])->afterLast('-') }}
+
+ @empty +
+ No orders in this range. +
+ @endforelse +
+
+ +
+ Top products + +
+ @forelse ($topProducts as $product) +
+
+
{{ $product->title_snapshot }}
+
{{ (int) $product->units_sold }} sold
+
+
{{ \Illuminate\Support\Number::currency(((int) $product->revenue) / 100, app('current_store')->default_currency) }}
+
+ @empty +
No sales data for this period.
+ @endforelse +
+
+
+ +
+
+ Recent orders +
+ +
+ + + + + + + + + + + @foreach ($recentOrders as $order) + + + + + + + @endforeach + +
OrderCustomerStatusTotal
+ {{ $order->order_number }} + {{ $order->customer?->name ?? $order->email }}{{ $order->financial_status->value }}{{ \Illuminate\Support\Number::currency($order->total_amount / 100, $order->currency) }}
+
+
+
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..c00e393e --- /dev/null +++ b/resources/views/livewire/admin/developers/index.blade.php @@ -0,0 +1,22 @@ +
+
+ Developers + API credentials and webhook delivery settings. +
+ +
+
+ API tokens +
+ Personal access token storage will be enabled in the apps and webhooks phase. +
+
+ +
+ Webhook subscriptions +
+ Webhook subscriptions will appear here after delivery tables are created. +
+
+
+
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..dbfc81e6 --- /dev/null +++ b/resources/views/livewire/admin/discounts/form.blade.php @@ -0,0 +1,50 @@ +
+
+
+ {{ $discount ? 'Edit discount' : 'Create discount' }} + {{ $code ?: 'Discount configuration' }} +
+ +
+ Cancel + Save discount +
+
+ +
+
+ + @foreach ($types as $typeOption) + + @endforeach + + + @if ($type === 'code') + + @endif + +
+ + @foreach ($valueTypes as $valueTypeOption) + + @endforeach + + +
+ +
+ + +
+ +
+ + + @foreach ($statuses as $statusOption) + + @endforeach + +
+
+
+
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..0f06765f --- /dev/null +++ b/resources/views/livewire/admin/discounts/index.blade.php @@ -0,0 +1,64 @@ +
+
+
+ Discounts + Codes, automatic offers, limits, and lifecycle status. +
+ + + Add discount + +
+ +
+
+ + + + @foreach ($statuses as $statusOption) + + @endforeach + +
+ +
+ + + + + + + + + + + + + @forelse ($discounts as $discount) + + + + + + + + + @empty + + + + @endforelse + +
CodeTypeValueUsageStatusActions
{{ $discount->code ?? 'Automatic discount' }}{{ $discount->type->value }}{{ $discount->value_type->value }} · {{ $discount->value_amount }}{{ $discount->usage_count }}{{ $discount->usage_limit ? ' / '.$discount->usage_limit : '' }}{{ $discount->status->value }} +
+ Edit + Disable +
+
No discounts match the current filters.
+
+ +
+ {{ $discounts->links() }} +
+
+
diff --git a/resources/views/livewire/admin/inventory/index.blade.php b/resources/views/livewire/admin/inventory/index.blade.php new file mode 100644 index 00000000..57b6f60c --- /dev/null +++ b/resources/views/livewire/admin/inventory/index.blade.php @@ -0,0 +1,34 @@ +
+
+ Inventory + Track available stock and oversell policy by variant. +
+ +
+
+ +
+ +
+ @forelse ($items as $item) +
+
+
{{ $item->variant?->product?->title }}
+
{{ $item->variant?->sku ?: 'No SKU' }} · Reserved {{ $item->quantity_reserved }}
+
+ + + @foreach ($policyOptions as $policy) + + @endforeach + + Save +
+ @empty +
No inventory items match the current filters.
+ @endforelse +
+ +
{{ $items->links() }}
+
+
diff --git a/resources/views/livewire/admin/layout/app.blade.php b/resources/views/livewire/admin/layout/app.blade.php new file mode 100644 index 00000000..484c4b6f --- /dev/null +++ b/resources/views/livewire/admin/layout/app.blade.php @@ -0,0 +1,31 @@ + + + + @include('partials.head') + + +
+ + +
+ + +
+
+ + + @if ($toast = session('admin_toast')) +
+ {{ $toast['message'] }} +
+ @endif + + {{ $slot }} +
+
+
+
+ + @fluxScripts + + diff --git a/resources/views/livewire/admin/layout/breadcrumbs.blade.php b/resources/views/livewire/admin/layout/breadcrumbs.blade.php new file mode 100644 index 00000000..ea10f0e8 --- /dev/null +++ b/resources/views/livewire/admin/layout/breadcrumbs.blade.php @@ -0,0 +1,13 @@ +
+ @foreach ($items as $item) + @if (! $loop->first) + / + @endif + + @if ($item['url']) + {{ $item['label'] }} + @else + {{ $item['label'] }} + @endif + @endforeach +
diff --git a/resources/views/livewire/admin/layout/sidebar.blade.php b/resources/views/livewire/admin/layout/sidebar.blade.php new file mode 100644 index 00000000..8653baa3 --- /dev/null +++ b/resources/views/livewire/admin/layout/sidebar.blade.php @@ -0,0 +1,37 @@ + diff --git a/resources/views/livewire/admin/layout/top-bar.blade.php b/resources/views/livewire/admin/layout/top-bar.blade.php new file mode 100644 index 00000000..e7d09929 --- /dev/null +++ b/resources/views/livewire/admin/layout/top-bar.blade.php @@ -0,0 +1,46 @@ +
+
+
+
+ +
+ + + + {{ $currentStoreName }} + + + + @foreach ($stores as $store) + + {{ $store->name }} + + @endforeach + + +
+ +
+
+ + @if ($unreadNotificationCount > 0) + {{ $unreadNotificationCount }} + @endif +
+ + + + + + + Settings + + + + Log out + + + +
+
+
diff --git a/resources/views/livewire/admin/navigation/index.blade.php b/resources/views/livewire/admin/navigation/index.blade.php new file mode 100644 index 00000000..f0827a83 --- /dev/null +++ b/resources/views/livewire/admin/navigation/index.blade.php @@ -0,0 +1,51 @@ +
+
+ Navigation + Menus and storefront links. +
+ +
+
+
+ Menus +
+ +
+ @foreach ($menus as $menu) +
+
{{ $menu->title }}
+
{{ $menu->handle }}
+ +
+ @foreach ($menu->items as $item) +
+ {{ $item->label }} · {{ $item->url }} + Delete +
+ @endforeach +
+
+ @endforeach +
+
+ +
+ Add item +
+ + @foreach ($menus as $menu) + + @endforeach + + + @foreach ($types as $typeOption) + + @endforeach + + + + Add item +
+
+
+
diff --git a/resources/views/livewire/admin/orders/index.blade.php b/resources/views/livewire/admin/orders/index.blade.php new file mode 100644 index 00000000..c58be415 --- /dev/null +++ b/resources/views/livewire/admin/orders/index.blade.php @@ -0,0 +1,54 @@ +
+
+ Orders + Review payment, fulfillment, and customer status. +
+ +
+
+ + + + @foreach ($financialStatuses as $status) + + @endforeach + +
+ +
+ + + + + + + + + + + + @forelse ($orders as $order) + + + + + + + + @empty + + + + @endforelse + +
OrderCustomerPaymentFulfillmentTotal
+ {{ $order->order_number }} +
{{ $order->placed_at?->format('M j, Y H:i') }}
+
{{ $order->customer?->name ?? $order->email }}{{ $order->financial_status->value }}{{ $order->fulfillment_status->value }}{{ \Illuminate\Support\Number::currency($order->total_amount / 100, $order->currency) }}
No orders match the current filters.
+
+ +
+ {{ $orders->links() }} +
+
+
diff --git a/resources/views/livewire/admin/orders/show.blade.php b/resources/views/livewire/admin/orders/show.blade.php new file mode 100644 index 00000000..97278478 --- /dev/null +++ b/resources/views/livewire/admin/orders/show.blade.php @@ -0,0 +1,107 @@ +
+
+
+ {{ $order->order_number }} + {{ $order->placed_at?->format('M j, Y H:i') }} · {{ $order->email }} +
+ +
+ @if ($canConfirmBankTransfer) + Confirm payment + @endif + Back to orders +
+
+ + @error('order') + + @enderror + +
+
+
+
+ Line items +
+
+ @foreach ($order->lines as $line) +
+
+
{{ $line->title_snapshot }}
+
{{ $line->sku_snapshot ?: 'No SKU' }} · Qty {{ $line->quantity }}
+
+
{{ \Illuminate\Support\Number::currency($line->total_amount / 100, $order->currency) }}
+
+ @endforeach +
+
+
Subtotal{{ \Illuminate\Support\Number::currency($order->subtotal_amount / 100, $order->currency) }}
+
Discount-{{ \Illuminate\Support\Number::currency($order->discount_amount / 100, $order->currency) }}
+
Shipping{{ \Illuminate\Support\Number::currency($order->shipping_amount / 100, $order->currency) }}
+
Tax{{ \Illuminate\Support\Number::currency($order->tax_amount / 100, $order->currency) }}
+
Total{{ \Illuminate\Support\Number::currency($order->total_amount / 100, $order->currency) }}
+
+
+ +
+ Fulfillment + @if (! $canFulfill) + + @else +
+ + + +
+ Create fulfillment + @endif + +
+ @foreach ($order->fulfillments as $fulfillment) +
+
{{ ucfirst($fulfillment->status->value) }}
+
{{ $fulfillment->tracking_company }} {{ $fulfillment->tracking_number }}
+
+ @endforeach +
+
+ +
+ Refund +
+ + + +
+ Process 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..01e832b9 --- /dev/null +++ b/resources/views/livewire/admin/pages/form.blade.php @@ -0,0 +1,26 @@ +
+
+
+ {{ $page ? 'Edit page' : 'Create page' }} + {{ $title ?: 'Page details' }} +
+ +
+ Cancel + Save page +
+
+ +
+
+ + + + + @foreach ($statuses as $statusOption) + + @endforeach + +
+
+
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..1f9f6971 --- /dev/null +++ b/resources/views/livewire/admin/pages/index.blade.php @@ -0,0 +1,32 @@ +
+
+
+ Pages + Storefront content pages and publishing status. +
+ + Add page +
+ +
+
+ +
+ +
+ @forelse ($pages as $page) +
+
+ {{ $page->title }} +
{{ $page->handle }}
+
+ {{ $page->status->value }} +
+ @empty +
No pages match the current filters.
+ @endforelse +
+ +
{{ $pages->links() }}
+
+
diff --git a/resources/views/livewire/admin/products/form.blade.php b/resources/views/livewire/admin/products/form.blade.php new file mode 100644 index 00000000..f96703b8 --- /dev/null +++ b/resources/views/livewire/admin/products/form.blade.php @@ -0,0 +1,52 @@ +
+
+
+ {{ $this->product ? 'Edit product' : 'Create product' }} + {{ $title ?: 'Product details' }} +
+ +
+ Cancel + Save product +
+
+ +
+
+
+
+ + +
+ + +
+ +
+
+ +
+ Default variant +
+ + + +
+
+
+ + +
+
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..36a14fe7 --- /dev/null +++ b/resources/views/livewire/admin/products/index.blade.php @@ -0,0 +1,74 @@ +
+
+
+ Products + Manage catalog items, status, pricing, and stock. +
+ + + Add product + +
+ +
+
+ + + + @foreach ($statuses as $statusOption) + + @endforeach + + Archive selected +
+ +
+ + + + + + + + + + + + + @forelse ($products as $product) + @php($stock = $product->variants->sum(fn ($variant) => (int) ($variant->inventoryItem?->availableQuantity() ?? 0))) + + + + + + + + + @empty + + + + @endforelse + +
ProductStatusVariantsStockActions
+ + + {{ $product->title }} +
{{ $product->handle }}
+
{{ $product->status->value }}{{ $product->variants_count }}{{ $stock }} +
+ Edit + Archive + @if ($product->status->value === 'draft') + Delete + @endif +
+
No products match the current filters.
+
+ +
+ {{ $products->links() }} +
+
+
diff --git a/resources/views/livewire/admin/search/settings.blade.php b/resources/views/livewire/admin/search/settings.blade.php new file mode 100644 index 00000000..3fc63f67 --- /dev/null +++ b/resources/views/livewire/admin/search/settings.blade.php @@ -0,0 +1,22 @@ +
+
+
+ Search settings + Synonyms, stop words, and index maintenance. +
+ +
+ Reindex + Save settings +
+
+ +
+
+ +
+
+ +
+
+
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..eb745d63 --- /dev/null +++ b/resources/views/livewire/admin/settings/index.blade.php @@ -0,0 +1,47 @@ +
+
+
+ Settings + Store identity, locale, domains, shipping, and tax controls. +
+ + Save settings +
+ +
+
+
+ +
+ + + +
+ + @foreach ($statuses as $statusOption) + + @endforeach + +
+
+ + +
+
diff --git a/resources/views/livewire/admin/settings/shipping.blade.php b/resources/views/livewire/admin/settings/shipping.blade.php new file mode 100644 index 00000000..f641e94c --- /dev/null +++ b/resources/views/livewire/admin/settings/shipping.blade.php @@ -0,0 +1,61 @@ +
+
+
+ Shipping + Zones and flat rates used during checkout. +
+ + Back to settings +
+ +
+
+
+ Zones +
+ +
+ @foreach ($zones as $zone) +
+
{{ $zone->name }}
+
{{ implode(', ', $zone->countries_json ?? []) }}
+
+ @foreach ($zone->rates as $rate) +
+ {{ $rate->name }} · {{ \Illuminate\Support\Number::currency(((int) data_get($rate->config_json, 'amount', 0)) / 100, app('current_store')->default_currency) }} + Delete +
+ @endforeach +
+
+ @endforeach +
+
+ + +
+
diff --git a/resources/views/livewire/admin/settings/taxes.blade.php b/resources/views/livewire/admin/settings/taxes.blade.php new file mode 100644 index 00000000..22f3b3a4 --- /dev/null +++ b/resources/views/livewire/admin/settings/taxes.blade.php @@ -0,0 +1,25 @@ +
+
+
+ Taxes + Manual tax rate and tax-inclusive price settings. +
+ +
+ Back to settings + Save taxes +
+
+ +
+
+ + @foreach ($modes as $modeOption) + + @endforeach + + + +
+
+
diff --git a/resources/views/livewire/admin/themes/editor.blade.php b/resources/views/livewire/admin/themes/editor.blade.php new file mode 100644 index 00000000..c2c173c1 --- /dev/null +++ b/resources/views/livewire/admin/themes/editor.blade.php @@ -0,0 +1,34 @@ +
+
+
+ {{ $theme->name }} + Theme settings JSON for the storefront renderer. +
+ +
+ Back to themes + Save settings +
+
+ +
+
+ Sections +
+
Announcement
+
Hero
+
Featured products
+
Footer
+
+
+ +
+ Preview + +
+ +
+ +
+
+
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..e37551cf --- /dev/null +++ b/resources/views/livewire/admin/themes/index.blade.php @@ -0,0 +1,26 @@ +
+
+ Themes + Publish, duplicate, and customize storefront themes. +
+ +
+ @foreach ($themes as $theme) +
+
+
+ {{ $theme->name }} + Version {{ $theme->version }} +
+ {{ $theme->status->value }} +
+ +
+ Customize + Publish + Duplicate +
+
+ @endforeach +
+
diff --git a/routes/admin.php b/routes/admin.php new file mode 100644 index 00000000..1661e2e2 --- /dev/null +++ b/routes/admin.php @@ -0,0 +1,79 @@ +group(function (): void { + Route::get('/login', AdminLogin::class) + ->middleware('guest') + ->name('admin.login'); + + Route::post('/logout', function (Request $request) { + Auth::guard('web')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('admin.login'); + })->middleware('auth')->name('admin.logout'); + + Route::middleware(['auth', 'verified', 'admin']) + ->name('admin.') + ->group(function (): void { + Route::get('/', AdminDashboard::class)->name('dashboard'); + Route::get('/products', AdminProductsIndex::class)->name('products.index'); + Route::get('/products/create', AdminProductsForm::class)->name('products.create'); + Route::get('/products/{product}/edit', AdminProductsForm::class)->name('products.edit'); + Route::get('/collections', AdminCollectionsIndex::class)->name('collections.index'); + Route::get('/collections/create', AdminCollectionsForm::class)->name('collections.create'); + Route::get('/collections/{collection}/edit', AdminCollectionsForm::class)->name('collections.edit'); + Route::get('/inventory', AdminInventoryIndex::class)->name('inventory.index'); + Route::get('/orders', AdminOrdersIndex::class)->name('orders.index'); + Route::get('/orders/{order}', AdminOrdersShow::class)->name('orders.show'); + Route::get('/customers', AdminCustomersIndex::class)->name('customers.index'); + Route::get('/customers/{customer}', AdminCustomersShow::class)->name('customers.show'); + Route::get('/discounts', AdminDiscountsIndex::class)->name('discounts.index'); + Route::get('/discounts/create', AdminDiscountsForm::class)->name('discounts.create'); + Route::get('/discounts/{discount}/edit', AdminDiscountsForm::class)->name('discounts.edit'); + Route::get('/settings', AdminSettingsIndex::class)->name('settings.index'); + Route::get('/settings/shipping', AdminSettingsShipping::class)->name('settings.shipping'); + Route::get('/settings/taxes', AdminSettingsTaxes::class)->name('settings.taxes'); + Route::get('/themes', AdminThemesIndex::class)->name('themes.index'); + Route::get('/themes/{theme}/editor', AdminThemesEditor::class)->name('themes.editor'); + Route::get('/pages', AdminPagesIndex::class)->name('pages.index'); + Route::get('/pages/create', AdminPagesForm::class)->name('pages.create'); + Route::get('/pages/{page}/edit', AdminPagesForm::class)->name('pages.edit'); + Route::get('/navigation', AdminNavigationIndex::class)->name('navigation.index'); + Route::get('/analytics', AdminAnalyticsIndex::class)->name('analytics.index'); + Route::get('/search/settings', AdminSearchSettings::class)->name('search.settings'); + Route::get('/apps', AdminAppsIndex::class)->name('apps.index'); + Route::get('/apps/{installation}', AdminAppsShow::class)->name('apps.show'); + Route::get('/developers', AdminDevelopersIndex::class)->name('developers.index'); + }); +}); diff --git a/routes/web.php b/routes/web.php index 15032716..72a94d3a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -44,3 +44,4 @@ ->name('dashboard'); require __DIR__.'/settings.php'; +require __DIR__.'/admin.php'; diff --git a/specs/progress.md b/specs/progress.md index 8822c6d5..bba2341b 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -15,7 +15,8 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Phase 4 cart/checkout/pricing is implemented and verified: carts, cart lines, checkouts, shipping zones/rates, tax settings, discounts, cart and checkout services, pricing snapshots, storefront REST endpoints, Livewire cart/checkout UI, and cleanup jobs. - Phase 5 payments/orders/customer persistence is implemented and verified: customers, customer addresses, orders, order lines, payments, refunds, fulfillments, mock PSP, checkout pay endpoint/UI, order confirmation, bank-transfer confirmation/cancellation services, and focused tests. - Phase 6 customer accounts are implemented and verified: store-scoped customer login/registration, account dashboard, order history/detail pages, address book CRUD, and seeded customer credentials. -- Admin shop UI, search indexing, analytics, apps, and webhooks are not implemented yet. +- Phase 7 admin panel is implemented and verified: admin login, admin shell/store switcher, dashboard, product/order/customer/discount/inventory/settings/theme/page/navigation surfaces, basic analytics, apps, developer, and search-settings pages. +- SQLite search indexing, analytics ingestion, API tokens, app installs, and webhooks are not implemented yet. ## Execution Plan @@ -38,7 +39,7 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit 6. **Customer accounts** - Implemented and verified - Add store-scoped customer auth, account dashboard, order history, and address book. - Verify customer registration/login isolation and account browser flows. -7. **Admin panel** - Pending +7. **Admin panel** - Implemented and verified - Add admin shell, dashboard, resource management pages, settings, themes, pages, navigation, analytics, apps, and developers surfaces. - Verify admin login, store switching, product/order/discount/settings flows. 8. **Search, analytics, apps, webhooks** - Pending @@ -61,18 +62,18 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Livewire update requests persist `ResolveStore` middleware so storefront actions keep tenant context after the initial page load. - Phase 5 adds the `customers` table immediately before cart/checkout migrations and converts `carts.customer_id` and `checkouts.customer_id` to nullable foreign keys for fresh installs. - `orders.checkout_id` is intentionally added beyond the table list so `OrderService::createFromCheckout()` can enforce idempotency with a durable unique key. +- Admin authentication uses a dedicated Livewire `/admin/login` screen on the existing `web` guard while leaving the starter-kit Fortify `/login` flow intact for existing auth/settings tests. ## Open Gaps -- Phase 1 still needs the resource policies listed in the roadmap, but most referenced resources do not exist until later phases. Policies will be added with their models to keep type hints and tests coherent. -- Phase 2 still needs Livewire/admin product management and full media resizing variants. Storefront product/collection browsing is covered by the Phase 3 shell. -- Phase 3 still needs the richer theme editor/admin surfaces, search modal autocomplete, checkout/account/error storefront templates, and fully configurable section ordering. These are deferred to the admin, search, checkout, and account slices. -- Phase 5 includes backend bank-transfer confirmation/cancellation, refunds, and fulfillment services. Admin UI actions for those services are deferred to the admin panel slice. +- Phase 2 still needs full media resizing variants and the richer multi-option variant builder. Admin product create/edit currently covers core product fields, default variant price/SKU, and stock. +- Phase 3 still needs search modal autocomplete, richer error templates, and fully configurable storefront section ordering. Basic theme editing and publishing now exist in the admin panel. +- Phase 5 backend bank-transfer confirmation, refunds, and fulfillment services now have admin order-detail actions. More granular partial-fulfillment UI can still be expanded during polish. - Phase 4 has a functional cart page and accessible cart count, but the richer slide-out cart drawer can be expanded during UI polish. - Discounts, shipping, and tax are implemented for the specified local/manual flows; provider/carrier integrations remain stubs by design. - Order-reference guards in product deletion/status logic are present but only become fully meaningful once `order_lines` exists in Phase 5. - Customer account password reset UI and emails remain deferred; login, registration, dashboard, order history/detail, and address book flows are implemented. -- Admin surfaces and all later shop phases remain unimplemented. +- Admin surfaces are implemented for the current data model; later search, analytics ingestion, apps, API tokens, and webhook backend phases remain unimplemented. - API token requirements mention Sanctum, but the package is not currently installed. This remains an open dependency decision for the API/developers phase because dependencies must not be changed without approval. ## Verification Log @@ -112,4 +113,11 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed with customer login and account seed data. - 2026-05-03: Playwright smoke completed customer login, account dashboard, order history, order detail, and address book creation at `http://shop.test`; latest console check reported no warnings or errors. - 2026-05-03: `browser_logs` reported no browser log file after the latest Phase 6 smoke check. -- Pending: Playwright admin browser flows. +- 2026-05-03: `php artisan route:list --path=admin --except-vendor` passed and showed 31 admin routes. +- 2026-05-03: `php artisan test --compact tests/Feature/Admin/AdminPanelTest.php` passed, 6 tests / 67 assertions. +- 2026-05-03: `vendor/bin/pint --dirty --format agent` passed after Phase 7 admin changes. +- 2026-05-03: `php artisan test --compact` passed, 85 tests / 355 assertions. +- 2026-05-03: `npm run build` passed for the updated admin Tailwind/Vite assets. +- 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed with admin routes and seeded admin/customer/order/catalog data. +- 2026-05-03: Playwright smoke completed admin login, dashboard, products list, and order detail at `http://shop.test/admin`; latest console check reported no new warnings or errors. +- 2026-05-03: `browser_logs` reported no browser log file after the latest Phase 7 smoke check. diff --git a/tests/Feature/Admin/AdminPanelTest.php b/tests/Feature/Admin/AdminPanelTest.php new file mode 100644 index 00000000..fe0177e4 --- /dev/null +++ b/tests/Feature/Admin/AdminPanelTest.php @@ -0,0 +1,140 @@ +seed(); + $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $this->user = User::query()->where('email', 'admin@example.com')->firstOrFail(); +}); + +function actAsAdmin($test): void +{ + $test->actingAs($test->user); + session(['current_store_id' => $test->store->id]); + app()->instance('current_store', $test->store); +} + +test('admin guests are redirected to the admin login page', function (): void { + $this->get('/admin') + ->assertRedirect(route('admin.login')); +}); + +test('admin can log in and gets an active store in session', function (): void { + Livewire::test(AdminLogin::class) + ->set('email', 'admin@example.com') + ->set('password', 'password') + ->call('login') + ->assertHasNoErrors() + ->assertRedirect(route('admin.dashboard')); + + expect(Auth::guard('web')->check())->toBeTrue() + ->and(session('current_store_id'))->toBe($this->store->id); +}); + +test('admin shell pages render with seeded store data', function (): void { + $this->actingAs($this->user); + $order = Order::query()->where('order_number', '#1001')->firstOrFail(); + $customer = $order->customer; + $theme = Theme::query()->firstOrFail(); + + foreach ([ + '/admin' => 'Dashboard', + '/admin/products' => 'Linen Shirt', + '/admin/products/create' => 'Create product', + '/admin/collections' => 'Summer Essentials', + '/admin/collections/create' => 'Create collection', + '/admin/inventory' => 'Track available stock', + '/admin/orders' => '#1001', + "/admin/orders/{$order->id}" => '#1001', + '/admin/customers' => 'jane@example.com', + "/admin/customers/{$customer->id}" => 'jane@example.com', + '/admin/discounts' => 'WELCOME10', + '/admin/discounts/create' => 'Create discount', + '/admin/settings' => 'Acme Fashion', + '/admin/settings/shipping' => 'Shipping', + '/admin/settings/taxes' => 'Manual rate', + '/admin/themes' => 'Default', + "/admin/themes/{$theme->id}/editor" => 'Theme settings JSON', + '/admin/pages' => 'Pages', + '/admin/pages/create' => 'Create page', + '/admin/navigation' => 'Main menu', + '/admin/analytics' => 'Total sales', + '/admin/search/settings' => 'Synonyms', + '/admin/apps' => 'Product Reviews', + '/admin/apps/reviews' => 'Reviews', + '/admin/developers' => 'API tokens', + ] as $uri => $expectedText) { + $this->withSession(['current_store_id' => $this->store->id]) + ->get($uri) + ->assertOk() + ->assertSee($expectedText); + } +}); + +test('admin can create a product with default variant inventory', function (): void { + actAsAdmin($this); + + Livewire::test(ProductForm::class) + ->set('title', 'Canvas Tote') + ->set('status', 'active') + ->set('vendor', 'Acme') + ->set('productType', 'Accessories') + ->set('priceAmount', 2500) + ->set('sku', 'BAG-TOTE') + ->set('quantityOnHand', 8) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::query()->where('title', 'Canvas Tote')->firstOrFail(); + $variant = $product->variants()->with('inventoryItem')->firstOrFail(); + + expect($product->handle)->toBe('canvas-tote') + ->and($variant->price_amount)->toBe(2500) + ->and($variant->sku)->toBe('BAG-TOTE') + ->and($variant->inventoryItem->quantity_on_hand)->toBe(8); +}); + +test('admin can confirm a pending bank transfer order', function (): void { + actAsAdmin($this); + + $order = Order::query()->where('order_number', '#1002')->firstOrFail(); + + Livewire::test(OrderShow::class, ['order' => $order]) + ->call('confirmBankTransfer') + ->assertHasNoErrors(); + + expect($order->fresh()->financial_status)->toBe(FinancialStatus::Paid); +}); + +test('admin can update store settings', function (): void { + actAsAdmin($this); + + Livewire::test(SettingsIndex::class) + ->set('name', 'Acme Admin Store') + ->set('defaultCurrency', 'EUR') + ->set('defaultLocale', 'en') + ->set('timezone', 'Europe/Berlin') + ->set('status', 'active') + ->call('save') + ->assertHasNoErrors(); + + expect($this->store->fresh()->name)->toBe('Acme Admin Store') + ->and($this->store->fresh()->timezone)->toBe('Europe/Berlin'); +}); From 45de605504016554bd982b9743543c990327a465 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 3 May 2026 16:05:29 +0200 Subject: [PATCH 12/35] Build storefront search --- .../Api/Storefront/SearchController.php | 88 ++++ .../Requests/Storefront/SearchRequest.php | 38 ++ .../Storefront/SearchSuggestRequest.php | 26 + .../Storefront/ProductSearchResource.php | 45 ++ app/Livewire/Admin/Search/Settings.php | 51 +- app/Livewire/Storefront/Search/Index.php | 84 +++- app/Livewire/Storefront/Search/Modal.php | 38 ++ app/Models/SearchQuery.php | 42 ++ app/Models/SearchSettings.php | 56 +++ app/Models/Store.php | 16 + app/Observers/ProductObserver.php | 29 ++ app/Providers/AppServiceProvider.php | 8 + app/Services/SearchService.php | 476 ++++++++++++++++++ database/factories/SearchQueryFactory.php | 27 + database/factories/SearchSettingsFactory.php | 26 + ...05_03_134255_create_products_fts_table.php | 35 ++ ..._03_134255_create_search_queries_table.php | 35 ++ ...03_134255_create_search_settings_table.php | 29 ++ database/seeders/DatabaseSeeder.php | 1 + database/seeders/SearchQuerySeeder.php | 16 + database/seeders/SearchSettingsSeeder.php | 34 ++ .../livewire/admin/search/settings.blade.php | 7 +- .../storefront/search/index.blade.php | 100 +++- .../storefront/search/modal.blade.php | 52 ++ .../views/storefront/layouts/app.blade.php | 2 +- routes/api.php | 6 + specs/progress.md | 22 +- tests/Feature/Search/SearchTest.php | 128 +++++ 28 files changed, 1465 insertions(+), 52 deletions(-) create mode 100644 app/Http/Controllers/Api/Storefront/SearchController.php create mode 100644 app/Http/Requests/Storefront/SearchRequest.php create mode 100644 app/Http/Requests/Storefront/SearchSuggestRequest.php create mode 100644 app/Http/Resources/Storefront/ProductSearchResource.php create mode 100644 app/Livewire/Storefront/Search/Modal.php create mode 100644 app/Models/SearchQuery.php create mode 100644 app/Models/SearchSettings.php create mode 100644 app/Observers/ProductObserver.php create mode 100644 app/Services/SearchService.php create mode 100644 database/factories/SearchQueryFactory.php create mode 100644 database/factories/SearchSettingsFactory.php create mode 100644 database/migrations/2026_05_03_134255_create_products_fts_table.php create mode 100644 database/migrations/2026_05_03_134255_create_search_queries_table.php create mode 100644 database/migrations/2026_05_03_134255_create_search_settings_table.php create mode 100644 database/seeders/SearchQuerySeeder.php create mode 100644 database/seeders/SearchSettingsSeeder.php create mode 100644 resources/views/livewire/storefront/search/modal.blade.php create mode 100644 tests/Feature/Search/SearchTest.php diff --git a/app/Http/Controllers/Api/Storefront/SearchController.php b/app/Http/Controllers/Api/Storefront/SearchController.php new file mode 100644 index 00000000..b84a3f2a --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/SearchController.php @@ -0,0 +1,88 @@ +currentStore(); + $query = trim((string) $request->validated('q', '')); + $filters = $this->filters($request); + $sort = (string) $request->validated('sort', 'relevance'); + $perPage = (int) $request->validated('per_page', 24); + $products = $search->search($store, $query, $filters, $perPage, $sort); + + return response()->json([ + 'query' => $query, + 'results' => ProductSearchResource::collection($products->getCollection())->resolve(), + 'facets' => $search->facets($store, $query), + 'pagination' => [ + 'current_page' => $products->currentPage(), + 'last_page' => $products->lastPage(), + 'per_page' => $products->perPage(), + 'total' => $products->total(), + ], + ]); + } + + public function suggest(SearchSuggestRequest $request, SearchService $search): JsonResponse + { + $query = trim((string) $request->validated('q')); + + return response()->json([ + 'query' => $query, + 'suggestions' => $search->autocomplete( + $this->currentStore(), + $query, + (int) $request->validated('limit', 5), + ), + ]); + } + + private function currentStore(): Store + { + return app('current_store'); + } + + /** + * @return array + */ + private function filters(SearchRequest $request): array + { + $filters = $request->validated('filters', []); + + if (is_string($filters) && trim($filters) !== '') { + $decoded = json_decode($filters, true); + + if (! is_array($decoded)) { + throw ValidationException::withMessages([ + 'filters' => ['The filters field must be valid JSON.'], + ]); + } + + $filters = $decoded; + } + + if (! is_array($filters)) { + $filters = []; + } + + foreach (['vendor', 'product_type', 'collection_id', 'price_min', 'price_max', 'in_stock', 'tags'] as $key) { + if ($request->filled($key)) { + $filters[$key] = $request->validated($key); + } + } + + return $filters; + } +} diff --git a/app/Http/Requests/Storefront/SearchRequest.php b/app/Http/Requests/Storefront/SearchRequest.php new file mode 100644 index 00000000..56f80c7f --- /dev/null +++ b/app/Http/Requests/Storefront/SearchRequest.php @@ -0,0 +1,38 @@ +|string> + */ + public function rules(): array + { + return [ + 'q' => ['nullable', 'string', 'max:200'], + 'filters' => ['nullable'], + 'vendor' => ['nullable', 'string', 'max:255'], + 'product_type' => ['nullable', 'string', 'max:255'], + 'collection_id' => ['nullable', 'integer', 'min:1'], + 'price_min' => ['nullable', 'integer', 'min:0'], + 'price_max' => ['nullable', 'integer', 'min:0'], + 'in_stock' => ['nullable', 'boolean'], + 'tags' => ['nullable', 'array'], + 'tags.*' => ['string', 'max:255'], + 'sort' => ['nullable', Rule::in(['relevance', 'price_asc', 'price_desc', 'newest', 'best_selling'])], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:50'], + ]; + } +} diff --git a/app/Http/Requests/Storefront/SearchSuggestRequest.php b/app/Http/Requests/Storefront/SearchSuggestRequest.php new file mode 100644 index 00000000..1441c389 --- /dev/null +++ b/app/Http/Requests/Storefront/SearchSuggestRequest.php @@ -0,0 +1,26 @@ +|string> + */ + public function rules(): array + { + return [ + 'q' => ['required', 'string', 'max:100'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:10'], + ]; + } +} diff --git a/app/Http/Resources/Storefront/ProductSearchResource.php b/app/Http/Resources/Storefront/ProductSearchResource.php new file mode 100644 index 00000000..473e3b26 --- /dev/null +++ b/app/Http/Resources/Storefront/ProductSearchResource.php @@ -0,0 +1,45 @@ + + */ + public function toArray(Request $request): array + { + $this->resource->loadMissing('variants.inventoryItem', 'media'); + + /** @var ProductVariant|null $variant */ + $variant = $this->resource->variants + ->sortBy([ + ['is_default', 'desc'], + ['position', 'asc'], + ]) + ->first(); + $media = $this->resource->media->sortBy('position')->first(); + + return [ + 'id' => $this->id, + 'title' => $this->title, + 'handle' => $this->handle, + 'url' => route('storefront.products.show', $this->handle), + 'vendor' => $this->vendor, + 'product_type' => $this->product_type, + 'price_amount' => $variant?->price_amount, + 'compare_at_amount' => $variant?->compare_at_amount, + 'currency' => $variant?->currency, + 'image_url' => $media instanceof ProductMedia ? asset('storage/'.$media->storage_key) : null, + 'in_stock' => $this->resource->variants->contains(fn (ProductVariant $variant): bool => ($variant->inventoryItem?->availableQuantity() ?? 0) > 0), + 'tags' => $this->tags ?? [], + ]; + } +} diff --git a/app/Livewire/Admin/Search/Settings.php b/app/Livewire/Admin/Search/Settings.php index 589d90ab..45d564a7 100644 --- a/app/Livewire/Admin/Search/Settings.php +++ b/app/Livewire/Admin/Search/Settings.php @@ -3,7 +3,8 @@ namespace App\Livewire\Admin\Search; use App\Livewire\Admin\Concerns\UsesAdminStore; -use App\Models\StoreSettings; +use App\Models\SearchSettings; +use App\Services\SearchService; use Illuminate\View\View; use Livewire\Component; @@ -11,31 +12,41 @@ class Settings extends Component { use UsesAdminStore; - public string $synonyms = ''; + public string $synonymGroups = ''; public string $stopWords = ''; + public ?string $lastIndexedAt = null; + public function mount(): void { - $settings = $this->settings()->settings_json; - $this->synonyms = implode("\n", data_get($settings, 'search.synonyms', [])); - $this->stopWords = implode("\n", data_get($settings, 'search.stop_words', [])); + $settings = $this->settings(); + $this->synonymGroups = collect($settings->synonyms_json) + ->map(fn (array $group): string => implode(', ', $group)) + ->implode("\n"); + $this->stopWords = implode("\n", $settings->stop_words_json ?? []); + $this->lastIndexedAt = $settings->updated_at?->diffForHumans(); } public function save(): void { $settings = $this->settings(); - $payload = $settings->settings_json; - data_set($payload, 'search.synonyms', $this->lines($this->synonyms)); - data_set($payload, 'search.stop_words', $this->lines($this->stopWords)); - $settings->forceFill(['settings_json' => $payload])->save(); + $settings->forceFill([ + 'synonyms_json' => $this->synonyms(), + 'stop_words_json' => $this->lines($this->stopWords), + ])->save(); + + $this->lastIndexedAt = $settings->fresh()->updated_at?->diffForHumans(); $this->notify('Search settings saved.'); } public function reindex(): void { - $this->notify('Search index queued.'); + $count = app(SearchService::class)->reindexStore($this->currentStore()); + $this->lastIndexedAt = $this->settings()->fresh()->updated_at?->diffForHumans(); + + $this->notify("Search index rebuilt for {$count} products."); } public function render(): View @@ -45,9 +56,9 @@ public function render(): View ]); } - private function settings(): StoreSettings + private function settings(): SearchSettings { - return StoreSettings::query()->firstOrCreate(['store_id' => $this->currentStore()->id]); + return SearchSettings::query()->firstOrCreate(['store_id' => $this->currentStore()->id]); } /** @@ -61,4 +72,20 @@ private function lines(string $value): array ->values() ->all(); } + + /** + * @return list> + */ + private function synonyms(): array + { + return collect(explode("\n", $this->synonymGroups)) + ->map(fn (string $line): array => collect(explode(',', $line)) + ->map(fn (string $term): string => trim($term)) + ->filter() + ->values() + ->all()) + ->filter(fn (array $group): bool => count($group) > 1) + ->values() + ->all(); + } } diff --git a/app/Livewire/Storefront/Search/Index.php b/app/Livewire/Storefront/Search/Index.php index f008c1d2..8f32f63f 100644 --- a/app/Livewire/Storefront/Search/Index.php +++ b/app/Livewire/Storefront/Search/Index.php @@ -2,40 +2,90 @@ namespace App\Livewire\Storefront\Search; -use App\Enums\ProductStatus; -use App\Models\Product; +use App\Services\SearchService; use Illuminate\View\View; use Livewire\Component; +use Livewire\WithPagination; class Index extends Component { + use WithPagination; + public string $q = ''; + public string $vendor = ''; + + public string $productType = ''; + + public string $minPrice = ''; + + public string $maxPrice = ''; + + public bool $inStock = false; + + public string $sort = 'relevance'; + protected $queryString = [ 'q' => ['except' => ''], + 'vendor' => ['except' => ''], + 'productType' => ['except' => ''], + 'minPrice' => ['except' => ''], + 'maxPrice' => ['except' => ''], + 'inStock' => ['except' => false], + 'sort' => ['except' => 'relevance'], ]; - public function render(): View + public function updated(string $property, mixed $value): void { - $query = Product::query() - ->with('variants', 'media') - ->where('status', ProductStatus::Active) - ->whereNotNull('published_at'); - - if (trim($this->q) !== '') { - $term = '%'.str_replace('%', '\\%', trim($this->q)).'%'; - - $query->where(function ($query) use ($term): void { - $query->where('title', 'like', $term) - ->orWhere('vendor', 'like', $term) - ->orWhere('product_type', 'like', $term); - }); + if ($property !== 'page') { + $this->resetPage(); } + } + + public function clearFilters(): void + { + $this->reset('vendor', 'productType', 'minPrice', 'maxPrice', 'inStock', 'sort'); + $this->resetPage(); + } + + public function render(): View + { + $store = app('current_store'); + $search = app(SearchService::class); return view('livewire.storefront.search.index', [ - 'products' => $query->latest('published_at')->get(), + 'products' => $search->search($store, $this->q, $this->filters(), 24, $this->sort), + 'facets' => $search->facets($store, $this->q), + 'store' => $store, ])->layout('storefront.layouts.app', [ 'title' => 'Search', ]); } + + /** + * @return array + */ + private function filters(): array + { + return collect([ + 'vendor' => $this->vendor, + 'product_type' => $this->productType, + 'price_min' => $this->priceToCents($this->minPrice), + 'price_max' => $this->priceToCents($this->maxPrice), + 'in_stock' => $this->inStock, + ]) + ->reject(fn (mixed $value): bool => $value === '' || $value === null || $value === false) + ->all(); + } + + private function priceToCents(string $value): ?int + { + $value = trim(str_replace(',', '.', $value)); + + if ($value === '') { + return null; + } + + return max(0, (int) round(((float) $value) * 100)); + } } diff --git a/app/Livewire/Storefront/Search/Modal.php b/app/Livewire/Storefront/Search/Modal.php new file mode 100644 index 00000000..34f984b4 --- /dev/null +++ b/app/Livewire/Storefront/Search/Modal.php @@ -0,0 +1,38 @@ +isOpen = true; + } + + public function close(): void + { + $this->isOpen = false; + $this->q = ''; + } + + public function render(): View + { + $suggestions = $this->isOpen + ? app(SearchService::class)->autocomplete(app('current_store'), $this->q, 6) + : collect(); + + return view('livewire.storefront.search.modal', [ + 'suggestions' => $suggestions, + ]); + } +} diff --git a/app/Models/SearchQuery.php b/app/Models/SearchQuery.php new file mode 100644 index 00000000..474c438d --- /dev/null +++ b/app/Models/SearchQuery.php @@ -0,0 +1,42 @@ + */ + use BelongsToStore, HasFactory; + + public const UPDATED_AT = null; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'query', + 'filters_json', + 'results_count', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'results_count' => 0, + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'filters_json' => 'array', + ]; + } +} diff --git a/app/Models/SearchSettings.php b/app/Models/SearchSettings.php new file mode 100644 index 00000000..674c3488 --- /dev/null +++ b/app/Models/SearchSettings.php @@ -0,0 +1,56 @@ + */ + use HasFactory; + + public const CREATED_AT = null; + + protected $primaryKey = 'store_id'; + + public $incrementing = false; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'synonyms_json', + 'stop_words_json', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'synonyms_json' => '[]', + 'stop_words_json' => '[]', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'synonyms_json' => 'array', + 'stop_words_json' => 'array', + 'updated_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/Store.php b/app/Models/Store.php index b968682d..f8dfb15b 100644 --- a/app/Models/Store.php +++ b/app/Models/Store.php @@ -82,6 +82,14 @@ public function settings(): HasOne return $this->hasOne(StoreSettings::class); } + /** + * @return HasOne + */ + public function searchSettings(): HasOne + { + return $this->hasOne(SearchSettings::class); + } + /** * @return HasMany */ @@ -186,6 +194,14 @@ public function discounts(): HasMany return $this->hasMany(Discount::class); } + /** + * @return HasMany + */ + public function searchQueries(): HasMany + { + return $this->hasMany(SearchQuery::class); + } + public function isSuspended(): bool { return $this->status === StoreStatus::Suspended; diff --git a/app/Observers/ProductObserver.php b/app/Observers/ProductObserver.php new file mode 100644 index 00000000..68ae27c9 --- /dev/null +++ b/app/Observers/ProductObserver.php @@ -0,0 +1,29 @@ +syncProduct($product); + } + + public function updated(Product $product): void + { + app(SearchService::class)->syncProduct($product); + } + + public function deleted(Product $product): void + { + app(SearchService::class)->removeProduct($product->id); + } + + public function forceDeleted(Product $product): void + { + app(SearchService::class)->removeProduct($product->id); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d2f87377..d526a57d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,6 +4,8 @@ use App\Contracts\PaymentProvider; use App\Http\Middleware\ResolveStore; +use App\Models\Product; +use App\Observers\ProductObserver; use App\Services\Payments\MockPaymentProvider; use App\Services\ThemeSettingsService; use Carbon\CarbonImmutable; @@ -36,6 +38,8 @@ public function boot(): void ResolveStore::class, ]); + Product::observe(ProductObserver::class); + $this->configureDefaults(); $this->configureRateLimiting(); } @@ -75,5 +79,9 @@ protected function configureRateLimiting(): void RateLimiter::for('checkout', function (Request $request) { return Limit::perMinute(10)->by($request->hasSession() ? $request->session()->getId() : $request->ip()); }); + + RateLimiter::for('search', function (Request $request) { + return Limit::perMinute(30)->by($request->ip()); + }); } } diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php new file mode 100644 index 00000000..a8478ec1 --- /dev/null +++ b/app/Services/SearchService.php @@ -0,0 +1,476 @@ + $filters + * @return LengthAwarePaginator + */ + public function search(Store $store, string $query, array $filters = [], int $perPage = 24, string $sort = 'relevance'): LengthAwarePaginator + { + $normalizedQuery = trim($query); + $match = $this->matchQuery($store, $normalizedQuery); + $rankedIds = $match ? $this->matchingProductIds($store, $match) : collect(); + + if ($normalizedQuery !== '' && $rankedIds->isEmpty()) { + $paginator = $this->emptyPaginator($perPage); + $this->logQuery($store, $normalizedQuery, $filters, $paginator->total()); + + return $paginator; + } + + $products = $this->visibleProductQuery($store) + ->with([ + 'media' => fn ($query) => $query->oldest('position'), + 'variants' => fn ($query) => $query + ->with('inventoryItem') + ->where('status', VariantStatus::Active) + ->orderByDesc('is_default') + ->oldest('position'), + ]); + + if ($rankedIds->isNotEmpty()) { + $products->whereIn((new Product)->getTable().'.id', $rankedIds->all()); + } + + $this->applyFilters($products, $filters); + $this->applySort($products, $sort, $rankedIds); + + $paginator = $products->paginate($perPage)->withQueryString(); + + if ($normalizedQuery !== '') { + $this->logQuery($store, $normalizedQuery, $filters, $paginator->total()); + } + + return $paginator; + } + + /** + * @return Collection + */ + public function autocomplete(Store $store, string $prefix, int $limit = 5): Collection + { + $prefix = trim($prefix); + + if ($prefix === '') { + return collect(); + } + + $match = $this->matchQuery($store, $prefix); + $productIds = $match ? $this->matchingProductIds($store, $match, $limit) : collect(); + + $products = $productIds->isEmpty() + ? collect() + : $this->visibleProductQuery($store) + ->whereIn((new Product)->getTable().'.id', $productIds->all()) + ->get() + ->sortBy(fn (Product $product): int => $productIds->search($product->id)) + ->values() + ->map(fn (Product $product): array => [ + 'type' => 'product', + 'title' => $product->title, + 'subtitle' => $product->vendor, + 'url' => route('storefront.products.show', $product->handle), + ]); + + $remaining = max(0, $limit - $products->count()); + $collections = $remaining === 0 ? collect() : ProductCollection::query() + ->withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('status', CollectionStatus::Active) + ->where(function (Builder $query) use ($prefix): void { + $term = $this->likeTerm($prefix); + $query->where('title', 'like', $term) + ->orWhere('handle', 'like', $term); + }) + ->oldest('title') + ->limit($remaining) + ->get() + ->map(fn (ProductCollection $collection): array => [ + 'type' => 'collection', + 'title' => $collection->title, + 'subtitle' => 'Collection', + 'url' => route('storefront.collections.show', $collection->handle), + ]); + + return $products->concat($collections)->values(); + } + + public function syncProduct(Product $product): void + { + $product = Product::withoutGlobalScopes()->find($product->id); + + if (! $product instanceof Product) { + return; + } + + $this->removeProduct($product->id); + + DB::insert( + 'INSERT INTO products_fts(rowid, store_id, product_id, title, description, vendor, product_type, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [ + $product->id, + $product->store_id, + $product->id, + $product->title ?? '', + strip_tags($product->description_html ?? ''), + $product->vendor ?? '', + $product->product_type ?? '', + implode(' ', $product->tags ?? []), + ], + ); + } + + public function removeProduct(int $productId): void + { + DB::delete('DELETE FROM products_fts WHERE product_id = ?', [$productId]); + } + + public function reindexStore(Store $store): int + { + DB::delete('DELETE FROM products_fts WHERE store_id = ?', [$store->id]); + + $count = 0; + + Product::withoutGlobalScopes() + ->where('store_id', $store->id) + ->orderBy('id') + ->each(function (Product $product) use (&$count): void { + $this->syncProduct($product); + $count++; + }); + + SearchSettings::query()->firstOrCreate(['store_id' => $store->id])->touch(); + + return $count; + } + + /** + * @return array + */ + public function facets(Store $store, string $query = ''): array + { + $match = $this->matchQuery($store, trim($query)); + $rankedIds = $match ? $this->matchingProductIds($store, $match, 1000) : collect(); + + if (trim($query) !== '' && ($match === null || $rankedIds->isEmpty())) { + return $this->emptyFacets(); + } + + $products = $this->visibleProductQuery($store) + ->with(['variants' => fn ($query) => $query->where('status', VariantStatus::Active), 'variants.inventoryItem']) + ->when($rankedIds->isNotEmpty(), fn (Builder $query) => $query->whereIn((new Product)->getTable().'.id', $rankedIds->all())) + ->get(); + + $prices = $products + ->flatMap(fn (Product $product) => $product->variants->pluck('price_amount')) + ->filter(fn (int $price): bool => $price > 0); + + return [ + 'vendors' => $products + ->pluck('vendor') + ->filter() + ->countBy() + ->sortKeys() + ->map(fn (int $count, string $value): array => ['value' => $value, 'count' => $count]) + ->values() + ->all(), + 'product_types' => $products + ->pluck('product_type') + ->filter() + ->countBy() + ->sortKeys() + ->map(fn (int $count, string $value): array => ['value' => $value, 'count' => $count]) + ->values() + ->all(), + 'tags' => $products + ->flatMap(fn (Product $product): array => $product->tags ?? []) + ->filter() + ->countBy() + ->sortKeys() + ->map(fn (int $count, string $value): array => ['value' => $value, 'count' => $count]) + ->values() + ->all(), + 'price' => [ + 'min' => $prices->min(), + 'max' => $prices->max(), + ], + ]; + } + + /** + * @return Builder + */ + private function visibleProductQuery(Store $store): Builder + { + return Product::query() + ->withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('status', ProductStatus::Active) + ->whereNotNull('published_at'); + } + + /** + * @param Builder $products + * @param array $filters + */ + private function applyFilters(Builder $products, array $filters): void + { + $vendor = trim((string) ($filters['vendor'] ?? '')); + $productType = trim((string) ($filters['product_type'] ?? '')); + $collectionId = (int) ($filters['collection_id'] ?? 0); + $minimumPrice = $this->integerFilter($filters['price_min'] ?? null); + $maximumPrice = $this->integerFilter($filters['price_max'] ?? null); + $inStock = filter_var($filters['in_stock'] ?? false, FILTER_VALIDATE_BOOLEAN); + $tags = collect((array) ($filters['tags'] ?? [])) + ->map(fn (mixed $tag): string => trim((string) $tag)) + ->filter() + ->values(); + + if ($vendor !== '') { + $products->where('vendor', $vendor); + } + + if ($productType !== '') { + $products->where('product_type', $productType); + } + + if ($collectionId > 0) { + $products->whereHas('collections', fn (Builder $query) => $query->whereKey($collectionId)); + } + + if ($minimumPrice !== null || $maximumPrice !== null) { + $products->whereHas('variants', function (Builder $query) use ($minimumPrice, $maximumPrice): void { + $query->where('status', VariantStatus::Active); + + if ($minimumPrice !== null) { + $query->where('price_amount', '>=', $minimumPrice); + } + + if ($maximumPrice !== null) { + $query->where('price_amount', '<=', $maximumPrice); + } + }); + } + + if ($inStock) { + $products->whereHas('variants', function (Builder $query): void { + $query->where('status', VariantStatus::Active) + ->whereHas('inventoryItem', function (Builder $query): void { + $query->whereRaw('quantity_on_hand - quantity_reserved > 0'); + }); + }); + } + + foreach ($tags as $tag) { + $products->whereJsonContains('tags', $tag); + } + } + + /** + * @param Builder $products + * @param Collection $rankedIds + */ + private function applySort(Builder $products, string $sort, Collection $rankedIds): void + { + if ($sort === 'price_asc' || $sort === 'price_desc') { + $products + ->withMin(['variants as search_price_amount' => fn (Builder $query) => $query->where('status', VariantStatus::Active)], 'price_amount') + ->orderBy('search_price_amount', $sort === 'price_asc' ? 'asc' : 'desc') + ->orderBy('title'); + + return; + } + + if ($sort === 'newest') { + $products->latest('published_at')->latest('id'); + + return; + } + + if ($rankedIds->isNotEmpty()) { + $case = $rankedIds + ->values() + ->map(fn (int $id, int $position): string => 'WHEN '.$id.' THEN '.$position) + ->implode(' '); + + $products->orderByRaw('CASE '.(new Product)->getTable().".id {$case} ELSE 999999 END"); + + return; + } + + $products->latest('published_at')->latest('id'); + } + + private function integerFilter(mixed $value): ?int + { + if ($value === null || $value === '') { + return null; + } + + return max(0, (int) $value); + } + + private function likeTerm(string $value): string + { + return '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], trim($value)).'%'; + } + + private function matchQuery(Store $store, string $query): ?string + { + $tokens = $this->tokens($query); + + if ($tokens->isEmpty()) { + return null; + } + + $settings = SearchSettings::query()->where('store_id', $store->id)->first(); + $stopWords = collect($settings?->stop_words_json ?? []) + ->map(fn (mixed $word) => $this->tokens((string) $word)->first()) + ->filter() + ->flip(); + $synonyms = $this->synonymMap($settings?->synonyms_json ?? []); + + $tokens = $tokens + ->reject(fn (string $token): bool => $stopWords->has($token)) + ->values(); + + if ($tokens->isEmpty()) { + return null; + } + + $lastIndex = $tokens->count() - 1; + + return $tokens + ->map(function (string $token, int $index) use ($synonyms, $lastIndex): string { + $suffix = $index === $lastIndex ? '*' : ''; + $terms = collect([$token]) + ->concat($synonyms[$token] ?? []) + ->unique() + ->map(fn (string $term): string => $term.$suffix) + ->values(); + + return $terms->count() > 1 + ? '('.$terms->implode(' OR ').')' + : (string) $terms->first(); + }) + ->implode(' '); + } + + /** + * @return Collection + */ + private function tokens(string $query): Collection + { + preg_match_all('/[\p{L}\p{N}_]+/u', Str::lower(Str::ascii($query)), $matches); + + return collect($matches[0] ?? []) + ->map(fn (string $token): string => trim($token)) + ->filter() + ->values(); + } + + /** + * @param array $groups + * @return array> + */ + private function synonymMap(array $groups): array + { + $map = []; + + foreach ($groups as $group) { + $tokens = collect((array) $group) + ->flatMap(fn (mixed $term): Collection => $this->tokens((string) $term)) + ->unique() + ->values(); + + foreach ($tokens as $token) { + $map[$token] = $tokens + ->reject(fn (string $synonym): bool => $synonym === $token) + ->values() + ->all(); + } + } + + return $map; + } + + /** + * @return Collection + */ + private function matchingProductIds(Store $store, string $match, int $limit = 500): Collection + { + return DB::table('products_fts') + ->select('product_id') + ->where('store_id', $store->id) + ->whereRaw('products_fts MATCH ?', [$match]) + ->orderBy('rank') + ->limit($limit) + ->pluck('product_id') + ->map(fn (mixed $productId): int => (int) $productId) + ->values(); + } + + /** + * @param array $filters + */ + private function logQuery(Store $store, string $query, array $filters, int $resultsCount): void + { + SearchQuery::query()->create([ + 'store_id' => $store->id, + 'query' => $query, + 'filters_json' => $filters === [] ? null : $filters, + 'results_count' => $resultsCount, + ]); + } + + /** + * @return LengthAwarePaginator + */ + private function emptyPaginator(int $perPage): LengthAwarePaginator + { + return new Paginator( + collect(), + 0, + $perPage, + Paginator::resolveCurrentPage(), + [ + 'path' => request()->url(), + 'query' => request()->query(), + ], + ); + } + + /** + * @return array + */ + private function emptyFacets(): array + { + return [ + 'vendors' => [], + 'product_types' => [], + 'tags' => [], + 'price' => [ + 'min' => null, + 'max' => null, + ], + ]; + } +} diff --git a/database/factories/SearchQueryFactory.php b/database/factories/SearchQueryFactory.php new file mode 100644 index 00000000..c52398b7 --- /dev/null +++ b/database/factories/SearchQueryFactory.php @@ -0,0 +1,27 @@ + + */ +class SearchQueryFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'query' => fake()->words(2, true), + 'filters_json' => [], + 'results_count' => fake()->numberBetween(0, 24), + ]; + } +} diff --git a/database/factories/SearchSettingsFactory.php b/database/factories/SearchSettingsFactory.php new file mode 100644 index 00000000..7c34d82b --- /dev/null +++ b/database/factories/SearchSettingsFactory.php @@ -0,0 +1,26 @@ + + */ +class SearchSettingsFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'synonyms_json' => [], + 'stop_words_json' => [], + ]; + } +} diff --git a/database/migrations/2026_05_03_134255_create_products_fts_table.php b/database/migrations/2026_05_03_134255_create_products_fts_table.php new file mode 100644 index 00000000..4ebfdefe --- /dev/null +++ b/database/migrations/2026_05_03_134255_create_products_fts_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->text('query'); + $table->json('filters_json')->nullable(); + $table->integer('results_count')->default(0); + $table->timestamp('created_at')->nullable(); + + $table->index('store_id'); + $table->index(['store_id', 'created_at'], 'search_queries_store_created_index'); + $table->index(['store_id', 'query'], 'search_queries_store_query_index'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('search_queries'); + } +}; diff --git a/database/migrations/2026_05_03_134255_create_search_settings_table.php b/database/migrations/2026_05_03_134255_create_search_settings_table.php new file mode 100644 index 00000000..b2fce202 --- /dev/null +++ b/database/migrations/2026_05_03_134255_create_search_settings_table.php @@ -0,0 +1,29 @@ +foreignId('store_id')->primary()->constrained()->cascadeOnDelete(); + $table->json('synonyms_json')->default('[]'); + $table->json('stop_words_json')->default('[]'); + $table->timestamp('updated_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('search_settings'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 3ef1829a..3653acc1 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -20,6 +20,7 @@ public function run(): void StoreSettingsSeeder::class, CollectionSeeder::class, ProductSeeder::class, + SearchSettingsSeeder::class, ShippingZoneSeeder::class, TaxSettingsSeeder::class, DiscountSeeder::class, diff --git a/database/seeders/SearchQuerySeeder.php b/database/seeders/SearchQuerySeeder.php new file mode 100644 index 00000000..7f9ff1a4 --- /dev/null +++ b/database/seeders/SearchQuerySeeder.php @@ -0,0 +1,16 @@ +where('handle', 'acme-fashion')->firstOrFail(); + + SearchSettings::query()->updateOrCreate( + ['store_id' => $store->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'], + ], + ); + + app(SearchService::class)->reindexStore($store); + } +} diff --git a/resources/views/livewire/admin/search/settings.blade.php b/resources/views/livewire/admin/search/settings.blade.php index 3fc63f67..669f8d22 100644 --- a/resources/views/livewire/admin/search/settings.blade.php +++ b/resources/views/livewire/admin/search/settings.blade.php @@ -3,6 +3,9 @@
Search settings Synonyms, stop words, and index maintenance. + @if($lastIndexedAt) + Last indexed {{ $lastIndexedAt }}. + @endif
@@ -13,10 +16,10 @@
- +
- +
diff --git a/resources/views/livewire/storefront/search/index.blade.php b/resources/views/livewire/storefront/search/index.blade.php index 8baf69c4..be44083f 100644 --- a/resources/views/livewire/storefront/search/index.blade.php +++ b/resources/views/livewire/storefront/search/index.blade.php @@ -1,17 +1,89 @@ -
-

Search

- - -
- @forelse($products as $product) - @include('storefront.components.product-card', ['product' => $product]) - @empty -
- No products found. +
+
+
+

Search

+

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

+
+ +
+ +
+ + +
+
+ +
+ +
+ @forelse($products as $product) + @include('storefront.components.product-card', ['product' => $product]) + @empty +
+ No products found. +
+ @endforelse +
+ +
+ {{ $products->links() }}
- @endforelse +
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..ce77236f --- /dev/null +++ b/resources/views/livewire/storefront/search/modal.blade.php @@ -0,0 +1,52 @@ +
+ + + @teleport('body') +
+ @if($isOpen) +
+
+
+ + +
+ +
+ @if(trim($q) === '') +
+ Start typing to search products and collections. +
+ @elseif($suggestions->isEmpty()) +
+ No suggestions found. +
+ @else + + + + View all results + + @endif +
+
+
+ @endif +
+ @endteleport +
diff --git a/resources/views/storefront/layouts/app.blade.php b/resources/views/storefront/layouts/app.blade.php index 00c6571e..6a616532 100644 --- a/resources/views/storefront/layouts/app.blade.php +++ b/resources/views/storefront/layouts/app.blade.php @@ -70,7 +70,7 @@
- Search + @livewire('storefront.search.modal') Account Cart
diff --git a/routes/api.php b/routes/api.php index e6c6fb2d..148f3b8f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,11 +2,17 @@ use App\Http\Controllers\Api\Storefront\CartController; use App\Http\Controllers\Api\Storefront\CheckoutController; +use App\Http\Controllers\Api\Storefront\SearchController; use Illuminate\Support\Facades\Route; Route::prefix('storefront/v1') ->middleware(['store.resolve', 'throttle:api.storefront']) ->group(function (): void { + Route::middleware('throttle:search')->group(function (): void { + Route::get('/search', [SearchController::class, 'index'])->name('api.storefront.search.index'); + Route::get('/search/suggest', [SearchController::class, 'suggest'])->name('api.storefront.search.suggest'); + }); + Route::post('/carts', [CartController::class, 'store'])->name('api.storefront.carts.store'); Route::get('/carts/{cartId}', [CartController::class, 'show'])->name('api.storefront.carts.show'); Route::post('/carts/{cartId}/lines', [CartController::class, 'storeLine'])->name('api.storefront.carts.lines.store'); diff --git a/specs/progress.md b/specs/progress.md index bba2341b..8f2163e6 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -16,7 +16,8 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Phase 5 payments/orders/customer persistence is implemented and verified: customers, customer addresses, orders, order lines, payments, refunds, fulfillments, mock PSP, checkout pay endpoint/UI, order confirmation, bank-transfer confirmation/cancellation services, and focused tests. - Phase 6 customer accounts are implemented and verified: store-scoped customer login/registration, account dashboard, order history/detail pages, address book CRUD, and seeded customer credentials. - Phase 7 admin panel is implemented and verified: admin login, admin shell/store switcher, dashboard, product/order/customer/discount/inventory/settings/theme/page/navigation surfaces, basic analytics, apps, developer, and search-settings pages. -- SQLite search indexing, analytics ingestion, API tokens, app installs, and webhooks are not implemented yet. +- SQLite FTS5 search is implemented and verified: search settings/query tables, product FTS indexing, product observer sync, storefront search page, header search modal, search API, seeded synonyms/stop words, and admin reindex action. +- Analytics ingestion, API tokens, app installs, and webhooks are not implemented yet. ## Execution Plan @@ -42,9 +43,10 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit 7. **Admin panel** - Implemented and verified - Add admin shell, dashboard, resource management pages, settings, themes, pages, navigation, analytics, apps, and developers surfaces. - Verify admin login, store switching, product/order/discount/settings flows. -8. **Search, analytics, apps, webhooks** - Pending - - Add SQLite FTS5 search, analytics ingestion/aggregation, API token support, app installs, webhook dispatch/signing/delivery. - - Verify API, search, analytics, and webhook tests. +8. **Search, analytics, apps, webhooks** - Partially implemented + - SQLite FTS5 search is implemented and verified. + - Analytics ingestion/aggregation, API token support, app installs, webhook dispatch/signing/delivery remain pending. + - Verify analytics and webhook tests once those slices land. 9. **Polish and completion audit** - Pending - Run full Pest suite, style formatting, fresh migration/seeding, Playwright customer/admin flows, responsive and browser log review. - Update this file with final evidence and close all gaps. @@ -67,13 +69,13 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit ## Open Gaps - Phase 2 still needs full media resizing variants and the richer multi-option variant builder. Admin product create/edit currently covers core product fields, default variant price/SKU, and stock. -- Phase 3 still needs search modal autocomplete, richer error templates, and fully configurable storefront section ordering. Basic theme editing and publishing now exist in the admin panel. +- Phase 3 still needs richer error templates and fully configurable storefront section ordering. Basic theme editing and publishing now exist in the admin panel; search modal autocomplete landed with the search slice. - Phase 5 backend bank-transfer confirmation, refunds, and fulfillment services now have admin order-detail actions. More granular partial-fulfillment UI can still be expanded during polish. - Phase 4 has a functional cart page and accessible cart count, but the richer slide-out cart drawer can be expanded during UI polish. - Discounts, shipping, and tax are implemented for the specified local/manual flows; provider/carrier integrations remain stubs by design. - Order-reference guards in product deletion/status logic are present but only become fully meaningful once `order_lines` exists in Phase 5. - Customer account password reset UI and emails remain deferred; login, registration, dashboard, order history/detail, and address book flows are implemented. -- Admin surfaces are implemented for the current data model; later search, analytics ingestion, apps, API tokens, and webhook backend phases remain unimplemented. +- Admin surfaces are implemented for the current data model; later analytics ingestion, apps, API tokens, and webhook backend phases remain unimplemented. - API token requirements mention Sanctum, but the package is not currently installed. This remains an open dependency decision for the API/developers phase because dependencies must not be changed without approval. ## Verification Log @@ -121,3 +123,11 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed with admin routes and seeded admin/customer/order/catalog data. - 2026-05-03: Playwright smoke completed admin login, dashboard, products list, and order detail at `http://shop.test/admin`; latest console check reported no new warnings or errors. - 2026-05-03: `browser_logs` reported no browser log file after the latest Phase 7 smoke check. +- 2026-05-03: `php artisan test --compact tests/Feature/Search/SearchTest.php` passed, 5 tests / 24 assertions. +- 2026-05-03: `php artisan test --compact tests/Feature/Search/SearchTest.php tests/Feature/Admin/AdminPanelTest.php tests/Feature/Storefront/StorefrontRenderTest.php` passed, 13 tests / 106 assertions. +- 2026-05-03: `php artisan route:list --path=api/storefront/v1/search --except-vendor` passed and showed the search and suggest API routes. +- 2026-05-03: `vendor/bin/pint --dirty --format agent` passed after the search changes. +- 2026-05-03: `php artisan test --compact` passed, 90 tests / 379 assertions. +- 2026-05-03: `npm run build` passed for the updated search UI assets. +- 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed with FTS5 search migrations, seeded search settings, and reindexed seeded products. +- 2026-05-03: Playwright smoke completed `/search?q=linen`, header search modal suggestions for `lin`, and `api/storefront/v1/search?q=linen` at `http://shop.test`; latest browser console checks reported no current warnings or errors. diff --git a/tests/Feature/Search/SearchTest.php b/tests/Feature/Search/SearchTest.php new file mode 100644 index 00000000..ef0f60ad --- /dev/null +++ b/tests/Feature/Search/SearchTest.php @@ -0,0 +1,128 @@ +seed(); + $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +}); + +test('search service uses the FTS index and logs storefront queries', function (): void { + $products = app(SearchService::class)->search($this->store, 'linen', [], 12); + + expect($products->total())->toBe(1) + ->and($products->getCollection()->first()->title)->toBe('Linen Shirt'); + + $query = SearchQuery::withoutGlobalScopes() + ->where('store_id', $this->store->id) + ->latest('id') + ->firstOrFail(); + + expect($query->query)->toBe('linen') + ->and($query->results_count)->toBe(1); +}); + +test('search applies synonyms and only returns published active products', function (): void { + Product::factory() + ->for($this->store) + ->draft() + ->create([ + 'title' => 'Hidden Trousers', + 'handle' => 'hidden-trousers', + 'vendor' => 'Acme Apparel', + 'product_type' => 'Pants', + ]); + + $synonymResults = app(SearchService::class)->search($this->store, 'tshirt', [], 12); + $hiddenResults = app(SearchService::class)->search($this->store, 'hidden', [], 12); + + expect($synonymResults->getCollection()->pluck('title')->all())->toContain('Logo Tee') + ->and($hiddenResults->total())->toBe(0); +}); + +test('product observer keeps the FTS table in sync', function (): void { + $product = Product::factory() + ->for($this->store) + ->create([ + 'title' => 'Canvas Bucket Bag', + 'handle' => 'canvas-bucket-bag', + 'vendor' => 'Acme Bags', + 'product_type' => 'Accessories', + ]); + + ProductVariant::factory() + ->default() + ->for($product) + ->create([ + 'price_amount' => 3900, + 'currency' => $this->store->default_currency, + ]); + + expect(app(SearchService::class)->search($this->store, 'bucket', [], 12)->total())->toBe(1); + + $product->update(['title' => 'Waxed Field Bag']); + + expect(app(SearchService::class)->search($this->store, 'bucket', [], 12)->total())->toBe(0) + ->and(app(SearchService::class)->search($this->store, 'waxed', [], 12)->total())->toBe(1); + + $product->delete(); + + expect(app(SearchService::class)->search($this->store, 'waxed', [], 12)->total())->toBe(0); +}); + +test('storefront search api returns results facets suggestions and pagination metadata', function (): void { + $this->getJson('http://shop.test/api/storefront/v1/search?q=linen') + ->assertOk() + ->assertJsonPath('query', 'linen') + ->assertJsonPath('results.0.title', 'Linen Shirt') + ->assertJsonPath('pagination.total', 1) + ->assertJsonPath('facets.vendors.0.value', 'Acme Apparel'); + + $this->getJson('http://shop.test/api/storefront/v1/search/suggest?q=lin') + ->assertOk() + ->assertJsonPath('suggestions.0.title', 'Linen Shirt') + ->assertJsonPath('suggestions.0.type', 'product'); +}); + +test('storefront search page and admin settings are livewire backed', function (): void { + app()->instance('current_store', $this->store); + + Livewire::test(StorefrontSearchIndex::class) + ->set('q', 'linen') + ->assertSee('Linen Shirt') + ->set('vendor', 'Acme Apparel') + ->assertSee('Linen Shirt'); + + $user = User::query()->where('email', 'admin@example.com')->firstOrFail(); + $this->actingAs($user); + session(['current_store_id' => $this->store->id]); + + Livewire::test(AdminSearchSettings::class) + ->set('synonymGroups', "bag, tote\njacket, coat") + ->set('stopWords', "foo\nbar") + ->call('save') + ->assertHasNoErrors() + ->call('reindex') + ->assertHasNoErrors(); + + $settings = SearchSettings::query()->where('store_id', $this->store->id)->firstOrFail(); + + expect($settings->synonyms_json)->toBe([ + ['bag', 'tote'], + ['jacket', 'coat'], + ])->and($settings->stop_words_json)->toBe(['foo', 'bar']); +}); From a237e19889e9798fefb9d6bb58aefcf5a258ba97 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 3 May 2026 16:36:44 +0200 Subject: [PATCH 13/35] Build analytics apps webhooks --- app/Events/ProductCreated.php | 15 ++ app/Events/ProductDeleted.php | 15 ++ app/Events/ProductUpdated.php | 15 ++ .../Api/Admin/AnalyticsSummaryController.php | 23 +++ .../Api/Storefront/AnalyticsController.php | 27 +++ app/Http/Middleware/AuthenticateApiToken.php | 42 +++++ .../Admin/AnalyticsSummaryRequest.php | 49 +++++ .../Storefront/AnalyticsEventsRequest.php | 72 ++++++++ app/Jobs/AggregateAnalytics.php | 25 +++ app/Jobs/DeliverWebhook.php | 96 ++++++++++ app/Listeners/DispatchWebhooks.php | 86 +++++++++ app/Livewire/Admin/Analytics/Index.php | 24 +-- app/Livewire/Admin/Apps/Index.php | 53 +++++- app/Livewire/Admin/Apps/Show.php | 17 +- app/Livewire/Admin/Developers/Index.php | 144 ++++++++++++++- app/Models/AnalyticsDaily.php | 59 ++++++ app/Models/AnalyticsEvent.php | 55 ++++++ app/Models/ApiToken.php | 65 +++++++ app/Models/App.php | 47 +++++ app/Models/AppInstallation.php | 73 ++++++++ app/Models/OauthClient.php | 51 ++++++ app/Models/OauthToken.php | 43 +++++ app/Models/Store.php | 40 ++++ app/Models/WebhookDelivery.php | 54 ++++++ app/Models/WebhookSubscription.php | 62 +++++++ app/Observers/ProductObserver.php | 7 + app/Providers/AppServiceProvider.php | 37 ++++ app/Services/AnalyticsService.php | 171 ++++++++++++++++++ app/Services/ApiTokenService.php | 58 ++++++ app/Services/WebhookService.php | 63 +++++++ bootstrap/app.php | 1 + database/factories/AnalyticsDailyFactory.php | 35 ++++ database/factories/AnalyticsEventFactory.php | 41 +++++ database/factories/ApiTokenFactory.php | 32 ++++ database/factories/AppFactory.php | 28 +++ database/factories/AppInstallationFactory.php | 29 +++ database/factories/OauthClientFactory.php | 28 +++ database/factories/OauthTokenFactory.php | 28 +++ database/factories/WebhookDeliveryFactory.php | 31 ++++ .../factories/WebhookSubscriptionFactory.php | 31 ++++ ...3_140833_create_analytics_events_table.php | 41 +++++ ..._140839_create_analytics_dailies_table.php | 37 ++++ .../2026_05_03_140849_create_apps_table.php | 32 ++++ ..._140858_create_app_installations_table.php | 35 ++++ ...5_03_140904_create_oauth_clients_table.php | 32 ++++ ...05_03_140909_create_oauth_tokens_table.php | 33 ++++ ...918_create_webhook_subscriptions_table.php | 38 ++++ ...140924_create_webhook_deliveries_table.php | 38 ++++ ...6_05_03_140930_create_api_tokens_table.php | 38 ++++ database/seeders/AnalyticsDailySeeder.php | 41 +++++ database/seeders/AnalyticsEventSeeder.php | 67 +++++++ database/seeders/ApiTokenSeeder.php | 16 ++ database/seeders/AppInstallationSeeder.php | 29 +++ database/seeders/AppSeeder.php | 26 +++ database/seeders/DatabaseSeeder.php | 7 + database/seeders/OauthClientSeeder.php | 25 +++ database/seeders/OauthTokenSeeder.php | 25 +++ database/seeders/WebhookDeliverySeeder.php | 16 ++ .../seeders/WebhookSubscriptionSeeder.php | 28 +++ .../livewire/admin/analytics/index.blade.php | 37 +++- .../views/livewire/admin/apps/index.blade.php | 26 ++- .../views/livewire/admin/apps/show.blade.php | 8 +- .../livewire/admin/developers/index.blade.php | 103 ++++++++++- routes/api.php | 14 ++ routes/console.php | 2 + specs/progress.md | 23 ++- tests/Feature/Analytics/AnalyticsTest.php | 130 +++++++++++++ .../Developers/DeveloperIntegrationsTest.php | 82 +++++++++ .../Feature/Webhooks/WebhookDeliveryTest.php | 93 ++++++++++ 69 files changed, 2944 insertions(+), 50 deletions(-) create mode 100644 app/Events/ProductCreated.php create mode 100644 app/Events/ProductDeleted.php create mode 100644 app/Events/ProductUpdated.php create mode 100644 app/Http/Controllers/Api/Admin/AnalyticsSummaryController.php create mode 100644 app/Http/Controllers/Api/Storefront/AnalyticsController.php create mode 100644 app/Http/Middleware/AuthenticateApiToken.php create mode 100644 app/Http/Requests/Admin/AnalyticsSummaryRequest.php create mode 100644 app/Http/Requests/Storefront/AnalyticsEventsRequest.php create mode 100644 app/Jobs/AggregateAnalytics.php create mode 100644 app/Jobs/DeliverWebhook.php create mode 100644 app/Listeners/DispatchWebhooks.php create mode 100644 app/Models/AnalyticsDaily.php create mode 100644 app/Models/AnalyticsEvent.php create mode 100644 app/Models/ApiToken.php create mode 100644 app/Models/App.php create mode 100644 app/Models/AppInstallation.php create mode 100644 app/Models/OauthClient.php create mode 100644 app/Models/OauthToken.php create mode 100644 app/Models/WebhookDelivery.php create mode 100644 app/Models/WebhookSubscription.php create mode 100644 app/Services/AnalyticsService.php create mode 100644 app/Services/ApiTokenService.php create mode 100644 app/Services/WebhookService.php create mode 100644 database/factories/AnalyticsDailyFactory.php create mode 100644 database/factories/AnalyticsEventFactory.php create mode 100644 database/factories/ApiTokenFactory.php create mode 100644 database/factories/AppFactory.php create mode 100644 database/factories/AppInstallationFactory.php create mode 100644 database/factories/OauthClientFactory.php create mode 100644 database/factories/OauthTokenFactory.php create mode 100644 database/factories/WebhookDeliveryFactory.php create mode 100644 database/factories/WebhookSubscriptionFactory.php create mode 100644 database/migrations/2026_05_03_140833_create_analytics_events_table.php create mode 100644 database/migrations/2026_05_03_140839_create_analytics_dailies_table.php create mode 100644 database/migrations/2026_05_03_140849_create_apps_table.php create mode 100644 database/migrations/2026_05_03_140858_create_app_installations_table.php create mode 100644 database/migrations/2026_05_03_140904_create_oauth_clients_table.php create mode 100644 database/migrations/2026_05_03_140909_create_oauth_tokens_table.php create mode 100644 database/migrations/2026_05_03_140918_create_webhook_subscriptions_table.php create mode 100644 database/migrations/2026_05_03_140924_create_webhook_deliveries_table.php create mode 100644 database/migrations/2026_05_03_140930_create_api_tokens_table.php create mode 100644 database/seeders/AnalyticsDailySeeder.php create mode 100644 database/seeders/AnalyticsEventSeeder.php create mode 100644 database/seeders/ApiTokenSeeder.php create mode 100644 database/seeders/AppInstallationSeeder.php create mode 100644 database/seeders/AppSeeder.php create mode 100644 database/seeders/OauthClientSeeder.php create mode 100644 database/seeders/OauthTokenSeeder.php create mode 100644 database/seeders/WebhookDeliverySeeder.php create mode 100644 database/seeders/WebhookSubscriptionSeeder.php create mode 100644 tests/Feature/Analytics/AnalyticsTest.php create mode 100644 tests/Feature/Developers/DeveloperIntegrationsTest.php create mode 100644 tests/Feature/Webhooks/WebhookDeliveryTest.php diff --git a/app/Events/ProductCreated.php b/app/Events/ProductCreated.php new file mode 100644 index 00000000..812d946c --- /dev/null +++ b/app/Events/ProductCreated.php @@ -0,0 +1,15 @@ +json([ + 'data' => $analytics->summary( + $store, + (string) $request->validated('from'), + (string) $request->validated('to'), + ), + ]); + } +} diff --git a/app/Http/Controllers/Api/Storefront/AnalyticsController.php b/app/Http/Controllers/Api/Storefront/AnalyticsController.php new file mode 100644 index 00000000..e3f65a3f --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/AnalyticsController.php @@ -0,0 +1,27 @@ +trackBatch( + $this->currentStore(), + $request->validated('events'), + ); + + return response()->json($result, 202); + } + + private function currentStore(): Store + { + return app('current_store'); + } +} diff --git a/app/Http/Middleware/AuthenticateApiToken.php b/app/Http/Middleware/AuthenticateApiToken.php new file mode 100644 index 00000000..0cfe778d --- /dev/null +++ b/app/Http/Middleware/AuthenticateApiToken.php @@ -0,0 +1,42 @@ +bearerToken(); + + if (! $plainTextToken) { + abort(401, 'Missing API token.'); + } + + $token = app(ApiTokenService::class)->findValidToken($plainTextToken, $ability); + + if (! $token) { + abort(403, 'Invalid API token or ability.'); + } + + $routeStore = $request->route('store'); + $store = $routeStore instanceof Store ? $routeStore : Store::query()->find($routeStore); + + if (! $store instanceof Store || $store->id !== $token->store_id) { + abort(403, 'API token cannot access this store.'); + } + + app()->instance('current_store', $store); + app()->instance('current_api_token', $token); + + return $next($request); + } +} diff --git a/app/Http/Requests/Admin/AnalyticsSummaryRequest.php b/app/Http/Requests/Admin/AnalyticsSummaryRequest.php new file mode 100644 index 00000000..9e996805 --- /dev/null +++ b/app/Http/Requests/Admin/AnalyticsSummaryRequest.php @@ -0,0 +1,49 @@ +|string> + */ + public function rules(): array + { + return [ + 'from' => ['required', 'date_format:Y-m-d'], + 'to' => ['required', 'date_format:Y-m-d', 'after_or_equal:from'], + 'granularity' => ['nullable', Rule::in(['day', 'week', 'month'])], + ]; + } + + /** + * @return array + */ + public function after(): array + { + return [ + function (Validator $validator): void { + if (! $this->filled('from') || ! $this->filled('to')) { + return; + } + + $from = Carbon::parse((string) $this->input('from')); + $to = Carbon::parse((string) $this->input('to')); + + if ($from->diffInDays($to) > 365) { + $validator->errors()->add('to', 'The date range may not exceed 365 days.'); + } + }, + ]; + } +} diff --git a/app/Http/Requests/Storefront/AnalyticsEventsRequest.php b/app/Http/Requests/Storefront/AnalyticsEventsRequest.php new file mode 100644 index 00000000..4675d6db --- /dev/null +++ b/app/Http/Requests/Storefront/AnalyticsEventsRequest.php @@ -0,0 +1,72 @@ +|string> + */ + public function rules(): array + { + return [ + 'events' => ['required', 'array', 'min:1', 'max:50'], + 'events.*.type' => ['required', 'string', Rule::in(AnalyticsService::EVENT_TYPES)], + 'events.*.session_id' => ['required', 'string', 'max:100'], + 'events.*.client_event_id' => ['required', 'string', 'max:100'], + 'events.*.properties' => ['nullable', 'array'], + 'events.*.occurred_at' => ['required', 'date'], + ]; + } + + /** + * @return array + */ + public function after(): array + { + return [ + function (Validator $validator): void { + foreach ($this->input('events', []) as $index => $event) { + try { + $occurredAt = Carbon::parse((string) data_get($event, 'occurred_at')); + } catch (\Throwable) { + continue; + } + + if ($occurredAt->lt(now()->subHour()) || $occurredAt->gt(now()->addHour())) { + $validator->errors()->add("events.{$index}.occurred_at", 'The occurred at field must be within one hour of now.'); + } + + if ($this->depth((array) data_get($event, 'properties', [])) > 3) { + $validator->errors()->add("events.{$index}.properties", 'The properties field may not be nested more than three levels.'); + } + } + }, + ]; + } + + private function depth(array $value): int + { + if ($value === []) { + return 1; + } + + $maxChildDepth = collect($value) + ->filter(fn (mixed $child): bool => is_array($child)) + ->map(fn (array $child): int => $this->depth($child)) + ->max(); + + return 1 + (int) ($maxChildDepth ?? 0); + } +} diff --git a/app/Jobs/AggregateAnalytics.php b/app/Jobs/AggregateAnalytics.php new file mode 100644 index 00000000..68e24977 --- /dev/null +++ b/app/Jobs/AggregateAnalytics.php @@ -0,0 +1,25 @@ +date ? Carbon::parse($this->date) : now()->subDay(); + + Store::query() + ->orderBy('id') + ->each(fn (Store $store): mixed => $analytics->aggregateDate($store, $date)); + } +} diff --git a/app/Jobs/DeliverWebhook.php b/app/Jobs/DeliverWebhook.php new file mode 100644 index 00000000..3a6108bb --- /dev/null +++ b/app/Jobs/DeliverWebhook.php @@ -0,0 +1,96 @@ + + */ + public array $backoff = [60, 300, 1800, 7200, 43200]; + + /** + * @param array $payload + */ + public function __construct( + public int $deliveryId, + public string $eventType, + public array $payload, + ) {} + + public function handle(WebhookService $webhooks): void + { + $delivery = WebhookDelivery::query()->with('subscription')->findOrFail($this->deliveryId); + $subscription = $delivery->subscription; + + $body = json_encode($webhooks->payload($this->eventType, $this->payload), JSON_THROW_ON_ERROR); + $timestamp = now()->timestamp; + $signature = $webhooks->signWithTimestamp($body, $subscription->signing_secret_encrypted, $timestamp); + + try { + $response = Http::timeout(10) + ->withHeaders([ + 'Content-Type' => 'application/json', + 'X-Platform-Signature' => $signature, + 'X-Platform-Event' => $this->eventType, + 'X-Platform-Delivery-Id' => (string) $delivery->id, + 'X-Platform-Timestamp' => (string) $timestamp, + ]) + ->withBody($body, 'application/json') + ->post($subscription->target_url); + } catch (Throwable $exception) { + $this->markFailed($delivery, $subscription, null, $exception->getMessage()); + + throw $exception; + } + + if ($response->successful()) { + $delivery->forceFill([ + 'attempt_count' => $this->attempts(), + 'status' => 'success', + 'last_attempt_at' => now(), + 'response_code' => $response->status(), + 'response_body_snippet' => str($response->body())->limit(1000)->toString(), + ])->save(); + + $subscription->forceFill(['consecutive_failures' => 0])->save(); + + return; + } + + $this->markFailed($delivery, $subscription, $response->status(), $response->body()); + + throw new RuntimeException('Webhook delivery failed with status '.$response->status().'.'); + } + + private function markFailed(WebhookDelivery $delivery, WebhookSubscription $subscription, ?int $statusCode, string $body): void + { + $failures = $subscription->consecutive_failures + 1; + + $delivery->forceFill([ + 'attempt_count' => $this->attempts(), + 'status' => 'failed', + 'last_attempt_at' => now(), + 'response_code' => $statusCode, + 'response_body_snippet' => str($body)->limit(1000)->toString(), + ])->save(); + + $subscription->forceFill([ + 'consecutive_failures' => $failures, + 'status' => $failures >= 5 ? 'paused' : $subscription->status, + ])->save(); + } +} diff --git a/app/Listeners/DispatchWebhooks.php b/app/Listeners/DispatchWebhooks.php new file mode 100644 index 00000000..b5bad5e5 --- /dev/null +++ b/app/Listeners/DispatchWebhooks.php @@ -0,0 +1,86 @@ +eventType($event); + $store = $this->store($event); + + if ($eventType === null || ! $store instanceof Store) { + return; + } + + app(WebhookService::class)->dispatch($store, $eventType, $this->payload($event)); + } + + private function eventType(object $event): ?string + { + return match ($event::class) { + OrderCreated::class => 'order.created', + OrderPaid::class => 'order.paid', + OrderFulfilled::class => 'order.fulfilled', + OrderRefunded::class => 'order.refunded', + ProductCreated::class => 'product.created', + ProductUpdated::class => 'product.updated', + ProductDeleted::class => 'product.deleted', + default => null, + }; + } + + private function store(object $event): ?Store + { + if (property_exists($event, 'order') && $event->order instanceof Order) { + return Store::query()->find($event->order->store_id); + } + + if (property_exists($event, 'product') && $event->product instanceof Product) { + return Store::query()->find($event->product->store_id); + } + + return null; + } + + /** + * @return array + */ + private function payload(object $event): array + { + if (property_exists($event, 'order') && $event->order instanceof Order) { + return [ + 'order_id' => $event->order->id, + 'order_number' => $event->order->order_number, + 'status' => $event->order->status->value, + 'financial_status' => $event->order->financial_status->value, + 'fulfillment_status' => $event->order->fulfillment_status->value, + 'total_amount' => $event->order->total_amount, + 'currency' => $event->order->currency, + ]; + } + + if (property_exists($event, 'product') && $event->product instanceof Product) { + return [ + 'product_id' => $event->product->id, + 'title' => $event->product->title, + 'handle' => $event->product->handle, + 'status' => $event->product->status->value, + ]; + } + + return []; + } +} diff --git a/app/Livewire/Admin/Analytics/Index.php b/app/Livewire/Admin/Analytics/Index.php index ab3d98b2..ca28c658 100644 --- a/app/Livewire/Admin/Analytics/Index.php +++ b/app/Livewire/Admin/Analytics/Index.php @@ -3,7 +3,7 @@ namespace App\Livewire\Admin\Analytics; use App\Livewire\Admin\Concerns\UsesAdminStore; -use App\Models\Order; +use App\Services\AnalyticsService; use Illuminate\View\View; use Livewire\Component; @@ -11,21 +11,21 @@ class Index extends Component { use UsesAdminStore; + public string $dateRange = '30'; + public function render(): View { - $orders = Order::query()->latest('placed_at')->get(); - $paidOrders = $orders->where('financial_status.value', 'paid'); + $to = now()->toDateString(); + $from = now()->subDays((int) $this->dateRange)->toDateString(); + $data = app(AnalyticsService::class)->summary($this->currentStore(), $from, $to); + $summary = $data['summary']; return view('livewire.admin.analytics.index', [ - 'totalSales' => $this->money((int) $orders->sum('total_amount')), - 'paidOrders' => $paidOrders->count(), - 'pendingOrders' => $orders->where('financial_status.value', 'pending')->count(), - 'refundedOrders' => $orders->whereIn('financial_status.value', ['refunded', 'partially_refunded'])->count(), - 'dailyOrders' => $orders - ->groupBy(fn (Order $order): string => $order->placed_at?->toDateString() ?? 'unknown') - ->map(fn ($orders, string $date): array => ['date' => $date, 'count' => $orders->count()]) - ->values() - ->take(14), + 'period' => $data['period'], + 'summary' => $summary, + 'dailyMetrics' => collect($data['daily'])->take(-14)->values(), + 'totalSales' => $this->money((int) $summary['revenue_amount']), + 'averageOrderValue' => $this->money((int) $summary['aov_amount']), ])->layout('livewire.admin.layout.app', [ 'title' => 'Analytics', ]); diff --git a/app/Livewire/Admin/Apps/Index.php b/app/Livewire/Admin/Apps/Index.php index d7139cb3..fb03fc9b 100644 --- a/app/Livewire/Admin/Apps/Index.php +++ b/app/Livewire/Admin/Apps/Index.php @@ -2,19 +2,62 @@ namespace App\Livewire\Admin\Apps; +use App\Livewire\Admin\Concerns\UsesAdminStore; +use App\Models\App; +use App\Models\AppInstallation; use Illuminate\View\View; use Livewire\Component; class Index extends Component { + use UsesAdminStore; + + public function installApp(int $appId): void + { + AppInstallation::withoutGlobalScopes()->updateOrCreate( + [ + 'store_id' => $this->currentStore()->id, + 'app_id' => $appId, + ], + [ + 'scopes_json' => ['read-products', 'read-orders'], + 'status' => 'active', + 'installed_at' => now(), + ], + ); + + $this->notify('App installed.'); + } + + public function uninstallApp(int $installationId): void + { + AppInstallation::withoutGlobalScopes() + ->where('store_id', $this->currentStore()->id) + ->whereKey($installationId) + ->firstOrFail() + ->forceFill(['status' => 'uninstalled']) + ->save(); + + $this->notify('App uninstalled.'); + } + public function render(): View { + $installedApps = AppInstallation::withoutGlobalScopes() + ->with('app') + ->where('store_id', $this->currentStore()->id) + ->where('status', '!=', 'uninstalled') + ->latest('installed_at') + ->get(); + $installedAppIds = $installedApps->pluck('app_id'); + return view('livewire.admin.apps.index', [ - 'apps' => [ - ['id' => 'reviews', 'name' => 'Product Reviews', 'status' => 'available'], - ['id' => 'email-automation', 'name' => 'Email Automation', 'status' => 'available'], - ['id' => 'warehouse-sync', 'name' => 'Warehouse Sync', 'status' => 'available'], - ], + 'installedApps' => $installedApps, + 'availableApps' => App::query() + ->where('status', 'active') + ->whereNotIn('id', $installedAppIds) + ->oldest('name') + ->get(), ])->layout('livewire.admin.layout.app', [ 'title' => 'Apps', ]); diff --git a/app/Livewire/Admin/Apps/Show.php b/app/Livewire/Admin/Apps/Show.php index 9487e61d..85b1aebc 100644 --- a/app/Livewire/Admin/Apps/Show.php +++ b/app/Livewire/Admin/Apps/Show.php @@ -2,11 +2,16 @@ namespace App\Livewire\Admin\Apps; +use App\Livewire\Admin\Concerns\UsesAdminStore; +use App\Models\App; +use App\Models\AppInstallation; use Illuminate\View\View; use Livewire\Component; class Show extends Component { + use UsesAdminStore; + public string $installation; public function mount(string $installation): void @@ -16,7 +21,17 @@ public function mount(string $installation): void public function render(): View { - return view('livewire.admin.apps.show')->layout('livewire.admin.layout.app', [ + $app = App::query()->where('handle', $this->installation)->firstOrFail(); + $installation = AppInstallation::withoutGlobalScopes() + ->with('webhookSubscriptions') + ->where('store_id', $this->currentStore()->id) + ->where('app_id', $app->id) + ->first(); + + return view('livewire.admin.apps.show', [ + 'app' => $app, + 'appInstallation' => $installation, + ])->layout('livewire.admin.layout.app', [ 'title' => 'App detail', ]); } diff --git a/app/Livewire/Admin/Developers/Index.php b/app/Livewire/Admin/Developers/Index.php index 513bed11..77b44f9f 100644 --- a/app/Livewire/Admin/Developers/Index.php +++ b/app/Livewire/Admin/Developers/Index.php @@ -2,14 +2,156 @@ namespace App\Livewire\Admin\Developers; +use App\Livewire\Admin\Concerns\UsesAdminStore; +use App\Models\ApiToken; +use App\Models\WebhookSubscription; +use App\Services\ApiTokenService; +use Illuminate\Validation\Rule; use Illuminate\View\View; use Livewire\Component; class Index extends Component { + use UsesAdminStore; + + public string $newTokenName = ''; + + public ?string $generatedToken = null; + + public string $webhookEventType = 'order.created'; + + public string $webhookUrl = ''; + + public ?int $editingWebhookId = null; + + /** + * @var list + */ + public array $tokenAbilities = ['read-analytics']; + + /** + * @var list + */ + public array $availableAbilities = [ + 'read-products', + 'read-orders', + 'read-customers', + 'read-analytics', + ]; + + /** + * @var list + */ + public array $webhookEventTypes = [ + 'order.created', + 'order.paid', + 'order.fulfilled', + 'order.refunded', + 'product.created', + 'product.updated', + 'product.deleted', + 'checkout.completed', + ]; + + public function generateToken(ApiTokenService $tokens): void + { + $validated = $this->validate([ + 'newTokenName' => ['required', 'string', 'max:255'], + 'tokenAbilities' => ['required', 'array', 'min:1'], + 'tokenAbilities.*' => [Rule::in($this->availableAbilities)], + ]); + + $result = $tokens->create( + $this->currentStore(), + $this->currentUser(), + $validated['newTokenName'], + $validated['tokenAbilities'], + ); + + $this->generatedToken = $result['plain_text_token']; + $this->reset('newTokenName'); + $this->tokenAbilities = ['read-analytics']; + $this->notify('API token generated.'); + } + + public function revokeToken(int $tokenId, ApiTokenService $tokens): void + { + $token = ApiToken::withoutGlobalScopes() + ->where('store_id', $this->currentStore()->id) + ->whereKey($tokenId) + ->firstOrFail(); + + $tokens->revoke($token); + $this->notify('API token revoked.'); + } + + public function editWebhook(int $webhookId): void + { + $webhook = WebhookSubscription::withoutGlobalScopes() + ->where('store_id', $this->currentStore()->id) + ->whereKey($webhookId) + ->firstOrFail(); + + $this->editingWebhookId = $webhook->id; + $this->webhookEventType = $webhook->event_type; + $this->webhookUrl = $webhook->target_url; + } + + public function saveWebhook(): void + { + $validated = $this->validate([ + 'webhookEventType' => ['required', Rule::in($this->webhookEventTypes)], + 'webhookUrl' => ['required', 'url', 'max:2048'], + ]); + + $attributes = [ + 'event_type' => $validated['webhookEventType'], + 'target_url' => $validated['webhookUrl'], + 'status' => 'active', + 'consecutive_failures' => 0, + ]; + + if ($this->editingWebhookId) { + WebhookSubscription::withoutGlobalScopes() + ->where('store_id', $this->currentStore()->id) + ->whereKey($this->editingWebhookId) + ->firstOrFail() + ->update($attributes); + } else { + WebhookSubscription::withoutGlobalScopes()->create([ + ...$attributes, + 'store_id' => $this->currentStore()->id, + 'signing_secret_encrypted' => str()->random(40), + ]); + } + + $this->reset('editingWebhookId', 'webhookUrl'); + $this->webhookEventType = 'order.created'; + $this->notify('Webhook saved.'); + } + + public function deleteWebhook(int $webhookId): void + { + WebhookSubscription::withoutGlobalScopes() + ->where('store_id', $this->currentStore()->id) + ->whereKey($webhookId) + ->delete(); + + $this->notify('Webhook deleted.'); + } + public function render(): View { - return view('livewire.admin.developers.index')->layout('livewire.admin.layout.app', [ + return view('livewire.admin.developers.index', [ + 'tokens' => ApiToken::withoutGlobalScopes() + ->where('store_id', $this->currentStore()->id) + ->latest() + ->get(), + 'webhooks' => WebhookSubscription::withoutGlobalScopes() + ->where('store_id', $this->currentStore()->id) + ->latest() + ->get(), + ])->layout('livewire.admin.layout.app', [ 'title' => 'Developers', ]); } diff --git a/app/Models/AnalyticsDaily.php b/app/Models/AnalyticsDaily.php new file mode 100644 index 00000000..aec4a938 --- /dev/null +++ b/app/Models/AnalyticsDaily.php @@ -0,0 +1,59 @@ + */ + use BelongsToStore, HasFactory; + + public $timestamps = false; + + protected $table = 'analytics_daily'; + + protected $primaryKey = 'store_id'; + + public $incrementing = false; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'date', + 'orders_count', + 'revenue_amount', + 'aov_amount', + 'visits_count', + 'add_to_cart_count', + 'checkout_started_count', + 'checkout_completed_count', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'orders_count' => 0, + 'revenue_amount' => 0, + 'aov_amount' => 0, + 'visits_count' => 0, + 'add_to_cart_count' => 0, + 'checkout_started_count' => 0, + 'checkout_completed_count' => 0, + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'date' => 'date:Y-m-d', + ]; + } +} diff --git a/app/Models/AnalyticsEvent.php b/app/Models/AnalyticsEvent.php new file mode 100644 index 00000000..89254af1 --- /dev/null +++ b/app/Models/AnalyticsEvent.php @@ -0,0 +1,55 @@ + */ + use BelongsToStore, HasFactory; + + public const UPDATED_AT = null; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'type', + 'session_id', + 'customer_id', + 'properties_json', + 'client_event_id', + 'occurred_at', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'properties_json' => '{}', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'properties_json' => 'array', + 'occurred_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/ApiToken.php b/app/Models/ApiToken.php new file mode 100644 index 00000000..66c58bd2 --- /dev/null +++ b/app/Models/ApiToken.php @@ -0,0 +1,65 @@ + */ + use BelongsToStore, HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'user_id', + 'name', + 'token_hash', + 'abilities_json', + 'last_used_at', + 'revoked_at', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'abilities_json' => '[]', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'abilities_json' => 'array', + 'last_used_at' => 'datetime', + 'revoked_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function hasAbility(string $ability): bool + { + return in_array('*', $this->abilities_json ?? [], true) + || in_array($ability, $this->abilities_json ?? [], true); + } + + public function isRevoked(): bool + { + return $this->revoked_at !== null; + } +} diff --git a/app/Models/App.php b/app/Models/App.php new file mode 100644 index 00000000..014677df --- /dev/null +++ b/app/Models/App.php @@ -0,0 +1,47 @@ + */ + use HasFactory; + + public const UPDATED_AT = null; + + /** + * @var list + */ + protected $fillable = [ + 'name', + 'handle', + 'status', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'status' => 'active', + ]; + + /** + * @return HasMany + */ + public function installations(): HasMany + { + return $this->hasMany(AppInstallation::class); + } + + /** + * @return HasMany + */ + public function oauthClients(): HasMany + { + return $this->hasMany(OauthClient::class); + } +} diff --git a/app/Models/AppInstallation.php b/app/Models/AppInstallation.php new file mode 100644 index 00000000..61be59a4 --- /dev/null +++ b/app/Models/AppInstallation.php @@ -0,0 +1,73 @@ + */ + use BelongsToStore, HasFactory; + + public const CREATED_AT = 'installed_at'; + + public const UPDATED_AT = null; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'app_id', + 'scopes_json', + 'status', + 'installed_at', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'scopes_json' => '[]', + 'status' => 'active', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'scopes_json' => 'array', + 'installed_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function app(): BelongsTo + { + return $this->belongsTo(App::class); + } + + /** + * @return HasMany + */ + public function oauthTokens(): HasMany + { + return $this->hasMany(OauthToken::class, 'installation_id'); + } + + /** + * @return HasMany + */ + public function webhookSubscriptions(): HasMany + { + return $this->hasMany(WebhookSubscription::class); + } +} diff --git a/app/Models/OauthClient.php b/app/Models/OauthClient.php new file mode 100644 index 00000000..7b661b59 --- /dev/null +++ b/app/Models/OauthClient.php @@ -0,0 +1,51 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'app_id', + 'client_id', + 'client_secret_encrypted', + 'redirect_uris_json', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'redirect_uris_json' => '[]', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'client_secret_encrypted' => 'encrypted', + 'redirect_uris_json' => 'array', + ]; + } + + /** + * @return BelongsTo + */ + 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..0b41770b --- /dev/null +++ b/app/Models/OauthToken.php @@ -0,0 +1,43 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'installation_id', + 'access_token_hash', + 'refresh_token_hash', + 'expires_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'expires_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function installation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class, 'installation_id'); + } +} diff --git a/app/Models/Store.php b/app/Models/Store.php index f8dfb15b..094eb552 100644 --- a/app/Models/Store.php +++ b/app/Models/Store.php @@ -202,6 +202,46 @@ public function searchQueries(): HasMany return $this->hasMany(SearchQuery::class); } + /** + * @return HasMany + */ + public function analyticsEvents(): HasMany + { + return $this->hasMany(AnalyticsEvent::class); + } + + /** + * @return HasMany + */ + public function analyticsDaily(): HasMany + { + return $this->hasMany(AnalyticsDaily::class); + } + + /** + * @return HasMany + */ + public function appInstallations(): HasMany + { + return $this->hasMany(AppInstallation::class); + } + + /** + * @return HasMany + */ + public function apiTokens(): HasMany + { + return $this->hasMany(ApiToken::class); + } + + /** + * @return HasMany + */ + public function webhookSubscriptions(): HasMany + { + return $this->hasMany(WebhookSubscription::class); + } + public function isSuspended(): bool { return $this->status === StoreStatus::Suspended; diff --git a/app/Models/WebhookDelivery.php b/app/Models/WebhookDelivery.php new file mode 100644 index 00000000..ab88e7fb --- /dev/null +++ b/app/Models/WebhookDelivery.php @@ -0,0 +1,54 @@ + */ + use HasFactory; + + public $timestamps = false; + + /** + * @var list + */ + protected $fillable = [ + 'subscription_id', + 'event_id', + 'attempt_count', + 'status', + 'last_attempt_at', + 'response_code', + 'response_body_snippet', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'attempt_count' => 1, + 'status' => 'pending', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'last_attempt_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function subscription(): BelongsTo + { + return $this->belongsTo(WebhookSubscription::class, 'subscription_id'); + } +} diff --git a/app/Models/WebhookSubscription.php b/app/Models/WebhookSubscription.php new file mode 100644 index 00000000..16e44afe --- /dev/null +++ b/app/Models/WebhookSubscription.php @@ -0,0 +1,62 @@ + */ + use BelongsToStore, HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'app_installation_id', + 'event_type', + 'target_url', + 'signing_secret_encrypted', + 'status', + 'consecutive_failures', + ]; + + /** + * @var array + */ + protected $attributes = [ + 'status' => 'active', + 'consecutive_failures' => 0, + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'signing_secret_encrypted' => 'encrypted', + ]; + } + + /** + * @return BelongsTo + */ + public function appInstallation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class); + } + + /** + * @return HasMany + */ + public function deliveries(): HasMany + { + return $this->hasMany(WebhookDelivery::class, 'subscription_id'); + } +} diff --git a/app/Observers/ProductObserver.php b/app/Observers/ProductObserver.php index 68ae27c9..b5d47511 100644 --- a/app/Observers/ProductObserver.php +++ b/app/Observers/ProductObserver.php @@ -2,6 +2,9 @@ namespace App\Observers; +use App\Events\ProductCreated; +use App\Events\ProductDeleted; +use App\Events\ProductUpdated; use App\Models\Product; use App\Services\SearchService; @@ -10,20 +13,24 @@ class ProductObserver public function created(Product $product): void { app(SearchService::class)->syncProduct($product); + ProductCreated::dispatch($product); } public function updated(Product $product): void { app(SearchService::class)->syncProduct($product); + ProductUpdated::dispatch($product); } public function deleted(Product $product): void { app(SearchService::class)->removeProduct($product->id); + ProductDeleted::dispatch($product); } public function forceDeleted(Product $product): void { app(SearchService::class)->removeProduct($product->id); + ProductDeleted::dispatch($product); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d526a57d..80a3c9f3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,7 +3,15 @@ namespace App\Providers; use App\Contracts\PaymentProvider; +use App\Events\OrderCreated; +use App\Events\OrderFulfilled; +use App\Events\OrderPaid; +use App\Events\OrderRefunded; +use App\Events\ProductCreated; +use App\Events\ProductDeleted; +use App\Events\ProductUpdated; use App\Http\Middleware\ResolveStore; +use App\Listeners\DispatchWebhooks; use App\Models\Product; use App\Observers\ProductObserver; use App\Services\Payments\MockPaymentProvider; @@ -13,6 +21,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; @@ -39,6 +48,7 @@ public function boot(): void ]); Product::observe(ProductObserver::class); + $this->registerEventListeners(); $this->configureDefaults(); $this->configureRateLimiting(); @@ -76,6 +86,10 @@ protected function configureRateLimiting(): void return Limit::perMinute(120)->by($request->ip()); }); + RateLimiter::for('api.admin', function (Request $request) { + return Limit::perMinute(60)->by($request->bearerToken() ?: $request->ip()); + }); + RateLimiter::for('checkout', function (Request $request) { return Limit::perMinute(10)->by($request->hasSession() ? $request->session()->getId() : $request->ip()); }); @@ -83,5 +97,28 @@ protected function configureRateLimiting(): void RateLimiter::for('search', function (Request $request) { return Limit::perMinute(30)->by($request->ip()); }); + + RateLimiter::for('analytics', function (Request $request) { + return Limit::perMinute(60)->by($request->ip()); + }); + + RateLimiter::for('webhooks', function (Request $request) { + return Limit::perMinute(100)->by($request->ip()); + }); + } + + protected function registerEventListeners(): void + { + foreach ([ + OrderCreated::class, + OrderPaid::class, + OrderFulfilled::class, + OrderRefunded::class, + ProductCreated::class, + ProductUpdated::class, + ProductDeleted::class, + ] as $event) { + Event::listen($event, DispatchWebhooks::class); + } } } diff --git a/app/Services/AnalyticsService.php b/app/Services/AnalyticsService.php new file mode 100644 index 00000000..337e2d8d --- /dev/null +++ b/app/Services/AnalyticsService.php @@ -0,0 +1,171 @@ + $properties + */ + public function track(Store $store, string $type, array $properties = [], ?string $sessionId = null, ?int $customerId = null, ?string $clientEventId = null, ?CarbonInterface $occurredAt = null): bool + { + if ($clientEventId === null) { + AnalyticsEvent::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'type' => $type, + 'session_id' => $sessionId, + 'customer_id' => $customerId, + 'properties_json' => $properties, + 'occurred_at' => $occurredAt ?? now(), + ]); + + return true; + } + + $event = AnalyticsEvent::withoutGlobalScopes()->firstOrCreate( + [ + 'store_id' => $store->id, + 'client_event_id' => $clientEventId, + ], + [ + 'type' => $type, + 'session_id' => $sessionId, + 'customer_id' => $customerId, + 'properties_json' => $properties, + 'occurred_at' => $occurredAt ?? now(), + ], + ); + + return $event->wasRecentlyCreated; + } + + /** + * @param array> $events + * @return array{accepted: int, rejected: int} + */ + public function trackBatch(Store $store, array $events, ?int $customerId = null): array + { + $accepted = 0; + + foreach ($events as $event) { + $created = $this->track( + $store, + (string) $event['type'], + $event['properties'] ?? [], + (string) $event['session_id'], + $customerId, + (string) $event['client_event_id'], + Carbon::parse((string) $event['occurred_at']), + ); + + if ($created) { + $accepted++; + } + } + + return [ + 'accepted' => $accepted, + 'rejected' => 0, + ]; + } + + /** + * @return Collection + */ + public function getDailyMetrics(Store $store, string $startDate, string $endDate): Collection + { + return AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $store->id) + ->whereBetween('date', [$startDate, $endDate]) + ->orderBy('date') + ->get(); + } + + /** + * @return array + */ + public function summary(Store $store, string $startDate, string $endDate): array + { + $daily = $this->getDailyMetrics($store, $startDate, $endDate); + $orders = (int) $daily->sum('orders_count'); + $revenue = (int) $daily->sum('revenue_amount'); + $visits = (int) $daily->sum('visits_count'); + $checkoutCompleted = (int) $daily->sum('checkout_completed_count'); + + return [ + 'period' => [ + 'from' => $startDate, + 'to' => $endDate, + ], + 'summary' => [ + 'orders_count' => $orders, + 'revenue_amount' => $revenue, + 'aov_amount' => $orders > 0 ? intdiv($revenue, $orders) : 0, + 'visits_count' => $visits, + 'add_to_cart_count' => (int) $daily->sum('add_to_cart_count'), + 'checkout_started_count' => (int) $daily->sum('checkout_started_count'), + 'checkout_completed_count' => $checkoutCompleted, + 'conversion_rate' => $visits > 0 ? round($checkoutCompleted / $visits, 4) : 0.0, + 'currency' => $store->default_currency, + ], + 'daily' => $daily->map(fn (AnalyticsDaily $metric): array => [ + 'date' => $metric->date->toDateString(), + 'orders_count' => $metric->orders_count, + 'revenue_amount' => $metric->revenue_amount, + 'aov_amount' => $metric->aov_amount, + 'visits_count' => $metric->visits_count, + 'add_to_cart_count' => $metric->add_to_cart_count, + 'checkout_started_count' => $metric->checkout_started_count, + 'checkout_completed_count' => $metric->checkout_completed_count, + ])->values()->all(), + ]; + } + + public function aggregateDate(Store $store, CarbonInterface $date): void + { + $dateString = $date->toDateString(); + $events = AnalyticsEvent::withoutGlobalScopes() + ->where('store_id', $store->id) + ->whereDate('occurred_at', $dateString) + ->get(); + + $ordersCount = $events->where('type', 'checkout_completed')->count(); + $revenue = (int) $events + ->where('type', 'checkout_completed') + ->sum(fn (AnalyticsEvent $event): int => (int) data_get($event->properties_json, 'total_amount', 0)); + + DB::table('analytics_daily')->updateOrInsert( + [ + 'store_id' => $store->id, + 'date' => $dateString, + ], + [ + 'orders_count' => $ordersCount, + 'revenue_amount' => $revenue, + 'aov_amount' => $ordersCount > 0 ? intdiv($revenue, $ordersCount) : 0, + 'visits_count' => $events->where('type', 'page_view')->pluck('session_id')->filter()->unique()->count(), + 'add_to_cart_count' => $events->where('type', 'add_to_cart')->count(), + 'checkout_started_count' => $events->where('type', 'checkout_started')->count(), + 'checkout_completed_count' => $ordersCount, + ], + ); + } +} diff --git a/app/Services/ApiTokenService.php b/app/Services/ApiTokenService.php new file mode 100644 index 00000000..f4c62874 --- /dev/null +++ b/app/Services/ApiTokenService.php @@ -0,0 +1,58 @@ + $abilities + * @return array{token: ApiToken, plain_text_token: string} + */ + public function create(Store $store, User $user, string $name, array $abilities): array + { + $plainTextToken = 'shop_'.Str::random(48); + + $token = ApiToken::query()->create([ + 'store_id' => $store->id, + 'user_id' => $user->id, + 'name' => $name, + 'token_hash' => $this->hash($plainTextToken), + 'abilities_json' => array_values(array_unique($abilities)), + ]); + + return [ + 'token' => $token, + 'plain_text_token' => $plainTextToken, + ]; + } + + public function findValidToken(string $plainTextToken, string $ability): ?ApiToken + { + $token = ApiToken::withoutGlobalScopes() + ->where('token_hash', $this->hash($plainTextToken)) + ->first(); + + if (! $token instanceof ApiToken || $token->isRevoked() || ! $token->hasAbility($ability)) { + return null; + } + + $token->forceFill(['last_used_at' => now()])->save(); + + return $token; + } + + public function revoke(ApiToken $token): void + { + $token->forceFill(['revoked_at' => now()])->save(); + } + + public function hash(string $plainTextToken): string + { + return hash('sha256', $plainTextToken); + } +} diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php new file mode 100644 index 00000000..4c7509e3 --- /dev/null +++ b/app/Services/WebhookService.php @@ -0,0 +1,63 @@ + $payload + */ + public function dispatch(Store $store, string $eventType, array $payload): void + { + $eventId = (string) Str::uuid(); + + WebhookSubscription::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('event_type', $eventType) + ->where('status', 'active') + ->get() + ->each(function (WebhookSubscription $subscription) use ($eventId, $eventType, $payload): void { + $delivery = WebhookDelivery::query()->create([ + 'subscription_id' => $subscription->id, + 'event_id' => $eventId, + ]); + + DeliverWebhook::dispatch($delivery->id, $eventType, $payload); + }); + } + + public function sign(string $payload, string $secret): string + { + return hash_hmac('sha256', $payload, $secret); + } + + public function signWithTimestamp(string $payload, string $secret, int $timestamp): string + { + return $this->sign($timestamp.'.'.$payload, $secret); + } + + public function verify(string $payload, string $signature, string $secret): bool + { + return hash_equals($this->sign($payload, $secret), $signature); + } + + /** + * @param array $payload + * @return array + */ + public function payload(string $eventType, array $payload): array + { + return [ + 'api_version' => '2026-05', + 'event' => $eventType, + 'timestamp' => now()->toISOString(), + 'data' => $payload, + ]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index a252758d..1fa8010f 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -18,6 +18,7 @@ : route('login')); $middleware->alias([ + 'api.token' => App\Http\Middleware\AuthenticateApiToken::class, 'customer.auth' => App\Http\Middleware\CustomerAuthenticate::class, 'store.resolve' => App\Http\Middleware\ResolveStore::class, ]); diff --git a/database/factories/AnalyticsDailyFactory.php b/database/factories/AnalyticsDailyFactory.php new file mode 100644 index 00000000..18d8421d --- /dev/null +++ b/database/factories/AnalyticsDailyFactory.php @@ -0,0 +1,35 @@ + + */ +class AnalyticsDailyFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $orders = fake()->numberBetween(0, 8); + $revenue = $orders * fake()->numberBetween(4000, 9000); + + return [ + 'store_id' => Store::factory(), + 'date' => fake()->dateTimeBetween('-30 days')->format('Y-m-d'), + 'orders_count' => $orders, + 'revenue_amount' => $revenue, + 'aov_amount' => $orders > 0 ? intdiv($revenue, $orders) : 0, + 'visits_count' => fake()->numberBetween(50, 200), + 'add_to_cart_count' => fake()->numberBetween(10, 50), + 'checkout_started_count' => fake()->numberBetween(4, 25), + 'checkout_completed_count' => $orders, + ]; + } +} diff --git a/database/factories/AnalyticsEventFactory.php b/database/factories/AnalyticsEventFactory.php new file mode 100644 index 00000000..70372951 --- /dev/null +++ b/database/factories/AnalyticsEventFactory.php @@ -0,0 +1,41 @@ + + */ +class AnalyticsEventFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $store = Store::factory(); + + return [ + 'store_id' => $store, + 'customer_id' => null, + 'type' => fake()->randomElement(['page_view', 'product_view', 'add_to_cart', 'checkout_started', 'checkout_completed', 'search']), + 'session_id' => 'sess_'.fake()->uuid(), + 'client_event_id' => 'evt_'.fake()->uuid(), + 'properties_json' => [], + 'occurred_at' => now(), + ]; + } + + public function forCustomer(Customer $customer): static + { + return $this->state(fn (array $attributes): array => [ + 'store_id' => $customer->store_id, + 'customer_id' => $customer->id, + ]); + } +} diff --git a/database/factories/ApiTokenFactory.php b/database/factories/ApiTokenFactory.php new file mode 100644 index 00000000..ae266c54 --- /dev/null +++ b/database/factories/ApiTokenFactory.php @@ -0,0 +1,32 @@ + + */ +class ApiTokenFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'user_id' => User::factory(), + 'name' => fake()->words(2, true), + 'token_hash' => hash('sha256', Str::random(64)), + 'abilities_json' => ['read-analytics'], + 'last_used_at' => null, + 'revoked_at' => null, + ]; + } +} diff --git a/database/factories/AppFactory.php b/database/factories/AppFactory.php new file mode 100644 index 00000000..b223f5a6 --- /dev/null +++ b/database/factories/AppFactory.php @@ -0,0 +1,28 @@ + + */ +class AppFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $name = fake()->unique()->words(2, true); + + return [ + 'name' => Str::headline($name), + 'handle' => Str::slug($name), + 'status' => 'active', + ]; + } +} diff --git a/database/factories/AppInstallationFactory.php b/database/factories/AppInstallationFactory.php new file mode 100644 index 00000000..4c8c1bc0 --- /dev/null +++ b/database/factories/AppInstallationFactory.php @@ -0,0 +1,29 @@ + + */ +class AppInstallationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'app_id' => App::factory(), + 'scopes_json' => ['read-products'], + 'status' => 'active', + 'installed_at' => now(), + ]; + } +} diff --git a/database/factories/OauthClientFactory.php b/database/factories/OauthClientFactory.php new file mode 100644 index 00000000..608799ae --- /dev/null +++ b/database/factories/OauthClientFactory.php @@ -0,0 +1,28 @@ + + */ +class OauthClientFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'app_id' => App::factory(), + 'client_id' => 'client_'.Str::random(32), + 'client_secret_encrypted' => Str::random(48), + 'redirect_uris_json' => ['https://example.com/oauth/callback'], + ]; + } +} diff --git a/database/factories/OauthTokenFactory.php b/database/factories/OauthTokenFactory.php new file mode 100644 index 00000000..0fb38917 --- /dev/null +++ b/database/factories/OauthTokenFactory.php @@ -0,0 +1,28 @@ + + */ +class OauthTokenFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'installation_id' => AppInstallation::factory(), + 'access_token_hash' => hash('sha256', Str::random(64)), + 'refresh_token_hash' => hash('sha256', Str::random(64)), + 'expires_at' => now()->addHour(), + ]; + } +} diff --git a/database/factories/WebhookDeliveryFactory.php b/database/factories/WebhookDeliveryFactory.php new file mode 100644 index 00000000..6cb75710 --- /dev/null +++ b/database/factories/WebhookDeliveryFactory.php @@ -0,0 +1,31 @@ + + */ +class WebhookDeliveryFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'subscription_id' => WebhookSubscription::factory(), + 'event_id' => (string) Str::uuid(), + 'attempt_count' => 1, + 'status' => 'pending', + 'last_attempt_at' => null, + 'response_code' => null, + 'response_body_snippet' => null, + ]; + } +} diff --git a/database/factories/WebhookSubscriptionFactory.php b/database/factories/WebhookSubscriptionFactory.php new file mode 100644 index 00000000..be84f0a7 --- /dev/null +++ b/database/factories/WebhookSubscriptionFactory.php @@ -0,0 +1,31 @@ + + */ +class WebhookSubscriptionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'app_installation_id' => null, + 'event_type' => fake()->randomElement(['order.created', 'order.paid', 'product.updated', 'checkout.completed']), + 'target_url' => 'https://example.com/webhooks/'.Str::random(8), + 'signing_secret_encrypted' => Str::random(40), + 'status' => 'active', + 'consecutive_failures' => 0, + ]; + } +} diff --git a/database/migrations/2026_05_03_140833_create_analytics_events_table.php b/database/migrations/2026_05_03_140833_create_analytics_events_table.php new file mode 100644 index 00000000..90c93c20 --- /dev/null +++ b/database/migrations/2026_05_03_140833_create_analytics_events_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('type'); + $table->string('session_id')->nullable(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->json('properties_json')->default('{}'); + $table->string('client_event_id')->nullable(); + $table->timestamp('occurred_at')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('store_id'); + $table->index(['store_id', 'type'], 'analytics_events_store_type_index'); + $table->index(['store_id', 'created_at'], 'analytics_events_store_created_index'); + $table->index('session_id'); + $table->index('customer_id'); + $table->unique(['store_id', 'client_event_id'], 'analytics_events_client_event_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('analytics_events'); + } +}; diff --git a/database/migrations/2026_05_03_140839_create_analytics_dailies_table.php b/database/migrations/2026_05_03_140839_create_analytics_dailies_table.php new file mode 100644 index 00000000..03da5774 --- /dev/null +++ b/database/migrations/2026_05_03_140839_create_analytics_dailies_table.php @@ -0,0 +1,37 @@ +foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->date('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'], 'analytics_daily_store_date_index'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('analytics_daily'); + } +}; diff --git a/database/migrations/2026_05_03_140849_create_apps_table.php b/database/migrations/2026_05_03_140849_create_apps_table.php new file mode 100644 index 00000000..67f2cf42 --- /dev/null +++ b/database/migrations/2026_05_03_140849_create_apps_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name'); + $table->string('handle')->unique(); + $table->enum('status', ['active', 'disabled'])->default('active'); + $table->timestamp('created_at')->nullable(); + + $table->index('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('apps'); + } +}; diff --git a/database/migrations/2026_05_03_140858_create_app_installations_table.php b/database/migrations/2026_05_03_140858_create_app_installations_table.php new file mode 100644 index 00000000..864ee549 --- /dev/null +++ b/database/migrations/2026_05_03_140858_create_app_installations_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('app_id')->constrained('apps')->cascadeOnDelete(); + $table->json('scopes_json')->default('[]'); + $table->enum('status', ['active', 'suspended', 'uninstalled'])->default('active'); + $table->timestamp('installed_at')->nullable(); + + $table->unique(['store_id', 'app_id']); + $table->index('store_id'); + $table->index('app_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('app_installations'); + } +}; diff --git a/database/migrations/2026_05_03_140904_create_oauth_clients_table.php b/database/migrations/2026_05_03_140904_create_oauth_clients_table.php new file mode 100644 index 00000000..b5e6eab0 --- /dev/null +++ b/database/migrations/2026_05_03_140904_create_oauth_clients_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('app_id')->constrained('apps')->cascadeOnDelete(); + $table->string('client_id')->unique(); + $table->text('client_secret_encrypted'); + $table->json('redirect_uris_json')->default('[]'); + + $table->index('app_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_clients'); + } +}; diff --git a/database/migrations/2026_05_03_140909_create_oauth_tokens_table.php b/database/migrations/2026_05_03_140909_create_oauth_tokens_table.php new file mode 100644 index 00000000..3b4eafe5 --- /dev/null +++ b/database/migrations/2026_05_03_140909_create_oauth_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('installation_id')->constrained('app_installations')->cascadeOnDelete(); + $table->string('access_token_hash')->unique(); + $table->string('refresh_token_hash')->nullable(); + $table->timestamp('expires_at'); + + $table->index('installation_id'); + $table->index('expires_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_tokens'); + } +}; diff --git a/database/migrations/2026_05_03_140918_create_webhook_subscriptions_table.php b/database/migrations/2026_05_03_140918_create_webhook_subscriptions_table.php new file mode 100644 index 00000000..661025f9 --- /dev/null +++ b/database/migrations/2026_05_03_140918_create_webhook_subscriptions_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('app_installation_id')->nullable()->constrained('app_installations')->cascadeOnDelete(); + $table->string('event_type'); + $table->string('target_url'); + $table->text('signing_secret_encrypted'); + $table->enum('status', ['active', 'paused', 'disabled'])->default('active'); + $table->integer('consecutive_failures')->default(0); + $table->timestamps(); + + $table->index('store_id'); + $table->index(['store_id', 'event_type']); + $table->index('app_installation_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_subscriptions'); + } +}; diff --git a/database/migrations/2026_05_03_140924_create_webhook_deliveries_table.php b/database/migrations/2026_05_03_140924_create_webhook_deliveries_table.php new file mode 100644 index 00000000..45212d6e --- /dev/null +++ b/database/migrations/2026_05_03_140924_create_webhook_deliveries_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('subscription_id')->constrained('webhook_subscriptions')->cascadeOnDelete(); + $table->string('event_id'); + $table->integer('attempt_count')->default(1); + $table->enum('status', ['pending', 'success', 'failed'])->default('pending'); + $table->timestamp('last_attempt_at')->nullable(); + $table->integer('response_code')->nullable(); + $table->text('response_body_snippet')->nullable(); + + $table->index('subscription_id'); + $table->index('event_id'); + $table->index('status'); + $table->index('last_attempt_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_deliveries'); + } +}; diff --git a/database/migrations/2026_05_03_140930_create_api_tokens_table.php b/database/migrations/2026_05_03_140930_create_api_tokens_table.php new file mode 100644 index 00000000..0b9482bb --- /dev/null +++ b/database/migrations/2026_05_03_140930_create_api_tokens_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('token_hash')->unique(); + $table->json('abilities_json')->default('[]'); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->timestamps(); + + $table->index('store_id'); + $table->index('user_id'); + $table->index('revoked_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('api_tokens'); + } +}; diff --git a/database/seeders/AnalyticsDailySeeder.php b/database/seeders/AnalyticsDailySeeder.php new file mode 100644 index 00000000..e3d68f7d --- /dev/null +++ b/database/seeders/AnalyticsDailySeeder.php @@ -0,0 +1,41 @@ +where('handle', 'acme-fashion')->firstOrFail(); + + for ($i = 30; $i >= 0; $i--) { + $dayFactor = 1 + (30 - $i) * 0.03; + $visits = (int) round(fake()->numberBetween(50, 100) * $dayFactor); + $addToCart = (int) round($visits * fake()->numberBetween(18, 25) / 100); + $checkoutStarted = (int) round($addToCart * fake()->numberBetween(40, 55) / 100); + $orders = max(2, (int) round($checkoutStarted * fake()->numberBetween(35, 55) / 100)); + $aov = fake()->numberBetween(4000, 9000); + $revenue = $orders * $aov; + + DB::table('analytics_daily')->updateOrInsert( + [ + 'store_id' => $store->id, + 'date' => now()->subDays($i)->toDateString(), + ], + [ + '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, + ], + ); + } + } +} diff --git a/database/seeders/AnalyticsEventSeeder.php b/database/seeders/AnalyticsEventSeeder.php new file mode 100644 index 00000000..a1263a20 --- /dev/null +++ b/database/seeders/AnalyticsEventSeeder.php @@ -0,0 +1,67 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $customers = Customer::withoutGlobalScopes()->where('store_id', $store->id)->pluck('id'); + $products = Product::withoutGlobalScopes()->where('store_id', $store->id)->with('variants')->get(); + $orders = Order::withoutGlobalScopes()->where('store_id', $store->id)->get(); + $types = [ + ...array_fill(0, 88, 'page_view'), + ...array_fill(0, 55, 'product_view'), + ...array_fill(0, 33, 'add_to_cart'), + ...array_fill(0, 22, 'checkout_started'), + ...array_fill(0, 11, 'checkout_completed'), + ...array_fill(0, 11, 'search'), + ]; + + shuffle($types); + + foreach ($types as $index => $type) { + $product = $products->random(); + $variant = $product->variants->first(); + $order = $orders->isNotEmpty() ? $orders->random() : null; + + AnalyticsEvent::withoutGlobalScopes()->updateOrCreate( + [ + 'store_id' => $store->id, + 'client_event_id' => 'evt_seed_'.$index, + ], + [ + 'type' => $type, + 'session_id' => 'sess_seed_'.fake()->numberBetween(1, 40), + 'customer_id' => fake()->boolean(30) && $customers->isNotEmpty() ? $customers->random() : null, + 'properties_json' => $this->properties($type, $product, $variant?->id, $order), + 'occurred_at' => now()->subDays(fake()->numberBetween(0, 7))->subMinutes(fake()->numberBetween(0, 1440)), + ], + ); + } + } + + /** + * @return array + */ + private function properties(string $type, Product $product, ?int $variantId, ?Order $order): array + { + return match ($type) { + 'page_view' => ['url' => fake()->randomElement(['/', '/collections/summer-essentials', '/pages/about'])], + 'product_view' => ['product_id' => $product->id, 'product_title' => $product->title, 'url' => '/products/'.$product->handle], + 'add_to_cart' => ['product_id' => $product->id, 'variant_id' => $variantId, 'quantity' => 1, 'price_amount' => $product->variants->first()?->price_amount ?? 0], + 'checkout_started' => ['item_count' => fake()->numberBetween(1, 3), 'cart_total' => fake()->numberBetween(2500, 15000)], + 'checkout_completed' => ['order_id' => $order?->id, 'order_number' => $order?->order_number, 'total_amount' => $order?->total_amount ?? fake()->numberBetween(4000, 9000)], + 'search' => ['query' => fake()->randomElement(['linen', 'tee', 'summer', 'shirt']), 'results_count' => fake()->numberBetween(1, 6)], + default => [], + }; + } +} diff --git a/database/seeders/ApiTokenSeeder.php b/database/seeders/ApiTokenSeeder.php new file mode 100644 index 00000000..f42806e7 --- /dev/null +++ b/database/seeders/ApiTokenSeeder.php @@ -0,0 +1,16 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $app = App::query()->where('handle', 'reviews')->firstOrFail(); + + AppInstallation::withoutGlobalScopes()->updateOrCreate( + [ + 'store_id' => $store->id, + 'app_id' => $app->id, + ], + [ + 'scopes_json' => ['read-products', 'read-orders', 'read-analytics'], + 'status' => 'active', + 'installed_at' => now()->subMonths(3), + ], + ); + } +} diff --git a/database/seeders/AppSeeder.php b/database/seeders/AppSeeder.php new file mode 100644 index 00000000..225a7fbd --- /dev/null +++ b/database/seeders/AppSeeder.php @@ -0,0 +1,26 @@ + 'Product Reviews', 'handle' => 'reviews'], + ['name' => 'Email Automation', 'handle' => 'email-automation'], + ['name' => 'Warehouse Sync', 'handle' => 'warehouse-sync'], + ] as $app) { + App::query()->updateOrCreate( + ['handle' => $app['handle']], + [ + 'name' => $app['name'], + 'status' => 'active', + ], + ); + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 3653acc1..fe95dca5 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -13,11 +13,15 @@ public function run(): void { $this->call([ OrganizationSeeder::class, + AppSeeder::class, StoreSeeder::class, StoreDomainSeeder::class, UserSeeder::class, StoreUserSeeder::class, StoreSettingsSeeder::class, + AppInstallationSeeder::class, + OauthClientSeeder::class, + OauthTokenSeeder::class, CollectionSeeder::class, ProductSeeder::class, SearchSettingsSeeder::class, @@ -26,6 +30,9 @@ public function run(): void DiscountSeeder::class, CustomerSeeder::class, OrderSeeder::class, + AnalyticsDailySeeder::class, + AnalyticsEventSeeder::class, + WebhookSubscriptionSeeder::class, ThemeSeeder::class, PageSeeder::class, NavigationSeeder::class, diff --git a/database/seeders/OauthClientSeeder.php b/database/seeders/OauthClientSeeder.php new file mode 100644 index 00000000..62dd0729 --- /dev/null +++ b/database/seeders/OauthClientSeeder.php @@ -0,0 +1,25 @@ +orderBy('id')->get()->each(function (App $app): void { + OauthClient::query()->updateOrCreate( + ['app_id' => $app->id], + [ + 'client_id' => 'client_'.$app->handle, + 'client_secret_encrypted' => Str::random(48), + 'redirect_uris_json' => ['https://example.com/oauth/callback'], + ], + ); + }); + } +} diff --git a/database/seeders/OauthTokenSeeder.php b/database/seeders/OauthTokenSeeder.php new file mode 100644 index 00000000..1ad64423 --- /dev/null +++ b/database/seeders/OauthTokenSeeder.php @@ -0,0 +1,25 @@ +get()->each(function (AppInstallation $installation): void { + OauthToken::query()->updateOrCreate( + ['installation_id' => $installation->id], + [ + 'access_token_hash' => hash('sha256', Str::random(64)), + 'refresh_token_hash' => hash('sha256', Str::random(64)), + 'expires_at' => now()->addHour(), + ], + ); + }); + } +} diff --git a/database/seeders/WebhookDeliverySeeder.php b/database/seeders/WebhookDeliverySeeder.php new file mode 100644 index 00000000..154720b0 --- /dev/null +++ b/database/seeders/WebhookDeliverySeeder.php @@ -0,0 +1,16 @@ +where('handle', 'acme-fashion')->firstOrFail(); + + WebhookSubscription::withoutGlobalScopes()->updateOrCreate( + [ + 'store_id' => $store->id, + 'event_type' => 'order.created', + 'target_url' => 'https://example.com/webhooks/orders', + ], + [ + 'signing_secret_encrypted' => Str::random(40), + 'status' => 'disabled', + ], + ); + } +} diff --git a/resources/views/livewire/admin/analytics/index.blade.php b/resources/views/livewire/admin/analytics/index.blade.php index 7454037b..5b7cfc48 100644 --- a/resources/views/livewire/admin/analytics/index.blade.php +++ b/resources/views/livewire/admin/analytics/index.blade.php @@ -1,15 +1,23 @@
Analytics - Sales and order movement for the active store. + Sales, traffic, and conversion for the active store. +
+ +
+ + + + +
@foreach ([ ['label' => 'Total sales', 'value' => $totalSales], - ['label' => 'Paid orders', 'value' => $paidOrders], - ['label' => 'Pending orders', 'value' => $pendingOrders], - ['label' => 'Refunded orders', 'value' => $refundedOrders], + ['label' => 'Orders', 'value' => $summary['orders_count']], + ['label' => 'Average order value', 'value' => $averageOrderValue], + ['label' => 'Conversion rate', 'value' => number_format($summary['conversion_rate'] * 100, 2).'%'], ] as $tile)
{{ $tile['label'] }}
@@ -19,17 +27,30 @@
- Daily orders + Daily revenue
- @foreach ($dailyOrders as $point) + @foreach ($dailyMetrics as $point)
{{ $point['date'] }}
-
+
-
{{ $point['count'] }}
+
{{ $point['orders_count'] }}
@endforeach
+ +
+ @foreach ([ + ['label' => 'Visits', 'value' => $summary['visits_count']], + ['label' => 'Add to cart', 'value' => $summary['add_to_cart_count']], + ['label' => 'Checkout started', 'value' => $summary['checkout_started_count']], + ] as $tile) +
+
{{ $tile['label'] }}
+
{{ $tile['value'] }}
+
+ @endforeach +
diff --git a/resources/views/livewire/admin/apps/index.blade.php b/resources/views/livewire/admin/apps/index.blade.php index a962b1a4..590beb8d 100644 --- a/resources/views/livewire/admin/apps/index.blade.php +++ b/resources/views/livewire/admin/apps/index.blade.php @@ -4,12 +4,26 @@ Installable integrations for store operations.
-
- @foreach ($apps as $app) - - {{ $app['name'] }} - {{ $app['status'] }} - +
+ @foreach ($installedApps as $installation) +
+ + Uninstall +
+ @endforeach + + @foreach ($availableApps as $app) +
+ {{ $app->name }} + Available + Install +
@endforeach
diff --git a/resources/views/livewire/admin/apps/show.blade.php b/resources/views/livewire/admin/apps/show.blade.php index 39615705..4c6b23be 100644 --- a/resources/views/livewire/admin/apps/show.blade.php +++ b/resources/views/livewire/admin/apps/show.blade.php @@ -1,7 +1,7 @@
- {{ \Illuminate\Support\Str::headline($installation) }} + {{ $app->name }} Scopes, webhooks, and installation state.
@@ -10,9 +10,9 @@
-
Status
Available
-
Scopes
Read products, read orders
-
Webhooks
Not configured
+
Status
{{ $appInstallation?->status ?? 'Available' }}
+
Scopes
{{ $appInstallation ? implode(', ', $appInstallation->scopes_json) : 'Not installed' }}
+
Webhooks
{{ $appInstallation?->webhookSubscriptions->count() ?? 0 }}
diff --git a/resources/views/livewire/admin/developers/index.blade.php b/resources/views/livewire/admin/developers/index.blade.php index c00e393e..9c86ea35 100644 --- a/resources/views/livewire/admin/developers/index.blade.php +++ b/resources/views/livewire/admin/developers/index.blade.php @@ -4,18 +4,109 @@ API credentials and webhook delivery settings.
-
+
API tokens -
- Personal access token storage will be enabled in the apps and webhooks phase. + @if($generatedToken) +
+
Copy this token now. It will not be shown again.
+ {{ $generatedToken }} +
+ @endif + +
+ +
+
Abilities
+
+ @foreach($availableAbilities as $ability) + + @endforeach +
+
+ Generate token + + +
+ + + + + + + + + + + @forelse($tokens as $token) + + + + + + + @empty + + @endforelse + +
NameLast usedStatusActions
{{ $token->name }}{{ $token->last_used_at?->diffForHumans() ?? 'Never' }} + {{ $token->revoked_at ? 'Revoked' : 'Active' }} + + @if(! $token->revoked_at) + Revoke + @endif +
No API tokens.
- Webhook subscriptions -
- Webhook subscriptions will appear here after delivery tables are created. + Webhooks +
+ + @foreach($webhookEventTypes as $eventType) + + @endforeach + + +
+ {{ $editingWebhookId ? 'Save webhook' : 'Add webhook' }} + @if($editingWebhookId) + Cancel + @endif +
+ + +
+ + + + + + + + + + + @forelse($webhooks as $webhook) + + + + + + + @empty + + @endforelse + +
EventURLStatusActions
{{ $webhook->event_type }}{{ $webhook->target_url }}{{ $webhook->status }} +
+ Edit + Delete +
+
No webhook subscriptions.
diff --git a/routes/api.php b/routes/api.php index 148f3b8f..36056e89 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,7 @@ name('api.storefront.search.suggest'); }); + Route::post('/analytics/events', [AnalyticsController::class, 'store']) + ->middleware('throttle:analytics') + ->name('api.storefront.analytics.events'); + Route::post('/carts', [CartController::class, 'store'])->name('api.storefront.carts.store'); Route::get('/carts/{cartId}', [CartController::class, 'show'])->name('api.storefront.carts.show'); Route::post('/carts/{cartId}/lines', [CartController::class, 'storeLine'])->name('api.storefront.carts.lines.store'); @@ -30,3 +36,11 @@ Route::delete('/checkouts/{checkoutId}/discount', [CheckoutController::class, 'removeDiscount'])->name('api.storefront.checkouts.remove-discount'); }); }); + +Route::prefix('admin/v1') + ->middleware('throttle:api.admin') + ->group(function (): void { + Route::get('/stores/{store}/analytics/summary', [AnalyticsSummaryController::class, 'show']) + ->middleware('api.token:read-analytics') + ->name('api.admin.analytics.summary'); + }); diff --git a/routes/console.php b/routes/console.php index 79fd6066..d93e7d7e 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,5 +1,6 @@ everyFifteenMinutes(); Schedule::job(new CleanupAbandonedCarts)->daily(); Schedule::job(new CancelUnpaidBankTransferOrders)->daily(); +Schedule::job(new AggregateAnalytics)->dailyAt('01:00'); diff --git a/specs/progress.md b/specs/progress.md index 8f2163e6..c71c09c0 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -15,9 +15,10 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Phase 4 cart/checkout/pricing is implemented and verified: carts, cart lines, checkouts, shipping zones/rates, tax settings, discounts, cart and checkout services, pricing snapshots, storefront REST endpoints, Livewire cart/checkout UI, and cleanup jobs. - Phase 5 payments/orders/customer persistence is implemented and verified: customers, customer addresses, orders, order lines, payments, refunds, fulfillments, mock PSP, checkout pay endpoint/UI, order confirmation, bank-transfer confirmation/cancellation services, and focused tests. - Phase 6 customer accounts are implemented and verified: store-scoped customer login/registration, account dashboard, order history/detail pages, address book CRUD, and seeded customer credentials. -- Phase 7 admin panel is implemented and verified: admin login, admin shell/store switcher, dashboard, product/order/customer/discount/inventory/settings/theme/page/navigation surfaces, basic analytics, apps, developer, and search-settings pages. +- Phase 7 admin panel is implemented and verified: admin login, admin shell/store switcher, dashboard, product/order/customer/discount/inventory/settings/theme/page/navigation surfaces, analytics, apps, developer, and search-settings pages. - SQLite FTS5 search is implemented and verified: search settings/query tables, product FTS indexing, product observer sync, storefront search page, header search modal, search API, seeded synonyms/stop words, and admin reindex action. -- Analytics ingestion, API tokens, app installs, and webhooks are not implemented yet. +- Analytics ingestion and aggregation are implemented and verified: storefront batch event API, client event deduplication, seeded daily/event analytics, daily aggregation job, admin analytics summary API, and admin analytics dashboard. +- Apps, API tokens, and webhooks are implemented and verified: apps/installations/OAuth metadata, developer API token generation/revocation, store-scoped token middleware, webhook subscriptions, signed delivery jobs, retry/failure tracking, and app admin screens. ## Execution Plan @@ -43,10 +44,9 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit 7. **Admin panel** - Implemented and verified - Add admin shell, dashboard, resource management pages, settings, themes, pages, navigation, analytics, apps, and developers surfaces. - Verify admin login, store switching, product/order/discount/settings flows. -8. **Search, analytics, apps, webhooks** - Partially implemented +8. **Search, analytics, apps, webhooks** - Implemented and verified - SQLite FTS5 search is implemented and verified. - - Analytics ingestion/aggregation, API token support, app installs, webhook dispatch/signing/delivery remain pending. - - Verify analytics and webhook tests once those slices land. + - Analytics ingestion/aggregation, API token support, app installs, webhook dispatch/signing/delivery are implemented and verified. 9. **Polish and completion audit** - Pending - Run full Pest suite, style formatting, fresh migration/seeding, Playwright customer/admin flows, responsive and browser log review. - Update this file with final evidence and close all gaps. @@ -65,6 +65,7 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Phase 5 adds the `customers` table immediately before cart/checkout migrations and converts `carts.customer_id` and `checkouts.customer_id` to nullable foreign keys for fresh installs. - `orders.checkout_id` is intentionally added beyond the table list so `OrderService::createFromCheckout()` can enforce idempotency with a durable unique key. - Admin authentication uses a dedicated Livewire `/admin/login` screen on the existing `web` guard while leaving the starter-kit Fortify `/login` flow intact for existing auth/settings tests. +- API token requirements mention Sanctum, but Sanctum is not installed and dependencies cannot be changed without approval. The developers/API slice uses a first-party hashed-token table and route middleware to satisfy store-scoped token generation, revocation, and ability checks without adding dependencies. ## Open Gaps @@ -75,8 +76,7 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Discounts, shipping, and tax are implemented for the specified local/manual flows; provider/carrier integrations remain stubs by design. - Order-reference guards in product deletion/status logic are present but only become fully meaningful once `order_lines` exists in Phase 5. - Customer account password reset UI and emails remain deferred; login, registration, dashboard, order history/detail, and address book flows are implemented. -- Admin surfaces are implemented for the current data model; later analytics ingestion, apps, API tokens, and webhook backend phases remain unimplemented. -- API token requirements mention Sanctum, but the package is not currently installed. This remains an open dependency decision for the API/developers phase because dependencies must not be changed without approval. +- Admin analytics, apps, API tokens, and webhook backend flows are implemented for the current data model. A future dependency decision could replace the first-party token table with Sanctum if package changes are approved. ## Verification Log @@ -131,3 +131,12 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - 2026-05-03: `npm run build` passed for the updated search UI assets. - 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed with FTS5 search migrations, seeded search settings, and reindexed seeded products. - 2026-05-03: Playwright smoke completed `/search?q=linen`, header search modal suggestions for `lin`, and `api/storefront/v1/search?q=linen` at `http://shop.test`; latest browser console checks reported no current warnings or errors. +- 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed with analytics, apps, OAuth metadata, webhooks, and API token migrations/seed data. +- 2026-05-03: `php artisan test --compact tests/Feature/Analytics/AnalyticsTest.php tests/Feature/Webhooks/WebhookDeliveryTest.php tests/Feature/Developers/DeveloperIntegrationsTest.php` passed, 7 tests / 49 assertions. +- 2026-05-03: `php artisan test --compact tests/Feature/Analytics/AnalyticsTest.php tests/Feature/Webhooks/WebhookDeliveryTest.php tests/Feature/Developers/DeveloperIntegrationsTest.php tests/Feature/Admin/AdminPanelTest.php tests/Feature/Search/SearchTest.php tests/Feature/Api/StorefrontCartApiTest.php tests/Feature/Api/StorefrontCheckoutPaymentApiTest.php` passed, 23 tests / 190 assertions. +- 2026-05-03: `vendor/bin/pint --dirty --format agent` passed and fixed generated seeder imports plus console route import ordering. +- 2026-05-03: `php artisan route:list --path=api --except-vendor` passed and showed 17 API routes, including storefront analytics ingestion and admin analytics summary. +- 2026-05-03: `php artisan test --compact` passed, 97 tests / 428 assertions. +- 2026-05-03: `npm run build` passed for the updated analytics/apps/developer admin UI assets. +- 2026-05-03: Playwright smoke completed admin analytics date-range interaction, developer API token generation, webhook creation, apps list, and app detail at `http://shop.test/admin`; latest browser console checks reported no warnings or errors. +- 2026-05-03: HTTP smoke through Herd returned `202` for `POST /api/storefront/v1/analytics/events` and `200` for token-protected `GET /api/admin/v1/stores/1/analytics/summary`. diff --git a/tests/Feature/Analytics/AnalyticsTest.php b/tests/Feature/Analytics/AnalyticsTest.php new file mode 100644 index 00000000..dd72063f --- /dev/null +++ b/tests/Feature/Analytics/AnalyticsTest.php @@ -0,0 +1,130 @@ +seed(); + $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $this->user = User::query()->where('email', 'admin@example.com')->firstOrFail(); +}); + +test('storefront analytics api accepts batches and deduplicates client events', function (): void { + $occurredAt = now()->toISOString(); + $payload = [ + 'events' => [ + [ + 'type' => 'page_view', + 'session_id' => 'sess-feature-analytics', + 'client_event_id' => 'evt-feature-page-view', + 'properties' => ['url' => '/products/linen-shirt'], + 'occurred_at' => $occurredAt, + ], + [ + 'type' => 'checkout_completed', + 'session_id' => 'sess-feature-analytics', + 'client_event_id' => 'evt-feature-checkout', + 'properties' => ['order_id' => 999, 'total_amount' => 12900], + 'occurred_at' => $occurredAt, + ], + ], + ]; + + $this->postJson('http://shop.test/api/storefront/v1/analytics/events', $payload) + ->assertStatus(202) + ->assertJson([ + 'accepted' => 2, + 'rejected' => 0, + ]); + + $this->postJson('http://shop.test/api/storefront/v1/analytics/events', $payload) + ->assertStatus(202) + ->assertJson([ + 'accepted' => 0, + 'rejected' => 0, + ]); + + expect(AnalyticsEvent::withoutGlobalScopes() + ->where('store_id', $this->store->id) + ->whereIn('client_event_id', ['evt-feature-page-view', 'evt-feature-checkout']) + ->count())->toBe(2); +}); + +test('analytics aggregation is idempotent for a daily metric row', function (): void { + $analytics = app(AnalyticsService::class); + $date = Carbon::parse('2026-03-20 12:00:00'); + + $analytics->track($this->store, 'page_view', ['url' => '/'], 'sess-one', null, 'evt-agg-page-one', $date); + $analytics->track($this->store, 'page_view', ['url' => '/products/linen-shirt'], 'sess-one', null, 'evt-agg-page-two', $date->copy()->addMinute()); + $analytics->track($this->store, 'page_view', ['url' => '/collections/summer-essentials'], 'sess-two', null, 'evt-agg-page-three', $date->copy()->addMinutes(2)); + $analytics->track($this->store, 'add_to_cart', ['variant_id' => 1], 'sess-two', null, 'evt-agg-cart', $date->copy()->addMinutes(3)); + $analytics->track($this->store, 'checkout_started', ['cart_total' => 14000], 'sess-two', null, 'evt-agg-started', $date->copy()->addMinutes(4)); + $analytics->track($this->store, 'checkout_completed', ['order_id' => 123, 'total_amount' => 14000], 'sess-two', null, 'evt-agg-checkout-one', $date->copy()->addMinutes(5)); + $analytics->track($this->store, 'checkout_completed', ['order_id' => 124, 'total_amount' => 6000], 'sess-three', null, 'evt-agg-checkout-two', $date->copy()->addMinutes(6)); + + app(AggregateAnalytics::class, ['date' => $date->toDateString()])->handle($analytics); + app(AggregateAnalytics::class, ['date' => $date->toDateString()])->handle($analytics); + + $metric = DB::table('analytics_daily') + ->where('store_id', $this->store->id) + ->where('date', $date->toDateString()) + ->first(); + + expect($metric)->not->toBeNull() + ->and($metric->orders_count)->toBe(2) + ->and($metric->revenue_amount)->toBe(20000) + ->and($metric->aov_amount)->toBe(10000) + ->and($metric->visits_count)->toBe(2) + ->and($metric->add_to_cart_count)->toBe(1) + ->and($metric->checkout_started_count)->toBe(1) + ->and($metric->checkout_completed_count)->toBe(2); +}); + +test('admin analytics summary api requires a store scoped token ability', function (): void { + DB::table('analytics_daily')->updateOrInsert( + [ + 'store_id' => $this->store->id, + 'date' => '2026-04-01', + ], + [ + 'orders_count' => 2, + 'revenue_amount' => 15000, + 'aov_amount' => 7500, + 'visits_count' => 20, + 'add_to_cart_count' => 5, + 'checkout_started_count' => 3, + 'checkout_completed_count' => 2, + ], + ); + + $token = app(ApiTokenService::class)->create($this->store, $this->user, 'Analytics integration', ['read-analytics']); + $url = route('api.admin.analytics.summary', $this->store).'?from=2026-04-01&to=2026-04-01'; + + $this->getJson($url)->assertUnauthorized(); + + $this->withToken($token['plain_text_token']) + ->getJson($url) + ->assertOk() + ->assertJsonPath('data.summary.orders_count', 2) + ->assertJsonPath('data.summary.revenue_amount', 15000) + ->assertJsonPath('data.summary.currency', $this->store->default_currency); + + expect($token['token']->fresh()->last_used_at)->not->toBeNull(); + + $wrongAbility = app(ApiTokenService::class)->create($this->store, $this->user, 'Catalog integration', ['read-products']); + + $this->withToken($wrongAbility['plain_text_token']) + ->getJson($url) + ->assertForbidden(); +}); diff --git a/tests/Feature/Developers/DeveloperIntegrationsTest.php b/tests/Feature/Developers/DeveloperIntegrationsTest.php new file mode 100644 index 00000000..f86ad995 --- /dev/null +++ b/tests/Feature/Developers/DeveloperIntegrationsTest.php @@ -0,0 +1,82 @@ +seed(); + $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $this->user = User::query()->where('email', 'admin@example.com')->firstOrFail(); + + $this->actingAs($this->user); + session(['current_store_id' => $this->store->id]); + app()->instance('current_store', $this->store); +}); + +test('admin can generate and revoke api tokens from the developer page', function (): void { + Livewire::test(DevelopersIndex::class) + ->set('newTokenName', 'Reporting sync') + ->set('tokenAbilities', ['read-analytics']) + ->call('generateToken') + ->assertHasNoErrors() + ->assertSet('newTokenName', '') + ->assertSee('shop_'); + + $token = ApiToken::withoutGlobalScopes() + ->where('store_id', $this->store->id) + ->where('name', 'Reporting sync') + ->firstOrFail(); + + expect($token->abilities_json)->toBe(['read-analytics']) + ->and($token->revoked_at)->toBeNull(); + + Livewire::test(DevelopersIndex::class) + ->call('revokeToken', $token->id) + ->assertHasNoErrors(); + + expect($token->fresh()->revoked_at)->not->toBeNull(); +}); + +test('admin can create edit and delete webhook subscriptions', function (): void { + Livewire::test(DevelopersIndex::class) + ->set('webhookEventType', 'order.paid') + ->set('webhookUrl', 'https://example.com/hooks/orders') + ->call('saveWebhook') + ->assertHasNoErrors() + ->assertSet('editingWebhookId', null) + ->assertSet('webhookUrl', '') + ->assertSee('order.paid'); + + $webhook = WebhookSubscription::withoutGlobalScopes() + ->where('store_id', $this->store->id) + ->where('target_url', 'https://example.com/hooks/orders') + ->firstOrFail(); + + $signingSecret = $webhook->signing_secret_encrypted; + + Livewire::test(DevelopersIndex::class) + ->call('editWebhook', $webhook->id) + ->assertSet('editingWebhookId', $webhook->id) + ->assertSet('webhookEventType', 'order.paid') + ->set('webhookUrl', 'https://example.com/hooks/orders-updated') + ->call('saveWebhook') + ->assertHasNoErrors(); + + expect($webhook->fresh()->target_url)->toBe('https://example.com/hooks/orders-updated') + ->and($webhook->fresh()->signing_secret_encrypted)->toBe($signingSecret); + + Livewire::test(DevelopersIndex::class) + ->call('deleteWebhook', $webhook->id) + ->assertHasNoErrors(); + + expect(WebhookSubscription::withoutGlobalScopes()->whereKey($webhook->id)->exists())->toBeFalse(); +}); diff --git a/tests/Feature/Webhooks/WebhookDeliveryTest.php b/tests/Feature/Webhooks/WebhookDeliveryTest.php new file mode 100644 index 00000000..c0900605 --- /dev/null +++ b/tests/Feature/Webhooks/WebhookDeliveryTest.php @@ -0,0 +1,93 @@ +seed(); + $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); +}); + +test('webhook service delivers signed json payloads to active subscriptions', function (): void { + Http::preventStrayRequests(); + Http::fake([ + 'https://example.com/webhooks/orders' => Http::response('ok', 200), + ]); + + $subscription = WebhookSubscription::factory() + ->for($this->store) + ->create([ + 'event_type' => 'order.created', + 'target_url' => 'https://example.com/webhooks/orders', + 'signing_secret_encrypted' => 'top-secret', + 'status' => 'active', + ]); + + app(WebhookService::class)->dispatch($this->store, 'order.created', [ + 'id' => 123, + 'order_number' => '#123', + ]); + + Http::assertSent(function (Request $request): bool { + $body = $request->body(); + $timestamp = $request->header('X-Platform-Timestamp')[0] ?? ''; + $expectedSignature = hash_hmac('sha256', $timestamp.'.'.$body, 'top-secret'); + + return $request->url() === 'https://example.com/webhooks/orders' + && $request->method() === 'POST' + && $request->hasHeader('Content-Type', 'application/json') + && $request->hasHeader('X-Platform-Event', 'order.created') + && $request->hasHeader('X-Platform-Signature', $expectedSignature) + && data_get(json_decode($body, true, flags: JSON_THROW_ON_ERROR), 'data.order_number') === '#123'; + }); + + $delivery = WebhookDelivery::query() + ->where('subscription_id', $subscription->id) + ->firstOrFail(); + + expect($delivery->status)->toBe('success') + ->and($delivery->attempt_count)->toBe(1) + ->and($delivery->response_code)->toBe(200) + ->and($subscription->fresh()->consecutive_failures)->toBe(0); +}); + +test('failed webhook deliveries are recorded and pause repeated failures', function (): void { + Http::preventStrayRequests(); + Http::fake([ + 'https://example.com/webhooks/failing' => Http::response('unavailable', 503), + ]); + + $subscription = WebhookSubscription::factory() + ->for($this->store) + ->create([ + 'event_type' => 'order.paid', + 'target_url' => 'https://example.com/webhooks/failing', + 'signing_secret_encrypted' => 'top-secret', + 'status' => 'active', + 'consecutive_failures' => 4, + ]); + + expect(fn () => app(WebhookService::class)->dispatch($this->store, 'order.paid', [ + 'id' => 456, + 'order_number' => '#456', + ]))->toThrow(\RuntimeException::class); + + $delivery = WebhookDelivery::query() + ->where('subscription_id', $subscription->id) + ->firstOrFail(); + + expect($delivery->status)->toBe('failed') + ->and($delivery->attempt_count)->toBe(1) + ->and($delivery->response_code)->toBe(503) + ->and($subscription->fresh()->consecutive_failures)->toBe(5) + ->and($subscription->fresh()->status)->toBe('paused'); +}); From aece43830b1b7e5758e9c8e099e4425ef42f892d Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 3 May 2026 16:47:32 +0200 Subject: [PATCH 14/35] Add security order status polish --- app/Actions/SanitizeHtml.php | 160 ++++++++++++++++++ .../Api/Storefront/OrderController.php | 67 ++++++++ app/Livewire/Admin/Collections/Form.php | 5 +- app/Livewire/Admin/Pages/Form.php | 5 +- app/Services/OrderService.php | 14 ++ app/Services/ProductService.php | 10 ++ config/logging.php | 8 + resources/views/errors/404.blade.php | 19 +++ resources/views/errors/503.blade.php | 18 ++ routes/api.php | 3 + specs/progress.md | 6 + .../Api/StorefrontOrderStatusApiTest.php | 40 +++++ tests/Feature/Security/HtmlSanitizerTest.php | 78 +++++++++ 13 files changed, 429 insertions(+), 4 deletions(-) create mode 100644 app/Actions/SanitizeHtml.php create mode 100644 app/Http/Controllers/Api/Storefront/OrderController.php create mode 100644 resources/views/errors/404.blade.php create mode 100644 resources/views/errors/503.blade.php create mode 100644 tests/Feature/Api/StorefrontOrderStatusApiTest.php create mode 100644 tests/Feature/Security/HtmlSanitizerTest.php diff --git a/app/Actions/SanitizeHtml.php b/app/Actions/SanitizeHtml.php new file mode 100644 index 00000000..7abedc99 --- /dev/null +++ b/app/Actions/SanitizeHtml.php @@ -0,0 +1,160 @@ +> + */ + private const ALLOWED_ELEMENTS = [ + 'a' => ['href'], + 'blockquote' => [], + 'br' => [], + 'div' => [], + 'em' => [], + 'h1' => [], + 'h2' => [], + 'h3' => [], + 'h4' => [], + 'h5' => [], + 'h6' => [], + 'img' => ['src', 'alt'], + 'li' => [], + 'ol' => [], + 'p' => [], + 'span' => [], + 'strong' => [], + 'table' => [], + 'tbody' => [], + 'td' => [], + 'th' => [], + 'thead' => [], + 'tr' => [], + 'u' => [], + 'ul' => [], + ]; + + /** + * @var list + */ + private const DROP_WITH_CONTENT = ['script', 'style', 'iframe', 'object', 'embed']; + + public function __invoke(?string $html): ?string + { + $html = trim((string) $html); + + if ($html === '') { + return null; + } + + $previous = libxml_use_internal_errors(true); + $document = new \DOMDocument('1.0', 'UTF-8'); + $document->loadHTML( + '
'.$html.'
', + LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING, + ); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + $root = $document->getElementById('sanitize-root'); + + if (! $root instanceof \DOMElement) { + return null; + } + + $this->sanitizeChildren($root); + + $clean = ''; + + foreach ($root->childNodes as $child) { + $clean .= $document->saveHTML($child); + } + + $clean = trim($clean); + + return $clean === '' ? null : $clean; + } + + private function sanitizeChildren(\DOMNode $parent): void + { + foreach (iterator_to_array($parent->childNodes) as $node) { + if ($node->nodeType === XML_COMMENT_NODE) { + $parent->removeChild($node); + + continue; + } + + if (! $node instanceof \DOMElement) { + continue; + } + + $tagName = strtolower($node->tagName); + + if (in_array($tagName, self::DROP_WITH_CONTENT, true)) { + $parent->removeChild($node); + + continue; + } + + $this->sanitizeChildren($node); + + if (! array_key_exists($tagName, self::ALLOWED_ELEMENTS)) { + $this->unwrap($node); + + continue; + } + + $this->sanitizeAttributes($node, self::ALLOWED_ELEMENTS[$tagName]); + + if (! in_array($tagName, ['br', 'img'], true) && trim($node->textContent) === '' && $node->childElementCount === 0) { + $node->parentNode?->removeChild($node); + } + } + } + + /** + * @param list $allowedAttributes + */ + private function sanitizeAttributes(\DOMElement $node, array $allowedAttributes): void + { + foreach (iterator_to_array($node->attributes) as $attribute) { + $name = strtolower($attribute->name); + $value = trim($attribute->value); + + if (! in_array($name, $allowedAttributes, true) || ! $this->isSafeAttribute($name, $value)) { + $node->removeAttribute($attribute->name); + } + } + } + + private function isSafeAttribute(string $name, string $value): bool + { + if (! in_array($name, ['href', 'src'], true)) { + return true; + } + + if (str_starts_with($value, '/') || str_starts_with($value, '#')) { + return true; + } + + $scheme = parse_url($value, PHP_URL_SCHEME); + + return in_array($scheme, $name === 'href' ? ['http', 'https', 'mailto'] : ['http', 'https'], true); + } + + private function unwrap(\DOMElement $node): void + { + $parent = $node->parentNode; + + if (! $parent instanceof \DOMNode) { + return; + } + + while ($node->firstChild instanceof \DOMNode) { + $parent->insertBefore($node->firstChild, $node); + } + + $parent->removeChild($node); + } +} diff --git a/app/Http/Controllers/Api/Storefront/OrderController.php b/app/Http/Controllers/Api/Storefront/OrderController.php new file mode 100644 index 00000000..e1de7c0c --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/OrderController.php @@ -0,0 +1,67 @@ +with('lines.variant.optionValues.option', 'payments', 'fulfillments.lines') + ->where('store_id', app('current_store')->id) + ->where('order_number', $this->normalizeOrderNumber($orderNumber)) + ->firstOrFail(); + + $token = (string) $request->query('token', ''); + + if ($token === '' || ! $orders->validAccessToken($order, $token)) { + abort(401, 'Invalid order access token.'); + } + + return response()->json([ + 'order_number' => $order->order_number, + 'status' => $order->status->value, + 'financial_status' => $order->financial_status->value, + 'fulfillment_status' => $order->fulfillment_status->value, + 'email' => $order->email, + 'currency' => $order->currency, + 'placed_at' => $order->placed_at?->toISOString(), + 'lines' => $order->lines->map(fn ($line): array => [ + 'title_snapshot' => $line->title_snapshot, + 'variant_title' => $line->variant?->optionValues?->pluck('value')->join(' / ') ?: null, + 'sku_snapshot' => $line->sku_snapshot, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'total_amount' => $line->total_amount, + ])->values()->all(), + 'totals' => [ + 'subtotal_amount' => $order->subtotal_amount, + 'discount_amount' => $order->discount_amount, + 'shipping_amount' => $order->shipping_amount, + 'tax_amount' => $order->tax_amount, + 'total_amount' => $order->total_amount, + ], + 'shipping_address' => $order->shipping_address_json ?? [], + 'fulfillments' => $order->fulfillments->map(fn ($fulfillment): array => [ + 'id' => $fulfillment->id, + 'status' => $fulfillment->status->value, + 'tracking_company' => $fulfillment->tracking_company, + 'tracking_number' => $fulfillment->tracking_number, + 'tracking_url' => $fulfillment->tracking_url, + 'shipped_at' => $fulfillment->shipped_at?->toISOString(), + 'delivered_at' => $fulfillment->delivered_at?->toISOString(), + ])->values()->all(), + ]); + } + + private function normalizeOrderNumber(string $orderNumber): string + { + return str_starts_with($orderNumber, '#') ? $orderNumber : '#'.$orderNumber; + } +} diff --git a/app/Livewire/Admin/Collections/Form.php b/app/Livewire/Admin/Collections/Form.php index 89170b8e..955ef150 100644 --- a/app/Livewire/Admin/Collections/Form.php +++ b/app/Livewire/Admin/Collections/Form.php @@ -2,6 +2,7 @@ namespace App\Livewire\Admin\Collections; +use App\Actions\SanitizeHtml; use App\Enums\CollectionStatus; use App\Livewire\Admin\Concerns\UsesAdminStore; use App\Models\Collection as ProductCollection; @@ -38,7 +39,7 @@ public function mount(?ProductCollection $collection = null): void $this->status = $this->collection->status->value; } - public function save(HandleGenerator $handles): mixed + public function save(HandleGenerator $handles, SanitizeHtml $sanitizeHtml): mixed { $validated = $this->validate([ 'title' => ['required', 'string', 'max:255'], @@ -51,7 +52,7 @@ public function save(HandleGenerator $handles): mixed 'store_id' => $this->currentStore()->id, 'title' => $validated['title'], 'handle' => $validated['handle'] ?: $handles->generate($validated['title'], (new ProductCollection)->getTable(), $this->currentStore()->id, $this->collection?->id), - 'description_html' => $validated['descriptionHtml'], + 'description_html' => $sanitizeHtml($validated['descriptionHtml']), 'status' => $validated['status'], ]; diff --git a/app/Livewire/Admin/Pages/Form.php b/app/Livewire/Admin/Pages/Form.php index da069fcf..f00b27d8 100644 --- a/app/Livewire/Admin/Pages/Form.php +++ b/app/Livewire/Admin/Pages/Form.php @@ -2,6 +2,7 @@ namespace App\Livewire\Admin\Pages; +use App\Actions\SanitizeHtml; use App\Enums\PageStatus; use App\Livewire\Admin\Concerns\UsesAdminStore; use App\Models\Page; @@ -38,7 +39,7 @@ public function mount(?Page $page = null): void $this->status = $this->page->status->value; } - public function save(HandleGenerator $handles): mixed + public function save(HandleGenerator $handles, SanitizeHtml $sanitizeHtml): mixed { $validated = $this->validate([ 'title' => ['required', 'string', 'max:255'], @@ -51,7 +52,7 @@ public function save(HandleGenerator $handles): mixed 'store_id' => $this->currentStore()->id, 'title' => $validated['title'], 'handle' => $validated['handle'] ?: $handles->generate($validated['title'], (new Page)->getTable(), $this->currentStore()->id, $this->page?->id), - 'body_html' => $validated['bodyHtml'], + 'body_html' => $sanitizeHtml($validated['bodyHtml']), 'status' => $validated['status'], 'published_at' => $validated['status'] === PageStatus::Published->value ? now() : null, ]; diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 5c5f29b9..07d195c9 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -159,6 +159,20 @@ public function generateOrderNumber(Store $store): string return $prefix.($lastNumber + 1); } + public function accessToken(Order $order): string + { + return hash_hmac( + 'sha256', + implode('|', [$order->store_id, $order->id, $order->order_number]), + (string) config('app.key'), + ); + } + + public function validAccessToken(Order $order, string $token): bool + { + return hash_equals($this->accessToken($order), $token); + } + public function confirmBankTransfer(Order $order): Order { return DB::transaction(function () use ($order): Order { diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php index 79a606a1..96ff5466 100644 --- a/app/Services/ProductService.php +++ b/app/Services/ProductService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Actions\SanitizeHtml; use App\Enums\ProductStatus; use App\Enums\VariantStatus; use App\Events\ProductStatusChanged; @@ -19,6 +20,7 @@ class ProductService public function __construct( private readonly HandleGenerator $handleGenerator, private readonly VariantMatrixService $variantMatrixService, + private readonly SanitizeHtml $sanitizeHtml, ) {} /** @@ -27,6 +29,10 @@ public function __construct( public function create(Store $store, array $data): Product { return DB::transaction(function () use ($store, $data): Product { + if (array_key_exists('description_html', $data)) { + $data['description_html'] = ($this->sanitizeHtml)($data['description_html']); + } + $product = Product::query()->create([ ...Arr::only($data, [ 'title', @@ -67,6 +73,10 @@ public function create(Store $store, array $data): Product public function update(Product $product, array $data): Product { return DB::transaction(function () use ($product, $data): Product { + if (array_key_exists('description_html', $data)) { + $data['description_html'] = ($this->sanitizeHtml)($data['description_html']); + } + $payload = Arr::only($data, [ 'title', 'status', diff --git a/config/logging.php b/config/logging.php index e975cedb..ee97f600 100644 --- a/config/logging.php +++ b/config/logging.php @@ -73,6 +73,14 @@ 'replace_placeholders' => true, ], + 'audit' => [ + 'driver' => 'single', + 'path' => storage_path('logs/audit.log'), + 'level' => env('LOG_LEVEL', 'info'), + 'formatter' => Monolog\Formatter\JsonFormatter::class, + 'replace_placeholders' => true, + ], + 'daily' => [ 'driver' => 'daily', 'path' => storage_path('logs/laravel.log'), diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php new file mode 100644 index 00000000..ed6414b3 --- /dev/null +++ b/resources/views/errors/404.blade.php @@ -0,0 +1,19 @@ + + + + + + Page not found + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+
+

404

+

Page not found

+

The page you requested is not available for this store.

+ Return home +
+
+ + diff --git a/resources/views/errors/503.blade.php b/resources/views/errors/503.blade.php new file mode 100644 index 00000000..98cf23f0 --- /dev/null +++ b/resources/views/errors/503.blade.php @@ -0,0 +1,18 @@ + + + + + + Store unavailable + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+
+

503

+

Store unavailable

+

This storefront is temporarily unavailable.

+
+
+ + diff --git a/routes/api.php b/routes/api.php index 36056e89..64eead8d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Api\Storefront\AnalyticsController; use App\Http\Controllers\Api\Storefront\CartController; use App\Http\Controllers\Api\Storefront\CheckoutController; +use App\Http\Controllers\Api\Storefront\OrderController; use App\Http\Controllers\Api\Storefront\SearchController; use Illuminate\Support\Facades\Route; @@ -19,6 +20,8 @@ ->middleware('throttle:analytics') ->name('api.storefront.analytics.events'); + Route::get('/orders/{orderNumber}', [OrderController::class, 'show'])->name('api.storefront.orders.show'); + Route::post('/carts', [CartController::class, 'store'])->name('api.storefront.carts.store'); Route::get('/carts/{cartId}', [CartController::class, 'show'])->name('api.storefront.carts.show'); Route::post('/carts/{cartId}/lines', [CartController::class, 'storeLine'])->name('api.storefront.carts.lines.store'); diff --git a/specs/progress.md b/specs/progress.md index c71c09c0..9db67736 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -66,6 +66,7 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - `orders.checkout_id` is intentionally added beyond the table list so `OrderService::createFromCheckout()` can enforce idempotency with a durable unique key. - Admin authentication uses a dedicated Livewire `/admin/login` screen on the existing `web` guard while leaving the starter-kit Fortify `/login` flow intact for existing auth/settings tests. - API token requirements mention Sanctum, but Sanctum is not installed and dependencies cannot be changed without approval. The developers/API slice uses a first-party hashed-token table and route middleware to satisfy store-scoped token generation, revocation, and ability checks without adding dependencies. +- Storefront order-status API access uses an HMAC token derived from store, order id, and order number because confirmation/status URLs are public and should not require customer login. ## Open Gaps @@ -140,3 +141,8 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - 2026-05-03: `npm run build` passed for the updated analytics/apps/developer admin UI assets. - 2026-05-03: Playwright smoke completed admin analytics date-range interaction, developer API token generation, webhook creation, apps list, and app detail at `http://shop.test/admin`; latest browser console checks reported no warnings or errors. - 2026-05-03: HTTP smoke through Herd returned `202` for `POST /api/storefront/v1/analytics/events` and `200` for token-protected `GET /api/admin/v1/stores/1/analytics/summary`. +- 2026-05-03: `php artisan test --compact tests/Feature/Security/HtmlSanitizerTest.php tests/Feature/Api/StorefrontOrderStatusApiTest.php` passed, 5 tests / 24 assertions. +- 2026-05-03: `vendor/bin/pint --dirty --format agent` passed and fixed the new security test import list. +- 2026-05-03: `php artisan route:list --path=api/storefront/v1/orders --except-vendor` passed and showed the signed storefront order-status route. +- 2026-05-03: `php artisan test --compact` passed, 102 tests / 452 assertions. +- 2026-05-03: `npm run build` passed after adding styled error pages. diff --git a/tests/Feature/Api/StorefrontOrderStatusApiTest.php b/tests/Feature/Api/StorefrontOrderStatusApiTest.php new file mode 100644 index 00000000..b6977e93 --- /dev/null +++ b/tests/Feature/Api/StorefrontOrderStatusApiTest.php @@ -0,0 +1,40 @@ +seed(); + $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $this->order = Order::withoutGlobalScopes() + ->where('store_id', $this->store->id) + ->where('order_number', '#1001') + ->firstOrFail(); +}); + +test('storefront order status api requires a valid order access token', function (): void { + $url = 'http://shop.test/api/storefront/v1/orders/'.ltrim($this->order->order_number, '#'); + + $this->getJson($url)->assertUnauthorized(); + + $this->getJson($url.'?token=wrong-token')->assertUnauthorized(); +}); + +test('storefront order status api returns order status for a valid token', function (): void { + $token = app(OrderService::class)->accessToken($this->order); + + $this->getJson('http://shop.test/api/storefront/v1/orders/'.ltrim($this->order->order_number, '#').'?token='.$token) + ->assertOk() + ->assertJsonPath('order_number', '#1001') + ->assertJsonPath('status', $this->order->status->value) + ->assertJsonPath('financial_status', $this->order->financial_status->value) + ->assertJsonPath('currency', $this->order->currency) + ->assertJsonPath('totals.total_amount', $this->order->total_amount) + ->assertJsonCount($this->order->lines()->count(), 'lines'); +}); diff --git a/tests/Feature/Security/HtmlSanitizerTest.php b/tests/Feature/Security/HtmlSanitizerTest.php new file mode 100644 index 00000000..72b3004e --- /dev/null +++ b/tests/Feature/Security/HtmlSanitizerTest.php @@ -0,0 +1,78 @@ +seed(); + $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $this->user = User::query()->where('email', 'admin@example.com')->firstOrFail(); +}); + +test('html sanitizer strips unsafe elements attributes and protocols', function (): void { + $html = app(SanitizeHtml::class)( + '

Safe copy

'. + 'bad'. + 'good'. + 'bad'. + 'Product'. + '
kept text
', + ); + + expect($html)->toContain('

Safe copy

') + ->and($html)->toContain('bad') + ->and($html)->toContain('good') + ->and($html)->toContain('bad') + ->and($html)->toContain('Product') + ->and($html)->toContain('kept text') + ->and($html)->not->toContain('script') + ->and($html)->not->toContain('onclick') + ->and($html)->not->toContain('javascript:') + ->and($html)->not->toContain('target=') + ->and($html)->not->toContain(''); +}); + +test('product service persists sanitized product descriptions', function (): void { + $product = app(ProductService::class)->create($this->store, [ + 'title' => 'Sanitized Product', + 'status' => 'draft', + 'description_html' => '

Details

Read', + 'price_amount' => 1200, + ]); + + expect($product->description_html)->toBe('

Details

Read'); + + $updated = app(ProductService::class)->update($product, [ + 'description_html' => '
xUpdated
', + ]); + + expect($updated->description_html)->toBe('
xUpdated
'); +}); + +test('admin page form persists sanitized page body html', function (): void { + $this->actingAs($this->user); + session(['current_store_id' => $this->store->id]); + app()->instance('current_store', $this->store); + + Livewire::test(PageForm::class) + ->set('title', 'Sanitized Page') + ->set('handle', 'sanitized-page') + ->set('bodyHtml', '

About

Email') + ->set('status', 'published') + ->call('save') + ->assertHasNoErrors(); + + $page = Page::query()->where('handle', 'sanitized-page')->firstOrFail(); + + expect($page->body_html)->toBe('

About

Email'); +}); From 15aa0695c13a067714a9f752217730b83a12676b Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 3 May 2026 16:57:33 +0200 Subject: [PATCH 15/35] Expand deterministic seed data --- database/seeders/CollectionSeeder.php | 37 ++- database/seeders/CustomerSeeder.php | 29 ++- database/seeders/DiscountSeeder.php | 50 ++-- database/seeders/OrderSeeder.php | 105 ++++++++ database/seeders/ProductSeeder.php | 231 ++++++++++++++++++ database/seeders/StoreDomainSeeder.php | 27 +- database/seeders/StoreSeeder.php | 27 +- database/seeders/StoreSettingsSeeder.php | 30 +-- database/seeders/StoreUserSeeder.php | 28 ++- database/seeders/UserSeeder.php | 23 +- specs/progress.md | 7 + .../Feature/Seeders/SeedDataContractTest.php | 69 ++++++ 12 files changed, 563 insertions(+), 100 deletions(-) create mode 100644 tests/Feature/Seeders/SeedDataContractTest.php diff --git a/database/seeders/CollectionSeeder.php b/database/seeders/CollectionSeeder.php index a278835b..c52379bc 100644 --- a/database/seeders/CollectionSeeder.php +++ b/database/seeders/CollectionSeeder.php @@ -14,19 +14,30 @@ class CollectionSeeder extends Seeder */ public function run(): void { - $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $fashion = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); - Collection::query()->updateOrCreate( - [ - 'store_id' => $store->id, - 'handle' => 'summer-essentials', - ], - [ - 'title' => 'Summer Essentials', - 'description_html' => '

Lightweight staples for warm days.

', - 'type' => 'manual', - 'status' => CollectionStatus::Active, - ], - ); + foreach ([ + [$fashion, 'summer-essentials', 'Summer Essentials', 'Lightweight staples for warm days.'], + [$fashion, 't-shirts', 'T-Shirts', 'Soft tees and everyday jersey staples.'], + [$fashion, 'new-arrivals', 'New Arrivals', 'Fresh pieces from the latest Acme drop.'], + [$fashion, 'denim', 'Denim', 'Jeans, jackets, and structured cotton layers.'], + [$fashion, 'accessories', 'Accessories', 'Bags, caps, socks, and finishing touches.'], + [$electronics, 'desk-setup', 'Desk Setup', 'Monitors, stands, and workspace essentials.'], + [$electronics, 'audio', 'Audio', 'Headphones and speakers for focused work.'], + ] as [$store, $handle, $title, $description]) { + Collection::query()->updateOrCreate( + [ + 'store_id' => $store->id, + 'handle' => $handle, + ], + [ + 'title' => $title, + 'description_html' => '

'.$description.'

', + 'type' => 'manual', + 'status' => CollectionStatus::Active, + ], + ); + } } } diff --git a/database/seeders/CustomerSeeder.php b/database/seeders/CustomerSeeder.php index 087cbce1..47e5a0d6 100644 --- a/database/seeders/CustomerSeeder.php +++ b/database/seeders/CustomerSeeder.php @@ -15,25 +15,36 @@ class CustomerSeeder extends Seeder */ public function run(): void { - $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $fashion = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); foreach ([ - ['email' => 'jane@example.com', 'name' => 'Jane Doe'], - ['email' => 'john@example.com', 'name' => 'John Doe'], - ] as $index => $data) { + [$fashion, 'jane@example.com', 'Jane Doe', true], + [$fashion, 'john@example.com', 'John Doe', false], + [$fashion, 'customer@acme.test', 'John Customer', true], + [$fashion, 'maria@example.com', 'Maria Meyer', false], + [$fashion, 'sam@example.com', 'Sam Taylor', false], + [$fashion, 'li@example.com', 'Li Wei', false], + [$fashion, 'fatima@example.com', 'Fatima Khan', false], + [$fashion, 'noah@example.com', 'Noah Smith', false], + [$fashion, 'emma@example.com', 'Emma Brown', false], + [$fashion, 'olivia@example.com', 'Olivia Davis', false], + [$electronics, 'techfan@example.com', 'Tech Fan', true], + [$electronics, 'buyer@electronics.test', 'Electronics Buyer', false], + ] as [$store, $email, $name, $canLogin]) { $customer = Customer::query()->updateOrCreate( [ 'store_id' => $store->id, - 'email' => $data['email'], + 'email' => $email, ], [ - 'name' => $data['name'], - 'password_hash' => $index === 0 ? Hash::make('password') : null, - 'marketing_opt_in' => $index === 0, + 'name' => $name, + 'password_hash' => $canLogin ? Hash::make('password') : null, + 'marketing_opt_in' => $canLogin, ], ); - [$firstName, $lastName] = explode(' ', $data['name']); + [$firstName, $lastName] = explode(' ', $name, 2); CustomerAddress::query()->updateOrCreate( [ diff --git a/database/seeders/DiscountSeeder.php b/database/seeders/DiscountSeeder.php index 3fbec840..65d268a9 100644 --- a/database/seeders/DiscountSeeder.php +++ b/database/seeders/DiscountSeeder.php @@ -18,27 +18,35 @@ public function run(): void { $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); - Discount::query()->updateOrCreate( - [ - 'store_id' => $store->id, - 'code' => 'WELCOME10', - ], - [ - 'type' => DiscountType::Code, - 'value_type' => DiscountValueType::Percent, - 'value_amount' => 10, - 'starts_at' => now()->subDay(), - 'ends_at' => now()->addYear(), - 'usage_limit' => null, - 'usage_count' => 0, - 'rules_json' => [ - 'min_purchase_amount' => 1000, - 'applicable_product_ids' => null, - 'applicable_collection_ids' => null, - 'customer_eligibility' => 'all', + foreach ([ + ['WELCOME10', DiscountValueType::Percent, 10, now()->subDay(), now()->addYear(), null, 0, DiscountStatus::Active, ['min_purchase_amount' => 1000]], + ['FLAT5', DiscountValueType::Fixed, 500, now()->subDay(), now()->addYear(), null, 0, DiscountStatus::Active, ['min_purchase_amount' => 2000]], + ['FREESHIP', DiscountValueType::FreeShipping, 0, now()->subDay(), now()->addYear(), null, 0, DiscountStatus::Active, ['min_purchase_amount' => null]], + ['EXPIRED20', DiscountValueType::Percent, 20, now()->subYear(), now()->subDay(), null, 0, DiscountStatus::Expired, ['min_purchase_amount' => null]], + ['MAXED', DiscountValueType::Percent, 10, now()->subDay(), now()->addYear(), 5, 5, DiscountStatus::Active, ['min_purchase_amount' => null]], + ] as [$code, $valueType, $valueAmount, $startsAt, $endsAt, $usageLimit, $usageCount, $status, $rules]) { + Discount::query()->updateOrCreate( + [ + 'store_id' => $store->id, + 'code' => $code, ], - 'status' => DiscountStatus::Active, - ], - ); + [ + 'type' => DiscountType::Code, + 'value_type' => $valueType, + 'value_amount' => $valueAmount, + 'starts_at' => $startsAt, + 'ends_at' => $endsAt, + 'usage_limit' => $usageLimit, + 'usage_count' => $usageCount, + 'rules_json' => [ + 'min_purchase_amount' => $rules['min_purchase_amount'], + 'applicable_product_ids' => null, + 'applicable_collection_ids' => null, + 'customer_eligibility' => 'all', + ], + 'status' => $status, + ], + ); + } } } diff --git a/database/seeders/OrderSeeder.php b/database/seeders/OrderSeeder.php index 3f765de3..d9c2b1ab 100644 --- a/database/seeders/OrderSeeder.php +++ b/database/seeders/OrderSeeder.php @@ -45,6 +45,8 @@ public function run(): void $this->createPaidOrder($store, $jane, $variant); $this->createPendingBankTransferOrder($store, $john, $variant); $this->createFulfilledOrder($store, $jane, $variant); + $this->createAdditionalFashionOrders($store); + $this->createElectronicsOrders(); } private function createPaidOrder(Store $store, Customer $customer, ProductVariant $variant): void @@ -155,4 +157,107 @@ private function createPayment(Order $order, PaymentMethod $method, PaymentStatu 'raw_json_encrypted' => ['provider' => 'mock', 'reference' => $reference], ]); } + + private function createAdditionalFashionOrders(Store $store): void + { + $customers = Customer::query() + ->where('store_id', $store->id) + ->orderBy('id') + ->get(); + $variants = ProductVariant::query() + ->whereHas('product', fn ($query) => $query->where('store_id', $store->id)->where('status', 'active')) + ->with('product') + ->orderBy('id') + ->get(); + + $rows = [ + ['#1004', 'customer@acme.test', PaymentMethod::CreditCard, OrderStatus::Paid, FinancialStatus::Paid, FulfillmentStatus::Unfulfilled, PaymentStatus::Captured, 2], + ['#1005', 'customer@acme.test', PaymentMethod::BankTransfer, OrderStatus::Pending, FinancialStatus::Pending, FulfillmentStatus::Unfulfilled, PaymentStatus::Pending, 1], + ['#1006', 'maria@example.com', PaymentMethod::Paypal, OrderStatus::Paid, FinancialStatus::Paid, FulfillmentStatus::Unfulfilled, PaymentStatus::Captured, 4], + ['#1007', 'sam@example.com', PaymentMethod::CreditCard, OrderStatus::Fulfilled, FinancialStatus::Paid, FulfillmentStatus::Fulfilled, PaymentStatus::Captured, 5], + ['#1008', 'li@example.com', PaymentMethod::CreditCard, OrderStatus::Refunded, FinancialStatus::Refunded, FulfillmentStatus::Unfulfilled, PaymentStatus::Refunded, 6], + ['#1009', 'fatima@example.com', PaymentMethod::Paypal, OrderStatus::Paid, FinancialStatus::Paid, FulfillmentStatus::Unfulfilled, PaymentStatus::Captured, 7], + ['#1010', 'noah@example.com', PaymentMethod::CreditCard, OrderStatus::Paid, FinancialStatus::Paid, FulfillmentStatus::Unfulfilled, PaymentStatus::Captured, 8], + ['#1011', 'emma@example.com', PaymentMethod::BankTransfer, OrderStatus::Pending, FinancialStatus::Pending, FulfillmentStatus::Unfulfilled, PaymentStatus::Pending, 9], + ['#1012', 'olivia@example.com', PaymentMethod::Paypal, OrderStatus::Paid, FinancialStatus::Paid, FulfillmentStatus::Unfulfilled, PaymentStatus::Captured, 10], + ['#1013', 'jane@example.com', PaymentMethod::CreditCard, OrderStatus::Paid, FinancialStatus::PartiallyRefunded, FulfillmentStatus::Partial, PaymentStatus::Captured, 11], + ['#1014', 'john@example.com', PaymentMethod::CreditCard, OrderStatus::Cancelled, FinancialStatus::Voided, FulfillmentStatus::Unfulfilled, PaymentStatus::Failed, 12], + ['#1015', 'customer@acme.test', PaymentMethod::Paypal, OrderStatus::Paid, FinancialStatus::Paid, FulfillmentStatus::Unfulfilled, PaymentStatus::Captured, 13], + ]; + + foreach ($rows as [$orderNumber, $email, $method, $status, $financialStatus, $fulfillmentStatus, $paymentStatus, $variantIndex]) { + $customer = $customers->firstWhere('email', $email) ?? $customers->first(); + $variant = $variants->get($variantIndex) ?? $variants->first(); + + if ($customer instanceof Customer && $variant instanceof ProductVariant) { + $this->createSeedOrder($store, $customer, $variant, $orderNumber, $method, $status, $financialStatus, $fulfillmentStatus, $paymentStatus); + } + } + } + + private function createElectronicsOrders(): void + { + $store = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + $customers = Customer::query()->where('store_id', $store->id)->orderBy('id')->get(); + $variants = ProductVariant::query() + ->whereHas('product', fn ($query) => $query->where('store_id', $store->id)) + ->with('product') + ->orderBy('id') + ->get(); + + foreach ([ + ['#5001', 0, 0, PaymentMethod::CreditCard, OrderStatus::Paid, FinancialStatus::Paid, FulfillmentStatus::Unfulfilled, PaymentStatus::Captured], + ['#5002', 1, 1, PaymentMethod::Paypal, OrderStatus::Paid, FinancialStatus::Paid, FulfillmentStatus::Fulfilled, PaymentStatus::Captured], + ['#5003', 0, 2, PaymentMethod::BankTransfer, OrderStatus::Pending, FinancialStatus::Pending, FulfillmentStatus::Unfulfilled, PaymentStatus::Pending], + ] as [$orderNumber, $customerIndex, $variantIndex, $method, $status, $financialStatus, $fulfillmentStatus, $paymentStatus]) { + $customer = $customers->get($customerIndex) ?? $customers->first(); + $variant = $variants->get($variantIndex) ?? $variants->first(); + + if ($customer instanceof Customer && $variant instanceof ProductVariant) { + $this->createSeedOrder($store, $customer, $variant, $orderNumber, $method, $status, $financialStatus, $fulfillmentStatus, $paymentStatus); + } + } + } + + private function createSeedOrder( + Store $store, + Customer $customer, + ProductVariant $variant, + string $orderNumber, + PaymentMethod $method, + OrderStatus $status, + FinancialStatus $financialStatus, + FulfillmentStatus $fulfillmentStatus, + PaymentStatus $paymentStatus, + ): void { + if (Order::withoutGlobalScopes()->where('store_id', $store->id)->where('order_number', $orderNumber)->exists()) { + return; + } + + $order = $this->createBaseOrder($store, $customer, $variant, $orderNumber, $method, $status, $financialStatus, $fulfillmentStatus, now()->subDays(fake()->numberBetween(1, 20))); + $this->createPayment($order, $method, $paymentStatus, 'mock_seed_'.trim($orderNumber, '#')); + + if ($financialStatus === FinancialStatus::Pending) { + $variant->inventoryItem()->withoutGlobalScopes()->increment('quantity_reserved'); + } elseif ($financialStatus === FinancialStatus::Paid || $financialStatus === FinancialStatus::PartiallyRefunded || $financialStatus === FinancialStatus::Refunded) { + $variant->inventoryItem()->withoutGlobalScopes()->decrement('quantity_on_hand'); + } + + if ($fulfillmentStatus === FulfillmentStatus::Fulfilled) { + $line = $order->lines()->firstOrFail(); + $fulfillment = $order->fulfillments()->create([ + 'status' => FulfillmentShipmentStatus::Delivered, + 'tracking_company' => 'DHL', + 'tracking_number' => 'DHL'.trim($orderNumber, '#'), + 'tracking_url' => 'https://example.test/tracking/DHL'.trim($orderNumber, '#'), + 'shipped_at' => now()->subDays(2), + 'delivered_at' => now()->subDay(), + ]); + + $fulfillment->lines()->create([ + 'order_line_id' => $line->id, + 'quantity' => 1, + ]); + } + } } diff --git a/database/seeders/ProductSeeder.php b/database/seeders/ProductSeeder.php index a0c7fd9d..6178f1e8 100644 --- a/database/seeders/ProductSeeder.php +++ b/database/seeders/ProductSeeder.php @@ -141,5 +141,236 @@ public function run(): void ], ['position' => 1], ); + + $this->seedFashionCatalog($store); + $this->seedElectronicsCatalog(); + } + + private function seedFashionCatalog(Store $store): void + { + $this->seedClassicCottonTShirt($store); + + $products = [ + ['premium-slim-fit-jeans', 'Premium Slim Fit Jeans', 7999, 10999, 'Acme Denim', 'Jeans', ['denim', 'sale'], 'denim', 45, 'deny', ProductStatus::Active], + ['merino-crew-sweater', 'Merino Crew Sweater', 8999, null, 'Acme Knitwear', 'Sweaters', ['winter', 'wool'], 'new-arrivals', 24, 'deny', ProductStatus::Active], + ['organic-poplin-shirt', 'Organic Poplin Shirt', 5999, null, 'Acme Apparel', 'Shirts', ['organic', 'workwear'], 'new-arrivals', 35, 'deny', ProductStatus::Active], + ['chino-trouser', 'Chino Trouser', 6999, null, 'Acme Apparel', 'Pants', ['cotton'], 'new-arrivals', 38, 'deny', ProductStatus::Active], + ['canvas-tote-bag', 'Canvas Tote Bag', 2499, null, 'Acme Bags', 'Bags', ['canvas', 'accessories'], 'accessories', 80, 'deny', ProductStatus::Active], + ['ribbed-socks-pack', 'Ribbed Socks Pack', 1499, null, 'Acme Basics', 'Accessories', ['basics'], 'accessories', 120, 'deny', ProductStatus::Active], + ['field-jacket', 'Field Jacket', 12999, null, 'Acme Outerwear', 'Jackets', ['outerwear'], 'new-arrivals', 18, 'deny', ProductStatus::Active], + ['pleated-midi-skirt', 'Pleated Midi Skirt', 7499, null, 'Acme Apparel', 'Skirts', ['summer'], 'summer-essentials', 22, 'deny', ProductStatus::Active], + ['relaxed-cotton-shorts', 'Relaxed Cotton Shorts', 3999, null, 'Acme Apparel', 'Shorts', ['summer'], 'summer-essentials', 36, 'deny', ProductStatus::Active], + ['structured-cap', 'Structured Cap', 1999, null, 'Acme Accessories', 'Accessories', ['cap'], 'accessories', 90, 'deny', ProductStatus::Active], + ['wool-overshirt', 'Wool Overshirt', 11999, null, 'Acme Outerwear', 'Shirts', ['wool'], 'new-arrivals', 16, 'deny', ProductStatus::Active], + ['everyday-tank-top', 'Everyday Tank Top', 2199, null, 'Acme Basics', 'T-Shirts', ['basics', 'summer'], 't-shirts', 64, 'deny', ProductStatus::Active], + ['heavyweight-pocket-tee', 'Heavyweight Pocket Tee', 3499, null, 'Acme Basics', 'T-Shirts', ['tee', 'cotton'], 't-shirts', 52, 'deny', ProductStatus::Active], + ['archive-sample-parka', 'Archive Sample Parka', 15999, null, 'Acme Archive', 'Jackets', ['archive'], 'new-arrivals', 5, 'deny', ProductStatus::Draft], + ['suede-weekender-bag', 'Suede Weekender Bag', 14999, null, 'Acme Bags', 'Bags', ['travel'], 'accessories', 12, 'deny', ProductStatus::Active], + ['sold-out-canvas-sneaker', 'Sold Out Canvas Sneaker', 6499, null, 'Acme Footwear', 'Shoes', ['sold-out'], 'new-arrivals', 0, 'deny', ProductStatus::Active], + ['backorder-utility-vest', 'Backorder Utility Vest', 8499, null, 'Acme Outerwear', 'Vests', ['backorder'], 'new-arrivals', 0, 'continue', ProductStatus::Active], + ]; + + foreach ($products as [$handle, $title, $price, $compareAt, $vendor, $type, $tags, $collection, $quantity, $policy, $status]) { + $product = $this->seedSimpleProduct($store, [ + 'handle' => $handle, + 'title' => $title, + 'price' => $price, + 'compare_at' => $compareAt, + 'vendor' => $vendor, + 'product_type' => $type, + 'tags' => $tags, + 'quantity' => $quantity, + 'policy' => $policy, + 'status' => $status, + 'sku' => strtoupper(str_replace('-', '-', $handle)).'-DEFAULT', + ]); + + $this->attachProductToCollection($store, $product, $collection); + } + } + + private function seedElectronicsCatalog(): void + { + $store = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + foreach ([ + ['usb-c-dock', 'USB-C Dock', 8999, 'Acme Devices', 'Docks', 'desk-setup', 20], + ['monitor-stand', 'Monitor Stand', 4999, 'Acme Workspace', 'Stands', 'desk-setup', 30], + ['wireless-keyboard', 'Wireless Keyboard', 7999, 'Acme Devices', 'Keyboards', 'desk-setup', 25], + ['noise-cancelling-headphones', 'Noise Cancelling Headphones', 17999, 'Acme Audio', 'Headphones', 'audio', 15], + ['portable-speaker', 'Portable Speaker', 9999, 'Acme Audio', 'Speakers', 'audio', 18], + ] as [$handle, $title, $price, $vendor, $type, $collection, $quantity]) { + $product = $this->seedSimpleProduct($store, [ + 'handle' => $handle, + 'title' => $title, + 'price' => $price, + 'compare_at' => null, + 'vendor' => $vendor, + 'product_type' => $type, + 'tags' => ['electronics'], + 'quantity' => $quantity, + 'policy' => 'deny', + 'status' => ProductStatus::Active, + 'sku' => strtoupper(str_replace('-', '-', $handle)).'-DEFAULT', + ]); + + $this->attachProductToCollection($store, $product, $collection); + } + } + + /** + * @param array{handle: string, title: string, price: int, compare_at: ?int, vendor: string, product_type: string, tags: list, quantity: int, policy: string, status: ProductStatus, sku: string} $data + */ + private function seedSimpleProduct(Store $store, array $data): Product + { + $product = Product::query()->updateOrCreate( + [ + 'store_id' => $store->id, + 'handle' => $data['handle'], + ], + [ + 'title' => $data['title'], + 'status' => $data['status'], + 'description_html' => '

'.$data['title'].' from '.$data['vendor'].'.

', + 'vendor' => $data['vendor'], + 'product_type' => $data['product_type'], + 'tags' => $data['tags'], + 'published_at' => $data['status'] === ProductStatus::Active ? now()->subDays(3) : null, + ], + ); + + $variant = ProductVariant::query()->updateOrCreate( + [ + 'product_id' => $product->id, + 'sku' => $data['sku'], + ], + [ + 'price_amount' => $data['price'], + 'compare_at_amount' => $data['compare_at'], + 'currency' => $store->default_currency, + 'requires_shipping' => true, + 'is_default' => true, + 'position' => 0, + 'status' => VariantStatus::Active, + ], + ); + + $variant->inventoryItem()->withoutGlobalScopes()->updateOrCreate( + ['variant_id' => $variant->id], + [ + 'store_id' => $store->id, + 'quantity_on_hand' => $data['quantity'], + 'quantity_reserved' => 0, + 'policy' => $data['policy'], + ], + ); + + return $product->refresh(); + } + + private function seedClassicCottonTShirt(Store $store): void + { + $product = Product::query()->updateOrCreate( + [ + 'store_id' => $store->id, + 'handle' => 'classic-cotton-t-shirt', + ], + [ + 'title' => 'Classic Cotton T-Shirt', + 'status' => ProductStatus::Active, + 'description_html' => '

A classic cotton t-shirt with size and color variants.

', + 'vendor' => 'Acme Basics', + 'product_type' => 'T-Shirts', + 'tags' => ['organic', 'cotton'], + 'published_at' => now()->subDays(2), + ], + ); + + foreach ([['Size', ['S', 'M', 'L', 'XL']], ['Color', ['Black', 'White', 'Navy']]] as $optionIndex => [$name, $values]) { + $option = ProductOption::query()->updateOrCreate( + [ + 'product_id' => $product->id, + 'position' => $optionIndex, + ], + ['name' => $name], + ); + + foreach ($values as $valueIndex => $value) { + $option->values()->updateOrCreate( + ['position' => $valueIndex], + ['value' => $value], + ); + } + } + + ProductVariant::query()->firstOrCreate( + [ + 'product_id' => $product->id, + 'sku' => 'CLASSIC-COTTON-TEMPLATE', + ], + [ + 'price_amount' => 2499, + 'currency' => $store->default_currency, + 'is_default' => true, + 'position' => 0, + 'status' => VariantStatus::Active, + ], + ); + + app(VariantMatrixService::class)->rebuildMatrix($product->refresh()); + + $product->variants() + ->with('optionValues') + ->orderBy('position') + ->get() + ->values() + ->each(function (ProductVariant $variant, int $index) use ($store): void { + $optionSku = $variant->optionValues + ->sortBy('product_option_id') + ->pluck('value') + ->map(fn (string $value): string => strtoupper($value)) + ->join('-'); + + $variant->forceFill([ + 'sku' => 'CLASSIC-COTTON-'.($optionSku ?: 'DEFAULT'), + 'price_amount' => 2499, + 'currency' => $store->default_currency, + 'is_default' => $index === 0, + 'status' => VariantStatus::Active, + ])->save(); + + $variant->inventoryItem()->withoutGlobalScopes()->updateOrCreate( + ['variant_id' => $variant->id], + [ + 'store_id' => $store->id, + 'quantity_on_hand' => 25, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ], + ); + }); + + $this->attachProductToCollection($store, $product, 't-shirts'); + $this->attachProductToCollection($store, $product, 'new-arrivals'); + } + + private function attachProductToCollection(Store $store, Product $product, string $collectionHandle): void + { + $collection = Collection::query() + ->where('store_id', $store->id) + ->where('handle', $collectionHandle) + ->first(); + + if (! $collection) { + return; + } + + DB::table('collection_products')->updateOrInsert( + [ + 'collection_id' => $collection->id, + 'product_id' => $product->id, + ], + ['position' => $collection->products()->count()], + ); } } diff --git a/database/seeders/StoreDomainSeeder.php b/database/seeders/StoreDomainSeeder.php index 2085dcab..1aab49bb 100644 --- a/database/seeders/StoreDomainSeeder.php +++ b/database/seeders/StoreDomainSeeder.php @@ -14,16 +14,23 @@ class StoreDomainSeeder extends Seeder */ public function run(): void { - $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $fashion = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); - StoreDomain::query()->updateOrCreate( - ['hostname' => 'shop.test'], - [ - 'store_id' => $store->id, - 'type' => StoreDomainType::Storefront, - 'is_primary' => true, - 'tls_mode' => 'managed', - ], - ); + foreach ([ + ['hostname' => 'shop.test', 'store' => $fashion, 'primary' => true], + ['hostname' => 'acme-fashion.test', 'store' => $fashion, 'primary' => false], + ['hostname' => 'acme-electronics.test', 'store' => $electronics, 'primary' => true], + ] as $domain) { + StoreDomain::query()->updateOrCreate( + ['hostname' => $domain['hostname']], + [ + 'store_id' => $domain['store']->id, + 'type' => StoreDomainType::Storefront, + 'is_primary' => $domain['primary'], + 'tls_mode' => 'managed', + ], + ); + } } } diff --git a/database/seeders/StoreSeeder.php b/database/seeders/StoreSeeder.php index 885d2222..1d303a2c 100644 --- a/database/seeders/StoreSeeder.php +++ b/database/seeders/StoreSeeder.php @@ -16,16 +16,21 @@ public function run(): void { $organization = Organization::query()->where('billing_email', 'billing@acme.test')->firstOrFail(); - Store::query()->updateOrCreate( - ['handle' => 'acme-fashion'], - [ - 'organization_id' => $organization->id, - 'name' => 'Acme Fashion', - 'status' => StoreStatus::Active, - 'default_currency' => 'EUR', - 'default_locale' => 'en', - 'timezone' => 'Europe/Berlin', - ], - ); + foreach ([ + ['handle' => 'acme-fashion', 'name' => 'Acme Fashion', 'currency' => 'EUR'], + ['handle' => 'acme-electronics', 'name' => 'Acme Electronics', 'currency' => 'EUR'], + ] as $store) { + Store::query()->updateOrCreate( + ['handle' => $store['handle']], + [ + 'organization_id' => $organization->id, + 'name' => $store['name'], + 'status' => StoreStatus::Active, + 'default_currency' => $store['currency'], + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ], + ); + } } } diff --git a/database/seeders/StoreSettingsSeeder.php b/database/seeders/StoreSettingsSeeder.php index 19759f50..38a6c27b 100644 --- a/database/seeders/StoreSettingsSeeder.php +++ b/database/seeders/StoreSettingsSeeder.php @@ -13,22 +13,22 @@ class StoreSettingsSeeder extends Seeder */ public function run(): void { - $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); - - StoreSettings::query()->updateOrCreate( - ['store_id' => $store->id], - [ - 'settings_json' => [ - 'checkout' => [ - 'guest_checkout_enabled' => true, - ], - 'notifications' => [ - 'order_confirmation' => true, + Store::query()->orderBy('id')->each(function (Store $store): void { + StoreSettings::query()->updateOrCreate( + ['store_id' => $store->id], + [ + 'settings_json' => [ + 'checkout' => [ + 'guest_checkout_enabled' => true, + ], + 'notifications' => [ + 'order_confirmation' => true, + ], + 'order_number_prefix' => '#', + 'bank_transfer_cancel_days' => 7, ], - 'order_number_prefix' => '#', - 'bank_transfer_cancel_days' => 7, ], - ], - ); + ); + }); } } diff --git a/database/seeders/StoreUserSeeder.php b/database/seeders/StoreUserSeeder.php index ff5e3ee8..03c5d0c7 100644 --- a/database/seeders/StoreUserSeeder.php +++ b/database/seeders/StoreUserSeeder.php @@ -15,18 +15,22 @@ class StoreUserSeeder extends Seeder */ public function run(): void { - $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); - $user = User::query()->where('email', 'admin@example.com')->firstOrFail(); + $stores = Store::query()->whereIn('handle', ['acme-fashion', 'acme-electronics'])->get(); + $users = User::query()->whereIn('email', ['admin@example.com', 'admin@acme.test'])->get(); - DB::table('store_users')->updateOrInsert( - [ - 'store_id' => $store->id, - 'user_id' => $user->id, - ], - [ - 'role' => StoreUserRole::Owner->value, - 'created_at' => now(), - ], - ); + foreach ($stores as $store) { + foreach ($users as $user) { + DB::table('store_users')->updateOrInsert( + [ + 'store_id' => $store->id, + 'user_id' => $user->id, + ], + [ + 'role' => StoreUserRole::Owner->value, + 'created_at' => now(), + ], + ); + } + } } } diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index 09029cf0..49df0377 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -13,14 +13,19 @@ class UserSeeder extends Seeder */ public function run(): void { - User::query()->updateOrCreate( - ['email' => 'admin@example.com'], - [ - 'name' => 'Admin User', - 'password' => Hash::make('password'), - 'status' => 'active', - 'email_verified_at' => now(), - ], - ); + foreach ([ + ['email' => 'admin@example.com', 'name' => 'Admin User'], + ['email' => 'admin@acme.test', 'name' => 'Acme Admin'], + ] as $user) { + User::query()->updateOrCreate( + ['email' => $user['email']], + [ + 'name' => $user['name'], + 'password' => Hash::make('password'), + 'status' => 'active', + 'email_verified_at' => now(), + ], + ); + } } } diff --git a/specs/progress.md b/specs/progress.md index 9db67736..26018f24 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -19,6 +19,7 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - SQLite FTS5 search is implemented and verified: search settings/query tables, product FTS indexing, product observer sync, storefront search page, header search modal, search API, seeded synonyms/stop words, and admin reindex action. - Analytics ingestion and aggregation are implemented and verified: storefront batch event API, client event deduplication, seeded daily/event analytics, daily aggregation job, admin analytics summary API, and admin analytics dashboard. - Apps, API tokens, and webhooks are implemented and verified: apps/installations/OAuth metadata, developer API token generation/revocation, store-scoped token middleware, webhook subscriptions, signed delivery jobs, retry/failure tracking, and app admin screens. +- Demo seed data now covers the browser-plan fixture contract while preserving `shop.test`: two stores/domains, admin aliases, 20 fashion products, 5 electronics products, sold-out/backorder/draft product edges, five discount codes, 10+2 customers, and 15+3 orders. ## Execution Plan @@ -146,3 +147,9 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - 2026-05-03: `php artisan route:list --path=api/storefront/v1/orders --except-vendor` passed and showed the signed storefront order-status route. - 2026-05-03: `php artisan test --compact` passed, 102 tests / 452 assertions. - 2026-05-03: `npm run build` passed after adding styled error pages. +- 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed with expanded deterministic browser-plan seed data. +- 2026-05-03: `php artisan test --compact tests/Feature/Seeders/SeedDataContractTest.php` passed, 3 tests / 23 assertions. +- 2026-05-03: `php artisan test --compact tests/Feature/Seeders/SeedDataContractTest.php tests/Feature/Search/SearchTest.php tests/Feature/Admin/AdminPanelTest.php tests/Feature/Storefront/StorefrontRenderTest.php tests/Feature/Api/StorefrontCheckoutPaymentApiTest.php tests/Feature/Storefront/CustomerAccountTest.php` passed, 25 tests / 181 assertions. +- 2026-05-03: `vendor/bin/pint --dirty --format agent` passed after expanded seeder changes. +- 2026-05-03: `php artisan test --compact` passed, 105 tests / 475 assertions. +- 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed again after the final seeder adjustment. diff --git a/tests/Feature/Seeders/SeedDataContractTest.php b/tests/Feature/Seeders/SeedDataContractTest.php new file mode 100644 index 00000000..00827e3e --- /dev/null +++ b/tests/Feature/Seeders/SeedDataContractTest.php @@ -0,0 +1,69 @@ +seed(); +}); + +test('seeders provide deterministic stores domains users and customers', function (): void { + $fashion = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + expect(Store::query()->count())->toBe(2) + ->and(StoreDomain::query()->where('hostname', 'shop.test')->where('store_id', $fashion->id)->exists())->toBeTrue() + ->and(StoreDomain::query()->where('hostname', 'acme-fashion.test')->where('store_id', $fashion->id)->exists())->toBeTrue() + ->and(StoreDomain::query()->where('hostname', 'acme-electronics.test')->where('store_id', $electronics->id)->exists())->toBeTrue() + ->and(User::query()->where('email', 'admin@example.com')->exists())->toBeTrue() + ->and(User::query()->where('email', 'admin@acme.test')->exists())->toBeTrue() + ->and(Customer::query()->where('store_id', $fashion->id)->count())->toBe(10) + ->and(Customer::query()->where('store_id', $electronics->id)->count())->toBe(2) + ->and(Customer::query()->where('store_id', $fashion->id)->where('email', 'customer@acme.test')->exists())->toBeTrue(); +}); + +test('seeders provide catalog fixtures for browsing inventory and tenant isolation', function (): void { + $fashion = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + $soldOut = Product::query()->where('store_id', $fashion->id)->where('handle', 'sold-out-canvas-sneaker')->firstOrFail(); + $backorder = Product::query()->where('store_id', $fashion->id)->where('handle', 'backorder-utility-vest')->firstOrFail(); + $draft = Product::query()->where('store_id', $fashion->id)->where('handle', 'archive-sample-parka')->firstOrFail(); + + expect(Product::query()->where('store_id', $fashion->id)->count())->toBe(20) + ->and(Product::query()->where('store_id', $electronics->id)->count())->toBe(5) + ->and(Product::query()->where('store_id', $fashion->id)->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->options()->count())->toBe(2) + ->and($soldOut->variants()->firstOrFail()->inventoryItem->quantity_on_hand)->toBe(0) + ->and($soldOut->variants()->firstOrFail()->inventoryItem->policy->value)->toBe('deny') + ->and($backorder->variants()->firstOrFail()->inventoryItem->quantity_on_hand)->toBe(0) + ->and($backorder->variants()->firstOrFail()->inventoryItem->policy->value)->toBe('continue') + ->and($draft->status->value)->toBe('draft'); +}); + +test('seeders provide discount and order workflow fixtures', function (): void { + $fashion = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + expect(Discount::query()->where('store_id', $fashion->id)->pluck('code')->sort()->values()->all())->toBe([ + 'EXPIRED20', + 'FLAT5', + 'FREESHIP', + 'MAXED', + 'WELCOME10', + ]) + ->and(Discount::query()->where('store_id', $fashion->id)->where('code', 'MAXED')->firstOrFail()->usage_count)->toBe(5) + ->and(Order::withoutGlobalScopes()->where('store_id', $fashion->id)->count())->toBe(15) + ->and(Order::withoutGlobalScopes()->where('store_id', $electronics->id)->count())->toBe(3) + ->and(Order::withoutGlobalScopes()->where('store_id', $fashion->id)->where('order_number', '#1005')->firstOrFail()->financial_status->value)->toBe('pending') + ->and(Order::withoutGlobalScopes()->where('store_id', $electronics->id)->where('order_number', '#5003')->firstOrFail()->payment_method->value)->toBe('bank_transfer'); +}); From d79f252ef090c5814291528a6c2f3d459aa455ad Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 3 May 2026 17:19:37 +0200 Subject: [PATCH 16/35] Build storefront cart drawer inventory states --- app/Livewire/Admin/Products/Index.php | 1 + app/Livewire/Storefront/CartDrawer.php | 84 +++++++++++++++- app/Livewire/Storefront/Products/Show.php | 95 ++++++++++++++++++ .../livewire/storefront/cart-drawer.blade.php | 96 ++++++++++++++++++- .../storefront/products/show.blade.php | 22 ++++- .../storefront/search/modal.blade.php | 80 ++++++++-------- resources/views/partials/head.blade.php | 1 - .../views/storefront/layouts/app.blade.php | 10 +- specs/progress.md | 8 +- tests/Feature/Search/SearchTest.php | 16 ++++ .../Storefront/StorefrontCartLivewireTest.php | 58 ++++++++++- 11 files changed, 415 insertions(+), 56 deletions(-) diff --git a/app/Livewire/Admin/Products/Index.php b/app/Livewire/Admin/Products/Index.php index aa025985..d0330624 100644 --- a/app/Livewire/Admin/Products/Index.php +++ b/app/Livewire/Admin/Products/Index.php @@ -82,6 +82,7 @@ public function render(): View })) ->when($this->status !== 'all', fn ($query) => $query->where('status', $this->status)) ->latest('updated_at') + ->orderBy('title') ->paginate(10), 'statuses' => ProductStatus::cases(), ])->layout('livewire.admin.layout.app', [ diff --git a/app/Livewire/Storefront/CartDrawer.php b/app/Livewire/Storefront/CartDrawer.php index 724f9025..0a4a9a16 100644 --- a/app/Livewire/Storefront/CartDrawer.php +++ b/app/Livewire/Storefront/CartDrawer.php @@ -2,6 +2,8 @@ namespace App\Livewire\Storefront; +use App\Exceptions\CartVersionConflictException; +use App\Exceptions\InvalidCartMutationException; use App\Models\Cart; use App\Services\CartService; use Illuminate\View\View; @@ -9,7 +11,71 @@ class CartDrawer extends Component { - protected $listeners = ['cart-updated' => '$refresh']; + public bool $open = false; + + protected $listeners = [ + 'cart-updated' => 'open', + ]; + + public function open(): void + { + $this->open = true; + } + + public function close(): void + { + $this->open = false; + } + + public function incrementLine(int $lineId): void + { + $cart = $this->cart(); + + if (! $cart) { + return; + } + + $line = $cart->lines->firstWhere('id', $lineId); + + if (! $line) { + return; + } + + $this->setLineQuantity($lineId, $line->quantity + 1); + } + + public function decrementLine(int $lineId): void + { + $cart = $this->cart(); + + if (! $cart) { + return; + } + + $line = $cart->lines->firstWhere('id', $lineId); + + if (! $line) { + return; + } + + $this->setLineQuantity($lineId, $line->quantity - 1); + } + + public function removeLine(int $lineId): void + { + $cart = $this->cart(); + + if (! $cart) { + return; + } + + try { + app(CartService::class)->removeLine($cart, $lineId, $cart->cart_version); + $this->resetErrorBag('cart'); + } catch (CartVersionConflictException|InvalidCartMutationException $exception) { + $this->addError('cart', $exception->getMessage()); + } + } public function render(): View { @@ -33,4 +99,20 @@ private function cart(): ?Cart return $cart instanceof Cart ? app(CartService::class)->loadForDisplay($cart) : null; } + + private function setLineQuantity(int $lineId, int $quantity): void + { + $cart = $this->cart(); + + if (! $cart) { + return; + } + + try { + app(CartService::class)->updateLineQuantity($cart, $lineId, $quantity, $cart->cart_version); + $this->resetErrorBag('cart'); + } catch (CartVersionConflictException|InvalidCartMutationException $exception) { + $this->addError('cart', $exception->getMessage()); + } + } } diff --git a/app/Livewire/Storefront/Products/Show.php b/app/Livewire/Storefront/Products/Show.php index 09938181..c416159e 100644 --- a/app/Livewire/Storefront/Products/Show.php +++ b/app/Livewire/Storefront/Products/Show.php @@ -2,6 +2,7 @@ namespace App\Livewire\Storefront\Products; +use App\Enums\InventoryPolicy; use App\Enums\ProductStatus; use App\Exceptions\InvalidCartMutationException; use App\Models\Product; @@ -27,6 +28,31 @@ public function mount(string $handle): void public function selectVariant(int $variantId): void { $this->selectedVariantId = $variantId; + + $product = $this->productQuery()->firstOrFail(); + + $this->normalizeQuantity($this->selectedVariant($product)); + } + + public function incrementQuantity(): void + { + $product = $this->productQuery()->firstOrFail(); + $variant = $this->selectedVariant($product); + $stock = $this->stockState($variant); + + $this->quantity = min($stock['max_quantity'], $this->quantity + 1); + } + + public function decrementQuantity(): void + { + $this->quantity = max(1, $this->quantity - 1); + } + + public function updatedQuantity(): void + { + $product = $this->productQuery()->firstOrFail(); + + $this->normalizeQuantity($this->selectedVariant($product)); } public function addToCart(): void @@ -44,6 +70,16 @@ public function addToCart(): void return; } + $stock = $this->stockState($variant); + + if (! $stock['can_add_to_cart']) { + $this->addError('quantity', 'This variant is out of stock.'); + + return; + } + + $this->normalizeQuantity($variant); + try { $cart = app(CartService::class)->getOrCreateForSession(app('current_store')); app(CartService::class)->addLine($cart, $variant->id, $this->quantity); @@ -65,6 +101,7 @@ public function render(): View return view('livewire.storefront.products.show', [ 'product' => $product, 'selectedVariant' => $selectedVariant, + 'stock' => $this->stockState($selectedVariant), ])->layout('storefront.layouts.app', [ 'title' => $product->title, ]); @@ -85,4 +122,62 @@ private function selectedVariant(Product $product): ?ProductVariant ?? $product->variants->firstWhere('is_default', true) ?? $product->variants->first(); } + + /** + * @return array{available_quantity: int, max_quantity: int, can_add_to_cart: bool, message: string, tone: string} + */ + private function stockState(?ProductVariant $variant): array + { + $inventoryItem = $variant?->inventoryItem; + + if (! $variant instanceof ProductVariant || ! $inventoryItem) { + return [ + 'available_quantity' => 0, + 'max_quantity' => 1, + 'can_add_to_cart' => false, + 'message' => 'Unavailable', + 'tone' => 'red', + ]; + } + + $availableQuantity = max(0, $inventoryItem->availableQuantity()); + + if ($inventoryItem->policy === InventoryPolicy::Continue && $availableQuantity < 1) { + return [ + 'available_quantity' => $availableQuantity, + 'max_quantity' => 9999, + 'can_add_to_cart' => true, + 'message' => 'Available on backorder', + 'tone' => 'blue', + ]; + } + + if ($availableQuantity < 1) { + return [ + 'available_quantity' => 0, + 'max_quantity' => 1, + 'can_add_to_cart' => false, + 'message' => 'Out of stock', + 'tone' => 'red', + ]; + } + + return [ + 'available_quantity' => $availableQuantity, + 'max_quantity' => $inventoryItem->policy === InventoryPolicy::Deny ? min($availableQuantity, 9999) : 9999, + 'can_add_to_cart' => true, + 'message' => $availableQuantity <= 10 ? "Only {$availableQuantity} left in stock" : 'In stock', + 'tone' => $availableQuantity <= 10 ? 'amber' : 'green', + ]; + } + + private function normalizeQuantity(?ProductVariant $variant): void + { + $stock = $this->stockState($variant); + + $this->quantity = min( + $stock['max_quantity'], + max(1, $this->quantity), + ); + } } diff --git a/resources/views/livewire/storefront/cart-drawer.blade.php b/resources/views/livewire/storefront/cart-drawer.blade.php index 6b2a6b4a..7c93a89d 100644 --- a/resources/views/livewire/storefront/cart-drawer.blade.php +++ b/resources/views/livewire/storefront/cart-drawer.blade.php @@ -1,3 +1,95 @@ -
- Cart items: {{ $cart?->itemCount() ?? 0 }} +
+
+ Cart items: {{ $cart?->itemCount() ?? 0 }} +
+ + @if($open) + + @endif
diff --git a/resources/views/livewire/storefront/products/show.blade.php b/resources/views/livewire/storefront/products/show.blade.php index c33b01df..709540b1 100644 --- a/resources/views/livewire/storefront/products/show.blade.php +++ b/resources/views/livewire/storefront/products/show.blade.php @@ -37,16 +37,34 @@ {!! $product->description_html !!}
+ @php + $stockClasses = match ($stock['tone']) { + 'green' => 'bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-950/30 dark:text-emerald-300 dark:ring-emerald-900/50', + 'amber' => 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-950/30 dark:text-amber-300 dark:ring-amber-900/50', + 'blue' => 'bg-sky-50 text-sky-700 ring-sky-200 dark:bg-sky-950/30 dark:text-sky-300 dark:ring-sky-900/50', + default => 'bg-red-50 text-red-700 ring-red-200 dark:bg-red-950/30 dark:text-red-300 dark:ring-red-900/50', + }; + @endphp + +
+ + {{ $stock['message'] }} +
+
- +
+ + + +
@error('quantity')

{{ $message }}

@enderror @if(session('cart_status'))

{{ session('cart_status') }}

@endif -
diff --git a/resources/views/livewire/storefront/search/modal.blade.php b/resources/views/livewire/storefront/search/modal.blade.php index ce77236f..d7c4838d 100644 --- a/resources/views/livewire/storefront/search/modal.blade.php +++ b/resources/views/livewire/storefront/search/modal.blade.php @@ -1,52 +1,48 @@
- - @teleport('body') -
- @if($isOpen) -
-
-
- - -
- -
- @if(trim($q) === '') -
- Start typing to search products and collections. -
- @elseif($suggestions->isEmpty()) -
- No suggestions found. -
- @else - + @if($isOpen) +
- @endteleport + @endif
diff --git a/resources/views/partials/head.blade.php b/resources/views/partials/head.blade.php index dce80588..9f5d9ec7 100644 --- a/resources/views/partials/head.blade.php +++ b/resources/views/partials/head.blade.php @@ -3,7 +3,6 @@ {{ $title ?? config('app.name') }} - diff --git a/resources/views/storefront/layouts/app.blade.php b/resources/views/storefront/layouts/app.blade.php index 6a616532..21696706 100644 --- a/resources/views/storefront/layouts/app.blade.php +++ b/resources/views/storefront/layouts/app.blade.php @@ -41,7 +41,7 @@ @endif
-
+
Menu @@ -57,7 +57,7 @@
- + {{ $store->name }} @@ -69,10 +69,10 @@ @endforeach -
+
@livewire('storefront.search.modal') - Account - Cart + +
diff --git a/specs/progress.md b/specs/progress.md index 26018f24..f8569829 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -20,6 +20,7 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Analytics ingestion and aggregation are implemented and verified: storefront batch event API, client event deduplication, seeded daily/event analytics, daily aggregation job, admin analytics summary API, and admin analytics dashboard. - Apps, API tokens, and webhooks are implemented and verified: apps/installations/OAuth metadata, developer API token generation/revocation, store-scoped token middleware, webhook subscriptions, signed delivery jobs, retry/failure tracking, and app admin screens. - Demo seed data now covers the browser-plan fixture contract while preserving `shop.test`: two stores/domains, admin aliases, 20 fashion products, 5 electronics products, sold-out/backorder/draft product edges, five discount codes, 10+2 customers, and 15+3 orders. +- Storefront product stock states and the slide-out cart drawer are implemented and verified: sold-out `deny` variants disable add-to-cart, backorder `continue` variants add successfully, the cart drawer opens from cart events, quantity mutations reuse the versioned cart service, and the mobile storefront header fits small viewports. ## Execution Plan @@ -74,7 +75,7 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Phase 2 still needs full media resizing variants and the richer multi-option variant builder. Admin product create/edit currently covers core product fields, default variant price/SKU, and stock. - Phase 3 still needs richer error templates and fully configurable storefront section ordering. Basic theme editing and publishing now exist in the admin panel; search modal autocomplete landed with the search slice. - Phase 5 backend bank-transfer confirmation, refunds, and fulfillment services now have admin order-detail actions. More granular partial-fulfillment UI can still be expanded during polish. -- Phase 4 has a functional cart page and accessible cart count, but the richer slide-out cart drawer can be expanded during UI polish. +- Phase 4 has a functional cart page, accessible cart count, and slide-out cart drawer. Discount-code entry remains on the cart/checkout flow; the drawer links customers into checkout rather than applying discounts inline. - Discounts, shipping, and tax are implemented for the specified local/manual flows; provider/carrier integrations remain stubs by design. - Order-reference guards in product deletion/status logic are present but only become fully meaningful once `order_lines` exists in Phase 5. - Customer account password reset UI and emails remain deferred; login, registration, dashboard, order history/detail, and address book flows are implemented. @@ -153,3 +154,8 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - 2026-05-03: `vendor/bin/pint --dirty --format agent` passed after expanded seeder changes. - 2026-05-03: `php artisan test --compact` passed, 105 tests / 475 assertions. - 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed again after the final seeder adjustment. +- 2026-05-03: `php artisan test --compact tests/Feature/Storefront/StorefrontCartLivewireTest.php tests/Feature/Search/SearchTest.php tests/Feature/Storefront/StorefrontRenderTest.php` passed, 12 tests / 66 assertions. +- 2026-05-03: `vendor/bin/pint --dirty --format agent` passed after storefront cart drawer, product stock state, search modal, and admin product sort changes. +- 2026-05-03: `php artisan test --compact` passed, 109 tests / 498 assertions. +- 2026-05-03: `npm run build` passed for the updated storefront drawer, product stock, search modal, and responsive header assets. +- 2026-05-03: Playwright smoke verified sold-out product disable state, backorder add-to-cart with drawer opening, drawer quantity controls, header search modal suggestions, and mobile drawer/header layout at `http://shop.test`; latest browser console checks reported no warnings or errors. diff --git a/tests/Feature/Search/SearchTest.php b/tests/Feature/Search/SearchTest.php index ef0f60ad..873f74c1 100644 --- a/tests/Feature/Search/SearchTest.php +++ b/tests/Feature/Search/SearchTest.php @@ -2,6 +2,7 @@ use App\Livewire\Admin\Search\Settings as AdminSearchSettings; use App\Livewire\Storefront\Search\Index as StorefrontSearchIndex; +use App\Livewire\Storefront\Search\Modal as StorefrontSearchModal; use App\Models\Product; use App\Models\ProductVariant; use App\Models\SearchQuery; @@ -126,3 +127,18 @@ ['jacket', 'coat'], ])->and($settings->stop_words_json)->toBe(['foo', 'bar']); }); + +test('storefront search modal opens and renders suggestions', function (): void { + app()->instance('current_store', $this->store); + + Livewire::test(StorefrontSearchModal::class) + ->assertSet('isOpen', false) + ->call('show') + ->assertSet('isOpen', true) + ->assertSee('Start typing to search products and collections.') + ->set('q', 'linen') + ->assertSee('Linen Shirt') + ->call('close') + ->assertSet('isOpen', false) + ->assertSet('q', ''); +}); diff --git a/tests/Feature/Storefront/StorefrontCartLivewireTest.php b/tests/Feature/Storefront/StorefrontCartLivewireTest.php index f2889090..77817e35 100644 --- a/tests/Feature/Storefront/StorefrontCartLivewireTest.php +++ b/tests/Feature/Storefront/StorefrontCartLivewireTest.php @@ -1,9 +1,11 @@ seed(); - app()->instance('current_store', \App\Models\Store::query()->where('handle', 'acme-fashion')->firstOrFail()); + app()->instance('current_store', Store::query()->where('handle', 'acme-fashion')->firstOrFail()); }); test('product page adds a line to the session cart and cart page starts checkout', function () { @@ -21,7 +23,8 @@ Livewire::test(ProductShow::class, ['handle' => $product->handle]) ->set('quantity', 2) ->call('addToCart') - ->assertHasNoErrors(); + ->assertHasNoErrors() + ->assertDispatched('cart-updated'); $cart = Cart::withoutGlobalScopes()->findOrFail(session('cart_id')); @@ -34,3 +37,54 @@ expect($cart->refresh()->checkouts)->toHaveCount(1); }); + +test('sold out deny policy variants cannot be added to the cart', function (): void { + $product = Product::query()->where('handle', 'sold-out-canvas-sneaker')->firstOrFail(); + + Livewire::test(ProductShow::class, ['handle' => $product->handle]) + ->assertSee('Out of stock') + ->call('addToCart') + ->assertHasErrors('quantity'); + + expect(session('cart_id'))->toBeNull(); +}); + +test('backorder variants can be added despite zero available stock', function (): void { + $product = Product::query()->where('handle', 'backorder-utility-vest')->firstOrFail(); + + Livewire::test(ProductShow::class, ['handle' => $product->handle]) + ->assertSee('Available on backorder') + ->set('quantity', 2) + ->call('addToCart') + ->assertHasNoErrors() + ->assertDispatched('cart-updated'); + + $cart = Cart::withoutGlobalScopes()->findOrFail(session('cart_id')); + + expect($cart->lines()->first())->quantity->toBe(2); +}); + +test('cart drawer opens from cart events and updates line quantities', function (): void { + $product = Product::query()->where('handle', 'linen-shirt')->firstOrFail(); + + Livewire::test(ProductShow::class, ['handle' => $product->handle]) + ->set('quantity', 2) + ->call('addToCart') + ->assertHasNoErrors(); + + $cart = Cart::withoutGlobalScopes()->with('lines')->findOrFail(session('cart_id')); + $line = $cart->lines->first(); + + Livewire::test(CartDrawer::class) + ->dispatch('cart-updated') + ->assertSet('open', true) + ->assertSee('Linen Shirt') + ->call('incrementLine', $line->id) + ->assertHasNoErrors() + ->call('decrementLine', $line->id) + ->assertHasNoErrors() + ->call('removeLine', $line->id) + ->assertHasNoErrors(); + + expect($cart->lines()->whereKey($line->id)->exists())->toBeFalse(); +}); From 5d9f4a06df6e8ea69ab3b6964bb061c81c77d68c Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 3 May 2026 17:29:48 +0200 Subject: [PATCH 17/35] Build admin fulfillment shipment workflow --- app/Events/FulfillmentDelivered.php | 15 ++++++ app/Events/FulfillmentShipped.php | 15 ++++++ app/Livewire/Admin/Orders/Show.php | 48 ++++++++++++++++++- app/Services/FulfillmentService.php | 28 ++++++++--- .../livewire/admin/orders/show.blade.php | 31 ++++++++++-- specs/progress.md | 8 +++- tests/Feature/Admin/AdminPanelTest.php | 36 ++++++++++++++ tests/Feature/Orders/OrderServiceTest.php | 37 ++++++++++++++ 8 files changed, 206 insertions(+), 12 deletions(-) create mode 100644 app/Events/FulfillmentDelivered.php create mode 100644 app/Events/FulfillmentShipped.php diff --git a/app/Events/FulfillmentDelivered.php b/app/Events/FulfillmentDelivered.php new file mode 100644 index 00000000..0b1c5088 --- /dev/null +++ b/app/Events/FulfillmentDelivered.php @@ -0,0 +1,15 @@ + $this->trackingUrl ?: null, ]); + $this->order = $this->order->refresh(); $this->reset('trackingCompany', 'trackingNumber', 'trackingUrl'); $this->notify('Fulfillment created.'); } catch (Throwable $exception) { @@ -81,6 +84,34 @@ public function fulfillAll(FulfillmentService $fulfillments): void } } + public function markFulfillmentShipped(int $fulfillmentId, FulfillmentService $fulfillments): void + { + $fulfillment = $this->fulfillment($fulfillmentId); + Gate::authorize('update', $fulfillment); + + try { + $fulfillments->markAsShipped($fulfillment); + $this->order = $this->order->refresh(); + $this->notify('Fulfillment marked as shipped.'); + } catch (Throwable $exception) { + $this->addError('order', $exception->getMessage()); + } + } + + public function markFulfillmentDelivered(int $fulfillmentId, FulfillmentService $fulfillments): void + { + $fulfillment = $this->fulfillment($fulfillmentId); + Gate::authorize('update', $fulfillment); + + try { + $fulfillments->markAsDelivered($fulfillment); + $this->order = $this->order->refresh(); + $this->notify('Fulfillment marked as delivered.'); + } catch (Throwable $exception) { + $this->addError('order', $exception->getMessage()); + } + } + public function refund(RefundService $refunds): void { Gate::authorize('refund', $this->order); @@ -108,13 +139,28 @@ public function refund(RefundService $refunds): void public function render(): View { $this->order->load('customer', 'lines.fulfillmentLines', 'payments.refunds', 'refunds', 'fulfillments.lines.orderLine'); + $paymentAllowsFulfillment = in_array($this->order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true); + $isFullyFulfilled = $this->order->fulfillment_status === FulfillmentStatus::Fulfilled; return view('livewire.admin.orders.show', [ 'canConfirmBankTransfer' => $this->order->payment_method->value === 'bank_transfer' && $this->order->financial_status === FinancialStatus::Pending, - 'canFulfill' => in_array($this->order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true), + 'canFulfill' => $paymentAllowsFulfillment && ! $isFullyFulfilled, + 'fulfillmentGuardMessage' => match (true) { + ! $paymentAllowsFulfillment => 'Payment must be confirmed before items can be fulfilled.', + $isFullyFulfilled => 'All line items have been fulfilled.', + default => null, + }, ])->layout('livewire.admin.layout.app', [ 'title' => $this->order->order_number, ]); } + + private function fulfillment(int $fulfillmentId): Fulfillment + { + return $this->order + ->fulfillments() + ->whereKey($fulfillmentId) + ->firstOrFail(); + } } diff --git a/app/Services/FulfillmentService.php b/app/Services/FulfillmentService.php index 8979ddea..b37e0ae0 100644 --- a/app/Services/FulfillmentService.php +++ b/app/Services/FulfillmentService.php @@ -6,6 +6,8 @@ use App\Enums\FulfillmentShipmentStatus; use App\Enums\FulfillmentStatus; use App\Enums\OrderStatus; +use App\Events\FulfillmentDelivered; +use App\Events\FulfillmentShipped; use App\Events\OrderFulfilled; use App\Exceptions\FulfillmentGuardException; use App\Exceptions\FulfillmentQuantityException; @@ -65,6 +67,10 @@ public function markAsShipped(Fulfillment $fulfillment, ?array $tracking = null) ->lockForUpdate() ->firstOrFail(); + if ($fulfillment->status !== FulfillmentShipmentStatus::Pending) { + throw new FulfillmentGuardException('Only pending fulfillments may be marked as shipped.'); + } + $fulfillment->forceFill([ 'status' => FulfillmentShipmentStatus::Shipped, 'tracking_company' => $tracking['tracking_company'] ?? $fulfillment->tracking_company, @@ -72,21 +78,29 @@ public function markAsShipped(Fulfillment $fulfillment, ?array $tracking = null) 'tracking_url' => $tracking['tracking_url'] ?? $fulfillment->tracking_url, 'shipped_at' => now(), ])->save(); + + FulfillmentShipped::dispatch($fulfillment->refresh()); }); } public function markAsDelivered(Fulfillment $fulfillment): void { DB::transaction(function () use ($fulfillment): void { - Fulfillment::query() + $fulfillment = Fulfillment::query() ->whereKey($fulfillment->id) ->lockForUpdate() - ->firstOrFail() - ->forceFill([ - 'status' => FulfillmentShipmentStatus::Delivered, - 'delivered_at' => now(), - ]) - ->save(); + ->firstOrFail(); + + if ($fulfillment->status !== FulfillmentShipmentStatus::Shipped) { + throw new FulfillmentGuardException('Only shipped fulfillments may be marked as delivered.'); + } + + $fulfillment->forceFill([ + 'status' => FulfillmentShipmentStatus::Delivered, + 'delivered_at' => now(), + ])->save(); + + FulfillmentDelivered::dispatch($fulfillment->refresh()); }); } diff --git a/resources/views/livewire/admin/orders/show.blade.php b/resources/views/livewire/admin/orders/show.blade.php index 97278478..7916e028 100644 --- a/resources/views/livewire/admin/orders/show.blade.php +++ b/resources/views/livewire/admin/orders/show.blade.php @@ -46,7 +46,7 @@
Fulfillment @if (! $canFulfill) - + @else
@@ -59,8 +59,33 @@
@foreach ($order->fulfillments as $fulfillment)
-
{{ ucfirst($fulfillment->status->value) }}
-
{{ $fulfillment->tracking_company }} {{ $fulfillment->tracking_number }}
+
+
+
{{ ucfirst($fulfillment->status->value) }}
+ @if ($fulfillment->tracking_company || $fulfillment->tracking_number) +
{{ $fulfillment->tracking_company }} {{ $fulfillment->tracking_number }}
+ @endif + @if ($fulfillment->tracking_url) + Tracking link + @endif +
+ +
+ @if ($fulfillment->status === \App\Enums\FulfillmentShipmentStatus::Pending) + Mark as shipped + @elseif ($fulfillment->status === \App\Enums\FulfillmentShipmentStatus::Shipped) + Mark as delivered + @endif +
+
+ +
+ @foreach ($fulfillment->lines as $fulfillmentLine) +
+ {{ $fulfillmentLine->orderLine?->title_snapshot }} × {{ $fulfillmentLine->quantity }} +
+ @endforeach +
@endforeach
diff --git a/specs/progress.md b/specs/progress.md index f8569829..020556a0 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -21,6 +21,7 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Apps, API tokens, and webhooks are implemented and verified: apps/installations/OAuth metadata, developer API token generation/revocation, store-scoped token middleware, webhook subscriptions, signed delivery jobs, retry/failure tracking, and app admin screens. - Demo seed data now covers the browser-plan fixture contract while preserving `shop.test`: two stores/domains, admin aliases, 20 fashion products, 5 electronics products, sold-out/backorder/draft product edges, five discount codes, 10+2 customers, and 15+3 orders. - Storefront product stock states and the slide-out cart drawer are implemented and verified: sold-out `deny` variants disable add-to-cart, backorder `continue` variants add successfully, the cart drawer opens from cart events, quantity mutations reuse the versioned cart service, and the mobile storefront header fits small viewports. +- Admin order fulfillment workflow now supports fulfillment creation, shipped/delivered shipment transitions, tracking links, shipment events, and verified desktop/mobile order-detail behavior. ## Execution Plan @@ -74,7 +75,7 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Phase 2 still needs full media resizing variants and the richer multi-option variant builder. Admin product create/edit currently covers core product fields, default variant price/SKU, and stock. - Phase 3 still needs richer error templates and fully configurable storefront section ordering. Basic theme editing and publishing now exist in the admin panel; search modal autocomplete landed with the search slice. -- Phase 5 backend bank-transfer confirmation, refunds, and fulfillment services now have admin order-detail actions. More granular partial-fulfillment UI can still be expanded during polish. +- Phase 5 backend bank-transfer confirmation, refunds, fulfillment creation, and shipped/delivered shipment transitions now have admin order-detail actions. More granular partial-fulfillment and line-level refund UI can still be expanded during polish. - Phase 4 has a functional cart page, accessible cart count, and slide-out cart drawer. Discount-code entry remains on the cart/checkout flow; the drawer links customers into checkout rather than applying discounts inline. - Discounts, shipping, and tax are implemented for the specified local/manual flows; provider/carrier integrations remain stubs by design. - Order-reference guards in product deletion/status logic are present but only become fully meaningful once `order_lines` exists in Phase 5. @@ -159,3 +160,8 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - 2026-05-03: `php artisan test --compact` passed, 109 tests / 498 assertions. - 2026-05-03: `npm run build` passed for the updated storefront drawer, product stock, search modal, and responsive header assets. - 2026-05-03: Playwright smoke verified sold-out product disable state, backorder add-to-cart with drawer opening, drawer quantity controls, header search modal suggestions, and mobile drawer/header layout at `http://shop.test`; latest browser console checks reported no warnings or errors. +- 2026-05-03: `php artisan test --compact tests/Feature/Orders/OrderServiceTest.php tests/Feature/Admin/AdminPanelTest.php` passed, 14 tests / 123 assertions. +- 2026-05-03: `vendor/bin/pint --dirty --format agent` passed after admin fulfillment shipment workflow changes. +- 2026-05-03: `php artisan test --compact` passed, 111 tests / 517 assertions. +- 2026-05-03: `npm run build` passed for the updated admin order detail UI assets. +- 2026-05-03: Playwright smoke verified admin order #1001 fulfillment creation, mark as shipped, mark as delivered, tracking display, and mobile order-detail layout at `http://shop.test/admin/orders/1`; latest browser console checks reported no warnings or errors. diff --git a/tests/Feature/Admin/AdminPanelTest.php b/tests/Feature/Admin/AdminPanelTest.php index fe0177e4..ce7fa495 100644 --- a/tests/Feature/Admin/AdminPanelTest.php +++ b/tests/Feature/Admin/AdminPanelTest.php @@ -1,6 +1,7 @@ fresh()->financial_status)->toBe(FinancialStatus::Paid); }); +test('admin can create ship and deliver a fulfillment', function (): void { + actAsAdmin($this); + + $order = Order::query()->where('order_number', '#1001')->firstOrFail(); + $component = Livewire::test(OrderShow::class, ['order' => $order]); + + $component + ->set('trackingCompany', 'DHL') + ->set('trackingNumber', 'DHL123456789') + ->set('trackingUrl', 'https://tracking.test/DHL123456789') + ->call('fulfillAll') + ->assertHasNoErrors() + ->assertSee('All line items have been fulfilled.'); + + $fulfillment = $order->fulfillments()->firstOrFail(); + + expect($fulfillment->tracking_company)->toBe('DHL') + ->and($fulfillment->tracking_number)->toBe('DHL123456789') + ->and($fulfillment->status)->toBe(FulfillmentShipmentStatus::Pending); + + $component + ->call('markFulfillmentShipped', $fulfillment->id) + ->assertHasNoErrors(); + + expect($fulfillment->refresh()->status)->toBe(FulfillmentShipmentStatus::Shipped) + ->and($fulfillment->shipped_at)->not->toBeNull(); + + $component + ->call('markFulfillmentDelivered', $fulfillment->id) + ->assertHasNoErrors(); + + expect($fulfillment->refresh()->status)->toBe(FulfillmentShipmentStatus::Delivered) + ->and($fulfillment->delivered_at)->not->toBeNull(); +}); + test('admin can update store settings', function (): void { actAsAdmin($this); diff --git a/tests/Feature/Orders/OrderServiceTest.php b/tests/Feature/Orders/OrderServiceTest.php index 02fe947d..fb1c173a 100644 --- a/tests/Feature/Orders/OrderServiceTest.php +++ b/tests/Feature/Orders/OrderServiceTest.php @@ -5,11 +5,14 @@ use App\Enums\DiscountType; use App\Enums\DiscountValueType; use App\Enums\FinancialStatus; +use App\Enums\FulfillmentShipmentStatus; use App\Enums\FulfillmentStatus; use App\Enums\OrderStatus; use App\Enums\PaymentMethod; use App\Enums\PaymentStatus; use App\Enums\ShippingRateType; +use App\Events\FulfillmentDelivered; +use App\Events\FulfillmentShipped; use App\Exceptions\FulfillmentGuardException; use App\Exceptions\PaymentFailedException; use App\Jobs\CancelUnpaidBankTransferOrders; @@ -25,6 +28,7 @@ use App\Services\FulfillmentService; use App\Services\OrderService; use App\Services\RefundService; +use Illuminate\Support\Facades\Event; uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); @@ -157,6 +161,39 @@ function phaseFiveCheckoutFixture(PaymentMethod $method = PaymentMethod::CreditC ->and($order->status)->toBe(OrderStatus::Fulfilled); }); +test('fulfillment service marks shipments shipped and delivered', function () { + [, $checkout] = phaseFiveCheckoutFixture(); + $order = app(OrderService::class)->createFromCheckout($checkout, [ + 'card_number' => '4242424242424242', + ]); + + $fulfillment = app(FulfillmentService::class)->create($order, [ + $order->lines()->first()->id => 2, + ], [ + 'tracking_company' => 'DHL', + 'tracking_number' => 'DHL123456789', + 'tracking_url' => 'https://tracking.test/DHL123456789', + ]); + + Event::fake([FulfillmentShipped::class, FulfillmentDelivered::class]); + + app(FulfillmentService::class)->markAsShipped($fulfillment); + + expect($fulfillment->refresh()->status)->toBe(FulfillmentShipmentStatus::Shipped) + ->and($fulfillment->shipped_at)->not->toBeNull() + ->and($fulfillment->tracking_company)->toBe('DHL') + ->and($fulfillment->tracking_number)->toBe('DHL123456789'); + + Event::assertDispatched(FulfillmentShipped::class, fn (FulfillmentShipped $event): bool => $event->fulfillment->is($fulfillment)); + + app(FulfillmentService::class)->markAsDelivered($fulfillment); + + expect($fulfillment->refresh()->status)->toBe(FulfillmentShipmentStatus::Delivered) + ->and($fulfillment->delivered_at)->not->toBeNull(); + + Event::assertDispatched(FulfillmentDelivered::class, fn (FulfillmentDelivered $event): bool => $event->fulfillment->is($fulfillment)); +}); + test('refund service updates financial status and can restock inventory', function () { [, $checkout, $variant] = phaseFiveCheckoutFixture(); $order = app(OrderService::class)->createFromCheckout($checkout, [ From acb8b8e8828b282d6dab4139d1732ba237a74a28 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 3 May 2026 17:41:19 +0200 Subject: [PATCH 18/35] Build checkout step flow --- app/Livewire/Storefront/Checkout/Show.php | 64 ++++++ .../storefront/checkout/show.blade.php | 202 +++++++++++------- specs/progress.md | 9 +- .../Storefront/CheckoutStepFlowTest.php | 53 +++++ 4 files changed, 254 insertions(+), 74 deletions(-) create mode 100644 tests/Feature/Storefront/CheckoutStepFlowTest.php diff --git a/app/Livewire/Storefront/Checkout/Show.php b/app/Livewire/Storefront/Checkout/Show.php index fbb808cb..233e45c0 100644 --- a/app/Livewire/Storefront/Checkout/Show.php +++ b/app/Livewire/Storefront/Checkout/Show.php @@ -19,6 +19,8 @@ class Show extends Component { public int $checkoutId; + public string $activeStep = 'address'; + /** * @var array */ @@ -62,6 +64,19 @@ public function mount(int $checkoutId): void $this->selectedShippingRateId = $checkout->shipping_method_id; $this->paymentMethod = $checkout->payment_method?->value ?? PaymentMethod::CreditCard->value; $this->discountCode = $checkout->discount_code ?? ''; + $this->activeStep = $this->initialStep($checkout); + } + + public function showStep(string $step): void + { + $checkout = $this->checkout(); + $steps = $this->steps($checkout); + + if (! isset($steps[$step]) || ! $steps[$step]['enabled']) { + return; + } + + $this->activeStep = $step; } public function saveAddress(): void @@ -80,6 +95,7 @@ public function saveAddress(): void 'shipping_address' => $this->shippingAddress, 'use_shipping_as_billing' => true, ]); + $this->activeStep = 'shipping'; } catch (CheckoutStateException $exception) { $this->addError('checkout', $exception->getMessage()); } @@ -93,6 +109,7 @@ public function selectShipping(): void try { app(CheckoutService::class)->setShippingMethod($this->checkout(), (int) $this->selectedShippingRateId); + $this->activeStep = 'payment'; } catch (CheckoutStateException|UnavailableShippingRateException $exception) { $this->addError('checkout', $exception->getMessage()); } @@ -189,6 +206,7 @@ public function render(): View return view('livewire.storefront.checkout.show', [ 'checkout' => $checkout, 'shippingMethods' => $shippingMethods, + 'steps' => $this->steps($checkout), 'totals' => $checkout->totals_json ?? [], ])->layout('storefront.layouts.app', [ 'title' => 'Checkout', @@ -203,4 +221,50 @@ private function checkout(): Checkout ->whereKey($this->checkoutId) ->firstOrFail(); } + + private function initialStep(Checkout $checkout): string + { + return match ($checkout->status) { + CheckoutStatus::Started => 'address', + CheckoutStatus::Addressed => 'shipping', + default => 'payment', + }; + } + + /** + * @return array + */ + private function steps(Checkout $checkout): array + { + $address = $checkout->shipping_address_json; + $hasAddress = is_array($address) && filled($address['address1'] ?? null); + $hasShipping = in_array($checkout->status, [CheckoutStatus::ShippingSelected, CheckoutStatus::PaymentSelected, CheckoutStatus::Completed], true); + $hasPayment = in_array($checkout->status, [CheckoutStatus::PaymentSelected, CheckoutStatus::Completed], true); + + return [ + 'address' => [ + 'number' => 1, + 'title' => 'Contact and address', + 'enabled' => true, + 'completed' => $hasAddress, + 'summary' => $hasAddress + ? trim(($address['first_name'] ?? '').' '.($address['last_name'] ?? '')).' · '.($address['address1'] ?? '').', '.($address['postal_code'] ?? '').' '.($address['city'] ?? '') + : $checkout->email, + ], + 'shipping' => [ + 'number' => 2, + 'title' => 'Shipping method', + 'enabled' => $hasAddress, + 'completed' => $hasShipping, + 'summary' => $checkout->shippingRate?->name, + ], + 'payment' => [ + 'number' => 3, + 'title' => 'Payment method', + 'enabled' => $hasShipping, + 'completed' => $hasPayment, + 'summary' => $checkout->payment_method?->value ? str($checkout->payment_method->value)->replace('_', ' ')->title()->toString() : null, + ], + ]; + } } diff --git a/resources/views/livewire/storefront/checkout/show.blade.php b/resources/views/livewire/storefront/checkout/show.blade.php index 874ffd7c..999bf77a 100644 --- a/resources/views/livewire/storefront/checkout/show.blade.php +++ b/resources/views/livewire/storefront/checkout/show.blade.php @@ -1,95 +1,151 @@
-

Checkout

+
+

Checkout

+
    + @foreach($steps as $stepKey => $step) +
  1. + +
  2. + @endforeach +
+
@error('checkout')

{{ $message }}

@enderror -
-

Contact and address

-
- - - - - - - - - -
-
+
+
+
+

1. Contact and address

+ @if($steps['address']['completed']) +

{{ $steps['address']['summary'] }}

+ @else +

{{ $checkout->email }}

+ @endif +
+ @if($activeStep !== 'address') + + @endif +
-
-

Shipping

- @if($shippingMethods->isEmpty()) -

Enter a shipping address to see rates.

- @else -
- @foreach($shippingMethods as $quote) - - @endforeach -
@endif
-
-

Payment

- @error('payment') -

{{ $message }}

- @enderror -
-
- - - +
+
+
+

2. Shipping method

+

{{ $steps['shipping']['summary'] ?: ($steps['shipping']['enabled'] ? 'Choose a shipping rate' : 'Complete address first') }}

- - @if($paymentMethod === 'credit_card') -
- - - - -
- @elseif($paymentMethod === 'bank_transfer') -

Your order will be held while payment is pending.

+ @if($steps['shipping']['completed'] && $activeStep !== 'shipping') + @endif +
- - + @if($activeStep === 'shipping') +
+ @if($shippingMethods->isEmpty()) +

+ {{ $checkout->shipping_address_json ? 'No shipping methods are available for your address.' : 'Enter a shipping address to see rates.' }} +

+ @else +
+
+ Shipping methods + @foreach($shippingMethods as $quote) + + @endforeach +
+ +
+ @endif +
+ @endif +
+ +
+
+

3. Payment method

+

{{ $steps['payment']['summary'] ?: ($steps['payment']['enabled'] ? 'Select a payment method' : 'Select shipping first') }}

+
+ + @if($activeStep === 'payment') +
+ @error('payment') +

{{ $message }}

+ @enderror +
+
+ + + +
+ + @if($paymentMethod === 'credit_card') +
+ + + + +
+ @elseif($paymentMethod === 'bank_transfer') +

Your order will be held while payment is pending.

+ @endif + + +
+
+ @endif
-
+ @if($variants === []) +
+ Default variant +
+ + + +
+
+ @endif +
- Default variant -
- - - +
+
+ Variants +
+ Add option +
+ + @error('options') +
{{ $message }}
+ @enderror + +
+ @foreach($options as $optionIndex => $option) +
+
+ + +
+
Values
+
+ @foreach(($option['values'] ?? []) as $valueIndex => $value) +
+ + +
+ @endforeach +
+
+ Add value +
+
+ + Remove +
+
+ @endforeach
+ + @error('variants') +
{{ $message }}
+ @enderror + + @if($variants !== []) +
+ + + + + + + + + + + + + + + @foreach($variants as $variantIndex => $variant) + + + + + + + + + + + @endforeach + +
VariantSKUBarcodePriceCompareWeightStockShip
{{ $variant['title'] ?? 'Variant' }} + + + + + + + + + + + + + +
+
+ @endif
diff --git a/specs/progress.md b/specs/progress.md index cb0c6672..532d3efb 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -10,7 +10,7 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Repository started from the Laravel Livewire starter kit with Fortify authentication. - Phase 1 foundation is implemented and committed: configuration defaults, core tenancy schema, models, factories, seeders, tenant middleware, customer guard provider registration, store role helper, and password_hash compatibility. -- Phase 2 catalog backend is implemented and committed: products, options, option values, variants, inventory, collections, collection pivot, media schema/models/factories/seed data, product lifecycle service, variant matrix service, inventory service, handle generator, and product/collection policies. +- Phase 2 catalog backend is implemented and committed: products, options, option values, variants, inventory, collections, collection pivot, media schema/models/factories/seed data, product lifecycle service, variant matrix service, admin multi-option variant builder, inventory service, handle generator, and product/collection policies. - Product media processing now generates resized image variants (`thumbnail`, `small`, `medium`, `large`) plus WebP sidecars when supported, records original metadata, fails invalid/missing originals, retries up to three times, and cleans up original/generated files when media is deleted. - Phase 3 theme/storefront shell is implemented and verified: theme/page/navigation schema, models, factories, seed data, theme settings service, navigation service, storefront layout, product cards, price rendering, and initial Livewire storefront pages. - Phase 4 cart/checkout/pricing is implemented and verified: carts, cart lines, checkouts, shipping zones/rates, tax settings, discounts, cart and checkout services, persisted cart discount codes with line allocations, pricing snapshots, storefront REST endpoints, Livewire cart/checkout UI, and cleanup jobs. @@ -85,7 +85,6 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit ## Open Gaps -- Phase 2 still needs the richer admin multi-option variant builder. Admin product create/edit currently covers core product fields, default variant price/SKU, and stock; backend variant matrix generation and media resizing are implemented and verified. - Phase 3 still needs richer error templates and fully configurable storefront section ordering. Basic theme editing and publishing now exist in the admin panel; search modal autocomplete landed with the search slice. - Phase 5 backend bank-transfer confirmation, refunds, fulfillment creation, and shipped/delivered shipment transitions now have admin order-detail actions. More granular partial-fulfillment and line-level refund UI can still be expanded during polish. - Discounts, shipping, and tax are implemented for the specified local/manual flows; provider/carrier integrations remain stubs by design. @@ -247,3 +246,9 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - 2026-05-03: `php artisan test --compact` passed, 156 tests / 932 assertions. - 2026-05-03: `npm run build` passed for the updated cart drawer/cart page discount UI. - 2026-05-03: Playwright smoke verified product add-to-cart, drawer discount apply/remove, cart-page discount apply, and checkout inheritance of `WELCOME10` at `http://shop.test`; current Playwright console check reported no warnings or errors. +- 2026-05-03: `php artisan test --compact tests/Feature/Admin/AdminPanelTest.php tests/Feature/Products/ProductServiceTest.php` passed, 16 tests / 111 assertions. +- 2026-05-03: `php artisan test --compact tests/Feature/Admin/AdminPanelTest.php tests/Feature/Products tests/Feature/Api/AdminProductApiTest.php` passed, 29 tests / 228 assertions. +- 2026-05-03: `vendor/bin/pint --dirty --format agent` passed after admin variant-builder changes. +- 2026-05-03: `php artisan test --compact` passed, 158 tests / 945 assertions. +- 2026-05-03: `npm run build` passed for the updated admin product variant-builder UI. +- 2026-05-03: Playwright smoke verified `/admin/products/create` option/value rows, generated 2x2 variant table, per-variant SKU/price/stock edits, save redirect to `/admin/products/{id}/edit`, and no current Playwright console warnings or errors. diff --git a/tests/Feature/Admin/AdminPanelTest.php b/tests/Feature/Admin/AdminPanelTest.php index ce7fa495..d7e47605 100644 --- a/tests/Feature/Admin/AdminPanelTest.php +++ b/tests/Feature/Admin/AdminPanelTest.php @@ -112,6 +112,46 @@ function actAsAdmin($test): void ->and($variant->inventoryItem->quantity_on_hand)->toBe(8); }); +test('admin can create a product with multi option variants', function (): void { + actAsAdmin($this); + + Livewire::test(ProductForm::class) + ->set('title', 'Matrix Hoodie') + ->set('status', 'active') + ->set('vendor', 'Acme') + ->set('productType', 'Hoodies') + ->set('priceAmount', 5900) + ->set('quantityOnHand', 3) + ->set('options', [ + ['name' => 'Size', 'values' => ['S', 'M']], + ['name' => 'Color', 'values' => ['Black', 'White']], + ]) + ->call('generateVariants') + ->set('variants.0.sku', 'HD-S-BLK') + ->set('variants.0.priceAmount', 5900) + ->set('variants.0.quantityOnHand', 4) + ->set('variants.3.sku', 'HD-M-WHT') + ->set('variants.3.priceAmount', 6200) + ->set('variants.3.quantityOnHand', 7) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::query() + ->where('title', 'Matrix Hoodie') + ->with('options.values', 'variants.optionValues.option', 'variants.inventoryItem') + ->firstOrFail(); + + $whiteMedium = $product->variants + ->first(fn ($variant): bool => $variant->optionValues->pluck('value')->sort()->values()->all() === ['M', 'White']); + + expect($product->options)->toHaveCount(2) + ->and($product->options->firstWhere('name', 'Size')->values)->toHaveCount(2) + ->and($product->variants)->toHaveCount(4) + ->and($whiteMedium->sku)->toBe('HD-M-WHT') + ->and($whiteMedium->price_amount)->toBe(6200) + ->and($whiteMedium->inventoryItem->quantity_on_hand)->toBe(7); +}); + test('admin can confirm a pending bank transfer order', function (): void { actAsAdmin($this); diff --git a/tests/Feature/Products/ProductServiceTest.php b/tests/Feature/Products/ProductServiceTest.php index 585d2695..c2222a95 100644 --- a/tests/Feature/Products/ProductServiceTest.php +++ b/tests/Feature/Products/ProductServiceTest.php @@ -93,6 +93,45 @@ ->and(ProductVariant::query()->whereKey($template->id)->exists())->toBeFalse(); }); +test('product service syncs option matrix variant fields and inventory', function () { + $store = Store::factory()->create(['default_currency' => 'EUR']); + $product = Product::factory()->for($store)->create(); + ProductVariant::factory()->for($product)->default()->create(['price_amount' => 1999, 'currency' => 'EUR']); + + app(ProductService::class)->syncOptionMatrix($product, [ + ['name' => 'Size', 'values' => ['S', 'M']], + ['name' => 'Color', 'values' => ['Black', 'White']], + ], [ + [ + 'option_values' => ['S', 'Black'], + 'sku' => 'TEE-S-BLK', + 'price_amount' => 2499, + 'quantity_on_hand' => 5, + 'requires_shipping' => true, + 'currency' => 'EUR', + ], + [ + 'option_values' => ['M', 'White'], + 'sku' => 'TEE-M-WHT', + 'price_amount' => 2799, + 'quantity_on_hand' => 8, + 'requires_shipping' => false, + 'currency' => 'EUR', + ], + ]); + + $product = $product->refresh()->load('options.values', 'variants.optionValues.option', 'variants.inventoryItem'); + $whiteMedium = $product->variants + ->first(fn (ProductVariant $variant): bool => $variant->optionValues->pluck('value')->sort()->values()->all() === ['M', 'White']); + + expect($product->options)->toHaveCount(2) + ->and($product->variants)->toHaveCount(4) + ->and($whiteMedium->sku)->toBe('TEE-M-WHT') + ->and($whiteMedium->price_amount)->toBe(2799) + ->and($whiteMedium->requires_shipping)->toBeFalse() + ->and($whiteMedium->inventoryItem->quantity_on_hand)->toBe(8); +}); + test('product service blocks draft reversion and deletion when order lines reference product', function () { $store = Store::factory()->create(); $product = Product::factory()->for($store)->withDefaultVariant(1000)->create([ From 9fb79117175b0984d537a3c077951747d63f4480 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 3 May 2026 20:27:26 +0200 Subject: [PATCH 33/35] Build configurable storefront theme sections --- .../Controllers/Api/Admin/ThemeController.php | 5 +- app/Http/Middleware/ResolveStore.php | 2 + app/Livewire/Admin/Themes/Editor.php | 204 +++++++++++++++++- app/Livewire/Admin/Themes/Index.php | 3 + app/Livewire/Storefront/Home.php | 23 +- app/Services/ThemeSettingsService.php | 148 ++++++++++++- database/factories/ThemeSettingsFactory.php | 16 ++ database/seeders/ThemeSeeder.php | 16 ++ resources/views/errors/404.blade.php | 34 ++- resources/views/errors/503.blade.php | 21 +- .../livewire/admin/themes/editor.blade.php | 108 ++++++++-- .../views/livewire/storefront/home.blade.php | 147 +++++++++---- specs/progress.md | 8 +- tests/Feature/Admin/AdminPanelTest.php | 33 ++- .../Storefront/StorefrontRenderTest.php | 8 + .../Storefront/ThemeNavigationTest.php | 30 ++- .../Feature/Tenancy/TenantResolutionTest.php | 4 +- 17 files changed, 718 insertions(+), 92 deletions(-) diff --git a/app/Http/Controllers/Api/Admin/ThemeController.php b/app/Http/Controllers/Api/Admin/ThemeController.php index 67b371ab..ba767cda 100644 --- a/app/Http/Controllers/Api/Admin/ThemeController.php +++ b/app/Http/Controllers/Api/Admin/ThemeController.php @@ -10,6 +10,7 @@ use App\Models\Store; use App\Models\Theme; use App\Services\ThemeArchiveImporter; +use App\Services\ThemeSettingsService; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Cache; @@ -49,13 +50,13 @@ public function publish(Store $store, int $theme): ThemeResource return new ThemeResource($existingTheme->refresh()->load('settings')->loadCount('files')); } - public function updateSettings(UpdateThemeSettingsRequest $request, Store $store, int $theme): ThemeResource + public function updateSettings(UpdateThemeSettingsRequest $request, Store $store, int $theme, ThemeSettingsService $themeSettings): ThemeResource { $existingTheme = $this->findTheme($store, $theme); $existingTheme->settings()->updateOrCreate( ['theme_id' => $existingTheme->id], - ['settings_json' => $request->validated('settings_json')], + ['settings_json' => $themeSettings->prepareForStorage($store, $request->validated('settings_json'))], ); Cache::forget("theme_settings:{$store->id}"); diff --git a/app/Http/Middleware/ResolveStore.php b/app/Http/Middleware/ResolveStore.php index 8d6b9a1e..5e3f2a83 100644 --- a/app/Http/Middleware/ResolveStore.php +++ b/app/Http/Middleware/ResolveStore.php @@ -51,6 +51,8 @@ private function resolveStorefrontStore(Request $request): Store } if ($store->status === StoreStatus::Suspended) { + app()->instance('current_store', $store); + abort(503); } diff --git a/app/Livewire/Admin/Themes/Editor.php b/app/Livewire/Admin/Themes/Editor.php index e7edb3ca..99150b69 100644 --- a/app/Livewire/Admin/Themes/Editor.php +++ b/app/Livewire/Admin/Themes/Editor.php @@ -2,8 +2,12 @@ namespace App\Livewire\Admin\Themes; +use App\Enums\ThemeStatus; use App\Livewire\Admin\Concerns\UsesAdminStore; use App\Models\Theme; +use App\Services\ThemeSettingsService; +use Illuminate\Support\Facades\Cache; +use Illuminate\Validation\Rule; use Illuminate\View\View; use Livewire\Component; @@ -13,32 +17,214 @@ class Editor extends Component public Theme $theme; - public string $settingsJson = '{}'; + /** + * @var array + */ + public array $settings = []; + + /** + * @var list + */ + public array $homeSections = []; + + public string $selectedSection = 'hero'; + + public int $previewVersion = 0; public function mount(Theme $theme): void { $this->theme = $theme->load('settings'); - $this->settingsJson = json_encode($this->theme->settings?->settings_json ?? [], JSON_PRETTY_PRINT) ?: '{}'; + $this->settings = app(ThemeSettingsService::class)->mergeWithDefaults( + $this->currentStore(), + $this->theme->settings?->settings_json ?? [], + ); + $this->homeSections = app(ThemeSettingsService::class)->homeSections($this->settings); + $this->selectedSection = $this->homeSections[0]['key'] ?? 'hero'; + } + + public function selectSection(string $sectionKey): void + { + if (! $this->editorSectionDefinitions()->has($sectionKey)) { + return; + } + + $this->selectedSection = $sectionKey; + } + + public function moveSectionUp(string $sectionKey): void + { + $this->moveSection($sectionKey, -1); + } + + public function moveSectionDown(string $sectionKey): void + { + $this->moveSection($sectionKey, 1); + } + + public function updated(string $property, mixed $value = null): void + { + if (str_starts_with($property, 'homeSections.')) { + $this->syncHomeSections(); + } } public function save(): void { - $validated = $this->validate([ - 'settingsJson' => ['required', 'json'], + $this->persistSettings(); + $this->refreshPreview(); + + $this->notify('Theme settings saved.'); + } + + public function publish(): void + { + $this->persistSettings(); + $this->refreshPreview(); + + $store = $this->currentStore(); + + Theme::withoutGlobalScopes() + ->where('store_id', $store->id) + ->update([ + 'status' => ThemeStatus::Draft->value, + 'published_at' => null, + ]); + + $this->theme->forceFill([ + 'status' => ThemeStatus::Published, + 'published_at' => now(), + ])->save(); + + Cache::forget("theme_settings:{$store->id}"); + + $this->notify('Theme saved and published.'); + } + + public function refreshPreview(): void + { + $this->previewVersion++; + } + + public function render(): View + { + return view('livewire.admin.themes.editor', [ + 'previewUrl' => route('home', ['preview' => $this->previewVersion]), + 'sectionDefinitions' => $this->editorSectionDefinitions()->all(), + 'selectedSectionDefinition' => $this->editorSectionDefinitions()->get($this->selectedSection), + ])->layout('livewire.admin.layout.app', [ + 'title' => 'Theme editor', ]); + } + + private function persistSettings(): void + { + $this->syncHomeSections(); + + $this->validate($this->rules()); + + $store = $this->currentStore(); + $settings = app(ThemeSettingsService::class)->prepareForStorage($store, $this->settings); $this->theme->settings()->updateOrCreate( ['theme_id' => $this->theme->id], - ['settings_json' => json_decode($validated['settingsJson'], true)], + ['settings_json' => $settings], ); - $this->notify('Theme settings saved.'); + Cache::forget("theme_settings:{$store->id}"); + + $this->settings = $settings; + $this->homeSections = app(ThemeSettingsService::class)->homeSections($settings); } - public function render(): View + /** + * @return array + */ + private function rules(): array { - return view('livewire.admin.themes.editor')->layout('livewire.admin.layout.app', [ - 'title' => 'Theme editor', + $sectionKeys = $this->homeSectionDefinitions()->keys()->all(); + + return [ + 'homeSections' => ['required', 'array'], + 'homeSections.*.key' => ['required', Rule::in($sectionKeys)], + 'homeSections.*.enabled' => ['boolean'], + 'settings.announcement.enabled' => ['boolean'], + 'settings.announcement.text' => ['nullable', 'string', 'max:160'], + 'settings.announcement.link' => ['nullable', 'string', 'max:255'], + 'settings.home.hero_heading' => ['required', 'string', 'max:120'], + 'settings.home.hero_subheading' => ['nullable', 'string', 'max:240'], + 'settings.home.hero_cta_label' => ['required', 'string', 'max:80'], + 'settings.home.hero_cta_url' => ['required', 'string', 'max:255'], + 'settings.home.featured_collections_heading' => ['required', 'string', 'max:120'], + 'settings.home.featured_collections_subheading' => ['nullable', 'string', 'max:240'], + 'settings.home.featured_collections_count' => ['required', 'integer', 'min:2', 'max:4'], + 'settings.home.featured_products_heading' => ['required', 'string', 'max:120'], + 'settings.home.featured_products_count' => ['required', 'integer', 'min:4', 'max:8'], + 'settings.home.newsletter_heading' => ['required', 'string', 'max:120'], + 'settings.home.newsletter_subheading' => ['nullable', 'string', 'max:240'], + 'settings.home.rich_text_heading' => ['required', 'string', 'max:120'], + 'settings.home.rich_text_html' => ['nullable', 'string', 'max:65535'], + 'settings.footer.contact_email' => ['nullable', 'email', 'max:255'], + ]; + } + + private function moveSection(string $sectionKey, int $direction): void + { + $index = collect($this->homeSections)->search( + fn (array $section): bool => $section['key'] === $sectionKey, + ); + + if ($index === false) { + return; + } + + $target = $index + $direction; + + if (! array_key_exists($target, $this->homeSections)) { + return; + } + + $currentSection = $this->homeSections[$index]; + $this->homeSections[$index] = $this->homeSections[$target]; + $this->homeSections[$target] = $currentSection; + + $this->syncHomeSections(); + } + + private function syncHomeSections(): void + { + $this->settings['home']['sections'] = app(ThemeSettingsService::class)->homeSections([ + 'home' => [ + 'sections' => $this->homeSections, + ], ]); + $this->homeSections = $this->settings['home']['sections']; + } + + /** + * @return \Illuminate\Support\Collection + */ + private function homeSectionDefinitions(): \Illuminate\Support\Collection + { + return collect(app(ThemeSettingsService::class)->homeSectionDefinitions())->keyBy('key'); + } + + /** + * @return \Illuminate\Support\Collection + */ + private function editorSectionDefinitions(): \Illuminate\Support\Collection + { + return collect([ + [ + 'key' => 'announcement', + 'label' => 'Announcement', + 'description' => 'Top storefront bar.', + ], + ...app(ThemeSettingsService::class)->homeSectionDefinitions(), + [ + 'key' => 'footer', + 'label' => 'Footer', + 'description' => 'Store footer.', + ], + ])->keyBy('key'); } } diff --git a/app/Livewire/Admin/Themes/Index.php b/app/Livewire/Admin/Themes/Index.php index f8349be0..4f530451 100644 --- a/app/Livewire/Admin/Themes/Index.php +++ b/app/Livewire/Admin/Themes/Index.php @@ -5,6 +5,7 @@ use App\Enums\ThemeStatus; use App\Livewire\Admin\Concerns\UsesAdminStore; use App\Models\Theme; +use Illuminate\Support\Facades\Cache; use Illuminate\View\View; use Livewire\Component; @@ -20,6 +21,8 @@ public function publish(int $themeId): void 'published_at' => now(), ])->save(); + Cache::forget("theme_settings:{$this->currentStore()->id}"); + $this->notify('Theme published.'); } diff --git a/app/Livewire/Storefront/Home.php b/app/Livewire/Storefront/Home.php index 016270c1..0660601d 100644 --- a/app/Livewire/Storefront/Home.php +++ b/app/Livewire/Storefront/Home.php @@ -12,24 +12,41 @@ class Home extends Component { + public string $newsletterEmail = ''; + + public bool $newsletterSubscribed = false; + + public function subscribeToNewsletter(): void + { + $this->validate([ + 'newsletterEmail' => ['required', 'email', 'max:255'], + ]); + + $this->newsletterSubscribed = true; + $this->newsletterEmail = ''; + } + public function render(): View { $store = app('current_store'); + $themeSettings = app(ThemeSettingsService::class); + $settings = $themeSettings->forStore($store); return view('livewire.storefront.home', [ 'store' => $store, - 'settings' => app(ThemeSettingsService::class)->forStore($store), + 'settings' => $settings, + 'sections' => $themeSettings->homeSections($settings), 'collections' => Collection::query() ->where('status', CollectionStatus::Active) ->latest() - ->limit(3) + ->limit((int) data_get($settings, 'home.featured_collections_count', 3)) ->get(), 'products' => Product::query() ->with('variants', 'media') ->where('status', ProductStatus::Active) ->whereNotNull('published_at') ->latest('published_at') - ->limit(6) + ->limit((int) data_get($settings, 'home.featured_products_count', 6)) ->get(), ])->layout('storefront.layouts.app', [ 'title' => $store->name, diff --git a/app/Services/ThemeSettingsService.php b/app/Services/ThemeSettingsService.php index 181dff87..07c82a08 100644 --- a/app/Services/ThemeSettingsService.php +++ b/app/Services/ThemeSettingsService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Actions\SanitizeHtml; use App\Enums\ThemeStatus; use App\Models\Store; use App\Models\Theme; @@ -9,6 +10,10 @@ class ThemeSettingsService { + public function __construct( + private readonly SanitizeHtml $sanitizeHtml, + ) {} + /** * @return array */ @@ -22,10 +27,119 @@ public function forStore(Store $store): array ->latest('published_at') ->first(); - return array_replace_recursive($this->defaults($store), $theme?->settings?->settings_json ?? []); + return $this->mergeWithDefaults($store, $theme?->settings?->settings_json ?? []); }); } + /** + * @param array $settings + * @return array + */ + public function mergeWithDefaults(Store $store, array $settings): array + { + $merged = array_replace_recursive($this->defaults($store), $settings); + $merged['home']['sections'] = $this->homeSections($settings); + $merged['home']['featured_collections_count'] = $this->boundedInteger( + data_get($merged, 'home.featured_collections_count'), + 3, + 2, + 4, + ); + $merged['home']['featured_products_count'] = $this->boundedInteger( + data_get($merged, 'home.featured_products_count'), + 6, + 4, + 8, + ); + + $richText = data_get($merged, 'home.rich_text_html'); + $merged['home']['rich_text_html'] = $richText === null + ? null + : ($this->sanitizeHtml)((string) $richText); + + return $merged; + } + + /** + * @param array $settings + * @return array + */ + public function prepareForStorage(Store $store, array $settings): array + { + return $this->mergeWithDefaults($store, $settings); + } + + /** + * @return list + */ + public function homeSectionDefinitions(): array + { + return [ + [ + 'key' => 'hero', + 'label' => 'Hero banner', + 'description' => 'Primary homepage feature.', + ], + [ + 'key' => 'featured_collections', + 'label' => 'Featured collections', + 'description' => 'Collection cards.', + ], + [ + 'key' => 'featured_products', + 'label' => 'Featured products', + 'description' => 'Product grid.', + ], + [ + 'key' => 'newsletter', + 'label' => 'Newsletter signup', + 'description' => 'Email capture block.', + ], + [ + 'key' => 'rich_text', + 'label' => 'Rich text', + 'description' => 'Editorial content.', + ], + ]; + } + + /** + * @param array $settings + * @return list + */ + public function homeSections(array $settings): array + { + $sections = data_get($settings, 'home.sections'); + $definitions = collect($this->homeSectionDefinitions())->keyBy('key'); + $normalized = []; + + if (is_array($sections)) { + foreach ($sections as $section) { + $key = is_array($section) ? (string) ($section['key'] ?? '') : ''; + + if (! $definitions->has($key) || array_key_exists($key, $normalized)) { + continue; + } + + $normalized[$key] = [ + 'key' => $key, + 'enabled' => $this->booleanValue(is_array($section) ? ($section['enabled'] ?? true) : true), + ]; + } + } + + foreach ($definitions->keys() as $key) { + if (! array_key_exists($key, $normalized)) { + $normalized[$key] = [ + 'key' => $key, + 'enabled' => true, + ]; + } + } + + return array_values($normalized); + } + /** * @return array */ @@ -38,14 +152,46 @@ private function defaults(Store $store): array 'link' => null, ], 'home' => [ + 'sections' => $this->homeSections([]), 'hero_heading' => $store->name, 'hero_subheading' => 'Curated products from '.$store->name.'.', 'hero_cta_label' => 'Shop products', 'hero_cta_url' => '/collections', + 'featured_collections_heading' => 'Featured Collections', + 'featured_collections_subheading' => 'Curated selections from '.$store->name.'.', + 'featured_collections_count' => 3, + 'featured_products_heading' => 'Featured Products', + 'featured_products_count' => 6, + 'newsletter_heading' => 'Stay in the loop', + 'newsletter_subheading' => 'Subscribe for exclusive offers and updates.', + 'rich_text_heading' => 'From the studio', + 'rich_text_html' => '

Discover thoughtful products selected for everyday use.

', ], 'footer' => [ 'contact_email' => $store->organization?->billing_email, ], ]; } + + private function boundedInteger(mixed $value, int $default, int $minimum, int $maximum): int + { + $integer = filter_var($value, FILTER_VALIDATE_INT); + + if ($integer === false) { + $integer = $default; + } + + return min($maximum, max($minimum, (int) $integer)); + } + + private function booleanValue(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + + $boolean = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE); + + return $boolean ?? true; + } } diff --git a/database/factories/ThemeSettingsFactory.php b/database/factories/ThemeSettingsFactory.php index 4183dc74..0d8b0108 100644 --- a/database/factories/ThemeSettingsFactory.php +++ b/database/factories/ThemeSettingsFactory.php @@ -26,10 +26,26 @@ public function definition(): array 'link' => '/collections/summer-essentials', ], 'home' => [ + 'sections' => [ + ['key' => 'hero', 'enabled' => true], + ['key' => 'featured_collections', 'enabled' => true], + ['key' => 'featured_products', 'enabled' => true], + ['key' => 'newsletter', 'enabled' => true], + ['key' => 'rich_text', 'enabled' => true], + ], 'hero_heading' => 'Acme Fashion', 'hero_subheading' => 'Everyday essentials with sharp fits and breathable fabrics.', 'hero_cta_label' => 'Shop new arrivals', 'hero_cta_url' => '/collections/summer-essentials', + 'featured_collections_heading' => 'Featured Collections', + 'featured_collections_subheading' => 'Curated selections from Acme Fashion.', + 'featured_collections_count' => 3, + 'featured_products_heading' => 'Featured Products', + 'featured_products_count' => 6, + 'newsletter_heading' => 'Stay in the loop', + 'newsletter_subheading' => 'Subscribe for exclusive offers and updates.', + 'rich_text_heading' => 'Designed for daily wear', + 'rich_text_html' => '

Clean silhouettes, durable fabrics, and useful details shape every release.

', ], 'footer' => [ 'contact_email' => 'support@acme.test', diff --git a/database/seeders/ThemeSeeder.php b/database/seeders/ThemeSeeder.php index 6822b76b..08818171 100644 --- a/database/seeders/ThemeSeeder.php +++ b/database/seeders/ThemeSeeder.php @@ -39,10 +39,26 @@ public function run(): void 'link' => '/collections/summer-essentials', ], 'home' => [ + 'sections' => [ + ['key' => 'hero', 'enabled' => true], + ['key' => 'featured_collections', 'enabled' => true], + ['key' => 'featured_products', 'enabled' => true], + ['key' => 'newsletter', 'enabled' => true], + ['key' => 'rich_text', 'enabled' => true], + ], 'hero_heading' => 'Acme Fashion', 'hero_subheading' => 'Light layers, clean lines, and durable everyday staples.', 'hero_cta_label' => 'Shop summer essentials', 'hero_cta_url' => '/collections/summer-essentials', + 'featured_collections_heading' => 'Featured Collections', + 'featured_collections_subheading' => 'Curated selections from Acme Fashion.', + 'featured_collections_count' => 3, + 'featured_products_heading' => 'Featured Products', + 'featured_products_count' => 6, + 'newsletter_heading' => 'Stay in the loop', + 'newsletter_subheading' => 'Subscribe for exclusive offers and updates.', + 'rich_text_heading' => 'Designed for daily wear', + 'rich_text_html' => '

Clean silhouettes, durable fabrics, and useful details shape every Acme Fashion release.

', ], 'footer' => [ 'contact_email' => 'support@acme.test', diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php index ed6414b3..af16dc8e 100644 --- a/resources/views/errors/404.blade.php +++ b/resources/views/errors/404.blade.php @@ -1,3 +1,7 @@ +@php + $store = app()->bound('current_store') ? app('current_store') : null; +@endphp + @@ -7,12 +11,30 @@ @vite(['resources/css/app.css', 'resources/js/app.js']) -
-
-

404

-

Page not found

-

The page you requested is not available for this store.

- Return home +
+
+ 404 +
+ +
+ @if($store) + {{ $store->name }} + @endif + +

Page not found

+

+ The page you're looking for doesn't exist or has been moved. +

+ + + + + + + + + Return home +
diff --git a/resources/views/errors/503.blade.php b/resources/views/errors/503.blade.php index 98cf23f0..43a0abfb 100644 --- a/resources/views/errors/503.blade.php +++ b/resources/views/errors/503.blade.php @@ -1,3 +1,7 @@ +@php + $store = app()->bound('current_store') ? app('current_store') : null; +@endphp + @@ -8,10 +12,19 @@
-
-

503

-

Store unavailable

-

This storefront is temporarily unavailable.

+
+
+ {{ str($store?->name ?? config('app.name'))->substr(0, 1)->upper() }} +
+ + @if($store) +

{{ $store->name }}

+ @endif + +

We'll be back soon

+

+ We're currently performing maintenance. Please check back shortly. +

diff --git a/resources/views/livewire/admin/themes/editor.blade.php b/resources/views/livewire/admin/themes/editor.blade.php index c2c173c1..8dcff9b8 100644 --- a/resources/views/livewire/admin/themes/editor.blade.php +++ b/resources/views/livewire/admin/themes/editor.blade.php @@ -2,33 +2,111 @@
{{ $theme->name }} - Theme settings JSON for the storefront renderer. + Storefront theme editor
-
- Back to themes - Save settings +
+ Back to themes + Refresh preview + Save + Save and publish
-
-
+
+
Sections -
-
Announcement
-
Hero
-
Featured products
-
Footer
+ +
+ @php($announcement = $sectionDefinitions['announcement']) + + +
+ @foreach($homeSections as $index => $section) + @php($definition = $sectionDefinitions[$section['key']]) +
+
+ + +
+
+ + +
+
+ @endforeach +
+ + @php($footer = $sectionDefinitions['footer']) +
-
- Preview - +
+
+ Live preview + Open +
+
- +
+
+ {{ $selectedSectionDefinition['label'] ?? 'Settings' }} + @if($selectedSectionDefinition) + {{ $selectedSectionDefinition['description'] }} + @endif +
+
+ +
+ @switch($selectedSection) + @case('announcement') + + + + @break + + @case('hero') + + + + + @break + + @case('featured_collections') + + + + @break + + @case('featured_products') + + + @break + + @case('newsletter') + + + @break + + @case('rich_text') + + + @break + + @case('footer') + + @break + @endswitch +
diff --git a/resources/views/livewire/storefront/home.blade.php b/resources/views/livewire/storefront/home.blade.php index c5f31446..1020159f 100644 --- a/resources/views/livewire/storefront/home.blade.php +++ b/resources/views/livewire/storefront/home.blade.php @@ -1,51 +1,104 @@
-
-
-
-

{{ data_get($settings, 'home.hero_heading') }}

-

{{ data_get($settings, 'home.hero_subheading') }}

- -
-
-
-
Featured edit
-
Summer Essentials
-
-
-
-
+ @foreach($sections as $section) + @continue(! $section['enabled']) -
-
-
-

Featured Collections

-

Curated selections from {{ $store->name }}.

-
- View all -
-
- @foreach($collections as $collection) - -

{{ $collection->title }}

-
{{ strip_tags($collection->description_html) }}
-
- @endforeach -
-
+ @switch($section['key']) + @case('hero') +
+
+
+

{{ data_get($settings, 'home.hero_heading') }}

+

{{ data_get($settings, 'home.hero_subheading') }}

+ +
+
+
+
{{ $store->name }}
+
{{ data_get($settings, 'home.hero_heading') }}
+
+
+
+
+ @break -
-
-

Featured Products

- Search products -
-
- @foreach($products as $product) - @include('storefront.components.product-card', ['product' => $product]) - @endforeach -
-
+ @case('featured_collections') +
+
+
+

{{ data_get($settings, 'home.featured_collections_heading') }}

+

{{ data_get($settings, 'home.featured_collections_subheading') }}

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

{{ $collection->title }}

+
Shop now
+
+
+ @endforeach +
+
+ @break + + @case('featured_products') +
+
+

{{ data_get($settings, 'home.featured_products_heading') }}

+ Search products +
+
+ @foreach($products as $product) +
+ @include('storefront.components.product-card', ['product' => $product]) +
+ @endforeach +
+
+ @break + + @case('newsletter') +
+
+

{{ data_get($settings, 'home.newsletter_heading') }}

+

{{ data_get($settings, 'home.newsletter_subheading') }}

+ + @if($newsletterSubscribed) +

Thanks for subscribing.

+ @else +
+ + + + @error('newsletterEmail') +

{{ $message }}

+ @enderror +
+ @endif +
+
+ @break + + @case('rich_text') + @if(data_get($settings, 'home.rich_text_html')) +
+

{{ data_get($settings, 'home.rich_text_heading') }}

+
+ {!! data_get($settings, 'home.rich_text_html') !!} +
+
+ @endif + @break + @endswitch + @endforeach
diff --git a/specs/progress.md b/specs/progress.md index 532d3efb..f3c064a4 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -12,7 +12,7 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Phase 1 foundation is implemented and committed: configuration defaults, core tenancy schema, models, factories, seeders, tenant middleware, customer guard provider registration, store role helper, and password_hash compatibility. - Phase 2 catalog backend is implemented and committed: products, options, option values, variants, inventory, collections, collection pivot, media schema/models/factories/seed data, product lifecycle service, variant matrix service, admin multi-option variant builder, inventory service, handle generator, and product/collection policies. - Product media processing now generates resized image variants (`thumbnail`, `small`, `medium`, `large`) plus WebP sidecars when supported, records original metadata, fails invalid/missing originals, retries up to three times, and cleans up original/generated files when media is deleted. -- Phase 3 theme/storefront shell is implemented and verified: theme/page/navigation schema, models, factories, seed data, theme settings service, navigation service, storefront layout, product cards, price rendering, and initial Livewire storefront pages. +- Phase 3 theme/storefront shell is implemented and verified: theme/page/navigation schema, models, factories, seed data, theme settings service, configurable storefront homepage section ordering/visibility, navigation service, storefront layout, product cards, price rendering, richer 404/503 error templates, and Livewire storefront pages. - Phase 4 cart/checkout/pricing is implemented and verified: carts, cart lines, checkouts, shipping zones/rates, tax settings, discounts, cart and checkout services, persisted cart discount codes with line allocations, pricing snapshots, storefront REST endpoints, Livewire cart/checkout UI, and cleanup jobs. - Phase 5 payments/orders/customer persistence is implemented and verified: customers, customer addresses, orders, order lines, payments, refunds, fulfillments, mock PSP, checkout pay endpoint/UI, order confirmation, bank-transfer confirmation/cancellation services, and focused tests. - Phase 6 customer accounts are implemented and verified: store-scoped customer login/registration, customer password reset links/emails, account dashboard, order history/detail pages, address book CRUD, and seeded customer credentials. @@ -85,7 +85,6 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit ## Open Gaps -- Phase 3 still needs richer error templates and fully configurable storefront section ordering. Basic theme editing and publishing now exist in the admin panel; search modal autocomplete landed with the search slice. - Phase 5 backend bank-transfer confirmation, refunds, fulfillment creation, and shipped/delivered shipment transitions now have admin order-detail actions. More granular partial-fulfillment and line-level refund UI can still be expanded during polish. - Discounts, shipping, and tax are implemented for the specified local/manual flows; provider/carrier integrations remain stubs by design. - Admin analytics, apps, API tokens, and webhook backend flows are implemented for the current data model. The developer screen exposes the current spec-backed token abilities, including theme/content/settings scopes and owner-only `manage-platform`. A future dependency decision could replace the first-party token table with Sanctum if package changes are approved. @@ -252,3 +251,8 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - 2026-05-03: `php artisan test --compact` passed, 158 tests / 945 assertions. - 2026-05-03: `npm run build` passed for the updated admin product variant-builder UI. - 2026-05-03: Playwright smoke verified `/admin/products/create` option/value rows, generated 2x2 variant table, per-variant SKU/price/stock edits, save redirect to `/admin/products/{id}/edit`, and no current Playwright console warnings or errors. +- 2026-05-03: `php artisan test --compact tests/Feature/Admin/AdminPanelTest.php tests/Feature/Storefront/ThemeNavigationTest.php tests/Feature/Storefront/StorefrontRenderTest.php tests/Feature/Tenancy/TenantResolutionTest.php tests/Feature/Api/AdminThemeApiTest.php` passed, 23 tests / 155 assertions. +- 2026-05-03: `vendor/bin/pint --dirty --format agent` passed after storefront section ordering, theme editor, and error template changes. +- 2026-05-03: `php artisan test --compact` passed, 161 tests / 964 assertions. +- 2026-05-03: `npm run build` passed for the updated storefront sections, error templates, and admin theme editor UI. +- 2026-05-03: Playwright smoke verified `http://shop.test/` homepage section rendering, `/products/missing-product` 404 search/home actions, suspended-store 503 identity/copy after temporary store status restoration, and `/admin/themes/1/editor` typed section editor with preview and rich-text settings. Current admin editor console check reported no warnings or errors; Boost browser logs only contained old 13:59 entries. diff --git a/tests/Feature/Admin/AdminPanelTest.php b/tests/Feature/Admin/AdminPanelTest.php index d7e47605..f2b1c0eb 100644 --- a/tests/Feature/Admin/AdminPanelTest.php +++ b/tests/Feature/Admin/AdminPanelTest.php @@ -6,6 +6,7 @@ use App\Livewire\Admin\Orders\Show as OrderShow; use App\Livewire\Admin\Products\Form as ProductForm; use App\Livewire\Admin\Settings\Index as SettingsIndex; +use App\Livewire\Admin\Themes\Editor as ThemeEditor; use App\Models\Order; use App\Models\Product; use App\Models\Store; @@ -72,7 +73,7 @@ function actAsAdmin($test): void '/admin/settings/shipping' => 'Shipping', '/admin/settings/taxes' => 'Manual rate', '/admin/themes' => 'Default', - "/admin/themes/{$theme->id}/editor" => 'Theme settings JSON', + "/admin/themes/{$theme->id}/editor" => 'Storefront theme editor', '/admin/pages' => 'Pages', '/admin/pages/create' => 'Create page', '/admin/navigation' => 'Main menu', @@ -152,6 +153,36 @@ function actAsAdmin($test): void ->and($whiteMedium->inventoryItem->quantity_on_hand)->toBe(7); }); +test('admin can configure storefront home section order and visibility', function (): void { + actAsAdmin($this); + + $theme = Theme::query()->where('status', 'published')->firstOrFail(); + + Livewire::test(ThemeEditor::class, ['theme' => $theme]) + ->call('moveSectionUp', 'featured_products') + ->set('homeSections.2.enabled', false) + ->call('selectSection', 'hero') + ->set('settings.home.hero_heading', 'Editorial Launch') + ->call('selectSection', 'rich_text') + ->set('settings.home.rich_text_heading', 'Material notes') + ->set('settings.home.rich_text_html', '

Breathable cotton

') + ->call('save') + ->assertHasNoErrors(); + + $settings = $theme->fresh()->settings->settings_json; + + expect(data_get($settings, 'home.sections.1.key'))->toBe('featured_products') + ->and(data_get($settings, 'home.sections.2.key'))->toBe('featured_collections') + ->and(data_get($settings, 'home.sections.2.enabled'))->toBeFalse() + ->and(data_get($settings, 'home.hero_heading'))->toBe('Editorial Launch') + ->and(data_get($settings, 'home.rich_text_html'))->toBe('

Breathable cotton

'); + + $this->get('http://shop.test/') + ->assertOk() + ->assertSeeInOrder(['Editorial Launch', 'Featured Products', 'Material notes']) + ->assertDontSee('Featured Collections'); +}); + test('admin can confirm a pending bank transfer order', function (): void { actAsAdmin($this); diff --git a/tests/Feature/Storefront/StorefrontRenderTest.php b/tests/Feature/Storefront/StorefrontRenderTest.php index c13d7249..02fb9494 100644 --- a/tests/Feature/Storefront/StorefrontRenderTest.php +++ b/tests/Feature/Storefront/StorefrontRenderTest.php @@ -39,3 +39,11 @@ ->assertOk() ->assertSee('Linen Shirt'); }); + +test('storefront not found page includes search and home actions', function (): void { + $this->get('http://shop.test/products/missing-product') + ->assertNotFound() + ->assertSee('Page not found') + ->assertSee('Search products') + ->assertSee('Return home'); +}); diff --git a/tests/Feature/Storefront/ThemeNavigationTest.php b/tests/Feature/Storefront/ThemeNavigationTest.php index 5051eb7a..1bc473a3 100644 --- a/tests/Feature/Storefront/ThemeNavigationTest.php +++ b/tests/Feature/Storefront/ThemeNavigationTest.php @@ -19,7 +19,35 @@ $settings = app(ThemeSettingsService::class)->forStore($store); expect(data_get($settings, 'announcement.enabled'))->toBeTrue() - ->and(data_get($settings, 'home.hero_heading'))->toBe('Acme Fashion'); + ->and(data_get($settings, 'home.hero_heading'))->toBe('Acme Fashion') + ->and(collect(app(ThemeSettingsService::class)->homeSections($settings))->pluck('key')->all()) + ->toBe(['hero', 'featured_collections', 'featured_products', 'newsletter', 'rich_text']); +}); + +test('theme settings service normalizes legacy home section settings', function (): void { + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + + $settings = app(ThemeSettingsService::class)->mergeWithDefaults($store, [ + 'home' => [ + 'sections' => [ + ['key' => 'featured_products', 'enabled' => false], + ['key' => 'hero', 'enabled' => true], + ['key' => 'unknown', 'enabled' => true], + ], + 'featured_products_count' => 99, + 'rich_text_html' => '

Safe

', + ], + ]); + + expect(app(ThemeSettingsService::class)->homeSections($settings))->toBe([ + ['key' => 'featured_products', 'enabled' => false], + ['key' => 'hero', 'enabled' => true], + ['key' => 'featured_collections', 'enabled' => true], + ['key' => 'newsletter', 'enabled' => true], + ['key' => 'rich_text', 'enabled' => true], + ]) + ->and(data_get($settings, 'home.featured_products_count'))->toBe(8) + ->and(data_get($settings, 'home.rich_text_html'))->toBe('

Safe

'); }); test('navigation service resolves seeded resource urls', function () { diff --git a/tests/Feature/Tenancy/TenantResolutionTest.php b/tests/Feature/Tenancy/TenantResolutionTest.php index 832894d5..56e6c2f0 100644 --- a/tests/Feature/Tenancy/TenantResolutionTest.php +++ b/tests/Feature/Tenancy/TenantResolutionTest.php @@ -44,7 +44,9 @@ StoreDomain::factory()->for($store)->create(['hostname' => 'suspended.test']); $this->get('http://suspended.test/tenant-probe') - ->assertStatus(503); + ->assertStatus(503) + ->assertSee("We'll be back soon", false) + ->assertSee($store->name); }); test('admin routes resolve the current store from the authenticated session', function () { From 906cb29598045720f6ac6ffb4a6e97b9ab5ef79e Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 3 May 2026 20:38:48 +0200 Subject: [PATCH 34/35] Add line-level order fulfillment refunds --- app/Livewire/Admin/Orders/Show.php | 151 +++++++++++-- app/Services/RefundService.php | 201 ++++++++++++++---- .../livewire/admin/orders/show.blade.php | 52 ++++- specs/progress.md | 9 +- tests/Feature/Admin/AdminPanelTest.php | 43 ++++ tests/Feature/Orders/OrderServiceTest.php | 17 ++ 6 files changed, 407 insertions(+), 66 deletions(-) diff --git a/app/Livewire/Admin/Orders/Show.php b/app/Livewire/Admin/Orders/Show.php index 617a2ce0..92e25c60 100644 --- a/app/Livewire/Admin/Orders/Show.php +++ b/app/Livewire/Admin/Orders/Show.php @@ -8,6 +8,7 @@ use App\Livewire\Admin\Concerns\UsesAdminStore; use App\Models\Fulfillment; use App\Models\Order; +use App\Models\OrderLine; use App\Services\FulfillmentService; use App\Services\OrderService; use App\Services\RefundService; @@ -28,6 +29,18 @@ class Show extends Component public bool $restockRefund = false; + public bool $refundUseCustomAmount = false; + + /** + * @var array + */ + public array $fulfillmentLines = []; + + /** + * @var array + */ + public array $refundLines = []; + public string $trackingCompany = ''; public string $trackingNumber = ''; @@ -39,6 +52,7 @@ public function mount(Order $order): void Gate::authorize('view', $order); $this->order = $order; + $this->resetLineInputs(); } public function confirmBankTransfer(OrderService $orders): void @@ -55,22 +69,26 @@ public function confirmBankTransfer(OrderService $orders): void public function fulfillAll(FulfillmentService $fulfillments): void { - Gate::authorize('fulfill', $this->order); + $this->order->load('lines.fulfillmentLines'); + $this->fulfillmentLines = $this->remainingFulfillmentQuantities(); - try { - $this->order->load('lines.fulfillmentLines'); - $lines = []; + $this->createFulfillment($fulfillments); + } - foreach ($this->order->lines as $line) { - $fulfilled = (int) $line->fulfillmentLines->sum('quantity'); - $remaining = $line->quantity - $fulfilled; + public function createFulfillment(FulfillmentService $fulfillments): void + { + Gate::authorize('fulfill', $this->order); - if ($remaining > 0) { - $lines[$line->id] = $remaining; - } - } + $this->validate([ + 'fulfillmentLines' => ['array'], + 'fulfillmentLines.*' => ['integer', 'min:0'], + 'trackingCompany' => ['nullable', 'string', 'max:255'], + 'trackingNumber' => ['nullable', 'string', 'max:255'], + 'trackingUrl' => ['nullable', 'url', 'max:255'], + ]); - $fulfillments->create($this->order, $lines, [ + try { + $fulfillments->create($this->order, $this->positiveQuantities($this->fulfillmentLines), [ 'tracking_company' => $this->trackingCompany ?: null, 'tracking_number' => $this->trackingNumber ?: null, 'tracking_url' => $this->trackingUrl ?: null, @@ -78,6 +96,7 @@ public function fulfillAll(FulfillmentService $fulfillments): void $this->order = $this->order->refresh(); $this->reset('trackingCompany', 'trackingNumber', 'trackingUrl'); + $this->resetLineInputs(); $this->notify('Fulfillment created.'); } catch (Throwable $exception) { $this->addError('order', $exception->getMessage()); @@ -116,10 +135,23 @@ public function refund(RefundService $refunds): void { Gate::authorize('refund', $this->order); + $lineQuantities = $this->positiveQuantities($this->refundLines); + + if ($lineQuantities === [] && ! $this->refundUseCustomAmount) { + $this->addError('order', 'Select at least one line quantity to refund or enable a custom amount.'); + + return; + } + $this->validate([ - 'refundAmount' => ['required', 'integer', 'min:1'], + 'refundAmount' => $this->refundUseCustomAmount || $lineQuantities === [] + ? ['required', 'integer', 'min:1'] + : ['integer', 'min:0'], 'refundReason' => ['nullable', 'string', 'max:500'], 'restockRefund' => ['bool'], + 'refundUseCustomAmount' => ['bool'], + 'refundLines' => ['array'], + 'refundLines.*' => ['integer', 'min:0'], ]); try { @@ -128,8 +160,22 @@ public function refund(RefundService $refunds): void ->latest('id') ->firstOrFail(); - $refunds->create($this->order, $payment, $this->refundAmount, $this->refundReason ?: null, $this->restockRefund); - $this->reset('refundAmount', 'refundReason', 'restockRefund'); + if ($lineQuantities === []) { + $refunds->create($this->order, $payment, $this->refundAmount, $this->refundReason ?: null, $this->restockRefund); + } else { + $refunds->createForLines( + $this->order, + $payment, + $lineQuantities, + $this->refundReason ?: null, + $this->restockRefund, + $this->refundUseCustomAmount ? $this->refundAmount : null, + ); + } + + $this->order = $this->order->refresh(); + $this->reset('refundAmount', 'refundReason', 'restockRefund', 'refundUseCustomAmount'); + $this->resetLineInputs(); $this->notify('Refund processed.'); } catch (Throwable $exception) { $this->addError('order', $exception->getMessage()); @@ -151,6 +197,8 @@ public function render(): View $isFullyFulfilled => 'All line items have been fulfilled.', default => null, }, + 'lineStates' => $this->lineStates(), + 'computedRefundAmount' => $this->computedRefundAmount(), ])->layout('livewire.admin.layout.app', [ 'title' => $this->order->order_number, ]); @@ -163,4 +211,77 @@ private function fulfillment(int $fulfillmentId): Fulfillment ->whereKey($fulfillmentId) ->firstOrFail(); } + + /** + * @return array + */ + private function remainingFulfillmentQuantities(): array + { + return $this->order->lines + ->mapWithKeys(function (OrderLine $line): array { + $fulfilled = (int) $line->fulfillmentLines->sum('quantity'); + + return [$line->id => max(0, $line->quantity - $fulfilled)]; + }) + ->all(); + } + + private function resetLineInputs(): void + { + $this->order->load('lines.fulfillmentLines'); + $this->fulfillmentLines = $this->remainingFulfillmentQuantities(); + $this->refundLines = $this->order->lines + ->mapWithKeys(fn (OrderLine $line): array => [$line->id => 0]) + ->all(); + } + + /** + * @param array $quantities + * @return array + */ + private function positiveQuantities(array $quantities): array + { + return collect($quantities) + ->map(fn (mixed $quantity): int => (int) $quantity) + ->filter(fn (int $quantity): bool => $quantity > 0) + ->all(); + } + + /** + * @return array + */ + private function lineStates(): array + { + return $this->order->lines + ->mapWithKeys(function (OrderLine $line): array { + $fulfilled = (int) $line->fulfillmentLines->sum('quantity'); + + return [ + $line->id => [ + 'fulfilled' => $fulfilled, + 'unfulfilled' => max(0, $line->quantity - $fulfilled), + 'refund_amount' => $this->lineRefundAmount($line, (int) ($this->refundLines[$line->id] ?? 0)), + ], + ]; + }) + ->all(); + } + + private function computedRefundAmount(): int + { + return $this->order->lines->sum( + fn (OrderLine $line): int => $this->lineRefundAmount($line, (int) ($this->refundLines[$line->id] ?? 0)), + ); + } + + private function lineRefundAmount(OrderLine $line, int $quantity): int + { + if ($quantity < 1) { + return 0; + } + + return $quantity >= $line->quantity + ? $line->total_amount + : intdiv($line->total_amount * $quantity, $line->quantity); + } } diff --git a/app/Services/RefundService.php b/app/Services/RefundService.php index 75a510a7..9a640c95 100644 --- a/app/Services/RefundService.php +++ b/app/Services/RefundService.php @@ -10,6 +10,7 @@ use App\Events\OrderRefunded; use App\Exceptions\RefundException; use App\Models\Order; +use App\Models\OrderLine; use App\Models\Payment; use App\Models\Refund; use Illuminate\Support\Facades\DB; @@ -24,64 +25,161 @@ public function __construct( public function create(Order $order, Payment $payment, int $amount, ?string $reason = null, bool $restock = false): Refund { return DB::transaction(function () use ($order, $payment, $amount, $reason, $restock): Refund { - $order = Order::withoutGlobalScopes() - ->with('lines.variant.inventoryItem', 'refunds') - ->whereKey($order->id) - ->lockForUpdate() - ->firstOrFail(); - - $payment = Payment::query() - ->where('order_id', $order->id) - ->whereKey($payment->id) - ->lockForUpdate() - ->firstOrFail(); - - if ($payment->status !== PaymentStatus::Captured) { - throw new RefundException('payment_not_captured', 'Only captured payments can be refunded.'); + $order = $this->lockOrder($order); + $payment = $this->lockPayment($order, $payment); + $refund = $this->processLockedRefund($order, $payment, $amount, $reason); + + if ($restock) { + $this->restockOrder($order); } - $refundable = $order->total_amount - (int) $order->refunds->where('status', RefundStatus::Processed)->sum('amount'); + return $refund; + }); + } + + /** + * @param array $lines + */ + public function createForLines(Order $order, Payment $payment, array $lines, ?string $reason = null, bool $restock = false, ?int $amount = null): Refund + { + return DB::transaction(function () use ($amount, $lines, $order, $payment, $reason, $restock): Refund { + $order = $this->lockOrder($order); + $payment = $this->lockPayment($order, $payment); + $lineQuantities = $this->normalizeRefundLines($order, $lines); - if ($amount < 1 || $amount > $refundable) { - throw new RefundException('invalid_refund_amount', 'The refund amount exceeds the remaining refundable amount.'); + if ($lineQuantities === []) { + throw new RefundException('invalid_refund_lines', 'Select at least one line quantity to refund.'); } - $result = $this->payments->refund($payment, $amount); - $refund = $order->refunds()->create([ - 'payment_id' => $payment->id, - 'amount' => $amount, - 'reason' => $reason, - 'status' => $result->status, - 'provider_refund_id' => $result->reference, - ]); - - if (! $result->success) { - throw new RefundException( - $result->errorCode ?? 'refund_failed', - $result->message ?? 'The refund could not be processed.', - ); + $refund = $this->processLockedRefund( + $order, + $payment, + $amount ?? $this->calculateLineRefundAmount($order, $lineQuantities), + $reason, + ); + + if ($restock) { + $this->restockLines($order, $lineQuantities); } - $totalRefunded = (int) $order->refunds()->where('status', RefundStatus::Processed)->sum('amount'); - $fullyRefunded = $totalRefunded >= $order->total_amount; + return $refund; + }); + } + + private function lockOrder(Order $order): Order + { + return Order::withoutGlobalScopes() + ->with('lines.variant.inventoryItem', 'refunds') + ->whereKey($order->id) + ->lockForUpdate() + ->firstOrFail(); + } + + private function lockPayment(Order $order, Payment $payment): Payment + { + return Payment::query() + ->where('order_id', $order->id) + ->whereKey($payment->id) + ->lockForUpdate() + ->firstOrFail(); + } + + private function processLockedRefund(Order $order, Payment $payment, int $amount, ?string $reason): Refund + { + if ($payment->status !== PaymentStatus::Captured) { + throw new RefundException('payment_not_captured', 'Only captured payments can be refunded.'); + } + + $refundable = $order->total_amount - (int) $order->refunds->where('status', RefundStatus::Processed)->sum('amount'); + + if ($amount < 1 || $amount > $refundable) { + throw new RefundException('invalid_refund_amount', 'The refund amount exceeds the remaining refundable amount.'); + } + + $result = $this->payments->refund($payment, $amount); + $refund = $order->refunds()->create([ + 'payment_id' => $payment->id, + 'amount' => $amount, + 'reason' => $reason, + 'status' => $result->status, + 'provider_refund_id' => $result->reference, + ]); + + if (! $result->success) { + throw new RefundException( + $result->errorCode ?? 'refund_failed', + $result->message ?? 'The refund could not be processed.', + ); + } + + $totalRefunded = (int) $order->refunds()->where('status', RefundStatus::Processed)->sum('amount'); + $fullyRefunded = $totalRefunded >= $order->total_amount; + + $order->forceFill([ + 'financial_status' => $fullyRefunded ? FinancialStatus::Refunded : FinancialStatus::PartiallyRefunded, + 'status' => $fullyRefunded ? OrderStatus::Refunded : $order->status, + ])->save(); + + if ($fullyRefunded) { + $payment->forceFill(['status' => PaymentStatus::Refunded])->save(); + } - $order->forceFill([ - 'financial_status' => $fullyRefunded ? FinancialStatus::Refunded : FinancialStatus::PartiallyRefunded, - 'status' => $fullyRefunded ? OrderStatus::Refunded : $order->status, - ])->save(); + OrderRefunded::dispatch($order, $refund); - if ($fullyRefunded) { - $payment->forceFill(['status' => PaymentStatus::Refunded])->save(); + return $refund->refresh(); + } + + /** + * @param array $lines + * @return array + */ + private function normalizeRefundLines(Order $order, array $lines): array + { + $normalized = []; + + foreach ($lines as $orderLineId => $quantity) { + $quantity = (int) $quantity; + + if ($quantity < 1) { + continue; } - if ($restock) { - $this->restockOrder($order); + $line = $order->lines->firstWhere('id', (int) $orderLineId); + + if (! $line instanceof OrderLine || $quantity > $line->quantity) { + throw new RefundException('invalid_refund_lines', 'The refund line quantity is invalid.'); } - OrderRefunded::dispatch($order, $refund); + $normalized[$line->id] = $quantity; + } - return $refund->refresh(); - }); + return $normalized; + } + + /** + * @param array $lines + */ + private function calculateLineRefundAmount(Order $order, array $lines): int + { + $amount = 0; + + foreach ($lines as $orderLineId => $quantity) { + $line = $order->lines->firstWhere('id', $orderLineId); + + if (! $line instanceof OrderLine) { + continue; + } + + $amount += $quantity >= $line->quantity + ? $line->total_amount + : intdiv($line->total_amount * $quantity, $line->quantity); + } + + if ($amount < 1) { + throw new RefundException('invalid_refund_amount', 'The selected lines do not have a refundable amount.'); + } + + return $amount; } private function restockOrder(Order $order): void @@ -94,4 +192,19 @@ private function restockOrder(Order $order): void } } } + + /** + * @param array $lines + */ + private function restockLines(Order $order, array $lines): void + { + foreach ($lines as $orderLineId => $quantity) { + $line = $order->lines->firstWhere('id', $orderLineId); + $item = $line?->variant?->inventoryItem; + + if ($item !== null) { + $this->inventory->restock($item, $quantity); + } + } + } } diff --git a/resources/views/livewire/admin/orders/show.blade.php b/resources/views/livewire/admin/orders/show.blade.php index 7916e028..9d646c2b 100644 --- a/resources/views/livewire/admin/orders/show.blade.php +++ b/resources/views/livewire/admin/orders/show.blade.php @@ -28,7 +28,11 @@
{{ $line->title_snapshot }}
-
{{ $line->sku_snapshot ?: 'No SKU' }} · Qty {{ $line->quantity }}
+
+ {{ $line->sku_snapshot ?: 'No SKU' }} · Qty {{ $line->quantity }} + · Fulfilled {{ $lineStates[$line->id]['fulfilled'] ?? 0 }} + · Unfulfilled {{ $lineStates[$line->id]['unfulfilled'] ?? 0 }} +
{{ \Illuminate\Support\Number::currency($line->total_amount / 100, $order->currency) }}
@@ -53,7 +57,22 @@
- Create fulfillment +
+ @foreach ($order->lines as $line) + @php($unfulfilled = $lineStates[$line->id]['unfulfilled'] ?? 0) +
+
+
{{ $line->title_snapshot }}
+
Unfulfilled {{ $unfulfilled }} of {{ $line->quantity }}
+
+ +
+ @endforeach +
+
+ Create fulfillment + Fulfill remaining +
@endif
@@ -93,10 +112,33 @@
Refund -
- +
+ @foreach ($order->lines as $line) + @php($lineRefundAmount = $lineStates[$line->id]['refund_amount'] ?? 0) +
+
+
{{ $line->title_snapshot }}
+
Ordered {{ $line->quantity }} · {{ \Illuminate\Support\Number::currency($line->total_amount / 100, $order->currency) }}
+
+ +
{{ \Illuminate\Support\Number::currency($lineRefundAmount / 100, $order->currency) }}
+
+ @endforeach +
+
- + +
+
+ + @if ($refundUseCustomAmount) + + @else +
+
Refund amount
+
{{ \Illuminate\Support\Number::currency($computedRefundAmount / 100, $order->currency) }}
+
+ @endif
Process refund
diff --git a/specs/progress.md b/specs/progress.md index f3c064a4..b528cf5d 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -22,7 +22,7 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Apps, API tokens, and webhooks are implemented and verified: apps/installations/OAuth metadata, developer API token generation/revocation, spec-backed token ability selection, store-scoped token middleware, webhook subscriptions, signed delivery jobs, retry/failure tracking, and app admin screens. - Demo seed data now covers the browser-plan fixture contract while preserving `shop.test`: two stores/domains, admin aliases, 20 fashion products, 5 electronics products, sold-out/backorder/draft product edges, five discount codes, 10+2 customers, and 15+3 orders. - Storefront product stock states and the slide-out cart drawer are implemented and verified: sold-out `deny` variants disable add-to-cart, backorder `continue` variants add successfully, the cart drawer opens from add-to-cart events, quantity mutations reuse the versioned cart service, discount codes apply/remove inline, and the mobile storefront header fits small viewports. -- Admin order fulfillment workflow now supports fulfillment creation, shipped/delivered shipment transitions, tracking links, shipment events, and verified desktop/mobile order-detail behavior. +- Admin order fulfillment/refund workflow now supports selected line quantities, line-derived refund amounts, selected restocking, fulfillment creation, shipped/delivered shipment transitions, tracking links, shipment events, and verified desktop/mobile order-detail behavior. - Storefront checkout now uses a verified address, shipping, and payment step flow with locked future steps, editable completed steps, compact step summaries, and responsive order summary behavior. - Admin Product REST API endpoints are implemented and verified: token-scoped list/show/create/update/archive routes, product JSON resources, store-scoped validation, variant inventory mutation, collection assignment, and ability/store isolation tests. - Admin Order REST API endpoints are implemented and verified: token-scoped list/show routes, shipped fulfillment creation, captured-payment refunds, nested order JSON resources, validation, and ability/store isolation tests. @@ -85,7 +85,6 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit ## Open Gaps -- Phase 5 backend bank-transfer confirmation, refunds, fulfillment creation, and shipped/delivered shipment transitions now have admin order-detail actions. More granular partial-fulfillment and line-level refund UI can still be expanded during polish. - Discounts, shipping, and tax are implemented for the specified local/manual flows; provider/carrier integrations remain stubs by design. - Admin analytics, apps, API tokens, and webhook backend flows are implemented for the current data model. The developer screen exposes the current spec-backed token abilities, including theme/content/settings scopes and owner-only `manage-platform`. A future dependency decision could replace the first-party token table with Sanctum if package changes are approved. @@ -256,3 +255,9 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - 2026-05-03: `php artisan test --compact` passed, 161 tests / 964 assertions. - 2026-05-03: `npm run build` passed for the updated storefront sections, error templates, and admin theme editor UI. - 2026-05-03: Playwright smoke verified `http://shop.test/` homepage section rendering, `/products/missing-product` 404 search/home actions, suspended-store 503 identity/copy after temporary store status restoration, and `/admin/themes/1/editor` typed section editor with preview and rich-text settings. Current admin editor console check reported no warnings or errors; Boost browser logs only contained old 13:59 entries. +- 2026-05-03: `php artisan test --compact tests/Feature/Admin/AdminPanelTest.php tests/Feature/Orders/OrderServiceTest.php tests/Feature/Api/AdminOrderApiTest.php` passed, 24 tests / 201 assertions. +- 2026-05-03: `vendor/bin/pint --dirty --format agent` passed after admin order line fulfillment/refund changes. +- 2026-05-03: `php artisan test --compact` passed, 164 tests / 977 assertions. +- 2026-05-03: `npm run build` passed for the updated admin order-detail UI. +- 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed before and after the admin order-detail browser smoke, leaving the local database in the seeded demo state. +- 2026-05-03: Playwright smoke verified `/admin/orders/1` selected line fulfillment quantity, tracking fields, computed selected-line refund amount, selected restock refund processing, and no current Playwright console warnings or errors. Boost browser logs only contained old 13:59 entries. diff --git a/tests/Feature/Admin/AdminPanelTest.php b/tests/Feature/Admin/AdminPanelTest.php index f2b1c0eb..49d5c551 100644 --- a/tests/Feature/Admin/AdminPanelTest.php +++ b/tests/Feature/Admin/AdminPanelTest.php @@ -2,6 +2,7 @@ use App\Enums\FinancialStatus; use App\Enums\FulfillmentShipmentStatus; +use App\Enums\FulfillmentStatus; use App\Livewire\Admin\Auth\Login as AdminLogin; use App\Livewire\Admin\Orders\Show as OrderShow; use App\Livewire\Admin\Products\Form as ProductForm; @@ -230,6 +231,48 @@ function actAsAdmin($test): void ->and($fulfillment->delivered_at)->not->toBeNull(); }); +test('admin can create a selected quantity fulfillment', function (): void { + actAsAdmin($this); + + $order = Order::query()->where('order_number', '#1001')->firstOrFail(); + $line = $order->lines()->firstOrFail(); + $line->forceFill(['quantity' => 2])->save(); + + Livewire::test(OrderShow::class, ['order' => $order]) + ->set("fulfillmentLines.{$line->id}", 1) + ->call('createFulfillment') + ->assertHasNoErrors(); + + $fulfillment = $order->fulfillments()->with('lines')->firstOrFail(); + + expect($fulfillment->lines)->toHaveCount(1) + ->and($fulfillment->lines->first()->quantity)->toBe(1) + ->and($order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Partial); +}); + +test('admin can refund selected line quantities', function (): void { + actAsAdmin($this); + + $order = Order::query()->where('order_number', '#1001')->firstOrFail(); + $line = $order->lines()->with('variant.inventoryItem')->firstOrFail(); + $inventory = $line->variant->inventoryItem; + $stockBeforeRefund = $inventory->quantity_on_hand; + + Livewire::test(OrderShow::class, ['order' => $order]) + ->set("refundLines.{$line->id}", 1) + ->set('refundReason', 'Customer returned one item') + ->set('restockRefund', true) + ->call('refund') + ->assertHasNoErrors(); + + $refund = $order->refunds()->firstOrFail(); + + expect($refund->amount)->toBe($line->total_amount) + ->and($refund->reason)->toBe('Customer returned one item') + ->and($order->refresh()->financial_status)->toBe(FinancialStatus::PartiallyRefunded) + ->and($inventory->refresh()->quantity_on_hand)->toBe($stockBeforeRefund + 1); +}); + test('admin can update store settings', function (): void { actAsAdmin($this); diff --git a/tests/Feature/Orders/OrderServiceTest.php b/tests/Feature/Orders/OrderServiceTest.php index fb1c173a..392282c8 100644 --- a/tests/Feature/Orders/OrderServiceTest.php +++ b/tests/Feature/Orders/OrderServiceTest.php @@ -208,6 +208,23 @@ function phaseFiveCheckoutFixture(PaymentMethod $method = PaymentMethod::CreditC ->and($variant->inventoryItem->refresh()->quantity_on_hand)->toBe(10); }); +test('refund service refunds selected line quantities and restocks selected quantity', function () { + [, $checkout, $variant] = phaseFiveCheckoutFixture(); + $order = app(OrderService::class)->createFromCheckout($checkout, [ + 'card_number' => '4242424242424242', + ]); + $line = $order->lines()->firstOrFail(); + + $refund = app(RefundService::class)->createForLines($order, $order->payments()->first(), [ + $line->id => 1, + ], 'Line item return', true); + + expect($refund->amount)->toBe(1000) + ->and($refund->reason)->toBe('Line item return') + ->and($order->refresh()->financial_status)->toBe(FinancialStatus::PartiallyRefunded) + ->and($variant->inventoryItem->refresh()->quantity_on_hand)->toBe(9); +}); + test('bank transfer cancellation job voids stale pending orders and releases reservations', function () { [, $checkout, $variant] = phaseFiveCheckoutFixture(PaymentMethod::BankTransfer); $order = app(OrderService::class)->createFromCheckout($checkout); From af7024f5c6544bbec790620139489e83177cceb4 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 3 May 2026 21:02:40 +0200 Subject: [PATCH 35/35] Complete final shop audit --- app/Livewire/Storefront/Cart/Show.php | 9 ++---- app/Livewire/Storefront/CartDrawer.php | 9 ++---- .../Storefront/Checkout/Confirmation.php | 11 ++++++++ app/Livewire/Storefront/Checkout/Show.php | 4 +++ app/Services/CartService.php | 28 ++++++++++++++++++- specs/progress.md | 18 ++++++++---- .../Storefront/CheckoutStepFlowTest.php | 11 +++++++- .../Storefront/StorefrontCartLivewireTest.php | 27 ++++++++++++++++++ 8 files changed, 96 insertions(+), 21 deletions(-) diff --git a/app/Livewire/Storefront/Cart/Show.php b/app/Livewire/Storefront/Cart/Show.php index 240921a2..9473ccc2 100644 --- a/app/Livewire/Storefront/Cart/Show.php +++ b/app/Livewire/Storefront/Cart/Show.php @@ -126,16 +126,11 @@ public function render(): View private function cart(): ?Cart { - $cartId = session(CartService::SESSION_KEY); - - if (! $cartId) { + if (! app()->bound('current_store')) { return null; } - $cart = Cart::withoutGlobalScopes() - ->where('store_id', app('current_store')->id) - ->whereKey($cartId) - ->first(); + $cart = app(CartService::class)->findActiveForSession(app('current_store')); return $cart instanceof Cart ? app(CartService::class)->loadForDisplay($cart) : null; } diff --git a/app/Livewire/Storefront/CartDrawer.php b/app/Livewire/Storefront/CartDrawer.php index 72713527..d5af1b85 100644 --- a/app/Livewire/Storefront/CartDrawer.php +++ b/app/Livewire/Storefront/CartDrawer.php @@ -135,16 +135,11 @@ public function render(): View private function cart(): ?Cart { - $cartId = session(CartService::SESSION_KEY); - - if (! $cartId || ! app()->bound('current_store')) { + if (! app()->bound('current_store')) { return null; } - $cart = Cart::withoutGlobalScopes() - ->where('store_id', app('current_store')->id) - ->whereKey($cartId) - ->first(); + $cart = app(CartService::class)->findActiveForSession(app('current_store')); return $cart instanceof Cart ? app(CartService::class)->loadForDisplay($cart) : null; } diff --git a/app/Livewire/Storefront/Checkout/Confirmation.php b/app/Livewire/Storefront/Checkout/Confirmation.php index 02c429f6..32818a0d 100644 --- a/app/Livewire/Storefront/Checkout/Confirmation.php +++ b/app/Livewire/Storefront/Checkout/Confirmation.php @@ -3,6 +3,7 @@ namespace App\Livewire\Storefront\Checkout; use App\Models\Checkout; +use App\Services\CartService; use Illuminate\View\View; use Livewire\Component; @@ -13,6 +14,16 @@ class Confirmation extends Component public function mount(int $checkoutId): void { $this->checkoutId = $checkoutId; + + $checkout = Checkout::withoutGlobalScopes() + ->with('cart', 'order') + ->where('store_id', app('current_store')->id) + ->whereKey($this->checkoutId) + ->firstOrFail(); + + if ($checkout->order !== null) { + app(CartService::class)->forgetSessionCart($checkout->cart); + } } public function render(): View diff --git a/app/Livewire/Storefront/Checkout/Show.php b/app/Livewire/Storefront/Checkout/Show.php index 233e45c0..16ac7551 100644 --- a/app/Livewire/Storefront/Checkout/Show.php +++ b/app/Livewire/Storefront/Checkout/Show.php @@ -9,6 +9,7 @@ use App\Exceptions\PaymentFailedException; use App\Exceptions\UnavailableShippingRateException; use App\Models\Checkout; +use App\Services\CartService; use App\Services\CheckoutService; use App\Services\OrderService; use App\Services\ShippingCalculator; @@ -168,6 +169,8 @@ public function pay(): mixed $checkout = $this->checkout(); if ($checkout->order !== null) { + app(CartService::class)->forgetSessionCart($checkout->cart); + return $this->redirect(route('storefront.checkout.confirmation', $checkout), navigate: true); } @@ -185,6 +188,7 @@ public function pay(): mixed 'card_cvc' => $this->cardCvc, 'card_holder' => $this->cardHolder, ]); + app(CartService::class)->forgetSessionCart($checkout->cart); return $this->redirect(route('storefront.checkout.confirmation', $checkout), navigate: true); } catch (PaymentFailedException $exception) { diff --git a/app/Services/CartService.php b/app/Services/CartService.php index b0c5c804..9f347418 100644 --- a/app/Services/CartService.php +++ b/app/Services/CartService.php @@ -247,6 +247,24 @@ public function loadForDisplay(Cart $cart): Cart ); } + public function findActiveForSession(Store $store): ?Cart + { + return $this->cartFromSession($store); + } + + public function forgetSessionCart(?Cart $cart = null): void + { + $cartId = session()->get(self::SESSION_KEY); + + if (! $cartId) { + return; + } + + if ($cart === null || (int) $cart->getKey() === (int) $cartId) { + session()->forget(self::SESSION_KEY); + } + } + private function cartFromSession(Store $store): ?Cart { $cartId = session()->get(self::SESSION_KEY); @@ -255,11 +273,19 @@ private function cartFromSession(Store $store): ?Cart return null; } - return Cart::withoutGlobalScopes() + $cart = Cart::withoutGlobalScopes() ->where('store_id', $store->id) ->where('status', CartStatus::Active) ->whereKey($cartId) ->first(); + + if (! $cart instanceof Cart) { + session()->forget(self::SESSION_KEY); + + return null; + } + + return $cart; } private function lockCart(Cart $cart): Cart diff --git a/specs/progress.md b/specs/progress.md index b528cf5d..a5582ccc 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -24,6 +24,7 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Storefront product stock states and the slide-out cart drawer are implemented and verified: sold-out `deny` variants disable add-to-cart, backorder `continue` variants add successfully, the cart drawer opens from add-to-cart events, quantity mutations reuse the versioned cart service, discount codes apply/remove inline, and the mobile storefront header fits small viewports. - Admin order fulfillment/refund workflow now supports selected line quantities, line-derived refund amounts, selected restocking, fulfillment creation, shipped/delivered shipment transitions, tracking links, shipment events, and verified desktop/mobile order-detail behavior. - Storefront checkout now uses a verified address, shipping, and payment step flow with locked future steps, editable completed steps, compact step summaries, and responsive order summary behavior. +- Completed checkout now clears/invalidates the active session cart, and active cart surfaces ignore stale converted carts so the confirmation page and cart page do not show already-ordered lines. - Admin Product REST API endpoints are implemented and verified: token-scoped list/show/create/update/archive routes, product JSON resources, store-scoped validation, variant inventory mutation, collection assignment, and ability/store isolation tests. - Admin Order REST API endpoints are implemented and verified: token-scoped list/show routes, shipped fulfillment creation, captured-payment refunds, nested order JSON resources, validation, and ability/store isolation tests. - Product lifecycle order-reference guards are now verified against real `order_lines`: draft reversion/deletion is blocked for referenced products, unreferenced draft products hard-delete, and orphan variants with order references are archived. @@ -61,9 +62,9 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit 8. **Search, analytics, apps, webhooks** - Implemented and verified - SQLite FTS5 search is implemented and verified. - Analytics ingestion/aggregation, API token support, app installs, webhook dispatch/signing/delivery are implemented and verified. -9. **Polish and completion audit** - Pending - - Run full Pest suite, style formatting, fresh migration/seeding, Playwright customer/admin flows, responsive and browser log review. - - Update this file with final evidence and close all gaps. +9. **Polish and completion audit** - Implemented and verified + - Full Pest suite, style formatting, frontend build, fresh migration/seeding, Playwright storefront/customer/admin flows, responsive checks, and browser log review passed. + - Final evidence is recorded below; no self-contained implementation gaps remain. ## Decisions @@ -83,9 +84,9 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - Storefront order-status API access uses an HMAC token derived from store, order id, and order number because confirmation/status URLs are public and should not require customer login. - Customer password resets use the `customers` password broker with a store-scoped token repository so `customer_password_reset_tokens` keeps the spec-required `(store_id, email)` key. Fortify user password reset paths are mapped to `/admin/forgot-password` and `/admin/reset-password/{token}` to avoid route conflicts with storefront customer reset URLs. -## Open Gaps +## Intentional Boundaries -- Discounts, shipping, and tax are implemented for the specified local/manual flows; provider/carrier integrations remain stubs by design. +- Discounts, shipping, and tax are implemented for the specified local/manual flows; external provider/carrier integrations remain stubs by design for the self-contained benchmark. - Admin analytics, apps, API tokens, and webhook backend flows are implemented for the current data model. The developer screen exposes the current spec-backed token abilities, including theme/content/settings scopes and owner-only `manage-platform`. A future dependency decision could replace the first-party token table with Sanctum if package changes are approved. ## Verification Log @@ -261,3 +262,10 @@ Build the complete self-contained shop platform from `specs/*` and verify it wit - 2026-05-03: `npm run build` passed for the updated admin order-detail UI. - 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed before and after the admin order-detail browser smoke, leaving the local database in the seeded demo state. - 2026-05-03: Playwright smoke verified `/admin/orders/1` selected line fulfillment quantity, tracking fields, computed selected-line refund amount, selected restock refund processing, and no current Playwright console warnings or errors. Boost browser logs only contained old 13:59 entries. +- 2026-05-03: `php artisan test --compact tests/Feature/Storefront/StorefrontCartLivewireTest.php tests/Feature/Storefront/CheckoutStepFlowTest.php` passed, 8 tests / 58 assertions, covering converted session-cart invalidation and checkout session-cart clearing. +- 2026-05-03: `php artisan test --compact tests/Feature/Cart tests/Feature/Checkout tests/Feature/Storefront/StorefrontCartLivewireTest.php tests/Feature/Storefront/CheckoutStepFlowTest.php tests/Feature/Api/StorefrontCheckoutPaymentApiTest.php` passed, 17 tests / 113 assertions. +- 2026-05-03: `vendor/bin/pint --dirty --format agent` passed after converted session-cart handling changes. +- 2026-05-03: `php artisan test --compact` passed, 165 tests / 987 assertions. +- 2026-05-03: `npm run build` passed for the final completion audit. +- 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed before final browser audit and again after browser mutation checks, leaving the local database in the seeded demo state. +- 2026-05-03: Playwright MCP final audit verified product add-to-cart, cart checkout, address/shipping/payment, paid confirmation, post-checkout empty cart, customer dashboard/order history/order detail/address book, admin dashboard/products/order detail/theme editor/analytics, and mobile storefront/admin order pages with no body-level horizontal overflow. Current Playwright console check reported no warnings or errors; Boost browser logs only contained old 13:59 entries. diff --git a/tests/Feature/Storefront/CheckoutStepFlowTest.php b/tests/Feature/Storefront/CheckoutStepFlowTest.php index 392fa5bf..be79f5e5 100644 --- a/tests/Feature/Storefront/CheckoutStepFlowTest.php +++ b/tests/Feature/Storefront/CheckoutStepFlowTest.php @@ -1,5 +1,6 @@ create($this->store); app(CartService::class)->addLine($cart, $variant->id, 1); $checkout = app(CheckoutService::class)->createFromCart($cart->refresh(), 'buyer@example.com'); + session([CartService::SESSION_KEY => $cart->id]); $rate = ShippingRate::query() ->whereHas('zone', fn ($query) => $query->where('store_id', $this->store->id)) ->firstOrFail(); @@ -49,5 +51,12 @@ ->assertSet('activeStep', 'payment') ->assertSee('Card number') ->call('showStep', 'address') - ->assertSet('activeStep', 'address'); + ->assertSet('activeStep', 'address') + ->call('showStep', 'payment') + ->call('pay') + ->assertHasNoErrors() + ->assertRedirect(route('storefront.checkout.confirmation', $checkout)); + + expect($cart->refresh()->status)->toBe(CartStatus::Converted) + ->and(session(CartService::SESSION_KEY))->toBeNull(); }); diff --git a/tests/Feature/Storefront/StorefrontCartLivewireTest.php b/tests/Feature/Storefront/StorefrontCartLivewireTest.php index 9646bfb3..f204c46f 100644 --- a/tests/Feature/Storefront/StorefrontCartLivewireTest.php +++ b/tests/Feature/Storefront/StorefrontCartLivewireTest.php @@ -1,11 +1,13 @@ discount_code)->toBe('WELCOME10') ->and($checkout->totals_json['discount'])->toBe(500); }); + +test('converted session carts are ignored by active cart surfaces', function (): void { + $product = Product::query()->where('handle', 'linen-shirt')->firstOrFail(); + + Livewire::test(ProductShow::class, ['handle' => $product->handle]) + ->set('quantity', 1) + ->call('addToCart') + ->assertHasNoErrors(); + + $cart = Cart::withoutGlobalScopes()->findOrFail(session(CartService::SESSION_KEY)); + $cart->forceFill(['status' => CartStatus::Converted])->save(); + + Livewire::test(CartShow::class) + ->assertSee('Your cart is empty.'); + + expect(session(CartService::SESSION_KEY))->toBeNull(); + + session([CartService::SESSION_KEY => $cart->id]); + + Livewire::test(CartDrawer::class) + ->dispatch('open-cart') + ->assertSee('Your cart is empty'); + + expect(session(CartService::SESSION_KEY))->toBeNull(); +});